diff --git a/composer.json b/composer.json index 9ddce743..89d3718e 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/container": "^2.0", "psr/log": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~0.0.18", + "simplesamlphp/openid": "~0.1.0", "spomky-labs/base64url": "^2.0", "symfony/expression-language": "^6.3", "symfony/psr-http-message-bridge": "^7.1", diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 98ac0cb7..300ed79a 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -3,6 +3,13 @@ declare(strict_types=1); /* + * | + * \ ___ / _________ + * _ / \ _ GÉANT | * * | Co-Funded by + * | ~ | Trust & Identity | * * | the European + * \_/ Incubator |__*_*__| Union + * = + * * This file is part of the simplesamlphp-module-oidc. * * Copyright (C) 2018 by the Spanish Research and Academic Network. @@ -13,7 +20,12 @@ declare(strict_types=1); * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; +use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; +use SimpleSAML\OpenID\Codebooks\LanguageTagsEnum; /* * Note: In v5 of this module, all config keys have been moved to constants for easier handling and verification. @@ -495,4 +507,346 @@ $config = [ * 'organization_uri'. Use 'organization_uri' instead. */ ModuleConfig::OPTION_HOMEPAGE_URI => null, + + + /** + * (optional) OpenID Verifiable Credential related options. If these are not set, OpenID Verifiable + * Credential capabilities will be disabled. + */ + + // Enable or disable verifiable credentials capabilities. Default is disabled (false). + ModuleConfig::OPTION_VERIFIABLE_CREDENTIAL_ENABLED => false, + + // Allow or disallow non-registered clients to request verifiable credentials. Default is disallowed (false). + ModuleConfig::OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI => false, + + // Allowed redirect URI prefixes for non-registered clients. By default, this is set to + // 'openid-credential-offer://' to allow only redirect URIs with this prefix. + // + // Example: + // [ + // 'https://example.org/redirect', + // 'https://example.org/redirect2', + // ] + // + ModuleConfig::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI => [ + 'openid-credential-offer://', + ], + + // (optional) Credential configuration statements, as per `credential_configurations_supported` claim definition in + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#credential-issuer-parameters. + // Check the example below on how this can be used. + ModuleConfig::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ + // Sample for 'jwt_vc_json' format with notes about required and optional fields. + 'ResearchAndScholarshipCredentialJwtVcJson' => [ + // REQUIRED + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + // OPTIONAL + ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialJwtVcJson', + + // OPTIONAL + // cryptographic_binding_methods_supported + + // OPTIONAL - will be set / overridden to the protocol signing algorithm. + // credential_signing_alg_values_supported + + // OPTIONAL + // proof_types_supported + + // OPTIONAL + // cryptographic_binding_methods_supported + + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'ResearchAndScholarshipCredentialJwtVcJson', + ClaimsEnum::Locale->value => 'en-US', + + // OPTIONAL + // logo + + // OPTIONAL + ClaimsEnum::Description->value => 'Research and Scholarship Credential', + + // OPTIONAL + // background_color + + // OPTIONAL + // background_image + + // OPTIONAL + // text_color + ], + ], + + // OPTIONAL A.1.1.2. https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-signed-as-a-jwt-not-usin + ClaimsEnum::Claims->value => [ + /** + * https://refeds.org/category/research-and-scholarship + * + * The R&S attribute bundle consists (abstractly) of the following required data elements: + * + * shared user identifier + * person name + * email address + * + * and one optional data element: + * + * affiliation + * + * where shared user identifier is a persistent, non-reassigned, non-targeted identifier + * defined to be either of the following: + * + * eduPersonPrincipalName (if non-reassigned) + * eduPersonPrincipalName + eduPersonTargetedID + * + * and where person name is defined to be either (or both) of the following: + * + * displayName + * givenName + sn + * + * and where email address is defined to be the mail attribute, + * + * and where affiliation is defined to be the eduPersonScopedAffiliation attribute. + * + * All of the above attributes are defined or referenced in the [eduPerson] specification. The + * specific naming and format of these attributes is guided by the protocol in use. For SAML + * 2.0 the [SAMLAttr] profile MUST be used. This specification may be extended to reference + * other protocol-specific formulations as circumstances warrant. + */ + [ + // REQUIRED + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName'], + // OPTIONAL + ClaimsEnum::Mandatory->value => true, + // OPTIONAL + ClaimsEnum::Display->value => [ + [ + // OPTIONAL + ClaimsEnum::Name->value => 'Principal Name', + // OPTIONAL + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonTargetedID'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Targeted ID', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'displayName'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Display Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'givenName'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Given Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'sn'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Last Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'mail'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Email Address', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonScopedAffiliation'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Scoped Affiliation', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + ], + + // REQUIRED + ClaimsEnum::CredentialDefinition->value => [ + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + 'ResearchAndScholarshipCredentialJwtVcJson', + ], + ], + ], + + // Sample for 'dc+sd-jwt' format without notes about required and optional fields. + 'ResearchAndScholarshipCredentialDcSdJwt' => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::DcSdJwt->value, + // In earlier drafts it was vc+sd-jwt. + //ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::VcSdJwt->value, + ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialDcSdJwt', + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'ResearchAndScholarshipCredentialDcSdJwt', + ClaimsEnum::Locale->value => 'en-US', + ClaimsEnum::Description->value => 'Research and Scholarship Credential', + ], + ], + ClaimsEnum::Claims->value => [ + [ + ClaimsEnum::Path->value => ['eduPersonPrincipalName'], + ClaimsEnum::Mandatory->value => true, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Principal Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => ['eduPersonTargetedID'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Targeted ID', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => ['displayName'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Display Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => ['givenName'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Given Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => ['sn'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Last Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => ['mail'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Email Address', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => ['eduPersonScopedAffiliation'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Scoped Affiliation', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + ], + + // REQUIRED + ClaimsEnum::Vct->value => 'ResearchAndScholarshipCredentialDcSdJwt', + ], + ], + + // Mapping of user attributes to a credential claim path, per credential configuration ID. + // Note that the path must be present in the credential configuration supported above. + // This is an array of arrays, with the following format: + // [ + // 'credential-configuration-id' => [ + // ['user-attribute-name' => ['path-element', 'path-element', ...]], + // '...', + // ], + // ], + ModuleConfig::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP => [ + 'ResearchAndScholarshipCredentialJwtVcJson' => [ + ['eduPersonPrincipalName' => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName']], + ['eduPersonTargetedID' => [ClaimsEnum::Credential_Subject->value, 'eduPersonTargetedID']], + ['displayName' => [ClaimsEnum::Credential_Subject->value, 'displayName']], + ['givenName' => [ClaimsEnum::Credential_Subject->value, 'givenName']], + ['sn' => [ClaimsEnum::Credential_Subject->value, 'sn']], + ['mail' => [ClaimsEnum::Credential_Subject->value, 'mail']], + ['eduPersonScopedAffiliation' => [ClaimsEnum::Credential_Subject->value, 'eduPersonScopedAffiliation']], + ], + 'ResearchAndScholarshipCredentialDcSdJwt' => [ + ['eduPersonPrincipalName' => ['eduPersonPrincipalName']], + ['eduPersonTargetedID' => ['eduPersonTargetedID']], + ['displayName' => ['displayName']], + ['givenName' => ['givenName']], + ['sn' => ['sn']], + ['mail' => ['mail']], + ['eduPersonScopedAffiliation' => ['eduPersonScopedAffiliation']], + ], + ], + + // Map of authentication sources and user's email attribute names. This enables you to define a specific attribute + // name which contains the user's email address, per authentication source. This is used, for example, to send + // Transaction Code in the case of pre-authorized codes for verifiable credential issuance. If not set, the + // default user's email attribute name will be used (see the option below). + // + // Format is: 'authentication-source-id' => 'email-attribute-name'. + ModuleConfig::OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP => [ + 'example-auth-source-id' => 'mail', + ], + + // The default name of the attribute which contains the user's email address. If not set, it will + // fall back to 'mail'. + ModuleConfig::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME => 'mail', + + /** + * (optional) API-related options. + */ + + // (optional) Enable or disable API capabilities. Default is disabled (false). + ModuleConfig::OPTION_API_ENABLED => false, + + // List of API tokens which can be used to access API endpoints based on given scopes. + // The format is: ['token' => [ApiScopesEnum]] + ModuleConfig::OPTION_API_TOKENS => [ +// 'strong-random-token-string' => [ +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll, // Gives access to all VCI-related endpoints. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer, // Gives access to the credential offer endpoint. +// ], + ], + + // (optional) Issuer State TTL (validity duration), with the given example. If not set, falls back to + // Authorization Code TTL. For duration format info, check + // https://www.php.net/manual/en/dateinterval.construct.php + ModuleConfig::OPTION_ISSUER_STATE_TTL => 'PT10M', // 10 minutes ]; diff --git a/docker/apache-override.cf b/docker/apache-override.cf index 307eea9e..54e24c2d 100644 --- a/docker/apache-override.cf +++ b/docker/apache-override.cf @@ -1,6 +1,9 @@ RewriteEngine On RewriteRule ^/.well-known/openid-configuration(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/openid-configuration$1 [PT] RewriteRule ^/.well-known/openid-federation(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/openid-federation$1 [PT] +RewriteRule ^/.well-known/openid-credential-issuer(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/openid-credential-issuer$1 [PT] +RewriteRule ^/.well-known/oauth-authorization-server(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/oauth-authorization-server$1 [PT] +RewriteRule ^/.well-known/jwt-vc-issuer(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/jwt-vc-issuer$1 [PT] # Leave Authorization header with Bearer tokens available in requests. # Solution 1: diff --git a/docker/conformance.sql b/docker/conformance.sql index 4194f9e8..bf09b946 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -43,30 +43,36 @@ CREATE TABLE oidc_client ( updated_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP NULL DEFAULT NULL, expires_at TIMESTAMP NULL DEFAULT NULL, - is_federated BOOLEAN NOT NULL DEFAULT false + is_federated BOOLEAN NOT NULL DEFAULT false, + is_generic BOOLEAN NOT NULL DEFAULT false ); -- Used 'httpd' host for back-channel logout url (https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout) -- since this is the hostname of conformance server while running in container environment -INSERT INTO oidc_client VALUES('_55a99a1d298da921cb27d700d4604352e51171ebc4','_8967dd97d07cc59db7055e84ac00e79005157c1132','Conformance Client 1',replace('Client 1 for Conformance Testing https://openid.net/certification/connect_op_testing/\n','\n',char(10)),'example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone","offline_access"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false); -INSERT INTO oidc_client VALUES('_34efb61060172a11d62101bc804db789f8f9100b0e','_91a4607a1c10ba801268929b961b3f6c067ff82d21','Conformance Client 2','','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","offline_access"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false); -INSERT INTO oidc_client VALUES('_0afb7d18e54b2de8205a93e38ca119e62ee321d031','_944e73bbeec7850d32b68f1b5c780562c955967e4e','Conformance Client 3','Client for client_secret_post','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false); -INSERT INTO oidc_client VALUES('_8957eda35234902ba8343c0cdacac040310f17dfca','_322d16999f9da8b5abc9e9c0c08e853f60f4dc4804','RP-Initiated Logout Client','Client for testing RP-Initiated Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]',NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false); -INSERT INTO oidc_client VALUES('_9fe2f7589ece1b71f5ef75a91847d71bc5125ec2a6','_3c0beb20194179c01d7796c6836f62801e9ed4b368','Back-Channel Logout Client','Client for testing Back-Channel Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false); +INSERT INTO oidc_client VALUES('_55a99a1d298da921cb27d700d4604352e51171ebc4','_8967dd97d07cc59db7055e84ac00e79005157c1132','Conformance Client 1',replace('Client 1 for Conformance Testing https://openid.net/certification/connect_op_testing/\n','\n',char(10)),'example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone","offline_access"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); +INSERT INTO oidc_client VALUES('_34efb61060172a11d62101bc804db789f8f9100b0e','_91a4607a1c10ba801268929b961b3f6c067ff82d21','Conformance Client 2','','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","offline_access"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); +INSERT INTO oidc_client VALUES('_0afb7d18e54b2de8205a93e38ca119e62ee321d031','_944e73bbeec7850d32b68f1b5c780562c955967e4e','Conformance Client 3','Client for client_secret_post','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email"]',1,1,NULL,NULL,NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); +INSERT INTO oidc_client VALUES('_8957eda35234902ba8343c0cdacac040310f17dfca','_322d16999f9da8b5abc9e9c0c08e853f60f4dc4804','RP-Initiated Logout Client','Client for testing RP-Initiated Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]',NULL,NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); +INSERT INTO oidc_client VALUES('_9fe2f7589ece1b71f5ef75a91847d71bc5125ec2a6','_3c0beb20194179c01d7796c6836f62801e9ed4b368','Back-Channel Logout Client','Client for testing Back-Channel Logout','example-userpass','["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/callback","https:\/\/www.certification.openid.net\/test\/a\/simplesamlphp-module-oidc\/callback"]','["openid","profile","email","address","phone"]',1,1,NULL,'["https:\/\/localhost.emobix.co.uk:8443\/test\/a\/simplesamlphp-module-oidc\/post_logout_redirect"]','https://httpd:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, false); CREATE TABLE oidc_access_token ( id VARCHAR(191) PRIMARY KEY NOT NULL, scopes TEXT, expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - user_id VARCHAR(191) NOT NULL, + user_id VARCHAR(191) NOT NULL, client_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, auth_code_id varchar(191) DEFAULT NULL, requested_claims TEXT NULL, - CONSTRAINT FK_43C1650EA76ED395 FOREIGN KEY (user_id) - REFERENCES oidc_user (id) ON DELETE CASCADE, - CONSTRAINT FK_43C1650E19EB6921 FOREIGN KEY (client_id) - REFERENCES oidc_client (id) ON DELETE CASCADE + flow_type CHAR(64) NULL, + authorization_details TEXT NULL, + bound_client_id TEXT NULL, + bound_redirect_uri TEXT NULL, + issuer_state TEXT NULL, + CONSTRAINT FK_43C1650EA76ED395 FOREIGN KEY (user_id) + REFERENCES oidc_user (id) ON DELETE CASCADE, + CONSTRAINT FK_43C1650E19EB6921 FOREIGN KEY (client_id) + REFERENCES oidc_client (id) ON DELETE CASCADE ); CREATE TABLE oidc_refresh_token ( - id VARCHAR(191) PRIMARY KEY NOT NULL, + id VARCHAR(191) PRIMARY KEY NOT NULL, expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, access_token_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, @@ -78,14 +84,20 @@ CREATE TABLE oidc_auth_code ( id VARCHAR(191) PRIMARY KEY NOT NULL, scopes TEXT, expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - user_id VARCHAR(191) NOT NULL, + user_id VARCHAR(191) NOT NULL, client_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, redirect_uri TEXT NOT NULL, nonce TEXT NULL, + flow_type CHAR(64) DEFAULT NULL, + tx_code varchar(191) DEFAULT NULL, + authorization_details TEXT NULL, + bound_client_id TEXT NULL, + bound_redirect_uri TEXT NULL, + issuer_state TEXT NULL, CONSTRAINT FK_97D32CA7A76ED395 FOREIGN KEY (user_id) - REFERENCES oidc_user (id) ON DELETE CASCADE, + REFERENCES oidc_user (id) ON DELETE CASCADE, CONSTRAINT FK_97D32CA719EB6921 FOREIGN KEY (client_id) - REFERENCES oidc_client (id) ON DELETE CASCADE + REFERENCES oidc_client (id) ON DELETE CASCADE ); CREATE TABLE oidc_allowed_origin ( client_id varchar(191) NOT NULL, @@ -98,4 +110,10 @@ CREATE TABLE oidc_session_logout_ticket ( sid VARCHAR(191) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE oidc_vci_issuer_state ( + value CHAR(64) PRIMARY KEY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_revoked BOOLEAN NOT NULL DEFAULT false +); COMMIT; diff --git a/docs/1-oidc.md b/docs/1-oidc.md index 3bfd3c56..60fbdc51 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -30,6 +30,26 @@ Currently supported OIDFed features: OIDFed is implemented using the [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid). +## Note on OpenID for Verifiable Credential Issuance (OpenID4VCI) support + +OpenID4VCI support was done as per draft 15 of the specification and is in the +experimental stage. You should NOT use it in production environments. + +Currently implemented OpenID4VCI features: + +- Grant types: + - Pre-authorized Code flow (new flow defined by the OpenID4VCI spec) + - Authorization Code flow +- Credential formats: + - jwt_vc_json, using VCDM v1.1 + - dc+sd-jwt (previously vc+sd-jwt) (SD-JWT VC) +- Proof types: + - jwt +- API for credential offer fetching + +OpenID4VCI is also implemented using the +[SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid). + ## Version compatibility Minor versions listed show which SimpleSAMLphp versions were used during diff --git a/docs/2-oidc-installation.md b/docs/2-oidc-installation.md index 1efe263f..776ede2b 100644 --- a/docs/2-oidc-installation.md +++ b/docs/2-oidc-installation.md @@ -34,12 +34,14 @@ and ensure at least the following parameters are set: Note: SQLite, PostgreSQL, and MySQL are supported. -## 4. Create RSA key pairs +## 4. Create key pairs ID and Access tokens are signed JWTs. Create a public/private RSA key pair for OIDC protocol operations. If you plan to use OpenID Federation, create a separate key pair for federation operations. +### RSA key pair generation + Generate private keys without a passphrase: ```bash @@ -73,6 +75,43 @@ openssl rsa -in cert/oidc_module_federation.key -passin pass:myPassPhrase -pubou If you use different file names or a passphrase, update `config/module_oidc.php` accordingly. +### EC key pair generation + +If you prefer to use Elliptic Curve Cryptography (ECC) instead of RSA. + +Generate private keys without a passphrase: + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out cert/oidc_module.key +openssl ecparam -name prime256v1 -genkey -noout -out cert/oidc_module_federation.key +``` + +Generate private keys with a passphrase: + +```bash +openssl ecparam -genkey -name secp384r1 -noout -out cert/oidc_module.key -passout pass:myPassPhrase +openssl ecparam -genkey -name secp384r1 -noout -out cert/oidc_module_federation.key -passout pass:myPassPhrase +``` + +Extract public keys: + +Without passphrase: + +```bash +openssl ec -in cert/oidc_module.key -pubout -out cert/oidc_module.crt +openssl ec -in cert/oidc_module_federation.key -pubout -out cert/oidc_module_federation.crt +``` + +With a passphrase: + +```bash +openssl ec -in cert/oidc_module.key -passin pass:myPassPhrase -pubout -out cert/oidc_module.crt +openssl ec -in cert/oidc_module.key -passin pass:myPassPhrase -pubout -out cert/oidc_module.crt +``` + +If you use different file names or a passphrase, update +`config/module_oidc.php` accordingly. + ## 5. Enable the module Edit `config/config.php` and enable `oidc`: diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 12ad22c7..d660dc1e 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -54,6 +54,12 @@ There you can see discovery URLs. Typical discovery endpoints are: [https://yourserver/simplesaml/module.php/oidc/.well-known/openid-configuration](https://yourserver/simplesaml/module.php/oidc/.well-known/openid-configuration) - OpenID Federation configuration: [https://yourserver/simplesaml/module.php/oidc/.well-known/openid-federation](https://yourserver/simplesaml/module.php/oidc/.well-known/openid-federation) +- OpenID for Verifiable Credential Issuance configuration: +[https://yourserver/simplesaml/module.php/oidc/.well-known/openid-credential-issuer](https://yourserver/simplesaml/module.php/oidc/.well-known/openid-credential-issuer) +- OAuth2 Authorization Server configuration: +[https://yourserver/simplesaml/module.php/oidc/.well-known/oauth-authorization-server](https://yourserver/simplesaml/module.php/oidc/.well-known/oauth-authorization-server) +- JWT VC Issuer configuration: +[https://yourserver/simplesaml/module.php/oidc/.well-known/jwt-vc-issuer](https://yourserver/simplesaml/module.php/oidc/.well-known/jwt-vc-issuer) You may publish these as ".well-known" URLs at the web root using your web server. For example, for `openid-configuration`: diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 534c102a..7604c11c 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -7,8 +7,14 @@ apply those relevant to your deployment. New features: +- Initial support for OpenID for Verifiable Credential Issuance +(OpenID4VCI). Note that the implementation is experimental. You should not use +it in production yet. + New configuration options: +- Several new options regarding support for OpenID4VCI. + Major impact changes: - In v6 of the module, when defining custom scopes, there was a possibility to diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..68b8ee62 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,145 @@ +# API + +## Enabling API + +To enable API capabilities, in module config file `config/module_oidc.php`, find option +`ModuleConfig::OPTION_API_ENABLED` and set it to `true`. + +```php +use SimpleSAML\Module\oidc\ModuleConfig; + +ModuleConfig::OPTION_API_ENABLED => true, +``` + + +## API Authentication and Authorization + +API access tokens are defined in file `config/module_oidc.php`, under option `ModuleConfig::OPTION_API_TOKENS`. +This option is an associative array, where keys are the API access tokens, and values are arrays of scopes. + +```php +use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; +use SimpleSAML\Module\oidc\ModuleConfig; + +ModuleConfig::OPTION_API_TOKENS => [ + 'strong-random-token-string' => [ + ApiScopesEnum::All, + ], +], +``` +Scopes determine which endpoints are accessible by the API access token. The following scopes are available: + +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All`: Access to all endpoints. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciAll`: Access to all VCI-related endpoints +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::VciCredentialOffer`: Access to credential offer endpoint. + +## API Endpoints + +Note that all endpoints will have a path prefix based on the SimpleSAMLphp base path and `oidc` module path. +For example, if you serve SimpleSAMLphp using base URL path `simplesaml/`, the path prefix for each API endpoint +will be + +`/simplesaml/module.php/oidc/api/` + +Check the SimpleSAMLphp config file `config/config.php`, option `baseurlpath` to find the base URL path of the +SimpleSAMLphp installation. + +### Credential Offer + +Enables fetching a credential offer as per OpenID4VCI specification. + +#### Path + +`/api/vci/credential-offer` + +#### Method + +`POST` + +#### Authorization + +`Bearer Token` + +#### Request + +The request is sent as a JSON object in the body with the following parameters: + +* __grant_type__ (string, mandatory): Specifies the type of grant (issuance flow) being requested. Allowed values are: + * `urn:ietf:params:oauth:grant-type:pre-authorized_code`: Pre-authorized code grant. + * `authorization_code`: Authorization code grant. +* __credential_configuration_id__ (string, mandatory): The identifier for the credential configuration being requested. +This must correspond to a predefined configuration ID for the VCI Issuer. Check the Credential Issuer Configuration URL +`/.well-known/openid-credential-issuer`, under the `credential_configurations_supported` field. +* __use_tx_code__ (boolean, optional, default being `false`): Indicates whether to use transaction code protection for +pre-authorized code grant. +* __users_email_attribute_name__ (string, optional, no default): The name of the attribute that holds the +user's email address. Used when transaction code protection is enabled to send the transaction code to the user's email +address. +* __authentication_source_id__ (string, optional, no default): The identifier for the SimpleSAMLphp authentication +source, that should be used to determine the user's email address attribute. Used if `users_email_attribute_name` is +not specified, and transaction code protection is enabled. +* __user_attributes__ (object, optional, no default): An object containing various user attributes. Used in +pre-authorized code grant to populate credential data. + +#### Response + +The response is a JSON object with the `credential_offer_uri` field containing the credential offer URI string value. + +#### Sample 1 + +Request a credential offer to issue a credential with the ID `ResearchAndScholarshipCredentialDcSdJwt` using the +authorization code grant. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/vci/credential-offer' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer ***' \ +--data '{ + "grant_type": "authorization_code", + "credential_configuration_id": "ResearchAndScholarshipCredentialDcSdJwt" +}' +``` + +Response: + +```json +{ + "credential_offer_uri": "openid-credential-offer://?credential_offer={\"credential_issuer\":\"https:\\/\\/idp.mivanci.incubator.hexaa.eu\",\"credential_configuration_ids\":[\"ResearchAndScholarshipCredentialDcSdJwt\"],\"grants\":{\"authorization_code\":{\"issuer_state\":\"30616b68fa26b00c5a6391faffc02e4e4fd9b0023fd6a3aa29ec754e2f5e2871\"}}}" +} + +``` + +#### Sample 2 + +Request a credential offer to issue a credential with the ID `ResearchAndScholarshipCredentialDcSdJwt` using the +pre-authorized code grant with transaction code protection. The user's email address is retrieved from the attribute +`mail`. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/vci/credential-offer' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer ***' \ +--data-raw '{ + "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code", + "credential_configuration_id": "ResearchAndScholarshipCredentialDcSdJwt", + "use_tx_code": true, + "users_email_attribute_name": "mail", + "user_attributes": { + "uid": [“testuseruid"], + "mail": ["testuser@example.com"], + "...": [“..."] + } +}' +``` + +Response: + +```json +{ + "credential_offer_uri": "openid-credential-offer://?credential_offer={\"credential_issuer\":\"https:\\/\\/idp.mivanci.incubator.hexaa.eu\",\"credential_configuration_ids\":[\"ResearchAndScholarshipCredentialDcSdJwt\"],\"grants\":{\"urn:ietf:params:oauth:grant-type:pre-authorized_code\":{\"pre-authorized_code\":\"_ffcdf6d86cd564c300346351dce0b4ccb2fde304e2\",\"tx_code\":{\"input_mode\":\"numeric\",\"length\":4,\"description\":\"Please provide the one-time code that was sent to e-mail testuser@example.com\"}}}}" +} +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..bbce5f8e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +# SimpleSAMLphp OIDC module + +* [API](api.md) diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index cb57e66d..f1520849 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -18,6 +18,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\Container; @@ -64,6 +65,10 @@ function oidc_hook_cron(array &$croninfo): void $refreshTokenRepository = $container->get(RefreshTokenRepository::class); $refreshTokenRepository->removeExpired(); + /** @var \SimpleSAML\Module\oidc\Repositories\IssuerStateRepository $issuerStateRepository */ + $issuerStateRepository = $container->get(IssuerStateRepository::class); + $issuerStateRepository->removeInvalid(); + $croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.'; } catch (Exception $e) { $message = 'Module `oidc` clean up cron script failed: ' . $e->getMessage(); diff --git a/public/assets/js/src/test-verifiable-credential-issuance.js b/public/assets/js/src/test-verifiable-credential-issuance.js new file mode 100644 index 00000000..24a45824 --- /dev/null +++ b/public/assets/js/src/test-verifiable-credential-issuance.js @@ -0,0 +1,25 @@ +(function () { + 'use strict'; + + // Handle option changes based on Grant Type + function togglePreAuthorizedCodeOptions() { + if (grantTypeSelect.value === "urn:ietf:params:oauth:grant-type:pre-authorized_code") { + useTxCodeCheckbox.disabled = false; + usersEmailAttributeNameInput.disabled = false; + } else { + useTxCodeCheckbox.disabled = true; + useTxCodeCheckbox.checked = false; + usersEmailAttributeNameInput.disabled = true; + } + } + + const grantTypeSelect = document.getElementById("grantType"); + + // Get references to options + const useTxCodeCheckbox = document.getElementById("useTxCode"); + const usersEmailAttributeNameInput = document.getElementById("usersEmailAttributeName"); + + grantTypeSelect.addEventListener("change", togglePreAuthorizedCodeOptions); + + togglePreAuthorizedCodeOptions(); +})(); \ No newline at end of file diff --git a/routing/routes/routes.php b/routing/routes/routes.php index caad53c7..16b7b01c 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -10,14 +10,20 @@ use SimpleSAML\Module\oidc\Controllers\AccessTokenController; use SimpleSAML\Module\oidc\Controllers\Admin\ClientController; use SimpleSAML\Module\oidc\Controllers\Admin\ConfigController; -use SimpleSAML\Module\oidc\Controllers\Admin\TestController; +use SimpleSAML\Module\oidc\Controllers\Admin\FederationTestController; +use SimpleSAML\Module\oidc\Controllers\Admin\VerifiableCredentailsTestController; +use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialOfferApiController; use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController; use SimpleSAML\Module\oidc\Controllers\JwksController; +use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; +use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; +use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; +use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\JwtVcIssuerConfigurationController; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -38,6 +44,8 @@ ->controller([ConfigController::class, 'protocolSettings']); $routes->add(RoutesEnum::AdminConfigFederation->name, RoutesEnum::AdminConfigFederation->value) ->controller([ConfigController::class, 'federationSettings']); + $routes->add(RoutesEnum::AdminConfigVerifiableCredential->name, RoutesEnum::AdminConfigVerifiableCredential->value) + ->controller([ConfigController::class, 'verifiableCredentialSettings']); // Client management @@ -62,11 +70,16 @@ // Testing $routes->add(RoutesEnum::AdminTestTrustChainResolution->name, RoutesEnum::AdminTestTrustChainResolution->value) - ->controller([TestController::class, 'trustChainResolution']) + ->controller([FederationTestController::class, 'trustChainResolution']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); $routes->add(RoutesEnum::AdminTestTrustMarkValidation->name, RoutesEnum::AdminTestTrustMarkValidation->value) - ->controller([TestController::class, 'trustMarkValidation']) + ->controller([FederationTestController::class, 'trustMarkValidation']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + $routes->add( + RoutesEnum::AdminTestVerifiableCredentialIssuance->name, + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + )->controller([VerifiableCredentailsTestController::class, 'verifiableCredentialIssuance']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); /***************************************************************************************************************** * OpenID Connect @@ -86,6 +99,13 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); + /***************************************************************************************************************** + * OAuth 2.0 Authorization Server + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::OAuth2Configuration->name, RoutesEnum::OAuth2Configuration->value) + ->controller(OAuth2ServerConfigurationController::class); + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ @@ -101,4 +121,34 @@ $routes->add(RoutesEnum::FederationList->name, RoutesEnum::FederationList->value) ->controller([SubordinateListingsController::class, 'list']) ->methods([HttpMethodsEnum::GET->value]); + + /***************************************************************************************************************** + * OpenID for Verifiable Credential Issuance + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::CredentialIssuerConfiguration->name, RoutesEnum::CredentialIssuerConfiguration->value) + ->controller([CredentialIssuerConfigurationController::class, 'configuration']) + ->methods([HttpMethodsEnum::GET->value]); + + $routes->add(RoutesEnum::CredentialIssuerCredential->name, RoutesEnum::CredentialIssuerCredential->value) + ->controller([CredentialIssuerCredentialController::class, 'credential']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + + /***************************************************************************************************************** + * SD-JWT-based Verifiable Credentials (SD-JWT VC) + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::JwtVcIssuerConfiguration->name, RoutesEnum::JwtVcIssuerConfiguration->value) + ->controller([JwtVcIssuerConfigurationController::class, 'configuration']) + ->methods([HttpMethodsEnum::GET->value]); + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + $routes->add( + RoutesEnum::ApiVciCredentialOffer->name, + RoutesEnum::ApiVciCredentialOffer->value, + )->controller([VciCredentialOfferApiController::class, 'credentialOffer']) + ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index 60f08be9..b3f284b6 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -60,9 +60,12 @@ services: factory: ['@SimpleSAML\Module\oidc\Factories\Grant\ImplicitGrantFactory', 'build'] SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant: factory: ['@SimpleSAML\Module\oidc\Factories\Grant\RefreshTokenGrantFactory', 'build'] + SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant: + factory: ['@SimpleSAML\Module\oidc\Factories\Grant\PreAuthCodeGrantFactory', 'build'] + # Responses - SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse: - factory: ['@SimpleSAML\Module\oidc\Factories\IdTokenResponseFactory', 'build'] + SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse: + factory: ['@SimpleSAML\Module\oidc\Factories\TokenResponseFactory', 'build'] oidc.key.private: class: League\OAuth2\Server\CryptKey @@ -78,7 +81,7 @@ services: SimpleSAML\Module\oidc\Factories\AuthorizationServerFactory: arguments: $privateKey: '@oidc.key.private' - SimpleSAML\Module\oidc\Factories\IdTokenResponseFactory: + SimpleSAML\Module\oidc\Factories\TokenResponseFactory: arguments: $privateKey: '@oidc.key.private' SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory: @@ -125,8 +128,13 @@ services: factory: [ '@SimpleSAML\Module\oidc\Factories\CoreFactory', 'build' ] SimpleSAML\OpenID\Federation: factory: [ '@SimpleSAML\Module\oidc\Factories\FederationFactory', 'build' ] + SimpleSAML\OpenID\VerifiableCredentials: + factory: [ '@SimpleSAML\Module\oidc\Factories\VerifiableCredentialsFactory', 'build' ] SimpleSAML\OpenID\Jwks: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] + SimpleSAML\OpenID\Jwk: ~ + SimpleSAML\OpenID\Did: ~ + # SSP SimpleSAML\Database: diff --git a/src/Admin/Authorization.php b/src/Admin/Authorization.php index 2f7a24c7..7bc35220 100644 --- a/src/Admin/Authorization.php +++ b/src/Admin/Authorization.php @@ -9,17 +9,20 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Services\AuthContextService; +use SimpleSAML\Module\oidc\Services\LoggerService; class Authorization { public function __construct( protected readonly SspBridge $sspBridge, protected readonly AuthContextService $authContextService, + protected readonly LoggerService $loggerService, ) { } public function isAdmin(): bool { + $this->loggerService->debug('Authorization::isAdmin'); return $this->sspBridge->utils()->auth()->isAdmin(); } @@ -28,10 +31,19 @@ public function isAdmin(): bool */ public function requireAdmin(bool $forceAdminAuthentication = false): void { + $this->loggerService->debug('Authorization::requireAdmin'); + $this->loggerService->debug( + 'Authorization: Force admin authentication:', + ['forceAdminAuthentication' => $forceAdminAuthentication], + ); if ($forceAdminAuthentication) { + $this->loggerService->debug('Authorization: Forcing admin authentication.'); try { $this->sspBridge->utils()->auth()->requireAdmin(); } catch (Exception $exception) { + $this->loggerService->error( + 'Authorization: Forcing admin authentication failed: ' . $exception->getMessage(), + ); throw new AuthorizationException( Translate::noop('Unable to initiate SimpleSAMLphp admin authentication.'), $exception->getCode(), @@ -41,7 +53,10 @@ public function requireAdmin(bool $forceAdminAuthentication = false): void } if (! $this->isAdmin()) { + $this->loggerService->error('Authorization: User is NOT admin.'); throw new AuthorizationException(Translate::noop('SimpleSAMLphp admin access required.')); + } else { + $this->loggerService->debug('Authorization: User is admin.'); } } @@ -50,16 +65,29 @@ public function requireAdmin(bool $forceAdminAuthentication = false): void */ public function requireAdminOrUserWithPermission(string $permission): void { + $this->loggerService->debug('Authorization::requireAdminOrUserWithPermission'); + $this->loggerService->debug('Authorization: For permission: ' . $permission); + if ($this->isAdmin()) { + $this->loggerService->debug('Authorization: User is admin, returning.'); return; + } else { + $this->loggerService->debug('Authorization: User is not (authenticated as) admin.'); } try { + $this->loggerService->debug('Authorization: Checking for user permission.'); $this->authContextService->requirePermission($permission); - } catch (\Exception) { - // TODO mivanci v7 log this exception + $this->loggerService->debug('Authorization: User has permission, returning.'); + return; + } catch (\Exception $exception) { + $this->loggerService->warning( + 'Authorization: User permission check failed: ' . $exception->getMessage(), + ); } + $this->loggerService->debug('Authorization: Falling back to admin authentication.'); + // If we get here, the user does not have the required permission, or permissions are not enabled. // Fallback to admin authentication. $this->requireAdmin(true); diff --git a/src/Codebooks/ApiScopesEnum.php b/src/Codebooks/ApiScopesEnum.php new file mode 100644 index 00000000..00006ac2 --- /dev/null +++ b/src/Codebooks/ApiScopesEnum.php @@ -0,0 +1,14 @@ + true, + default => false, + }; + } + + public function isVciFlow(): bool + { + return match ($this) { + self::VciAuthorizationCode, self::VciPreAuthorizedCode => true, + default => false, + }; + } +} diff --git a/src/Codebooks/ParametersEnum.php b/src/Codebooks/ParametersEnum.php index bb8630ad..7bf5395c 100644 --- a/src/Codebooks/ParametersEnum.php +++ b/src/Codebooks/ParametersEnum.php @@ -7,4 +7,6 @@ enum ParametersEnum: string { case ClientId = 'client_id'; + case CredentialOffer = 'credential_offer'; + case CredentialOfferUri = 'credential_offer_uri'; } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6c17691a..6e44dbf2 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -12,6 +12,7 @@ enum RoutesEnum: string case AdminConfigProtocol = 'admin/config/protocol'; case AdminConfigFederation = 'admin/config/federation'; + case AdminConfigVerifiableCredential = 'admin/config/verifiable-credential'; case AdminMigrations = 'admin/migrations'; case AdminMigrationsRun = 'admin/migrations/run'; @@ -27,6 +28,7 @@ enum RoutesEnum: string // Testing case AdminTestTrustChainResolution = 'admin/test/trust-chain-resolution'; case AdminTestTrustMarkValidation = 'admin/test/trust-mark-validation'; + case AdminTestVerifiableCredentialIssuance = 'admin/test/verifiable-credential-issuance'; /***************************************************************************************************************** @@ -40,6 +42,13 @@ enum RoutesEnum: string case Jwks = 'jwks'; case EndSession = 'end-session'; + /***************************************************************************************************************** + * OAuth 2.0 Authorization Server + ****************************************************************************************************************/ + + // OAuth 2.0 Authorization Server Metadata https://www.rfc-editor.org/rfc/rfc8414.html + case OAuth2Configuration = '.well-known/oauth-authorization-server'; + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ @@ -47,4 +56,23 @@ enum RoutesEnum: string case FederationConfiguration = '.well-known/openid-federation'; case FederationFetch = 'federation/fetch'; case FederationList = 'federation/list'; + + /***************************************************************************************************************** + * OpenID for Verifiable Credential Issuance + ****************************************************************************************************************/ + + case CredentialIssuerConfiguration = '.well-known/openid-credential-issuer'; + case CredentialIssuerCredential = 'credential-issuer/credential'; + + /***************************************************************************************************************** + * SD-JWT-based Verifiable Credentials (SD-JWT VC) + ****************************************************************************************************************/ + + case JwtVcIssuerConfiguration = '.well-known/jwt-vc-issuer'; + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + case ApiVciCredentialOffer = 'api/vci/credential-offer'; } diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index 5eb24c3f..3718dac4 100644 --- a/src/Controllers/Admin/ConfigController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -104,4 +104,15 @@ function (string $token): Federation\TrustMark { RoutesEnum::AdminConfigFederation->value, ); } + + public function verifiableCredentialSettings(): Response + { + return $this->templateFactory->build( + 'oidc:config/verifiable-credential.twig', + [ + 'moduleConfig' => $this->moduleConfig, + ], + RoutesEnum::AdminConfigVerifiableCredential->value, + ); + } } diff --git a/src/Controllers/Admin/TestController.php b/src/Controllers/Admin/FederationTestController.php similarity index 99% rename from src/Controllers/Admin/TestController.php rename to src/Controllers/Admin/FederationTestController.php index 87e2086b..f9d60ebc 100644 --- a/src/Controllers/Admin/TestController.php +++ b/src/Controllers/Admin/FederationTestController.php @@ -17,7 +17,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -class TestController +class FederationTestController { protected readonly Federation $federationWithArrayLogger; diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php new file mode 100644 index 00000000..fe5f26d7 --- /dev/null +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -0,0 +1,182 @@ +authorization->requireAdmin(true); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\CredentialOfferException + * @psalm-suppress MixedAssignment, InternalMethod + */ + public function verifiableCredentialIssuance(Request $request): Response + { + $setupErrors = []; + + if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + $setupErrors[] = 'Verifiable Credential functionalities are not enabled.'; + } + + $selectedAuthSourceId = $this->sessionService->getCurrentSession()->getData('vci', 'auth_source_id'); + + $authSource = null; + if (is_string($selectedAuthSourceId)) { + $authSource = $this->authSimpleFactory->forAuthSourceId($selectedAuthSourceId); + } + + // Check if the logout was called. + if ( + $request->request->has('logout') && + $authSource instanceof Simple && + $authSource->isAuthenticated() + ) { + $this->sessionService->getCurrentSession()->deleteData('vci', 'auth_source_id'); + $selectedAuthSourceId = null; + $authSource->logout(); + } elseif (is_string($newAuthSourceId = $request->get('authSourceId'))) { + $authSource = $this->authSimpleFactory->forAuthSourceId($newAuthSourceId); + $this->sessionService->getCurrentSession()->setData('vci', 'auth_source_id', $newAuthSourceId); + $selectedAuthSourceId = $newAuthSourceId; + } + + $authSourceIds = array_filter( + $this->sspBridge->auth()->source()->getSources(), + fn (string $authSourceId): bool => $authSourceId !== 'admin', + ); + + if ( + $authSource instanceof Simple && + ($authSource->isAuthenticated() === false) && + is_string($selectedAuthSourceId) && + in_array($selectedAuthSourceId, $authSourceIds, true) + ) { + $authSource->login(['ReturnTo' => $this->routes->urlAdminTestVerifiableCredentialIssuance()]); + } + + /** @psalm-suppress MixedAssignment */ + $selectedCredentialConfigurationId = $this->sessionService->getCurrentSession()->getData( + 'vci', + 'credential_configuration_id', + ); + + /** @psalm-suppress MixedAssignment, InternalMethod */ + if (is_string($newCredentialConfigurationId = $request->get('credentialConfigurationId'))) { + $this->sessionService->getCurrentSession()->setData( + 'vci', + 'credential_configuration_id', + $newCredentialConfigurationId, + ); + $selectedCredentialConfigurationId = $newCredentialConfigurationId; + } + + $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + + if (empty($credentialConfigurationIdsSupported)) { + $setupErrors[] = 'No credential configuration IDs configured.'; + } + + if ( + is_null($selectedCredentialConfigurationId) || + !in_array($selectedCredentialConfigurationId, $credentialConfigurationIdsSupported, true) + ) { + $selectedCredentialConfigurationId = current($credentialConfigurationIdsSupported); + } + + $credentialOfferQrUri = null; + $credentialOfferUri = null; + /** @psalm-suppress MixedAssignment, InternalMethod */ + $grantType = $request->get('grantType'); + /** @psalm-suppress InternalMethod */ + $useTxCode = (bool) $request->get('useTxCode'); + /** @psalm-suppress MixedAssignment, InternalMethod */ + $usersEmailAttributeName = $request->get('usersEmailAttributeName'); + $usersEmailAttributeName = is_string($usersEmailAttributeName) && (trim($usersEmailAttributeName) !== '') ? + trim($usersEmailAttributeName) : + null; + + if ( + $authSource instanceof Simple && + $authSource->isAuthenticated() + ) { + $userAttributes = $authSource->getAttributes(); + $usersEmailAttributeName ??= $this->moduleConfig->getUsersEmailAttributeNameForAuthSourceId( + $authSource->getAuthSource()->getAuthId(), + ); + + if ($grantType === GrantTypesEnum::PreAuthorizedCode->value) { + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( + [$selectedCredentialConfigurationId], + $userAttributes, + $useTxCode, + $usersEmailAttributeName, + ); + } else { + $credentialOfferUri = $this->credentialOfferUriFactory->buildForAuthorization( + [$selectedCredentialConfigurationId], + ); + } + + // TODO mivanci Local QR code generator + // https://quickchart.io/documentation/qr-codes/ + $credentialOfferQrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); + } + + $authSourceActionRoute = $this->routes->urlAdminTestVerifiableCredentialIssuance(); + + $defaultUsersEmailAttributeName = $this->moduleConfig->getDefaultUsersEmailAttributeName(); + + $grantTypesSupported = [ + GrantTypesEnum::PreAuthorizedCode->value => Translate::noop('Pre-authorized Code'), + GrantTypesEnum::AuthorizationCode->value => Translate::noop('Authorization Code'), + ]; + + return $this->templateFactory->build( + 'oidc:tests/verifiable-credential-issuance.twig', + compact( + 'setupErrors', + 'credentialOfferQrUri', + 'credentialOfferUri', + 'authSourceIds', + 'authSourceActionRoute', + 'authSource', + 'credentialConfigurationIdsSupported', + 'selectedCredentialConfigurationId', + 'defaultUsersEmailAttributeName', + 'usersEmailAttributeName', + 'grantTypesSupported', + ), + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + ); + } +} diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php new file mode 100644 index 00000000..58f29524 --- /dev/null +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -0,0 +1,214 @@ +moduleConfig->getApiEnabled()) { + $this->loggerService->warning('API capabilities not enabled.'); + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + + if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + /** + */ + public function credentialOffer(Request $request): Response + { + $this->loggerService->debug('VciCredentialOfferApiController::credentialOffer'); + + $this->loggerService->debug( + 'VciCredentialOfferApiController: Request data: ', + $request->getPayload()->all(), + ); + + try { + $this->authorization->requireTokenForAnyOfScope( + $request, + [ApiScopesEnum::VciCredentialOffer, ApiScopesEnum::VciAll, ApiScopesEnum::All], + ); + } catch (AuthorizationException $e) { + $this->loggerService->error( + 'VciCredentialOfferApiController: AuthorizationException: ' . $e->getMessage(), + ); + return $this->routes->newJsonErrorResponse( + error: 'unauthorized', + description: $e->getMessage(), + httpCode: Response::HTTP_UNAUTHORIZED, + ); + } + + $input = $request->getPayload()->all(); + + $credentialConfigurationId = $input['credential_configuration_id'] ?? null; + + if (!is_string($credentialConfigurationId)) { + $this->loggerService->error( + 'VciCredentialOfferApiController: credential_configuration_id not provided or not a string.', + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'No credential configuration ID (credential_configuration_id) provided.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $credentialConfiguration = $this->moduleConfig->getCredentialConfiguration($credentialConfigurationId); + + if (!is_array($credentialConfiguration)) { + $this->loggerService->error( + 'VciCredentialOfferApiController: Provided Credential Configuration ID is not supported.', + ['credentialConfigurationId' => $credentialConfigurationId], + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Provided credential configuration ID (credential_configuration_id) is not supported.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $grantType = $input['grant_type'] ?? null; + + if (!is_string($grantType)) { + $this->loggerService->error('VciCredentialOfferApiController: Grant Type (grant_type) not provided.'); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'No credential Grant Type (grant_type) provided.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $grantTypeEnum = GrantTypesEnum::tryFrom($grantType); + + if (!$grantTypeEnum instanceof GrantTypesEnum) { + $this->loggerService->error( + 'VciCredentialOfferApiController: Invalid credential Grant Type (grant_type) provided.', + ['grantType' => $grantType], + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Invalid credential Grant Type (grant_type) provided.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + if (!$grantTypeEnum->canBeUsedForVerifiableCredentialIssuance()) { + $this->loggerService->error( + 'VciCredentialOfferApiController: Provided Grant Type can not be used for verifiable credential' . + ' issuance.', + ['grantType' => $grantType], + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Provided Grant Type can not be used for verifiable credential issuance.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $credentialOfferUri = null; + + if ($grantTypeEnum === GrantTypesEnum::AuthorizationCode) { + $this->loggerService->debug( + 'VciCredentialOfferApiController: AuthorizationCode Grant Type provided. Building credential ' . + 'offer for Authorization Code Flow.', + ); + $credentialOfferUri = $this->credentialOfferUriFactory->buildForAuthorization( + [$credentialConfigurationId], + ); + } + + if ($grantTypeEnum === GrantTypesEnum::PreAuthorizedCode) { + $this->loggerService->debug( + 'VciCredentialOfferApiController: PreAuthorizedCode Grant Type provided. Building credential ' . + 'offer for Pre-authorized Code Flow.', + ); + + /** @psalm-suppress MixedAssignment */ + $userAttributes = $input['user_attributes'] ?? []; + $userAttributes = is_array($userAttributes) ? $userAttributes : []; + $useTxCode = boolval($input['use_tx_code'] ?? false); + /** @psalm-suppress MixedAssignment */ + $usersEmailAttributeName = $input['users_email_attribute_name'] ?? null; + $usersEmailAttributeName = is_string($usersEmailAttributeName) ? $usersEmailAttributeName : null; + /** @psalm-suppress MixedAssignment */ + $authenticationSourceId = $input['authentication_source_id'] ?? null; + $authenticationSourceId = is_string($authenticationSourceId) ? $authenticationSourceId : null; + + if (is_null($usersEmailAttributeName) && is_string($authenticationSourceId)) { + $usersEmailAttributeName = $this->moduleConfig->getUsersEmailAttributeNameForAuthSourceId( + $authenticationSourceId, + ); + } + + $this->loggerService->debug( + 'VciCredentialOfferApiController: PreAuthorizedCode data:', + [ + 'userAttributes' => $userAttributes, + 'useTxCode' => $useTxCode, + 'authenticationSourceId' => $authenticationSourceId, + 'usersEmailAttributeName' => $usersEmailAttributeName, + ], + ); + + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( + [$credentialConfigurationId], + $userAttributes, + $useTxCode, + $usersEmailAttributeName, + ); + } + + if ($credentialOfferUri !== null) { + $data = [ + 'credential_offer_uri' => $credentialOfferUri, + ]; + + $this->loggerService->debug( + 'VciCredentialOfferApiController: Credential Offer URI built successfully, returning data:', + $data, + ); + return $this->routes->newJsonResponse( + data: $data, + ); + } + + $this->loggerService->debug( + 'VciCredentialOfferApiController: Credential Offer URI NOT built for provided Grant Type.', + ['grantType' => $grantType], + ); + + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'No implementation for provided Grant Type.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } +} diff --git a/src/Controllers/AuthorizationController.php b/src/Controllers/AuthorizationController.php index fa4c6079..00d13570 100644 --- a/src/Controllers/AuthorizationController.php +++ b/src/Controllers/AuthorizationController.php @@ -59,8 +59,10 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface { $queryParameters = $request->getQueryParams(); $state = null; + $this->loggerService->debug('AuthorizationController::invoke: Request parameters: ', $queryParameters); if (!isset($queryParameters[ProcessingChain::AUTHPARAM])) { + $this->loggerService->debug('AuthorizationController::invoke: No AuthProcId query param.'); $authorizationRequest = $this->authorizationServer->validateAuthorizationRequest($request); $state = $this->authenticationService->processRequest($request, $authorizationRequest); // processState will trigger a redirect diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index b1b74f84..a7918b37 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -13,7 +13,9 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\FederationCache; +use SimpleSAML\Module\oidc\Utils\FingerprintGenerator; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; @@ -22,6 +24,7 @@ use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum; use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Jwk; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -42,6 +45,7 @@ public function __construct( private readonly Helpers $helpers, private readonly Routes $routes, private readonly Federation $federation, + private readonly Jwk $jwk, private readonly LoggerService $loggerService, private readonly ?FederationCache $federationCache, ) { @@ -55,7 +59,6 @@ public function __construct( * * @return \Symfony\Component\HttpFoundation\Response * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \ReflectionException * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \Psr\SimpleCache\InvalidArgumentException */ @@ -71,63 +74,70 @@ public function configuration(): Response return $this->prepareEntityStatementResponse((string)$cachedEntityConfigurationToken); } - $builder = $this->jsonWebTokenBuilderService->getFederationJwtBuilder() - ->withHeader(ClaimsEnum::Typ->value, JwtTypesEnum::EntityStatementJwt->value) - ->relatedTo($this->moduleConfig->getIssuer()) // This is entity configuration (statement about itself). - ->expiresAt( - $this->helpers->dateTime()->getUtc()->add($this->moduleConfig->getFederationEntityStatementDuration()), - )->withClaim( - ClaimsEnum::Jwks->value, - ['keys' => array_values($this->jsonWebKeySetService->federationKeys()),], - ) - ->withClaim( - ClaimsEnum::Metadata->value, - [ - EntityTypesEnum::FederationEntity->value => [ - // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters - ...(array_filter( - [ - ClaimsEnum::OrganizationName->value => $this->moduleConfig->getOrganizationName(), - ClaimsEnum::DisplayName->value => $this->moduleConfig->getDisplayName(), - ClaimsEnum::Description->value => $this->moduleConfig->getDescription(), - ClaimsEnum::Keywords->value => $this->moduleConfig->getKeywords(), - ClaimsEnum::Contacts->value => $this->moduleConfig->getContacts(), - ClaimsEnum::LogoUri->value => $this->moduleConfig->getLogoUri(), - ClaimsEnum::PolicyUri->value => $this->moduleConfig->getPolicyUri(), - ClaimsEnum::InformationUri->value => $this->moduleConfig->getInformationUri(), - ClaimsEnum::OrganizationUri->value => $this->moduleConfig->getOrganizationUri(), - ], - )), - ClaimsEnum::FederationFetchEndpoint->value => $this->routes->urlFederationFetch(), - ClaimsEnum::FederationListEndpoint->value => $this->routes->urlFederationList(), - // TODO v7 mivanci Add when ready. Use ClaimsEnum for keys. - // https://openid.net/specs/openid-federation-1_0.html#name-federation-entity - //'federation_resolve_endpoint', - //'federation_trust_mark_status_endpoint', - //'federation_trust_mark_list_endpoint', - //'federation_trust_mark_endpoint', - //'federation_historical_keys_endpoint', - //'endpoint_auth_signing_alg_values_supported' - // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters - //'signed_jwks_uri', - //'jwks_uri', - //'jwks', - ], - // OP metadata with additional federation related claims. - EntityTypesEnum::OpenIdProvider->value => [ - ...$this->opMetadataService->getMetadata(), - ClaimsEnum::ClientRegistrationTypesSupported->value => [ - ClientRegistrationTypesEnum::Automatic->value, + $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); + + $header = [ + ClaimsEnum::Kid->value => FingerprintGenerator::forFile( + $this->moduleConfig->getFederationCertPath(), + ), + ]; + + $payload = [ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => $this->helpers->random()->getIdentifier(), + // This is entity configuration (statement about itself). + ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Exp->value => $this->helpers->dateTime()->getUtc()->add( + $this->moduleConfig->getFederationEntityStatementDuration(), + )->getTimestamp(), + ClaimsEnum::Jwks->value => ['keys' => array_values($this->jsonWebKeySetService->federationKeys()),], + ClaimsEnum::Metadata->value => [ + EntityTypesEnum::FederationEntity->value => [ + // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + ...(array_filter( + [ + ClaimsEnum::OrganizationName->value => $this->moduleConfig->getOrganizationName(), + ClaimsEnum::DisplayName->value => $this->moduleConfig->getDisplayName(), + ClaimsEnum::Description->value => $this->moduleConfig->getDescription(), + ClaimsEnum::Keywords->value => $this->moduleConfig->getKeywords(), + ClaimsEnum::Contacts->value => $this->moduleConfig->getContacts(), + ClaimsEnum::LogoUri->value => $this->moduleConfig->getLogoUri(), + ClaimsEnum::PolicyUri->value => $this->moduleConfig->getPolicyUri(), + ClaimsEnum::InformationUri->value => $this->moduleConfig->getInformationUri(), + ClaimsEnum::OrganizationUri->value => $this->moduleConfig->getOrganizationUri(), ], + )), + ClaimsEnum::FederationFetchEndpoint->value => $this->routes->urlFederationFetch(), + ClaimsEnum::FederationListEndpoint->value => $this->routes->urlFederationList(), + // TODO v7 mivanci Add when ready. Use ClaimsEnum for keys. + // https://openid.net/specs/openid-federation-1_0.html#name-federation-entity + //'federation_resolve_endpoint', + //'federation_trust_mark_status_endpoint', + //'federation_trust_mark_list_endpoint', + //'federation_trust_mark_endpoint', + //'federation_historical_keys_endpoint', + //'endpoint_auth_signing_alg_values_supported' + // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + //'signed_jwks_uri', + //'jwks_uri', + //'jwks', + ], + // OP metadata with additional federation related claims. + EntityTypesEnum::OpenIdProvider->value => [ + ...$this->opMetadataService->getMetadata(), + ClaimsEnum::ClientRegistrationTypesSupported->value => [ + ClientRegistrationTypesEnum::Automatic->value, ], ], - ); + ], + ]; if ( is_array($authorityHints = $this->moduleConfig->getFederationAuthorityHints()) && (!empty($authorityHints)) ) { - $builder = $builder->withClaim(ClaimsEnum::AuthorityHints->value, $authorityHints); + $payload[ClaimsEnum::AuthorityHints->value] = $authorityHints; } $trustMarks = []; @@ -190,16 +200,23 @@ public function configuration(): Response } if (!empty($trustMarks)) { - $builder = $builder->withClaim(ClaimsEnum::TrustMarks->value, $trustMarks); + $payload[ClaimsEnum::TrustMarks->value] = $trustMarks; } // TODO v7 mivanci Continue // Remaining claims, add if / when ready. // * crit - $jws = $this->jsonWebTokenBuilderService->getSignedFederationJwt($builder); - - $entityConfigurationToken = $jws->toString(); + /** @psalm-suppress ArgumentTypeCoercion */ + $entityConfigurationToken = $this->federation->entityStatementFactory()->fromData( + $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( + $this->moduleConfig->getFederationPrivateKeyPath(), + ), + SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), + $payload, + $header, + ) + ->getToken(); $this->federationCache?->set( $entityConfigurationToken, diff --git a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php new file mode 100644 index 00000000..9984d16a --- /dev/null +++ b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php @@ -0,0 +1,29 @@ +routes->newJsonResponse( + $this->opMetadataService->getMetadata(), + ); + + // TODO mivanci Add ability for claim 'signed_metadata' when moving to simplesamlphp/openid, as per + // https://www.rfc-editor.org/rfc/rfc8414.html#section-2.1, with caching support. + } +} diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php new file mode 100644 index 00000000..95959a5e --- /dev/null +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -0,0 +1,108 @@ +moduleConfig->getVerifiableCredentialEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + public function configuration(): Response + { + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p + + $signer = $this->moduleConfig->getProtocolSigner(); + + $credentialConfigurationsSupported = $this->moduleConfig->getCredentialConfigurationsSupported(); + + // For now, we only support one credential signing algorithm. + /** @psalm-suppress MixedAssignment */ + foreach ($credentialConfigurationsSupported as $credentialConfigurationId => $credentialConfiguration) { + if (is_array($credentialConfiguration)) { + // Draft 17 + $credentialConfiguration[ClaimsEnum::CredentialSigningAlgValuesSupported->value] = [ + $signer->algorithmId(), + ]; + // Earlier drafts + // TODO mivanci Delete CryptographicSuitesSupported once we are on the final draft. + $credentialConfiguration[ClaimsEnum::CryptographicSuitesSupported->value] = [ + $signer->algorithmId(), + ]; + $credentialConfigurationsSupported[$credentialConfigurationId] = $credentialConfiguration; + } + } + + $configuration = [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + + // OPTIONAL // WND + // authorization_servers + + // REQUIRED + ClaimsEnum::CredentialEndpoint->value => $this->routes->urlCredentialIssuerCredential(), + + // OPTIONAL + // nonce_endpoint + + // OPTIONAL + // deferred_credential_endpoint + + // OPTIONAL + // notification_endpoint + + // OPTIONAL + // credential_response_encryption + + // OPTIONAL + // batch_credential_issuance + + // OPTIONAL + // signed_metadata + + // OPTIONAL + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => $this->moduleConfig->getOrganizationName(), + ClaimsEnum::Locale->value => 'en-US', + // OPTIONAL + // logo + ], + + ], + + ClaimsEnum::CredentialConfigurationsSupported->value => $credentialConfigurationsSupported, + + ]; + + return $this->routes->newJsonResponse($configuration); + } +} diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php new file mode 100644 index 00000000..ec0f5391 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -0,0 +1,734 @@ +value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, + ]; + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function __construct( + protected readonly ResourceServer $resourceServer, + protected readonly AccessTokenRepository $accessTokenRepository, + protected readonly ModuleConfig $moduleConfig, + protected readonly Routes $routes, + protected readonly PsrHttpBridge $psrHttpBridge, + protected readonly VerifiableCredentials $verifiableCredentials, + protected readonly Jwk $jwk, + protected readonly LoggerService $loggerService, + protected readonly RequestParamsResolver $requestParamsResolver, + protected readonly UserRepository $userRepository, + protected readonly Did $did, + protected readonly IssuerStateRepository $issuerStateRepository, + ) { + if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \ReflectionException + * @throws OpenIdException + */ + public function credential(Request $request): Response + { + $this->loggerService->debug('CredentialIssuerCredentialController::credential'); + + $requestData = $this->requestParamsResolver->getAllFromRequestBasedOnAllowedMethods( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + [HttpMethodsEnum::POST], + ); + + $this->loggerService->debug( + 'CredentialIssuerCredentialController: Request data: ', + $requestData, + ); + + $authorization = $this->resourceServer->validateAuthenticatedRequest( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + ); + + $accessToken = $this->accessTokenRepository->findById( + (string)$authorization->getAttribute('oauth_access_token_id'), + ); + + if (! $accessToken instanceof AccessTokenEntity) { + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token not found.', + 401, + ); + } + + if ($accessToken->isRevoked()) { + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token is revoked.', + 401, + ); + } + + if ( + ($flowType = $accessToken->getFlowTypeEnum()) === null || + $flowType->isVciFlow() === false + ) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Access token is not intended for Verifiable' . + ' Credential Issuance.', + ['accessTokenState' => $accessToken->getState()], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token is not intended for verifiable credential issuance.', + 401, + ); + } + + $issuerState = $accessToken->getIssuerState(); + if ( + !is_string($issuerState) && + ($accessToken->getFlowTypeEnum() === FlowTypeEnum::VciAuthorizationCode) + ) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Issuer state missing in access token.', + ['accessTokenState' => $accessToken->getState()], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Issuer state missing in access token.', + 401, + ); + } + + if (is_string($issuerState) && $this->issuerStateRepository->findValid($issuerState) === null) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Issuer state not valid.', + ['issuerState' => $issuerState], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Issuer state not valid.', + ); + } + + if ( + isset($requestData[ClaimsEnum::CredentialConfigurationId->value]) && + isset($requestData[ClaimsEnum::CredentialIdentifier->value]) + ) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential configuration ID ' . + '(credential_configuration_id) present in request together with credential identifier ' . + '(credential_identifier).', + ); + + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Credential configuration ID must not be used together with credential identifier.', + ); + } + + // Resolve the requested credential identifier. + $resolvedCredentialIdentifier = null; + + // If the `authorization_details` parameter was used in the grant flow, the credential request has to use + // `credential_identifier` to request a specific credential. In this case `credential_configuration_id` + // must not be present. + if (($authorizationDetails = $accessToken->getAuthorizationDetails()) !== null) { + $credentialIdentifier = $requestData[ClaimsEnum::CredentialIdentifier->value] ?? null; + + if (!is_string($credentialIdentifier)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential identifier missing in request.', + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Can not resolve credential identifier.', + ); + } + + $isCredentialIdentifierUsedInFlow = false; + foreach ($authorizationDetails as $authorizationDetail) { + + /** @psalm-suppress MixedAssignment */ + if ( + !is_array($authorizationDetail) || + !isset($authorizationDetail[ClaimsEnum::Type->value]) || + $authorizationDetail[ClaimsEnum::Type->value] !== 'openid_credential' || + !isset($authorizationDetail[ClaimsEnum::CredentialConfigurationId->value]) || + !is_string( + $authorizationDetailCredentialConfigurationId = + $authorizationDetail[ClaimsEnum::CredentialConfigurationId->value], + ) + ) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Unusable authorization detail.', + ['authorizationDetail' => $authorizationDetail], + ); + continue; + } + + if ($credentialIdentifier === $authorizationDetailCredentialConfigurationId) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Credential identifier used in flow.', + ['credentialIdentifier' => $credentialIdentifier], + ); + $isCredentialIdentifierUsedInFlow = true; + break; + } + } + + if (!$isCredentialIdentifierUsedInFlow) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential identifier not used in flow.', + ['credentialIdentifier' => $credentialIdentifier], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Credential identifier not used in flow.', + ); + } + + $resolvedCredentialIdentifier = $credentialIdentifier; + + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential identifier from ' . + 'credential_identifier parameter.', + ['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier], + ); + } else { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: No authorization details found in access' . + ' token. Skipping credential identifier resolution from credential_identifier parameter.', + ); + } + + if (!is_string($resolvedCredentialIdentifier)) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential identifier from ' . + 'credential_configuration_id parameter.', + ); + + /** @psalm-suppress MixedAssignment */ + $credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null; + + if (is_string($credentialConfigurationId)) { + /** @psalm-suppress MixedAssignment */ + $resolvedCredentialIdentifier = $credentialConfigurationId; + + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential identifier from ' . + 'credential_configuration_id parameter.', + ['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier], + ); + } else { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential configuration ID missing in ' . + 'request.', + ); + } + } + + if (!is_string($resolvedCredentialIdentifier)) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: No credential identifier found in request. ' . + 'Falling back to resolution from format and credential type.', + ); + + $requestedCredentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null; + + if (!is_string($requestedCredentialFormatId)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential format missing in request.', + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Can not resolve credential format.', + ); + } + + if ( + !in_array($requestedCredentialFormatId, [ + CredentialFormatIdentifiersEnum::JwtVcJson->value, + CredentialFormatIdentifiersEnum::DcSdJwt->value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, // Deprecated value, but let's support it for now. + ]) + ) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Unsupported credential format.', + ['requestedCredentialFormatId' => $requestedCredentialFormatId], + ); + return $this->routes->newJsonErrorResponse( + 'unsupported_credential_type', + sprintf('Credential format ID "%s" is not supported.', $requestedCredentialFormatId), + ); + } + + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential format.', + ['requestedCredentialFormatId' => $requestedCredentialFormatId], + ); + + $fallbackCredentialConfigurationId = null; + + // TODO mivanci Update this to newest draft. + // Check per draft 14 (Sphereon wallet case). + /** @psalm-suppress MixedAssignment */ + if ( + $requestedCredentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value && + is_array( + $credentialDefinitionType = + $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value] ?? null, + ) + ) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential configuration ID ' . + 'from credential definition type.', + ['credentialDefinitionType' => $credentialDefinitionType], + ); + $fallbackCredentialConfigurationId = + $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( + $credentialDefinitionType, + ); + } elseif ( + in_array($requestedCredentialFormatId, self::SD_JWT_FORMAT_IDS, true) && + is_string($vct = $requestData[ClaimsEnum::Vct->value] ?? null) + ) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential configuration ID ' . + 'from VCT.', + ['vct' => $vct], + ); + $fallbackCredentialConfigurationId = $vct; + } + + if (!is_string($fallbackCredentialConfigurationId)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Could not resolve credential from ' . + 'format and credential type.', + ); + } else { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential configuration ID ' . + 'from format and credential type.', + ['fallbackCredentialConfigurationId' => $fallbackCredentialConfigurationId], + ); + + $resolvedCredentialIdentifier = $fallbackCredentialConfigurationId; + } + } + if (!is_string($resolvedCredentialIdentifier)) { + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Can not resolve credential configuration ID.', + ); + } + + $resolvedCredentialConfiguration = $this->moduleConfig->getCredentialConfiguration( + $resolvedCredentialIdentifier, + ); + if (!is_array($resolvedCredentialConfiguration)) { + return $this->routes->newJsonErrorResponse( + 'unsupported_credential_type', + sprintf('Credential ID "%s" is not supported.', $resolvedCredentialIdentifier), + ); + } + + $credentialFormatId = $resolvedCredentialConfiguration[ClaimsEnum::Format->value] ?? null; + if (!is_string($credentialFormatId)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential format ID missing in ' . + 'resolved credential configuration.', + ['resolvedCredentialConfiguration' => $resolvedCredentialConfiguration], + ); + throw OidcServerException::serverError( + 'Credential format ID missing in resolved credential configuration (format is mandatory).', + ); + } + + $userId = $accessToken->getUserIdentifier(); + if (!is_string($userId)) { + throw OidcServerException::invalidRequest('User identifier not available in Access Token.'); + } + $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); + if ($userEntity === null) { + throw OidcServerException::invalidRequest('User not found.'); + } + + // Placeholder sub identifier. Will do if proof is not provided. + $sub = $this->moduleConfig->getIssuer() . '/sub/' . $userId; + + $proof = null; + // Validate proof, if provided. + // TODO mivanci consider making proof mandatory (in issuer metadata). + /** @psalm-suppress MixedAssignment */ + if ( + isset($requestData['proof']['proof_type']) && + isset($requestData['proof']['jwt']) && + $requestData['proof']['proof_type'] === 'jwt' && + is_string($proofJwt = $requestData['proof']['jwt']) + ) { + $this->loggerService->debug('Verifying proof JWT: ' . $proofJwt); + + try { + /** + * Sample proof structure: + * 'proof' => + * array ( + * 'proof_type' => 'jwt', + * 'jwt' => 'eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2Iiwia2lkIjoiZGlkOmtleTp6MmRtekQ4MWNnUHg4VmtpN0pidXVNbUZZcldQZ1lveXR5a1VaM2V5cWh0MWo5S2JyU2ZYMkJVeHNVaW5QbVA3QUVzZEN4OWpQYlV0ZkIzWXN2MTd4TGpyZkMxeDNVZmlMTWtyeWdTZDJMeWltQ3RGejhHWlBqOFFrMUJFU0F6M21LWGRCTEpuUHNNQ0R4Nm9QNjNuZVpmR1NKelF5SjRLVlN6Nmt4UTJQOTE4NGdXS1FnI3oyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWcifQ.eyJhdWQiOiJodHRwczovL2lkcC5taXZhbmNpLmluY3ViYXRvci5oZXhhYS5ldSIsImlhdCI6MTc0ODUxNDE0NywiZXhwIjoxNzQ4NTE0ODA3LCJpc3MiOiJkaWQ6a2V5OnoyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWciLCJqdGkiOiJiMmNlZDQ2Yi0zOWNiLTRkZDAtYmQxZS1hNzY5ZWNlOWUxMTIifQ.SPdMSnrfF8ybhfYluzz5OrfWJQDOpCu7-of8zVbp5UR89GaB7j14Egext1h9pYgl6JwIP8zibUjTSc8JLVYuvA', + * ), + * + * Sphereon proof in credential request + * { + * "typ": "openid4vci-proof+jwt", + * "alg": "ES256", + * "kid": "did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbrSfX2BUxsUinPmP7AEsdCx9jPbUtfB3Ysv17xLjrfC1x3UfiLMkrygSd2LyimCtFz8GZPj8Qk1BESAz3mKXdBLJnPsMCDx6oP63neZfGSJzQyJ4KVSz6kxQ2P9184gWKQg#z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbrSfX2BUxsUinPmP7AEsdCx9jPbUtfB3Ysv17xLjrfC1x3UfiLMkrygSd2LyimCtFz8GZPj8Qk1BESAz3mKXdBLJnPsMCDx6oP63neZfGSJzQyJ4KVSz6kxQ2P9184gWKQg" + * } + * { + * "aud": "https://idp.mivanci.incubator.hexaa.eu", + * "iat": 1748514147, + * "exp": 1748514807, + * "iss": "did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbrSfX2BUxsUinPmP7AEsdCx9jPbUtfB3Ysv17xLjrfC1x3UfiLMkrygSd2LyimCtFz8GZPj8Qk1BESAz3mKXdBLJnPsMCDx6oP63neZfGSJzQyJ4KVSz6kxQ2P9184gWKQg", + * "jti": "b2ced46b-39cb-4dd0-bd1e-a769ece9e112" + * } + */ + $proof = $this->verifiableCredentials->openId4VciProofFactory()->fromToken($proofJwt); + (in_array($this->moduleConfig->getIssuer(), $proof->getAudience())) || + throw new OpenId4VciProofException('Invalid Proof audience.'); + + $kid = $proof->getKeyId(); + if (is_string($kid) && str_starts_with($kid, 'did:key:z')) { + // The fragment (#z2dmzD...) typically points to a specific verification method within the DID's + // context. For did:key, since the DID is the key, this fragment often just refers to the key + // itself. + ($didKey = strtok($kid, '#')) || throw new OpenId4VciProofException( + 'Error getting did:key without fragment. Value was: ' . $kid, + ); + + $jwk = $this->did->didKeyResolver()->extractJwkFromDidKey($didKey); + + $proof->verifyWithKey($jwk); + + $this->loggerService->debug('Proof verified successfully using did:key ' . $didKey); + // Set it as a subject identifier (bind it). + $sub = $didKey; + } else { + $this->loggerService->warning( + 'Proof currently not supported (no did:key:z). ', + ['header' => $proof->getHeader(), 'payload' => $proof->getPayload()], + ); + // TODO mivanci Consider adding support for other proof keys, like in sample for Lissi ('jwk') + /** + * 'header' => + * array ( + * 'alg' => 'ES256', + * 'typ' => 'openid4vci-proof+jwt', + * 'jwk' => + * array ( + * 'kty' => 'EC', + * 'crv' => 'P-256', + * 'x' => '7d1peDK5BTcnw45yGrRHcJJOxYrEj2sOvBnIXRyhxEM', + * 'y' => 'Z5x8pVp85PouIYkvQT2eJWZP3YgfUXPc6BIhJ2pETbM', + * ), + * ), + * 'payload' => + * array ( + * 'aud' => 'https://idp.mivanci.incubator.hexaa.eu', + * 'nonce' => NULL, + * 'iat' => 1758102462, + * 'iss' => '9c481dc3-2ad0-4fe0-881d-c32ad02fe0fc', + * ), + * ) + */ + } + } catch (\Exception $e) { + $message = 'Error processing proof JWT: ' . $e->getMessage(); + $this->loggerService->error($message); + return $this->routes->newJsonErrorResponse( + 'invalid_proof', + $message, + ); + } + } + + $userAttributes = $userEntity->getClaims(); + + // Get valid claim paths so we can check if the user attribute is allowed to be included in the credential, + // as per the credential configuration supported configuration. + $validClaimPaths = $this->moduleConfig->getValidCredentialClaimPathsFor($resolvedCredentialIdentifier); + + // Map user attributes to credential claims + $credentialSubject = []; // For JwtVcJson + $disclosureBag = $this->verifiableCredentials->disclosureBagFactory()->build(); // For DcSdJwt + $attributeToCredentialClaimPathMap = $this->moduleConfig->getUserAttributeToCredentialClaimPathMapFor( + $resolvedCredentialIdentifier, + ); + foreach ($attributeToCredentialClaimPathMap as $mapEntry) { + if (!is_array($mapEntry)) { + $this->loggerService->warning( + sprintf( + 'Attribute to credential claim path map entry is not an array. Value was: %s', + var_export($mapEntry, true), + ), + ); + continue; + } + + $userAttributeName = key($mapEntry); + /** @psalm-suppress MixedAssignment */ + $credentialClaimPath = current($mapEntry); + if (!is_array($credentialClaimPath)) { + $this->loggerService->warning( + sprintf( + 'Credential claim path for user attribute name %s is not an array. Value was: %s', + $userAttributeName, + var_export($credentialClaimPath, true), + ), + ); + continue; + } + $credentialClaimPath = array_filter($credentialClaimPath, 'is_string'); + if (!in_array($credentialClaimPath, $validClaimPaths)) { + $this->loggerService->warning( + 'Attribute "%s" does not use one of valid credential claim paths.', + $mapEntry, + ); + continue; + } + + if (!isset($userAttributes[$userAttributeName])) { + $this->loggerService->warning( + 'Attribute "%s" does not exist in user attributes.', + $mapEntry, + ); + continue; + } + + // Normalize to string for single array values. + /** @psalm-suppress MixedAssignment */ + $attributeValue = is_array($userAttributes[$userAttributeName]) && + count($userAttributes[$userAttributeName]) === 1 ? + reset($userAttributes[$userAttributeName]) : + $userAttributes[$userAttributeName]; + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { + $this->verifiableCredentials->helpers()->arr()->setNestedValue( + $credentialSubject, + $attributeValue, + ...$credentialClaimPath, + ); + } + + if (in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true)) { + // For now, we will only support disclosures for object properties. + $claimName = array_pop($credentialClaimPath); + if (!is_string($claimName)) { + $message = sprintf( + 'Invalid credential claim path for user attribute name %s. Can not extract claim name.' . + ' Path was: %s', + $userAttributeName, + print_r($credentialClaimPath, true), + ); + $this->loggerService->error($message); + continue; + } + + /** @psalm-suppress ArgumentTypeCoercion */ + $disclosure = $this->verifiableCredentials->disclosureFactory()->build( + value: $attributeValue, + name: $claimName, + path: $credentialClaimPath, + saltBlacklist: $disclosureBag->salts(), + ); + + $disclosureBag->add($disclosure); + } + } + + // Make sure that the subject identifier is in credentialSubject claim. + $this->setCredentialClaimValue( + $credentialSubject, + [ClaimsEnum::Credential_Subject->value, ClaimsEnum::Id->value], + $sub, + ); + + $signingKey = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( + $this->moduleConfig->getProtocolPrivateKeyPath(), + null, + ); + + $publicKey = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( + $this->moduleConfig->getProtocolCertPath(), + null, + [ + //ClaimsEnum::Use->value => 'sig', + ], + ); + + $base64PublicKey = json_encode($publicKey->jwk()->all(), JSON_UNESCAPED_SLASHES); + $base64PublicKey = Base64Url::encode($base64PublicKey); + + $issuerDid = 'did:jwk:' . $base64PublicKey; + + $issuedAt = new \DateTimeImmutable(); + + $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); + $signatureAlgorithm = SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()); + + $verifiableCredential = null; + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { + $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( + $signingKey, + $signatureAlgorithm, + [ + ClaimsEnum::Vc->value => [ + ClaimsEnum::AtContext->value => [ + AtContextsEnum::W3Org2018CredentialsV1->value, + ], + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + $resolvedCredentialIdentifier, + ], + //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Id->value => $vcId, + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], + ], + //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, + ], + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', + ], + ); + } + + if (in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true)) { + $sdJwtPayload = [ + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, + ClaimsEnum::Vct->value => $resolvedCredentialIdentifier, + ]; + + if ($proof instanceof OpenId4VciProof) { + $sdJwtPayload[ClaimsEnum::Cnf->value] = [ + ClaimsEnum::Kid->value => $proof->getKeyId(), + ]; + } + + $verifiableCredential = $this->verifiableCredentials->sdJwtVcFactory()->fromData( + $signingKey, + $signatureAlgorithm, + $sdJwtPayload, + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', + ], + disclosureBag: $disclosureBag, + jwtTypesEnum: JwtTypesEnum::VcSdJwt, + ); + } + + if ($verifiableCredential === null) { + throw new OpenIdException('Invalid credential format ID.'); + } + + if (is_string($issuerState)) { + $this->loggerService->debug('Revoking issuer state.', ['issuerState' => $issuerState]); + $this->issuerStateRepository->revoke($issuerState); + } + + $this->loggerService->debug('Returning credential response.', [ + 'credentials' => [ + ['credential' => $verifiableCredential->getToken()], + ], + ],); + + return $this->routes->newJsonResponse( + ['credential' => $verifiableCredential->getToken()], + // [ + // 'credentials' => [ + // ['credential' => $verifiableCredential->getToken()], + // ] + // ], + ); + } + + /** + * Helper method to set a claim value at a path. Supports creating nested arrays dynamically. + * @psalm-suppress UnusedVariable, MixedAssignment + * @param array-key[] $path + */ + protected function setCredentialClaimValue(array &$claims, array $path, mixed $value): void + { + $temp = &$claims; + + foreach ($path as $key) { + if (!is_array($temp)) { + $temp = []; + } + + if (!isset($temp[$key])) { + $temp[$key] = []; + } + + $temp = &$temp[$key]; + } + + // If the value is an array and holds only one element, we will set the value directly. + if (is_array($value) && count($value) === 1) { + $temp = $value[0]; + } else { + $temp = $value; + } + } +} diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php new file mode 100644 index 00000000..410a9cf6 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -0,0 +1,49 @@ +moduleConfig->getVerifiableCredentialEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + public function configuration(): Response + { + $configuration = [ + ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::JwksUri->value => $this->moduleConfig->getModuleUrl(RoutesEnum::Jwks->value), + ]; + + return $this->routes->newJsonResponse($configuration); + } +} diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index b98fe7cf..2fef7aaa 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -24,6 +24,7 @@ use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\EntityStringRepresentationInterface; use SimpleSAML\Module\oidc\Entities\Traits\AssociateWithAuthCodeTrait; @@ -69,6 +70,11 @@ public function __construct( ?array $requestedClaims = null, ?bool $isRevoked = false, ?Configuration $jwtConfiguration = null, + protected readonly ?FlowTypeEnum $flowTypeEnum = null, + protected readonly ?array $authorizationDetails = null, + protected readonly ?string $boundClientId = null, + protected readonly ?string $boundRedirectUri = null, + protected readonly ?string $issuerState = null, ) { $this->setIdentifier($id); $this->setClient($clientEntity); @@ -114,6 +120,13 @@ public function getState(): array 'is_revoked' => $this->isRevoked(), 'auth_code_id' => $this->getAuthCodeId(), 'requested_claims' => json_encode($this->requestedClaims, JSON_THROW_ON_ERROR), + 'flow_type' => $this->flowTypeEnum?->value, + 'authorization_details' => is_array($this->authorizationDetails) ? + json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR) : + null, + 'bound_client_id' => $this->boundClientId, + 'bound_redirect_uri' => $this->boundRedirectUri, + 'issuer_state' => $this->issuerState, ]; } @@ -155,7 +168,35 @@ protected function convertToJWT(): Token ->expiresAt($this->getExpiryDateTime()) ->relatedTo((string) $this->getUserIdentifier()) ->withClaim('scopes', $this->getScopes()); + if ($this->issuerState !== null) { + $jwtBuilder = $jwtBuilder->withClaim('issuer_state', $this->issuerState); + } return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($jwtBuilder); } + + public function getFlowTypeEnum(): ?FlowTypeEnum + { + return $this->flowTypeEnum; + } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } + + public function getIssuerState(): ?string + { + return $this->issuerState; + } } diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index d98fe347..2cacb7e1 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -19,6 +19,7 @@ use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\MementoInterface; use SimpleSAML\Module\oidc\Entities\Traits\OidcAuthCodeTrait; @@ -43,6 +44,12 @@ public function __construct( ?string $redirectUri = null, ?string $nonce = null, bool $isRevoked = false, + protected readonly ?FlowTypeEnum $flowTypeEnum = null, + protected readonly ?string $txCode = null, + protected readonly ?array $authorizationDetails = null, + protected readonly ?string $boundClientId = null, + protected readonly ?string $boundRedirectUri = null, + protected readonly ?string $issuerState = null, ) { $this->identifier = $id; $this->client = $client; @@ -68,6 +75,49 @@ public function getState(): array 'is_revoked' => $this->isRevoked(), 'redirect_uri' => $this->getRedirectUri(), 'nonce' => $this->getNonce(), + 'flow_type' => $this->flowTypeEnum?->value, + 'tx_code' => $this->txCode, + 'authorization_details' => is_array($this->authorizationDetails) ? + json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR) : + null, + 'bound_client_id' => $this->boundClientId, + 'bound_redirect_uri' => $this->boundRedirectUri, + 'issuer_state' => $this->issuerState, ]; } + + public function isVciPreAuthorized(): bool + { + return $this->flowTypeEnum === FlowTypeEnum::VciPreAuthorizedCode; + } + + public function getTxCode(): ?string + { + return $this->txCode; + } + + public function getFlowTypeEnum(): ?FlowTypeEnum + { + return $this->flowTypeEnum; + } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } + + public function getIssuerState(): ?string + { + return $this->issuerState; + } } diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index d834d41e..ef1da0a4 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -51,6 +51,7 @@ class ClientEntity implements ClientEntityInterface public const KEY_CREATED_AT = 'created_at'; public const KEY_EXPIRES_AT = 'expires_at'; public const KEY_IS_FEDERATED = 'is_federated'; + public const KEY_IS_GENERIC = 'is_generic'; private string $secret; @@ -93,6 +94,7 @@ class ClientEntity implements ClientEntityInterface private ?DateTimeImmutable $createdAt; private ?DateTimeImmutable $expiresAt; private bool $isFederated; + private bool $isGeneric; /** * @param string[] $redirectUri @@ -126,6 +128,7 @@ public function __construct( ?DateTimeImmutable $createdAt = null, ?DateTimeImmutable $expiresAt = null, bool $isFederated = false, + bool $isGeneric = false, ) { $this->identifier = $identifier; $this->secret = $secret; @@ -150,6 +153,7 @@ public function __construct( $this->createdAt = $createdAt; $this->expiresAt = $expiresAt; $this->isFederated = $isFederated; + $this->isGeneric = $isGeneric; } /** @@ -188,6 +192,7 @@ public function getState(): array self::KEY_CREATED_AT => $this->getCreatedAt()?->format('Y-m-d H:i:s'), self::KEY_EXPIRES_AT => $this->getExpiresAt()?->format('Y-m-d H:i:s'), self::KEY_IS_FEDERATED => $this->isFederated(), + self::KEY_IS_GENERIC => $this->isGeneric(), ]; } @@ -217,6 +222,7 @@ public function toArray(): array self::KEY_CREATED_AT => $this->createdAt, self::KEY_EXPIRES_AT => $this->expiresAt, self::KEY_IS_FEDERATED => $this->isFederated, + self::KEY_IS_GENERIC => $this->isGeneric, ]; } @@ -355,4 +361,9 @@ public function isFederated(): bool { return $this->isFederated; } + + public function isGeneric(): bool + { + return $this->isGeneric; + } } diff --git a/src/Entities/Interfaces/AuthCodeEntityInterface.php b/src/Entities/Interfaces/AuthCodeEntityInterface.php index 0fd28f7c..00f66db2 100644 --- a/src/Entities/Interfaces/AuthCodeEntityInterface.php +++ b/src/Entities/Interfaces/AuthCodeEntityInterface.php @@ -6,7 +6,7 @@ use League\OAuth2\Server\Entities\AuthCodeEntityInterface as OAuth2AuthCodeEntityInterface; -interface AuthCodeEntityInterface extends OAuth2AuthCodeEntityInterface +interface AuthCodeEntityInterface extends OAuth2AuthCodeEntityInterface, TokenRevokableInterface { /** * @return string|null diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index fd794774..b14ca517 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -79,4 +79,5 @@ public function getCreatedAt(): ?DateTimeImmutable; public function getExpiresAt(): ?DateTimeImmutable; public function isExpired(): bool; public function isFederated(): bool; + public function isGeneric(): bool; } diff --git a/src/Entities/IssuerStateEntity.php b/src/Entities/IssuerStateEntity.php new file mode 100644 index 00000000..beb044c5 --- /dev/null +++ b/src/Entities/IssuerStateEntity.php @@ -0,0 +1,57 @@ + $this->getValue(), + 'created_at' => $this->getCreatedAt()->format('Y-m-d H:i:s'), + 'expires_at' => $this->getExpirestAt()->format('Y-m-d H:i:s'), + 'is_revoked' => $this->isRevoked(), + ]; + } + + public function getValue(): string + { + return $this->value; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpirestAt(): DateTimeImmutable + { + return $this->expirestAt; + } + + public function isRevoked(): bool + { + return $this->isRevoked; + } + + public function revoke(): void + { + $this->isRevoked = true; + } +} diff --git a/src/Factories/AuthSimpleFactory.php b/src/Factories/AuthSimpleFactory.php index 0f708ce2..77ce0e48 100644 --- a/src/Factories/AuthSimpleFactory.php +++ b/src/Factories/AuthSimpleFactory.php @@ -16,6 +16,7 @@ namespace SimpleSAML\Module\oidc\Factories; +use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use SimpleSAML\Auth\Simple; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\ModuleConfig; @@ -31,7 +32,7 @@ public function __construct( * @codeCoverageIgnore * @throws \Exception */ - public function build(ClientEntityInterface $clientEntity): Simple + public function build(OAuth2ClientEntityInterface $clientEntity): Simple { $authSourceId = $this->resolveAuthSourceId($clientEntity); @@ -52,8 +53,19 @@ public function getDefaultAuthSource(): Simple * * @throws \Exception */ - public function resolveAuthSourceId(ClientEntityInterface $client): string + public function resolveAuthSourceId(OAuth2ClientEntityInterface $client): string { - return $client->getAuthSourceId() ?? $this->moduleConfig->getDefaultAuthSourceId(); + $defaultAuthSourceId = $this->moduleConfig->getDefaultAuthSourceId(); + + if ($client instanceof ClientEntityInterface) { + $client->getAuthSourceId() ?? $this->moduleConfig->getDefaultAuthSourceId(); + } + + return $defaultAuthSourceId; + } + + public function forAuthSourceId(string $authSourceId): Simple + { + return new Simple($authSourceId); } } diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 54cf5d38..a49f81a4 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -24,9 +24,11 @@ use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\ImplicitGrant; +use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; +use SimpleSAML\Module\oidc\Services\LoggerService; class AuthorizationServerFactory { @@ -38,9 +40,11 @@ public function __construct( private readonly AuthCodeGrant $authCodeGrant, private readonly ImplicitGrant $implicitGrant, private readonly RefreshTokenGrant $refreshTokenGrant, - private readonly IdTokenResponse $idTokenResponse, + private readonly TokenResponse $tokenResponse, private readonly RequestRulesManager $requestRulesManager, private readonly CryptKey $privateKey, + private readonly PreAuthCodeGrant $preAuthCodeGrant, + private readonly LoggerService $loggerService, ) { } @@ -52,8 +56,9 @@ public function build(): AuthorizationServer $this->scopeRepository, $this->privateKey, $this->moduleConfig->getEncryptionKey(), - $this->idTokenResponse, + $this->tokenResponse, $this->requestRulesManager, + $this->loggerService, ); $authorizationServer->enableGrantType( @@ -71,6 +76,13 @@ public function build(): AuthorizationServer $this->moduleConfig->getAccessTokenDuration(), ); + if ($this->moduleConfig->getVerifiableCredentialEnabled()) { + $authorizationServer->enableGrantType( + $this->preAuthCodeGrant, + $this->moduleConfig->getAccessTokenDuration(), + ); + } + return $authorizationServer; } } diff --git a/src/Factories/CoreFactory.php b/src/Factories/CoreFactory.php index 90c3454b..0708ac87 100644 --- a/src/Factories/CoreFactory.php +++ b/src/Factories/CoreFactory.php @@ -33,6 +33,9 @@ public function build(): Core SignatureAlgorithmEnum::ES256, SignatureAlgorithmEnum::ES384, SignatureAlgorithmEnum::ES512, + SignatureAlgorithmEnum::PS256, + SignatureAlgorithmEnum::PS384, + SignatureAlgorithmEnum::PS512, ), ); diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php new file mode 100644 index 00000000..5d314ba4 --- /dev/null +++ b/src/Factories/CredentialOfferUriFactory.php @@ -0,0 +1,306 @@ + 0) { + try { + $issuerState = $this->issuerStateEntityFactory->buildNew(); + $this->issuerStateRepository->persist($issuerState); + break; + } catch (\Throwable $e) { + if ($issuerStateGenerationAttempts === 0) { + $this->loggerService->error( + 'All attempts to generate Issuer State failed: ' . $e->getMessage(), + ); + throw new OpenIdException('Failed to generate issuer state.', previous: $e); + } + + $this->loggerService->warning('Failed to generate Issuer State: ' . $e->getMessage()); + } + } + + /** @psalm-var \SimpleSAML\Module\oidc\Entities\IssuerStateEntity $issuerState */ + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + ...$credentialConfigurationIds, + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::AuthorizationCode->value => [ + ClaimsEnum::IssuerState->value => $issuerState->getValue(), + ], + ], + ], + ); + + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + return "openid-credential-offer://?$parameterName=$credentialOfferValue"; + } + + /** + * @param string[] $credentialConfigurationIds + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + public function buildPreAuthorized( + array $credentialConfigurationIds, + array $userAttributes, + bool $useTxCode = false, + string $userEmailAttributeName = null, + ): string { + if (empty($credentialConfigurationIds)) { + throw new RuntimeException('No credential configuration IDs provided.'); + } + + $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + + if (empty($credentialConfigurationIdsSupported)) { + throw new RuntimeException('No credential configuration IDs configured.'); + } + + if (array_diff($credentialConfigurationIds, $credentialConfigurationIdsSupported)) { + throw new RuntimeException('Unsupported credential configuration IDs provided.'); + } + + // TODO mivanci Wallet (client) credential_offer_endpoint metadata + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata + + $scopes = array_map( + fn (string $scope) => new ScopeEntity($scope), + ['openid', ...$credentialConfigurationIds], + ); + + // Currently, we need a dedicated client for which the PreAuthZed code will be bound to. + // TODO mivanci: Remove requirement for dedicated client for (pre-)authorization codes once the dynamic + // client registration is enabled. + $client = $this->clientRepository->getGenericForVci(); + + $userId = null; + try { + /** @psalm-suppress MixedAssignment */ + $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $this->moduleConfig->getUserIdentifierAttribute(), + ); + + if (!is_scalar($userId)) { + throw new RuntimeException('User identifier attribute value is not a string.'); + } + $userId = strval($userId); + } catch (\Throwable $e) { + $this->loggerService->warning( + 'Could not extract user identifier from user attributes: ' . $e->getMessage(), + $userAttributes, + ); + } + + if ($userId === null) { + $this->loggerService->warning('Falling back to user attributes hash for user identifier.'); + $sortedAttributes = $userAttributes; + $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); + $userId = 'vci_credential_offer_preauthz_' . hash('sha256', serialize($sortedAttributes)); + $this->loggerService->info( + 'Generated user identifier based on user attributes: ' . $userId, + $userAttributes, + ); + } + + $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); + + $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); + + if ($oldUserEntity instanceof UserEntity) { + $this->userRepository->update($userEntity); + } else { + $this->userRepository->add($userEntity); + } + + $txCode = null; + $userEmail = null; + $userEmailAttributeName ??= $this->moduleConfig->getDefaultUsersEmailAttributeName(); + if ($useTxCode) { + $userEmail = $this->getUserEmail($userEmailAttributeName, $userAttributes); + $txCodeDescription = 'Please provide the one-time code that was sent to e-mail ' . $userEmail; + $txCode = $this->buildTxCode($txCodeDescription); + $this->loggerService->debug( + 'Generated TxCode for sending by email: ' . $txCode->getCodeAsString(), + $txCode->jsonSerialize(), + ); + } + + $authCodeIdGenerationAttempts = 3; + while ($authCodeIdGenerationAttempts-- > 0) { + try { + $authCode = $this->authCodeEntityFactory->fromData( + id: $this->sspBridge->utils()->random()->generateID(), + client: $client, + scopes: $scopes, + expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), + userIdentifier: $userId, + redirectUri: 'openid-credential-offer://', + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + txCode: $txCode instanceof VerifiableCredentials\TxCode ? $txCode->getCodeAsString() : null, + ); + $this->authCodeRepository->persistNewAuthCode($authCode); + break; + } catch (\Throwable $e) { + if ($authCodeIdGenerationAttempts === 0) { + $this->loggerService->error( + 'All attempts to generate Authorization Code failed: ' . $e->getMessage(), + ); + throw new OpenIdException('Failed to generate Authorization Code.', previous: $e); + } + + $this->loggerService->warning('Failed to generate Authorization Code ID: ' . $e->getMessage()); + } + } + + /** @psalm-var \SimpleSAML\Module\oidc\Entities\AuthCodeEntity $authCode */ + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + ...$credentialConfigurationIds, + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::PreAuthorizedCode->value => [ + ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), + ...(array_filter( + [ + ClaimsEnum::TxCode->value => $txCode instanceof VerifiableCredentials\TxCode ? + $txCode->jsonSerialize() : + null, + ], + )), + ], + ], + ], + ); + + if ($txCode instanceof VerifiableCredentials\TxCode && $userEmail !== null) { + $this->sendTxCodeByEmail($txCode, $userEmail); + } + + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + return "openid-credential-offer://?$parameterName=$credentialOfferValue"; + } + + /** + * @param mixed[] $userAttributes + * @throws RuntimeException + */ + public function getUserEmail(string $userEmailAttributeName, array $userAttributes): string + { + try { + $userEmail = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $userEmailAttributeName, + true, + ); + } catch (Exception $e) { + throw new RuntimeException('Could not extract user email from user attributes: ' . $e->getMessage()); + } + + if (!is_string($userEmail)) { + throw new RuntimeException('User email attribute value is not a string.'); + } + + return $userEmail; + } + + public function buildTxCode( + string $description, + int|string $txCode = null, + ): TxCode { + $txCode ??= rand(1000, 9999); + + return $this->verifiableCredentials->txCodeFactory()->build( + $txCode, + $description, + ); + } + + public function sendTxCodeByEmail(TxCode $txCode, string $email, string $subject = null): void + { + $subject ??= 'Your one-time code'; + + $email = $this->emailFactory->build( + subject: $subject, + to: $email, + ); + + $email->setText('Use the following code to complete the transaction.'); + + $email->setData([ + 'Transaction Code' => $txCode->getCodeAsString(), + ]); + + $email->send(); + } +} diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index a7cc02ea..908d464b 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -22,6 +22,7 @@ public function buildPrivateKey(): CryptKey return new CryptKey( $this->moduleConfig->getProtocolPrivateKeyPath(), $this->moduleConfig->getProtocolPrivateKeyPassPhrase(), + true, ); } @@ -30,6 +31,6 @@ public function buildPrivateKey(): CryptKey */ public function buildPublicKey(): CryptKey { - return new CryptKey($this->moduleConfig->getProtocolCertPath()); + return new CryptKey($this->moduleConfig->getProtocolCertPath(), null, false); } } diff --git a/src/Factories/EmailFactory.php b/src/Factories/EmailFactory.php new file mode 100644 index 00000000..582d5cc7 --- /dev/null +++ b/src/Factories/EmailFactory.php @@ -0,0 +1,29 @@ +fromData( $id, $client, @@ -99,6 +121,11 @@ public function fromState(array $state): AccessTokenEntity $authCodeId, $stateRequestedClaims, $isRevoked, + $flowType, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, + $issuerState, ); } } diff --git a/src/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index be0cdee2..0304b804 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; @@ -30,7 +31,13 @@ public function fromData( ?string $userIdentifier = null, ?string $redirectUri = null, ?string $nonce = null, + ?string $issuerState = null, bool $isRevoked = false, + ?FlowTypeEnum $flowTypeEnum = null, + ?string $txCode = null, + ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, ): AuthCodeEntity { return new AuthCodeEntity( $id, @@ -41,6 +48,12 @@ public function fromData( $redirectUri, $nonce, $isRevoked, + $flowTypeEnum, + $txCode, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, + $issuerState, ); } @@ -81,6 +94,18 @@ public function fromState(array $state): AuthCodeEntity $redirectUri = empty($state['redirect_uri']) ? null : (string)$state['redirect_uri']; $nonce = empty($state['nonce']) ? null : (string)$state['nonce']; $isRevoked = (bool) $state['is_revoked']; + $flowType = empty($state['flow_type']) ? null : FlowTypeEnum::tryFrom((string)$state['flow_type']); + $txCode = empty($state['tx_code']) ? null : (string)$state['tx_code']; + $issuerState = empty($state['issuer_state']) ? null : (string)$state['issuer_state']; + + /** @psalm-suppress MixedAssignment */ + $authorizationDetails = isset($state['authorization_details']) && is_string($state['authorization_details']) ? + json_decode($state['authorization_details'], true, 512, JSON_THROW_ON_ERROR) : + null; + $authorizationDetails = is_array($authorizationDetails) ? $authorizationDetails : null; + + $boundClientId = empty($state['bound_client_id']) ? null : (string)$state['bound_client_id']; + $boundRedirectUri = empty($state['bound_redirect_uri']) ? null : (string)$state['bound_redirect_uri']; return $this->fromData( $id, @@ -90,7 +115,13 @@ public function fromState(array $state): AuthCodeEntity $userIdentifier, $redirectUri, $nonce, + $issuerState, $isRevoked, + $flowType, + $txCode, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, ); } } diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index bdbad325..549ff663 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -29,6 +30,7 @@ public function __construct( private readonly Helpers $helpers, private readonly ClaimTranslatorExtractor $claimTranslatorExtractor, private readonly RequestParamsResolver $requestParamsResolver, + private readonly ModuleConfig $moduleConfig, ) { } @@ -64,6 +66,7 @@ public function fromData( ?DateTimeImmutable $createdAt = null, ?DateTimeImmutable $expiresAt = null, bool $isFederated = false, + bool $isGeneric = false, ): ClientEntityInterface { return new ClientEntity( $id, @@ -89,6 +92,7 @@ public function fromData( $createdAt, $expiresAt, $isFederated, + $isGeneric, ); } @@ -190,6 +194,7 @@ public function fromRegistrationData( // $expiresAt = $expiresAt; $isFederated = $existingClient?->isFederated() ?? false; + $isGeneric = $existingClient?->isGeneric() ?? false; return $this->fromData( $id, @@ -215,6 +220,7 @@ public function fromRegistrationData( $createdAt, $expiresAt, $isFederated, + $isGeneric, ); } @@ -353,6 +359,7 @@ public function fromState(array $state): ClientEntityInterface $this->helpers->dateTime()->getUtc((string)$state[ClientEntity::KEY_EXPIRES_AT]); $isFederated = (bool)$state[ClientEntity::KEY_IS_FEDERATED]; + $isGeneric = (bool)$state[ClientEntity::KEY_IS_GENERIC]; return $this->fromData( $id, @@ -378,6 +385,32 @@ public function fromState(array $state): ClientEntityInterface $createdAt, $expiresAt, $isFederated, + $isGeneric, + ); + } + + public function getGenericForVci(): ClientEntityInterface + { + $clientId = 'vci_' . + hash('sha256', 'vci_' . $this->moduleConfig->sspConfig()->getString('secretsalt')); + + $clientSecret = $this->helpers->random()->getIdentifier(); + + $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + + $createdAt = $this->helpers->dateTime()->getUtc(); + + return $this->fromData( + id: $clientId, + secret: $clientSecret, + name: 'VCI Generic Client', + description: 'Generic client for Verifiable Credential Issuance flows.', + redirectUri: ['openid-credential-offer://'], + scopes: ['openid', ...$credentialConfigurationIdsSupported], + isEnabled: true, + updatedAt: $createdAt, + createdAt: $createdAt, + isGeneric: true, ); } } diff --git a/src/Factories/Entities/IssuerStateEntityFactory.php b/src/Factories/Entities/IssuerStateEntityFactory.php new file mode 100644 index 00000000..7b683dd0 --- /dev/null +++ b/src/Factories/Entities/IssuerStateEntityFactory.php @@ -0,0 +1,85 @@ +helpers->random()->getIdentifier()); + + $createdAt ??= $this->helpers->dateTime()->getUtc(); + $expiresAt ??= $createdAt->add($this->moduleConfig->getIssuerStateDuration()); + + return $this->fromData($value, $createdAt, $expiresAt, $isRevoked); + } + + /** + * @param string $value Issuer State Entity value, max 64 characters. + * @throws OpenIdException + */ + public function fromData( + string $value, + DateTimeImmutable $createdAt, + DateTimeImmutable $expiresAt, + bool $isRevoked = false, + ): IssuerStateEntity { + if (strlen($value) > 64) { + throw new OpenIdException('Invalid Issuer State Entity value.'); + } + + return new IssuerStateEntity($value, $createdAt, $expiresAt, $isRevoked); + } + + /** + * @param mixed[] $state + * @return IssuerStateEntity + * @throws OpenIdException + */ + public function fromState(array $state): IssuerStateEntity + { + if ( + !is_string($value = $state['value']) || + !is_string($createdAt = $state['created_at']) || + !is_string($expiresAt = $state['expires_at']) + ) { + throw new OpenIdException('Invalid Issuer State Entity state.'); + } + + if (strlen($value) > 64) { + throw new OpenIdException('Invalid Issuer State Entity value.'); + } + + $isRevoked = (bool)($state['is_revoked'] ?? true); + + return new IssuerStateEntity( + $value, + $this->helpers->dateTime()->getUtc($createdAt), + $this->helpers->dateTime()->getUtc($expiresAt), + $isRevoked, + ); + } +} diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index b2db10f4..8df2a264 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -35,6 +35,9 @@ public function build(): Federation SignatureAlgorithmEnum::ES256, SignatureAlgorithmEnum::ES384, SignatureAlgorithmEnum::ES512, + SignatureAlgorithmEnum::PS256, + SignatureAlgorithmEnum::PS384, + SignatureAlgorithmEnum::PS512, ), ); diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index a72a53c4..5b90a452 100644 --- a/src/Factories/Grant/AuthCodeGrantFactory.php +++ b/src/Factories/Grant/AuthCodeGrantFactory.php @@ -26,6 +26,7 @@ use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; class AuthCodeGrantFactory @@ -41,6 +42,7 @@ public function __construct( private readonly AuthCodeEntityFactory $authCodeEntityFactory, private readonly RefreshTokenIssuer $refreshTokenIssuer, private readonly Helpers $helpers, + private readonly LoggerService $loggerService, ) { } @@ -60,6 +62,7 @@ public function build(): AuthCodeGrant $this->authCodeEntityFactory, $this->refreshTokenIssuer, $this->helpers, + $this->loggerService, ); $authCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/Grant/PreAuthCodeGrantFactory.php b/src/Factories/Grant/PreAuthCodeGrantFactory.php new file mode 100644 index 00000000..9e241c3f --- /dev/null +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -0,0 +1,71 @@ +authCodeRepository, + $this->accessTokenRepository, + $this->refreshTokenRepository, + $this->moduleConfig->getAuthCodeDuration(), + $this->requestRulesManager, + $this->requestParamsResolver, + $this->accessTokenEntityFactory, + $this->authCodeEntityFactory, + $this->refreshTokenIssuer, + $this->helpers, + $this->loggerService, + ); + $preAuthCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); + + return $preAuthCodeGrant; + } +} diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index bf28d4da..27de4e3c 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -14,16 +14,19 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AuthorizationDetailsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IssuerStateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; @@ -84,7 +87,8 @@ private function getDefaultRules(): array { return [ new StateRule($this->requestParamsResolver, $this->helpers), - new ClientIdRule( + new IssuerStateRule($this->requestParamsResolver, $this->helpers), + new ClientRule( $this->requestParamsResolver, $this->helpers, $this->clientRepository, @@ -93,9 +97,10 @@ private function getDefaultRules(): array $this->federation, $this->jwksResolver, $this->federationParticipationValidator, + $this->logger, $this->federationCache, ), - new RedirectUriRule($this->requestParamsResolver, $this->helpers), + new ClientRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), new RequestObjectRule($this->requestParamsResolver, $this->helpers, $this->jwksResolver), new PromptRule( $this->requestParamsResolver, @@ -141,6 +146,8 @@ private function getDefaultRules(): array $this->protocolCache, ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), + new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), + new ClientIdRule($this->requestParamsResolver, $this->helpers), ]; } } diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 48986fcb..7e26f6e5 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -141,6 +141,20 @@ protected function includeDefaultMenuItems(): void Translate::noop('Test Trust Mark Validation'), ), ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigVerifiableCredential->value), + Translate::noop('Verifiable Credential Settings'), + ), + ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value), + Translate::noop('Test Verifiable Credential Issuance'), + ), + ); } public function setShowMenu(bool $showMenu): TemplateFactory diff --git a/src/Factories/IdTokenResponseFactory.php b/src/Factories/TokenResponseFactory.php similarity index 71% rename from src/Factories/IdTokenResponseFactory.php rename to src/Factories/TokenResponseFactory.php index acc031bd..93983eee 100644 --- a/src/Factories/IdTokenResponseFactory.php +++ b/src/Factories/TokenResponseFactory.php @@ -19,28 +19,31 @@ use League\OAuth2\Server\CryptKey; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\UserRepository; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Services\LoggerService; -class IdTokenResponseFactory +class TokenResponseFactory { public function __construct( private readonly ModuleConfig $moduleConfig, private readonly UserRepository $userRepository, private readonly IdTokenBuilder $idTokenBuilder, private readonly CryptKey $privateKey, + private readonly LoggerService $loggerService, ) { } - public function build(): IdTokenResponse + public function build(): TokenResponse { - $idTokenResponse = new IdTokenResponse( + $tokenResponse = new TokenResponse( $this->userRepository, $this->idTokenBuilder, $this->privateKey, + $this->loggerService, ); - $idTokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); + $tokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); - return $idTokenResponse; + return $tokenResponse; } } diff --git a/src/Factories/VerifiableCredentialsFactory.php b/src/Factories/VerifiableCredentialsFactory.php new file mode 100644 index 00000000..6ffb2e51 --- /dev/null +++ b/src/Factories/VerifiableCredentialsFactory.php @@ -0,0 +1,48 @@ +moduleConfig->getProtocolSigner()->algorithmId()), + SignatureAlgorithmEnum::RS256, + SignatureAlgorithmEnum::RS384, + SignatureAlgorithmEnum::RS512, + SignatureAlgorithmEnum::ES256, + SignatureAlgorithmEnum::ES384, + SignatureAlgorithmEnum::ES512, + SignatureAlgorithmEnum::PS256, + SignatureAlgorithmEnum::PS384, + SignatureAlgorithmEnum::PS512, + ), + ); + + return new VerifiableCredentials( + supportedAlgorithms: $supportedAlgorithms, + logger: $this->loggerService, + ); + } +} diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 3abe4bbe..a8e13824 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -24,6 +24,7 @@ use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ScopesEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; @@ -101,6 +102,19 @@ class ModuleConfig final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE = 'federation_new_private_key_passphrase'; final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME = 'federation_new_private_key_filename'; final public const OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME = 'federation_new_certificate_filename'; + final public const OPTION_VERIFIABLE_CREDENTIAL_ENABLED = 'verifiable_credentials_enabled'; + final public const OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED = 'credential_configurations_supported'; + final public const OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = + 'user_attribute_to_credential_claim_path_map'; + final public const OPTION_API_ENABLED = 'api_enabled'; + final public const OPTION_API_TOKENS = 'api_tokens'; + final public const OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME = 'users_email_attribute_name'; + final public const OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP = + 'auth_sources_to_users_email_attribute_name_map'; + final public const OPTION_ISSUER_STATE_TTL = 'issuer_state_ttl'; + final public const OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI = 'allow_non_registered_clients_for_vci'; + final public const OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI = + 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -349,6 +363,8 @@ public function getProtocolSigner(): Signer /** * Get the path to the private key used in OIDC protocol. * @throws \Exception + * @return non-empty-string The file system path + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType */ public function getProtocolPrivateKeyPath(): string { @@ -371,8 +387,9 @@ public function getProtocolPrivateKeyPassPhrase(): ?string /** * Get the path to the public certificate used in OIDC protocol. - * @return string The file system path + * @return non-empty-string The file system path * @throws \Exception + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType */ public function getProtocolCertPath(): string { @@ -443,7 +460,12 @@ public function getForcedAcrValueForCookieAuthentication(): ?string */ public function getScopes(): array { - return array_merge(self::$standardScopes, $this->getPrivateScopes()); + return array_merge( + self::$standardScopes, + $this->getPrivateScopes(), + // Also include VCI scopes if enabled. + $this->getVciScopes(), + ); } /** @@ -855,4 +877,232 @@ public function isFederationParticipationLimitedByTrustMarksFor(string $trustAnc { return !empty($this->getTrustMarksNeededForFederationParticipationFor($trustAnchorId)); } + + + /***************************************************************************************************************** + * OpenID Verifiable Credential Issuance related config. + ****************************************************************************************************************/ + + public function getVerifiableCredentialEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_VERIFIABLE_CREDENTIAL_ENABLED, false); + } + + public function getCredentialConfigurationsSupported(): array + { + return $this->config()->getOptionalArray(self::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); + } + + /** + * @param string $credentialConfigurationId + * @return mixed[]|null + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getCredentialConfiguration(string $credentialConfigurationId): ?array + { + $credentialConfiguration = $this->getCredentialConfigurationsSupported()[$credentialConfigurationId] ?? null; + + if (is_null($credentialConfiguration)) { + return null; + } + + if (!is_array($credentialConfiguration)) { + throw new ConfigurationError( + sprintf( + 'Invalid configuration for credential configuration %s: %s', + $credentialConfigurationId, + var_export($credentialConfiguration, true), + ), + ); + } + + return $credentialConfiguration; + } + + /** + * @return array + */ + public function getCredentialConfigurationIdsSupported(): array + { + return array_map( + 'strval', + array_keys($this->getCredentialConfigurationsSupported()), + ); + } + + /** + * Helper function to get the credential configuration IDs in a format suitable for creating ScopeEntity instances. + * Returns an empty array if VCI is not enabled. + * + * @return array> + */ + public function getVciScopes(): array + { + if (! $this->getVerifiableCredentialEnabled()) { + return []; + } + + $vciScopes = []; + foreach ($this->getCredentialConfigurationIdsSupported() as $credentialConfigurationId) { + $vciScopes[$credentialConfigurationId] = ['description' => $credentialConfigurationId]; + } + return $vciScopes; + } + + public function getCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string + { + foreach ( + $this->getCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration + ) { + if (!is_array($credentialConfiguration)) { + continue; + } + + $credentialDefinition = $credentialConfiguration[ClaimsEnum::CredentialDefinition->value] ?? null; + + if (!is_array($credentialDefinition)) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $configuredType = $credentialDefinition[ClaimsEnum::Type->value] ?? null; + + if ($configuredType === $credentialDefinitionType) { + return (string)$credentialConfigurationId; + } + } + + return null; + } + + /** + * Extract and parse the claims path definition from the credential configuration supported. + * Returns an array of valid paths for the claims. + */ + public function getValidCredentialClaimPathsFor(string $credentialConfigurationId): array + { + $claimsConfig = $this->getCredentialConfigurationsSupported()[$credentialConfigurationId] + [ClaimsEnum::Claims->value] ?? []; + + $validPaths = []; + + if (!is_array($claimsConfig)) { + return $validPaths; + } + + /** @psalm-suppress MixedAssignment */ + foreach ($claimsConfig as $claim) { + if (is_array($claim)) { + /** @psalm-suppress MixedAssignment */ + $validPaths[] = $claim[ClaimsEnum::Path->value] ?? null; + } + } + + return array_filter($validPaths); + } + + public function getUserAttributeToCredentialClaimPathMap(): array + { + return $this->config()->getOptionalArray(self::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []); + } + + public function getUserAttributeToCredentialClaimPathMapFor(string $credentialConfigurationId): array + { + /** @psalm-suppress MixedAssignment */ + $map = $this->getUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; + + if (is_array($map)) { + return $map; + } + + return []; + } + + /** + * Get Issuer State Duration (TTL) if set. If not set, it will fall back to Authorization Code Duration. + * + * @return DateInterval + * @throws \Exception + */ + public function getIssuerStateDuration(): DateInterval + { + $issuerStateDuration = $this->config()->getOptionalString(self::OPTION_ISSUER_STATE_TTL, null); + + if (is_null($issuerStateDuration)) { + return $this->getAuthCodeDuration(); + } + + return new DateInterval( + $this->config()->getString(self::OPTION_ISSUER_STATE_TTL), + ); + } + + public function getAllowNonRegisteredClientsForVci(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI, false); + } + + public function getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci(): array + { + return $this->config()->getOptionalArray( + self::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI, + ['openid-credential-offer://',], + ); + } + + + /***************************************************************************************************************** + * API-related config. + ****************************************************************************************************************/ + + public function getApiEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_ENABLED, false); + } + + /** + * @return mixed[]|null + */ + public function getApiTokens(): ?array + { + return $this->config()->getOptionalArray(self::OPTION_API_TOKENS, null); + } + + /** + * @param string $token + * @return mixed[] + */ + public function getApiTokenScopes(string $token): ?array + { + /** @psalm-suppress MixedAssignment */ + $tokenScopes = $this->getApiTokens()[$token] ?? null; + + if (is_array($tokenScopes)) { + return $tokenScopes; + } + + return null; + } + + public function getAuthSourcesToUsersEmailAttributeMap(): array + { + return $this->config()->getOptionalArray(self::OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP, []); + } + + public function getUsersEmailAttributeNameForAuthSourceId(string $authSource): string + { + /** @psalm-suppress MixedAssignment */ + $attributeName = $this->getAuthSourcesToUsersEmailAttributeMap()[$authSource] ?? null; + + if (is_string($attributeName)) { + return $attributeName; + } + + return $this->getDefaultUsersEmailAttributeName(); + } + + public function getDefaultUsersEmailAttributeName(): string + { + return $this->config()->getOptionalString(self::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME, 'mail'); + } } diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 6c7d16e5..ac535490 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -101,8 +101,36 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo } $stmt = sprintf( - "INSERT INTO %s (id, scopes, expires_at, user_id, client_id, is_revoked, auth_code_id, requested_claims) " - . "VALUES (:id, :scopes, :expires_at, :user_id, :client_id, :is_revoked, :auth_code_id, :requested_claims)", + "INSERT INTO %s ( + id, + scopes, + expires_at, + user_id, + client_id, + is_revoked, + auth_code_id, + requested_claims, + flow_type, + authorization_details, + bound_client_id, + bound_redirect_uri, + issuer_state + ) " + . "VALUES ( + :id, + :scopes, + :expires_at, + :user_id, + :client_id, + :is_revoked, + :auth_code_id, + :requested_claims, + :flow_type, + :authorization_details, + :bound_client_id, + :bound_redirect_uri, + :issuer_state + )", $this->getTableName(), ); @@ -239,7 +267,9 @@ private function update(AccessTokenEntity $accessTokenEntity): void $stmt = sprintf( "UPDATE %s SET scopes = :scopes, expires_at = :expires_at, user_id = :user_id, " . "client_id = :client_id, is_revoked = :is_revoked, auth_code_id = :auth_code_id, " - . "requested_claims = :requested_claims WHERE id = :id", + . "requested_claims = :requested_claims, flow_type = :flow_type, " . + "authorization_details = :authorization_details, bound_client_id = :bound_client_id, " . + "bound_redirect_uri = :bound_redirect_uri, issuer_state = :issuer_state WHERE id = :id", $this->getTableName(), ); diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index 46d46832..f083cfc8 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -70,8 +70,39 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity } $stmt = sprintf( - "INSERT INTO %s (id, scopes, expires_at, user_id, client_id, is_revoked, redirect_uri, nonce) " - . "VALUES (:id, :scopes, :expires_at, :user_id, :client_id, :is_revoked, :redirect_uri, :nonce)", + <<getTableName(), ); @@ -93,7 +124,7 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity * Find Auth Code by id. * @throws \Exception */ - public function findById(string $codeId): ?AuthCodeEntityInterface + public function findById(string $codeId): ?AuthCodeEntity { /** @var ?array $data */ $data = $this->protocolCache?->get(null, $this->getCacheKey($codeId)); @@ -190,7 +221,13 @@ private function update(AuthCodeEntity $authCodeEntity): void client_id = :client_id, is_revoked = :is_revoked, redirect_uri = :redirect_uri, - nonce = :nonce + nonce = :nonce, + flow_type = :flow_type, + tx_code = :tx_code, + authorization_details = :authorization_details, + bound_client_id = :bound_client_id, + bound_redirect_uri = :bound_redirect_uri, + issuer_state = :issuer_state WHERE id = :id EOS , diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 27bc952a..e8087edc 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -361,7 +361,8 @@ public function add(ClientEntityInterface $client): void updated_at, created_at, expires_at, - is_federated + is_federated, + is_generic ) VALUES ( :id, @@ -386,7 +387,8 @@ public function add(ClientEntityInterface $client): void :updated_at, :created_at, :expires_at, - :is_federated + :is_federated, + :is_generic ) EOS , @@ -458,7 +460,8 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo updated_at = :updated_at, created_at = :created_at, expires_at = :expires_at, - is_federated = :is_federated + is_federated = :is_federated, + is_generic = :is_generic WHERE id = :id EOF , @@ -552,11 +555,25 @@ protected function preparePdoState(array $state): array $isEnabled = (bool)($state[ClientEntity::KEY_IS_ENABLED] ?? false); $isConfidential = (bool)($state[ClientEntity::KEY_IS_CONFIDENTIAL] ?? false); $isFederated = (bool)($state[ClientEntity::KEY_IS_FEDERATED] ?? false); + $isGeneric = (bool)($state[ClientEntity::KEY_IS_GENERIC] ?? false); $state[ClientEntity::KEY_IS_ENABLED] = [$isEnabled, PDO::PARAM_BOOL]; $state[ClientEntity::KEY_IS_CONFIDENTIAL] = [$isConfidential, PDO::PARAM_BOOL]; $state[ClientEntity::KEY_IS_FEDERATED] = [$isFederated, PDO::PARAM_BOOL]; + $state[ClientEntity::KEY_IS_GENERIC] = [$isGeneric, PDO::PARAM_BOOL]; return $state; } + + public function getGenericForVci(): ClientEntityInterface + { + $client = $this->clientEntityFactory->getGenericForVci(); + if ($this->findById($client->getIdentifier()) === null) { + $this->add($client); + } else { + $this->update($client); + } + + return $client; + } } diff --git a/src/Repositories/IssuerStateRepository.php b/src/Repositories/IssuerStateRepository.php new file mode 100644 index 00000000..a91e1138 --- /dev/null +++ b/src/Repositories/IssuerStateRepository.php @@ -0,0 +1,190 @@ +protocolCache?->get(null, $this->getCacheKey($value)); + + if (!is_array($data)) { + $stmt = $this->database->read( + "SELECT * FROM {$this->getTableName()} WHERE value = :value", + [ + 'value' => $value, + ], + ); + + if (empty($rows = $stmt->fetchAll())) { + return null; + } + + /** @var array $data */ + $data = current($rows); + } + + $issuerState = $this->issuerStateEntityFactory->fromState($data); + + $this->protocolCache?->set( + $issuerState->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $issuerState->getExpirestAt()->getTimestamp(), + ), + $this->getCacheKey($issuerState->getValue()), + ); + + return $issuerState; + } + + public function findValid(string $value): ?IssuerStateEntity + { + $issuerState = $this->find($value); + + if ($issuerState === null) { + return null; + } + + if ($issuerState->getExpirestAt() < $this->helpers->dateTime()->getUtc()) { + return null; + } + + if ($issuerState->isRevoked()) { + return null; + } + + return $issuerState; + } + + public function revoke(string $value): void + { + $issuerState = $this->find($value); + + if ($issuerState === null) { + return; + } + + $issuerState->revoke(); + $this->update($issuerState); + } + + public function update(IssuerStateEntity $issuerState): void + { + $stmt = sprintf( + <<getTableName(), + ); + + $this->database->write( + $stmt, + $this->preparePdoState($issuerState->getState()), + ); + + $this->protocolCache?->set( + $issuerState->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $issuerState->getExpirestAt()->getTimestamp(), + ), + $this->getCacheKey($issuerState->getValue()), + ); + } + + public function persist(IssuerStateEntity $issuerState): void + { + $stmt = sprintf( + <<getTableName(), + ); + + $this->database->write( + $stmt, + $this->preparePdoState($issuerState->getState()), + ); + + $this->protocolCache?->set( + $issuerState->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $issuerState->getExpirestAt()->getTimestamp(), + ), + $this->getCacheKey($issuerState->getValue()), + ); + } + + /** + * Remove invalid issuer state entities (expired or revoked). + * @return void + */ + public function removeInvalid(): void + { + $stmt = sprintf( + <<getTableName(), + ); + + $data = [ + 'expires_at' => $this->helpers->dateTime()->getUtc()->format(DateFormatsEnum::DB_DATETIME->value), + 'is_revoked' => true, + ]; + + $this->database->write($stmt, $this->preparePdoState($data)); + } + + protected function preparePdoState(array $state): array + { + $isRevoked = (bool)($state['is_revoked'] ?? true); + + $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + + return $state; + } +} diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 65c83e98..0c0367b4 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -18,13 +18,14 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\AuthorizationValidatableWithRequestRules; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; class AuthorizationServer extends OAuth2AuthorizationServer @@ -51,6 +52,7 @@ public function __construct( Key|string $encryptionKey, ?ResponseTypeInterface $responseType = null, ?RequestRulesManager $requestRulesManager = null, + protected readonly ?LoggerService $loggerService = null, ) { parent::__construct( $clientRepository, @@ -77,10 +79,12 @@ public function __construct( */ public function validateAuthorizationRequest(ServerRequestInterface $request): OAuth2AuthorizationRequest { + $this->loggerService?->debug('AuthorizationServer::validateAuthorizationRequest'); + $rulesToExecute = [ StateRule::class, - ClientIdRule::class, - RedirectUriRule::class, + ClientRule::class, + ClientRedirectUriRule::class, ]; try { @@ -91,27 +95,68 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O [HttpMethodsEnum::GET, HttpMethodsEnum::POST], ); } catch (OidcServerException $exception) { - $reason = sprintf("%s %s", $exception->getMessage(), $exception->getHint() ?? ''); + $reason = sprintf( + "AuthorizationServer: %s %s", + $exception->getMessage(), + $exception->getHint() ?? '', + ); + $this->loggerService?->error($reason); throw new BadRequest($reason); } + $this->loggerService?->debug( + 'AuthorizationServer: Result bag validated', + ['rulesToExecute' => $rulesToExecute], + ); + // state and redirectUri is used here, so we can return HTTP redirect error in case of invalid response_type. /** @var ?string $state */ $state = $resultBag->getOrFail(StateRule::class)->getValue(); /** @var string $redirectUri */ - $redirectUri = $resultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $resultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); foreach ($this->enabledGrantTypes as $grantType) { + $this->loggerService?->debug( + 'AuthorizationServer: Checking if grant type can respond to authorization request: ' . + $grantType::class, + ); if ($grantType->canRespondToAuthorizationRequest($request)) { + $this->loggerService?->debug( + 'AuthorizationServer: Grant type can respond to authorization request: ' . + $grantType::class, + ); + if (! $grantType instanceof AuthorizationValidatableWithRequestRules) { + $this->loggerService?->error( + 'AuthorizationServer: grant type must be validatable with ' . + 'already validated result bag: ' . $grantType::class, + ); throw OidcServerException::serverError('grant type must be validatable with already validated ' . 'result bag'); } + $this->loggerService?->debug( + sprintf( + 'AuthorizationServer: Grant type class: %s, identifier: %s ', + $grantType::class, + $grantType->getIdentifier(), + ), + ); + return $grantType->validateAuthorizationRequestWithRequestRules($request, $resultBag); + } else { + $this->loggerService?->debug( + 'AuthorizationServer: Grant type can NOT respond to ' . + 'authorization request: ' . $grantType::class, + ); } } + $this->loggerService?->error( + 'AuthorizationServer: Not a single registered grant type can respond to authorization ' . + 'request.', + ['requestQueryParams' => $request->getQueryParams()], + ); throw OidcServerException::unsupportedResponseType($redirectUri, $state); } diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index dfaac1cf..5693e220 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -21,13 +21,18 @@ use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use LogicException; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; +use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; +use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface; +use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\AuthCodeRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; @@ -38,15 +43,19 @@ use SimpleSAML\Module\oidc\Server\Grants\Traits\IssueAccessTokenTrait; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; +use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AuthorizationDetailsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IssuerStateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; @@ -59,6 +68,7 @@ use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\NonceResponseTypeInterface; use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\SessionIdResponseTypeInterface; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -165,6 +175,7 @@ public function __construct( protected AuthCodeEntityFactory $authCodeEntityFactory, protected RefreshTokenIssuer $refreshTokenIssuer, protected Helpers $helpers, + protected LoggerService $loggerService, ) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); @@ -194,6 +205,8 @@ public function __construct( */ public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool { + $this->loggerService->debug('AuthCodeGrant::canRespondToAuthorizationRequest'); + $requestParams = $this->requestParamsResolver->getAllBasedOnAllowedMethods( $request, $this->allowedAuthorizationHttpMethods, @@ -267,8 +280,7 @@ public function completeOidcAuthorizationRequest( $authorizationRequest->getClient(), $user->getIdentifier(), $finalRedirectUri, - $authorizationRequest->getScopes(), - $authorizationRequest->getNonce(), + $authorizationRequest, ); $payload = [ @@ -285,6 +297,8 @@ public function completeOidcAuthorizationRequest( 'claims' => $authorizationRequest->getClaims(), 'acr' => $authorizationRequest->getAcr(), 'session_id' => $authorizationRequest->getSessionId(), + // Do not add anything else to the payload, as it will make it dangerously long to send it as a query + // parameter. Use storage instead. ]; $jsonPayload = json_encode($payload, JSON_THROW_ON_ERROR); @@ -304,7 +318,6 @@ public function completeOidcAuthorizationRequest( } /** - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException */ @@ -313,8 +326,7 @@ protected function issueOidcAuthCode( OAuth2ClientEntityInterface $client, string $userIdentifier, string $redirectUri, - array $scopes = [], - ?string $nonce = null, + AuthorizationRequest $authorizationRequest, ): AuthCodeEntityInterface { $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; @@ -322,16 +334,25 @@ protected function issueOidcAuthCode( throw OidcServerException::serverError('Unexpected auth code repository entity type.'); } + $flowType = $authorizationRequest->isVciRequest() ? + FlowTypeEnum::VciAuthorizationCode : + FlowTypeEnum::OidcAuthorizationCode; + while ($maxGenerationAttempts-- > 0) { try { $authCode = $this->authCodeEntityFactory->fromData( $this->generateUniqueIdentifier(), $client, - $scopes, + $authorizationRequest->getScopes(), (new DateTimeImmutable())->add($authCodeTTL), $userIdentifier, $redirectUri, - $nonce, + $authorizationRequest->getNonce(), + $authorizationRequest->getIssuerState(), + flowTypeEnum: $flowType, + authorizationDetails: $authorizationRequest->getAuthorizationDetails(), + boundClientId: $authorizationRequest->getBoundClientId(), + boundRedirectUri: $authorizationRequest->getBoundRedirectUri(), ); $this->authCodeRepository->persistNewAuthCode($authCode); @@ -386,13 +407,98 @@ public function respondToAccessTokenRequest( // OAuth2 implementation //[$clientId] = $this->getClientCredentials($request); + $this->loggerService->debug( + 'AuthCodeGrant::respondToAccessTokenRequest', + $this->requestParamsResolver->getAllBasedOnAllowedMethods($request, $this->allowedTokenHttpMethods), + ); + + $encryptedAuthCode = $this->getRequestParameter('code', $request); + + if ($encryptedAuthCode === null) { + $this->loggerService->debug('Code parameter not provided.'); + throw OAuthServerException::invalidRequest('code'); + } + + try { + /** + * @noinspection PhpUndefinedClassInspection + * @psalm-var AuthCodePayloadObject $authCodePayload + */ + $authCodePayload = json_decode($this->decrypt($encryptedAuthCode), null, 512, JSON_THROW_ON_ERROR); + } catch (LogicException $e) { + throw OAuthServerException::invalidRequest('code', 'Cannot decrypt the authorization code', $e); + } + + if (!property_exists($authCodePayload, 'auth_code_id')) { + throw OAuthServerException::invalidRequest('code', 'Authorization code malformed'); + } + + if (! is_a($this->authCodeRepository, AuthCodeRepository::class)) { + throw OidcServerException::serverError('Unexpected auth code repository entity type.'); + } + + $storedAuthCodeEntity = $this->authCodeRepository->findById($authCodePayload->auth_code_id); + + if ($storedAuthCodeEntity === null) { + throw OAuthServerException::invalidGrant('Authorization code not found'); + } + + // Client used during authorization request. + $authorizationClientEntity = $storedAuthCodeEntity->getClient(); + + if (! $authorizationClientEntity instanceof ClientEntity) { + throw OidcServerException::serverError('Unexpected Client Entity instance.'); + } + $rulesToExecute = [ - ClientIdRule::class, - RedirectUriRule::class, - ClientAuthenticationRule::class, CodeVerifierRule::class, ]; + if (! $authorizationClientEntity->isGeneric()) { + $this->loggerService->debug('Executing standard rules for non-generic clients.'); + $rulesToExecute = [ + ClientRule::class, + ClientRedirectUriRule::class, + ClientAuthenticationRule::class, + ...$rulesToExecute, + ]; + } else { + $this->loggerService->debug('Generic client encountered. Checking for authorization bound params.'); + // We used generic client in the flow, so check for bound client_id and redirect_uri. + // Currently used client_id and redirect_uri must be the same as in authorization request. + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $this->allowedTokenHttpMethods, + ); + + // For now, we require client_id, however, in the future this will have to be resolved based on used + // client authentication... + if (! $clientId) { + throw OidcServerException::invalidRequest('client_id'); + } + + if ($clientId !== $storedAuthCodeEntity->getBoundClientId()) { + throw OAuthServerException::invalidGrant('Authorization code not intended for this client_id.'); + } + + $redirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::RedirectUri->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (! $redirectUri) { + throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); + } + + if ($redirectUri !== $storedAuthCodeEntity->getBoundRedirectUri()) { + throw OAuthServerException::invalidGrant('Authorization code not intended for this redirect_uri.'); + } + + $this->requestRulesManager->predefineResult(new Result(ClientRule::class, $authorizationClientEntity)); + } + $resultBag = $this->requestRulesManager->check( $request, $rulesToExecute, @@ -401,9 +507,15 @@ public function respondToAccessTokenRequest( ); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $authorizationClientEntity->isGeneric() ? + $authorizationClientEntity : + $resultBag->getOrFail(ClientRule::class)->getValue(); + /** @var ?string $clientAuthenticationParam */ - $clientAuthenticationParam = $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); + $clientAuthenticationParam = $authorizationClientEntity->isGeneric() ? + null : + $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); + /** @var ?string $codeVerifier */ $codeVerifier = $resultBag->getOrFail(CodeVerifierRule::class)->getValue(); @@ -429,30 +541,15 @@ public function respondToAccessTokenRequest( // $this->validateClient($request); // } - $encryptedAuthCode = $this->getRequestParameter('code', $request); - - if ($encryptedAuthCode === null) { - throw OAuthServerException::invalidRequest('code'); - } - - try { - /** - * @noinspection PhpUndefinedClassInspection - * @psalm-var AuthCodePayloadObject $authCodePayload - */ - $authCodePayload = json_decode($this->decrypt($encryptedAuthCode), null, 512, JSON_THROW_ON_ERROR); + $this->validateAuthorizationCode($authCodePayload, $client, $request, $storedAuthCodeEntity); - $this->validateAuthorizationCode($authCodePayload, $client, $request); + $scopes = $this->scopeRepository->finalizeScopes( + $this->validateScopes($authCodePayload->scopes), + $this->getIdentifier(), + $client, + $authCodePayload->user_id, + ); - $scopes = $this->scopeRepository->finalizeScopes( - $this->validateScopes($authCodePayload->scopes), - $this->getIdentifier(), - $client, - $authCodePayload->user_id, - ); - } catch (LogicException $e) { - throw OAuthServerException::invalidRequest('code', 'Cannot decrypt the authorization code', $e); - } // OAuth2 implementation // $codeVerifier = $this->getRequestParameter('code_verifier', $request); @@ -516,6 +613,11 @@ public function respondToAccessTokenRequest( $scopes, $authCodePayload->auth_code_id, $claims, + $storedAuthCodeEntity->getFlowTypeEnum(), + $storedAuthCodeEntity->getAuthorizationDetails(), + $storedAuthCodeEntity->getBoundClientId(), + $storedAuthCodeEntity->getBoundRedirectUri(), + $storedAuthCodeEntity->getIssuerState(), ); $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); $responseType->setAccessToken($accessToken); @@ -563,9 +665,6 @@ public function respondToAccessTokenRequest( $responseType->setRefreshToken($refreshToken); } } - if (! is_a($this->authCodeRepository, AuthCodeRepositoryInterface::class)) { - throw OidcServerException::serverError('Unexpected auth code repository entity type.'); - } // Revoke used auth code $this->authCodeRepository->revokeAuthCode($authCodePayload->auth_code_id); @@ -586,20 +685,13 @@ protected function validateAuthorizationCode( object $authCodePayload, OAuth2ClientEntityInterface $client, ServerRequestInterface $request, + AuthCodeEntity $storedAuthCodeEntity, ): void { /** * @noinspection PhpUndefinedClassInspection * @psalm-var AuthCodePayloadObject $authCodePayload */ - if (!property_exists($authCodePayload, 'auth_code_id')) { - throw OAuthServerException::invalidRequest('code', 'Authorization code malformed'); - } - - if (! is_a($this->authCodeRepository, AuthCodeRepositoryInterface::class)) { - throw OidcServerException::serverError('Unexpected auth code repository entity type.'); - } - if (! is_a($this->accessTokenRepository, AccessTokenRepositoryInterface::class)) { throw OidcServerException::serverError('Unexpected access token repository entity type.'); } @@ -612,7 +704,7 @@ protected function validateAuthorizationCode( throw OAuthServerException::invalidGrant('Authorization code has expired'); } - if ($this->authCodeRepository->isAuthCodeRevoked($authCodePayload->auth_code_id) === true) { + if ($storedAuthCodeEntity->isRevoked()) { // Code is reused, all related tokens must be revoked, per https://tools.ietf.org/html/rfc6749#section-4.1.2 $this->accessTokenRepository->revokeByAuthCodeId($authCodePayload->auth_code_id); $this->refreshTokenRepository->revokeByAuthCodeId($authCodePayload->auth_code_id); @@ -645,7 +737,10 @@ public function validateAuthorizationRequestWithRequestRules( ServerRequestInterface $request, ResultBagInterface $resultBag, ): OAuth2AuthorizationRequest { + $this->loggerService->debug('AuthCodeGrant::validateAuthorizationRequestWithRequestRules'); + $rulesToExecute = [ + ClientIdRule::class, RequestObjectRule::class, PromptRule::class, MaxAgeRule::class, @@ -656,17 +751,25 @@ public function validateAuthorizationRequestWithRequestRules( RequiredOpenIdScopeRule::class, CodeChallengeRule::class, CodeChallengeMethodRule::class, + IssuerStateRule::class, + AuthorizationDetailsRule::class, ]; // Since we have already validated redirect_uri, and we have state, make it available for other checkers. $this->requestRulesManager->predefineResultBag($resultBag); /** @var string $redirectUri */ - $redirectUri = $resultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $resultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $resultBag->getOrFail(StateRule::class)->getValue(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $resultBag->getOrFail(ClientRule::class)->getValue(); + + $this->loggerService->debug('AuthCodeGrant: Resolved data:', [ + 'redirectUri' => $redirectUri, + 'state' => $state, + 'clientId' => $client->getIdentifier(), + ]); // Some rules have to have certain things available in order to work properly... $this->requestRulesManager->setData('default_scope', $this->defaultScope); @@ -679,9 +782,13 @@ public function validateAuthorizationRequestWithRequestRules( $this->allowedAuthorizationHttpMethods, ); + $this->loggerService->debug('AuthCodeGrant: executed rules.', ['rulesToExecute' => $rulesToExecute]); + /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ $scopes = $resultBag->getOrFail(ScopeRule::class)->getValue(); + $this->loggerService->debug('AuthCodeGrant: Resolved scopes: ', ['scopes' => $scopes]); + $oAuth2AuthorizationRequest = new OAuth2AuthorizationRequest(); $oAuth2AuthorizationRequest->setClient($client); @@ -696,17 +803,46 @@ public function validateAuthorizationRequestWithRequestRules( /** @var ?string $codeChallenge */ $codeChallenge = $resultBag->getOrFail(CodeChallengeRule::class)->getValue(); if ($codeChallenge) { + $this->loggerService->debug('AuthCodeGrant: Code challenge: ', [ + 'codeChallenge' => $codeChallenge, + ]); /** @var string $codeChallengeMethod */ $codeChallengeMethod = $resultBag->getOrFail(CodeChallengeMethodRule::class)->getValue(); $oAuth2AuthorizationRequest->setCodeChallenge($codeChallenge); $oAuth2AuthorizationRequest->setCodeChallengeMethod($codeChallengeMethod); + } else { + $this->loggerService->debug('AuthCodeGrant: No code challenge present.'); } - if (! $this->isOidcCandidate($oAuth2AuthorizationRequest)) { + $isOidcCandidate = $this->isOidcCandidate($oAuth2AuthorizationRequest); + + + + $this->loggerService->debug('AuthCodeGrant: Is OIDC candidate: ', [ + 'isOidcCandidate' => $isOidcCandidate, + ]); + + $isVciAuthorizationCodeRequest = $this->requestParamsResolver->isVciAuthorizationCodeRequest( + $request, + $this->allowedAuthorizationHttpMethods, + ); + + $this->loggerService->debug('AuthCodeGrant: Is VCI authorization code request: ', [ + 'isVciAuthorizationCodeRequest' => $isVciAuthorizationCodeRequest, + ]); + + + if ( + (! $isOidcCandidate) && + (! $isVciAuthorizationCodeRequest) + ) { + $this->loggerService->debug('Not an OIDC nor VCI request, returning as OAuth2 request.'); return $oAuth2AuthorizationRequest; } + $this->loggerService->debug('AuthCodeGrant: OIDC or VCI request, continuing with request setup.'); + $authorizationRequest = AuthorizationRequest::fromOAuth2AuthorizationRequest($oAuth2AuthorizationRequest); $nonce = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( @@ -714,16 +850,19 @@ public function validateAuthorizationRequestWithRequestRules( $request, $this->allowedAuthorizationHttpMethods, ); + $this->loggerService->debug('AuthCodeGrant: Nonce: ', ['nonce' => $nonce]); if ($nonce !== null) { $authorizationRequest->setNonce($nonce); } $maxAge = $resultBag->get(MaxAgeRule::class); + $this->loggerService->debug('AuthCodeGrant: MaxAge: ', ['maxAge' => $maxAge]); if (null !== $maxAge) { $authorizationRequest->setAuthTime((int) $maxAge->getValue()); } $requestClaims = $resultBag->get(RequestedClaimsRule::class); + $this->loggerService->debug('AuthCodeGrant: Requested claims: ', ['requestClaims' => $requestClaims]); if (null !== $requestClaims) { /** @var ?array $requestClaimValues */ $requestClaimValues = $requestClaims->getValue(); @@ -734,8 +873,76 @@ public function validateAuthorizationRequestWithRequestRules( /** @var array|null $acrValues */ $acrValues = $resultBag->getOrFail(AcrValuesRule::class)->getValue(); + $this->loggerService->debug('AuthCodeGrant: ACR values: ', ['acrValues' => $acrValues]); $authorizationRequest->setRequestedAcrValues($acrValues); + + $authorizationRequest->setIsVciRequest($isVciAuthorizationCodeRequest); + $flowType = $isVciAuthorizationCodeRequest ? + FlowTypeEnum::VciAuthorizationCode : FlowTypeEnum::OidcAuthorizationCode; + $this->loggerService->debug('AuthCodeGrant: FlowType: ', ['flowType' => $flowType]); + $authorizationRequest->setFlowType($flowType); + + /** @var ?string $issuerState */ + $issuerState = $resultBag->get(IssuerStateRule::class)?->getValue(); + $this->loggerService->debug('AuthCodeGrant: Issuer state: ', ['issuerState' => $issuerState]); + $authorizationRequest->setIssuerState($issuerState); + + /** @var ?array $authorizationDetails */ + $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); + $this->loggerService->debug( + 'AuthCodeGrant: Authorization details: ', + ['authorizationDetails' => $authorizationDetails], + ); + $authorizationRequest->setAuthorizationDetails($authorizationDetails); + + // TODO This is a band-aid fix for having credential claims in the userinfo endpoint when + // only VCI authorizationDetails are supplied. This requires configuring a matching OIDC scope + // that has all the credential type claims as well. + if (is_array($authorizationDetails)) { + /** @psalm-suppress MixedAssignment */ + foreach ($authorizationDetails as $authorizationDetail) { + if ( + is_array($authorizationDetail) && + (isset($authorizationDetail['type'])) && + ($authorizationDetail['type']) === 'openid_credential' + ) { + /** @psalm-suppress MixedAssignment */ + $credentialConfigurationId = $authorizationDetail['credential_configuration_id'] ?? null; + if (is_string($credentialConfigurationId)) { + $scopes[] = new ScopeEntity($credentialConfigurationId); + } + } + } + $this->loggerService->debug('authorizationDetails Resolved Scopes: ', ['scopes' => $scopes]); + $authorizationRequest->setScopes($scopes); + } + + // Check if we are using a generic client for this request. This can happen for non-registered clients + // in VCI flows. This can be removed once the VCI clients (wallets) are properly registered using DCR. + if ($client->isGeneric()) { + $this->loggerService->debug( + 'AuthCodeGrant: Generic client is used for authorization request.', + ['genericClientId' => $client->getIdentifier()], + ); + // The generic client was used. Make sure to store actually used client_id and redirect_uri params. + /** @var string $clientIdParam */ + $clientIdParam = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $this->loggerService->debug( + 'AuthCodeGrant: Binding client_id param to request: ', + ['clientIdParam' => $clientIdParam], + ); + $authorizationRequest->setBoundClientId($clientIdParam); + + $this->loggerService->debug( + 'AuthCodeGrant: Binding redirect_uri param to request: ', + ['redirectUri' => $redirectUri], + ); + $authorizationRequest->setBoundRedirectUri($redirectUri); + } + + $this->loggerService->debug('AuthCodeGrant: Finished setting up authorization request.'); + return $authorizationRequest; } diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index 4e2026bc..fc088e97 100644 --- a/src/Server/Grants/ImplicitGrant.php +++ b/src/Server/Grants/ImplicitGrant.php @@ -24,10 +24,10 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; @@ -138,11 +138,11 @@ public function validateAuthorizationRequestWithRequestRules( $this->requestRulesManager->predefineResultBag($resultBag); /** @var string $redirectUri */ - $redirectUri = $resultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $resultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $resultBag->getOrFail(StateRule::class)->getValue(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $resultBag->getOrFail(ClientRule::class)->getValue(); // Some rules need certain things available in order to work properly... $this->requestRulesManager->setData('default_scope', $this->defaultScope); diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php new file mode 100644 index 00000000..5c7e0a24 --- /dev/null +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -0,0 +1,290 @@ +value; + } + + /** + * Reimplemented to disable authz requests (code is pre-authorized). + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @return bool + */ + public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool + { + return false; + } + + /** + * Check if the authorization request is OIDC candidate (can respond with ID token). + */ + public function isOidcCandidate( + OAuth2AuthorizationRequest $authorizationRequest, + ): bool { + return false; + } + + /** + * @inheritDoc + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \JsonException + */ + public function completeAuthorizationRequest( + OAuth2AuthorizationRequest $authorizationRequest, + ): ResponseTypeInterface { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * This is reimplementation of OAuth2 completeAuthorizationRequest method with addition of nonce handling. + * + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + * @throws \JsonException + */ + public function completeOidcAuthorizationRequest( + AuthorizationRequest $authorizationRequest, + ): RedirectResponse { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + */ + protected function issueOidcAuthCode( + DateInterval $authCodeTTL, + OAuth2ClientEntityInterface $client, + string $userIdentifier, + string $redirectUri, + AuthorizationRequest $authorizationRequest, + ): AuthCodeEntityInterface { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * Reimplementation for Pre-authorized Code. + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \League\OAuth2\Server\ResponseTypes\ResponseTypeInterface $responseType + * @param \DateInterval $accessTokenTTL + * + * @return \League\OAuth2\Server\ResponseTypes\ResponseTypeInterface + * + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \JsonException + * @throws \Throwable + * + */ + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + DateInterval $accessTokenTTL, + ): ResponseTypeInterface { + + // TODO mivanci client authentication? + + $this->loggerService->debug( + 'PreAuthCodeGrant::respondToAccessTokenRequest: Request parameters: ', + $this->requestParamsResolver->getAllFromRequest($request), + ); + + $preAuthorizedCodeId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::PreAuthorizedCode->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (empty($preAuthorizedCodeId)) { + $this->loggerService->error('Empty pre-authorized code ID.'); + throw OidcServerException::invalidRequest(ParamsEnum::PreAuthorizedCode->value); + } + + if (!is_a($this->authCodeRepository, AuthCodeRepository::class)) { + throw OidcServerException::serverError('Unexpected auth code repository entity type.'); + } + + $preAuthorizedCode = $this->authCodeRepository->findById($preAuthorizedCodeId); + + if ( + is_null($preAuthorizedCode) || + !is_a($preAuthorizedCode, AuthCodeEntity::class) + ) { + $this->loggerService->error('Invalid pre-authorized code ID. Value was: ' . $preAuthorizedCodeId); + throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); + } + + $client = $preAuthorizedCode->getClient(); + + $this->validateAuthorizationCode($preAuthorizedCode, $client, $request, $preAuthorizedCode); + + // Validate Transaction Code. + if (($preAuthorizedCodeTxCode = $preAuthorizedCode->getTxCode()) !== null) { + $this->loggerService->debug('Validating transaction code ' . $preAuthorizedCodeTxCode); + $txCodeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::TxCode->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (empty($txCodeParam)) { + $this->loggerService->warning('Empty transaction code parameter.'); + throw OidcServerException::invalidRequest(ParamsEnum::TxCode->value, 'Transaction Code is missing.'); + } + + $this->loggerService->debug('Transaction code parameter value: ' . $txCodeParam); + + if ($preAuthorizedCodeTxCode !== $txCodeParam) { + $this->loggerService->warning( + 'Transaction code parameter value does not match pre-authorized code transaction code.', + ['txCodeParam' => $txCodeParam, 'preAuthorizedCodeTxCode' => $preAuthorizedCodeTxCode,], + ); + throw OidcServerException::invalidRequest(ParamsEnum::TxCode->value, 'Transaction Code is invalid.'); + } + } + + $resultBag = $this->requestRulesManager->check( + $request, + [AuthorizationDetailsRule::class], + false, + $this->allowedTokenHttpMethods, + ); + + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $this->allowedTokenHttpMethods, + ); + + /** @var ?array $authorizationDetails */ + $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); + + // Issue and persist new access token + $accessToken = $this->issueAccessToken( + $accessTokenTTL, + $client, + $preAuthorizedCode->getUserIdentifier() ? (string) $preAuthorizedCode->getUserIdentifier() : null, + [], // TODO mivanci handle scopes + $preAuthorizedCodeId, + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + authorizationDetails: $authorizationDetails, + boundClientId: $clientId, + ); + + $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $responseType->setAccessToken($accessToken); + + + // TODO mivanci revoke pre-authorized code or let it expire only after access token is issued? + // $this->authCodeRepository->revokeAuthCode($preAuthorizedCode); + + return $responseType; + } + + /** + * Reimplementation because of private parent access + * + * @param object $authCodePayload + * @param \League\OAuth2\Server\Entities\ClientEntityInterface $client + * @param \Psr\Http\Message\ServerRequestInterface $request + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function validateAuthorizationCode( + object $authCodePayload, + OAuth2ClientEntityInterface $client, + ServerRequestInterface $request, + AuthCodeEntity $storedAuthCodeEntity, + ): void { + $this->loggerService->debug('PreAuthCodeGrant::validateAuthorizationCode'); + + if (!$storedAuthCodeEntity->isVciPreAuthorized()) { + $this->loggerService->error( + 'Pre-authorized code is not pre-authorized. ID was: ', + ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], + ); + throw OidcServerException::invalidGrant('Pre-authorized code is not pre-authorized.'); + } + + if ($storedAuthCodeEntity->getExpiryDateTime()->getTimestamp() < time()) { + $this->loggerService->error( + 'Pre-authorized code is expired. ID was: ', + ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], + ); + + throw OidcServerException::invalidGrant('Pre-authorized code is expired.'); + } + + if ($storedAuthCodeEntity->isRevoked()) { + $this->loggerService->error( + 'Pre-authorized code is revoked. ID was: ', + ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], + ); + throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); + } + + $this->loggerService->debug('PreAuthCodeGrant::validateAuthorizationCode passed.'); + } + + /** + * @inheritDoc + * @throws \Throwable + */ + public function validateAuthorizationRequestWithRequestRules( + ServerRequestInterface $request, + ResultBagInterface $resultBag, + ): OAuth2AuthorizationRequest { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * @param \League\OAuth2\Server\Entities\AccessTokenEntityInterface $accessToken + * @param string|null $authCodeId + * @return \SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface|null + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + */ + protected function issueRefreshToken( + OAuth2AccessTokenEntityInterface $accessToken, + ?string $authCodeId = null, + ): ?RefreshTokenEntityInterface { + if (! is_a($accessToken, AccessTokenEntityInterface::class)) { + throw OidcServerException::serverError('Unexpected access token entity type.'); + } + + return $this->refreshTokenIssuer->issue( + $accessToken, + $this->refreshTokenTTL, + $authCodeId, + self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS, + ); + } +} diff --git a/src/Server/Grants/Traits/IssueAccessTokenTrait.php b/src/Server/Grants/Traits/IssueAccessTokenTrait.php index 6660ec92..6e8f47b6 100644 --- a/src/Server/Grants/Traits/IssueAccessTokenTrait.php +++ b/src/Server/Grants/Traits/IssueAccessTokenTrait.php @@ -9,6 +9,7 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Grant\AbstractGrant; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; @@ -50,6 +51,11 @@ protected function issueAccessToken( array $scopes = [], ?string $authCodeId = null, ?array $requestedClaims = null, + ?FlowTypeEnum $flowTypeEnum = null, + ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, + ?string $issuerState = null, ): AccessTokenEntityInterface { $maxGenerationAttempts = AbstractGrant::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; @@ -70,6 +76,11 @@ protected function issueAccessToken( $userIdentifier, $authCodeId, $requestedClaims, + flowTypeEnum: $flowTypeEnum, + authorizationDetails: $authorizationDetails, + boundClientId: $boundClientId, + boundRedirectUri: $boundRedirectUri, + issuerState: $issuerState, ); $this->accessTokenRepository->persistNewAccessToken($accessToken); return $accessToken; diff --git a/src/Server/RequestRules/Rules/AcrValuesRule.php b/src/Server/RequestRules/Rules/AcrValuesRule.php index f0f1c0df..7d02cf1f 100644 --- a/src/Server/RequestRules/Rules/AcrValuesRule.php +++ b/src/Server/RequestRules/Rules/AcrValuesRule.php @@ -25,6 +25,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('AcrValuesRule::checkRule'); + $acrValues = [ 'essential' => false, 'values' => [], diff --git a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php new file mode 100644 index 00000000..58470163 --- /dev/null +++ b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php @@ -0,0 +1,139 @@ +debug('AuthorizationDetailsRule::checkRule.'); + + $authorizationDetailsParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::AuthorizationDetails->value, + $request, + $allowedServerRequestMethods, + ); + + if ($authorizationDetailsParam === null) { + $loggerService->debug('AuthorizationDetailsRule: No authorization_details parameter.'); + return null; + } + + $loggerService->debug( + 'AuthorizationDetailsRule: authorization_details parameter value: ' . $authorizationDetailsParam, + ); + + try { + $authorizationDetails = json_decode($authorizationDetailsParam, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $loggerService->error( + 'AuthorizationDetailsRule: Could not JSON decode authorization_details parameter value.', + ); + return null; + } + + if (!is_array($authorizationDetails)) { + $loggerService->error('AuthorizationDetailsRule: authorization_details parameter value is not an array.'); + return null; + } + + if (empty($authorizationDetails)) { + $loggerService->error('AuthorizationDetailsRule: authorization_details parameter value is empty.'); + return null; + } + + // Since we only use AuthorizationDetailsRule for VCI, we will throw as per RAR spec. + // https://www.rfc-editor.org/rfc/rfc9396.html#name-authorization-error-respons + if (! $this->moduleConfig->getVerifiableCredentialEnabled()) { + $loggerService->error('AuthorizationDetailsRule: Rich Authorization Requests are not used by this server.'); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Rich Authorization Requests are not used by this server.', + ); + } + + // Check for known authorization_details and their types. + // Currently, only 'vci' is supported, which defines type as per: + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-authorization-details + foreach ($authorizationDetails as $authorizationDetail) { + if (!is_array($authorizationDetail)) { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value is not an array.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Malformed authorization_details parameter value.', + ); + } + + if (!isset($authorizationDetail['type'])) { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value has no type.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Authorization details parameter value has no type.', + ); + } + + if ($authorizationDetail['type'] !== 'openid_credential') { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value has unknown type.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Authorization details parameter value has unknown type.', + ); + } + + if (!isset($authorizationDetail['credential_configuration_id'])) { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value has no' . + ' credential_configuration_id.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Authorization details parameter value has no credential_configuration_id.', + ); + } + } + + $loggerService->debug( + 'AuthorizationDetailsRule: authorization_details decoded.', + ['authorization_details' => $authorizationDetails,], + ); + + return new Result($this->getKey(), $authorizationDetails); + } +} diff --git a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php index 62b522ca..478d4689 100644 --- a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php +++ b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php @@ -47,7 +47,7 @@ public function checkRule( array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); // We will only perform client authentication if the client type is confidential. if (!$client->isConfidential()) { diff --git a/src/Server/RequestRules/Rules/ClientIdRule.php b/src/Server/RequestRules/Rules/ClientIdRule.php index b377377f..b329c179 100644 --- a/src/Server/RequestRules/Rules/ClientIdRule.php +++ b/src/Server/RequestRules/Rules/ClientIdRule.php @@ -5,47 +5,19 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Error\ConfigurationError; -use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; -use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; -use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; -use SimpleSAML\Module\oidc\Forms\ClientForm; -use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; -use SimpleSAML\Module\oidc\Utils\JwksResolver; -use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; -use SimpleSAML\OpenID\Federation; -use Throwable; +/** + * Resolve a client instance based on a client_id or request object. + */ class ClientIdRule extends AbstractRule { - protected const KEY_REQUEST_OBJECT_JTI = 'request_object_jti'; - - public function __construct( - RequestParamsResolver $requestParamsResolver, - Helpers $helpers, - protected ClientRepository $clientRepository, - protected ModuleConfig $moduleConfig, - protected ClientEntityFactory $clientEntityFactory, - protected Federation $federation, - protected JwksResolver $jwksResolver, - protected FederationParticipationValidator $federationParticipationValidator, - protected ?FederationCache $federationCache = null, - ) { - parent::__construct($requestParamsResolver, $helpers); - } - /** * @inheritDoc * @throws \JsonException @@ -69,8 +41,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ClientIdRule::checkRule'); + /** @var ?string $clientId */ - $clientId = $this->requestParamsResolver->getBasedOnAllowedMethods( + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::ClientId->value, $request, $allowedServerRequestMethods, @@ -80,172 +54,6 @@ public function checkRule( throw OidcServerException::invalidRequest('client_id'); } - $client = $this->clientRepository->getClientEntity($clientId); - - if ($client instanceof ClientEntityInterface) { - return new Result($this->getKey(), $client); - } - - // If federation capabilities are not enabled, we don't have anything else to do. - if ($this->moduleConfig->getFederationEnabled() === false) { - throw OidcServerException::invalidClient($request); - } - - // Federation is enabled. - // Check if we have a request object available. If not, we don't have anything else to do. - $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::Request->value, - $request, - $allowedServerRequestMethods, - ); - - if (is_null($requestParam)) { - throw OidcServerException::invalidClient($request); - } - - // We have a request object available. We must verify that it is the one compatible with OpenID Federation - // specification (not only Core specification). - try { - $requestObject = $this->requestParamsResolver->parseFederationRequestObjectToken($requestParam); - } catch (Throwable $exception) { - throw OidcServerException::invalidRequest( - ParamsEnum::Request->value, - 'Request object error: ' . $exception->getMessage(), - $exception, - ); - } - - // We have a Federation compatible Request Object. - // The Audience (aud) value MUST be or include the OP's Issuer Identifier URL. - (in_array($this->moduleConfig->getIssuer(), $requestObject->getAudience(), true)) || - throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Invalid audience.'); - - // Check for reuse of the Request Object. Request Object MUST only be used once (by OpenID Federation spec). - if ($this->federationCache) { - ($this->federationCache->has(self::KEY_REQUEST_OBJECT_JTI, $requestObject->getJwtId()) === false) - || throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Request Object reused.'); - } - - $clientEntityId = $requestObject->getIssuer(); - // Make sure that the Client ID is valid URL. - (preg_match(ClientForm::REGEX_HTTP_URI_PATH, $requestObject->getIssuer())) || - throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Client ID is not valid URI.'); - - // We are ready to resolve trust chain. - // TODO mivanci v7 Request Object can contain trust_chain claim, so also implement resolving using that claim. - // Note that this is only possible if we have JWKS configured for common TA, so we can check TA Configuration - // signature. - try { - $trustChain = $this->federation->trustChainResolver()->for( - $clientEntityId, - $this->moduleConfig->getFederationTrustAnchorIds(), - )->getShortest(); - } catch (ConfigurationError $exception) { - throw OidcServerException::serverError( - 'invalid OIDC configuration: ' . $exception->getMessage(), - $exception, - ); - } catch (Throwable $exception) { - throw OidcServerException::invalidTrustChain( - 'error while trying to resolve trust chain: ' . $exception->getMessage(), - null, - $exception, - ); - } - - // Validate TA with locally saved JWKS, if available. - $trustAnchorEntityConfiguration = $trustChain->getResolvedTrustAnchor(); - $localTrustAnchorJwksJson = $this->moduleConfig - ->getTrustAnchorJwksJson($trustAnchorEntityConfiguration->getIssuer()); - if (!is_null($localTrustAnchorJwksJson)) { - /** @psalm-suppress MixedArgument */ - $localTrustAnchorJwks = $this->federation->helpers()->json()->decode($localTrustAnchorJwksJson); - if (!is_array($localTrustAnchorJwks)) { - throw OidcServerException::serverError('Unexpected JWKS format.'); - } - $trustAnchorEntityConfiguration->verifyWithKeySet($localTrustAnchorJwks); - } - - $clientFederationEntity = $trustChain->getResolvedLeaf(); - - if ($clientFederationEntity->getIssuer() !== $clientEntityId) { - throw OidcServerException::invalidTrustChain( - 'Client entity ID mismatch in request object and configuration statement.', - ); - } - try { - $clientMetadata = $trustChain->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty); - } catch (Throwable $exception) { - throw OidcServerException::invalidTrustChain( - 'Error while trying to resolve relying party metadata: ' . $exception->getMessage(), - null, - $exception, - ); - } - - if (is_null($clientMetadata)) { - throw OidcServerException::invalidTrustChain('No relying party metadata available.'); - } - - // We have client metadata resolved. Check if the client exists in storage, as it may be previously registered - // but marked as expired. - $existingClient = $this->clientRepository->findById($clientEntityId); - - if ($existingClient && ($existingClient->isEnabled() === false)) { - throw OidcServerException::accessDenied('Client is disabled.'); - } - - if ($existingClient && ($existingClient->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic)) { - throw OidcServerException::accessDenied( - 'Unexpected existing client registration type: ' . $existingClient->getRegistrationType()->value, - ); - } - - // Resolve client registration metadata - $registrationClient = $this->clientEntityFactory->fromRegistrationData( - $clientMetadata, - RegistrationTypeEnum::FederatedAutomatic, - $this->helpers->dateTime()->getFromTimestamp($trustChain->getResolvedExpirationTime()), - $existingClient, - $clientEntityId, - $clientFederationEntity->getJwks()->getValue(), - $request, - ); - - ($clientJwks = $this->jwksResolver->forClient($registrationClient)) || - throw OidcServerException::accessDenied('Client JWKS not available.'); - - // Verify signature on Request Object using client JWKS. - $requestObject->verifyWithKeySet($clientJwks); - - // Check if federation participation is limited by Trust Marks. - if ( - $this->moduleConfig->isFederationParticipationLimitedByTrustMarksFor( - $trustAnchorEntityConfiguration->getIssuer(), - ) - ) { - $this->federationParticipationValidator->byTrustMarksFor($trustChain); - } - - // All is verified, We can persist (new) client registration. - if ($existingClient) { - $this->clientRepository->update($registrationClient); - } else { - $this->clientRepository->add($registrationClient); - } - - // Mark Request Object as used. - $this->federationCache?->set( - $requestObject->getJwtId(), - $this->helpers->dateTime()->getSecondsToExpirationTime($requestObject->getExpirationTime()), - self::KEY_REQUEST_OBJECT_JTI, - $requestObject->getJwtId(), - ); - - // We will also update result for RequestParameterRule (inject value from here), since the request object - // is already resolved. - $currentResultBag->add(new Result(RequestObjectRule::class, $requestObject->getPayload())); - - return new Result($this->getKey(), $registrationClient); + return new Result($this->getKey(), $clientId); } } diff --git a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php new file mode 100644 index 00000000..ddc67e4e --- /dev/null +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -0,0 +1,123 @@ +debug('RedirectUriRule::checkRule'); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + if (! $client instanceof ClientEntityInterface) { + throw new LogicException('Can not check redirect_uri, client is not ClientEntityInterface.'); + } + + $redirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::RedirectUri->value, + $request, + $allowedServerRequestMethods, + ); + + // On OAuth2 redirect_uri is optional if there is only one registered, however we will always require it + // since this is OIDC oriented package and in OIDC this parameter is required. + if ($redirectUri === null) { + throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); + } + + $clientRedirectUri = $client->getRedirectUri(); + + try { + if (is_string($clientRedirectUri) && (strcmp($clientRedirectUri, $redirectUri) !== 0)) { + throw OidcServerException::invalidClient($request); + } elseif ( + is_array($clientRedirectUri) && + in_array($redirectUri, $clientRedirectUri, true) === false + ) { + throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); + } + } catch (\Throwable $exception) { + if ( + $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && + $this->moduleConfig->getVerifiableCredentialEnabled() && + $this->moduleConfig->getAllowNonRegisteredClientsForVci() + ) { + $loggerService->debug( + 'RedirectUriRule: Verifiable Credential capabilities with non-registered clients are enabled. ' . + 'Checking for allowed redirect URI prefixes.', + ); + + /** @psalm-suppress MixedAssignment */ + foreach ( + $this->moduleConfig->getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci( + ) as $clientRedirectUriPrefix + ) { + if (str_starts_with($redirectUri, (string)$clientRedirectUriPrefix)) { + $loggerService->debug( + 'RedirectUriRule: Redirect URI param starts with allowed redirect URI prefix, continuing.', + ['redirect_uri' => $redirectUri, 'redirect_uri_prefix' => $clientRedirectUriPrefix], + ); + + return new Result($this->getKey(), $redirectUri); + } + } + + $loggerService->error( + 'RedirectUriRule: Redirect URI param does not start with allowed redirect URI prefix, stopping.', + ['redirect_uri' => $redirectUri], + ); + + throw $exception; + } else { + $loggerService->debug( + 'RedirectUriRule: Verifiable Credential capabilities with non-registered clients are not enabled. ', + ); + $loggerService->error( + 'RedirectUriRule: Redirect URI param does not correspond to the client redirect URI.', + ['redirect_uri' => $redirectUri, 'client_redirect_uri' => $clientRedirectUri], + ); + throw $exception; + } + } + + $loggerService->debug( + 'RedirectUriRule: Redirect URI param corresponds to the client redirect URI.', + ['redirect_uri' => $redirectUri, 'client_redirect_uri' => $clientRedirectUri], + ); + + return new Result($this->getKey(), $redirectUri); + } +} diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php new file mode 100644 index 00000000..0d89c2ae --- /dev/null +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -0,0 +1,368 @@ +debug('ClientRule::checkRule.'); + + /** @var ?string $clientId */ + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $allowedServerRequestMethods, + ) ?? $request->getServerParams()['PHP_AUTH_USER'] ?? null; + + if ($clientId === null) { + $this->loggerService->debug('ClientRule: Client ID not found in request parameters or PHP_AUTH_USER.'); + + throw OidcServerException::invalidRequest('client_id'); + } + + $this->loggerService->debug( + 'ClientRule: Client ID: ' . $clientId, + ); + + $client = $this->clientRepository->getClientEntity($clientId); + + if ($client instanceof ClientEntityInterface) { + $this->loggerService->debug( + 'ClientRule: Client found in storage: ' . $client->getIdentifier(), + ); + return new Result($this->getKey(), $client); + } + + // If federation capabilities are not enabled, we don't have anything else to do. + if ($this->moduleConfig->getFederationEnabled()) { + $this->loggerService->debug( + 'ClientRule: Federation capabilities are enabled.', + ); + + $client = $this->resolveFromFederation($request, $allowedServerRequestMethods, $currentResultBag); + + if ($client instanceof ClientEntityInterface) { + $this->loggerService->debug( + 'ClientRule: Client resolved from federation: ' . $client->getIdentifier(), + ); + return new Result($this->getKey(), $client); + } + } else { + $this->loggerService->debug( + 'ClientRule: Federation capabilities are not enabled.', + ); + } + + if ( + $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && + $this->moduleConfig->getVerifiableCredentialEnabled() && + $this->moduleConfig->getAllowNonRegisteredClientsForVci() + ) { + $this->loggerService->debug( + 'ClientRule: Verifiable Credential capabilities with non-registered clients are enabled. ' . + 'Falling back to generic VCI client.', + ); + + return new Result($this->getKey(), $this->clientRepository->getGenericForVci()); + } else { + $this->loggerService->debug( + 'ClientRule: Not a VCI request, or VCI capabilities not enabled, or VCI with non-registered' . + ' clients not enabled.', + ); + } + + $this->loggerService->debug('ClientRule: Client could not be resolved.'); + + throw OidcServerException::invalidClient($request); + } + + /** + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods + */ + public function resolveFromFederation( + ServerRequestInterface $request, + array $allowedMethods, + ResultBagInterface $currentResultBag, + ): ?ClientEntityInterface { + $this->loggerService->debug('ClientRule: Resolving client from federation.'); + // Federation is enabled. + // Check if we have a request object available. If not, we don't have anything else to do. + $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Request->value, + $request, + $allowedMethods, + ); + + if (is_null($requestParam)) { + $this->loggerService->error('ClientRule: No request param available, nothing to do.'); + return null; + } + + $this->loggerService->debug('ClientRule: Request param available.', ['requestParam' => $requestParam]); + + // We have a request object available. We must verify that it is the one compatible with OpenID Federation + // specification (not only Core specification). + try { + $requestObject = $this->requestParamsResolver->parseFederationRequestObjectToken($requestParam); + } catch (Throwable $exception) { + $this->loggerService->error('ClientRule: Request object error: ' . $exception->getMessage()); + return null; + } + + $this->loggerService->debug('ClientRule: Request object parsed successfully.'); + + // We have a Federation-compatible Request Object. + // The Audience (aud) value MUST be or include the OP's Issuer Identifier URL. + if (! in_array($this->moduleConfig->getIssuer(), $requestObject->getAudience(), true)) { + $this->loggerService->error( + 'ClientRule: Request object audience mismatch.', + ['expected' => $this->moduleConfig->getIssuer(), 'actual' => $requestObject->getAudience()], + ); + return null; + } + + // Check for reuse of the Request Object. Request Object MUST only be used once (by OpenID Federation spec). + if ( + $this->federationCache && + $this->federationCache->has(self::KEY_REQUEST_OBJECT_JTI, $requestObject->getJwtId()) + ) { + $this->loggerService->error( + 'ClientRule: Request object reused.', + ['request_object_jti' => $requestObject->getJwtId()], + ); + return null; + } + + $clientEntityId = $requestObject->getIssuer(); + // Make sure that the Client Entity ID is valid URL. + if (!preg_match(ClientForm::REGEX_HTTP_URI_PATH, $clientEntityId)) { + $this->loggerService->error( + 'ClientRule: Client Entity ID is not valid URI.', + ['client_id' => $clientEntityId], + ); + return null; + } + + $this->loggerService->debug('ClientRule: Client Entity ID is valid URI.'); + + // We are ready to resolve trust chain. + // TODO mivanci v7 Request Object can contain trust_chain claim, so also implement resolving using that claim. + // Note that this is only possible if we have JWKS configured for common TA, so we can check TA Configuration + // signature. + try { + $this->loggerService->debug('ClientRule: Resolving trust chain.'); + $trustChain = $this->federation->trustChainResolver()->for( + $clientEntityId, + $this->moduleConfig->getFederationTrustAnchorIds(), + )->getShortest(); + } catch (ConfigurationError $exception) { + $this->loggerService->error('ClientRule: Invalid OIDC configuration: ' . $exception->getMessage()); + return null; + } catch (Throwable $exception) { + $this->loggerService->error( + 'ClientRule: Error while trying to resolve trust chain: ' . $exception->getMessage(), + ); + return null; + } + + // Validate TA with locally saved JWKS, if available. + $trustAnchorEntityConfiguration = $trustChain->getResolvedTrustAnchor(); + $localTrustAnchorJwksJson = $this->moduleConfig + ->getTrustAnchorJwksJson($trustAnchorEntityConfiguration->getIssuer()); + if (!is_null($localTrustAnchorJwksJson)) { + $this->loggerService->debug('ClientRule: Validating TA with locally saved JWKS.'); + /** @psalm-suppress MixedArgument */ + $localTrustAnchorJwks = $this->federation->helpers()->json()->decode($localTrustAnchorJwksJson); + if (!is_array($localTrustAnchorJwks)) { + $this->loggerService->error( + 'ClientRule: Unexpected JWKS format for locally saved Trust Anchor JWKS.', + ); + return null; + } + $trustAnchorEntityConfiguration->verifyWithKeySet($localTrustAnchorJwks); + $this->loggerService->debug('ClientRule: TA with locally saved JWKS validated successfully.'); + } + + $clientFederationEntity = $trustChain->getResolvedLeaf(); + + if ($clientFederationEntity->getIssuer() !== $clientEntityId) { + $this->loggerService->error( + 'Client entity ID mismatch in request object and configuration statement.', + ['expected' => $clientFederationEntity->getIssuer(), 'actual' => $clientEntityId], + ); + } + + try { + $this->loggerService->debug('ClientRule: Resolving relying party metadata.'); + $clientMetadata = $trustChain->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty); + } catch (Throwable $exception) { + $this->loggerService->error( + 'ClientRule: Error while trying to resolve relying party metadata: ' . $exception->getMessage(), + ); + return null; + } + + if (is_null($clientMetadata)) { + $this->loggerService->error('ClientRule: No relying party metadata available.'); + return null; + } + + // We have client metadata resolved. Check if the client exists in storage, as it may be previously registered + // but marked as expired. + $existingClient = $this->clientRepository->findById($clientEntityId); + + if ($existingClient && ($existingClient->isEnabled() === false)) { + $this->loggerService->error('ClientRule: Client is disabled:'); + return null; + } + + if ($existingClient && ($existingClient->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic)) { + $this->loggerService->error( + 'Unexpected existing client registration type: ' . $existingClient->getRegistrationType()->value, + ); + return null; + } + + // Resolve client registration metadata + $registrationClient = $this->clientEntityFactory->fromRegistrationData( + $clientMetadata, + RegistrationTypeEnum::FederatedAutomatic, + $this->helpers->dateTime()->getFromTimestamp($trustChain->getResolvedExpirationTime()), + $existingClient, + $clientEntityId, + $clientFederationEntity->getJwks()->getValue(), + $request, + ); + + $clientJwks = $this->jwksResolver->forClient($registrationClient); + if (!is_array($clientJwks)) { + $this->loggerService->debug('ClientRule: Client JWKS not available.'); + return null; + } + + // Verify signature on Request Object using client JWKS. + try { + $requestObject->verifyWithKeySet($clientJwks); + } catch (JwsException $e) { + $this->loggerService->error( + 'ClientRule: Request object signature verification failed: ' . $e->getMessage(), + ); + return null; + } + + // Check if federation participation is limited by Trust Marks. + if ( + $this->moduleConfig->isFederationParticipationLimitedByTrustMarksFor( + $trustAnchorEntityConfiguration->getIssuer(), + ) + ) { + $this->loggerService->debug('ClientRule: Verifying trust marks for federation participation.'); + try { + $this->federationParticipationValidator->byTrustMarksFor($trustChain); + } catch (Throwable $e) { + $this->loggerService->error( + 'ClientRule: Trust marks for federation participation verification failed: ' . $e->getMessage(), + ); + return null; + } + } + + $this->loggerService->debug('ClientRule: All verified, persisting client registration.'); + + // All is verified, We can persist (new) client registration. + if ($existingClient) { + $this->clientRepository->update($registrationClient); + } else { + $this->clientRepository->add($registrationClient); + } + + // Mark Request Object as used. + try { + $this->federationCache?->set( + $requestObject->getJwtId(), + $this->helpers->dateTime()->getSecondsToExpirationTime($requestObject->getExpirationTime()), + self::KEY_REQUEST_OBJECT_JTI, + $requestObject->getJwtId(), + ); + } catch (Throwable $e) { + $this->loggerService->error( + 'ClientRule: Error while trying to mark request object as used: ' . $e->getMessage(), + ); + } + + // We will also update a result for RequestParameterRule (inject value from here), since the request object + // is already resolved. + $currentResultBag->add(new Result(RequestObjectRule::class, $requestObject->getPayload())); + + return $registrationClient; + } +} diff --git a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php index ed087d3e..33d5f70a 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php @@ -38,8 +38,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('CodeChallengeMethodRule::checkRule'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/CodeChallengeRule.php b/src/Server/RequestRules/Rules/CodeChallengeRule.php index 38a9e431..feb37160 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeRule.php @@ -27,10 +27,12 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('CodeChallengeRule::checkRule'); + /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/CodeVerifierRule.php b/src/Server/RequestRules/Rules/CodeVerifierRule.php index 5f96672d..8b3767eb 100644 --- a/src/Server/RequestRules/Rules/CodeVerifierRule.php +++ b/src/Server/RequestRules/Rules/CodeVerifierRule.php @@ -27,7 +27,7 @@ public function checkRule( array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $codeVerifier = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::CodeVerifier->value, diff --git a/src/Server/RequestRules/Rules/IssuerStateRule.php b/src/Server/RequestRules/Rules/IssuerStateRule.php new file mode 100644 index 00000000..7ba9bf2d --- /dev/null +++ b/src/Server/RequestRules/Rules/IssuerStateRule.php @@ -0,0 +1,36 @@ +requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::IssuerState->value, + $request, + $allowedServerRequestMethods, + ); + + return new Result($this->getKey(), $issuerState); + } +} diff --git a/src/Server/RequestRules/Rules/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index 38c4a809..e5731a7f 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -46,13 +46,15 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('MaxAgeRule::checkRule'); + $requestParams = $this->requestParamsResolver->getAllBasedOnAllowedMethods( $request, $allowedServerRequestMethods, ); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $authSimple = $this->authSimpleFactory->build($client); @@ -61,7 +63,7 @@ public function checkRule( } /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var ?string $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); @@ -94,7 +96,7 @@ public function checkRule( $requestParams, ); - $this->authenticationService->authenticate($client, $loginParams); + $this->authenticationService->authenticateForClient($client, $loginParams); } return new Result($this->getKey(), $lastAuth); diff --git a/src/Server/RequestRules/Rules/PromptRule.php b/src/Server/RequestRules/Rules/PromptRule.php index 60fd38cc..8a994f45 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -47,8 +47,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('PromptRule::checkRule'); + /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $authSimple = $this->authSimpleFactory->build($client); @@ -66,7 +68,7 @@ public function checkRule( throw OAuthServerException::invalidRequest(ParamsEnum::Prompt->value, 'Invalid prompt parameter'); } /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var ?string $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); @@ -88,7 +90,7 @@ public function checkRule( $requestParams, ); - $this->authenticationService->authenticate($client, $loginParams); + $this->authenticationService->authenticateForClient($client, $loginParams); } return null; diff --git a/src/Server/RequestRules/Rules/RedirectUriRule.php b/src/Server/RequestRules/Rules/RedirectUriRule.php deleted file mode 100644 index 22a9f95f..00000000 --- a/src/Server/RequestRules/Rules/RedirectUriRule.php +++ /dev/null @@ -1,61 +0,0 @@ -getOrFail(ClientIdRule::class)->getValue(); - if (! $client instanceof ClientEntityInterface) { - throw new LogicException('Can not check redirect_uri, client is not ClientEntityInterface.'); - } - - $redirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( - ParamsEnum::RedirectUri->value, - $request, - $allowedServerRequestMethods, - ); - - // On OAuth2 redirect_uri is optional if there is only one registered, however we will always require it - // since this is OIDC oriented package and in OIDC this parameter is required. - if ($redirectUri === null) { - throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); - } - - $clientRedirectUri = $client->getRedirectUri(); - if (is_string($clientRedirectUri) && (strcmp($clientRedirectUri, $redirectUri) !== 0)) { - throw OidcServerException::invalidClient($request); - } elseif ( - is_array($clientRedirectUri) && - in_array($redirectUri, $clientRedirectUri, true) === false - ) { - throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); - } - - return new Result($this->getKey(), $redirectUri); - } -} diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index a1f74a24..81c05812 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -38,6 +38,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequestObjectRule::checkRule'); + $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::Request->value, $request, @@ -67,9 +69,9 @@ public function checkRule( // It is protected, we must validate it. /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); diff --git a/src/Server/RequestRules/Rules/RequestedClaimsRule.php b/src/Server/RequestRules/Rules/RequestedClaimsRule.php index d8b27970..3a7d60b3 100644 --- a/src/Server/RequestRules/Rules/RequestedClaimsRule.php +++ b/src/Server/RequestRules/Rules/RequestedClaimsRule.php @@ -37,6 +37,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequestedClaimsRule::checkRule'); + /** @psalm-suppress MixedAssignment We'll check the type. */ $claimsParam = $this->requestParamsResolver->getBasedOnAllowedMethods( ParamsEnum::Claims->value, @@ -56,7 +58,7 @@ public function checkRule( return null; } /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $authorizedClaims = []; foreach ($client->getScopes() as $scope) { diff --git a/src/Server/RequestRules/Rules/RequiredNonceRule.php b/src/Server/RequestRules/Rules/RequiredNonceRule.php index e96529c0..16034d17 100644 --- a/src/Server/RequestRules/Rules/RequiredNonceRule.php +++ b/src/Server/RequestRules/Rules/RequiredNonceRule.php @@ -28,7 +28,7 @@ public function checkRule( array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php index b6695c02..5fa0dc86 100644 --- a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php +++ b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php @@ -26,8 +26,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequiredOpenIdScopeRule::checkRule.'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] $validScopes */ @@ -38,15 +40,29 @@ public function checkRule( fn($scopeEntity) => $scopeEntity->getIdentifier() === 'openid', ); - if (! $isOpenIdScopePresent) { - throw OidcServerException::invalidRequest( - 'scope', - 'Scope openid is required', - null, - $redirectUri, - $state, - $useFragmentInHttpErrorResponses, - ); + $loggerService->debug( + 'RequiredOpenIdScopeRule: Is openid scope present: ', + ['isOpenIdScopePresent' => $isOpenIdScopePresent], + ); + + try { + if (! $isOpenIdScopePresent) { + throw OidcServerException::invalidRequest( + 'scope', + 'Scope openid is required', + null, + $redirectUri, + $state, + $useFragmentInHttpErrorResponses, + ); + } + } catch (\Throwable $e) { + if ($this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods)) { + $loggerService->info('RequiredOpenIdScopeRule: Skippping openid scope check for VCI request.'); + } else { + $loggerService->error('RequiredOpenIdScopeRule: Scope openid is required.'); + throw $e; + } } return new Result($this->getKey(), true); diff --git a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php index ae67f588..ee4188b2 100644 --- a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php +++ b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php @@ -26,12 +26,14 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ScopeOfflineAccessRule::checkRule'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] $validScopes */ $validScopes = $currentResultBag->getOrFail(ScopeRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index e1eb7884..bc6b753c 100644 --- a/src/Server/RequestRules/Rules/ScopeRule.php +++ b/src/Server/RequestRules/Rules/ScopeRule.php @@ -39,8 +39,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ScopeRule::checkRule.'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); /** @var string $defaultScope */ @@ -48,12 +50,16 @@ public function checkRule( /** @var non-empty-string $scopeDelimiterString */ $scopeDelimiterString = $data['scope_delimiter_string'] ?? ' '; + $loggerService->debug('ScopeRule: defaultScope: ' . ($defaultScope ? $defaultScope : 'N/A')); + ; + $scopeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::Scope->value, $request, $allowedServerRequestMethods, ) ?? $defaultScope; + $loggerService->debug('ScopeRule: scopeParam: ' . $scopeParam); $scopes = $this->helpers->str()->convertScopesStringToArray($scopeParam, $scopeDelimiterString); $validScopes = []; @@ -62,9 +68,10 @@ public function checkRule( $scope = $this->scopeRepository->getScopeEntityByIdentifier($scopeItem); if ($scope instanceof ScopeEntityInterface === false) { + $loggerService->error('ScopeRule: Invalid scope: ' . $scopeItem); throw OidcServerException::invalidScope($scopeItem, $redirectUri, $state); } - + $loggerService->debug('ScopeRule: Valid scope: ' . $scopeItem); $validScopes[] = $scope; } diff --git a/src/Server/RequestRules/Rules/StateRule.php b/src/Server/RequestRules/Rules/StateRule.php index a8ba6d3f..d60d31d7 100644 --- a/src/Server/RequestRules/Rules/StateRule.php +++ b/src/Server/RequestRules/Rules/StateRule.php @@ -25,6 +25,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('StateRule::checkRule'); + $state = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::State->value, $request, diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index c4c664e7..1278e9f9 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestTypes; use League\OAuth2\Server\RequestTypes\AuthorizationRequest as OAuth2AuthorizationRequest; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; class AuthorizationRequest extends OAuth2AuthorizationRequest { @@ -44,6 +45,31 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest */ protected ?string $sessionId = null; + /** + * Indicates if the request is related to Verifiable Credential Issuance (VCI request). + * + * @var bool + */ + protected bool $isVciRequest = false; + + protected ?FlowTypeEnum $flowType = null; + + /** + * @var mixed[]|null + */ + protected ?array $authorizationDetails = null; + + protected ?string $boundClientId = null; + + protected ?string $boundRedirectUri = null; + + /** + * Verifiable Credential Issuer state. + * + * @var string|null + */ + protected ?string $issuerState = null; + public static function fromOAuth2AuthorizationRequest( OAuth2AuthorizationRequest $oAuth2authorizationRequest, ): AuthorizationRequest { @@ -204,4 +230,64 @@ public function setSessionId(?string $sessionId): void { $this->sessionId = $sessionId; } + + public function isVciRequest(): bool + { + return $this->isVciRequest; + } + + public function setIsVciRequest(bool $isVciRequest): void + { + $this->isVciRequest = $isVciRequest; + } + + public function getIssuerState(): ?string + { + return $this->issuerState; + } + + public function setIssuerState(?string $issuerState): void + { + $this->issuerState = $issuerState; + } + + public function getFlowType(): ?FlowTypeEnum + { + return $this->flowType; + } + + public function setFlowType(?FlowTypeEnum $flowType): void + { + $this->flowType = $flowType; + } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } + + public function setAuthorizationDetails(?array $authorizationDetails): void + { + $this->authorizationDetails = $authorizationDetails; + } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function setBoundClientId(?string $boundClientId): void + { + $this->boundClientId = $boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } + + public function setBoundRedirectUri(?string $boundRedirectUri): void + { + $this->boundRedirectUri = $boundRedirectUri; + } } diff --git a/src/Server/ResponseTypes/IdTokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php similarity index 66% rename from src/Server/ResponseTypes/IdTokenResponse.php rename to src/Server/ResponseTypes/TokenResponse.php index 7c212e92..96fbaee7 100644 --- a/src/Server/ResponseTypes/IdTokenResponse.php +++ b/src/Server/ResponseTypes/TokenResponse.php @@ -28,6 +28,7 @@ use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\NonceResponseTypeInterface; use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\SessionIdResponseTypeInterface; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Services\LoggerService; /** * Class IdTokenResponse. @@ -37,7 +38,7 @@ * * @see https://github.com/steverhoades/oauth2-openid-connect-server/blob/master/src/IdTokenResponse.php */ -class IdTokenResponse extends BearerTokenResponse implements +class TokenResponse extends BearerTokenResponse implements // phpcs:ignore NonceResponseTypeInterface, // phpcs:ignore @@ -71,6 +72,7 @@ public function __construct( private readonly IdentityProviderInterface $identityProvider, protected IdTokenBuilder $idTokenBuilder, CryptKey $privateKey, + protected LoggerService $loggerService, ) { $this->privateKey = $privateKey; } @@ -82,14 +84,36 @@ public function __construct( */ protected function getExtraParams(AccessTokenEntityInterface $accessToken): array { - if (false === $this->isOpenIDRequest($accessToken->getScopes())) { - return []; - } - if ($accessToken instanceof AccessTokenEntity === false) { throw new RuntimeException('AccessToken must be ' . AccessTokenEntity::class); } + $extraParams = []; + + if ($this->isOpenIDRequest($accessToken->getScopes())) { + $extraParams = [ + ...$extraParams, + ...$this->prepareIdTokenExtraParam($accessToken), + ]; + } + + // For VCI, in token response for authorization code flow we need to return authorization details. + if ( + ($flowType = $accessToken->getFlowTypeEnum()) !== null && + $flowType->isVciFlow() && + $accessToken->getAuthorizationDetails() !== null + ) { + $extraParams = [ + ...$extraParams, + ...$this->prepareVciAuthorizationDetailsExtraParam($accessToken), + ]; + } + + return array_filter($extraParams); + } + + protected function prepareIdTokenExtraParam(AccessTokenEntity $accessToken): array + { $userIdentifier = $accessToken->getUserIdentifier(); if (empty($userIdentifier)) { @@ -118,6 +142,42 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra ]; } + protected function prepareVciAuthorizationDetailsExtraParam(AccessTokenEntity $accessToken): array + { + $normalizedAuthorizationDetails = []; + + $this->loggerService->debug( + 'TokenResponse::prepareAuthorizationDetailsExtraParam', + ['accessTokenAuthorizationDetails' => $accessToken->getAuthorizationDetails()], + ); + + if (($accessTokenAuthorizationDetails = $accessToken->getAuthorizationDetails()) === null) { + return $normalizedAuthorizationDetails; + } + + /** @psalm-suppress MixedAssignment */ + foreach ($accessTokenAuthorizationDetails as $authorizationDetail) { + if ( + (isset($authorizationDetail['type'])) && + ($authorizationDetail['type']) === 'openid_credential' + ) { + /** @psalm-suppress MixedAssignment */ + $credentialConfigurationId = $authorizationDetail['credential_configuration_id'] ?? null; + if ($credentialConfigurationId !== null) { + $authorizationDetail['credential_identifiers'] = [$credentialConfigurationId]; + } + $normalizedAuthorizationDetails[] = $authorizationDetail; + } + } + + $this->loggerService->debug( + 'TokenResponse::prepareAuthorizationDetailsExtraParam. Summarized authorization details: ', + ['authorizationDetails' => $normalizedAuthorizationDetails], + ); + + return ['authorization_details' => $normalizedAuthorizationDetails]; + } + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 0a371aa4..b1afbd23 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -9,7 +9,6 @@ use Lcobucci\Clock\SystemClock; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Validation\Constraint\SignedWith; use Lcobucci\JWT\Validation\Constraint\StrictValidAt; use Lcobucci\JWT\Validation\RequiredConstraintsViolated; @@ -18,6 +17,7 @@ use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface as OAuth2AccessTokenRepositoryInterface; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -44,6 +44,7 @@ class BearerTokenValidator extends OAuth2BearerTokenValidator public function __construct( AccessTokenRepositoryInterface $accessTokenRepository, CryptKey $publicKey, + protected readonly ModuleConfig $moduleConfig, ?DateInterval $jwtValidAtDateLeeway = null, protected LoggerService $loggerService = new LoggerService(), ) { @@ -71,16 +72,14 @@ public function setPublicKey(CryptKey $key): void */ protected function initJwtConfiguration(): void { + /** @psalm-suppress ArgumentTypeCoercion */ $this->jwtConfiguration = Configuration::forSymmetricSigner( - new Sha256(), + $this->moduleConfig->getProtocolSigner(), InMemory::plainText('empty', 'empty'), - ); - - /** @psalm-suppress DeprecatedMethod, ArgumentTypeCoercion */ - $this->jwtConfiguration->setValidationConstraints( + )->withValidationConstraints( new StrictValidAt(new SystemClock(new DateTimeZone(date_default_timezone_get()))), new SignedWith( - new Sha256(), + $this->moduleConfig->getProtocolSigner(), InMemory::plainText($this->publicKey->getKeyContents(), $this->publicKey->getPassPhrase() ?? ''), ), ); diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php new file mode 100644 index 00000000..66979574 --- /dev/null +++ b/src/Services/Api/Authorization.php @@ -0,0 +1,96 @@ +sspBridge->utils()->auth()->requireAdmin(); + } catch (\Throwable $exception) { + throw new AuthorizationException( + Translate::noop('Unable to initiate admin authentication.'), + previous: $exception, + ); + } + } + + if (! $this->sspBridge->utils()->auth()->isAdmin()) { + throw new AuthorizationException(Translate::noop('SimpleSAMLphp Admin access required.')); + } + } + + /** + * @param \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] $requiredScopes + * + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + */ + public function requireTokenForAnyOfScope(Request $request, array $requiredScopes): void + { + try { + $this->requireSimpleSAMLphpAdmin(); + return; + } catch (Throwable) { + // Not admin, check for token. + } + + if (empty($token = $this->findToken($request))) { + throw new AuthorizationException(Translate::noop('Token not provided.')); + } + + if (empty($tokenScopes = $this->moduleConfig->getApiTokenScopes($token))) { + throw new AuthorizationException(Translate::noop('Token does not have defined scopes.')); + } + + $hasAny = !empty(array_filter($tokenScopes, fn($tokenScope) => in_array($tokenScope, $requiredScopes, true))); + + if (!$hasAny) { + throw new AuthorizationException(Translate::noop('Token is not authorized.')); + } + } + + protected function findToken(Request $request): ?string + { + /** @psalm-suppress InternalMethod */ + if ($token = trim((string) $request->get(self::KEY_TOKEN))) { + return $token; + } + + if ($request->headers->has(self::KEY_AUTHORIZATION)) { + return trim( + (string) preg_replace( + '/^\s*Bearer\s/', + '', + (string)$request->headers->get(self::KEY_AUTHORIZATION), + ), + ); + } + + return null; + } +} diff --git a/src/Services/AuthContextService.php b/src/Services/AuthContextService.php index 7783aed2..84e034c3 100644 --- a/src/Services/AuthContextService.php +++ b/src/Services/AuthContextService.php @@ -80,10 +80,16 @@ public function requirePermission(string $neededPermission): void /** * @throws \Exception */ - private function authenticate(): Simple + public function authenticate(): Simple { $simple = $this->authSimpleFactory->getDefaultAuthSource(); $simple->requireAuth(); return $simple; } + + public function logout(): void + { + $simple = $this->authSimpleFactory->getDefaultAuthSource(); + $simple->logout(); + } } diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php index 4dbba7d9..59bb7716 100644 --- a/src/Services/AuthenticationService.php +++ b/src/Services/AuthenticationService.php @@ -16,6 +16,7 @@ namespace SimpleSAML\Module\oidc\Services; +use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest as OAuth2AuthorizationRequest; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Auth\ProcessingChain; @@ -32,7 +33,6 @@ use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; -use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; @@ -66,7 +66,6 @@ public function __construct( private readonly ModuleConfig $moduleConfig, private readonly ProcessingChainFactory $processingChainFactory, private readonly StateService $stateService, - private readonly Helpers $helpers, private readonly RequestParamsResolver $requestParamsResolver, private readonly UserEntityFactory $userEntityFactory, ) { @@ -89,14 +88,13 @@ public function processRequest( ServerRequestInterface $request, OAuth2AuthorizationRequest $authorizationRequest, ): array { - // TODO mivanci v7 Fix: client has already been resolved up to this point, but we are again fetching it from DB. - $oidcClient = $this->helpers->client()->getFromRequest($request, $this->clientRepository); + $oidcClient = $authorizationRequest->getClient(); $authSimple = $this->authSimpleFactory->build($oidcClient); $this->authSourceId = $authSimple->getAuthSource()->getAuthId(); if (! $authSimple->isAuthenticated()) { - $this->authenticate($oidcClient); + $this->authenticate($authSimple); } elseif ($this->sessionService->getIsAuthnPerformedInPreviousRequest()) { $this->sessionService->setIsAuthnPerformedInPreviousRequest(false); @@ -197,7 +195,7 @@ public function getAuthorizationRequestFromState(array|null $state): OAuth2Autho /** * @param Simple $authSimple - * @param ClientEntityInterface $client + * @param OAuth2ClientEntityInterface $client * @param ServerRequestInterface $request * @param OAuth2AuthorizationRequest $authorizationRequest * @@ -207,16 +205,18 @@ public function getAuthorizationRequestFromState(array|null $state): OAuth2Autho public function prepareStateArray( Simple $authSimple, - ClientEntityInterface $client, + OAuth2ClientEntityInterface $client, ServerRequestInterface $request, OAuth2AuthorizationRequest $authorizationRequest, ): array { $state = $authSimple->getAuthDataArray(); + $clientArray = $client instanceof ClientEntityInterface ? $client->toArray() : []; + $state['Oidc'] = [ 'OpenIdProviderMetadata' => $this->opMetadataService->getMetadata(), 'RelyingPartyMetadata' => array_filter( - $client->toArray(), + $clientArray, fn(/** @param array-key $key */ $key) => $key !== 'secret', ARRAY_FILTER_USE_KEY, ), @@ -272,20 +272,31 @@ public function getSessionId(): ?string * @throws Error\NotFound * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \Exception */ - public function authenticate( - ClientEntityInterface $clientEntity, + Simple $authSimple, array $loginParams = [], ): void { - $authSimple = $this->authSimpleFactory->build($clientEntity); - $this->sessionService->setIsCookieBasedAuthn(false); $this->sessionService->setIsAuthnPerformedInPreviousRequest(true); $authSimple->login($loginParams); } + /** + * @throws Error\BadRequest + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws Error\NotFound + * @throws \JsonException + */ + public function authenticateForClient( + ClientEntityInterface $clientEntity, + array $loginParams = [], + ): void { + $this->authenticate($this->authSimpleFactory->build($clientEntity), $loginParams); + } + /** * Store Relying on Party Association to the current session. * @throws \Exception diff --git a/src/Services/Container.php b/src/Services/Container.php index 3570fbad..9a375595 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -44,6 +44,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\IssuerStateEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\RefreshTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ScopeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; @@ -51,12 +52,13 @@ use SimpleSAML\Module\oidc\Factories\FormFactory; use SimpleSAML\Module\oidc\Factories\Grant\AuthCodeGrantFactory; use SimpleSAML\Module\oidc\Factories\Grant\ImplicitGrantFactory; +use SimpleSAML\Module\oidc\Factories\Grant\PreAuthCodeGrantFactory; use SimpleSAML\Module\oidc\Factories\Grant\RefreshTokenGrantFactory; -use SimpleSAML\Module\oidc\Factories\IdTokenResponseFactory; use SimpleSAML\Module\oidc\Factories\JwksFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; use SimpleSAML\Module\oidc\Factories\ResourceServerFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; +use SimpleSAML\Module\oidc\Factories\TokenResponseFactory; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; @@ -65,18 +67,21 @@ use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\ImplicitGrant; +use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule; @@ -84,7 +89,6 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; @@ -94,7 +98,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; @@ -233,6 +237,7 @@ public function __construct() $helpers, $claimTranslatorExtractor, $requestParamsResolver, + $moduleConfig, ); $this->services[ClientEntityFactory::class] = $clientEntityFactory; @@ -327,6 +332,21 @@ public function __construct() ); $this->services[AllowedOriginRepository::class] = $allowedOriginRepository; + $issuerStateEntityFactory = new IssuerStateEntityFactory( + $moduleConfig, + $helpers, + ); + $this->services[IssuerStateEntityFactory::class] = $issuerStateEntityFactory; + + $issuerStateRepository = new IssuerStateRepository( + $moduleConfig, + $database, + $protocolCache, + $issuerStateEntityFactory, + $helpers, + ); + $this->services[IssuerStateRepository::class] = $issuerStateRepository; + $databaseMigration = new DatabaseMigration($database); $this->services[DatabaseMigration::class] = $databaseMigration; @@ -340,7 +360,6 @@ public function __construct() $moduleConfig, $processingChainFactory, $stateService, - $helpers, $requestParamsResolver, $userEntityFactory, ); @@ -366,7 +385,7 @@ public function __construct() $requestRules = [ new StateRule($requestParamsResolver, $helpers), - new ClientIdRule( + new ClientRule( $requestParamsResolver, $helpers, $clientRepository, @@ -375,9 +394,10 @@ public function __construct() $federation, $jwksResolver, $federationParticipationValidator, + $loggerService, $federationCache, ), - new RedirectUriRule($requestParamsResolver, $helpers), + new ClientRedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver), new PromptRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), new MaxAgeRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), @@ -418,13 +438,14 @@ public function __construct() $sessionLogoutTicketStoreBuilder = new LogoutTicketStoreBuilder($sessionLogoutTicketStoreDb); $this->services[LogoutTicketStoreBuilder::class] = $sessionLogoutTicketStoreBuilder; - $idTokenResponseFactory = new IdTokenResponseFactory( + $tokenResponseFactory = new TokenResponseFactory( $moduleConfig, $userRepository, $this->services[IdTokenBuilder::class], $privateKey, + $loggerService, ); - $this->services[IdTokenResponse::class] = $idTokenResponseFactory->build(); + $this->services[TokenResponse::class] = $tokenResponseFactory->build(); $this->services[Helpers::class] = $helpers; @@ -447,6 +468,7 @@ public function __construct() $authCodeEntityFactory, $refreshTokenIssuer, $helpers, + $loggerService, ); $this->services[AuthCodeGrant::class] = $authCodeGrantFactory->build(); @@ -468,6 +490,21 @@ public function __construct() ); $this->services[RefreshTokenGrant::class] = $refreshTokenGrantFactory->build(); + $preAuthCodeGrantFactory = new PreAuthCodeGrantFactory( + $moduleConfig, + $authCodeRepository, + $accessTokenRepository, + $refreshTokenRepository, + $requestRuleManager, + $requestParamsResolver, + $accessTokenEntityFactory, + $authCodeEntityFactory, + $refreshTokenIssuer, + $helpers, + $loggerService, + ); + $this->services[PreAuthCodeGrant::class] = $preAuthCodeGrantFactory->build(); + $authorizationServerFactory = new AuthorizationServerFactory( $moduleConfig, $clientRepository, @@ -476,13 +513,19 @@ public function __construct() $this->services[AuthCodeGrant::class], $this->services[ImplicitGrant::class], $this->services[RefreshTokenGrant::class], - $this->services[IdTokenResponse::class], + $this->services[TokenResponse::class], $requestRuleManager, $privateKey, + $this->services[PreAuthCodeGrant::class], + $loggerService, ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); - $bearerTokenValidator = new BearerTokenValidator($accessTokenRepository, $publicKey); + $bearerTokenValidator = new BearerTokenValidator( + $accessTokenRepository, + $publicKey, + $moduleConfig, + ); $this->services[BearerTokenValidator::class] = $bearerTokenValidator; $resourceServerFactory = new ResourceServerFactory( diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index a4936e88..06020ef2 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; @@ -163,6 +164,51 @@ public function migrate(): void $this->version20240906120000(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20240906120000')"); } + + if (!in_array('20250818163000', $versions, true)) { + $this->version20250818163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250818163000')"); + } + + if (!in_array('20250908163000', $versions, true)) { + $this->version20250908163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250908163000')"); + } + + if (!in_array('20250912163000', $versions, true)) { + $this->version20250912163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250912163000')"); + } + + if (!in_array('20250913163000', $versions, true)) { + $this->version20250913163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250913163000')"); + } + + if (!in_array('20250915163000', $versions, true)) { + $this->version20250915163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250915163000')"); + } + + if (!in_array('20250916163000', $versions, true)) { + $this->version20250916163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250916163000')"); + } + + if (!in_array('20250917163000', $versions, true)) { + $this->version20250917163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250917163000')"); + } + + if (!in_array('20251021000001', $versions, true)) { + $this->version20251021000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20251021000001')"); + } + + if (!in_array('20251021000002', $versions, true)) { + $this->version20251021000002(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20251021000002')"); + } } private function versionsTableName(): string @@ -531,6 +577,137 @@ private function version20240906120000(): void ,); } + private function version20250818163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD is_pre_authorized BOOLEAN NOT NULL DEFAULT false; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD tx_code VARCHAR(191) NULL; +EOT + ,); + } + + private function version20250908163000(): void + { + $issuerStateTableName = $this->database->applyPrefix(IssuerStateRepository::TABLE_NAME); + $this->database->write(<<< EOT + CREATE TABLE $issuerStateTableName ( + value CHAR(64) PRIMARY KEY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_revoked BOOLEAN NOT NULL DEFAULT false + ) +EOT + ,); + } + + private function version20250912163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + DROP COLUMN is_pre_authorized; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD flow_type CHAR(64) NULL; +EOT + ,); + } + + private function version20250913163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD authorization_details TEXT NULL; +EOT + ,); + } + + private function version20250915163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD bound_client_id TEXT NULL; +EOT + ,); + + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + ADD is_generic BOOLEAN NOT NULL DEFAULT false; +EOT + ,); + } + + private function version20250916163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD bound_redirect_uri TEXT NULL; +EOT + ,); + } + + private function version20250917163000(): void + { + $accessTokenTableName = $this->database->applyPrefix(AccessTokenRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD flow_type CHAR(64) NULL; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD authorization_details TEXT NULL; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD bound_client_id TEXT NULL; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD bound_redirect_uri TEXT NULL; +EOT + ,); + } + + private function version20251021000001(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD issuer_state TEXT NULL +EOT + ,); + } + + private function version20251021000002(): void + { + $accessTokenTableName = $this->database->applyPrefix(AccessTokenRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD issuer_state TEXT NULL +EOT + ,); + } + /** * @param string[] $columnNames */ diff --git a/src/Services/JsonWebKeySetService.php b/src/Services/JsonWebKeySetService.php index f02f9e05..8bb56868 100644 --- a/src/Services/JsonWebKeySetService.php +++ b/src/Services/JsonWebKeySetService.php @@ -88,8 +88,8 @@ protected function prepareProtocolJwkSet(): void file_exists($protocolNewPublicKeyPath) ) { $newJwk = JWKFactory::createFromKeyFile($protocolNewPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), ]); diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 921724bb..5986de2a 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -8,6 +8,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; /** @@ -70,7 +71,16 @@ private function initMetadata(): void $signer->algorithmId(), ]; $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = false; - $this->metadata[ClaimsEnum::GrantTypesSupported->value] = ['authorization_code', 'refresh_token']; + + $grantTypesSupported = [ + GrantTypesEnum::AuthorizationCode->value, + GrantTypesEnum::RefreshToken->value, + ]; + if ($this->moduleConfig->getVerifiableCredentialEnabled()) { + $grantTypesSupported[] = GrantTypesEnum::PreAuthorizedCode->value; + } + $this->metadata[ClaimsEnum::GrantTypesSupported->value] = $grantTypesSupported; + $this->metadata[ClaimsEnum::ClaimsParameterSupported->value] = true; if (!(empty($acrValuesSupported = $this->moduleConfig->getAcrValuesSupported()))) { $this->metadata[ClaimsEnum::AcrValuesSupported->value] = $acrValuesSupported; @@ -82,6 +92,10 @@ private function initMetadata(): void $claimsSupported = $this->claimTranslatorExtractor->getSupportedClaims(); $this->metadata[ClaimsEnum::ClaimsSupported->value] = $claimsSupported; } + + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-oauth-20-authorization-serv + // OPTIONAL + // pre-authorized_grant_anonymous_access_supported // TODO mivanci Make configurable } /** diff --git a/src/Utils/README.md b/src/Utils/README.md new file mode 100644 index 00000000..51da160a --- /dev/null +++ b/src/Utils/README.md @@ -0,0 +1,45 @@ +# Utility Classes + +This directory contains various utility classes used throughout the module. + +## DidKeyResolver + +The `DidKeyResolver` class provides functionality to extract a JWK (JSON Web Key) from a "did:key" value according to the [W3C DID Key specification](https://w3c-ccg.github.io/did-key-spec/). + +### Usage + +```php +// Instantiate the resolver +$didKeyResolver = new DidKeyResolver(); + +// Extract JWK from a did:key value +$didKey = 'did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9Kbp7R1FUvzP1s9pLTKP21oYQNWMJFzgVGWYb5WmD3ngVmjMeTABs9MjYUaRfzTWg9dLdPw6o16UeakmtE7tHDMug3XgcJptPxRYuwFdVJXa6KAMUBhkmouMZisDJYMGbaGAp'; +$jwk = $didKeyResolver->extractJwkFromDidKey($didKey); + +// Use the JWK for verification or other purposes +// ... +``` + +### Supported Key Types + +The `DidKeyResolver` supports the following key types: + +- Ed25519 (0xed01) +- X25519 (0xec01) +- Secp256k1 (0x1200) +- P-256 (NIST) (0x1201) +- P-384 (NIST) (0x1202) +- P-521 (NIST) (0x1203) + +### Implementation Details + +The `DidKeyResolver` implements the following steps to extract a JWK from a "did:key" value: + +1. Validate the "did:key" format (must start with "did:key:") +2. Extract the multibase-encoded public key +3. Check if it's a base58btc encoded key (starts with 'z') +4. Decode the base58 key +5. Determine the key type based on the multicodec identifier +6. Extract the actual key bytes and create the appropriate JWK representation + +For more information about the "did:key" format and the W3C DID Key specification, see the [official documentation](https://w3c-ccg.github.io/did-key-spec/). \ No newline at end of file diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 11d23565..c18600c7 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -197,4 +197,29 @@ public function parseClientAssertionToken(string $clientAssertionParam): Core\Cl { return $this->core->clientAssertionFactory()->fromToken($clientAssertionParam); } + + /** + * @param ServerRequestInterface $request + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods + * @return bool + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function isVciAuthorizationCodeRequest( + ServerRequestInterface $request, + array $allowedMethods, + ): bool { + return + // Only applies to VCI Authorization Code flow. + $this->getAsStringBasedOnAllowedMethods( + ParamsEnum::ResponseType->value, + $request, + $allowedMethods, + ) === 'code' && + // Issuer State is only used for VCI Authorization Code flow requests, so use it as a form of detection. + is_string($this->getAsStringBasedOnAllowedMethods( + ParamsEnum::IssuerState->value, + $request, + $allowedMethods, + )); + } } diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index d9134231..f7763577 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -146,6 +146,20 @@ public function urlAdminTestTrustMarkValidation(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->value, $parameters); } + public function urlAdminTestVerifiableCredentialIssuance(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value, $parameters); + } + + /***************************************************************************************************************** + * OAuth 2.0 Authorization Server + ****************************************************************************************************************/ + + public function urlOAuth2Configuration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::OAuth2Configuration->value, $parameters); + } + /***************************************************************************************************************** * OpenID Connect URLs. ****************************************************************************************************************/ @@ -198,4 +212,36 @@ public function urlFederationList(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::FederationList->value, $parameters); } + + /***************************************************************************************************************** + * OpenID for Verifiable Credential Issuance URLs. + ****************************************************************************************************************/ + + public function urlCredentialIssuerConfiguration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::CredentialIssuerConfiguration->value, $parameters); + } + + public function urlCredentialIssuerCredential(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::CredentialIssuerCredential->value, $parameters); + } + + /***************************************************************************************************************** + * SD-JWT-based Verifiable Credentials (SD-JWT VC) + ****************************************************************************************************************/ + + public function urlJwtVcIssuerConfiguration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::JwtVcIssuerConfiguration->value, $parameters); + } + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + public function urlApiVciCredentialOffer(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); + } } diff --git a/templates/config/verifiable-credential.twig b/templates/config/verifiable-credential.twig new file mode 100644 index 00000000..c6d64e20 --- /dev/null +++ b/templates/config/verifiable-credential.twig @@ -0,0 +1,24 @@ +{% set subPageTitle = 'Verifiable Credential Settings'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} +

+ {{ 'Verifiable Credential Enabled'|trans }}: + {{ moduleConfig.getVerifiableCredentialEnabled ? 'Yes'|trans : 'No'|trans }} +

+ +

{{ 'Entity'|trans }}

+

+ {{ 'Credential Issuer Configuration URL'|trans }}: + {{ routes.urlCredentialIssuerConfiguration }} +

+

+ {{ 'JWT VC Issuer Configuration URL'|trans }}: + {{ routes.urlJwtVcIssuerConfiguration }} +

+

+ {{ 'Issuer'|trans }}: {{ moduleConfig.getIssuer }} +

+ +{% endblock oidcContent -%} diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig new file mode 100644 index 00000000..4679cb2d --- /dev/null +++ b/templates/tests/verifiable-credential-issuance.twig @@ -0,0 +1,124 @@ +{% set subPageTitle = 'Test Verifiable Credential Issuance'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + + {% if setupErrors %} +

+ {{ 'There are some setup errors which you should deal with before proceeding.'|trans }} +
+ {{ setupErrors|join('
') }} +

+ {% else %} + + {% if not authSource or not authSource.isAuthenticated %} + +

+ {{ 'To test Verifiable Credential issuance, choose authentication source, desired Credential Configuration ID, Grant Type, and click Proceed.'|trans }} + {{ 'Once you log in, you will be presented with a Credential Offer which you can use to test credential issuance.'|trans }} +

+ +
+
+ + + + {% trans %}Authentication source to be used for user login.{% endtrans %} + + + + + + {% trans %}Credential Configuration ID to be offered.{% endtrans %} + + + {# Grant Type #} + + + + + {% trans %}Grant Type to be used in credential issuance.{% endtrans %} + + + + + + {% trans %}Check if you want to use Transaction Code protection for pre-authorized code grant.{% endtrans %} + {% trans %}If selected, server will send the transaction code to user's email address.{% endtrans %} + + + + + + {% trans %}If Transaction Code protection is used, this attribute will be used to get user's email address to which the transaction code will be sent.{% endtrans %} + {% trans %}Default value for attribute name is taken from module configuration, however, override if necessary.{% endtrans %} + + +
+ +
+
+ {% else %} +

+ You are currently authenticated with the following user data: +
+ + {{- authSource.getAttributes|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+ + {% endif %} + + + {% if credentialOfferUri and credentialOfferQrUri %} +

+ Credential Offer: + + {{- credentialOfferUri -}} + +

+ QR Code + {% endif %} + + {% if authSource and authSource.isAuthenticated %} +
+
+ +
+
+ {% endif %} + + {% endif %} + + + +{% endblock oidcContent -%} + +{% block postload %} + {{ parent() }} + + +{% endblock %} \ No newline at end of file diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 2c096334..6ace21ac 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -116,6 +116,11 @@ public function setUp(): void 'is_revoked' => false, 'auth_code_id' => self::AUTH_CODE_ID, 'requested_claims' => '[]', + 'flow_type' => null, + 'authorization_details' => null, + 'bound_client_id' => null, + 'bound_redirect_uri' => null, + 'issuer_state' => null, ]; $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); diff --git a/tests/unit/src/Admin/AuthorizationTest.php b/tests/unit/src/Admin/AuthorizationTest.php index e35421c1..ee64df2a 100644 --- a/tests/unit/src/Admin/AuthorizationTest.php +++ b/tests/unit/src/Admin/AuthorizationTest.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Services\AuthContextService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Utils\Auth; #[CoversClass(Authorization::class)] @@ -22,6 +23,7 @@ class AuthorizationTest extends TestCase protected MockObject $sspBridgeUtilsMock; protected MockObject $sspBridgeUtilsAuthMock; protected MockObject $authContextServiceMock; + protected MockObject $loggerServiceMock; protected function setUp(): void { @@ -32,16 +34,19 @@ protected function setUp(): void $this->sspBridgeUtilsMock->method('auth')->willReturn($this->sspBridgeUtilsAuthMock); $this->authContextServiceMock = $this->createMock(AuthContextService::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); } protected function sut( ?SspBridge $sspBridge = null, ?AuthContextService $authContextService = null, + ?LoggerService $loggerService = null, ): Authorization { $sspBridge ??= $this->sspBridgeMock; $authContextService ??= $this->authContextServiceMock; + $loggerService ??= $this->loggerServiceMock; - return new Authorization($sspBridge, $authContextService); + return new Authorization($sspBridge, $authContextService, $loggerService); } public function testCanCreateInstance(): void @@ -100,7 +105,7 @@ public function testRequireAdminOrUserWithPermissionReturnsIfUser(): void false, true, // After requireAdmin called, isAdmin will return true ); - $this->sspBridgeUtilsAuthMock->expects($this->once())->method('requireAdmin'); + $this->sspBridgeUtilsAuthMock->expects($this->never())->method('requireAdmin'); $this->authContextServiceMock->expects($this->once())->method('requirePermission'); $this->sut()->requireAdminOrUserWithPermission('permission'); diff --git a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php index 56a5e589..f9603d3a 100644 --- a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php +++ b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Jwk; #[CoversClass(EntityStatementController::class)] class EntityStatementControllerTest extends TestCase @@ -31,6 +32,7 @@ class EntityStatementControllerTest extends TestCase protected MockObject $helpersMock; protected MockObject $routesMock; protected MockObject $federationMock; + protected MockObject $jwkMock; protected MockObject $loggerServiceMock; protected MockObject $federationCacheMock; @@ -44,6 +46,7 @@ protected function setUp(): void $this->helpersMock = $this->createMock(Helpers::class); $this->routesMock = $this->createMock(Routes::class); $this->federationMock = $this->createMock(Federation::class); + $this->jwkMock = $this->createMock(Jwk::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); $this->federationCacheMock = $this->createMock(FederationCache::class); } @@ -57,6 +60,7 @@ protected function sut( ?Helpers $helpers = null, ?Routes $routes = null, ?Federation $federation = null, + ?Jwk $jwk = null, ?LoggerService $loggerService = null, ?FederationCache $federationCache = null, ): EntityStatementController { @@ -68,6 +72,7 @@ protected function sut( $helpers ??= $this->helpersMock; $routes ??= $this->routesMock; $federation ??= $this->federationMock; + $jwk ??= $this->jwkMock; $loggerService ??= $this->loggerServiceMock; $federationCache ??= $this->federationCacheMock; @@ -80,6 +85,7 @@ protected function sut( $helpers, $routes, $federation, + $jwk, $loggerService, $federationCache, ); diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index b9cc457e..6b0be802 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -28,6 +28,7 @@ class AuthCodeEntityTest extends TestCase protected string $redirectUri; protected string $nonce; protected DateTimeImmutable $expiryDateTime; + protected ?array $authorizationDetails; /** * @throws \Exception @@ -49,6 +50,7 @@ protected function setUp(): void $this->isRevoked = false; $this->redirectUri = 'https://localhost/redirect'; $this->nonce = 'nonce'; + $this->authorizationDetails = null; } /** @@ -66,6 +68,7 @@ protected function mock(): AuthCodeEntity $this->redirectUri, $this->nonce, $this->isRevoked, + $this->authorizationDetails, ); } @@ -98,6 +101,12 @@ public function testCanGetState(): void 'is_revoked' => false, 'redirect_uri' => 'https://localhost/redirect', 'nonce' => 'nonce', + 'flow_type' => null, + 'tx_code' => null, + 'authorization_details' => null, + 'bound_client_id' => null, + 'bound_redirect_uri' => null, + 'issuer_state' => null, ], ); } diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index 49a709cc..0ed42a75 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -38,6 +38,7 @@ class ClientEntityTest extends TestCase protected ?DateTimeImmutable $createdAt = null; protected ?DateTimeImmutable $expiresAt = null; protected bool $isFederated = false; + protected bool $isGeneric = false; protected function setUp(): void { @@ -59,6 +60,7 @@ protected function setUp(): void 'created_at' => null, 'expires_at' => null, 'is_federated' => false, + 'is_generic' => false, ]; } @@ -92,6 +94,7 @@ public function mock(): ClientEntity $this->createdAt, $this->expiresAt, $this->isFederated, + $this->isGeneric, ); } @@ -183,6 +186,7 @@ public function testCanGetState(): void 'created_at' => null, 'expires_at' => null, 'is_federated' => $this->state['is_federated'], + 'is_generic' => $this->state['is_generic'], ], ); } @@ -219,6 +223,7 @@ public function testCanExportAsArray(): void 'created_at' => null, 'expires_at' => null, 'is_federated' => false, + 'is_generic' => false, ], ); } diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index 4479ddf3..d991c6a1 100644 --- a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php @@ -17,6 +17,7 @@ use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; /** @@ -35,6 +36,7 @@ class AuthCodeGrantTest extends TestCase protected Stub $authCodeEntityFactoryStub; protected Stub $refreshTokenIssuerStub; protected Stub $helpersStub; + protected Stub $loggerMock; /** * @throws \Exception @@ -52,6 +54,7 @@ protected function setUp(): void $this->authCodeEntityFactoryStub = $this->createStub(AuthcodeEntityFactory::class); $this->refreshTokenIssuerStub = $this->createStub(RefreshTokenIssuer::class); $this->helpersStub = $this->createStub(Helpers::class); + $this->loggerMock = $this->createMock(LoggerService::class); } /** @@ -72,6 +75,8 @@ public function testCanCreateInstance(): void $this->authCodeEntityFactoryStub, $this->refreshTokenIssuerStub, $this->helpersStub, + $this->loggerMock, + $this->moduleConfigStub, ), ); } diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php similarity index 92% rename from tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php rename to tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php index 9556d8d3..abf0eb9e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php @@ -15,7 +15,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; @@ -24,9 +24,9 @@ use SimpleSAML\OpenID\Federation; /** - * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule + * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule */ -class ClientIdRuleTest extends TestCase +class ClientRuleTest extends TestCase { protected Stub $clientEntityStub; protected Stub $clientRepositoryStub; @@ -62,9 +62,9 @@ protected function setUp(): void $this->federationParticipationValidatorStub = $this->createStub(FederationParticipationValidator::class); } - protected function sut(): ClientIdRule + protected function sut(): ClientRule { - return new ClientIdRule( + return new ClientRule( $this->requestParamsResolverStub, $this->helpersStub, $this->clientRepositoryStub, @@ -73,13 +73,14 @@ protected function sut(): ClientIdRule $this->federationStub, $this->jwksResolverStub, $this->federationParticipationValidatorStub, + $this->loggerServiceStub, $this->federationCacheStub, ); } public function testConstruct(): void { - $this->assertInstanceOf(ClientIdRule::class, $this->sut()); + $this->assertInstanceOf(ClientRule::class, $this->sut()); } public function testCheckRuleEmptyClientIdThrows(): void @@ -111,7 +112,7 @@ public function testCheckRuleInvalidClientThrows(): void */ public function testCheckRuleForValidClientId(): void { - $this->requestParamsResolverStub->method('getBasedOnAllowedMethods')->willReturn('123'); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('123'); $this->clientRepositoryStub->method('getClientEntity')->willReturn($this->clientEntityStub); $result = $this->sut()->checkRule( diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php index 4d0217d7..f01343ca 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php @@ -16,8 +16,8 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -43,7 +43,7 @@ protected function setUp(): void { $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->resultBagStub = $this->createStub(ResultBagInterface::class); - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php index 671badb7..1755ea6f 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php @@ -15,9 +15,9 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -47,12 +47,12 @@ protected function setUp(): void { $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->resultBagStub = $this->createStub(ResultBagInterface::class); - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->clientStub = $this->createStub(ClientEntityInterface::class); - $this->clientIdResult = new Result(ClientIdRule::class, $this->clientStub); + $this->clientIdResult = new Result(ClientRule::class, $this->clientStub); $this->helpers = new Helpers(); } diff --git a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php index 3650edde..33a31f4e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php @@ -10,21 +10,22 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; /** - * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule + * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule */ class RedirectUriRuleTest extends TestCase { - protected RedirectUriRule $rule; + protected ClientRedirectUriRule $rule; protected ResultBag $resultBag; protected Stub $clientStub; protected Stub $requestStub; @@ -32,6 +33,7 @@ class RedirectUriRuleTest extends TestCase protected Stub $loggerServiceStub; protected Stub $requestParamsResolverStub; protected Helpers $helpers; + protected Stub $moduleConfigStub; /** @@ -45,18 +47,22 @@ protected function setUp(): void $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpers = new Helpers(); + $this->moduleConfigStub = $this->createStub(ModuleConfig::class); } protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, - ): RedirectUriRule { + ?ModuleConfig $moduleConfig = null, + ): ClientRedirectUriRule { $requestParamsResolver ??= $this->requestParamsResolverStub; $helpers ??= $this->helpers; + $moduleConfig ??= $this->moduleConfigStub; - return new RedirectUriRule( + return new ClientRedirectUriRule( $requestParamsResolver, $helpers, + $moduleConfig, ); } @@ -76,7 +82,7 @@ public function testCheckRuleClientIdDependency(): void */ public function testCheckRuleWithInvalidClientDependancy(): void { - $this->resultBag->add(new Result(ClientIdRule::class, 'invalid')); + $this->resultBag->add(new Result(ClientRule::class, 'invalid')); $this->expectException(LogicException::class); $this->sut()->checkRule($this->requestStub, $this->resultBag, $this->loggerServiceStub); } @@ -112,7 +118,7 @@ public function testCheckRuleDifferentClientRedirectUriArrayThrows(): void $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('invalid'); $this->clientStub->method('getRedirectUri')->willReturn([$this->redirectUri]); - $this->resultBag->add(new Result(ClientIdRule::class, $this->clientStub)); + $this->resultBag->add(new Result(ClientRule::class, $this->clientStub)); $this->expectException(OidcServerException::class); $this->sut()->checkRule($this->requestStub, $this->resultBag, $this->loggerServiceStub); @@ -137,7 +143,7 @@ public function testCheckRuleWithValidRedirectUri(): void protected function prepareValidResultBag(): ResultBag { $this->clientStub->method('getRedirectUri')->willReturn($this->redirectUri); - $this->resultBag->add(new Result(ClientIdRule::class, $this->clientStub)); + $this->resultBag->add(new Result(ClientRule::class, $this->clientStub)); return $this->resultBag; } } diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 69c9392c..45861adb 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -14,8 +14,8 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\JwksResolver; @@ -39,8 +39,8 @@ protected function setUp(): void $this->clientStub = $this->createStub(ClientEntityInterface::class); $this->resultBagStub = $this->createStub(ResultBag::class); $this->resultBagStub->method('getOrFail')->willReturnMap([ - [ClientIdRule::class, new Result(ClientIdRule::class, $this->clientStub)], - [RedirectUriRule::class, new Result(RedirectUriRule::class, 'https://example.com/redirect')], + [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], + [ClientRedirectUriRule::class, new Result(ClientRedirectUriRule::class, 'https://example.com/redirect')], ]); $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); $this->requestObjectMock = $this->createMock(RequestObject::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php index 20d165c1..a17f677d 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php @@ -13,7 +13,7 @@ use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; @@ -44,7 +44,7 @@ protected function setUp(): void $this->clientStub = $this->createStub(ClientEntityInterface::class); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->clientStub->method('getScopes')->willReturn(['openid', 'profile', 'email']); - $this->resultBag->add(new Result(ClientIdRule::class, $this->clientStub)); + $this->resultBag->add(new Result(ClientRule::class, $this->clientStub)); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->claimSetEntityFactoryStub = $this->createStub(ClaimSetEntityFactory::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php index 8a6d377d..6bfbd34e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php @@ -12,7 +12,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -43,7 +43,7 @@ class RequiredNonceRuleTest extends TestCase */ protected function setUp(): void { - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->requestStub = $this->createStub(ServerRequestInterface::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php index 9f6dcedf..05668b79 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php @@ -13,7 +13,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; @@ -42,7 +42,7 @@ class RequiredOpenIdScopeRuleTest extends TestCase */ protected function setUp(): void { - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->scopeEntities = [ diff --git a/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php index c40143b9..1916686e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php @@ -17,7 +17,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -55,7 +55,7 @@ protected function setUp(): void { $this->scopeRepositoryStub = $this->createStub(ScopeRepositoryInterface::class); $this->resultBagStub = $this->createStub(ResultBagInterface::class); - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->scopeEntities = [ diff --git a/tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php similarity index 94% rename from tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php rename to tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index e57d2891..c389b565 100644 --- a/tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -31,15 +31,16 @@ use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\Interfaces\IdentityProviderInterface; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; /** - * @covers \SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse + * @covers \SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse */ -class IdTokenResponseTest extends TestCase +class TokenResponseTest extends TestCase { final public const TOKEN_ID = 'tokenId'; final public const ISSUER = 'someIssuer'; @@ -59,6 +60,7 @@ class IdTokenResponseTest extends TestCase protected CryptKey $privateKey; protected IdTokenBuilder $idTokenBuilder; protected Stub $claimSetEntityFactoryStub; + protected MockObject $loggerMock; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -119,28 +121,31 @@ protected function setUp(): void new JsonWebTokenBuilderService($this->moduleConfigMock), new ClaimTranslatorExtractor(self::USER_ID_ATTR, $this->claimSetEntityFactoryStub), ); + + $this->loggerMock = $this->createMock(LoggerService::class); } - protected function prepareMockedInstance(): IdTokenResponse + protected function prepareMockedInstance(): TokenResponse { - $idTokenResponse = new IdTokenResponse( + $tokenResponse = new TokenResponse( $this->identityProviderMock, $this->idTokenBuilder, $this->privateKey, + $this->loggerMock, ); - $idTokenResponse->setNonce(null); - $idTokenResponse->setAuthTime(null); - $idTokenResponse->setAcr(null); - $idTokenResponse->setSessionId(null); + $tokenResponse->setNonce(null); + $tokenResponse->setAuthTime(null); + $tokenResponse->setAcr(null); + $tokenResponse->setSessionId(null); - return $idTokenResponse; + return $tokenResponse; } public function testItIsInitializable(): void { $this->assertInstanceOf( - IdTokenResponse::class, + TokenResponse::class, $this->prepareMockedInstance(), ); } diff --git a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php index daa8bf19..d24ed378 100644 --- a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php +++ b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php @@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\StreamFactory; +use Lcobucci\JWT\Signer\Rsa\Sha256; use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,6 +16,7 @@ use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Entities\ScopeEntity; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator; @@ -41,6 +43,7 @@ class BearerTokenValidatorTest extends TestCase protected static ClientEntityInterface $clientEntity; protected ServerRequestInterface $serverRequest; protected MockObject $publicKeyMock; + protected MockObject $moduleConfigMock; /** * @throws \Exception @@ -49,7 +52,13 @@ public function setUp(): void { $this->accessTokenRepositoryMock = $this->createMock(AccessTokenRepository::class); $this->serverRequest = new ServerRequest(); - $this->bearerTokenValidator = new BearerTokenValidator($this->accessTokenRepositoryMock, self::$publicCryptKey); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getProtocolSigner')->willReturn(new Sha256()); + $this->bearerTokenValidator = new BearerTokenValidator( + $this->accessTokenRepositoryMock, + self::$publicCryptKey, + $this->moduleConfigMock, + ); } /** @@ -221,6 +230,7 @@ public function testThrowsForRevokedAccessToken() $bearerTokenValidator = new BearerTokenValidator( $this->accessTokenRepositoryMock, self::$publicCryptKey, + $this->moduleConfigMock, ); $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . self::$accessToken); diff --git a/tests/unit/src/Services/AuthenticationServiceTest.php b/tests/unit/src/Services/AuthenticationServiceTest.php index deae3f3d..d03abdcd 100644 --- a/tests/unit/src/Services/AuthenticationServiceTest.php +++ b/tests/unit/src/Services/AuthenticationServiceTest.php @@ -148,7 +148,6 @@ public function mock(): AuthenticationService $this->moduleConfigMock, $this->processingChainFactoryMock, $this->stateServiceMock, - $this->helpersMock, $this->requestParamsResolverMock, $this->userEntityFactoryMock, ], @@ -332,7 +331,7 @@ public function testItAuthenticates(): void { $this->authSimpleMock->expects($this->once())->method('login')->with([]); - $this->mock()->authenticate($this->clientEntityMock); + $this->mock()->authenticateForClient($this->clientEntityMock); } /** @@ -399,7 +398,6 @@ public function testItProcessesRequest(bool $isAuthnPer): void $this->moduleConfigMock, $this->processingChainFactoryMock, $this->stateServiceMock, - $this->helpersMock, $this->requestParamsResolverMock, $this->userEntityFactoryMock, ]) @@ -408,7 +406,7 @@ public function testItProcessesRequest(bool $isAuthnPer): void $this->moduleConfigMock->method('getAuthProcFilters')->willReturn([]); $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); - $this->clientHelperMock->method('getFromRequest')->willReturn($this->clientEntityMock); + $this->authorizationRequestMock->method('getClient')->willReturn($this->clientEntityMock); $authenticationServiceMock->method('prepareStateArray')->with( $this->authSimpleMock, $this->clientEntityMock, @@ -490,7 +488,6 @@ public function testItRunAuthProcs(): void $this->moduleConfigMock, $this->processingChainFactoryMock, $this->stateServiceMock, - $this->helpersMock, $this->requestParamsResolverMock, $this->userEntityFactoryMock, ) extends AuthenticationService {