From da38910b8c1d29f648bd67a21ed4e35da690e5de Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Sun, 6 Apr 2025 12:23:57 +0200 Subject: [PATCH 01/70] Start with Credential Issuer and OAuth2 configuration discovery --- routing/routes/routes.php | 17 +++++++++++ src/Codebooks/RoutesEnum.php | 13 +++++++++ .../OAuth2ServerConfigurationController.php | 29 +++++++++++++++++++ ...redentialIssuerConfigurationController.php | 28 ++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 src/Controllers/OAuth2/OAuth2ServerConfigurationController.php create mode 100644 src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php diff --git a/routing/routes/routes.php b/routing/routes/routes.php index caad53c7..4a38bf23 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -17,7 +17,9 @@ 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\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -86,6 +88,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 +110,12 @@ $routes->add(RoutesEnum::FederationList->name, RoutesEnum::FederationList->value) ->controller([SubordinateListingsController::class, 'list']) ->methods([HttpMethodsEnum::GET->value]); + + /***************************************************************************************************************** + * OpenID Verifiable Credential Issuance + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::CredentialIssuerConfiguration->name, RoutesEnum::CredentialIssuerConfiguration->value) + ->controller([CredentialIssuerConfigurationController::class, 'configuration']) + ->methods([HttpMethodsEnum::GET->value]); }; diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6c17691a..4f1d4ebf 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -40,6 +40,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 +54,10 @@ enum RoutesEnum: string case FederationConfiguration = '.well-known/openid-federation'; case FederationFetch = 'federation/fetch'; case FederationList = 'federation/list'; + + /***************************************************************************************************************** + * OpenID Verifiable Credential Issuance + ****************************************************************************************************************/ + + case CredentialIssuerConfiguration = '.well-known/openid-credential-issuer'; } diff --git a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php new file mode 100644 index 00000000..50824eea --- /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 + } +} diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php new file mode 100644 index 00000000..0d85c687 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -0,0 +1,28 @@ +value => $this->moduleConfig->getIssuer(), + ]; + + return $this->routes->newJsonResponse($configuration); + } +} From 512b0ad64d74ce5c2e137ceaad9f766f36b6de28 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 10 Apr 2025 15:27:49 +0200 Subject: [PATCH 02/70] WIP Credential Issuer --- UPGRADE.md | 2 + composer.json | 2 +- config/module_oidc.php.dist | 16 +++ routing/services/services.yml | 1 + .../Federation/EntityStatementController.php | 117 ++++++++++-------- .../OAuth2ServerConfigurationController.php | 2 +- ...redentialIssuerConfigurationController.php | 105 ++++++++++++++++ src/ModuleConfig.php | 11 ++ src/Services/OpMetadataService.php | 16 ++- .../EntityStatementControllerTest.php | 6 + 10 files changed, 225 insertions(+), 53 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index a4efab84..88814a64 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,5 @@ +# TODO mivanci +* Move to specific simplesamlphp/openid release (composer.json). # Version 5 to 6 diff --git a/composer.json b/composer.json index f1c3a1a6..f07bf201 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", + "simplesamlphp/openid": "dev-wip-vci", "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 8c56930b..be115c27 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. @@ -477,4 +484,13 @@ $config = [ ModuleConfig::OPTION_LOGO_URI => null, ModuleConfig::OPTION_POLICY_URI => null, 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, ]; diff --git a/routing/services/services.yml b/routing/services/services.yml index 60f08be9..19359dfc 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -127,6 +127,7 @@ services: factory: [ '@SimpleSAML\Module\oidc\Factories\FederationFactory', 'build' ] SimpleSAML\OpenID\Jwks: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] + SimpleSAML\OpenID\Jwk: ~ # SSP SimpleSAML\Database: diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index 5f4494a7..c41d30d1 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,59 +74,66 @@ 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::Contacts->value => $this->moduleConfig->getContacts(), - ClaimsEnum::LogoUri->value => $this->moduleConfig->getLogoUri(), - ClaimsEnum::PolicyUri->value => $this->moduleConfig->getPolicyUri(), - ClaimsEnum::HomepageUri->value => $this->moduleConfig->getHomepageUri(), - ], - )), - 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::Contacts->value => $this->moduleConfig->getContacts(), + ClaimsEnum::LogoUri->value => $this->moduleConfig->getLogoUri(), + ClaimsEnum::PolicyUri->value => $this->moduleConfig->getPolicyUri(), + ClaimsEnum::HomepageUri->value => $this->moduleConfig->getHomepageUri(), ], + )), + 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 = []; @@ -186,16 +196,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 index 50824eea..9984d16a 100644 --- a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php +++ b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php @@ -24,6 +24,6 @@ public function __invoke(): JsonResponse ); // 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 + // 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 index 0d85c687..6a3e82ee 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -2,25 +2,130 @@ declare(strict_types=1); +/* + * | + * \ ___ / _________ + * _ / \ _ GÉANT | * * | Co-Funded by + * | ~ | Trust & Identity | * * | the European + * \_/ Incubator |__*_*__| Union + * = + */ + namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use Symfony\Component\HttpFoundation\Response; class CredentialIssuerConfigurationController { + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Routes $routes, ) { + if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); + } } public function configuration(): Response { + // TODO mivanci Abstract configuring Credential Issuer / Configuration away from module config. + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p + + $signer = $this->moduleConfig->getProtocolSigner(); + $configuration = [ ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + + // OPTIONAL // WND + // authorization_servers + + // REQUIRED + // TODO credential_endpoint + + // 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 => [ + 'ResearchAndScholarshipCredentialJwtVcJson' => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialJwtVcJson', + + // OPTIONAL + // cryptographic_binding_methods_supported + + // OPTIONAL + ClaimsEnum::CredentialSigningAlgValuesSupported->value => [ + $signer->algorithmId(), + ], + + // OPTIONAL + // proof_types_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 + ], + ], + + // As per appendix 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 => [ + [ + + ], + ], + ], + ], + ]; return $this->routes->newJsonResponse($configuration); diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 4a9fe3b8..3b377614 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -93,6 +93,7 @@ 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'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -773,4 +774,14 @@ public function isFederationParticipationLimitedByTrustMarksFor(string $trustAnc { return !empty($this->getTrustMarksNeededForFederationParticipationFor($trustAnchorId)); } + + + /***************************************************************************************************************** + * OpenID Verifiable Credential related config. + ****************************************************************************************************************/ + + public function getVerifiableCredentialEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_VERIFIABLE_CREDENTIAL_ENABLED, false); + } } 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/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, ); From 9d73bc566464d608c307b929a77469ada422ad3c Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 28 Apr 2025 15:30:25 +0200 Subject: [PATCH 03/70] To be removed --- src/Controllers/Federation/EntityStatementController.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index c41d30d1..bdd53983 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -77,9 +77,10 @@ public function configuration(): Response $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); $header = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile( - $this->moduleConfig->getFederationCertPath(), - ), + ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', + //ClaimsEnum::Kid->value => FingerprintGenerator::forFile( + // $this->moduleConfig->getFederationCertPath(), + //), ]; $payload = [ From f02db2c4d7faacdd8c0d02d118dcf6bb8f616632 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 28 Apr 2025 15:30:25 +0200 Subject: [PATCH 04/70] To be removed --- src/Controllers/Federation/EntityStatementController.php | 7 ++++--- src/Services/JsonWebKeySetService.php | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index c41d30d1..bdd53983 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -77,9 +77,10 @@ public function configuration(): Response $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); $header = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile( - $this->moduleConfig->getFederationCertPath(), - ), + ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', + //ClaimsEnum::Kid->value => FingerprintGenerator::forFile( + // $this->moduleConfig->getFederationCertPath(), + //), ]; $payload = [ diff --git a/src/Services/JsonWebKeySetService.php b/src/Services/JsonWebKeySetService.php index f02f9e05..1d2b9554 100644 --- a/src/Services/JsonWebKeySetService.php +++ b/src/Services/JsonWebKeySetService.php @@ -76,7 +76,8 @@ protected function prepareProtocolJwkSet(): void } $jwk = JWKFactory::createFromKeyFile($protocolPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), + //ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), + ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), ]); From 1a9c775e3c9adac679c7feabcc527164185b06d0 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 28 Apr 2025 16:06:27 +0200 Subject: [PATCH 05/70] Interop --- src/Factories/FederationFactory.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index 96503899..1ccefa52 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -35,6 +35,10 @@ public function build(): Federation SignatureAlgorithmEnum::ES256, SignatureAlgorithmEnum::ES384, SignatureAlgorithmEnum::ES512, + SignatureAlgorithmEnum::PS256, + SignatureAlgorithmEnum::PS384, + SignatureAlgorithmEnum::PS512, + S ), ); From e75ccda930f049ac7073f0620d93707ea452c888 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 28 Apr 2025 16:06:47 +0200 Subject: [PATCH 06/70] Interop --- src/Factories/FederationFactory.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index 1ccefa52..683d388c 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -38,7 +38,6 @@ public function build(): Federation SignatureAlgorithmEnum::PS256, SignatureAlgorithmEnum::PS384, SignatureAlgorithmEnum::PS512, - S ), ); From 8fcf3d52dd1e7cfdecf9f647577c937b1f95e890 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 28 Apr 2025 16:23:29 +0200 Subject: [PATCH 07/70] Interop --- src/Factories/CoreFactory.php | 3 +++ 1 file changed, 3 insertions(+) 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, ), ); From 4c54a793dae15cf169f402099b00c6bf7b25711e Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 28 Apr 2025 16:31:44 +0200 Subject: [PATCH 08/70] Revert hardcoded kid for interop --- .../Federation/EntityStatementController.php | 7 +++---- src/Services/JsonWebKeySetService.php | 11 +++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index bdd53983..c41d30d1 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -77,10 +77,9 @@ public function configuration(): Response $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); $header = [ - ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', - //ClaimsEnum::Kid->value => FingerprintGenerator::forFile( - // $this->moduleConfig->getFederationCertPath(), - //), + ClaimsEnum::Kid->value => FingerprintGenerator::forFile( + $this->moduleConfig->getFederationCertPath(), + ), ]; $payload = [ diff --git a/src/Services/JsonWebKeySetService.php b/src/Services/JsonWebKeySetService.php index cff64f67..8bb56868 100644 --- a/src/Services/JsonWebKeySetService.php +++ b/src/Services/JsonWebKeySetService.php @@ -76,8 +76,7 @@ protected function prepareProtocolJwkSet(): void } $jwk = JWKFactory::createFromKeyFile($protocolPublicKeyPath, null, [ - //ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), - ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), ]); @@ -90,9 +89,7 @@ protected function prepareProtocolJwkSet(): void ) { $newJwk = JWKFactory::createFromKeyFile($protocolNewPublicKeyPath, null, [ ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - //ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), - - ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), ]); @@ -111,9 +108,7 @@ protected function prepareFederationJwkSet(): void } $federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [ - //ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath), - - ClaimsEnum::Kid->value => '4fdbd515cda5cc0d2fc2f1124a1a3dc995741037bbd87451dc78fcd3251e025a', + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath), ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), ]); From 6d4245b3b2a553deec7fca2a655c4d2ea7a37721 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 7 May 2025 13:03:14 +0200 Subject: [PATCH 09/70] WIP --- routing/routes/routes.php | 8 +- src/Codebooks/RoutesEnum.php | 6 +- src/Controllers/Admin/ConfigController.php | 11 ++ ...redentialIssuerConfigurationController.php | 138 +++++++++++++++++- .../CredentialIssuerCredentialController.php | 34 +++++ src/Factories/TemplateFactory.php | 7 + src/ModuleConfig.php | 2 +- src/Services/AuthContextService.php | 8 +- src/Utils/Routes.php | 23 +++ templates/config/verifiable-credential.twig | 20 +++ 10 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php create mode 100644 templates/config/verifiable-credential.twig diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 4a38bf23..9a92de37 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -40,6 +40,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 @@ -112,10 +114,14 @@ ->methods([HttpMethodsEnum::GET->value]); /***************************************************************************************************************** - * OpenID Verifiable Credential Issuance + * 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([CredentialIssuerConfigurationController::class, 'credential']) + ->methods([HttpMethodsEnum::GET->value]); }; diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 4f1d4ebf..bdfad0bd 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'; @@ -45,7 +46,7 @@ enum RoutesEnum: string ****************************************************************************************************************/ // OAuth 2.0 Authorization Server Metadata https://www.rfc-editor.org/rfc/rfc8414.html - case OAuth2Configuration = '/.well-known/oauth-authorization-server'; + case OAuth2Configuration = '.well-known/oauth-authorization-server'; /***************************************************************************************************************** * OpenID Federation @@ -56,8 +57,9 @@ enum RoutesEnum: string case FederationList = 'federation/list'; /***************************************************************************************************************** - * OpenID Verifiable Credential Issuance + * OpenID for Verifiable Credential Issuance ****************************************************************************************************************/ case CredentialIssuerConfiguration = '.well-known/openid-credential-issuer'; + case CredentialIssuerCredential = 'credential-issuer/credential'; } diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index 85e35a6c..ac27d21d 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/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 6a3e82ee..de00135c 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -18,6 +18,7 @@ use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; +use SimpleSAML\OpenID\Codebooks\LanguageTagsEnum; use Symfony\Component\HttpFoundation\Response; class CredentialIssuerConfigurationController @@ -48,7 +49,7 @@ public function configuration(): Response // authorization_servers // REQUIRED - // TODO credential_endpoint + ClaimsEnum::CredentialEndpoint->value => $this->routes->urlCredentialIssuerCredential(), // OPTIONAL // nonce_endpoint @@ -81,7 +82,9 @@ public function configuration(): Response ClaimsEnum::CredentialConfigurationsSupported->value => [ 'ResearchAndScholarshipCredentialJwtVcJson' => [ + // REQUIRED ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + // OPTIONAL ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialJwtVcJson', // OPTIONAL @@ -117,10 +120,141 @@ public function configuration(): Response ], ], - // As per appendix A.1.1.2. https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-signed-as-a-jwt-not-usin + // 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 => [ + 'VerifiableCredential', // TODO mivanci CredentialTypesEnum + 'ResearchAndScholarshipCredentialJwtVcJson', ], ], ], diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php new file mode 100644 index 00000000..be366859 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -0,0 +1,34 @@ +moduleConfig->getVerifiableCredentialEnabled()) { + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); + } + } + + public function credential(): Response + { + return $this->routes->newJsonResponse( + [ + 'credential' => 'credential', + ], + ); + } +} diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 48986fcb..54505a73 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -141,6 +141,13 @@ 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'), + ), + ); } public function setShowMenu(bool $showMenu): TemplateFactory diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 3b377614..d0a96a5d 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -777,7 +777,7 @@ public function isFederationParticipationLimitedByTrustMarksFor(string $trustAnc /***************************************************************************************************************** - * OpenID Verifiable Credential related config. + * OpenID Verifiable Credential Issuance related config. ****************************************************************************************************************/ public function getVerifiableCredentialEnabled(): bool 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/Utils/Routes.php b/src/Utils/Routes.php index d9134231..715ac06e 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -146,6 +146,15 @@ public function urlAdminTestTrustMarkValidation(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->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 +207,18 @@ 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); + } } diff --git a/templates/config/verifiable-credential.twig b/templates/config/verifiable-credential.twig new file mode 100644 index 00000000..db1dcb39 --- /dev/null +++ b/templates/config/verifiable-credential.twig @@ -0,0 +1,20 @@ +{% set subPageTitle = 'Verifiable Credential Settings'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} +

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

+ +

{{ 'Entity'|trans }}

+

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

+

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

+ +{% endblock oidcContent -%} From fccdbe1c038155ff4a81cad92a3565b05fd783cc Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Tue, 13 May 2025 13:24:40 +0200 Subject: [PATCH 10/70] WIP --- routing/routes/routes.php | 12 +- routing/services/services.yml | 5 + src/Codebooks/ParametersEnum.php | 2 + src/Codebooks/RoutesEnum.php | 1 + ...oller.php => FederationTestController.php} | 2 +- .../VerifiableCredentailsTestController.php | 145 +++++++++++ src/Factories/AuthorizationServerFactory.php | 8 + src/Factories/Grant/AuthCodeGrantFactory.php | 3 + .../Grant/PreAuthCodeGrantFactory.php | 72 +++++ src/Factories/TemplateFactory.php | 7 + .../VerifiableCredentialsFactory.php | 47 ++++ src/Server/Grants/AuthCodeGrant.php | 2 + src/Server/Grants/PreAuthCodeGrant.php | 246 ++++++++++++++++++ src/Services/Container.php | 1 + .../tests/verifiable-credential-issuance.twig | 38 +++ 15 files changed, 587 insertions(+), 4 deletions(-) rename src/Controllers/Admin/{TestController.php => FederationTestController.php} (99%) create mode 100644 src/Controllers/Admin/VerifiableCredentailsTestController.php create mode 100644 src/Factories/Grant/PreAuthCodeGrantFactory.php create mode 100644 src/Factories/VerifiableCredentialsFactory.php create mode 100644 src/Server/Grants/PreAuthCodeGrant.php create mode 100644 templates/tests/verifiable-credential-issuance.twig diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 9a92de37..2f0c2c12 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -10,7 +10,8 @@ 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\AuthorizationController; use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Controllers\EndSessionController; @@ -66,11 +67,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 diff --git a/routing/services/services.yml b/routing/services/services.yml index 19359dfc..8dc339ef 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -60,6 +60,9 @@ 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'] @@ -125,6 +128,8 @@ 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: ~ 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 bdfad0bd..77ccee71 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -28,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'; /***************************************************************************************************************** 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 e05a6dc1..fadd64aa 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..02ecef57 --- /dev/null +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -0,0 +1,145 @@ +authorization->requireAdmin(true); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\CredentialOfferException + */ + public function verifiableCredentialIssuance(Request $request): Response + { + $sampleData = [ + 'eduPersonPrincipalName' => 'testuser@example.com', + 'eduPersonTargetedID' => 'abc123', + 'displayName' => 'Test User', + 'givenName' => 'Test', + 'sn' => 'User', + 'mail' => 'testuser@example.com', + 'eduPersonScopedAffiliation' => 'member@example.com', + ]; + + $this->loggerService->info('test', $sampleData);; + + // TODO mivanci Wallet (client) credential_offer_endpoint metadata + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata + + $clientId = '1234567890'; + $clientSecret = '1234567890'; + + if (($client = $this->clientRepository->findById($clientId)) === null) { + $client = $this->clientEntityFactory->fromData( + id: $clientId, + secret: $clientSecret, + name: 'VCI Test Client', + description: 'Test client for VCI', + redirectUri: ['https://example.com/oidc/callback'], + scopes: ['openid', 'ResearchAndScholarshipCredentialJwtVcJson'], + isEnabled: true, + ); + + $this->clientRepository->add($client); + ; + } + + $authCodeId = '1234567890'; + + // TODO mivanci Add indication of preauthz code to the auth code table. + + if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { + $authCode = $this->authCodeEntityFactory->fromData( + id: $authCodeId, + client: $client, + scopes: [ + new ScopeEntity('openid'), + new ScopeEntity('ResearchAndScholarshipCredentialJwtVcJson'), + ], + expiryDateTime: new \DateTimeImmutable('+1 month'), + userIdentifier: 'testuid', + redirectUri: 'https://example.com/oidc/callback', + nonce: '1234567890', + ); + + $this->authCodeRepository->persistNewAuthCode($authCode); + } + + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + 'ResearchAndScholarshipCredentialJwtVcJson', + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::PreAuthorizedCode->value => [ + ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), + // TODO mivanci support for TxCode + // ClaimsEnum::TxCode->value => [ + // ClaimsEnum::InputMode->value => 'numeric', + // ClaimsEnum::Length->value => 6, + // ClaimsEnum::Description->value => 'Sent to user mail', + // ], + ], + ], + ], + ); + + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; + + // https://quickchart.io/documentation/qr-codes/ + $qrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); + + return $this->templateFactory->build( + 'oidc:tests/verifiable-credential-issuance.twig', + compact('qrUri', 'sampleData', 'credentialOfferUri'), + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + ); + } +} diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 54cf5d38..31ed11b5 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -24,6 +24,7 @@ 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; @@ -41,6 +42,7 @@ public function __construct( private readonly IdTokenResponse $idTokenResponse, private readonly RequestRulesManager $requestRulesManager, private readonly CryptKey $privateKey, + private readonly PreAuthCodeGrant $preAuthCodeGrant, ) { } @@ -71,6 +73,12 @@ public function build(): AuthorizationServer $this->moduleConfig->getAccessTokenDuration(), ); + // TODO mivanci Only enable if VCI is enabled. + $authorizationServer->enableGrantType( + $this->preAuthCodeGrant, + $this->moduleConfig->getAccessTokenDuration(), + ); + return $authorizationServer; } } diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index a72a53c4..db6168a4 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..abe4e180 --- /dev/null +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -0,0 +1,72 @@ +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/TemplateFactory.php b/src/Factories/TemplateFactory.php index 54505a73..7e26f6e5 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -148,6 +148,13 @@ protected function includeDefaultMenuItems(): void 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/VerifiableCredentialsFactory.php b/src/Factories/VerifiableCredentialsFactory.php new file mode 100644 index 00000000..85cdfda5 --- /dev/null +++ b/src/Factories/VerifiableCredentialsFactory.php @@ -0,0 +1,47 @@ +moduleConfig->getProtocolSigner()->algorithmId()), + 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/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index dfaac1cf..2656f7ec 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -59,6 +59,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 +166,7 @@ public function __construct( protected AuthCodeEntityFactory $authCodeEntityFactory, protected RefreshTokenIssuer $refreshTokenIssuer, protected Helpers $helpers, + protected LoggerService $loggerService, ) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php new file mode 100644 index 00000000..7a4e9b24 --- /dev/null +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -0,0 +1,246 @@ +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'); + } + + /** + * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + */ + protected function issueOidcAuthCode( + DateInterval $authCodeTTL, + OAuth2ClientEntityInterface $client, + string $userIdentifier, + string $redirectUri, + array $scopes = [], + ?string $nonce = null, + ): 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 client authentication + + $this->loggerService->debug( + 'Pre-authorized code grant respondToAccessTokenRequest', + $this->requestParamsResolver->getAllFromRequest($request) + ); + + $preAuthorizedCodeId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::PreAuthorizedCode->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (empty($preAuthorizedCodeId)) { + 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) + ) { + throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); + } + + if ($preAuthorizedCode->isRevoked()) { + throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); + } + + $client = $preAuthorizedCode->getClient(); + + // TODO validate code + // $this->validateAuthorizationCode($preAuthorizedCode, $client, $request); + + // TODO handle tx_code parameter +// $txCode = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( +// ParamsEnum::TxCode->value, +// $request, +// $this->allowedTokenHttpMethods, +// ); + + $authorizationDetails = null; + $authorizationDetailsParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::AuthorizationDetails->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (!empty($authorizationDetailsParam)) { + $authorizationDetails = json_decode($authorizationDetailsParam, true, 512, JSON_THROW_ON_ERROR); + } + + if ($authorizationDetails !== null) { + if (!is_array($authorizationDetails)) { + throw OidcServerException::invalidRequest(ParamsEnum::AuthorizationDetails->value); + } + } + + // TODO handle authorization_details parameter, add to response. +//dd($authorizationDetails); + + + // Issue and persist new access token + $accessToken = $this->issueAccessToken( + $accessTokenTTL, + $client, + $preAuthorizedCode->getUserIdentifier() ? (string) $preAuthorizedCode->getUserIdentifier() : null, + [], // TODO mivanci handle scopes + $preAuthorizedCodeId, + ); + + $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $responseType->setAccessToken($accessToken); + + + + 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, + ): void { + } + + /** + * @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/Services/Container.php b/src/Services/Container.php index 3570fbad..0d1ea320 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -447,6 +447,7 @@ public function __construct() $authCodeEntityFactory, $refreshTokenIssuer, $helpers, + $loggerService, ); $this->services[AuthCodeGrant::class] = $authCodeGrantFactory->build(); diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig new file mode 100644 index 00000000..38b2e4fe --- /dev/null +++ b/templates/tests/verifiable-credential-issuance.twig @@ -0,0 +1,38 @@ +{% set subPageTitle = 'Test Verifiable Credential Issuance'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + +

Pre-Authorized Code

+

+ {{ 'You can use info below to test Verifiable Credential Issuance.'|trans }} +

+

+ (1) End-User provides information required for the issuance of a certain Credential +
+ User data sample: + + {{- sampleData|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+

+ (2) Credential Offer (Pre-Authorized Code) + + {{- credentialOfferUri -}} + +

+ + QR Code + +

+ (3) Obtains Issuer's Credential Issuer metadata +
+ (4) Token Request (Pre-Authorized Code, tx_code) + Token Response (access_token) +
+ (5) Credential Request (access_token, proof(s)) + Credential Response (Credential(s)) +

+ +{% endblock oidcContent -%} From 1d806d56c1c9c39a7702e782a8beb272512a75ee Mon Sep 17 00:00:00 2001 From: www-data Date: Wed, 14 May 2025 15:18:14 +0200 Subject: [PATCH 11/70] WIP --- routing/routes/routes.php | 5 +- .../VerifiableCredentailsTestController.php | 10 +-- ...redentialIssuerConfigurationController.php | 3 +- .../CredentialIssuerCredentialController.php | 77 ++++++++++++++++++- src/Factories/Grant/AuthCodeGrantFactory.php | 2 +- .../Grant/PreAuthCodeGrantFactory.php | 3 +- src/Server/Grants/PreAuthCodeGrant.php | 2 +- src/Services/Container.php | 18 +++++ 8 files changed, 103 insertions(+), 17 deletions(-) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 2f0c2c12..1e142f5a 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -21,6 +21,7 @@ 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\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -128,6 +129,6 @@ ->methods([HttpMethodsEnum::GET->value]); $routes->add(RoutesEnum::CredentialIssuerCredential->name, RoutesEnum::CredentialIssuerCredential->value) - ->controller([CredentialIssuerConfigurationController::class, 'credential']) - ->methods([HttpMethodsEnum::GET->value]); + ->controller([CredentialIssuerCredentialController::class, 'credential']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); }; diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 02ecef57..9cbabdf4 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -11,17 +11,13 @@ use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; -use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\Debug\ArrayLogger; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; -use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\VerifiableCredentials; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class VerifiableCredentailsTestController @@ -35,8 +31,6 @@ public function __construct( protected readonly AuthCodeEntityFactory $authCodeEntityFactory, protected readonly ClientRepository $clientRepository, protected readonly ClientEntityFactory $clientEntityFactory, - protected readonly Federation $federation, - protected readonly Helpers $helpers, protected readonly LoggerService $loggerService, ) { $this->authorization->requireAdmin(true); @@ -47,7 +41,7 @@ public function __construct( * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException * @throws \SimpleSAML\OpenID\Exceptions\CredentialOfferException */ - public function verifiableCredentialIssuance(Request $request): Response + public function verifiableCredentialIssuance(): Response { $sampleData = [ 'eduPersonPrincipalName' => 'testuser@example.com', @@ -59,7 +53,7 @@ public function verifiableCredentialIssuance(Request $request): Response 'eduPersonScopedAffiliation' => 'member@example.com', ]; - $this->loggerService->info('test', $sampleData);; + $this->loggerService->info('test', $sampleData); // TODO mivanci Wallet (client) credential_offer_endpoint metadata // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index de00135c..5f1fa4a9 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -18,6 +18,7 @@ use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; +use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; use SimpleSAML\OpenID\Codebooks\LanguageTagsEnum; use Symfony\Component\HttpFoundation\Response; @@ -253,7 +254,7 @@ public function configuration(): Response // REQUIRED ClaimsEnum::CredentialDefinition->value => [ ClaimsEnum::Type->value => [ - 'VerifiableCredential', // TODO mivanci CredentialTypesEnum + CredentialTypesEnum::VerifiableCredential->value, 'ResearchAndScholarshipCredentialJwtVcJson', ], ], diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index be366859..e9590132 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -4,9 +4,20 @@ namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; +use League\OAuth2\Server\ResourceServer; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Utils\FingerprintGenerator; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\AtContextsEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; +use SimpleSAML\OpenID\Jwk; +use SimpleSAML\OpenID\VerifiableCredentials; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class CredentialIssuerCredentialController @@ -15,19 +26,81 @@ class CredentialIssuerCredentialController * @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, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); } } - public function credential(): Response + public function credential(Request $request): Response { + $authorization = $this->resourceServer->validateAuthenticatedRequest( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + ); + + // TODO mivanci validate + $accessToken = $this->accessTokenRepository->findById($authorization->getAttribute('oauth_access_token_id')); + if ($accessToken->isRevoked()) { + throw OidcServerException::accessDenied('Access token is revoked.'); + } + + // TODO mivanci validate requested credential identifier + + $jwk = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( + $this->moduleConfig->getProtocolPrivateKeyPath(), + null, + ); + + $issuedAt = new \DateTimeImmutable(); + + $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( + $jwk, + SignatureAlgorithmEnum::RS256, + [ + ClaimsEnum::Vc->value => [ + ClaimsEnum::AtContext->value => [ + AtContextsEnum::W3Org2018CredentialsV1->value, + ], + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + 'ResearchAndScholarshipCredentialJwtVcJson', + ], + ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Credential_Subject->value => [ + 'eduPersonPrincipalName' => 'testuser@example.com', + 'eduPersonTargetedID' => 'abc123', + 'displayName' => 'Test User', + 'givenName' => 'Test', + 'sn' => 'User', + 'mail' => 'testuser@example.com', + 'eduPersonScopedAffiliation' => 'member@example.com', + ], + ], + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => 'testuid', + ], + [ + ClaimsEnum::Kid->value => FingerprintGenerator::forFile( + $this->moduleConfig->getProtocolCertPath(), + ), + ], + ); + return $this->routes->newJsonResponse( [ - 'credential' => 'credential', + 'credentials' => [ + 'credential' => $verifiableCredential->getToken(), + ], ], ); } diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index db6168a4..5b90a452 100644 --- a/src/Factories/Grant/AuthCodeGrantFactory.php +++ b/src/Factories/Grant/AuthCodeGrantFactory.php @@ -62,7 +62,7 @@ public function build(): AuthCodeGrant $this->authCodeEntityFactory, $this->refreshTokenIssuer, $this->helpers, - $this->loggerService + $this->loggerService, ); $authCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/Grant/PreAuthCodeGrantFactory.php b/src/Factories/Grant/PreAuthCodeGrantFactory.php index abe4e180..9e241c3f 100644 --- a/src/Factories/Grant/PreAuthCodeGrantFactory.php +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -23,7 +23,6 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; -use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; @@ -63,7 +62,7 @@ public function build(): PreAuthCodeGrant $this->authCodeEntityFactory, $this->refreshTokenIssuer, $this->helpers, - $this->loggerService + $this->loggerService, ); $preAuthCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index 7a4e9b24..daca234d 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -114,7 +114,7 @@ public function respondToAccessTokenRequest( $this->loggerService->debug( 'Pre-authorized code grant respondToAccessTokenRequest', - $this->requestParamsResolver->getAllFromRequest($request) + $this->requestParamsResolver->getAllFromRequest($request), ); $preAuthorizedCodeId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( diff --git a/src/Services/Container.php b/src/Services/Container.php index 0d1ea320..f33e4ca2 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -51,6 +51,7 @@ 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; @@ -71,6 +72,7 @@ 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; @@ -469,6 +471,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, @@ -480,6 +497,7 @@ public function __construct() $this->services[IdTokenResponse::class], $requestRuleManager, $privateKey, + $this->services[PreAuthCodeGrant::class], ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); From b9ba59434e20a659c2c9723352273a83f8f93529 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 16:25:57 +0200 Subject: [PATCH 12/70] WIP --- .../CredentialIssuerCredentialController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index e9590132..fa112325 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -9,6 +9,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FingerprintGenerator; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -33,6 +34,7 @@ public function __construct( protected readonly PsrHttpBridge $psrHttpBridge, protected readonly VerifiableCredentials $verifiableCredentials, protected readonly Jwk $jwk, + protected readonly LoggerService $loggerService, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); @@ -41,6 +43,9 @@ public function __construct( public function credential(Request $request): Response { + $this->loggerService->info('credential', $request->request->all()); + + $authorization = $this->resourceServer->validateAuthenticatedRequest( $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), ); From e65bbf6e9071ad2205afbfc90a711be112f14308 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 16:27:00 +0200 Subject: [PATCH 13/70] WIP --- .../CredentialIssuerCredentialController.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index fa112325..7a75c300 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -101,6 +101,12 @@ public function credential(Request $request): Response ], ); + $this->loggerService->debug('response', [ + 'credentials' => [ + 'credential' => $verifiableCredential->getToken(), + ], + ],); + return $this->routes->newJsonResponse( [ 'credentials' => [ From 25893571c0c14791885b1676e6066dd0c14f0670 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 18:53:05 +0200 Subject: [PATCH 14/70] WIP --- src/Factories/CryptKeyFactory.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index a7cc02ea..c5a7768f 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(), + false, // TODO mivanci Return to true ); } From 5b64d714838648da19208e882ffe9eadd7cd2679 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 19:08:45 +0200 Subject: [PATCH 15/70] WIP --- .../CredentialIssuerCredentialController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 7a75c300..e1729497 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -110,7 +110,7 @@ public function credential(Request $request): Response return $this->routes->newJsonResponse( [ 'credentials' => [ - 'credential' => $verifiableCredential->getToken(), + ['credential' => $verifiableCredential->getToken()], ], ], ); From 02625cb2f423de3214e3a9f2897e812723f28be1 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 19:38:20 +0200 Subject: [PATCH 16/70] WIP --- .../CredentialIssuerCredentialController.php | 2 +- src/Factories/CryptKeyFactory.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index e1729497..54767fa4 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -103,7 +103,7 @@ public function credential(Request $request): Response $this->loggerService->debug('response', [ 'credentials' => [ - 'credential' => $verifiableCredential->getToken(), + ['credential' => $verifiableCredential->getToken()], ], ],); diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index c5a7768f..51559621 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -31,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); } } From d3d1e76c94bdfcce2017f8c27e115ade8dd45464 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 19:54:49 +0200 Subject: [PATCH 17/70] WIP --- .../CredentialIssuerCredentialController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 54767fa4..b9713b02 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -92,7 +92,6 @@ public function credential(Request $request): Response ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => 'testuid', ], [ ClaimsEnum::Kid->value => FingerprintGenerator::forFile( From bfbc3c0decc6b86abbefbd3a52b0d72f5fcb982d Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 20:09:03 +0200 Subject: [PATCH 18/70] WIP --- .../CredentialIssuerCredentialController.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index b9713b02..05c8c321 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FingerprintGenerator; +use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\AtContextsEnum; @@ -35,6 +36,7 @@ public function __construct( protected readonly VerifiableCredentials $verifiableCredentials, protected readonly Jwk $jwk, protected readonly LoggerService $loggerService, + protected readonly RequestParamsResolver $requestParamsResolver, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); @@ -43,7 +45,10 @@ public function __construct( public function credential(Request $request): Response { - $this->loggerService->info('credential', $request->request->all()); + $this->loggerService->info('Request data: ', + $this->requestParamsResolver->getAllFromRequest( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + )); $authorization = $this->resourceServer->validateAuthenticatedRequest( From f4c9788d6be1156bb08b0d31584918aa6cb2116d Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 20:21:40 +0200 Subject: [PATCH 19/70] WIP --- .../CredentialIssuerCredentialController.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 05c8c321..bfc77840 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -113,9 +113,7 @@ public function credential(Request $request): Response return $this->routes->newJsonResponse( [ - 'credentials' => [ - ['credential' => $verifiableCredential->getToken()], - ], + "error" => "unsupported_credential_format", ], ); } From 435f5124fbdda94bb4387d5c8786021b3038bcf8 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 20:30:47 +0200 Subject: [PATCH 20/70] WIP --- .../CredentialIssuerCredentialController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index bfc77840..fbaf6699 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -85,6 +85,7 @@ public function credential(Request $request): Response ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), ClaimsEnum::Credential_Subject->value => [ + ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/cred/1234567890', 'eduPersonPrincipalName' => 'testuser@example.com', 'eduPersonTargetedID' => 'abc123', 'displayName' => 'Test User', @@ -97,6 +98,7 @@ public function credential(Request $request): Response ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/cred/1234567890', ], [ ClaimsEnum::Kid->value => FingerprintGenerator::forFile( From ef510473f90435c639c4d7e419f94e3b7397672d Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 14 May 2025 20:31:45 +0200 Subject: [PATCH 21/70] WIP --- .../CredentialIssuerCredentialController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index fbaf6699..929b2352 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -84,8 +84,9 @@ public function credential(Request $request): Response ], ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', ClaimsEnum::Credential_Subject->value => [ - ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/cred/1234567890', + ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/sub/1234567890', 'eduPersonPrincipalName' => 'testuser@example.com', 'eduPersonTargetedID' => 'abc123', 'displayName' => 'Test User', @@ -98,7 +99,8 @@ public function credential(Request $request): Response ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/cred/1234567890', + ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/1234567890', + ClaimsEnum::Jti->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', ], [ ClaimsEnum::Kid->value => FingerprintGenerator::forFile( From 70866d7155957d7d6bbcd247ed92a80bc8284c3e Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 15 May 2025 09:04:03 +0200 Subject: [PATCH 22/70] WIP --- .../CredentialIssuerCredentialController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 929b2352..6d9796d0 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -119,6 +119,7 @@ public function credential(Request $request): Response [ "error" => "unsupported_credential_format", ], + 400, ); } } From bd7447c4bb7d1711196851b4afedf25d6d8a97fa Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 15 May 2025 09:09:11 +0200 Subject: [PATCH 23/70] WIP --- .../CredentialIssuerCredentialController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 6d9796d0..a7ca6bdc 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -117,9 +117,10 @@ public function credential(Request $request): Response return $this->routes->newJsonResponse( [ - "error" => "unsupported_credential_format", + 'credentials' => [ + ['credential' => $verifiableCredential->getToken()], + ] ], - 400, ); } } From 2725b3d8557f23ef76657a4c55b6e53476f79f75 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 15 May 2025 10:00:00 +0200 Subject: [PATCH 24/70] WIP --- .../CredentialIssuerCredentialController.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index a7ca6bdc..febec058 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -116,11 +116,12 @@ public function credential(Request $request): Response ],); return $this->routes->newJsonResponse( - [ - 'credentials' => [ - ['credential' => $verifiableCredential->getToken()], - ] - ], + ['credential' => $verifiableCredential->getToken()], +// [ +// 'credentials' => [ +// ['credential' => $verifiableCredential->getToken()], +// ] +// ], ); } } From 07cec9fe802690d35b3909c2ec44a6eee4e88218 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 15 May 2025 13:08:01 +0200 Subject: [PATCH 25/70] WIP --- README.md | 34 ++++++++++++++++++- .../CredentialIssuerCredentialController.php | 2 +- .../Validators/BearerTokenValidator.php | 6 ++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e68c6775..9fb49e39 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ the following parameters configured: > [!NOTE] > The module has been tested with and supports SQLite, PostgreSQL, and MySQL databases. -### Create Protocol / Federation RSA key pairs +### Create Protocol / Federation pairs During the authentication flow, the generated ID Token and Access Token will be in the form of signed JSON Web Tokens (JWS). For signing these tokens, you need to create a public/private RSA key pair, referred to as "OIDC protocol" keys. @@ -86,6 +86,7 @@ For signing these tokens, you need to create a public/private RSA key pair, refe If you plan to use OpenID Federation capabilities, you should create a separate key pair dedicated to OpenID Federation operations, such as signing Entity Statement JWS. +#### RSA key pair generation Below are sample commands to create key pairs with default file names for both "protocol" and "federation" purposes: To generate the private keys without a passphrase: @@ -112,6 +113,37 @@ With passphrase: If you use different file names or a passphrase, be sure to update these settings in the `module_oidc.php` configuration file. +#### EC key pair generation + +If you prefer to use Elliptic Curve Cryptography (ECC) instead of RSA, you can generate the key pair using the +following commands: + +To generate the private keys without a passphrase: + + openssl ecparam -name prime256v1 -genkey -noout -out cert/oidc_module.key + openssl ecparam -name prime256v1 -genkey -noout -out cert/oidc_module_federation.key + +To generate the private keys with a passphrase: + + 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 + +Next, extract the public key from each private key: + +Without passphrase: + + 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 passphrase: + + 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, be sure to update these settings in the `module_oidc.php` +configuration file. + + ### Enabling the module To enable the module, add `'oidc' => true` to the list of enabled modules in the main SimpleSAMLphp diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index febec058..e26340ed 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -72,7 +72,7 @@ public function credential(Request $request): Response $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( $jwk, - SignatureAlgorithmEnum::RS256, + SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()), [ ClaimsEnum::Vc->value => [ ClaimsEnum::AtContext->value => [ diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 0a371aa4..fac09685 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -18,6 +18,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 +45,7 @@ class BearerTokenValidator extends OAuth2BearerTokenValidator public function __construct( AccessTokenRepositoryInterface $accessTokenRepository, CryptKey $publicKey, + protected readonly ModuleConfig $moduleConfig, ?DateInterval $jwtValidAtDateLeeway = null, protected LoggerService $loggerService = new LoggerService(), ) { @@ -72,7 +74,7 @@ public function setPublicKey(CryptKey $key): void protected function initJwtConfiguration(): void { $this->jwtConfiguration = Configuration::forSymmetricSigner( - new Sha256(), + $this->moduleConfig->getProtocolSigner(), InMemory::plainText('empty', 'empty'), ); @@ -80,7 +82,7 @@ protected function initJwtConfiguration(): void $this->jwtConfiguration->setValidationConstraints( 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() ?? ''), ), ); From 559a9c508472f5277a62642d242e06e2c3c6019d Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 15 May 2025 13:46:32 +0200 Subject: [PATCH 26/70] WIP --- .../CredentialIssuerCredentialController.php | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index e26340ed..def76cdd 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Controllers\VerifiableCredentials; +use Base64Url\Base64Url; use League\OAuth2\Server\ResourceServer; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\ModuleConfig; @@ -63,15 +64,29 @@ public function credential(Request $request): Response // TODO mivanci validate requested credential identifier - $jwk = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( + $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(); $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( - $jwk, + $signingKey, SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()), [ ClaimsEnum::Vc->value => [ @@ -82,7 +97,9 @@ public function credential(Request $request): Response CredentialTypesEnum::VerifiableCredential->value, 'ResearchAndScholarshipCredentialJwtVcJson', ], - ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), +// ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), +// ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', ClaimsEnum::Credential_Subject->value => [ @@ -96,16 +113,16 @@ public function credential(Request $request): Response 'eduPersonScopedAffiliation' => 'member@example.com', ], ], - ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), +// ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), +// ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/1234567890', ClaimsEnum::Jti->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', ], [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile( - $this->moduleConfig->getProtocolCertPath(), - ), + ClaimsEnum::Kid->value => $issuerDid . '#0', ], ); From 9bce3f8036cf4d49c26a0b3180a49f6a0df0db4a Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 15 May 2025 13:54:43 +0200 Subject: [PATCH 27/70] WIP --- .../CredentialIssuerCredentialController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index def76cdd..c8c78e7c 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -98,8 +98,8 @@ public function credential(Request $request): Response 'ResearchAndScholarshipCredentialJwtVcJson', ], // ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), -// ClaimsEnum::Issuer->value => $issuerDid, - ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', + ClaimsEnum::Issuer->value => $issuerDid, +// ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', ClaimsEnum::Credential_Subject->value => [ @@ -114,8 +114,8 @@ public function credential(Request $request): Response ], ], // ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), -// ClaimsEnum::Iss->value => $issuerDid, - ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', + ClaimsEnum::Iss->value => $issuerDid, +// ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/1234567890', From bf4597e5a5c314c1e3068633c1efd9bfe9de0601 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 22 May 2025 10:47:16 +0200 Subject: [PATCH 28/70] WIP --- src/Services/Container.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Services/Container.php b/src/Services/Container.php index f33e4ca2..c11cf5e7 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -501,7 +501,11 @@ public function __construct() ); $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( From 2eda4a013f5ffb7cfe6d4fa20aa3c9c3e55cf5a2 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 29 May 2025 15:55:46 +0200 Subject: [PATCH 29/70] WIP --- config/module_oidc.php.dist | 186 +++++++++++++ .../VerifiableCredentailsTestController.php | 244 ++++++++++++------ ...redentialIssuerConfigurationController.php | 188 +------------- .../CredentialIssuerCredentialController.php | 135 +++++++--- src/Factories/AuthSimpleFactory.php | 5 + src/Factories/EmailFactory.php | 29 +++ src/ModuleConfig.php | 36 +++ src/Server/Grants/PreAuthCodeGrant.php | 4 +- .../Validators/BearerTokenValidator.php | 1 - src/Utils/Routes.php | 5 + .../tests/verifiable-credential-issuance.twig | 67 +++-- 11 files changed, 586 insertions(+), 314 deletions(-) create mode 100644 src/Factories/EmailFactory.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index be115c27..eead71a5 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -21,6 +21,10 @@ declare(strict_types=1); * 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. @@ -493,4 +497,186 @@ $config = [ // Enable or disable verifiable credentials capabilities. Default is disabled (false). ModuleConfig::OPTION_VERIFIABLE_CREDENTIAL_ENABLED => false, + + + // (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 => [ + '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 + + 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', + ], + ], + ], + + // 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']], + ], + ], ]; diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 9cbabdf4..d6170a04 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -4,20 +4,31 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; +use SimpleSAML\Auth\Simple; use SimpleSAML\Module\oidc\Admin\Authorization; +use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Entities\ScopeEntity; +use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; +use SimpleSAML\Module\oidc\Factories\EmailFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Services\SessionMessagesService; +use SimpleSAML\Module\oidc\Services\SessionService; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; +use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\VerifiableCredentials; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class VerifiableCredentailsTestController @@ -32,6 +43,15 @@ public function __construct( protected readonly ClientRepository $clientRepository, protected readonly ClientEntityFactory $clientEntityFactory, protected readonly LoggerService $loggerService, + protected readonly EmailFactory $emailFactory, + protected readonly ?ProtocolCache $protocolCache, + protected readonly SessionMessagesService $sessionMessagesService, + protected readonly AuthSimpleFactory $authSimpleFactory, + protected readonly SessionService $sessionService, + protected readonly SspBridge $sspBridge, + protected readonly Routes $routes, + protected readonly UserRepository $userRepository, + protected readonly UserEntityFactory $userEntityFactory, ) { $this->authorization->requireAdmin(true); } @@ -41,98 +61,174 @@ public function __construct( * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException * @throws \SimpleSAML\OpenID\Exceptions\CredentialOfferException */ - public function verifiableCredentialIssuance(): Response + public function verifiableCredentialIssuance(Request $request): Response { - $sampleData = [ - 'eduPersonPrincipalName' => 'testuser@example.com', - 'eduPersonTargetedID' => 'abc123', - 'displayName' => 'Test User', - 'givenName' => 'Test', - 'sn' => 'User', - 'mail' => 'testuser@example.com', - 'eduPersonScopedAffiliation' => 'member@example.com', - ]; - - $this->loggerService->info('test', $sampleData); - - // TODO mivanci Wallet (client) credential_offer_endpoint metadata - // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata - - $clientId = '1234567890'; - $clientSecret = '1234567890'; - - if (($client = $this->clientRepository->findById($clientId)) === null) { - $client = $this->clientEntityFactory->fromData( - id: $clientId, - secret: $clientSecret, - name: 'VCI Test Client', - description: 'Test client for VCI', - redirectUri: ['https://example.com/oidc/callback'], - scopes: ['openid', 'ResearchAndScholarshipCredentialJwtVcJson'], - isEnabled: true, - ); + $selectedAuthSourceId = $this->sessionService->getCurrentSession()->getData('vci', 'auth_source_id'); - $this->clientRepository->add($client); - ; + $authSource = null; + if (is_string($selectedAuthSourceId)) { + $authSource = $this->authSimpleFactory->forAuthSourceId($selectedAuthSourceId); } - $authCodeId = '1234567890'; - - // TODO mivanci Add indication of preauthz code to the auth code table. + // 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; + } - if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { - $authCode = $this->authCodeEntityFactory->fromData( - id: $authCodeId, - client: $client, - scopes: [ - new ScopeEntity('openid'), - new ScopeEntity('ResearchAndScholarshipCredentialJwtVcJson'), - ], - expiryDateTime: new \DateTimeImmutable('+1 month'), - userIdentifier: 'testuid', - redirectUri: 'https://example.com/oidc/callback', - nonce: '1234567890', - ); - $this->authCodeRepository->persistNewAuthCode($authCode); + if ( + $authSource instanceof Simple && + ($authSource->isAuthenticated() === false) && + is_string($selectedAuthSourceId) + ) { + $authSource->login(['ReturnTo' => $this->routes->urlAdminTestVerifiableCredentialIssuance()]); } + $authSourceIds = array_filter( + $this->sspBridge->auth()->source()->getSources(), + fn (string $authSourceId): bool => $authSourceId !== 'admin', + ); + - $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( - parameters: [ - ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::CredentialConfigurationIds->value => [ - 'ResearchAndScholarshipCredentialJwtVcJson', - ], - ClaimsEnum::Grants->value => [ - GrantTypesEnum::PreAuthorizedCode->value => [ - ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), - // TODO mivanci support for TxCode - // ClaimsEnum::TxCode->value => [ - // ClaimsEnum::InputMode->value => 'numeric', - // ClaimsEnum::Length->value => 6, - // ClaimsEnum::Description->value => 'Sent to user mail', - // ], + $credentialOfferQrUri = null; + $credentialOfferUri = null; + + if ( + $authSource instanceof Simple && + $authSource->isAuthenticated() + ) { + $userAttributes = $authSource->getAttributes(); + + $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $this->moduleConfig->getUserIdentifierAttribute(), + ); + + // Persist / update user entity. + $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); + + if ($userEntity) { + $userEntity->setClaims($userAttributes); + $this->userRepository->update($userEntity); + } else { + $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); + $this->userRepository->add($userEntity); + } + + /* TODO mivanci TX Code handling + $email = $this->emailFactory->build( + subject: 'VC Issuance Transaction code', + to: 'testuser@example.com', + ); + + $email->setData(['Transaction Code' => '1234']); + try { + $email->send(); + $this->sessionMessagesService->addMessage('Email with tx code sent to: testuser@example.com'); + } catch (Exception $e) { + $this->sessionMessagesService->addMessage('Error emailing tx code.'); + } + */ + + // TODO mivanci Wallet (client) credential_offer_endpoint metadata + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata + + // TODO mivanci Implement API and API clients hanlding, together with API client to OIDC client binding. + $clientId = '1234567890'; + $clientSecret = '1234567890'; + + if (($client = $this->clientRepository->findById($clientId)) === null) { + $client = $this->clientEntityFactory->fromData( + id: $clientId, + secret: $clientSecret, + name: 'VCI Pre-authorized Code Test Client', + description: 'Test client for VCI Pre-authorized Code', + redirectUri: ['https://example.com/oidc/callback'], + scopes: ['openid', 'ResearchAndScholarshipCredentialJwtVcJson'], // TODO mivanci from config + isEnabled: true, + ); + + $this->clientRepository->add($client); + } + $authCodeId = '1234567890'; + + // TODO mivanci Add indication of preauthz code to the auth code table. + + if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { + $authCode = $this->authCodeEntityFactory->fromData( + id: $authCodeId, + client: $client, + scopes: [ + new ScopeEntity('openid'), + new ScopeEntity('ResearchAndScholarshipCredentialJwtVcJson'), + ], + expiryDateTime: new \DateTimeImmutable('+1 month'), + userIdentifier: $userId, + redirectUri: 'https://example.com/oidc/callback', + nonce: '1234567890', + ); + + $this->authCodeRepository->persistNewAuthCode($authCode); + } + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + 'ResearchAndScholarshipCredentialJwtVcJson', // TODO mivanci from config + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::PreAuthorizedCode->value => [ + ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), + // TODO mivanci support for TxCode + // ClaimsEnum::TxCode->value => [ + // ClaimsEnum::InputMode->value => 'numeric', + // ClaimsEnum::Length->value => 6, + // ClaimsEnum::Description->value => 'Sent to user mail', + // ], + ], ], ], - ], - ); + ); - $credentialOfferValue = $credentialOffer->jsonSerialize(); - $parameterName = ParametersEnum::CredentialOfferUri->value; - if (is_array($credentialOfferValue)) { - $parameterName = ParametersEnum::CredentialOffer->value; - $credentialOfferValue = json_encode($credentialOfferValue); + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; + + // https://quickchart.io/documentation/qr-codes/ + $credentialOfferQrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); } - $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; - // https://quickchart.io/documentation/qr-codes/ - $qrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); + + + + $authSourceActionRoute = $this->routes->urlAdminTestVerifiableCredentialIssuance(); return $this->templateFactory->build( 'oidc:tests/verifiable-credential-issuance.twig', - compact('qrUri', 'sampleData', 'credentialOfferUri'), + compact( + 'credentialOfferQrUri', + 'credentialOfferUri', + 'authSourceIds', + 'authSourceActionRoute', + 'authSource', + ), RoutesEnum::AdminTestVerifiableCredentialIssuance->value, ); } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 5f1fa4a9..4c21c926 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -17,9 +17,6 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; -use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; -use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; -use SimpleSAML\OpenID\Codebooks\LanguageTagsEnum; use Symfony\Component\HttpFoundation\Response; class CredentialIssuerConfigurationController @@ -43,6 +40,11 @@ public function configuration(): Response $signer = $this->moduleConfig->getProtocolSigner(); + $credentialConfigurationsSupported = $this->moduleConfig->getCredentialConfigurationsSupported(); + $credentialConfigurationsSupported[ClaimsEnum::CredentialSigningAlgValuesSupported->value] = [ + $signer->algorithmId(), + ]; + $configuration = [ ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), @@ -81,185 +83,7 @@ public function configuration(): Response ], - ClaimsEnum::CredentialConfigurationsSupported->value => [ - 'ResearchAndScholarshipCredentialJwtVcJson' => [ - // REQUIRED - ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, - // OPTIONAL - ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialJwtVcJson', - - // OPTIONAL - // cryptographic_binding_methods_supported - - // OPTIONAL - ClaimsEnum::CredentialSigningAlgValuesSupported->value => [ - $signer->algorithmId(), - ], - - // OPTIONAL - // proof_types_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', - ], - ], - ], - ], + ClaimsEnum::CredentialConfigurationsSupported->value => $credentialConfigurationsSupported, ]; diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index c8c78e7c..7e2471d1 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -9,9 +9,9 @@ use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; +use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\FingerprintGenerator; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -38,31 +38,90 @@ public function __construct( protected readonly Jwk $jwk, protected readonly LoggerService $loggerService, protected readonly RequestParamsResolver $requestParamsResolver, + protected readonly UserRepository $userRepository, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { 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 + */ public function credential(Request $request): Response { - $this->loggerService->info('Request data: ', - $this->requestParamsResolver->getAllFromRequest( - $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), - )); - + $this->loggerService->info( + 'Credential request data: ', + $this->requestParamsResolver->getAllFromRequest( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + ), + ); $authorization = $this->resourceServer->validateAuthenticatedRequest( $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), ); - // TODO mivanci validate + // TODO mivanci validate access token $accessToken = $this->accessTokenRepository->findById($authorization->getAttribute('oauth_access_token_id')); if ($accessToken->isRevoked()) { throw OidcServerException::accessDenied('Access token is revoked.'); } - // TODO mivanci validate requested credential identifier + // TODO mivanci validate credential request, including proof. Sample: + /** + * 'credential_definition' => + * array ( + * 'type' => + * array ( + * 0 => 'VerifiableCredential', + * 1 => 'ResearchAndScholarshipCredentialJwtVcJson', + * ), + * ), + * 'format' => 'jwt_vc_json', + * 'proof' => + * array ( + * 'proof_type' => 'jwt', + * 'jwt' => 'eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2Iiwia2lkIjoiZGlkOmtleTp6MmRtekQ4MWNnUHg4VmtpN0pidXVNbUZZcldQZ1lveXR5a1VaM2V5cWh0MWo5S2JyU2ZYMkJVeHNVaW5QbVA3QUVzZEN4OWpQYlV0ZkIzWXN2MTd4TGpyZkMxeDNVZmlMTWtyeWdTZDJMeWltQ3RGejhHWlBqOFFrMUJFU0F6M21LWGRCTEpuUHNNQ0R4Nm9QNjNuZVpmR1NKelF5SjRLVlN6Nmt4UTJQOTE4NGdXS1FnI3oyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWcifQ.eyJhdWQiOiJodHRwczovL2lkcC5taXZhbmNpLmluY3ViYXRvci5oZXhhYS5ldSIsImlhdCI6MTc0ODUxNDE0NywiZXhwIjoxNzQ4NTE0ODA3LCJpc3MiOiJkaWQ6a2V5OnoyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWciLCJqdGkiOiJiMmNlZDQ2Yi0zOWNiLTRkZDAtYmQxZS1hNzY5ZWNlOWUxMTIifQ.SPdMSnrfF8ybhfYluzz5OrfWJQDOpCu7-of8zVbp5UR89GaB7j14Egext1h9pYgl6JwIP8zibUjTSc8JLVYuvA', + * ), + * ) + */ + + $userId = $accessToken->getUserIdentifier(); + $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); + if ($userEntity === null) { + throw OidcServerException::invalidRequest('User not found'); + } + + $userAttributes = $userEntity->getClaims(); + + // TODO mivanci Resolve credential configuration ID from the credential request. Validate that the credential + // configuration ID is supported (check module configuration). + $credentialConfigurationId = 'ResearchAndScholarshipCredentialJwtVcJson'; + + // 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($credentialConfigurationId); + + + // Map user attributes to credential claims + $credentialSubject = []; + $attributeToCredentialClaimPathMap = $this->moduleConfig->getUserAttributeToCredentialClaimPathMapFor( + $credentialConfigurationId, + ); + foreach ($attributeToCredentialClaimPathMap as $mapEntry) { + $userAttributeName = key($mapEntry); + $credentialClaimPath = current($mapEntry); + if (isset($userAttributes[$userAttributeName])) { + $this->setCredentialClaimValue( + $credentialSubject, + $credentialClaimPath, + $userAttributes[$userAttributeName], + ); + } + } $signingKey = $this->jwk->jwkDecoratorFactory()->fromPkcs1Or8KeyFile( $this->moduleConfig->getProtocolPrivateKeyPath(), @@ -74,7 +133,7 @@ public function credential(Request $request): Response null, [ //ClaimsEnum::Use->value => 'sig', - ] + ], ); $base64PublicKey = json_encode($publicKey->jwk()->all(), JSON_UNESCAPED_SLASHES); @@ -97,29 +156,21 @@ public function credential(Request $request): Response CredentialTypesEnum::VerifiableCredential->value, 'ResearchAndScholarshipCredentialJwtVcJson', ], -// ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuer->value => $issuerDid, -// ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', + //ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), - ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', - ClaimsEnum::Credential_Subject->value => [ - ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/sub/1234567890', - 'eduPersonPrincipalName' => 'testuser@example.com', - 'eduPersonTargetedID' => 'abc123', - 'displayName' => 'Test User', - 'givenName' => 'Test', - 'sn' => 'User', - 'mail' => 'testuser@example.com', - 'eduPersonScopedAffiliation' => 'member@example.com', - ], + ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/vc/' . uniqid(), + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], ], -// ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iss->value => $issuerDid, -// ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', + //ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/1234567890', - ClaimsEnum::Jti->value => $this->moduleConfig->getIssuer() . '/vc/1234567890', + ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/' . $userId, + ClaimsEnum::Jti->value => $this->moduleConfig->getIssuer() . '/vc/' . uniqid(), ], [ ClaimsEnum::Kid->value => $issuerDid . '#0', @@ -134,11 +185,33 @@ public function credential(Request $request): Response return $this->routes->newJsonResponse( ['credential' => $verifiableCredential->getToken()], -// [ -// 'credentials' => [ -// ['credential' => $verifiableCredential->getToken()], -// ] -// ], + // [ + // 'credentials' => [ + // ['credential' => $verifiableCredential->getToken()], + // ] + // ], ); } + + /** + * Helper method to set a claim value at a path. Supports creating nested arrays dynamically. + */ + protected function setCredentialClaimValue(array &$claims, array $path, mixed $value): void + { + $temp = &$claims; + + foreach ($path as $key) { + 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/Factories/AuthSimpleFactory.php b/src/Factories/AuthSimpleFactory.php index 0f708ce2..7767824a 100644 --- a/src/Factories/AuthSimpleFactory.php +++ b/src/Factories/AuthSimpleFactory.php @@ -56,4 +56,9 @@ public function resolveAuthSourceId(ClientEntityInterface $client): string { return $client->getAuthSourceId() ?? $this->moduleConfig->getDefaultAuthSourceId(); } + + public function forAuthSourceId(string $authSourceId): Simple + { + return new Simple($authSourceId); + } } 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 @@ +value => [ @@ -784,4 +788,36 @@ 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, []) ?? []; + } + + /** + * 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 = []; + foreach ($claimsConfig as $claim) { + $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 + { + return $this->getUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; + } } diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index daca234d..707fe267 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -110,7 +110,7 @@ public function respondToAccessTokenRequest( DateInterval $accessTokenTTL, ): ResponseTypeInterface { - // TODO client authentication + // TODO mivanci client authentication $this->loggerService->debug( 'Pre-authorized code grant respondToAccessTokenRequest', @@ -174,7 +174,7 @@ public function respondToAccessTokenRequest( } // TODO handle authorization_details parameter, add to response. -//dd($authorizationDetails); + //dd($authorizationDetails); // Issue and persist new access token diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index fac09685..3eaded18 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; diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 715ac06e..71948fb0 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -146,6 +146,11 @@ 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 ****************************************************************************************************************/ diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig index 38b2e4fe..8045c23f 100644 --- a/templates/tests/verifiable-credential-issuance.twig +++ b/templates/tests/verifiable-credential-issuance.twig @@ -8,31 +8,50 @@

{{ 'You can use info below to test Verifiable Credential Issuance.'|trans }}

-

- (1) End-User provides information required for the issuance of a certain Credential -
- User data sample: - - {{- sampleData|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} - -

-

- (2) Credential Offer (Pre-Authorized Code) - - {{- credentialOfferUri -}} - -

- QR Code + {% if not authSource or not authSource.isAuthenticated %} +
+
+ + + +
+ +
+
+ {% 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 (Pre-Authorized Code) + + {{- credentialOfferUri -}} + +

+ QR Code + {% endif %} -

- (3) Obtains Issuer's Credential Issuer metadata -
- (4) Token Request (Pre-Authorized Code, tx_code) - Token Response (access_token) -
- (5) Credential Request (access_token, proof(s)) - Credential Response (Credential(s)) -

{% endblock oidcContent -%} From 3910e532e2bf50be489557a1d8cc0b56124820de Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 29 May 2025 16:11:19 +0200 Subject: [PATCH 30/70] WIP --- .../CredentialIssuerCredentialController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 7e2471d1..2cdb7604 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -144,6 +144,8 @@ public function credential(Request $request): Response $issuedAt = new \DateTimeImmutable(); + $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); + $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( $signingKey, SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()), @@ -160,7 +162,7 @@ public function credential(Request $request): Response ClaimsEnum::Issuer->value => $issuerDid, //ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), - ClaimsEnum::Id->value => $this->moduleConfig->getIssuer() . '/vc/' . uniqid(), + ClaimsEnum::Id->value => $vcId, ClaimsEnum::Credential_Subject->value => $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], ], @@ -170,7 +172,7 @@ public function credential(Request $request): Response ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/' . $userId, - ClaimsEnum::Jti->value => $this->moduleConfig->getIssuer() . '/vc/' . uniqid(), + ClaimsEnum::Jti->value => $vcId, ], [ ClaimsEnum::Kid->value => $issuerDid . '#0', From ee98b5a9b9ad5666653563164f9db69d2f2b2912 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Mon, 2 Jun 2025 13:53:56 +0200 Subject: [PATCH 31/70] WIP --- .../VerifiableCredentailsTestController.php | 72 ++++++++--- src/ModuleConfig.php | 5 + .../tests/verifiable-credential-issuance.twig | 117 +++++++++++------- 3 files changed, 136 insertions(+), 58 deletions(-) diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index d6170a04..cd447dff 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -63,6 +63,12 @@ public function __construct( */ 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; @@ -85,20 +91,46 @@ public function verifiableCredentialIssuance(Request $request): Response $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) + is_string($selectedAuthSourceId) && + in_array($selectedAuthSourceId, $authSourceIds, true) ) { $authSource->login(['ReturnTo' => $this->routes->urlAdminTestVerifiableCredentialIssuance()]); } - $authSourceIds = array_filter( - $this->sspBridge->auth()->source()->getSources(), - fn (string $authSourceId): bool => $authSourceId !== 'admin', + $selectedCredentialConfigurationId = $this->sessionService->getCurrentSession()->getData( + 'vci', + 'credential_configuration_id', ); + 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; @@ -147,19 +179,23 @@ public function verifiableCredentialIssuance(Request $request): Response $clientId = '1234567890'; $clientSecret = '1234567890'; - if (($client = $this->clientRepository->findById($clientId)) === null) { - $client = $this->clientEntityFactory->fromData( - id: $clientId, - secret: $clientSecret, - name: 'VCI Pre-authorized Code Test Client', - description: 'Test client for VCI Pre-authorized Code', - redirectUri: ['https://example.com/oidc/callback'], - scopes: ['openid', 'ResearchAndScholarshipCredentialJwtVcJson'], // TODO mivanci from config - isEnabled: true, - ); + $client = $this->clientEntityFactory->fromData( + id: $clientId, + secret: $clientSecret, + name: 'VCI Pre-authorized Code Test Client', + description: 'Test client for VCI Pre-authorized Code', + redirectUri: ['https://example.com/oidc/callback'], + scopes: ['openid', ...$credentialConfigurationIdsSupported], // Test Client so will have + isEnabled: true, + ); + if ($this->clientRepository->findById($clientId) === null) { $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); } + + // TODO mivanci Randomly generate auth code. $authCodeId = '1234567890'; // TODO mivanci Add indication of preauthz code to the auth code table. @@ -170,7 +206,7 @@ public function verifiableCredentialIssuance(Request $request): Response client: $client, scopes: [ new ScopeEntity('openid'), - new ScopeEntity('ResearchAndScholarshipCredentialJwtVcJson'), + new ScopeEntity($selectedCredentialConfigurationId), ], expiryDateTime: new \DateTimeImmutable('+1 month'), userIdentifier: $userId, @@ -185,7 +221,7 @@ public function verifiableCredentialIssuance(Request $request): Response parameters: [ ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::CredentialConfigurationIds->value => [ - 'ResearchAndScholarshipCredentialJwtVcJson', // TODO mivanci from config + $selectedCredentialConfigurationId, ], ClaimsEnum::Grants->value => [ GrantTypesEnum::PreAuthorizedCode->value => [ @@ -210,6 +246,7 @@ public function verifiableCredentialIssuance(Request $request): Response $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; + // TODO mivanci Local QR code generator // https://quickchart.io/documentation/qr-codes/ $credentialOfferQrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); } @@ -223,11 +260,14 @@ public function verifiableCredentialIssuance(Request $request): Response return $this->templateFactory->build( 'oidc:tests/verifiable-credential-issuance.twig', compact( + 'setupErrors', 'credentialOfferQrUri', 'credentialOfferUri', 'authSourceIds', 'authSourceActionRoute', 'authSource', + 'credentialConfigurationIdsSupported', + 'selectedCredentialConfigurationId' ), RoutesEnum::AdminTestVerifiableCredentialIssuance->value, ); diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 0e3f79a1..48489279 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -794,6 +794,11 @@ public function getCredentialConfigurationsSupported(): array return $this->config()->getOptionalArray(self::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []) ?? []; } + public function getCredentialConfigurationIdsSupported(): array + { + return array_keys($this->getCredentialConfigurationsSupported()); + } + /** * Extract and parse the claims path definition from the credential configuration supported. * Returns an array of valid paths for the claims. diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig index 8045c23f..1932af63 100644 --- a/templates/tests/verifiable-credential-issuance.twig +++ b/templates/tests/verifiable-credential-issuance.twig @@ -4,54 +4,87 @@ {% block oidcContent %} -

Pre-Authorized Code

-

- {{ 'You can use info below to test Verifiable Credential Issuance.'|trans }} -

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

- You are currently authenticated with the following user data: + {{ 'There are some setup errors which you should deal with before proceeding.'|trans }}
- - {{- authSource.getAttributes|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} - + {{ setupErrors|join('
') }}

-
-
- -
-
- {% endif %} + {% else %} +

Pre-Authorized Code

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

+ {{ 'To test Verifiable Credential issuance using pre-authorized code, choose authentication source, desired Credential Configuration ID, 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 %} + + +
+ +
+
+ {% 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 %} - {% if credentialOfferUri and credentialOfferQrUri %} -

- Credential Offer (Pre-Authorized Code) - - {{- credentialOfferUri -}} - -

- QR Code {% endif %} + {% endblock oidcContent -%} From 3d0c16a50f9a61a78a558b894f79624d210e9260 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Tue, 3 Jun 2025 16:07:24 +0200 Subject: [PATCH 32/70] WIP --- .../CredentialIssuerCredentialController.php | 52 +++++++++++++++---- src/ModuleConfig.php | 14 +++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 2cdb7604..bd8e8375 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -18,6 +18,7 @@ use SimpleSAML\OpenID\Codebooks\AtContextsEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; +use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\VerifiableCredentials; use Symfony\Component\HttpFoundation\Request; @@ -53,13 +54,13 @@ public function __construct( */ public function credential(Request $request): Response { - $this->loggerService->info( - 'Credential request data: ', - $this->requestParamsResolver->getAllFromRequest( - $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), - ), + $requestData = $this->requestParamsResolver->getAllFromRequestBasedOnAllowedMethods( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + [HttpMethodsEnum::POST], ); + $this->loggerService->debug('Verifiable Credential request data: ', $requestData); + $authorization = $this->resourceServer->validateAuthenticatedRequest( $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), ); @@ -67,7 +68,11 @@ public function credential(Request $request): Response // TODO mivanci validate access token $accessToken = $this->accessTokenRepository->findById($authorization->getAttribute('oauth_access_token_id')); if ($accessToken->isRevoked()) { - throw OidcServerException::accessDenied('Access token is revoked.'); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token is revoked.', + 401, + ); } // TODO mivanci validate credential request, including proof. Sample: @@ -89,6 +94,35 @@ public function credential(Request $request): Response * ) */ + // TODO mivanci Check / handle credential_identifier parameter. + + $credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null; + + if (is_null($credentialConfigurationId)) { + // Check per draft 14 + if (is_array( + $credentialDefinitionType = + $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value], + ) + ) { + $credentialConfigurationId = + $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( + $credentialDefinitionType, + ); + } + } + + if (is_null($credentialConfigurationId)) { + return $this->routes->newJsonErrorResponse('invalid_credential_request', 'Can not resolve credential configuration ID.'); + } + + if (!in_array($credentialConfigurationId, $this->moduleConfig->getCredentialConfigurationIdsSupported())) { + return $this->routes->newJsonErrorResponse( + 'unsupported_credential_type', + sprintf('Credential configuration ID "%s" is not supported.', $credentialConfigurationId), + ); + } + $userId = $accessToken->getUserIdentifier(); $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); if ($userEntity === null) { @@ -97,10 +131,6 @@ public function credential(Request $request): Response $userAttributes = $userEntity->getClaims(); - // TODO mivanci Resolve credential configuration ID from the credential request. Validate that the credential - // configuration ID is supported (check module configuration). - $credentialConfigurationId = 'ResearchAndScholarshipCredentialJwtVcJson'; - // 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($credentialConfigurationId); @@ -156,7 +186,7 @@ public function credential(Request $request): Response ], ClaimsEnum::Type->value => [ CredentialTypesEnum::VerifiableCredential->value, - 'ResearchAndScholarshipCredentialJwtVcJson', + $credentialConfigurationId, ], //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuer->value => $issuerDid, diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 48489279..14a1fdfa 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -799,6 +799,20 @@ public function getCredentialConfigurationIdsSupported(): array return array_keys($this->getCredentialConfigurationsSupported()); } + public function getCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string + { + foreach ($this->getCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration) { + $configuredType = + $credentialConfiguration[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value]; + + if ($configuredType === $credentialDefinitionType) { + return $credentialConfigurationId; + } + } + + return null; + } + /** * Extract and parse the claims path definition from the credential configuration supported. * Returns an array of valid paths for the claims. From a89915df77d620ecf00f5e187a5ff5ea00fc07c6 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 5 Jun 2025 10:51:09 +0200 Subject: [PATCH 33/70] WIP --- .../CredentialIssuerCredentialController.php | 79 +++++- src/Services/Container.php | 4 + src/Utils/DidKeyResolver.php | 256 ++++++++++++++++++ src/Utils/README.md | 45 +++ 4 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 src/Utils/DidKeyResolver.php create mode 100644 src/Utils/README.md diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index bd8e8375..a38288c6 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\DidKeyResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -40,6 +41,7 @@ public function __construct( protected readonly LoggerService $loggerService, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly UserRepository $userRepository, + protected readonly DidKeyResolver $didKeyResolver, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); @@ -75,23 +77,65 @@ public function credential(Request $request): Response ); } - // TODO mivanci validate credential request, including proof. Sample: + // Validate credential request, including proof + if (isset($requestData['proof']) && isset($requestData['proof']['proof_type']) && + $requestData['proof']['proof_type'] === 'jwt' && isset($requestData['proof']['jwt'])) { + + $proofJwt = $requestData['proof']['jwt']; + $this->loggerService->debug('Verifying proof JWT: ' . $proofJwt); + + try { + // Parse the JWT to extract header and payload + $jwtParts = explode('.', $proofJwt); + if (count($jwtParts) !== 3) { + throw OidcServerException::invalidRequest('Invalid JWT format in proof'); + } + + $header = json_decode(Base64Url::decode($jwtParts[0]), true); + $payload = json_decode(Base64Url::decode($jwtParts[1]), true); + + if (!isset($payload['iss'])) { + throw OidcServerException::invalidRequest('Missing issuer (iss) in proof JWT'); + } + + $issuer = $payload['iss']; + $this->loggerService->debug('Proof JWT issuer: ' . $issuer); + + // Check if the issuer is a did:key + if (str_starts_with($issuer, 'did:key:')) { + $this->loggerService->debug('Extracting JWK from did:key: ' . $issuer); + + // Extract JWK from did:key + $jwk = $this->didKeyResolver->extractJwkFromDidKey($issuer); + + // If kid is present in the header, add it to the JWK + if (isset($header['kid'])) { + $jwk['kid'] = $header['kid']; + } else { + // If no kid in header, use the did:key as kid + $jwk['kid'] = $issuer; + } + + $this->loggerService->debug('Extracted JWK: ', $jwk); + + // TODO: Verify the JWT signature using the extracted JWK + // This would typically involve using a JWT library to verify the signature + // For now, we'll just log that we've extracted the JWK successfully + $this->loggerService->debug('JWK extracted successfully from did:key'); + } + } catch (\Exception $e) { + $this->loggerService->error('Error processing proof JWT: ' . $e->getMessage()); + throw OidcServerException::invalidRequest('Error processing proof JWT: ' . $e->getMessage()); + } + } + /** - * 'credential_definition' => - * array ( - * 'type' => - * array ( - * 0 => 'VerifiableCredential', - * 1 => 'ResearchAndScholarshipCredentialJwtVcJson', - * ), - * ), - * 'format' => 'jwt_vc_json', + * Sample proof structure: * 'proof' => * array ( * 'proof_type' => 'jwt', * 'jwt' => 'eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2Iiwia2lkIjoiZGlkOmtleTp6MmRtekQ4MWNnUHg4VmtpN0pidXVNbUZZcldQZ1lveXR5a1VaM2V5cWh0MWo5S2JyU2ZYMkJVeHNVaW5QbVA3QUVzZEN4OWpQYlV0ZkIzWXN2MTd4TGpyZkMxeDNVZmlMTWtyeWdTZDJMeWltQ3RGejhHWlBqOFFrMUJFU0F6M21LWGRCTEpuUHNNQ0R4Nm9QNjNuZVpmR1NKelF5SjRLVlN6Nmt4UTJQOTE4NGdXS1FnI3oyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWcifQ.eyJhdWQiOiJodHRwczovL2lkcC5taXZhbmNpLmluY3ViYXRvci5oZXhhYS5ldSIsImlhdCI6MTc0ODUxNDE0NywiZXhwIjoxNzQ4NTE0ODA3LCJpc3MiOiJkaWQ6a2V5OnoyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWciLCJqdGkiOiJiMmNlZDQ2Yi0zOWNiLTRkZDAtYmQxZS1hNzY5ZWNlOWUxMTIifQ.SPdMSnrfF8ybhfYluzz5OrfWJQDOpCu7-of8zVbp5UR89GaB7j14Egext1h9pYgl6JwIP8zibUjTSc8JLVYuvA', * ), - * ) */ // TODO mivanci Check / handle credential_identifier parameter. @@ -113,7 +157,10 @@ public function credential(Request $request): Response } if (is_null($credentialConfigurationId)) { - return $this->routes->newJsonErrorResponse('invalid_credential_request', 'Can not resolve credential configuration ID.'); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Can not resolve credential configuration ID.', + ); } if (!in_array($credentialConfigurationId, $this->moduleConfig->getCredentialConfigurationIdsSupported())) { @@ -135,7 +182,6 @@ public function credential(Request $request): Response // as per the credential configuration supported configuration. $validClaimPaths = $this->moduleConfig->getValidCredentialClaimPathsFor($credentialConfigurationId); - // Map user attributes to credential claims $credentialSubject = []; $attributeToCredentialClaimPathMap = $this->moduleConfig->getUserAttributeToCredentialClaimPathMapFor( @@ -144,6 +190,13 @@ public function credential(Request $request): Response foreach ($attributeToCredentialClaimPathMap as $mapEntry) { $userAttributeName = key($mapEntry); $credentialClaimPath = current($mapEntry); + 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->setCredentialClaimValue( $credentialSubject, diff --git a/src/Services/Container.php b/src/Services/Container.php index c11cf5e7..8fe159ab 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -103,6 +103,7 @@ use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; +use SimpleSAML\Module\oidc\Utils\DidKeyResolver; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; use SimpleSAML\Module\oidc\Utils\JwksResolver; @@ -230,6 +231,9 @@ public function __construct() $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation); $this->services[RequestParamsResolver::class] = $requestParamsResolver; + $didKeyResolver = new DidKeyResolver(); + $this->services[DidKeyResolver::class] = $didKeyResolver; + $clientEntityFactory = new ClientEntityFactory( $sspBridge, $helpers, diff --git a/src/Utils/DidKeyResolver.php b/src/Utils/DidKeyResolver.php new file mode 100644 index 00000000..7a72dd2b --- /dev/null +++ b/src/Utils/DidKeyResolver.php @@ -0,0 +1,256 @@ +base58Decode($base58Key); + + // The first byte is the multicodec identifier + $multicodecIdentifier = ord($decodedKey[0]) * 256 + ord($decodedKey[1]); + + // Extract the actual key bytes (skip the multicodec bytes) + $keyBytes = substr($decodedKey, 2); + + // Determine the key type based on the multicodec identifier + // See: https://github.com/multiformats/multicodec/blob/master/table.csv + switch ($multicodecIdentifier) { + case 0xed01: // Ed25519 public key + return $this->createEd25519Jwk($keyBytes); + case 0xec01: // X25519 public key + return $this->createX25519Jwk($keyBytes); + case 0x1200: // Secp256k1 public key + return $this->createSecp256k1Jwk($keyBytes); + case 0x1201: // P-256 (NIST) public key + return $this->createP256Jwk($keyBytes); + case 0x1202: // P-384 (NIST) public key + return $this->createP384Jwk($keyBytes); + case 0x1203: // P-521 (NIST) public key + return $this->createP521Jwk($keyBytes); + default: + throw OidcServerException::serverError(sprintf('Unsupported key type with multicodec identifier: 0x%04x', $multicodecIdentifier)); + } + } catch (\Exception $e) { + throw OidcServerException::serverError('Error processing did:key: ' . $e->getMessage()); + } + } + + /** + * Decode a base58 encoded string. + * + * @param string $base58 The base58 encoded string + * @return string The decoded binary data + */ + private function base58Decode(string $base58): string + { + $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + $base = strlen($alphabet); + + // Convert from base58 to base10 + $num = gmp_init(0); + for ($i = 0; $i < strlen($base58); $i++) { + $char = $base58[$i]; + $pos = strpos($alphabet, $char); + if ($pos === false) { + throw new \InvalidArgumentException("Invalid character in base58 string: $char"); + } + $num = gmp_add(gmp_mul($num, $base), $pos); + } + + // Convert from base10 to binary + $result = ''; + while (gmp_cmp($num, 0) > 0) { + list($num, $remainder) = gmp_div_qr($num, 256); + $result = chr(gmp_intval($remainder)) . $result; + } + + // Add leading zeros + for ($i = 0; $i < strlen($base58) && $base58[$i] === '1'; $i++) { + $result = "\0" . $result; + } + + return $result; + } + + /** + * Create a JWK for an Ed25519 public key. + * + * @param string $keyBytes The raw key bytes + * @return array The JWK representation + */ + private function createEd25519Jwk(string $keyBytes): array + { + return [ + 'kty' => 'OKP', + 'crv' => 'Ed25519', + 'x' => Base64Url::encode($keyBytes), + 'use' => 'sig', + ]; + } + + /** + * Create a JWK for an X25519 public key. + * + * @param string $keyBytes The raw key bytes + * @return array The JWK representation + */ + private function createX25519Jwk(string $keyBytes): array + { + return [ + 'kty' => 'OKP', + 'crv' => 'X25519', + 'x' => Base64Url::encode($keyBytes), + 'use' => 'enc', + ]; + } + + /** + * Create a JWK for a Secp256k1 public key. + * + * @param string $keyBytes The raw key bytes + * @return array The JWK representation + */ + private function createSecp256k1Jwk(string $keyBytes): array + { + // For Secp256k1, we need to extract x and y coordinates from the compressed or uncompressed point + $firstByte = ord($keyBytes[0]); + + if ($firstByte === 0x04 && strlen($keyBytes) === 65) { + // Uncompressed point format (0x04 || x || y) + $x = substr($keyBytes, 1, 32); + $y = substr($keyBytes, 33, 32); + } elseif (($firstByte === 0x02 || $firstByte === 0x03) && strlen($keyBytes) === 33) { + // Compressed point format - would need to decompress + // This is complex and requires secp256k1 library support + throw OidcServerException::serverError('Compressed Secp256k1 keys are not currently supported'); + } else { + throw OidcServerException::serverError('Invalid Secp256k1 public key format'); + } + + return [ + 'kty' => 'EC', + 'crv' => 'secp256k1', + 'x' => Base64Url::encode($x), + 'y' => Base64Url::encode($y), + 'use' => 'sig', + ]; + } + + /** + * Create a JWK for a P-256 (NIST) public key. + * + * @param string $keyBytes The raw key bytes + * @return array The JWK representation + */ + private function createP256Jwk(string $keyBytes): array + { + // Similar to Secp256k1, we need to extract x and y coordinates + $firstByte = ord($keyBytes[0]); + + if ($firstByte === 0x04 && strlen($keyBytes) === 65) { + // Uncompressed point format (0x04 || x || y) + $x = substr($keyBytes, 1, 32); + $y = substr($keyBytes, 33, 32); + } else { + throw OidcServerException::serverError('Invalid P-256 public key format'); + } + + return [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => Base64Url::encode($x), + 'y' => Base64Url::encode($y), + 'use' => 'sig', + ]; + } + + /** + * Create a JWK for a P-384 (NIST) public key. + * + * @param string $keyBytes The raw key bytes + * @return array The JWK representation + */ + private function createP384Jwk(string $keyBytes): array + { + $firstByte = ord($keyBytes[0]); + + if ($firstByte === 0x04 && strlen($keyBytes) === 97) { + // Uncompressed point format (0x04 || x || y) + $x = substr($keyBytes, 1, 48); + $y = substr($keyBytes, 49, 48); + } else { + throw OidcServerException::serverError('Invalid P-384 public key format'); + } + + return [ + 'kty' => 'EC', + 'crv' => 'P-384', + 'x' => Base64Url::encode($x), + 'y' => Base64Url::encode($y), + 'use' => 'sig', + ]; + } + + /** + * Create a JWK for a P-521 (NIST) public key. + * + * @param string $keyBytes The raw key bytes + * @return array The JWK representation + */ + private function createP521Jwk(string $keyBytes): array + { + $firstByte = ord($keyBytes[0]); + + if ($firstByte === 0x04 && strlen($keyBytes) === 133) { + // Uncompressed point format (0x04 || x || y) + $x = substr($keyBytes, 1, 66); + $y = substr($keyBytes, 67, 66); + } else { + throw OidcServerException::serverError('Invalid P-521 public key format'); + } + + return [ + 'kty' => 'EC', + 'crv' => 'P-521', + 'x' => Base64Url::encode($x), + 'y' => Base64Url::encode($y), + 'use' => 'sig', + ]; + } +} \ No newline at end of file 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 From a049d09a3d135b9e14becf97a645098a4e13c3e1 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Fri, 6 Jun 2025 20:41:22 +0200 Subject: [PATCH 34/70] WIP --- routing/services/services.yml | 2 + .../VerifiableCredentailsTestController.php | 4 +- .../CredentialIssuerCredentialController.php | 161 ++++++----- src/ModuleConfig.php | 6 +- src/Services/Container.php | 4 - src/Utils/DidKeyResolver.php | 256 ------------------ 6 files changed, 99 insertions(+), 334 deletions(-) delete mode 100644 src/Utils/DidKeyResolver.php diff --git a/routing/services/services.yml b/routing/services/services.yml index 8dc339ef..c78cd35c 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -133,6 +133,8 @@ services: SimpleSAML\OpenID\Jwks: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] SimpleSAML\OpenID\Jwk: ~ + SimpleSAML\OpenID\Did: ~ + # SSP SimpleSAML\Database: diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index cd447dff..944c2cb3 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -129,7 +129,7 @@ public function verifiableCredentialIssuance(Request $request): Response is_null($selectedCredentialConfigurationId) || !in_array($selectedCredentialConfigurationId, $credentialConfigurationIdsSupported, true) ) { - $selectedCredentialConfigurationId = current($credentialConfigurationIdsSupported); + $selectedCredentialConfigurationId = current($credentialConfigurationIdsSupported); } $credentialOfferQrUri = null; @@ -267,7 +267,7 @@ public function verifiableCredentialIssuance(Request $request): Response 'authSourceActionRoute', 'authSource', 'credentialConfigurationIdsSupported', - 'selectedCredentialConfigurationId' + 'selectedCredentialConfigurationId', ), RoutesEnum::AdminTestVerifiableCredentialIssuance->value, ); diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index a38288c6..019df105 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -12,7 +12,6 @@ use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\DidKeyResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -20,6 +19,8 @@ use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use SimpleSAML\OpenID\Did; +use SimpleSAML\OpenID\Exceptions\OpenId4VciProofException; use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\VerifiableCredentials; use Symfony\Component\HttpFoundation\Request; @@ -41,7 +42,7 @@ public function __construct( protected readonly LoggerService $loggerService, protected readonly RequestParamsResolver $requestParamsResolver, protected readonly UserRepository $userRepository, - protected readonly DidKeyResolver $didKeyResolver, + protected readonly Did $did, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); @@ -77,66 +78,7 @@ public function credential(Request $request): Response ); } - // Validate credential request, including proof - if (isset($requestData['proof']) && isset($requestData['proof']['proof_type']) && - $requestData['proof']['proof_type'] === 'jwt' && isset($requestData['proof']['jwt'])) { - - $proofJwt = $requestData['proof']['jwt']; - $this->loggerService->debug('Verifying proof JWT: ' . $proofJwt); - - try { - // Parse the JWT to extract header and payload - $jwtParts = explode('.', $proofJwt); - if (count($jwtParts) !== 3) { - throw OidcServerException::invalidRequest('Invalid JWT format in proof'); - } - - $header = json_decode(Base64Url::decode($jwtParts[0]), true); - $payload = json_decode(Base64Url::decode($jwtParts[1]), true); - - if (!isset($payload['iss'])) { - throw OidcServerException::invalidRequest('Missing issuer (iss) in proof JWT'); - } - - $issuer = $payload['iss']; - $this->loggerService->debug('Proof JWT issuer: ' . $issuer); - - // Check if the issuer is a did:key - if (str_starts_with($issuer, 'did:key:')) { - $this->loggerService->debug('Extracting JWK from did:key: ' . $issuer); - - // Extract JWK from did:key - $jwk = $this->didKeyResolver->extractJwkFromDidKey($issuer); - - // If kid is present in the header, add it to the JWK - if (isset($header['kid'])) { - $jwk['kid'] = $header['kid']; - } else { - // If no kid in header, use the did:key as kid - $jwk['kid'] = $issuer; - } - - $this->loggerService->debug('Extracted JWK: ', $jwk); - - // TODO: Verify the JWT signature using the extracted JWK - // This would typically involve using a JWT library to verify the signature - // For now, we'll just log that we've extracted the JWK successfully - $this->loggerService->debug('JWK extracted successfully from did:key'); - } - } catch (\Exception $e) { - $this->loggerService->error('Error processing proof JWT: ' . $e->getMessage()); - throw OidcServerException::invalidRequest('Error processing proof JWT: ' . $e->getMessage()); - } - } - - /** - * Sample proof structure: - * 'proof' => - * array ( - * 'proof_type' => 'jwt', - * 'jwt' => 'eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIsImFsZyI6IkVTMjU2Iiwia2lkIjoiZGlkOmtleTp6MmRtekQ4MWNnUHg4VmtpN0pidXVNbUZZcldQZ1lveXR5a1VaM2V5cWh0MWo5S2JyU2ZYMkJVeHNVaW5QbVA3QUVzZEN4OWpQYlV0ZkIzWXN2MTd4TGpyZkMxeDNVZmlMTWtyeWdTZDJMeWltQ3RGejhHWlBqOFFrMUJFU0F6M21LWGRCTEpuUHNNQ0R4Nm9QNjNuZVpmR1NKelF5SjRLVlN6Nmt4UTJQOTE4NGdXS1FnI3oyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWcifQ.eyJhdWQiOiJodHRwczovL2lkcC5taXZhbmNpLmluY3ViYXRvci5oZXhhYS5ldSIsImlhdCI6MTc0ODUxNDE0NywiZXhwIjoxNzQ4NTE0ODA3LCJpc3MiOiJkaWQ6a2V5OnoyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnJTZlgyQlV4c1VpblBtUDdBRXNkQ3g5alBiVXRmQjNZc3YxN3hManJmQzF4M1VmaUxNa3J5Z1NkMkx5aW1DdEZ6OEdaUGo4UWsxQkVTQXozbUtYZEJMSm5Qc01DRHg2b1A2M25lWmZHU0p6UXlKNEtWU3o2a3hRMlA5MTg0Z1dLUWciLCJqdGkiOiJiMmNlZDQ2Yi0zOWNiLTRkZDAtYmQxZS1hNzY5ZWNlOWUxMTIifQ.SPdMSnrfF8ybhfYluzz5OrfWJQDOpCu7-of8zVbp5UR89GaB7j14Egext1h9pYgl6JwIP8zibUjTSc8JLVYuvA', - * ), - */ + // TODO mivanci Validate credential request // TODO mivanci Check / handle credential_identifier parameter. @@ -144,15 +86,16 @@ public function credential(Request $request): Response if (is_null($credentialConfigurationId)) { // Check per draft 14 - if (is_array( - $credentialDefinitionType = + if ( + is_array( + $credentialDefinitionType = $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value], - ) + ) ) { $credentialConfigurationId = - $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( - $credentialDefinitionType, - ); + $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( + $credentialDefinitionType, + ); } } @@ -206,6 +149,85 @@ public function credential(Request $request): Response } } + // Placeholder sub identifier. Will do if proof is not provided. + $sub = $this->moduleConfig->getIssuer() . '/sub/' . $userId; + + // Validate proof, if provided. + // TODO mivanci consider making proof mandatory (in issuer metadata). + if ( + isset($requestData['proof']['proof_type']) && + isset($requestData['proof']['jwt']) && + $requestData['proof']['proof_type'] === 'jwt' + ) { + $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. ', + ['header' => $proof->getHeader(), 'payload' => $proof->getPayload()], + ); + } + } catch (\Exception $e) { + $message = 'Error processing proof JWT: ' . $e->getMessage(); + $this->loggerService->error($message); + return $this->routes->newJsonErrorResponse( + 'invalid_proof', + $message, + ); + } + } + + // Also 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, @@ -224,7 +246,6 @@ public function credential(Request $request): Response $issuerDid = 'did:jwk:' . $base64PublicKey; - $issuedAt = new \DateTimeImmutable(); $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); @@ -254,7 +275,7 @@ public function credential(Request $request): Response //ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $this->moduleConfig->getIssuer() . '/sub/' . $userId, + ClaimsEnum::Sub->value => $sub, ClaimsEnum::Jti->value => $vcId, ], [ diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 14a1fdfa..a63c0af1 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -801,9 +801,11 @@ public function getCredentialConfigurationIdsSupported(): array public function getCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string { - foreach ($this->getCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration) { + foreach ( + $this->getCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration + ) { $configuredType = - $credentialConfiguration[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value]; + $credentialConfiguration[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value]; if ($configuredType === $credentialDefinitionType) { return $credentialConfigurationId; diff --git a/src/Services/Container.php b/src/Services/Container.php index 8fe159ab..c11cf5e7 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -103,7 +103,6 @@ use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; -use SimpleSAML\Module\oidc\Utils\DidKeyResolver; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; use SimpleSAML\Module\oidc\Utils\JwksResolver; @@ -231,9 +230,6 @@ public function __construct() $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation); $this->services[RequestParamsResolver::class] = $requestParamsResolver; - $didKeyResolver = new DidKeyResolver(); - $this->services[DidKeyResolver::class] = $didKeyResolver; - $clientEntityFactory = new ClientEntityFactory( $sspBridge, $helpers, diff --git a/src/Utils/DidKeyResolver.php b/src/Utils/DidKeyResolver.php deleted file mode 100644 index 7a72dd2b..00000000 --- a/src/Utils/DidKeyResolver.php +++ /dev/null @@ -1,256 +0,0 @@ -base58Decode($base58Key); - - // The first byte is the multicodec identifier - $multicodecIdentifier = ord($decodedKey[0]) * 256 + ord($decodedKey[1]); - - // Extract the actual key bytes (skip the multicodec bytes) - $keyBytes = substr($decodedKey, 2); - - // Determine the key type based on the multicodec identifier - // See: https://github.com/multiformats/multicodec/blob/master/table.csv - switch ($multicodecIdentifier) { - case 0xed01: // Ed25519 public key - return $this->createEd25519Jwk($keyBytes); - case 0xec01: // X25519 public key - return $this->createX25519Jwk($keyBytes); - case 0x1200: // Secp256k1 public key - return $this->createSecp256k1Jwk($keyBytes); - case 0x1201: // P-256 (NIST) public key - return $this->createP256Jwk($keyBytes); - case 0x1202: // P-384 (NIST) public key - return $this->createP384Jwk($keyBytes); - case 0x1203: // P-521 (NIST) public key - return $this->createP521Jwk($keyBytes); - default: - throw OidcServerException::serverError(sprintf('Unsupported key type with multicodec identifier: 0x%04x', $multicodecIdentifier)); - } - } catch (\Exception $e) { - throw OidcServerException::serverError('Error processing did:key: ' . $e->getMessage()); - } - } - - /** - * Decode a base58 encoded string. - * - * @param string $base58 The base58 encoded string - * @return string The decoded binary data - */ - private function base58Decode(string $base58): string - { - $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - $base = strlen($alphabet); - - // Convert from base58 to base10 - $num = gmp_init(0); - for ($i = 0; $i < strlen($base58); $i++) { - $char = $base58[$i]; - $pos = strpos($alphabet, $char); - if ($pos === false) { - throw new \InvalidArgumentException("Invalid character in base58 string: $char"); - } - $num = gmp_add(gmp_mul($num, $base), $pos); - } - - // Convert from base10 to binary - $result = ''; - while (gmp_cmp($num, 0) > 0) { - list($num, $remainder) = gmp_div_qr($num, 256); - $result = chr(gmp_intval($remainder)) . $result; - } - - // Add leading zeros - for ($i = 0; $i < strlen($base58) && $base58[$i] === '1'; $i++) { - $result = "\0" . $result; - } - - return $result; - } - - /** - * Create a JWK for an Ed25519 public key. - * - * @param string $keyBytes The raw key bytes - * @return array The JWK representation - */ - private function createEd25519Jwk(string $keyBytes): array - { - return [ - 'kty' => 'OKP', - 'crv' => 'Ed25519', - 'x' => Base64Url::encode($keyBytes), - 'use' => 'sig', - ]; - } - - /** - * Create a JWK for an X25519 public key. - * - * @param string $keyBytes The raw key bytes - * @return array The JWK representation - */ - private function createX25519Jwk(string $keyBytes): array - { - return [ - 'kty' => 'OKP', - 'crv' => 'X25519', - 'x' => Base64Url::encode($keyBytes), - 'use' => 'enc', - ]; - } - - /** - * Create a JWK for a Secp256k1 public key. - * - * @param string $keyBytes The raw key bytes - * @return array The JWK representation - */ - private function createSecp256k1Jwk(string $keyBytes): array - { - // For Secp256k1, we need to extract x and y coordinates from the compressed or uncompressed point - $firstByte = ord($keyBytes[0]); - - if ($firstByte === 0x04 && strlen($keyBytes) === 65) { - // Uncompressed point format (0x04 || x || y) - $x = substr($keyBytes, 1, 32); - $y = substr($keyBytes, 33, 32); - } elseif (($firstByte === 0x02 || $firstByte === 0x03) && strlen($keyBytes) === 33) { - // Compressed point format - would need to decompress - // This is complex and requires secp256k1 library support - throw OidcServerException::serverError('Compressed Secp256k1 keys are not currently supported'); - } else { - throw OidcServerException::serverError('Invalid Secp256k1 public key format'); - } - - return [ - 'kty' => 'EC', - 'crv' => 'secp256k1', - 'x' => Base64Url::encode($x), - 'y' => Base64Url::encode($y), - 'use' => 'sig', - ]; - } - - /** - * Create a JWK for a P-256 (NIST) public key. - * - * @param string $keyBytes The raw key bytes - * @return array The JWK representation - */ - private function createP256Jwk(string $keyBytes): array - { - // Similar to Secp256k1, we need to extract x and y coordinates - $firstByte = ord($keyBytes[0]); - - if ($firstByte === 0x04 && strlen($keyBytes) === 65) { - // Uncompressed point format (0x04 || x || y) - $x = substr($keyBytes, 1, 32); - $y = substr($keyBytes, 33, 32); - } else { - throw OidcServerException::serverError('Invalid P-256 public key format'); - } - - return [ - 'kty' => 'EC', - 'crv' => 'P-256', - 'x' => Base64Url::encode($x), - 'y' => Base64Url::encode($y), - 'use' => 'sig', - ]; - } - - /** - * Create a JWK for a P-384 (NIST) public key. - * - * @param string $keyBytes The raw key bytes - * @return array The JWK representation - */ - private function createP384Jwk(string $keyBytes): array - { - $firstByte = ord($keyBytes[0]); - - if ($firstByte === 0x04 && strlen($keyBytes) === 97) { - // Uncompressed point format (0x04 || x || y) - $x = substr($keyBytes, 1, 48); - $y = substr($keyBytes, 49, 48); - } else { - throw OidcServerException::serverError('Invalid P-384 public key format'); - } - - return [ - 'kty' => 'EC', - 'crv' => 'P-384', - 'x' => Base64Url::encode($x), - 'y' => Base64Url::encode($y), - 'use' => 'sig', - ]; - } - - /** - * Create a JWK for a P-521 (NIST) public key. - * - * @param string $keyBytes The raw key bytes - * @return array The JWK representation - */ - private function createP521Jwk(string $keyBytes): array - { - $firstByte = ord($keyBytes[0]); - - if ($firstByte === 0x04 && strlen($keyBytes) === 133) { - // Uncompressed point format (0x04 || x || y) - $x = substr($keyBytes, 1, 66); - $y = substr($keyBytes, 67, 66); - } else { - throw OidcServerException::serverError('Invalid P-521 public key format'); - } - - return [ - 'kty' => 'EC', - 'crv' => 'P-521', - 'x' => Base64Url::encode($x), - 'y' => Base64Url::encode($y), - 'use' => 'sig', - ]; - } -} \ No newline at end of file From b2282fff9270a0402286e04183dbe3592b51f423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 4 Aug 2025 10:59:22 +0200 Subject: [PATCH 35/70] WIP --- README.md | 6 +- config/module_oidc.php.dist | 96 ++++++++++++++++++- docker/apache-override.cf | 3 + routing/routes/routes.php | 9 ++ src/Codebooks/RoutesEnum.php | 6 ++ .../JwtVcIssuerConfigurationController.php | 46 +++++++++ src/Utils/Routes.php | 10 ++ templates/config/verifiable-credential.twig | 6 +- 8 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php diff --git a/README.md b/README.md index 9fb49e39..27e37a2c 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,7 @@ The module offers an OpenID Federation configuration endpoint at URL: You can configure your web server (Apache, Nginx) in a way to serve the mentioned URLs in a '.well-known' format. Below are some sample configurations for `openid-configuration`, but you can take the same approach for -`openid-federation`. +`openid-federation`, `jwt-vc-issuer`, or any other. #### nginx location = /.well-known/openid-configuration { @@ -382,9 +382,9 @@ format. Below are some sample configurations for `openid-configuration`, but you ## Using Docker -### With current git branch. +### With the current git branch. -To explore the module using docker run the below command. This will run an SSP image, with the current oidc module +To explore the module using docker, run the below command. This will run an SSP image, with the current oidc module mounted in the container, along with some configuration files. Any code changes you make to your git checkout are "live" in the container, allowing you to test and iterate different things. diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index df898ef2..8205089f 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -514,6 +514,7 @@ $config = [ // 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, @@ -529,6 +530,9 @@ $config = [ // OPTIONAL // proof_types_supported + // OPTIONAL + // cryptographic_binding_methods_supported + ClaimsEnum::Display->value => [ [ ClaimsEnum::Name->value => 'ResearchAndScholarshipCredentialJwtVcJson', @@ -588,8 +592,7 @@ $config = [ */ [ // REQUIRED - ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName'] - ], + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName'], // OPTIONAL ClaimsEnum::Mandatory->value => true, // OPTIONAL @@ -668,6 +671,92 @@ $config = [ 'ResearchAndScholarshipCredentialJwtVcJson', ], ], + ], + + // Sample for 'dc+sd-jwt' format without notes about required and optional fields. + 'ResearchAndScholarshipCredentialDcSdJwt' => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->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. @@ -689,5 +778,8 @@ $config = [ ['mail' => [ClaimsEnum::Credential_Subject->value, 'mail']], ['eduPersonScopedAffiliation' => [ClaimsEnum::Credential_Subject->value, 'eduPersonScopedAffiliation']], ], + 'ResearchAndScholarshipCredentialDcSdJwt' => [ + + ], ], ]; 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/routing/routes/routes.php b/routing/routes/routes.php index 1e142f5a..4847dec5 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -22,6 +22,7 @@ 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; @@ -131,4 +132,12 @@ $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]); }; diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 77ccee71..3df1ca0c 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -63,4 +63,10 @@ enum RoutesEnum: string 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'; } diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php new file mode 100644 index 00000000..af675f59 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -0,0 +1,46 @@ +moduleConfig->getVerifiableCredentialEnabled()) { + 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/Utils/Routes.php b/src/Utils/Routes.php index 71948fb0..e9781103 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -226,4 +226,14 @@ 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); + } + } diff --git a/templates/config/verifiable-credential.twig b/templates/config/verifiable-credential.twig index db1dcb39..c6d64e20 100644 --- a/templates/config/verifiable-credential.twig +++ b/templates/config/verifiable-credential.twig @@ -10,9 +10,13 @@

{{ 'Entity'|trans }}

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

+

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

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

From 5d4c433a11ee9a06caedf653999c649083abf69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 4 Aug 2025 15:32:47 +0200 Subject: [PATCH 36/70] WIP --- config/module_oidc.php.dist | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 8205089f..eb79b61d 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -779,7 +779,13 @@ $config = [ ['eduPersonScopedAffiliation' => [ClaimsEnum::Credential_Subject->value, 'eduPersonScopedAffiliation']], ], 'ResearchAndScholarshipCredentialDcSdJwt' => [ - + ['eduPersonPrincipalName' => ['eduPersonPrincipalName']], + ['eduPersonTargetedID' => ['eduPersonTargetedID']], + ['displayName' => ['displayName']], + ['givenName' => ['givenName']], + ['sn' => ['sn']], + ['mail' => ['mail']], + ['eduPersonScopedAffiliation' => ['eduPersonScopedAffiliation']], ], ], ]; From eb44e299b2a3e06e3dcd12eddc44c1c33104433a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 6 Aug 2025 08:43:29 +0200 Subject: [PATCH 37/70] WIP --- .../CredentialIssuerCredentialController.php | 221 +++++++++++++----- src/ModuleConfig.php | 26 +++ src/Utils/Routes.php | 1 - 3 files changed, 184 insertions(+), 64 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 019df105..cb1068a9 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -17,8 +17,10 @@ use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\AtContextsEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum; use SimpleSAML\OpenID\Codebooks\CredentialTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Exceptions\OpenId4VciProofException; use SimpleSAML\OpenID\Jwk; @@ -28,6 +30,12 @@ class CredentialIssuerCredentialController { + + public const SD_JWT_FORMAT_IDS = [ + CredentialFormatIdentifiersEnum::DcSdJwt->value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, + ]; + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ @@ -106,47 +114,42 @@ public function credential(Request $request): Response ); } - if (!in_array($credentialConfigurationId, $this->moduleConfig->getCredentialConfigurationIdsSupported())) { + if ( + !is_array( + $credentialConfiguration = $this->moduleConfig->getCredentialConfiguration($credentialConfigurationId), + ) + ) { return $this->routes->newJsonErrorResponse( 'unsupported_credential_type', sprintf('Credential configuration ID "%s" is not supported.', $credentialConfigurationId), ); } - $userId = $accessToken->getUserIdentifier(); - $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); - if ($userEntity === null) { - throw OidcServerException::invalidRequest('User not found'); - } + $credentialFormatId = $credentialConfiguration[ClaimsEnum::Format->value] ?? null; - $userAttributes = $userEntity->getClaims(); + if (is_null($credentialFormatId)) { + throw OidcServerException::serverError( + 'Credential format not specified for configuration ID: ' . $credentialConfigurationId, + ); + } - // 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($credentialConfigurationId); + if ( + !in_array($credentialFormatId, [ + CredentialFormatIdentifiersEnum::JwtVcJson->value, + CredentialFormatIdentifiersEnum::DcSdJwt->value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, // Deprecated value, but let's support it for now. + ]) + ) { + return $this->routes->newJsonErrorResponse( + 'unsupported_credential_type', + sprintf('Credential format ID "%s" is not supported.', $credentialFormatId), + ); + } - // Map user attributes to credential claims - $credentialSubject = []; - $attributeToCredentialClaimPathMap = $this->moduleConfig->getUserAttributeToCredentialClaimPathMapFor( - $credentialConfigurationId, - ); - foreach ($attributeToCredentialClaimPathMap as $mapEntry) { - $userAttributeName = key($mapEntry); - $credentialClaimPath = current($mapEntry); - 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->setCredentialClaimValue( - $credentialSubject, - $credentialClaimPath, - $userAttributes[$userAttributeName], - ); - } + $userId = $accessToken->getUserIdentifier(); + $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); + if ($userEntity === null) { + throw OidcServerException::invalidRequest('User not found.'); } // Placeholder sub identifier. Will do if proof is not provided. @@ -221,6 +224,71 @@ public function credential(Request $request): Response } } + $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($credentialConfigurationId); + + // Map user attributes to credential claims + $credentialSubject = []; // For JwtVcJson + $disclosureBag = $this->verifiableCredentials->disclosureBagFactory()->build(); // For DcSdJwt + $attributeToCredentialClaimPathMap = $this->moduleConfig->getUserAttributeToCredentialClaimPathMapFor( + $credentialConfigurationId, + ); + foreach ($attributeToCredentialClaimPathMap as $mapEntry) { + $userAttributeName = key($mapEntry); + $credentialClaimPath = current($mapEntry); + 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; + } + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::DcSdJwt->value) { + $this->setCredentialClaimValue( + $credentialSubject, + $credentialClaimPath, + $userAttributes[$userAttributeName], + ); + } + + 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; + } + + $disclosure = $this->verifiableCredentials->disclosureFactory()->build( + value: $userAttributes[$userAttributeName], + name: $claimName, + path: is_array($credentialClaimPath) ? $credentialClaimPath : [], + saltBlacklist: $disclosureBag->salts(), + ); + + $disclosureBag->add($disclosure);; + } + } + + dd($disclosureBag->all()); // Also make sure that the subject identifier is in credentialSubject claim. $this->setCredentialClaimValue( $credentialSubject, @@ -249,39 +317,66 @@ public function credential(Request $request): Response $issuedAt = new \DateTimeImmutable(); $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); - - $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( - $signingKey, - SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()), - [ - ClaimsEnum::Vc->value => [ - ClaimsEnum::AtContext->value => [ - AtContextsEnum::W3Org2018CredentialsV1->value, + $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, + $credentialConfigurationId, + ], + //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Issuer->value => $issuerDid, + //ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', + ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Id->value => $vcId, + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], ], - ClaimsEnum::Type->value => [ - CredentialTypesEnum::VerifiableCredential->value, - $credentialConfigurationId, - ], - //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::Issuer->value => $issuerDid, - //ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', - 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::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, ], - //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::Iss->value => $issuerDid, - //ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', - ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), - ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $sub, - ClaimsEnum::Jti->value => $vcId, - ], - [ - ClaimsEnum::Kid->value => $issuerDid . '#0', - ], - ); + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', + ], + ); + } + + if (in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true)) { + // TODO selectiveDisclosureBag + + $verifiableCredential = $this->verifiableCredentials->sdJwtVcFactory()->fromData( + $signingKey, + $signatureAlgorithm, + [ + 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', + ], + jwtTypesEnum: JwtTypesEnum::VcSdJwt, + ); + } + + $this->loggerService->debug('response', [ 'credentials' => [ diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 6fc5f21f..a9c5a09d 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -858,6 +858,32 @@ 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; + } + public function getCredentialConfigurationIdsSupported(): array { return array_keys($this->getCredentialConfigurationsSupported()); diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index e9781103..955aef8e 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -235,5 +235,4 @@ public function urlJwtVcIssuerConfiguration(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::JwtVcIssuerConfiguration->value, $parameters); } - } From 089780ef177c6da112bc53f33f64a90e4ba7a7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 6 Aug 2025 09:50:21 +0200 Subject: [PATCH 38/70] WIP --- .../CredentialIssuerCredentialController.php | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index cb1068a9..dc58edb7 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -25,12 +25,12 @@ use SimpleSAML\OpenID\Exceptions\OpenId4VciProofException; use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\VerifiableCredentials; +use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class CredentialIssuerCredentialController { - public const SD_JWT_FORMAT_IDS = [ CredentialFormatIdentifiersEnum::DcSdJwt->value, CredentialFormatIdentifiersEnum::VcSdJwt->value, @@ -88,22 +88,48 @@ public function credential(Request $request): Response // TODO mivanci Validate credential request + $credentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null; + + if (is_null($credentialFormatId)) { + throw OidcServerException::serverError('Credential format missing in request.'); + } + + if ( + !in_array($credentialFormatId, [ + CredentialFormatIdentifiersEnum::JwtVcJson->value, + CredentialFormatIdentifiersEnum::DcSdJwt->value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, // Deprecated value, but let's support it for now. + ]) + ) { + return $this->routes->newJsonErrorResponse( + 'unsupported_credential_type', + sprintf('Credential format ID "%s" is not supported.', $credentialFormatId), + ); + } + // TODO mivanci Check / handle credential_identifier parameter. $credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null; if (is_null($credentialConfigurationId)) { - // Check per draft 14 + // TODO mivanci Update this to newest draft. + // Check per draft 14 (Sphereon wallet case). if ( + $credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value && is_array( $credentialDefinitionType = - $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value], + $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value] ?? null, ) ) { $credentialConfigurationId = $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( $credentialDefinitionType, ); + } elseif ( + in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true) && + is_string($vct = $requestData[ClaimsEnum::Vct->value] ?? null) + ) { + $credentialConfigurationId = $vct; } } @@ -114,38 +140,13 @@ public function credential(Request $request): Response ); } - if ( - !is_array( - $credentialConfiguration = $this->moduleConfig->getCredentialConfiguration($credentialConfigurationId), - ) - ) { + if (!is_array($this->moduleConfig->getCredentialConfiguration($credentialConfigurationId))) { return $this->routes->newJsonErrorResponse( 'unsupported_credential_type', sprintf('Credential configuration ID "%s" is not supported.', $credentialConfigurationId), ); } - $credentialFormatId = $credentialConfiguration[ClaimsEnum::Format->value] ?? null; - - if (is_null($credentialFormatId)) { - throw OidcServerException::serverError( - 'Credential format not specified for configuration ID: ' . $credentialConfigurationId, - ); - } - - if ( - !in_array($credentialFormatId, [ - CredentialFormatIdentifiersEnum::JwtVcJson->value, - CredentialFormatIdentifiersEnum::DcSdJwt->value, - CredentialFormatIdentifiersEnum::VcSdJwt->value, // Deprecated value, but let's support it for now. - ]) - ) { - return $this->routes->newJsonErrorResponse( - 'unsupported_credential_type', - sprintf('Credential format ID "%s" is not supported.', $credentialFormatId), - ); - } - $userId = $accessToken->getUserIdentifier(); $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); if ($userEntity === null) { @@ -155,6 +156,7 @@ public function credential(Request $request): Response // 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). if ( @@ -284,12 +286,11 @@ public function credential(Request $request): Response saltBlacklist: $disclosureBag->salts(), ); - $disclosureBag->add($disclosure);; + $disclosureBag->add($disclosure); } } - dd($disclosureBag->all()); - // Also make sure that the subject identifier is in credentialSubject claim. + // Make sure that the subject identifier is in credentialSubject claim. $this->setCredentialClaimValue( $credentialSubject, [ClaimsEnum::Credential_Subject->value, ClaimsEnum::Id->value], @@ -336,7 +337,6 @@ public function credential(Request $request): Response ], //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuer->value => $issuerDid, - //ClaimsEnum::Issuer->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), ClaimsEnum::Id->value => $vcId, ClaimsEnum::Credential_Subject->value => @@ -344,7 +344,6 @@ public function credential(Request $request): Response ], //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Iss->value => $issuerDid, - //ClaimsEnum::Iss->value => 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/jwks', ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $sub, @@ -357,27 +356,33 @@ public function credential(Request $request): Response } if (in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true)) { - // TODO selectiveDisclosureBag + $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 => $credentialConfigurationId, + ]; + + if ($proof instanceof OpenId4VciProof) { + $sdJwtPayload[ClaimsEnum::Cnf->value] = [ + ClaimsEnum::Kid->value => $proof->getKeyId(), + ]; + } $verifiableCredential = $this->verifiableCredentials->sdJwtVcFactory()->fromData( $signingKey, $signatureAlgorithm, - [ - ClaimsEnum::Iss->value => $issuerDid, - ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), - ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), - ClaimsEnum::Sub->value => $sub, - ClaimsEnum::Jti->value => $vcId, - ], + $sdJwtPayload, [ ClaimsEnum::Kid->value => $issuerDid . '#0', ], + disclosureBag: $disclosureBag, jwtTypesEnum: JwtTypesEnum::VcSdJwt, ); } - - $this->loggerService->debug('response', [ 'credentials' => [ ['credential' => $verifiableCredential->getToken()], From ba04a7015eebcf8ca83fac4b02870fe19665c966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 6 Aug 2025 10:22:19 +0200 Subject: [PATCH 39/70] WIP --- .../CredentialIssuerCredentialController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index dc58edb7..2485075a 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -257,7 +257,7 @@ public function credential(Request $request): Response continue; } - if ($credentialFormatId === CredentialFormatIdentifiersEnum::DcSdJwt->value) { + if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { $this->setCredentialClaimValue( $credentialSubject, $credentialClaimPath, From 819bc628ab74996edf43510490a15fa873630290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 6 Aug 2025 10:37:50 +0200 Subject: [PATCH 40/70] WIP --- .../CredentialIssuerCredentialController.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 2485075a..35de1984 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -257,11 +257,17 @@ public function credential(Request $request): Response continue; } + // Normalize to string for single array values. + $attributeValue = is_array($userAttributes[$userAttributeName]) && + count($userAttributes[$userAttributeName]) === 1 ? + reset($userAttributes[$userAttributeName]) : + $userAttributes[$userAttributeName]; + if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { - $this->setCredentialClaimValue( + $this->verifiableCredentials->helpers()->arr()->setNestedValue( $credentialSubject, - $credentialClaimPath, - $userAttributes[$userAttributeName], + $attributeValue, + ...$credentialClaimPath, ); } @@ -280,7 +286,7 @@ public function credential(Request $request): Response } $disclosure = $this->verifiableCredentials->disclosureFactory()->build( - value: $userAttributes[$userAttributeName], + value: $attributeValue, name: $claimName, path: is_array($credentialClaimPath) ? $credentialClaimPath : [], saltBlacklist: $disclosureBag->salts(), From dc8766fab8c307911f5e0e861e506e79e53d3a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 12 Aug 2025 09:29:05 +0200 Subject: [PATCH 41/70] WIP --- config/module_oidc.php.dist | 21 ++++ routing/routes/routes.php | 9 ++ src/Codebooks/ApiScopesEnum.php | 14 +++ src/Codebooks/RoutesEnum.php | 6 ++ .../VerifiableCredentailsTestController.php | 17 +--- .../Api/VciCredentialOfferController.php | 54 +++++++++++ .../CredentialIssuerCredentialController.php | 4 +- .../Entities/ClientEntityFactory.php | 41 ++++++++ src/ModuleConfig.php | 36 +++++++ src/Services/Api/Authorization.php | 97 +++++++++++++++++++ 10 files changed, 281 insertions(+), 18 deletions(-) create mode 100644 src/Codebooks/ApiScopesEnum.php create mode 100644 src/Controllers/Api/VciCredentialOfferController.php create mode 100644 src/Services/Api/Authorization.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index eb79b61d..ed6bd22b 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -20,6 +20,7 @@ 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; @@ -788,4 +789,24 @@ $config = [ ['eduPersonScopedAffiliation' => ['eduPersonScopedAffiliation']], ], ], + + /** + * (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. +// ], + ], ]; diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 4847dec5..8fa73589 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Controllers\Admin\ConfigController; use SimpleSAML\Module\oidc\Controllers\Admin\FederationTestController; use SimpleSAML\Module\oidc\Controllers\Admin\VerifiableCredentailsTestController; +use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialOfferController; use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Controllers\EndSessionController; @@ -140,4 +141,12 @@ $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([VciCredentialOfferController::class, 'credentialOffer']) + ->methods([HttpMethodsEnum::POST->value]); }; 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 @@ +clientEntityFactory->fromData( - id: $clientId, - secret: $clientSecret, - name: 'VCI Pre-authorized Code Test Client', - description: 'Test client for VCI Pre-authorized Code', - redirectUri: ['https://example.com/oidc/callback'], - scopes: ['openid', ...$credentialConfigurationIdsSupported], // Test Client so will have - isEnabled: true, - ); - if ($this->clientRepository->findById($clientId) === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } + $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); // TODO mivanci Randomly generate auth code. $authCodeId = '1234567890'; diff --git a/src/Controllers/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php new file mode 100644 index 00000000..6958133b --- /dev/null +++ b/src/Controllers/Api/VciCredentialOfferController.php @@ -0,0 +1,54 @@ +moduleConfig->getApiEnabled()) { + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + } + + /** + * @throws AuthorizationException + */ + public function credentialOffer(Request $request): Response + { + $this->authorization->requireTokenForAnyOfScope( + $request, + [ApiScopesEnum::VciCredentialOffer, ApiScopesEnum::VciAll, ApiScopesEnum::All], + ); + + $input = $request->getPayload()->all(); + + // Currently, we need a dedicated client for which the PreAuthZed code will be bound to. + // TODO mivanci: Remove requirement for dedicated client for authorization codes. + $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); + + dd($this->verifiableCredentials->helpers()->arr()->hybridSort($input['user_attributes'])); + + return new JsonResponse(['ok']); + } +} diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 35de1984..7b5dafa5 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -260,8 +260,8 @@ public function credential(Request $request): Response // Normalize to string for single array values. $attributeValue = is_array($userAttributes[$userAttributeName]) && count($userAttributes[$userAttributeName]) === 1 ? - reset($userAttributes[$userAttributeName]) : - $userAttributes[$userAttributeName]; + reset($userAttributes[$userAttributeName]) : + $userAttributes[$userAttributeName]; if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { $this->verifiableCredentials->helpers()->arr()->setNestedValue( diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index bdbad325..7b4f18a8 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -11,6 +11,8 @@ 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\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -29,6 +31,8 @@ public function __construct( private readonly Helpers $helpers, private readonly ClaimTranslatorExtractor $claimTranslatorExtractor, private readonly RequestParamsResolver $requestParamsResolver, + private readonly ModuleConfig $moduleConfig, + private readonly ClientRepository $clientRepository, ) { } @@ -380,4 +384,41 @@ public function fromState(array $state): ClientEntityInterface $isFederated, ); } + + public function getGenericForVciPreAuthZFlow(): ClientEntityInterface + { + $clientId = 'vci_' . + hash('sha256', 'vci_' . $this->moduleConfig->sspConfig()->getString('secretsalt')); + + $clientSecret = $this->helpers->random()->getIdentifier(); + + $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + + $oldClient = $this->clientRepository->findById($clientId); + $createdAt = $this->helpers->dateTime()->getUtc(); + + if ($oldClient instanceof ClientEntityInterface) { + $createdAt = $oldClient->getCreatedAt(); + } + + $client = $this->fromData( + id: $clientId, + secret: $clientSecret, + name: 'VCI Pre-authorized Code Generic Client', + description: 'Generic client for VCI Pre-authorized Code', + redirectUri: ['openid-credential-offer://'], + scopes: ['openid', ...$credentialConfigurationIdsSupported], + isEnabled: true, + updatedAt: $this->helpers->dateTime()->getUtc(), + createdAt: $createdAt, + ); + + if ($oldClient === null) { + $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); + } + + return $client; + } } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index a9c5a09d..595e6d5a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -103,6 +103,8 @@ class ModuleConfig 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'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -931,4 +933,38 @@ public function getUserAttributeToCredentialClaimPathMapFor(string $credentialCo { return $this->getUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; } + + + + /***************************************************************************************************************** + * 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 + { + $tokenScopes = $this->getApiTokens()[$token] ?? null; + + if (is_array($tokenScopes)) { + return $tokenScopes; + } + + return null; + } } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php new file mode 100644 index 00000000..4456c0b7 --- /dev/null +++ b/src/Services/Api/Authorization.php @@ -0,0 +1,97 @@ +sspBridge->utils()->auth()->requireAdmin(); + } catch (\Throwable $exception) { + throw new AuthorizationException( + Translate::noop('Unable to initiate admin authentication.'), + $exception->getCode(), + $exception, + ); + } + } + + if (! $this->sspBridge->utils()->auth()->isAdmin()) { + throw new AuthorizationException(Translate::noop('SimpleSAMLphp Admin access required.')); + } + } + + /** + * @param ApiScopesEnum[] $requiredScopes + * + * @throws 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 + { + 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; + } +} From 659466743bc6fcf21e14caaf05bd451819a54ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 12 Aug 2025 15:10:18 +0200 Subject: [PATCH 42/70] WIP --- routing/routes/routes.php | 6 +- src/Codebooks/RoutesEnum.php | 2 +- .../VerifiableCredentailsTestController.php | 21 ++- .../Api/VciCredentialOfferController.php | 136 ++++++++++++++++-- .../Entities/ClientEntityFactory.php | 21 +-- src/Services/Api/Authorization.php | 1 - src/Utils/Routes.php | 9 ++ 7 files changed, 153 insertions(+), 43 deletions(-) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 8fa73589..4cb5967a 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -146,7 +146,9 @@ * API ****************************************************************************************************************/ - $routes->add(RoutesEnum::ApiVciCredentialOffer->name, RoutesEnum::ApiVciCredentialOffer->value) - ->controller([VciCredentialOfferController::class, 'credentialOffer']) + $routes->add( + RoutesEnum::ApiVciPreAuthorizedCredentialOffer->name, + RoutesEnum::ApiVciPreAuthorizedCredentialOffer->value, + )->controller([VciCredentialOfferController::class, 'preAuthorizedCredentialOffer']) ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6e44dbf2..9f8724af 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -74,5 +74,5 @@ enum RoutesEnum: string * API ****************************************************************************************************************/ - case ApiVciCredentialOffer = 'api/vci/credential-offer'; + case ApiVciPreAuthorizedCredentialOffer = 'api/vci/pre-authorized-credential-offer'; } diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index afb08192..10fa3198 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -175,15 +175,17 @@ public function verifiableCredentialIssuance(Request $request): Response // TODO mivanci Wallet (client) credential_offer_endpoint metadata // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata - $clientSecret = '1234567890'; - $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); + if ($this->clientRepository->findById($client->getIdentifier()) === null) { + $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); + } - // TODO mivanci Randomly generate auth code. - $authCodeId = '1234567890'; + $authCodeId = $this->sspBridge->utils()->random()->generateID(); - // TODO mivanci Add indication of preauthz code to the auth code table. + // TODO mivanci Add indication of preAuthZ code to the auth code table. if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { $authCode = $this->authCodeEntityFactory->fromData( @@ -193,10 +195,9 @@ public function verifiableCredentialIssuance(Request $request): Response new ScopeEntity('openid'), new ScopeEntity($selectedCredentialConfigurationId), ], - expiryDateTime: new \DateTimeImmutable('+1 month'), + expiryDateTime: new \DateTimeImmutable('+10 minutes'), userIdentifier: $userId, - redirectUri: 'https://example.com/oidc/callback', - nonce: '1234567890', + redirectUri: 'openid-credential-offer://', ); $this->authCodeRepository->persistNewAuthCode($authCode); @@ -236,10 +237,6 @@ public function verifiableCredentialIssuance(Request $request): Response $credentialOfferQrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); } - - - - $authSourceActionRoute = $this->routes->urlAdminTestVerifiableCredentialIssuance(); return $this->templateFactory->build( diff --git a/src/Controllers/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php index 6958133b..932c48bd 100644 --- a/src/Controllers/Api/VciCredentialOfferController.php +++ b/src/Controllers/Api/VciCredentialOfferController.php @@ -4,14 +4,26 @@ namespace SimpleSAML\Module\oidc\Controllers\Api; +use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; -use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; +use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; +use SimpleSAML\Module\oidc\Entities\ScopeEntity; +use SimpleSAML\Module\oidc\Entities\UserEntity; +use SimpleSAML\Module\oidc\Exceptions\OidcException; +use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; +use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\Api\Authorization; +use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\VerifiableCredentials; -use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +37,14 @@ public function __construct( protected readonly Authorization $authorization, protected readonly VerifiableCredentials $verifiableCredentials, protected readonly ClientEntityFactory $clientEntityFactory, + protected readonly ClientRepository $clientRepository, + protected readonly SspBridge $sspBridge, + protected readonly LoggerService $loggerService, + protected readonly UserRepository $userRepository, + protected readonly UserEntityFactory $userEntityFactory, + protected readonly AuthCodeRepository $authCodeRepository, + protected readonly AuthCodeEntityFactory $authCodeEntityFactory, + protected readonly Routes $routes, ) { if (!$this->moduleConfig->getApiEnabled()) { throw OidcServerException::forbidden('API capabilities not enabled.'); @@ -32,23 +52,121 @@ public function __construct( } /** - * @throws AuthorizationException */ - public function credentialOffer(Request $request): Response + public function preAuthorizedCredentialOffer(Request $request): Response { $this->authorization->requireTokenForAnyOfScope( $request, [ApiScopesEnum::VciCredentialOffer, ApiScopesEnum::VciAll, ApiScopesEnum::All], ); - $input = $request->getPayload()->all(); - // Currently, we need a dedicated client for which the PreAuthZed code will be bound to. - // TODO mivanci: Remove requirement for dedicated client for authorization codes. + // TODO mivanci: Remove requirement for dedicated client for (pre-)authorization codes. $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); + if ($this->clientRepository->findById($client->getIdentifier()) === null) { + $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); + } + + $input = $request->getPayload()->all(); + $userAttributes = $input['user_attributes'] ?? []; + + $selectedCredentialConfigurationId = $input['credential_configuration_id'] ?? null; + if ($selectedCredentialConfigurationId === null) { + throw new OidcException('No credential configuration ID provided.'); + } + $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); + + if (empty($credentialConfigurationIdsSupported)) { + throw new OidcException('No credential configuration IDs configured.'); + } + if (!in_array($selectedCredentialConfigurationId, $credentialConfigurationIdsSupported, true)) { + throw new OidcException( + 'Credential configuration ID not supported: ' . $selectedCredentialConfigurationId, + ); + } + + $userId = null; + try { + $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $this->moduleConfig->getUserIdentifierAttribute(), + ); + } catch (\Throwable $e) { + $this->loggerService->warning( + 'Could not extract user identifier from user attributes: ' . $e->getMessage(), + ); + } + + if ($userId === null) { + $sortedAttributes = $userAttributes; + $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); + $userId = 'vci_preauthz_' . hash('sha256', serialize($sortedAttributes)); + } - dd($this->verifiableCredentials->helpers()->arr()->hybridSort($input['user_attributes'])); + $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); - return new JsonResponse(['ok']); + $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); + + if ($oldUserEntity instanceof UserEntity) { + $this->userRepository->update($userEntity); + } else { + $this->userRepository->add($userEntity); + } + + + $authCodeId = $this->sspBridge->utils()->random()->generateID(); + + if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { + $authCode = $this->authCodeEntityFactory->fromData( + id: $authCodeId, + client: $client, + scopes: [ + new ScopeEntity('openid'), + new ScopeEntity($selectedCredentialConfigurationId), + ], + expiryDateTime: new \DateTimeImmutable('+10 minutes'), + userIdentifier: $userId, + redirectUri: 'openid-credential-offer://', + ); + + $this->authCodeRepository->persistNewAuthCode($authCode); + } + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + $selectedCredentialConfigurationId, + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::PreAuthorizedCode->value => [ + ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), + // TODO mivanci support for TxCode + // ClaimsEnum::TxCode->value => [ + // ClaimsEnum::InputMode->value => 'numeric', + // ClaimsEnum::Length->value => 6, + // ClaimsEnum::Description->value => 'Sent to user mail', + // ], + ], + ], + ], + ); + + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; + + return $this->routes->newJsonResponse( + data: [ + 'credential_offer_uri' => $credentialOfferUri, + ], + ); } } diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 7b4f18a8..42f3ec11 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -12,7 +12,6 @@ use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; 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\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -32,7 +31,6 @@ public function __construct( private readonly ClaimTranslatorExtractor $claimTranslatorExtractor, private readonly RequestParamsResolver $requestParamsResolver, private readonly ModuleConfig $moduleConfig, - private readonly ClientRepository $clientRepository, ) { } @@ -388,20 +386,15 @@ public function fromState(array $state): ClientEntityInterface public function getGenericForVciPreAuthZFlow(): ClientEntityInterface { $clientId = 'vci_' . - hash('sha256', 'vci_' . $this->moduleConfig->sspConfig()->getString('secretsalt')); + hash('sha256', 'vci_' . $this->moduleConfig->sspConfig()->getString('secretsalt')); $clientSecret = $this->helpers->random()->getIdentifier(); $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); - $oldClient = $this->clientRepository->findById($clientId); $createdAt = $this->helpers->dateTime()->getUtc(); - if ($oldClient instanceof ClientEntityInterface) { - $createdAt = $oldClient->getCreatedAt(); - } - - $client = $this->fromData( + return $this->fromData( id: $clientId, secret: $clientSecret, name: 'VCI Pre-authorized Code Generic Client', @@ -409,16 +402,8 @@ public function getGenericForVciPreAuthZFlow(): ClientEntityInterface redirectUri: ['openid-credential-offer://'], scopes: ['openid', ...$credentialConfigurationIdsSupported], isEnabled: true, - updatedAt: $this->helpers->dateTime()->getUtc(), + updatedAt: $createdAt, createdAt: $createdAt, ); - - if ($oldClient === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } - - return $client; } } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 4456c0b7..7e1024c2 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -6,7 +6,6 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Bridges\SspBridge; -use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\ModuleConfig; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 955aef8e..b04e062b 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -235,4 +235,13 @@ public function urlJwtVcIssuerConfiguration(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::JwtVcIssuerConfiguration->value, $parameters); } + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + public function urlApiVciPreAuthorizedCredentialOffer(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiVciPreAuthorizedCredentialOffer->value, $parameters); + } } From 9e7e0949b9a88e4cee9c75555e66fd05aee52287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 12 Aug 2025 16:42:14 +0200 Subject: [PATCH 43/70] WIP --- .../VerifiableCredentailsTestController.php | 19 +- .../Api/VciCredentialOfferController.php | 50 +++-- src/Factories/CredentialOfferUriFactory.php | 180 ++++++++++++++++++ src/Services/Container.php | 1 + 4 files changed, 221 insertions(+), 29 deletions(-) create mode 100644 src/Factories/CredentialOfferUriFactory.php diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 10fa3198..19a9bf6a 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; +use DateTimeImmutable; use SimpleSAML\Auth\Simple; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Bridges\SspBridge; @@ -183,25 +184,7 @@ public function verifiableCredentialIssuance(Request $request): Response $this->clientRepository->update($client); } - $authCodeId = $this->sspBridge->utils()->random()->generateID(); - // TODO mivanci Add indication of preAuthZ code to the auth code table. - - if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { - $authCode = $this->authCodeEntityFactory->fromData( - id: $authCodeId, - client: $client, - scopes: [ - new ScopeEntity('openid'), - new ScopeEntity($selectedCredentialConfigurationId), - ], - expiryDateTime: new \DateTimeImmutable('+10 minutes'), - userIdentifier: $userId, - redirectUri: 'openid-credential-offer://', - ); - - $this->authCodeRepository->persistNewAuthCode($authCode); - } $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( parameters: [ diff --git a/src/Controllers/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php index 932c48bd..0d7fd05b 100644 --- a/src/Controllers/Api/VciCredentialOfferController.php +++ b/src/Controllers/Api/VciCredentialOfferController.php @@ -9,7 +9,9 @@ use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; +use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Exceptions\OidcException; +use SimpleSAML\Module\oidc\Factories\CredentialOfferUriFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; @@ -45,6 +47,7 @@ public function __construct( protected readonly AuthCodeRepository $authCodeRepository, protected readonly AuthCodeEntityFactory $authCodeEntityFactory, protected readonly Routes $routes, + protected readonly CredentialOfferUriFactory $credentialOfferUriFactory, ) { if (!$this->moduleConfig->getApiEnabled()) { throw OidcServerException::forbidden('API capabilities not enabled.'); @@ -55,13 +58,43 @@ public function __construct( */ public function preAuthorizedCredentialOffer(Request $request): Response { - $this->authorization->requireTokenForAnyOfScope( - $request, - [ApiScopesEnum::VciCredentialOffer, ApiScopesEnum::VciAll, ApiScopesEnum::All], + try { + $this->authorization->requireTokenForAnyOfScope( + $request, + [ApiScopesEnum::VciCredentialOffer, ApiScopesEnum::VciAll, ApiScopesEnum::All], + ); + } catch (AuthorizationException $e) { + return $this->routes->newJsonErrorResponse( + error: 'unauthorized', + description: $e->getMessage(), + httpCode: Response::HTTP_UNAUTHORIZED, + ); + } + + $input = $request->getPayload()->all(); + $userAttributes = $input['user_attributes'] ?? []; + + $selectedCredentialConfigurationId = $input['credential_configuration_id'] ?? null; + + if (!is_string($selectedCredentialConfigurationId)) { + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'No credential configuration ID provided.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( + [$selectedCredentialConfigurationId], + $userAttributes, ); - // 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. + // TODO mivanci continue + dd($credentialOfferUri); + + + ///////// + $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); if ($this->clientRepository->findById($client->getIdentifier()) === null) { $this->clientRepository->add($client); @@ -69,13 +102,8 @@ public function preAuthorizedCredentialOffer(Request $request): Response $this->clientRepository->update($client); } - $input = $request->getPayload()->all(); - $userAttributes = $input['user_attributes'] ?? []; - $selectedCredentialConfigurationId = $input['credential_configuration_id'] ?? null; - if ($selectedCredentialConfigurationId === null) { - throw new OidcException('No credential configuration ID provided.'); - } + $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); if (empty($credentialConfigurationIdsSupported)) { diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php new file mode 100644 index 00000000..b9c6b301 --- /dev/null +++ b/src/Factories/CredentialOfferUriFactory.php @@ -0,0 +1,180 @@ +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 TX Code handling + $email = $this->emailFactory->build( + subject: 'VC Issuance Transaction code', + to: 'testuser@example.com', + ); + + $email->setData(['Transaction Code' => '1234']); + try { + $email->send(); + $this->sessionMessagesService->addMessage('Email with tx code sent to: testuser@example.com'); + } catch (Exception $e) { + $this->sessionMessagesService->addMessage('Error emailing tx code.'); + } + */ + + // 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. + $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); + if ($this->clientRepository->findById($client->getIdentifier()) === null) { + $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); + } + + $userId = null; + try { + $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $this->moduleConfig->getUserIdentifierAttribute(), + ); + } catch (\Throwable $e) { + $this->loggerService->warning( + 'Could not extract user identifier from user attributes: ' . $e->getMessage(), + ); + } + + if ($userId === null) { + $sortedAttributes = $userAttributes; + $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); + $userId = 'vci_preauthz_' . hash('sha256', serialize($sortedAttributes)); + } + + $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); + + $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); + + if ($oldUserEntity instanceof UserEntity) { + $this->userRepository->update($userEntity); + } else { + $this->userRepository->add($userEntity); + } + + $authCodeId = null; + $authCodeIdGenerationAttempts = 3; + while ($authCodeIdGenerationAttempts > 0) { + $authCodeId = $this->sspBridge->utils()->random()->generateID(); + if ($this->authCodeRepository->findById($authCodeId) === null) { + break; + } + $authCodeIdGenerationAttempts--; + } + + if ($authCodeId === null) { + throw new RuntimeException('Failed to generate Authorization Code ID.'); + } + + // TODO mivanci Add indication of preAuthZ code to the auth code table. + $authCode = $this->authCodeEntityFactory->fromData( + id: $authCodeId, + client: $client, + scopes: $scopes, + expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), + userIdentifier: $userId, + redirectUri: 'openid-credential-offer://', + ); + $this->authCodeRepository->persistNewAuthCode($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(), + // TODO mivanci support for TxCode + // ClaimsEnum::TxCode->value => [ + // ClaimsEnum::InputMode->value => 'numeric', + // ClaimsEnum::Length->value => 6, + // ClaimsEnum::Description->value => 'Sent to user mail', + // ], + ], + ], + ], + ); + + $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"; + } +} \ No newline at end of file diff --git a/src/Services/Container.php b/src/Services/Container.php index c11cf5e7..6144e9ab 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -235,6 +235,7 @@ public function __construct() $helpers, $claimTranslatorExtractor, $requestParamsResolver, + $moduleConfig, ); $this->services[ClientEntityFactory::class] = $clientEntityFactory; From 35794173b62920412ca0ac544d377a0ff01d4d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 13 Aug 2025 09:21:15 +0200 Subject: [PATCH 44/70] WIP --- routing/routes/routes.php | 6 +- src/Codebooks/RoutesEnum.php | 2 +- .../VerifiableCredentailsTestController.php | 88 +---- .../Api/VciCredentialOfferController.php | 110 +----- src/Factories/CredentialOfferUriFactory.php | 359 +++++++++--------- src/Services/Api/Authorization.php | 9 +- src/Utils/Routes.php | 4 +- 7 files changed, 194 insertions(+), 384 deletions(-) diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 4cb5967a..61b759de 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -147,8 +147,8 @@ ****************************************************************************************************************/ $routes->add( - RoutesEnum::ApiVciPreAuthorizedCredentialOffer->name, - RoutesEnum::ApiVciPreAuthorizedCredentialOffer->value, - )->controller([VciCredentialOfferController::class, 'preAuthorizedCredentialOffer']) + RoutesEnum::ApiVciCredentialOffer->name, + RoutesEnum::ApiVciCredentialOffer->value, + )->controller([VciCredentialOfferController::class, 'credentialOffer']) ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 9f8724af..6e44dbf2 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -74,5 +74,5 @@ enum RoutesEnum: string * API ****************************************************************************************************************/ - case ApiVciPreAuthorizedCredentialOffer = 'api/vci/pre-authorized-credential-offer'; + case ApiVciCredentialOffer = 'api/vci/credential-offer'; } diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 19a9bf6a..a4ad0e24 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -4,14 +4,12 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; -use DateTimeImmutable; use SimpleSAML\Auth\Simple; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Bridges\SspBridge; -use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; -use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; +use SimpleSAML\Module\oidc\Factories\CredentialOfferUriFactory; use SimpleSAML\Module\oidc\Factories\EmailFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; @@ -26,8 +24,6 @@ use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\Codebooks\ClaimsEnum; -use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\VerifiableCredentials; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,21 +34,13 @@ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly TemplateFactory $templateFactory, protected readonly Authorization $authorization, - protected readonly VerifiableCredentials $verifiableCredentials, - protected readonly AuthCodeRepository $authCodeRepository, - protected readonly AuthCodeEntityFactory $authCodeEntityFactory, - protected readonly ClientRepository $clientRepository, - protected readonly ClientEntityFactory $clientEntityFactory, protected readonly LoggerService $loggerService, protected readonly EmailFactory $emailFactory, - protected readonly ?ProtocolCache $protocolCache, - protected readonly SessionMessagesService $sessionMessagesService, protected readonly AuthSimpleFactory $authSimpleFactory, protected readonly SessionService $sessionService, protected readonly SspBridge $sspBridge, protected readonly Routes $routes, - protected readonly UserRepository $userRepository, - protected readonly UserEntityFactory $userEntityFactory, + protected readonly CredentialOfferUriFactory $credentialOfferUriFactory, ) { $this->authorization->requireAdmin(true); } @@ -142,79 +130,11 @@ public function verifiableCredentialIssuance(Request $request): Response ) { $userAttributes = $authSource->getAttributes(); - $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( + [$selectedCredentialConfigurationId], $userAttributes, - $this->moduleConfig->getUserIdentifierAttribute(), ); - // Persist / update user entity. - $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); - - if ($userEntity) { - $userEntity->setClaims($userAttributes); - $this->userRepository->update($userEntity); - } else { - $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); - $this->userRepository->add($userEntity); - } - - /* TODO mivanci TX Code handling - $email = $this->emailFactory->build( - subject: 'VC Issuance Transaction code', - to: 'testuser@example.com', - ); - - $email->setData(['Transaction Code' => '1234']); - try { - $email->send(); - $this->sessionMessagesService->addMessage('Email with tx code sent to: testuser@example.com'); - } catch (Exception $e) { - $this->sessionMessagesService->addMessage('Error emailing tx code.'); - } - */ - - // TODO mivanci Wallet (client) credential_offer_endpoint metadata - // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata - - - $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); - if ($this->clientRepository->findById($client->getIdentifier()) === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } - - - - $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( - parameters: [ - ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::CredentialConfigurationIds->value => [ - $selectedCredentialConfigurationId, - ], - ClaimsEnum::Grants->value => [ - GrantTypesEnum::PreAuthorizedCode->value => [ - ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), - // TODO mivanci support for TxCode - // ClaimsEnum::TxCode->value => [ - // ClaimsEnum::InputMode->value => 'numeric', - // ClaimsEnum::Length->value => 6, - // ClaimsEnum::Description->value => 'Sent to user mail', - // ], - ], - ], - ], - ); - - $credentialOfferValue = $credentialOffer->jsonSerialize(); - $parameterName = ParametersEnum::CredentialOfferUri->value; - if (is_array($credentialOfferValue)) { - $parameterName = ParametersEnum::CredentialOffer->value; - $credentialOfferValue = json_encode($credentialOfferValue); - } - - $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; - // TODO mivanci Local QR code generator // https://quickchart.io/documentation/qr-codes/ $credentialOfferQrUri = 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri); diff --git a/src/Controllers/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php index 0d7fd05b..6143476e 100644 --- a/src/Controllers/Api/VciCredentialOfferController.php +++ b/src/Controllers/Api/VciCredentialOfferController.php @@ -6,11 +6,7 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; -use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; -use SimpleSAML\Module\oidc\Entities\ScopeEntity; -use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; -use SimpleSAML\Module\oidc\Exceptions\OidcException; use SimpleSAML\Module\oidc\Factories\CredentialOfferUriFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; @@ -23,8 +19,6 @@ use SimpleSAML\Module\oidc\Services\Api\Authorization; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\Codebooks\ClaimsEnum; -use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\VerifiableCredentials; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -56,7 +50,7 @@ public function __construct( /** */ - public function preAuthorizedCredentialOffer(Request $request): Response + public function credentialOffer(Request $request): Response { try { $this->authorization->requireTokenForAnyOfScope( @@ -89,108 +83,6 @@ public function preAuthorizedCredentialOffer(Request $request): Response $userAttributes, ); - // TODO mivanci continue - dd($credentialOfferUri); - - - ///////// - - $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); - if ($this->clientRepository->findById($client->getIdentifier()) === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } - - - - $credentialConfigurationIdsSupported = $this->moduleConfig->getCredentialConfigurationIdsSupported(); - - if (empty($credentialConfigurationIdsSupported)) { - throw new OidcException('No credential configuration IDs configured.'); - } - if (!in_array($selectedCredentialConfigurationId, $credentialConfigurationIdsSupported, true)) { - throw new OidcException( - 'Credential configuration ID not supported: ' . $selectedCredentialConfigurationId, - ); - } - - $userId = null; - try { - $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( - $userAttributes, - $this->moduleConfig->getUserIdentifierAttribute(), - ); - } catch (\Throwable $e) { - $this->loggerService->warning( - 'Could not extract user identifier from user attributes: ' . $e->getMessage(), - ); - } - - if ($userId === null) { - $sortedAttributes = $userAttributes; - $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); - $userId = 'vci_preauthz_' . hash('sha256', serialize($sortedAttributes)); - } - - $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); - - $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); - - if ($oldUserEntity instanceof UserEntity) { - $this->userRepository->update($userEntity); - } else { - $this->userRepository->add($userEntity); - } - - - $authCodeId = $this->sspBridge->utils()->random()->generateID(); - - if (($authCode = $this->authCodeRepository->findById($authCodeId)) === null) { - $authCode = $this->authCodeEntityFactory->fromData( - id: $authCodeId, - client: $client, - scopes: [ - new ScopeEntity('openid'), - new ScopeEntity($selectedCredentialConfigurationId), - ], - expiryDateTime: new \DateTimeImmutable('+10 minutes'), - userIdentifier: $userId, - redirectUri: 'openid-credential-offer://', - ); - - $this->authCodeRepository->persistNewAuthCode($authCode); - } - - $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( - parameters: [ - ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), - ClaimsEnum::CredentialConfigurationIds->value => [ - $selectedCredentialConfigurationId, - ], - ClaimsEnum::Grants->value => [ - GrantTypesEnum::PreAuthorizedCode->value => [ - ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), - // TODO mivanci support for TxCode - // ClaimsEnum::TxCode->value => [ - // ClaimsEnum::InputMode->value => 'numeric', - // ClaimsEnum::Length->value => 6, - // ClaimsEnum::Description->value => 'Sent to user mail', - // ], - ], - ], - ], - ); - - $credentialOfferValue = $credentialOffer->jsonSerialize(); - $parameterName = ParametersEnum::CredentialOfferUri->value; - if (is_array($credentialOfferValue)) { - $parameterName = ParametersEnum::CredentialOffer->value; - $credentialOfferValue = json_encode($credentialOfferValue); - } - - $credentialOfferUri = "openid-credential-offer://?$parameterName=$credentialOfferValue"; - return $this->routes->newJsonResponse( data: [ 'credential_offer_uri' => $credentialOfferUri, diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index b9c6b301..4993ac60 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -1,180 +1,179 @@ -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 TX Code handling - $email = $this->emailFactory->build( - subject: 'VC Issuance Transaction code', - to: 'testuser@example.com', - ); - - $email->setData(['Transaction Code' => '1234']); - try { - $email->send(); - $this->sessionMessagesService->addMessage('Email with tx code sent to: testuser@example.com'); - } catch (Exception $e) { - $this->sessionMessagesService->addMessage('Error emailing tx code.'); - } - */ - - // 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. - $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); - if ($this->clientRepository->findById($client->getIdentifier()) === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } - - $userId = null; - try { - $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( - $userAttributes, - $this->moduleConfig->getUserIdentifierAttribute(), - ); - } catch (\Throwable $e) { - $this->loggerService->warning( - 'Could not extract user identifier from user attributes: ' . $e->getMessage(), - ); - } - - if ($userId === null) { - $sortedAttributes = $userAttributes; - $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); - $userId = 'vci_preauthz_' . hash('sha256', serialize($sortedAttributes)); - } - - $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); - - $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); - - if ($oldUserEntity instanceof UserEntity) { - $this->userRepository->update($userEntity); - } else { - $this->userRepository->add($userEntity); - } - - $authCodeId = null; - $authCodeIdGenerationAttempts = 3; - while ($authCodeIdGenerationAttempts > 0) { - $authCodeId = $this->sspBridge->utils()->random()->generateID(); - if ($this->authCodeRepository->findById($authCodeId) === null) { - break; - } - $authCodeIdGenerationAttempts--; - } - - if ($authCodeId === null) { - throw new RuntimeException('Failed to generate Authorization Code ID.'); - } - - // TODO mivanci Add indication of preAuthZ code to the auth code table. - $authCode = $this->authCodeEntityFactory->fromData( - id: $authCodeId, - client: $client, - scopes: $scopes, - expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), - userIdentifier: $userId, - redirectUri: 'openid-credential-offer://', - ); - $this->authCodeRepository->persistNewAuthCode($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(), - // TODO mivanci support for TxCode - // ClaimsEnum::TxCode->value => [ - // ClaimsEnum::InputMode->value => 'numeric', - // ClaimsEnum::Length->value => 6, - // ClaimsEnum::Description->value => 'Sent to user mail', - // ], - ], - ], - ], - ); - - $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"; - } -} \ No newline at end of file +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 TX Code handling + $email = $this->emailFactory->build( + subject: 'VC Issuance Transaction code', + to: 'testuser@example.com', + ); + + $email->setData(['Transaction Code' => '1234']); + try { + $email->send(); + $this->sessionMessagesService->addMessage('Email with tx code sent to: testuser@example.com'); + } catch (Exception $e) { + $this->sessionMessagesService->addMessage('Error emailing tx code.'); + } + */ + + // 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. + $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); + if ($this->clientRepository->findById($client->getIdentifier()) === null) { + $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); + } + + $userId = null; + try { + $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $this->moduleConfig->getUserIdentifierAttribute(), + ); + } catch (\Throwable $e) { + $this->loggerService->warning( + 'Could not extract user identifier from user attributes: ' . $e->getMessage(), + ); + } + + if ($userId === null) { + $sortedAttributes = $userAttributes; + $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); + $userId = 'vci_preauthz_' . hash('sha256', serialize($sortedAttributes)); + } + + $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); + + $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); + + if ($oldUserEntity instanceof UserEntity) { + $this->userRepository->update($userEntity); + } else { + $this->userRepository->add($userEntity); + } + + $authCodeId = null; + $authCodeIdGenerationAttempts = 3; + while ($authCodeIdGenerationAttempts > 0) { + $authCodeId = $this->sspBridge->utils()->random()->generateID(); + if ($this->authCodeRepository->findById($authCodeId) === null) { + break; + } + $authCodeIdGenerationAttempts--; + } + + if ($authCodeId === null) { + throw new RuntimeException('Failed to generate Authorization Code ID.'); + } + + // TODO mivanci Add indication of preAuthZ code to the auth code table. + $authCode = $this->authCodeEntityFactory->fromData( + id: $authCodeId, + client: $client, + scopes: $scopes, + expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), + userIdentifier: $userId, + redirectUri: 'openid-credential-offer://', + ); + $this->authCodeRepository->persistNewAuthCode($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(), + // TODO mivanci support for TxCode + // ClaimsEnum::TxCode->value => [ + // ClaimsEnum::InputMode->value => 'numeric', + // ClaimsEnum::Length->value => 6, + // ClaimsEnum::Description->value => 'Sent to user mail', + // ], + ], + ], + ], + ); + + $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"; + } +} diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 7e1024c2..83024ae7 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -25,7 +25,7 @@ public function __construct( /** - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function requireSimpleSAMLphpAdmin(bool $forceAdminAuthentication = false): void { @@ -35,8 +35,7 @@ public function requireSimpleSAMLphpAdmin(bool $forceAdminAuthentication = false } catch (\Throwable $exception) { throw new AuthorizationException( Translate::noop('Unable to initiate admin authentication.'), - $exception->getCode(), - $exception, + previous: $exception, ); } } @@ -47,9 +46,9 @@ public function requireSimpleSAMLphpAdmin(bool $forceAdminAuthentication = false } /** - * @param ApiScopesEnum[] $requiredScopes + * @param \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] $requiredScopes * - * @throws AuthorizationException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ public function requireTokenForAnyOfScope(Request $request, array $requiredScopes): void { diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index b04e062b..f7763577 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -240,8 +240,8 @@ public function urlJwtVcIssuerConfiguration(array $parameters = []): string * API ****************************************************************************************************************/ - public function urlApiVciPreAuthorizedCredentialOffer(array $parameters = []): string + public function urlApiVciCredentialOffer(array $parameters = []): string { - return $this->getModuleUrl(RoutesEnum::ApiVciPreAuthorizedCredentialOffer->value, $parameters); + return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); } } From 2dd5315b8552fce58d6debc81cac23cd843989ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 18 Aug 2025 16:54:53 +0200 Subject: [PATCH 45/70] WIP --- config/module_oidc.php.dist | 14 +++ .../VerifiableCredentailsTestController.php | 23 ++-- .../Api/VciCredentialOfferController.php | 14 +++ src/Entities/AuthCodeEntity.php | 14 +++ src/Factories/CredentialOfferUriFactory.php | 119 ++++++++++++++---- .../Entities/AuthCodeEntityFactory.php | 8 ++ src/ModuleConfig.php | 24 ++++ src/Repositories/AuthCodeRepository.php | 33 ++++- src/Server/Grants/PreAuthCodeGrant.php | 35 +++++- src/Services/DatabaseMigration.php | 16 +++ .../tests/verifiable-credential-issuance.twig | 18 +++ 11 files changed, 275 insertions(+), 43 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index ed6bd22b..65318f13 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -790,6 +790,20 @@ $config = [ ], ], + // 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. */ diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index a4ad0e24..9f049e7d 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -11,20 +11,11 @@ use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; use SimpleSAML\Module\oidc\Factories\CredentialOfferUriFactory; use SimpleSAML\Module\oidc\Factories\EmailFactory; -use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; -use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; -use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; -use SimpleSAML\Module\oidc\Repositories\ClientRepository; -use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\Module\oidc\Services\SessionService; -use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\VerifiableCredentials; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -123,16 +114,26 @@ public function verifiableCredentialIssuance(Request $request): Response $credentialOfferQrUri = null; $credentialOfferUri = null; + $useTxCode = (bool) $request->get('useTxCode'); + $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(), + ); $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( [$selectedCredentialConfigurationId], $userAttributes, + $useTxCode, + $usersEmailAttributeName, ); // TODO mivanci Local QR code generator @@ -142,6 +143,8 @@ public function verifiableCredentialIssuance(Request $request): Response $authSourceActionRoute = $this->routes->urlAdminTestVerifiableCredentialIssuance(); + $defaultUsersEmailAttributeName = $this->moduleConfig->getDefaultUsersEmailAttributeName(); + return $this->templateFactory->build( 'oidc:tests/verifiable-credential-issuance.twig', compact( @@ -153,6 +156,8 @@ public function verifiableCredentialIssuance(Request $request): Response 'authSource', 'credentialConfigurationIdsSupported', 'selectedCredentialConfigurationId', + 'defaultUsersEmailAttributeName', + 'usersEmailAttributeName', ), RoutesEnum::AdminTestVerifiableCredentialIssuance->value, ); diff --git a/src/Controllers/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php index 6143476e..8c8d3dfb 100644 --- a/src/Controllers/Api/VciCredentialOfferController.php +++ b/src/Controllers/Api/VciCredentialOfferController.php @@ -78,9 +78,23 @@ public function credentialOffer(Request $request): Response ); } + $useTxCode = boolval($input['use_tx_code'] ?? false); + $usersEmailAttributeName = $input['users_email_attribute_name'] ?? null; + $usersEmailAttributeName = is_string($usersEmailAttributeName) ? $usersEmailAttributeName : null; + $authenticationSourceId = $input['authentication_source_id'] ?? null; + $authenticationSourceId = is_string($authenticationSourceId) ? $authenticationSourceId : null; + + if (is_null($usersEmailAttributeName) && is_string($authenticationSourceId)) { + $usersEmailAttributeName = $this->moduleConfig->getUsersEmailAttributeNameForAuthSourceId( + $authenticationSourceId, + ); + } + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( [$selectedCredentialConfigurationId], $userAttributes, + $useTxCode, + $usersEmailAttributeName, ); return $this->routes->newJsonResponse( diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index d98fe347..0f95bba0 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -43,6 +43,8 @@ public function __construct( ?string $redirectUri = null, ?string $nonce = null, bool $isRevoked = false, + protected readonly bool $isPreAuthorized = false, + protected readonly ?string $txCode = null, ) { $this->identifier = $id; $this->client = $client; @@ -68,6 +70,18 @@ public function getState(): array 'is_revoked' => $this->isRevoked(), 'redirect_uri' => $this->getRedirectUri(), 'nonce' => $this->getNonce(), + 'is_pre_authorized' => $this->isPreAuthorized, + 'tx_code' => $this->txCode, ]; } + + public function isPreAuthorized(): bool + { + return $this->isPreAuthorized; + } + + public function getTxCode(): ?string + { + return $this->txCode; + } } diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index 4993ac60..bb3185d6 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -6,11 +6,11 @@ use DateTimeImmutable; use RuntimeException; +use SimpleSAML\Error\Exception; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; -use SimpleSAML\Module\oidc\Exceptions\OidcException; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; @@ -21,8 +21,8 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; -use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\VerifiableCredentials; +use SimpleSAML\OpenID\VerifiableCredentials\TxCode; class CredentialOfferUriFactory { @@ -37,6 +37,7 @@ public function __construct( protected readonly LoggerService $loggerService, protected readonly UserRepository $userRepository, protected readonly UserEntityFactory $userEntityFactory, + protected readonly EmailFactory $emailFactory, ) { } @@ -47,6 +48,8 @@ public function __construct( 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.'); @@ -62,21 +65,6 @@ public function buildPreAuthorized( throw new RuntimeException('Unsupported credential configuration IDs provided.'); } - /* TODO mivanci TX Code handling - $email = $this->emailFactory->build( - subject: 'VC Issuance Transaction code', - to: 'testuser@example.com', - ); - - $email->setData(['Transaction Code' => '1234']); - try { - $email->send(); - $this->sessionMessagesService->addMessage('Email with tx code sent to: testuser@example.com'); - } catch (Exception $e) { - $this->sessionMessagesService->addMessage('Error emailing tx code.'); - } - */ - // TODO mivanci Wallet (client) credential_offer_endpoint metadata // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata @@ -103,13 +91,19 @@ public function buildPreAuthorized( } 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_preauthz_' . hash('sha256', serialize($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); @@ -136,7 +130,19 @@ public function buildPreAuthorized( throw new RuntimeException('Failed to generate Authorization Code ID.'); } - // TODO mivanci Add indication of preAuthZ code to the auth code table. + $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(), + ); + } + $authCode = $this->authCodeEntityFactory->fromData( id: $authCodeId, client: $client, @@ -144,6 +150,8 @@ public function buildPreAuthorized( expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), userIdentifier: $userId, redirectUri: 'openid-credential-offer://', + isPreAuthorized: true, + txCode: $txCode instanceof VerifiableCredentials\TxCode ? $txCode->getCodeAsString() : null, ); $this->authCodeRepository->persistNewAuthCode($authCode); @@ -156,17 +164,22 @@ public function buildPreAuthorized( ClaimsEnum::Grants->value => [ GrantTypesEnum::PreAuthorizedCode->value => [ ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), - // TODO mivanci support for TxCode - // ClaimsEnum::TxCode->value => [ - // ClaimsEnum::InputMode->value => 'numeric', - // ClaimsEnum::Length->value => 6, - // ClaimsEnum::Description->value => 'Sent to user mail', - // ], + ...(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)) { @@ -176,4 +189,60 @@ public function buildPreAuthorized( 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, + ); + } + + /** + * @throws OidcException + */ + 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/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index be0cdee2..435ae179 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -31,6 +31,8 @@ public function fromData( ?string $redirectUri = null, ?string $nonce = null, bool $isRevoked = false, + bool $isPreAuthorized = false, + ?string $txCode = null, ): AuthCodeEntity { return new AuthCodeEntity( $id, @@ -41,6 +43,8 @@ public function fromData( $redirectUri, $nonce, $isRevoked, + $isPreAuthorized, + $txCode, ); } @@ -81,6 +85,8 @@ 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']; + $isPreAuthorized = (bool) $state['is_pre_authorized']; + $txCode = empty($state['tx_code']) ? null : (string)$state['tx_code']; return $this->fromData( $id, @@ -91,6 +97,8 @@ public function fromState(array $state): AuthCodeEntity $redirectUri, $nonce, $isRevoked, + $isPreAuthorized, + $txCode, ); } } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 595e6d5a..dadc7535 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -105,6 +105,9 @@ class ModuleConfig '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'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -967,4 +970,25 @@ public function getApiTokenScopes(string $token): ?array 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 + { + $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/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index 46d46832..8c08425c 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -70,8 +70,31 @@ 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(), ); @@ -190,7 +213,9 @@ private function update(AuthCodeEntity $authCodeEntity): void client_id = :client_id, is_revoked = :is_revoked, redirect_uri = :redirect_uri, - nonce = :nonce + nonce = :nonce, + is_pre_authorized = :is_pre_authorized, + tx_code = :tx_code WHERE id = :id EOS , @@ -214,8 +239,10 @@ private function update(AuthCodeEntity $authCodeEntity): void protected function preparePdoState(array $state): array { $isRevoked = (bool)($state['is_revoked'] ?? true); + $isPreAuthorized = (bool)($state['is_pre_authorized'] ?? false); $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + $state['is_pre_authorized'] = [$isPreAuthorized, PDO::PARAM_BOOL]; return $state; } diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index 707fe267..df293547 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -124,6 +124,7 @@ public function respondToAccessTokenRequest( ); if (empty($preAuthorizedCodeId)) { + $this->loggerService->warning('Empty pre-authorized code ID.'); throw OidcServerException::invalidRequest(ParamsEnum::PreAuthorizedCode->value); } @@ -137,10 +138,12 @@ public function respondToAccessTokenRequest( 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.'); } if ($preAuthorizedCode->isRevoked()) { + $this->loggerService->error('Pre-authorized code is revoked. Value was: ' . $preAuthorizedCodeId); throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); } @@ -149,12 +152,30 @@ public function respondToAccessTokenRequest( // TODO validate code // $this->validateAuthorizationCode($preAuthorizedCode, $client, $request); - // TODO handle tx_code parameter -// $txCode = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( -// ParamsEnum::TxCode->value, -// $request, -// $this->allowedTokenHttpMethods, -// ); + // Validate Transaction Code. + if (($preAuthorizedCodeTxCode = $preAuthorizedCode->getTxCode()) !== null) { + $this->loggerService->debug('Validating transaction code ' . $preAuthorizedCode->getTxCode()); + $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.'); + } + } $authorizationDetails = null; $authorizationDetailsParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( @@ -190,6 +211,8 @@ public function respondToAccessTokenRequest( $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; } diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index a4936e88..08381a7c 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -163,6 +163,11 @@ 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')"); + } } private function versionsTableName(): string @@ -531,6 +536,17 @@ 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, + ADD tx_code VARCHAR(191) NULL +EOT + ,); + } + /** * @param string[] $columnNames */ diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig index 1932af63..88d1ada4 100644 --- a/templates/tests/verifiable-credential-issuance.twig +++ b/templates/tests/verifiable-credential-issuance.twig @@ -45,6 +45,24 @@ {% trans %}Credential Configuration ID to be offered.{% endtrans %} + + + + {% trans %}Check if you want to use Transaction Code protection.{% 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 %} + +
From 36553ad5302dff6fa9a1485210787fb69e64cee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 8 Sep 2025 13:56:11 +0200 Subject: [PATCH 46/70] WIP --- config/module_oidc.php.dist | 5 + hooks/hook_cron.php | 5 + src/Entities/IssuerStateEntity.php | 57 ++++++ src/Factories/CredentialOfferUriFactory.php | 57 +++++- .../Entities/IssuerStateEntityFactory.php | 87 ++++++++ src/ModuleConfig.php | 20 ++ src/Repositories/IssuerStateRepository.php | 188 ++++++++++++++++++ src/Services/DatabaseMigration.php | 20 ++ 8 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 src/Entities/IssuerStateEntity.php create mode 100644 src/Factories/Entities/IssuerStateEntityFactory.php create mode 100644 src/Repositories/IssuerStateRepository.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 65318f13..13a8f52e 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -823,4 +823,9 @@ $config = [ // \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/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/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/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index bb3185d6..879015d0 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\IssuerStateEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; @@ -21,6 +22,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; +use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\VerifiableCredentials; use SimpleSAML\OpenID\VerifiableCredentials\TxCode; @@ -38,9 +40,59 @@ public function __construct( protected readonly UserRepository $userRepository, protected readonly UserEntityFactory $userEntityFactory, protected readonly EmailFactory $emailFactory, + protected readonly IssuerStateEntityFactory $issuerStateEntityFactory, ) { } + /** + * @param string[] $credentialConfigurationIds + * @throws \SimpleSAML\OpenId\Exceptions\OpenIdException + */ + public function buildForAuthorization( + array $credentialConfigurationIds, + ): string { + + $issuerState = null; + + $issuerStateGenerationAttempts = 3; + while ($issuerStateGenerationAttempts > 0) { + $newIssuerState = $this->issuerStateEntityFactory->buildNew(); + if ($this->authCodeRepository->findById($newIssuerState->getValue()) === null) { + $issuerState = $newIssuerState; + break; + } + $issuerStateGenerationAttempts--; + } + + if ($issuerState === null) { + throw new OpenIdException('Failed to generate issuer state.'); + } + + + $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 @@ -119,8 +171,9 @@ public function buildPreAuthorized( $authCodeId = null; $authCodeIdGenerationAttempts = 3; while ($authCodeIdGenerationAttempts > 0) { - $authCodeId = $this->sspBridge->utils()->random()->generateID(); - if ($this->authCodeRepository->findById($authCodeId) === null) { + $newAuthCodeId = $this->sspBridge->utils()->random()->generateID(); + if ($this->authCodeRepository->findById($newAuthCodeId) === null) { + $authCodeId = $newAuthCodeId; break; } $authCodeIdGenerationAttempts--; diff --git a/src/Factories/Entities/IssuerStateEntityFactory.php b/src/Factories/Entities/IssuerStateEntityFactory.php new file mode 100644 index 00000000..49297469 --- /dev/null +++ b/src/Factories/Entities/IssuerStateEntityFactory.php @@ -0,0 +1,87 @@ +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/ModuleConfig.php b/src/ModuleConfig.php index dadc7535..34bcbd4b 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -108,6 +108,7 @@ class ModuleConfig 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'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -991,4 +992,23 @@ public function getDefaultUsersEmailAttributeName(): string { return $this->config()->getOptionalString(self::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME, 'mail'); } + + /** + * 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), + ); + } } diff --git a/src/Repositories/IssuerStateRepository.php b/src/Repositories/IssuerStateRepository.php new file mode 100644 index 00000000..18c87956 --- /dev/null +++ b/src/Repositories/IssuerStateRepository.php @@ -0,0 +1,188 @@ +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 create(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/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 08381a7c..e3b65885 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; @@ -168,6 +169,11 @@ public function migrate(): void $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')"); + } } private function versionsTableName(): string @@ -547,6 +553,20 @@ private function version20250818163000(): void ,); } + private function version20250908163000(): void + { + $issuerStateTableName = $this->database->applyPrefix(IssuerStateRepository::TABLE_NAME); + $this->database->write(<<< EOT + CREATE TABLE $issuerStateTableName ( + value CHAR(64) 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 + ,); + } + /** * @param string[] $columnNames */ From 9ec230607be9987c1348f291bccc41427414df08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 10 Sep 2025 10:45:30 +0200 Subject: [PATCH 47/70] Lint --- config/module_oidc.php.dist | 2 +- .../test-verifiable-credential-issuance.js | 25 +++++ .../VerifiableCredentailsTestController.php | 37 ++++++-- .../Api/VciCredentialOfferController.php | 21 +---- .../CredentialIssuerCredentialController.php | 66 +++++++++++-- src/Factories/AuthorizationServerFactory.php | 11 ++- src/Factories/CredentialOfferUriFactory.php | 94 ++++++++++--------- .../Entities/IssuerStateEntityFactory.php | 6 +- src/ModuleConfig.php | 55 +++++++++-- src/Repositories/IssuerStateRepository.php | 4 +- src/Server/Grants/PreAuthCodeGrant.php | 3 +- src/Services/Api/Authorization.php | 1 + src/Services/Container.php | 17 ++++ src/Services/DatabaseMigration.php | 8 +- .../tests/verifiable-credential-issuance.twig | 20 +++- .../unit/src/Entities/AuthCodeEntityTest.php | 2 + .../src/Server/Grants/AuthCodeGrantTest.php | 4 + .../Validators/BearerTokenValidatorTest.php | 12 ++- 18 files changed, 288 insertions(+), 100 deletions(-) create mode 100644 public/assets/js/src/test-verifiable-credential-issuance.js diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 13a8f52e..8cf7dacd 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -676,7 +676,7 @@ $config = [ // Sample for 'dc+sd-jwt' format without notes about required and optional fields. 'ResearchAndScholarshipCredentialDcSdJwt' => [ - ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::DcSdJwt->value, ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialDcSdJwt', ClaimsEnum::Display->value => [ [ 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/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index 9f049e7d..b6335835 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -5,17 +5,17 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; use SimpleSAML\Auth\Simple; +use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; use SimpleSAML\Module\oidc\Factories\CredentialOfferUriFactory; -use SimpleSAML\Module\oidc\Factories\EmailFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,8 +25,6 @@ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly TemplateFactory $templateFactory, protected readonly Authorization $authorization, - protected readonly LoggerService $loggerService, - protected readonly EmailFactory $emailFactory, protected readonly AuthSimpleFactory $authSimpleFactory, protected readonly SessionService $sessionService, protected readonly SspBridge $sspBridge, @@ -40,6 +38,7 @@ public function __construct( * @throws \SimpleSAML\Error\ConfigurationError * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException * @throws \SimpleSAML\OpenID\Exceptions\CredentialOfferException + * @psalm-suppress MixedAssignment, InternalMethod */ public function verifiableCredentialIssuance(Request $request): Response { @@ -85,11 +84,13 @@ public function verifiableCredentialIssuance(Request $request): Response $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', @@ -114,7 +115,11 @@ public function verifiableCredentialIssuance(Request $request): Response $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) : @@ -129,12 +134,18 @@ public function verifiableCredentialIssuance(Request $request): Response $authSource->getAuthSource()->getAuthId(), ); - $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( - [$selectedCredentialConfigurationId], - $userAttributes, - $useTxCode, - $usersEmailAttributeName, - ); + 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/ @@ -145,6 +156,11 @@ public function verifiableCredentialIssuance(Request $request): Response $defaultUsersEmailAttributeName = $this->moduleConfig->getDefaultUsersEmailAttributeName(); + $grantTypesSupported = [ + GrantTypesEnum::AuthorizationCode->value => Translate::noop('Authorization Code'), + GrantTypesEnum::PreAuthorizedCode->value => Translate::noop('Pre-authorized Code'), + ]; + return $this->templateFactory->build( 'oidc:tests/verifiable-credential-issuance.twig', compact( @@ -158,6 +174,7 @@ public function verifiableCredentialIssuance(Request $request): Response 'selectedCredentialConfigurationId', 'defaultUsersEmailAttributeName', 'usersEmailAttributeName', + 'grantTypesSupported', ), RoutesEnum::AdminTestVerifiableCredentialIssuance->value, ); diff --git a/src/Controllers/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php index 8c8d3dfb..b2309b6e 100644 --- a/src/Controllers/Api/VciCredentialOfferController.php +++ b/src/Controllers/Api/VciCredentialOfferController.php @@ -4,22 +4,14 @@ namespace SimpleSAML\Module\oidc\Controllers\Api; -use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Factories\CredentialOfferUriFactory; -use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; -use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; -use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; -use SimpleSAML\Module\oidc\Repositories\ClientRepository; -use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\Api\Authorization; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; -use SimpleSAML\OpenID\VerifiableCredentials; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,15 +23,7 @@ class VciCredentialOfferController public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Authorization $authorization, - protected readonly VerifiableCredentials $verifiableCredentials, - protected readonly ClientEntityFactory $clientEntityFactory, - protected readonly ClientRepository $clientRepository, - protected readonly SspBridge $sspBridge, protected readonly LoggerService $loggerService, - protected readonly UserRepository $userRepository, - protected readonly UserEntityFactory $userEntityFactory, - protected readonly AuthCodeRepository $authCodeRepository, - protected readonly AuthCodeEntityFactory $authCodeEntityFactory, protected readonly Routes $routes, protected readonly CredentialOfferUriFactory $credentialOfferUriFactory, ) { @@ -52,6 +36,7 @@ public function __construct( */ public function credentialOffer(Request $request): Response { + $this->loggerService->debug('VCI credential offer request data: ', $request->getPayload()->all()); try { $this->authorization->requireTokenForAnyOfScope( $request, @@ -66,7 +51,9 @@ public function credentialOffer(Request $request): Response } $input = $request->getPayload()->all(); + /** @psalm-suppress MixedAssignment */ $userAttributes = $input['user_attributes'] ?? []; + $userAttributes = is_array($userAttributes) ? $userAttributes : []; $selectedCredentialConfigurationId = $input['credential_configuration_id'] ?? null; @@ -79,8 +66,10 @@ public function credentialOffer(Request $request): Response } $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; diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 7b5dafa5..c27e3a9e 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -7,6 +7,7 @@ use Base64Url\Base64Url; use League\OAuth2\Server\ResourceServer; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; @@ -23,6 +24,7 @@ use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Did; use SimpleSAML\OpenID\Exceptions\OpenId4VciProofException; +use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\Jwk; use SimpleSAML\OpenID\VerifiableCredentials; use SimpleSAML\OpenID\VerifiableCredentials\OpenId4VciProof; @@ -62,6 +64,7 @@ public function __construct( * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \ReflectionException + * @throws OpenIdException */ public function credential(Request $request): Response { @@ -77,7 +80,18 @@ public function credential(Request $request): Response ); // TODO mivanci validate access token - $accessToken = $this->accessTokenRepository->findById($authorization->getAttribute('oauth_access_token_id')); + $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', @@ -90,7 +104,7 @@ public function credential(Request $request): Response $credentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null; - if (is_null($credentialFormatId)) { + if (!is_string($credentialFormatId)) { throw OidcServerException::serverError('Credential format missing in request.'); } @@ -111,6 +125,7 @@ public function credential(Request $request): Response $credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null; + /** @psalm-suppress MixedAssignment */ if (is_null($credentialConfigurationId)) { // TODO mivanci Update this to newest draft. // Check per draft 14 (Sphereon wallet case). @@ -133,7 +148,7 @@ public function credential(Request $request): Response } } - if (is_null($credentialConfigurationId)) { + if (!is_string($credentialConfigurationId)) { return $this->routes->newJsonErrorResponse( 'invalid_credential_request', 'Can not resolve credential configuration ID.', @@ -148,6 +163,9 @@ public function credential(Request $request): Response } $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.'); @@ -159,12 +177,13 @@ public function credential(Request $request): Response $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' + $requestData['proof']['proof_type'] === 'jwt' && + is_string($proofJwt = $requestData['proof']['jwt']) ) { - $proofJwt = $requestData['proof']['jwt']; $this->loggerService->debug('Verifying proof JWT: ' . $proofJwt); try { @@ -239,8 +258,30 @@ public function credential(Request $request): Response $credentialConfigurationId, ); 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.', @@ -258,6 +299,7 @@ public function credential(Request $request): Response } // Normalize to string for single array values. + /** @psalm-suppress MixedAssignment */ $attributeValue = is_array($userAttributes[$userAttributeName]) && count($userAttributes[$userAttributeName]) === 1 ? reset($userAttributes[$userAttributeName]) : @@ -285,10 +327,11 @@ public function credential(Request $request): Response continue; } + /** @psalm-suppress ArgumentTypeCoercion */ $disclosure = $this->verifiableCredentials->disclosureFactory()->build( value: $attributeValue, name: $claimName, - path: is_array($credentialClaimPath) ? $credentialClaimPath : [], + path: $credentialClaimPath, saltBlacklist: $disclosureBag->salts(), ); @@ -389,6 +432,10 @@ public function credential(Request $request): Response ); } + if ($verifiableCredential === null) { + throw new OpenIdException('Invalid credential format ID.'); + } + $this->loggerService->debug('response', [ 'credentials' => [ ['credential' => $verifiableCredential->getToken()], @@ -407,15 +454,22 @@ public function credential(Request $request): Response /** * 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]; } diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 31ed11b5..54e65388 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -73,11 +73,12 @@ public function build(): AuthorizationServer $this->moduleConfig->getAccessTokenDuration(), ); - // TODO mivanci Only enable if VCI is enabled. - $authorizationServer->enableGrantType( - $this->preAuthCodeGrant, - $this->moduleConfig->getAccessTokenDuration(), - ); + if ($this->moduleConfig->getVerifiableCredentialEnabled()) { + $authorizationServer->enableGrantType( + $this->preAuthCodeGrant, + $this->moduleConfig->getAccessTokenDuration(), + ); + } return $authorizationServer; } diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index 879015d0..2540aa47 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -18,6 +18,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; @@ -41,33 +42,37 @@ public function __construct( protected readonly UserEntityFactory $userEntityFactory, protected readonly EmailFactory $emailFactory, protected readonly IssuerStateEntityFactory $issuerStateEntityFactory, + protected readonly IssuerStateRepository $issuerStateRepository, ) { } /** * @param string[] $credentialConfigurationIds - * @throws \SimpleSAML\OpenId\Exceptions\OpenIdException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ public function buildForAuthorization( array $credentialConfigurationIds, ): string { - $issuerState = null; - $issuerStateGenerationAttempts = 3; - while ($issuerStateGenerationAttempts > 0) { - $newIssuerState = $this->issuerStateEntityFactory->buildNew(); - if ($this->authCodeRepository->findById($newIssuerState->getValue()) === null) { - $issuerState = $newIssuerState; + while ($issuerStateGenerationAttempts-- > 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()); } - $issuerStateGenerationAttempts--; - } - - if ($issuerState === null) { - throw new OpenIdException('Failed to generate issuer state.'); } + /** @psalm-var \SimpleSAML\Module\oidc\Entities\IssuerStateEntity $issuerState */ $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( parameters: [ @@ -95,7 +100,7 @@ public function buildForAuthorization( /** * @param string[] $credentialConfigurationIds - * @throws \SimpleSAML\OpenId\Exceptions\OpenIdException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ public function buildPreAuthorized( array $credentialConfigurationIds, @@ -136,10 +141,16 @@ public function buildPreAuthorized( $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(), @@ -168,21 +179,6 @@ public function buildPreAuthorized( $this->userRepository->add($userEntity); } - $authCodeId = null; - $authCodeIdGenerationAttempts = 3; - while ($authCodeIdGenerationAttempts > 0) { - $newAuthCodeId = $this->sspBridge->utils()->random()->generateID(); - if ($this->authCodeRepository->findById($newAuthCodeId) === null) { - $authCodeId = $newAuthCodeId; - break; - } - $authCodeIdGenerationAttempts--; - } - - if ($authCodeId === null) { - throw new RuntimeException('Failed to generate Authorization Code ID.'); - } - $txCode = null; $userEmail = null; $userEmailAttributeName ??= $this->moduleConfig->getDefaultUsersEmailAttributeName(); @@ -196,17 +192,34 @@ public function buildPreAuthorized( ); } - $authCode = $this->authCodeEntityFactory->fromData( - id: $authCodeId, - client: $client, - scopes: $scopes, - expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), - userIdentifier: $userId, - redirectUri: 'openid-credential-offer://', - isPreAuthorized: true, - txCode: $txCode instanceof VerifiableCredentials\TxCode ? $txCode->getCodeAsString() : null, - ); - $this->authCodeRepository->persistNewAuthCode($authCode); + $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://', + isPreAuthorized: true, + 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: [ @@ -278,9 +291,6 @@ public function buildTxCode( ); } - /** - * @throws OidcException - */ public function sendTxCodeByEmail(TxCode $txCode, string $email, string $subject = null): void { $subject ??= 'Your one-time code'; diff --git a/src/Factories/Entities/IssuerStateEntityFactory.php b/src/Factories/Entities/IssuerStateEntityFactory.php index 49297469..7b683dd0 100644 --- a/src/Factories/Entities/IssuerStateEntityFactory.php +++ b/src/Factories/Entities/IssuerStateEntityFactory.php @@ -7,7 +7,6 @@ use DateTimeImmutable; use SimpleSAML\Module\oidc\Entities\IssuerStateEntity; use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\Helpers\Random; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\Exceptions\OpenIdException; @@ -15,14 +14,13 @@ class IssuerStateEntityFactory { public function __construct( protected readonly ModuleConfig $moduleConfig, - protected readonly Random $random, protected readonly Helpers $helpers, ) { } /** - * @throws OpenIdException - * @throws OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @throws \Exception */ public function buildNew( diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 34bcbd4b..dd97786d 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -357,6 +357,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 { @@ -379,8 +381,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 { @@ -861,7 +864,7 @@ public function getVerifiableCredentialEnabled(): bool public function getCredentialConfigurationsSupported(): array { - return $this->config()->getOptionalArray(self::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []) ?? []; + return $this->config()->getOptionalArray(self::OPTION_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); } /** @@ -890,9 +893,15 @@ public function getCredentialConfiguration(string $credentialConfigurationId): ? return $credentialConfiguration; } + /** + * @return array + */ public function getCredentialConfigurationIdsSupported(): array { - return array_keys($this->getCredentialConfigurationsSupported()); + return array_map( + 'strval', + array_keys($this->getCredentialConfigurationsSupported()), + ); } public function getCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string @@ -900,11 +909,21 @@ public function getCredentialConfigurationIdForCredentialDefinitionType(array $c foreach ( $this->getCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration ) { - $configuredType = - $credentialConfiguration[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value]; + 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 $credentialConfigurationId; + return (string)$credentialConfigurationId; } } @@ -921,8 +940,17 @@ public function getValidCredentialClaimPathsFor(string $credentialConfigurationI [ClaimsEnum::Claims->value] ?? []; $validPaths = []; + + if (!is_array($claimsConfig)) { + return $validPaths; + } + + /** @psalm-suppress MixedAssignment */ foreach ($claimsConfig as $claim) { - $validPaths[] = $claim[ClaimsEnum::Path->value] ?? null; + if (is_array($claim)) { + /** @psalm-suppress MixedAssignment */ + $validPaths[] = $claim[ClaimsEnum::Path->value] ?? null; + } } return array_filter($validPaths); @@ -930,12 +958,19 @@ public function getValidCredentialClaimPathsFor(string $credentialConfigurationI public function getUserAttributeToCredentialClaimPathMap(): array { - return $this->config()->getOptionalArray(self::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []) ?? []; + return $this->config()->getOptionalArray(self::OPTION_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []); } public function getUserAttributeToCredentialClaimPathMapFor(string $credentialConfigurationId): array { - return $this->getUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; + /** @psalm-suppress MixedAssignment */ + $map = $this->getUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; + + if (is_array($map)) { + return $map; + } + + return []; } @@ -963,6 +998,7 @@ public function getApiTokens(): ?array */ public function getApiTokenScopes(string $token): ?array { + /** @psalm-suppress MixedAssignment */ $tokenScopes = $this->getApiTokens()[$token] ?? null; if (is_array($tokenScopes)) { @@ -979,6 +1015,7 @@ public function getAuthSourcesToUsersEmailAttributeMap(): array public function getUsersEmailAttributeNameForAuthSourceId(string $authSource): string { + /** @psalm-suppress MixedAssignment */ $attributeName = $this->getAuthSourcesToUsersEmailAttributeMap()[$authSource] ?? null; if (is_string($attributeName)) { diff --git a/src/Repositories/IssuerStateRepository.php b/src/Repositories/IssuerStateRepository.php index 18c87956..4e165a5b 100644 --- a/src/Repositories/IssuerStateRepository.php +++ b/src/Repositories/IssuerStateRepository.php @@ -27,7 +27,7 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } - public function getTableName(): ?string + public function getTableName(): string { return self::TABLE_NAME; } @@ -125,7 +125,7 @@ public function update(IssuerStateEntity $issuerState): void ); } - public function create(IssuerStateEntity $issuerState): void + public function persist(IssuerStateEntity $issuerState): void { $stmt = sprintf( <<getTxCode()) !== null) { - $this->loggerService->debug('Validating transaction code ' . $preAuthorizedCode->getTxCode()); + $this->loggerService->debug('Validating transaction code ' . $preAuthorizedCodeTxCode); $txCodeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::TxCode->value, $request, @@ -185,6 +185,7 @@ public function respondToAccessTokenRequest( ); if (!empty($authorizationDetailsParam)) { + /** @psalm-suppress MixedAssignment */ $authorizationDetails = json_decode($authorizationDetailsParam, true, 512, JSON_THROW_ON_ERROR); } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 83024ae7..66979574 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -76,6 +76,7 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope protected function findToken(Request $request): ?string { + /** @psalm-suppress InternalMethod */ if ($token = trim((string) $request->get(self::KEY_TOKEN))) { return $token; } diff --git a/src/Services/Container.php b/src/Services/Container.php index 6144e9ab..89629bee 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; @@ -66,6 +67,7 @@ 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; @@ -330,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; diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index e3b65885..ee408536 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -547,8 +547,12 @@ 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, - ADD tx_code VARCHAR(191) NULL + ADD is_pre_authorized BOOLEAN NOT NULL DEFAULT false; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD tx_code VARCHAR(191) NULL; EOT ,); } diff --git a/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig index 88d1ada4..e792eb0e 100644 --- a/templates/tests/verifiable-credential-issuance.twig +++ b/templates/tests/verifiable-credential-issuance.twig @@ -45,10 +45,22 @@ {% 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.{% 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 %} @@ -106,3 +118,9 @@ {% endblock oidcContent -%} + +{% block postload %} + {{ parent() }} + + +{% endblock %} \ No newline at end of file diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index b9cc457e..ac7ddbbc 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -98,6 +98,8 @@ public function testCanGetState(): void 'is_revoked' => false, 'redirect_uri' => 'https://localhost/redirect', 'nonce' => 'nonce', + 'is_pre_authorized' => false, + 'tx_code' => null, ], ); } diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index 4479ddf3..d875d77d 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,7 @@ public function testCanCreateInstance(): void $this->authCodeEntityFactoryStub, $this->refreshTokenIssuerStub, $this->helpersStub, + $this->loggerMock, ), ); } 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); From a16b9985ad030be8fb52ecafa12e9e2062cb5ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 10 Sep 2025 11:57:41 +0200 Subject: [PATCH 48/70] Update conformance SQL --- docker/conformance.sql | 26 ++++++++++++------- .../VerifiableCredentailsTestController.php | 2 +- src/Services/DatabaseMigration.php | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docker/conformance.sql b/docker/conformance.sql index 4194f9e8..26f5546d 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -56,17 +56,17 @@ 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 + 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 +78,16 @@ 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, + is_pre_authorized BOOLEAN NOT NULL DEFAULT false, + tx_code varchar(191) DEFAULT 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 +100,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/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php index b6335835..fe5f26d7 100644 --- a/src/Controllers/Admin/VerifiableCredentailsTestController.php +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -157,8 +157,8 @@ public function verifiableCredentialIssuance(Request $request): Response $defaultUsersEmailAttributeName = $this->moduleConfig->getDefaultUsersEmailAttributeName(); $grantTypesSupported = [ - GrantTypesEnum::AuthorizationCode->value => Translate::noop('Authorization Code'), GrantTypesEnum::PreAuthorizedCode->value => Translate::noop('Pre-authorized Code'), + GrantTypesEnum::AuthorizationCode->value => Translate::noop('Authorization Code'), ]; return $this->templateFactory->build( diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index ee408536..60943297 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -562,7 +562,7 @@ private function version20250908163000(): void $issuerStateTableName = $this->database->applyPrefix(IssuerStateRepository::TABLE_NAME); $this->database->write(<<< EOT CREATE TABLE $issuerStateTableName ( - value CHAR(64) NOT NULL, + 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 From d75b9d2003970f2184de3a8c7e8609cdf77c8183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 10 Sep 2025 15:55:29 +0200 Subject: [PATCH 49/70] Before VCI Authorization Code --- config/module_oidc.php.dist | 2 ++ .../CredentialIssuerConfigurationController.php | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 8cf7dacd..9b5f8486 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -677,6 +677,8 @@ $config = [ // 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 => [ [ diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index 4c21c926..f5725412 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -41,9 +41,20 @@ public function configuration(): Response $signer = $this->moduleConfig->getProtocolSigner(); $credentialConfigurationsSupported = $this->moduleConfig->getCredentialConfigurationsSupported(); - $credentialConfigurationsSupported[ClaimsEnum::CredentialSigningAlgValuesSupported->value] = [ - $signer->algorithmId(), - ]; + + // For now, we only support one credential signing algorithm. + foreach ($credentialConfigurationsSupported as $credentialConfigurationId => $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(), From ce40d9592a40c56520fad442ea6dc30e8e396286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 10 Sep 2025 16:10:55 +0200 Subject: [PATCH 50/70] Rename ClientIdRule to ClientRule --- ...redentialIssuerConfigurationController.php | 24 ++++++++++--------- src/Factories/RequestRulesManagerFactory.php | 4 ++-- src/Server/AuthorizationServer.php | 4 ++-- src/Server/Grants/AuthCodeGrant.php | 8 +++---- src/Server/Grants/ImplicitGrant.php | 4 ++-- .../Rules/ClientAuthenticationRule.php | 2 +- .../{ClientIdRule.php => ClientRule.php} | 5 +++- .../RequestRules/Rules/CodeChallengeRule.php | 2 +- .../RequestRules/Rules/CodeVerifierRule.php | 2 +- src/Server/RequestRules/Rules/MaxAgeRule.php | 2 +- src/Server/RequestRules/Rules/PromptRule.php | 2 +- .../RequestRules/Rules/RedirectUriRule.php | 2 +- .../RequestRules/Rules/RequestObjectRule.php | 2 +- .../Rules/RequestedClaimsRule.php | 2 +- .../Rules/ScopeOfflineAccessRule.php | 2 +- src/Services/Container.php | 4 ++-- .../RequestRules/Rules/ClientIdRuleTest.php | 10 ++++---- .../Rules/CodeChallengeRuleTest.php | 4 ++-- .../Rules/RedirectUriRuleTest.php | 8 +++---- .../Rules/RequestObjectRuleTest.php | 4 ++-- .../Rules/RequestedClaimsRuleTest.php | 4 ++-- 21 files changed, 53 insertions(+), 48 deletions(-) rename src/Server/RequestRules/Rules/{ClientIdRule.php => ClientRule.php} (98%) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index f5725412..fc42f900 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -35,7 +35,6 @@ public function __construct( public function configuration(): Response { - // TODO mivanci Abstract configuring Credential Issuer / Configuration away from module config. // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p $signer = $this->moduleConfig->getProtocolSigner(); @@ -43,17 +42,20 @@ public function configuration(): Response $credentialConfigurationsSupported = $this->moduleConfig->getCredentialConfigurationsSupported(); // For now, we only support one credential signing algorithm. + /** @psalm-suppress MixedAssignment */ foreach ($credentialConfigurationsSupported as $credentialConfigurationId => $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; + 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 = [ diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index bf28d4da..df7e8c74 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -15,7 +15,7 @@ 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\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 +84,7 @@ private function getDefaultRules(): array { return [ new StateRule($this->requestParamsResolver, $this->helpers), - new ClientIdRule( + new ClientRule( $this->requestParamsResolver, $this->helpers, $this->clientRepository, diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 65c83e98..da165133 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -18,7 +18,7 @@ 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\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; @@ -79,7 +79,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O { $rulesToExecute = [ StateRule::class, - ClientIdRule::class, + ClientRule::class, RedirectUriRule::class, ]; diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 2656f7ec..1ee143de 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -40,7 +40,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +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; @@ -389,7 +389,7 @@ public function respondToAccessTokenRequest( //[$clientId] = $this->getClientCredentials($request); $rulesToExecute = [ - ClientIdRule::class, + ClientRule::class, RedirectUriRule::class, ClientAuthenticationRule::class, CodeVerifierRule::class, @@ -403,7 +403,7 @@ public function respondToAccessTokenRequest( ); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $resultBag->getOrFail(ClientRule::class)->getValue(); /** @var ?string $clientAuthenticationParam */ $clientAuthenticationParam = $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); /** @var ?string $codeVerifier */ @@ -668,7 +668,7 @@ public function validateAuthorizationRequestWithRequestRules( /** @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 have to have certain things available in order to work properly... $this->requestRulesManager->setData('default_scope', $this->defaultScope); diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index 4e2026bc..4b804621 100644 --- a/src/Server/Grants/ImplicitGrant.php +++ b/src/Server/Grants/ImplicitGrant.php @@ -24,7 +24,7 @@ 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\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; @@ -142,7 +142,7 @@ public function validateAuthorizationRequestWithRequestRules( /** @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/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/ClientRule.php similarity index 98% rename from src/Server/RequestRules/Rules/ClientIdRule.php rename to src/Server/RequestRules/Rules/ClientRule.php index b377377f..e58fe4aa 100644 --- a/src/Server/RequestRules/Rules/ClientIdRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -28,7 +28,10 @@ use SimpleSAML\OpenID\Federation; use Throwable; -class ClientIdRule extends AbstractRule +/** + * Resolve a client instance based on a client_id or request object. + */ +class ClientRule extends AbstractRule { protected const KEY_REQUEST_OBJECT_JTI = 'request_object_jti'; diff --git a/src/Server/RequestRules/Rules/CodeChallengeRule.php b/src/Server/RequestRules/Rules/CodeChallengeRule.php index 38a9e431..40389576 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeRule.php @@ -28,7 +28,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(); /** @var string $redirectUri */ $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); /** @var string|null $state */ 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/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index 38c4a809..0d64ea7e 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -52,7 +52,7 @@ public function 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); diff --git a/src/Server/RequestRules/Rules/PromptRule.php b/src/Server/RequestRules/Rules/PromptRule.php index 60fd38cc..46a70978 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -48,7 +48,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(); $authSimple = $this->authSimpleFactory->build($client); diff --git a/src/Server/RequestRules/Rules/RedirectUriRule.php b/src/Server/RequestRules/Rules/RedirectUriRule.php index 22a9f95f..07409ed5 100644 --- a/src/Server/RequestRules/Rules/RedirectUriRule.php +++ b/src/Server/RequestRules/Rules/RedirectUriRule.php @@ -29,7 +29,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); if (! $client instanceof ClientEntityInterface) { throw new LogicException('Can not check redirect_uri, client is not ClientEntityInterface.'); } diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index a1f74a24..4e0e2d49 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -67,7 +67,7 @@ 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(); /** @var ?string $stateValue */ diff --git a/src/Server/RequestRules/Rules/RequestedClaimsRule.php b/src/Server/RequestRules/Rules/RequestedClaimsRule.php index d8b27970..68f3c583 100644 --- a/src/Server/RequestRules/Rules/RequestedClaimsRule.php +++ b/src/Server/RequestRules/Rules/RequestedClaimsRule.php @@ -56,7 +56,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/ScopeOfflineAccessRule.php b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php index ae67f588..07f83b0a 100644 --- a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php +++ b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php @@ -31,7 +31,7 @@ public function checkRule( /** @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/Services/Container.php b/src/Services/Container.php index 89629bee..1a316290 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -80,7 +80,7 @@ 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\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; @@ -386,7 +386,7 @@ public function __construct() $requestRules = [ new StateRule($requestParamsResolver, $helpers), - new ClientIdRule( + new ClientRule( $requestParamsResolver, $helpers, $clientRepository, diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php index 9556d8d3..4e58fd24 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.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,7 +24,7 @@ use SimpleSAML\OpenID\Federation; /** - * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule + * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule */ class ClientIdRuleTest extends TestCase { @@ -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, @@ -79,7 +79,7 @@ protected function sut(): ClientIdRule public function testConstruct(): void { - $this->assertInstanceOf(ClientIdRule::class, $this->sut()); + $this->assertInstanceOf(ClientRule::class, $this->sut()); } public function testCheckRuleEmptyClientIdThrows(): void diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php index 671badb7..65355a6e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php @@ -15,7 +15,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\ClientIdRule; +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; @@ -52,7 +52,7 @@ protected function setUp(): void $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..cfdb5fc9 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php @@ -14,7 +14,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\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -76,7 +76,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 +112,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 +137,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..08704abb 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -14,7 +14,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\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -39,7 +39,7 @@ 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)], + [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], [RedirectUriRule::class, new Result(RedirectUriRule::class, 'https://example.com/redirect')], ]); $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::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); From 847111a99fd7db5c6445e5e6e50af708f242c3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Sep 2025 09:35:41 +0200 Subject: [PATCH 51/70] Pre auth code release modification --- config/module_oidc.php.dist | 26 +- src/Factories/AuthSimpleFactory.php | 13 +- src/Factories/CredentialOfferUriFactory.php | 2 +- .../Entities/ClientEntityFactory.php | 6 +- src/Factories/RequestRulesManagerFactory.php | 7 +- src/ModuleConfig.php | 26 ++ src/Server/Grants/AuthCodeGrant.php | 13 + src/Server/Grants/PreAuthCodeGrant.php | 9 +- src/Server/RequestRules/Rules/ClientRule.php | 247 ++++++++++++++---- .../RequestRules/Rules/IssuerStateRule.php | 61 +++++ src/Server/RequestRules/Rules/MaxAgeRule.php | 2 +- src/Server/RequestRules/Rules/PromptRule.php | 2 +- .../RequestRules/Rules/RedirectUriRule.php | 75 +++++- .../Rules/RequiredOpenIdScopeRule.php | 27 +- .../RequestTypes/AuthorizationRequest.php | 34 +++ src/Services/AuthenticationService.php | 35 ++- src/Services/Container.php | 4 +- src/Utils/RequestParamsResolver.php | 25 ++ .../tests/verifiable-credential-issuance.twig | 4 +- ...lientIdRuleTest.php => ClientRuleTest.php} | 5 +- .../Rules/RedirectUriRuleTest.php | 6 + .../Services/AuthenticationServiceTest.php | 7 +- 22 files changed, 532 insertions(+), 104 deletions(-) create mode 100644 src/Server/RequestRules/Rules/IssuerStateRule.php rename tests/unit/src/Server/RequestRules/Rules/{ClientIdRuleTest.php => ClientRuleTest.php} (96%) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 9b5f8486..58d23b2f 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -510,6 +510,25 @@ $config = [ // 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://', + ], + + // Allow or disallow clients to request verifiable credentials using Authorization Code Grant without client ID. + // Default is disallowed (false). + ModuleConfig::OPTION_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID => false, // (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. @@ -813,11 +832,8 @@ $config = [ // (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]] - */ + // 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. diff --git a/src/Factories/AuthSimpleFactory.php b/src/Factories/AuthSimpleFactory.php index 7767824a..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,9 +53,15 @@ 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 diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index 2540aa47..5713870d 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -132,7 +132,7 @@ public function buildPreAuthorized( // 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. - $client = $this->clientEntityFactory->getGenericForVciPreAuthZFlow(); + $client = $this->clientEntityFactory->getGenericForVci(); if ($this->clientRepository->findById($client->getIdentifier()) === null) { $this->clientRepository->add($client); } else { diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 42f3ec11..f6d759e5 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -383,7 +383,7 @@ public function fromState(array $state): ClientEntityInterface ); } - public function getGenericForVciPreAuthZFlow(): ClientEntityInterface + public function getGenericForVci(): ClientEntityInterface { $clientId = 'vci_' . hash('sha256', 'vci_' . $this->moduleConfig->sspConfig()->getString('secretsalt')); @@ -397,8 +397,8 @@ public function getGenericForVciPreAuthZFlow(): ClientEntityInterface return $this->fromData( id: $clientId, secret: $clientSecret, - name: 'VCI Pre-authorized Code Generic Client', - description: 'Generic client for VCI Pre-authorized Code', + name: 'VCI Generic Client', + description: 'Generic client for Verifiable Credential Issuance flows.', redirectUri: ['openid-credential-offer://'], scopes: ['openid', ...$credentialConfigurationIdsSupported], isEnabled: true, diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index df7e8c74..b108b2fc 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; @@ -20,6 +21,7 @@ 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; @@ -62,6 +64,7 @@ public function __construct( private readonly JwksResolver $jwksResolver, private readonly FederationParticipationValidator $federationParticipationValidator, private readonly SspBridge $sspBridge, + private readonly IssuerStateRepository $issuerStateRepository, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, ) { @@ -93,9 +96,10 @@ private function getDefaultRules(): array $this->federation, $this->jwksResolver, $this->federationParticipationValidator, + $this->logger, $this->federationCache, ), - new RedirectUriRule($this->requestParamsResolver, $this->helpers), + new RedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), new RequestObjectRule($this->requestParamsResolver, $this->helpers, $this->jwksResolver), new PromptRule( $this->requestParamsResolver, @@ -141,6 +145,7 @@ private function getDefaultRules(): array $this->protocolCache, ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), + new IssuerStateRule($this->requestParamsResolver, $this->helpers, $this->issuerStateRepository), ]; } } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index dd97786d..73883e38 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -109,6 +109,11 @@ class ModuleConfig 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_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID = + 'allow_vci_authorization_code_requests_without_client_id'; + 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 => [ @@ -1048,4 +1053,25 @@ public function getIssuerStateDuration(): 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 getAllowVciAuthorizationCodeRequestsWithoutClientId(): bool + { + return $this->config()->getOptionalBoolean( + self::OPTION_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID, + false, + ); + } + + public function getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci(): array + { + return $this->config()->getOptionalArray( + self::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI, + ['openid-credential-offer://',], + ); + } } diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 1ee143de..df725cbe 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -44,6 +44,7 @@ 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; @@ -658,6 +659,7 @@ public function validateAuthorizationRequestWithRequestRules( RequiredOpenIdScopeRule::class, CodeChallengeRule::class, CodeChallengeMethodRule::class, + IssuerStateRule::class, ]; // Since we have already validated redirect_uri, and we have state, make it available for other checkers. @@ -738,6 +740,17 @@ public function validateAuthorizationRequestWithRequestRules( $acrValues = $resultBag->getOrFail(AcrValuesRule::class)->getValue(); $authorizationRequest->setRequestedAcrValues($acrValues); + $authorizationRequest->setIsVciRequest( + $this->requestParamsResolver->isVciAuthorizationCodeRequest( + $request, + $this->allowedAuthorizationHttpMethods, + ), + ); + + /** @var ?string $issuerState */ + $issuerState = $resultBag->get(IssuerStateRule::class)?->getValue(); + $authorizationRequest->setIssuerState($issuerState); + return $authorizationRequest; } diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index fa9d124e..59636c65 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -124,7 +124,7 @@ public function respondToAccessTokenRequest( ); if (empty($preAuthorizedCodeId)) { - $this->loggerService->warning('Empty pre-authorized code ID.'); + $this->loggerService->error('Empty pre-authorized code ID.'); throw OidcServerException::invalidRequest(ParamsEnum::PreAuthorizedCode->value); } @@ -142,6 +142,13 @@ public function respondToAccessTokenRequest( throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); } + if (!$preAuthorizedCode->isPreAuthorized()) { + $this->loggerService->error( + 'Pre-authorized code is not pre-authorized. Value was: ' . $preAuthorizedCodeId, + ); + throw OidcServerException::invalidGrant('Pre-authorized code is not pre-authorized.'); + } + if ($preAuthorizedCode->isRevoked()) { $this->loggerService->error('Pre-authorized code is revoked. Value was: ' . $preAuthorizedCodeId); throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index e58fe4aa..9c962900 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -25,6 +25,7 @@ use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Federation; use Throwable; @@ -44,6 +45,7 @@ public function __construct( protected Federation $federation, protected JwksResolver $jwksResolver, protected FederationParticipationValidator $federationParticipationValidator, + protected LoggerService $loggerService, protected ?FederationCache $federationCache = null, ) { parent::__construct($requestParamsResolver, $helpers); @@ -72,88 +74,189 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + + $this->loggerService->debug( + 'ClientRule: Request parameters:', + $this->requestParamsResolver->getAllBasedOnAllowedMethods( + $request, + $allowedServerRequestMethods, + ), + ); + /** @var ?string $clientId */ - $clientId = $this->requestParamsResolver->getBasedOnAllowedMethods( + $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.', + ); + // Check to see if this is a Verifiable Credential Request. Is yes, check if VCI is + // enabled, and if client_id is allowed to be empty. We know that this is a VCI request because the + // Issuer State parameter must be present. + if ( + $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && + $this->moduleConfig->getVerifiableCredentialEnabled() && + $this->moduleConfig->getAllowVciAuthorizationCodeRequestsWithoutClientId() && + $this->moduleConfig->getAllowNonRegisteredClientsForVci() + ) { + // We will use a VCI generic client in this case. + $this->loggerService->warning( + 'ClientRule: VCI authorization code request without client_id detected.' . + ' Using generic VCI client.', + ); + + return new Result($this->getKey(), $this->getGenericVciClient()); + } + 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() === false) { - throw OidcServerException::invalidClient($request); + 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->getGenericVciClient()); + } else { + $this->loggerService->debug( + 'ClientRule: Verifiable Credential capabilities with non-registered clients are 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, - $allowedServerRequestMethods, + $allowedMethods, ); if (is_null($requestParam)) { - throw OidcServerException::invalidClient($request); + $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) { - throw OidcServerException::invalidRequest( - ParamsEnum::Request->value, - 'Request object error: ' . $exception->getMessage(), - $exception, - ); + $this->loggerService->error('ClientRule: Request object error: ' . $exception->getMessage()); + return null; } - // We have a Federation compatible Request Object. + $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. - (in_array($this->moduleConfig->getIssuer(), $requestObject->getAudience(), true)) || - throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Invalid audience.'); + 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()) === false) - || throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Request Object reused.'); + 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 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.'); + // 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) { - throw OidcServerException::serverError( - 'invalid OIDC configuration: ' . $exception->getMessage(), - $exception, - ); + $this->loggerService->error('ClientRule: Invalid OIDC configuration: ' . $exception->getMessage()); + return null; } catch (Throwable $exception) { - throw OidcServerException::invalidTrustChain( - 'error while trying to resolve trust chain: ' . $exception->getMessage(), - null, - $exception, + $this->loggerService->error( + 'ClientRule: Error while trying to resolve trust chain: ' . $exception->getMessage(), ); + return null; } // Validate TA with locally saved JWKS, if available. @@ -161,33 +264,41 @@ public function checkRule( $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)) { - throw OidcServerException::serverError('Unexpected JWKS format.'); + $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) { - throw OidcServerException::invalidTrustChain( + $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) { - throw OidcServerException::invalidTrustChain( - 'Error while trying to resolve relying party metadata: ' . $exception->getMessage(), - null, - $exception, + $this->loggerService->error( + 'ClientRule: Error while trying to resolve relying party metadata: ' . $exception->getMessage(), ); + return null; } if (is_null($clientMetadata)) { - throw OidcServerException::invalidTrustChain('No relying party metadata available.'); + $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 @@ -195,13 +306,15 @@ public function checkRule( $existingClient = $this->clientRepository->findById($clientEntityId); if ($existingClient && ($existingClient->isEnabled() === false)) { - throw OidcServerException::accessDenied('Client is disabled.'); + $this->loggerService->error('ClientRule: Client is disabled:'); + return null; } if ($existingClient && ($existingClient->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic)) { - throw OidcServerException::accessDenied( + $this->loggerService->error( 'Unexpected existing client registration type: ' . $existingClient->getRegistrationType()->value, ); + return null; } // Resolve client registration metadata @@ -215,11 +328,21 @@ public function checkRule( $request, ); - ($clientJwks = $this->jwksResolver->forClient($registrationClient)) || - throw OidcServerException::accessDenied('Client JWKS not available.'); + $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. - $requestObject->verifyWithKeySet($clientJwks); + 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 ( @@ -227,9 +350,19 @@ public function checkRule( $trustAnchorEntityConfiguration->getIssuer(), ) ) { - $this->federationParticipationValidator->byTrustMarksFor($trustChain); + $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); @@ -238,17 +371,35 @@ public function checkRule( } // Mark Request Object as used. - $this->federationCache?->set( - $requestObject->getJwtId(), - $this->helpers->dateTime()->getSecondsToExpirationTime($requestObject->getExpirationTime()), - self::KEY_REQUEST_OBJECT_JTI, - $requestObject->getJwtId(), - ); + 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 result for RequestParameterRule (inject value from here), since the request object + // 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 new Result($this->getKey(), $registrationClient); + return $registrationClient; + } + + protected function getGenericVciClient(): ClientEntityInterface + { + $client = $this->clientEntityFactory->getGenericForVci(); + if ($this->clientRepository->findById($client->getIdentifier()) === null) { + $this->clientRepository->add($client); + } else { + $this->clientRepository->update($client); + } + + return $client; } } diff --git a/src/Server/RequestRules/Rules/IssuerStateRule.php b/src/Server/RequestRules/Rules/IssuerStateRule.php new file mode 100644 index 00000000..5e47dcc5 --- /dev/null +++ b/src/Server/RequestRules/Rules/IssuerStateRule.php @@ -0,0 +1,61 @@ +debug('IssuerStateRule: Running issuer state rule.'); + + $issuerState = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::IssuerState->value, + $request, + $allowedServerRequestMethods, + ); + + if ($issuerState === null) { + return null; + } + + if ($this->issuerStateRepository->findValid($issuerState) === null) { + $loggerService->error('IssuerStateRule: Invalid issuer state: ' . $issuerState); + throw OidcServerException::invalidRequest(ParamsEnum::IssuerState->value); + } + + $loggerService->debug('IssuerStateRule: Valid issuer state: ' . $issuerState); + + return new Result($this->getKey(), $issuerState); + } +} diff --git a/src/Server/RequestRules/Rules/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index 0d64ea7e..aa3d9481 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -94,7 +94,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 46a70978..25cc18a8 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -88,7 +88,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 index 07409ed5..9a9bd792 100644 --- a/src/Server/RequestRules/Rules/RedirectUriRule.php +++ b/src/Server/RequestRules/Rules/RedirectUriRule.php @@ -7,16 +7,27 @@ use LogicException; 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\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\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; class RedirectUriRule extends AbstractRule { + public function __construct( + RequestParamsResolver $requestParamsResolver, + Helpers $helpers, + protected ModuleConfig $moduleConfig, + ) { + parent::__construct($requestParamsResolver, $helpers); + } + /** * @inheritDoc * @throws \Throwable @@ -47,15 +58,65 @@ public function checkRule( } $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); + + 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/RequiredOpenIdScopeRule.php b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php index b6695c02..84f48cb4 100644 --- a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php +++ b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php @@ -26,6 +26,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequiredOpenIdScopeRule: Checking if required openid scope is present.'); /** @var string $redirectUri */ $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); /** @var string|null $state */ @@ -38,15 +39,23 @@ public function checkRule( fn($scopeEntity) => $scopeEntity->getIdentifier() === 'openid', ); - if (! $isOpenIdScopePresent) { - throw OidcServerException::invalidRequest( - 'scope', - 'Scope openid is required', - null, - $redirectUri, - $state, - $useFragmentInHttpErrorResponses, - ); + 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->error('RequiredOpenIdScopeRule: Skippping openid scope check for VCI request.'); + } else { + throw $e; + } } return new Result($this->getKey(), true); diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index c4c664e7..b094b766 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -44,6 +44,20 @@ 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; + + /** + * Verifiable Credential Issuer state. + * + * @var string|null + */ + protected ?string $issuerState = null; + public static function fromOAuth2AuthorizationRequest( OAuth2AuthorizationRequest $oAuth2authorizationRequest, ): AuthorizationRequest { @@ -204,4 +218,24 @@ 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; + } } 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 1a316290..cb6a6fe0 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -360,7 +360,6 @@ public function __construct() $moduleConfig, $processingChainFactory, $stateService, - $helpers, $requestParamsResolver, $userEntityFactory, ); @@ -395,9 +394,10 @@ public function __construct() $federation, $jwksResolver, $federationParticipationValidator, + $loggerService, $federationCache, ), - new RedirectUriRule($requestParamsResolver, $helpers), + new RedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver), new PromptRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), new MaxAgeRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), 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/templates/tests/verifiable-credential-issuance.twig b/templates/tests/verifiable-credential-issuance.twig index e792eb0e..4679cb2d 100644 --- a/templates/tests/verifiable-credential-issuance.twig +++ b/templates/tests/verifiable-credential-issuance.twig @@ -12,12 +12,10 @@

{% else %} -

Pre-Authorized Code

- {% if not authSource or not authSource.isAuthenticated %}

- {{ 'To test Verifiable Credential issuance using pre-authorized code, choose authentication source, desired Credential Configuration ID, and click Proceed.'|trans }} + {{ '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 }}

diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php similarity index 96% rename from tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php rename to tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php index 4e58fd24..abf0eb9e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php @@ -26,7 +26,7 @@ /** * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule */ -class ClientIdRuleTest extends TestCase +class ClientRuleTest extends TestCase { protected Stub $clientEntityStub; protected Stub $clientRepositoryStub; @@ -73,6 +73,7 @@ protected function sut(): ClientRule $this->federationStub, $this->jwksResolverStub, $this->federationParticipationValidatorStub, + $this->loggerServiceStub, $this->federationCacheStub, ); } @@ -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/RedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php index cfdb5fc9..637464e8 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php @@ -10,6 +10,7 @@ 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; @@ -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, + ?ModuleConfig $moduleConfig = null, ): RedirectUriRule { $requestParamsResolver ??= $this->requestParamsResolverStub; $helpers ??= $this->helpers; + $moduleConfig ??= $this->moduleConfigStub; return new RedirectUriRule( $requestParamsResolver, $helpers, + $moduleConfig, ); } 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 { From 14317ddd4dc2d5abcff2a2c160d298aaf2ae1e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Sep 2025 10:28:02 +0200 Subject: [PATCH 52/70] Introduce auth code flow type attribute --- docker/conformance.sql | 2 +- src/Codebooks/FlowTypeEnum.php | 16 +++++++++++++++ src/Entities/AuthCodeEntity.php | 14 +++++++++---- src/Factories/CredentialOfferUriFactory.php | 3 ++- .../Entities/AuthCodeEntityFactory.php | 9 +++++---- src/Repositories/AuthCodeRepository.php | 8 +++----- src/Server/Grants/AuthCodeGrant.php | 17 +++++++++------- src/Server/Grants/PreAuthCodeGrant.php | 2 +- src/Services/DatabaseMigration.php | 20 +++++++++++++++++++ .../unit/src/Entities/AuthCodeEntityTest.php | 2 +- 10 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 src/Codebooks/FlowTypeEnum.php diff --git a/docker/conformance.sql b/docker/conformance.sql index 26f5546d..91846fad 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -82,7 +82,7 @@ CREATE TABLE oidc_auth_code ( client_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, redirect_uri TEXT NOT NULL, nonce TEXT NULL, - is_pre_authorized BOOLEAN NOT NULL DEFAULT false, + flow_type CHAR(64) DEFAULT NULL, tx_code varchar(191) DEFAULT NULL, CONSTRAINT FK_97D32CA7A76ED395 FOREIGN KEY (user_id) REFERENCES oidc_user (id) ON DELETE CASCADE, diff --git a/src/Codebooks/FlowTypeEnum.php b/src/Codebooks/FlowTypeEnum.php new file mode 100644 index 00000000..9214059d --- /dev/null +++ b/src/Codebooks/FlowTypeEnum.php @@ -0,0 +1,16 @@ +identifier = $id; @@ -70,18 +71,23 @@ public function getState(): array 'is_revoked' => $this->isRevoked(), 'redirect_uri' => $this->getRedirectUri(), 'nonce' => $this->getNonce(), - 'is_pre_authorized' => $this->isPreAuthorized, + 'flow_type' => $this->flowTypeEnum?->value, 'tx_code' => $this->txCode, ]; } - public function isPreAuthorized(): bool + public function isVciPreAuthorized(): bool { - return $this->isPreAuthorized; + return $this->flowTypeEnum === FlowTypeEnum::VciPreAuthorizedCode; } public function getTxCode(): ?string { return $this->txCode; } + + public function getFlowTypeEnum(): ?FlowTypeEnum + { + return $this->flowTypeEnum; + } } diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index 5713870d..b40e9695 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -8,6 +8,7 @@ use RuntimeException; use SimpleSAML\Error\Exception; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; @@ -202,7 +203,7 @@ public function buildPreAuthorized( expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), userIdentifier: $userId, redirectUri: 'openid-credential-offer://', - isPreAuthorized: true, + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, txCode: $txCode instanceof VerifiableCredentials\TxCode ? $txCode->getCodeAsString() : null, ); $this->authCodeRepository->persistNewAuthCode($authCode); diff --git a/src/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index 435ae179..abe74c01 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; @@ -31,7 +32,7 @@ public function fromData( ?string $redirectUri = null, ?string $nonce = null, bool $isRevoked = false, - bool $isPreAuthorized = false, + ?FlowTypeEnum $flowTypeEnum = null, ?string $txCode = null, ): AuthCodeEntity { return new AuthCodeEntity( @@ -43,7 +44,7 @@ public function fromData( $redirectUri, $nonce, $isRevoked, - $isPreAuthorized, + $flowTypeEnum, $txCode, ); } @@ -85,7 +86,7 @@ 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']; - $isPreAuthorized = (bool) $state['is_pre_authorized']; + $flowType = empty($state['flow_type']) ? null : FlowTypeEnum::tryFrom((string)$state['flow_type']); $txCode = empty($state['tx_code']) ? null : (string)$state['tx_code']; return $this->fromData( @@ -97,7 +98,7 @@ public function fromState(array $state): AuthCodeEntity $redirectUri, $nonce, $isRevoked, - $isPreAuthorized, + $flowType, $txCode, ); } diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index 8c08425c..86d9718e 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -80,7 +80,7 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity is_revoked, redirect_uri, nonce, - is_pre_authorized, + flow_type, tx_code ) VALUES ( :id, @@ -91,7 +91,7 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity :is_revoked, :redirect_uri, :nonce, - :is_pre_authorized, + :flow_type, :tx_code ) EOS, @@ -214,7 +214,7 @@ private function update(AuthCodeEntity $authCodeEntity): void is_revoked = :is_revoked, redirect_uri = :redirect_uri, nonce = :nonce, - is_pre_authorized = :is_pre_authorized, + flow_type = :flow_type, tx_code = :tx_code WHERE id = :id EOS @@ -239,10 +239,8 @@ private function update(AuthCodeEntity $authCodeEntity): void protected function preparePdoState(array $state): array { $isRevoked = (bool)($state['is_revoked'] ?? true); - $isPreAuthorized = (bool)($state['is_pre_authorized'] ?? false); $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; - $state['is_pre_authorized'] = [$isPreAuthorized, PDO::PARAM_BOOL]; return $state; } diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index df725cbe..6635c346 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -21,6 +21,7 @@ use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use LogicException; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface; @@ -270,8 +271,7 @@ public function completeOidcAuthorizationRequest( $authorizationRequest->getClient(), $user->getIdentifier(), $finalRedirectUri, - $authorizationRequest->getScopes(), - $authorizationRequest->getNonce(), + $authorizationRequest, ); $payload = [ @@ -307,7 +307,6 @@ public function completeOidcAuthorizationRequest( } /** - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException */ @@ -316,8 +315,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; @@ -325,16 +323,21 @@ 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(), + flowTypeEnum: $flowType, ); $this->authCodeRepository->persistNewAuthCode($authCode); diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index 59636c65..dbbb3c75 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -142,7 +142,7 @@ public function respondToAccessTokenRequest( throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); } - if (!$preAuthorizedCode->isPreAuthorized()) { + if (!$preAuthorizedCode->isVciPreAuthorized()) { $this->loggerService->error( 'Pre-authorized code is not pre-authorized. Value was: ' . $preAuthorizedCodeId, ); diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 60943297..21d4a696 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -174,6 +174,11 @@ public function migrate(): void $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')"); + } } private function versionsTableName(): string @@ -571,6 +576,21 @@ private function version20250908163000(): void ,); } + 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 + ,); + } + /** * @param string[] $columnNames */ diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index ac7ddbbc..28c4b8cd 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -98,7 +98,7 @@ public function testCanGetState(): void 'is_revoked' => false, 'redirect_uri' => 'https://localhost/redirect', 'nonce' => 'nonce', - 'is_pre_authorized' => false, + 'flow_type' => null, 'tx_code' => null, ], ); From 2fc22b0922098af88671f1caa26442c7c1aa935d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Sep 2025 10:48:03 +0200 Subject: [PATCH 53/70] Lint --- src/Server/Grants/PreAuthCodeGrant.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index dbbb3c75..c6768b23 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -75,7 +75,6 @@ public function completeOidcAuthorizationRequest( } /** - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException */ @@ -84,8 +83,7 @@ protected function issueOidcAuthCode( OAuth2ClientEntityInterface $client, string $userIdentifier, string $redirectUri, - array $scopes = [], - ?string $nonce = null, + AuthorizationRequest $authorizationRequest, ): AuthCodeEntityInterface { throw OidcServerException::serverError('Not implemented'); } From cc1256d64f5024aa571c4f09bbfff1b3d4a42437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Sep 2025 15:02:53 +0200 Subject: [PATCH 54/70] WIP Authorization Details in AuthCode --- docker/conformance.sql | 1 + src/Entities/AuthCodeEntity.php | 7 + .../Interfaces/AuthCodeEntityInterface.php | 2 +- .../Entities/AuthCodeEntityFactory.php | 9 ++ src/Factories/RequestRulesManagerFactory.php | 2 + src/Repositories/AuthCodeRepository.php | 11 +- src/Server/Grants/AuthCodeGrant.php | 62 +++++--- src/Server/Grants/PreAuthCodeGrant.php | 1 + .../Rules/AuthorizationDetailsRule.php | 139 ++++++++++++++++++ .../RequestTypes/AuthorizationRequest.php | 28 ++++ src/Services/DatabaseMigration.php | 16 ++ .../unit/src/Entities/AuthCodeEntityTest.php | 3 + 12 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 src/Server/RequestRules/Rules/AuthorizationDetailsRule.php diff --git a/docker/conformance.sql b/docker/conformance.sql index 91846fad..70a5deef 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -84,6 +84,7 @@ CREATE TABLE oidc_auth_code ( redirect_uri TEXT NOT NULL, nonce TEXT NULL, flow_type CHAR(64) DEFAULT NULL, tx_code varchar(191) DEFAULT NULL, + authorization_details TEXT NULL, CONSTRAINT FK_97D32CA7A76ED395 FOREIGN KEY (user_id) REFERENCES oidc_user (id) ON DELETE CASCADE, CONSTRAINT FK_97D32CA719EB6921 FOREIGN KEY (client_id) diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index cce08a32..4f4605a1 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -46,6 +46,7 @@ public function __construct( bool $isRevoked = false, protected readonly ?FlowTypeEnum $flowTypeEnum = null, protected readonly ?string $txCode = null, + protected readonly ?array $authorizationDetails = null, ) { $this->identifier = $id; $this->client = $client; @@ -73,6 +74,7 @@ public function getState(): array 'nonce' => $this->getNonce(), 'flow_type' => $this->flowTypeEnum?->value, 'tx_code' => $this->txCode, + 'authorization_details' => json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR), ]; } @@ -90,4 +92,9 @@ public function getFlowTypeEnum(): ?FlowTypeEnum { return $this->flowTypeEnum; } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } } 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/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index abe74c01..f298c488 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -34,6 +34,7 @@ public function fromData( bool $isRevoked = false, ?FlowTypeEnum $flowTypeEnum = null, ?string $txCode = null, + ?array $authorizationDetails = null, ): AuthCodeEntity { return new AuthCodeEntity( $id, @@ -46,6 +47,7 @@ public function fromData( $isRevoked, $flowTypeEnum, $txCode, + $authorizationDetails, ); } @@ -89,6 +91,12 @@ public function fromState(array $state): AuthCodeEntity $flowType = empty($state['flow_type']) ? null : FlowTypeEnum::tryFrom((string)$state['flow_type']); $txCode = empty($state['tx_code']) ? null : (string)$state['tx_code']; + /** @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; + return $this->fromData( $id, $client, @@ -100,6 +108,7 @@ public function fromState(array $state): AuthCodeEntity $isRevoked, $flowType, $txCode, + $authorizationDetails, ); } } diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index b108b2fc..8397ac2f 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -15,6 +15,7 @@ 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\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; @@ -146,6 +147,7 @@ private function getDefaultRules(): array ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), new IssuerStateRule($this->requestParamsResolver, $this->helpers, $this->issuerStateRepository), + new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), ]; } } diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index 86d9718e..7dfd9b67 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -81,7 +81,8 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity redirect_uri, nonce, flow_type, - tx_code + tx_code, + authorization_details ) VALUES ( :id, :scopes, @@ -92,7 +93,8 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity :redirect_uri, :nonce, :flow_type, - :tx_code + :tx_code, + :authorization_details ) EOS, $this->getTableName(), @@ -116,7 +118,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)); @@ -215,7 +217,8 @@ private function update(AuthCodeEntity $authCodeEntity): void redirect_uri = :redirect_uri, nonce = :nonce, flow_type = :flow_type, - tx_code = :tx_code + tx_code = :tx_code, + authorization_details = :authorization_details WHERE id = :id EOS , diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 6635c346..6903280f 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -22,6 +22,7 @@ use LogicException; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; +use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface; @@ -29,6 +30,7 @@ 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; @@ -40,6 +42,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; 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\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; @@ -288,6 +291,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); @@ -338,6 +343,7 @@ protected function issueOidcAuthCode( $redirectUri, $authorizationRequest->getNonce(), flowTypeEnum: $flowType, + authorizationDetails: $authorizationRequest->getAuthorizationDetails(), ); $this->authCodeRepository->persistNewAuthCode($authCode); @@ -448,7 +454,21 @@ public function respondToAccessTokenRequest( */ $authCodePayload = json_decode($this->decrypt($encryptedAuthCode), null, 512, JSON_THROW_ON_ERROR); - $this->validateAuthorizationCode($authCodePayload, $client, $request); + 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'); + } + + $this->validateAuthorizationCode($authCodePayload, $client, $request, $storedAuthCodeEntity); $scopes = $this->scopeRepository->finalizeScopes( $this->validateScopes($authCodePayload->scopes), @@ -569,9 +589,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); @@ -592,20 +609,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.'); } @@ -618,7 +628,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); @@ -663,6 +673,7 @@ public function validateAuthorizationRequestWithRequestRules( 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. @@ -710,7 +721,15 @@ public function validateAuthorizationRequestWithRequestRules( $oAuth2AuthorizationRequest->setCodeChallengeMethod($codeChallengeMethod); } - if (! $this->isOidcCandidate($oAuth2AuthorizationRequest)) { + $isVciAuthorizationCodeRequest = $this->requestParamsResolver->isVciAuthorizationCodeRequest( + $request, + $this->allowedAuthorizationHttpMethods, + ); + + if ( + (! $this->isOidcCandidate($oAuth2AuthorizationRequest)) && + (! $isVciAuthorizationCodeRequest) + ) { return $oAuth2AuthorizationRequest; } @@ -743,17 +762,22 @@ public function validateAuthorizationRequestWithRequestRules( $acrValues = $resultBag->getOrFail(AcrValuesRule::class)->getValue(); $authorizationRequest->setRequestedAcrValues($acrValues); - $authorizationRequest->setIsVciRequest( - $this->requestParamsResolver->isVciAuthorizationCodeRequest( - $request, - $this->allowedAuthorizationHttpMethods, - ), + + $authorizationRequest->setIsVciRequest($isVciAuthorizationCodeRequest); + $authorizationRequest->setFlowType( + $isVciAuthorizationCodeRequest ? + FlowTypeEnum::VciAuthorizationCode : + FlowTypeEnum::OidcAuthorizationCode, ); /** @var ?string $issuerState */ $issuerState = $resultBag->get(IssuerStateRule::class)?->getValue(); $authorizationRequest->setIssuerState($issuerState); + /** @var ?array $authorizationDetails */ + $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); + $authorizationRequest->setAuthorizationDetails($authorizationDetails); + return $authorizationRequest; } diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index c6768b23..64329e65 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -236,6 +236,7 @@ protected function validateAuthorizationCode( object $authCodePayload, OAuth2ClientEntityInterface $client, ServerRequestInterface $request, + AuthCodeEntity $storedAuthCodeEntity, ): void { } diff --git a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php new file mode 100644 index 00000000..18c3a72b --- /dev/null +++ b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php @@ -0,0 +1,139 @@ +debug('AuthorizationDetailsRule: Running.'); + + $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/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index b094b766..aac07abb 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 { @@ -51,6 +52,13 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest */ protected bool $isVciRequest = false; + protected ?FlowTypeEnum $flowType = null; + + /** + * @var mixed[]|null + */ + protected ?array $authorizationDetails = null; + /** * Verifiable Credential Issuer state. * @@ -238,4 +246,24 @@ 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; + } } diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 21d4a696..c23f6b24 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -179,6 +179,11 @@ public function migrate(): void $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')"); + } } private function versionsTableName(): string @@ -591,6 +596,17 @@ private function version20250912163000(): void ,); } + private function version20250913163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD authorization_details TEXT NULL; +EOT + ,); + } + /** * @param string[] $columnNames */ diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index 28c4b8cd..0f489171 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, ); } From 1210f98754b4bd707464c55518e4a9a0beee29fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Sep 2025 15:19:34 +0200 Subject: [PATCH 55/70] Rename IdTokenResponse to TokenResponse --- routing/services/services.yml | 6 ++--- src/Entities/AuthCodeEntity.php | 4 +++- src/Factories/AuthorizationServerFactory.php | 6 ++--- ...seFactory.php => TokenResponseFactory.php} | 12 +++++----- ...{IdTokenResponse.php => TokenResponse.php} | 2 +- src/Services/Container.php | 10 ++++----- .../unit/src/Entities/AuthCodeEntityTest.php | 1 + ...ResponseTest.php => TokenResponseTest.php} | 22 +++++++++---------- 8 files changed, 33 insertions(+), 30 deletions(-) rename src/Factories/{IdTokenResponseFactory.php => TokenResponseFactory.php} (77%) rename src/Server/ResponseTypes/{IdTokenResponse.php => TokenResponse.php} (98%) rename tests/unit/src/Server/ResponseTypes/{IdTokenResponseTest.php => TokenResponseTest.php} (95%) diff --git a/routing/services/services.yml b/routing/services/services.yml index c78cd35c..b3f284b6 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -64,8 +64,8 @@ services: 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 @@ -81,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: diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index 4f4605a1..a3f71d61 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -74,7 +74,9 @@ public function getState(): array 'nonce' => $this->getNonce(), 'flow_type' => $this->flowTypeEnum?->value, 'tx_code' => $this->txCode, - 'authorization_details' => json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR), + 'authorization_details' => is_array($this->authorizationDetails) ? + json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR) : + null, ]; } diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 54e65388..9efafe0e 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -27,7 +27,7 @@ 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; class AuthorizationServerFactory { @@ -39,7 +39,7 @@ 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, @@ -54,7 +54,7 @@ public function build(): AuthorizationServer $this->scopeRepository, $this->privateKey, $this->moduleConfig->getEncryptionKey(), - $this->idTokenResponse, + $this->tokenResponse, $this->requestRulesManager, ); diff --git a/src/Factories/IdTokenResponseFactory.php b/src/Factories/TokenResponseFactory.php similarity index 77% rename from src/Factories/IdTokenResponseFactory.php rename to src/Factories/TokenResponseFactory.php index acc031bd..fcb8bd2b 100644 --- a/src/Factories/IdTokenResponseFactory.php +++ b/src/Factories/TokenResponseFactory.php @@ -19,10 +19,10 @@ 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; -class IdTokenResponseFactory +class TokenResponseFactory { public function __construct( private readonly ModuleConfig $moduleConfig, @@ -32,15 +32,15 @@ public function __construct( ) { } - public function build(): IdTokenResponse + public function build(): TokenResponse { - $idTokenResponse = new IdTokenResponse( + $tokenResponse = new TokenResponse( $this->userRepository, $this->idTokenBuilder, $this->privateKey, ); - $idTokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); + $tokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); - return $idTokenResponse; + return $tokenResponse; } } diff --git a/src/Server/ResponseTypes/IdTokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php similarity index 98% rename from src/Server/ResponseTypes/IdTokenResponse.php rename to src/Server/ResponseTypes/TokenResponse.php index 7c212e92..fde70be9 100644 --- a/src/Server/ResponseTypes/IdTokenResponse.php +++ b/src/Server/ResponseTypes/TokenResponse.php @@ -37,7 +37,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 diff --git a/src/Services/Container.php b/src/Services/Container.php index cb6a6fe0..dd91c127 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -54,11 +54,11 @@ 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; @@ -98,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; @@ -438,13 +438,13 @@ 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, ); - $this->services[IdTokenResponse::class] = $idTokenResponseFactory->build(); + $this->services[TokenResponse::class] = $tokenResponseFactory->build(); $this->services[Helpers::class] = $helpers; @@ -512,7 +512,7 @@ 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], diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index 0f489171..79d44265 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -103,6 +103,7 @@ public function testCanGetState(): void 'nonce' => 'nonce', 'flow_type' => null, 'tx_code' => null, + 'authorization_details' => null, ], ); } diff --git a/tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php similarity index 95% rename from tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php rename to tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index e57d2891..6eee2c0d 100644 --- a/tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -31,15 +31,15 @@ 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\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'; @@ -121,26 +121,26 @@ protected function setUp(): void ); } - protected function prepareMockedInstance(): IdTokenResponse + protected function prepareMockedInstance(): TokenResponse { - $idTokenResponse = new IdTokenResponse( + $tokenResponse = new TokenResponse( $this->identityProviderMock, $this->idTokenBuilder, $this->privateKey, ); - $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(), ); } From cd7a71ec668b8ea09b9254cea095a1c4ff604ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 15 Sep 2025 16:21:42 +0200 Subject: [PATCH 56/70] Initial auth-code implementation --- docker/conformance.sql | 19 +- src/Codebooks/FlowTypeEnum.php | 16 ++ src/Entities/AccessTokenEntity.php | 31 ++++ src/Entities/AuthCodeEntity.php | 14 ++ src/Entities/ClientEntity.php | 11 ++ .../Interfaces/ClientEntityInterface.php | 1 + .../Entities/AccessTokenEntityFactory.php | 23 +++ .../Entities/AuthCodeEntityFactory.php | 9 + .../Entities/ClientEntityFactory.php | 7 + src/Factories/RequestRulesManagerFactory.php | 6 +- src/Factories/TokenResponseFactory.php | 3 + src/ModuleConfig.php | 105 ++++++----- src/Repositories/AccessTokenRepository.php | 34 +++- src/Repositories/AuthCodeRepository.php | 12 +- src/Repositories/ClientRepository.php | 11 +- src/Server/AuthorizationServer.php | 6 +- src/Server/Grants/AuthCodeGrant.php | 169 +++++++++++++----- src/Server/Grants/ImplicitGrant.php | 4 +- src/Server/Grants/PreAuthCodeGrant.php | 8 +- .../Grants/Traits/IssueAccessTokenTrait.php | 9 + .../RequestRules/Rules/ClientIdRule.php | 59 ++++++ ...tUriRule.php => ClientRedirectUriRule.php} | 2 +- src/Server/RequestRules/Rules/ClientRule.php | 3 +- .../Rules/CodeChallengeMethodRule.php | 2 +- .../RequestRules/Rules/CodeChallengeRule.php | 2 +- src/Server/RequestRules/Rules/MaxAgeRule.php | 2 +- src/Server/RequestRules/Rules/PromptRule.php | 2 +- .../RequestRules/Rules/RequestObjectRule.php | 2 +- .../RequestRules/Rules/RequiredNonceRule.php | 2 +- .../Rules/RequiredOpenIdScopeRule.php | 2 +- .../Rules/ScopeOfflineAccessRule.php | 2 +- src/Server/RequestRules/Rules/ScopeRule.php | 11 +- .../RequestTypes/AuthorizationRequest.php | 24 +++ src/Server/ResponseTypes/TokenResponse.php | 68 ++++++- src/Services/Container.php | 5 +- src/Services/DatabaseMigration.php | 71 ++++++++ src/Services/OpMetadataService.php | 1 + .../unit/src/Entities/AuthCodeEntityTest.php | 2 + tests/unit/src/Entities/ClientEntityTest.php | 5 + .../Rules/CodeChallengeMethodRuleTest.php | 4 +- .../Rules/CodeChallengeRuleTest.php | 4 +- .../Rules/RedirectUriRuleTest.php | 10 +- .../Rules/RequestObjectRuleTest.php | 4 +- .../Rules/RequiredNonceRuleTest.php | 4 +- .../Rules/RequiredOpenIdScopeRuleTest.php | 4 +- .../RequestRules/Rules/ScopeRuleTest.php | 4 +- .../ResponseTypes/TokenResponseTest.php | 5 + 47 files changed, 660 insertions(+), 144 deletions(-) create mode 100644 src/Server/RequestRules/Rules/ClientIdRule.php rename src/Server/RequestRules/Rules/{RedirectUriRule.php => ClientRedirectUriRule.php} (99%) diff --git a/docker/conformance.sql b/docker/conformance.sql index 70a5deef..42aa9078 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -43,15 +43,16 @@ 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, @@ -60,6 +61,10 @@ CREATE TABLE oidc_access_token ( client_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, auth_code_id varchar(191) DEFAULT NULL, requested_claims TEXT NULL, + flow_type CHAR(64) NULL, + authorization_details TEXT NULL, + bound_client_id TEXT NULL, + bound_redirect_uri TEXT NULL, CONSTRAINT FK_43C1650EA76ED395 FOREIGN KEY (user_id) REFERENCES oidc_user (id) ON DELETE CASCADE, CONSTRAINT FK_43C1650E19EB6921 FOREIGN KEY (client_id) @@ -85,6 +90,8 @@ CREATE TABLE oidc_auth_code ( 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, CONSTRAINT FK_97D32CA7A76ED395 FOREIGN KEY (user_id) REFERENCES oidc_user (id) ON DELETE CASCADE, CONSTRAINT FK_97D32CA719EB6921 FOREIGN KEY (client_id) diff --git a/src/Codebooks/FlowTypeEnum.php b/src/Codebooks/FlowTypeEnum.php index 9214059d..9362a15d 100644 --- a/src/Codebooks/FlowTypeEnum.php +++ b/src/Codebooks/FlowTypeEnum.php @@ -13,4 +13,20 @@ enum FlowTypeEnum: string case VciAuthorizationCode = 'vci_authorization_code'; case VciPreAuthorizedCode = 'vci_pre_authorized_code'; + + public function isOidcFlow(): bool + { + return match ($this) { + self::OidcAuthorizationCode, self::OidcImplicit, self::OidcHybrid => true, + default => false, + }; + } + + public function isVciFlow(): bool + { + return match ($this) { + self::VciAuthorizationCode, self::VciPreAuthorizedCode => true, + default => false, + }; + } } diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index b98fe7cf..834902a1 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,10 @@ 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, ) { $this->setIdentifier($id); $this->setClient($clientEntity); @@ -114,6 +119,12 @@ 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, ]; } @@ -158,4 +169,24 @@ protected function convertToJWT(): Token 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; + } } diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index a3f71d61..e8e10f30 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -47,6 +47,8 @@ public function __construct( 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, ) { $this->identifier = $id; $this->client = $client; @@ -77,6 +79,8 @@ public function getState(): array '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, ]; } @@ -99,4 +103,14 @@ public function getAuthorizationDetails(): ?array { return $this->authorizationDetails; } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } } 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/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/Factories/Entities/AccessTokenEntityFactory.php b/src/Factories/Entities/AccessTokenEntityFactory.php index f4672e98..b3ed8f3a 100644 --- a/src/Factories/Entities/AccessTokenEntityFactory.php +++ b/src/Factories/Entities/AccessTokenEntityFactory.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; @@ -35,6 +36,10 @@ public function fromData( ?string $authCodeId = null, ?array $requestedClaims = null, ?bool $isRevoked = false, + ?FlowTypeEnum $flowTypeEnum = null, + ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, ): AccessTokenEntity { return new AccessTokenEntity( $id, @@ -47,6 +52,10 @@ public function fromData( $authCodeId, $requestedClaims, $isRevoked, + flowTypeEnum: $flowTypeEnum, + authorizationDetails: $authorizationDetails, + boundClientId: $boundClientId, + boundRedirectUri: $boundRedirectUri, ); } @@ -90,6 +99,16 @@ public function fromState(array $state): AccessTokenEntity throw OidcServerException::serverError('Invalid Access Token Entity state: requested claims'); } + $flowType = empty($state['flow_type']) ? null : FlowTypeEnum::tryFrom((string)$state['flow_type']); + /** @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, $client, @@ -99,6 +118,10 @@ public function fromState(array $state): AccessTokenEntity $authCodeId, $stateRequestedClaims, $isRevoked, + $flowType, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, ); } } diff --git a/src/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index f298c488..4449e74b 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -35,6 +35,8 @@ public function fromData( ?FlowTypeEnum $flowTypeEnum = null, ?string $txCode = null, ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, ): AuthCodeEntity { return new AuthCodeEntity( $id, @@ -48,6 +50,8 @@ public function fromData( $flowTypeEnum, $txCode, $authorizationDetails, + $boundClientId, + $boundRedirectUri, ); } @@ -97,6 +101,9 @@ public function fromState(array $state): AuthCodeEntity 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, $client, @@ -109,6 +116,8 @@ public function fromState(array $state): AuthCodeEntity $flowType, $txCode, $authorizationDetails, + $boundClientId, + $boundRedirectUri, ); } } diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index f6d759e5..549ff663 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -66,6 +66,7 @@ public function fromData( ?DateTimeImmutable $createdAt = null, ?DateTimeImmutable $expiresAt = null, bool $isFederated = false, + bool $isGeneric = false, ): ClientEntityInterface { return new ClientEntity( $id, @@ -91,6 +92,7 @@ public function fromData( $createdAt, $expiresAt, $isFederated, + $isGeneric, ); } @@ -192,6 +194,7 @@ public function fromRegistrationData( // $expiresAt = $expiresAt; $isFederated = $existingClient?->isFederated() ?? false; + $isGeneric = $existingClient?->isGeneric() ?? false; return $this->fromData( $id, @@ -217,6 +220,7 @@ public function fromRegistrationData( $createdAt, $expiresAt, $isFederated, + $isGeneric, ); } @@ -355,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, @@ -380,6 +385,7 @@ public function fromState(array $state): ClientEntityInterface $createdAt, $expiresAt, $isFederated, + $isGeneric, ); } @@ -404,6 +410,7 @@ public function getGenericForVci(): ClientEntityInterface isEnabled: true, updatedAt: $createdAt, createdAt: $createdAt, + isGeneric: true, ); } } diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 8397ac2f..a05bf4f0 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -17,6 +17,8 @@ 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; @@ -26,7 +28,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; @@ -100,7 +101,7 @@ private function getDefaultRules(): array $this->logger, $this->federationCache, ), - new RedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), + new ClientRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), new RequestObjectRule($this->requestParamsResolver, $this->helpers, $this->jwksResolver), new PromptRule( $this->requestParamsResolver, @@ -148,6 +149,7 @@ private function getDefaultRules(): array new CodeVerifierRule($this->requestParamsResolver, $this->helpers), new IssuerStateRule($this->requestParamsResolver, $this->helpers, $this->issuerStateRepository), new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), + new ClientIdRule($this->requestParamsResolver, $this->helpers), ]; } } diff --git a/src/Factories/TokenResponseFactory.php b/src/Factories/TokenResponseFactory.php index fcb8bd2b..93983eee 100644 --- a/src/Factories/TokenResponseFactory.php +++ b/src/Factories/TokenResponseFactory.php @@ -21,6 +21,7 @@ use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Services\LoggerService; class TokenResponseFactory { @@ -29,6 +30,7 @@ public function __construct( private readonly UserRepository $userRepository, private readonly IdTokenBuilder $idTokenBuilder, private readonly CryptKey $privateKey, + private readonly LoggerService $loggerService, ) { } @@ -38,6 +40,7 @@ public function build(): TokenResponse $this->userRepository, $this->idTokenBuilder, $this->privateKey, + $this->loggerService, ); $tokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 73883e38..7f30c10b 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -459,7 +459,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(), + ); } /** @@ -909,6 +914,25 @@ public function getCredentialConfigurationIdsSupported(): array ); } + /** + * 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 ( @@ -978,6 +1002,45 @@ public function getUserAttributeToCredentialClaimPathMapFor(string $credentialCo 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 getAllowVciAuthorizationCodeRequestsWithoutClientId(): bool + { + return $this->config()->getOptionalBoolean( + self::OPTION_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID, + false, + ); + } + + public function getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci(): array + { + return $this->config()->getOptionalArray( + self::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI, + ['openid-credential-offer://',], + ); + } /***************************************************************************************************************** @@ -1034,44 +1097,4 @@ public function getDefaultUsersEmailAttributeName(): string { return $this->config()->getOptionalString(self::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME, 'mail'); } - - /** - * 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 getAllowVciAuthorizationCodeRequestsWithoutClientId(): bool - { - return $this->config()->getOptionalBoolean( - self::OPTION_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID, - false, - ); - } - - public function getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci(): array - { - return $this->config()->getOptionalArray( - self::OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI, - ['openid-credential-offer://',], - ); - } } diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 6c7d16e5..9d55097b 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -101,8 +101,34 @@ 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 + ) " + . "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 + )", $this->getTableName(), ); @@ -239,7 +265,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 WHERE id = :id", $this->getTableName(), ); diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index 7dfd9b67..a24afdf3 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -82,7 +82,9 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity nonce, flow_type, tx_code, - authorization_details + authorization_details, + bound_client_id, + bound_redirect_uri ) VALUES ( :id, :scopes, @@ -94,7 +96,9 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity :nonce, :flow_type, :tx_code, - :authorization_details + :authorization_details, + :bound_client_id, + :bound_redirect_uri ) EOS, $this->getTableName(), @@ -218,7 +222,9 @@ private function update(AuthCodeEntity $authCodeEntity): void nonce = :nonce, flow_type = :flow_type, tx_code = :tx_code, - authorization_details = :authorization_details + authorization_details = :authorization_details, + bound_client_id = :bound_client_id, + bound_redirect_uri = :bound_redirect_uri WHERE id = :id EOS , diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 27bc952a..fa59d855 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,10 +555,12 @@ 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; } diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index da165133..e7607edd 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -18,10 +18,10 @@ 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\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; @@ -80,7 +80,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O $rulesToExecute = [ StateRule::class, ClientRule::class, - RedirectUriRule::class, + ClientRedirectUriRule::class, ]; try { @@ -99,7 +99,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O /** @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) { if ($grantType->canRespondToAuthorizationRequest($request)) { diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 6903280f..a3d59cfc 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -23,6 +23,7 @@ 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; @@ -41,9 +42,12 @@ 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; @@ -51,7 +55,6 @@ 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; @@ -344,6 +347,8 @@ protected function issueOidcAuthCode( $authorizationRequest->getNonce(), flowTypeEnum: $flowType, authorizationDetails: $authorizationRequest->getAuthorizationDetails(), + boundClientId: $authorizationRequest->getBoundClientId(), + boundRedirectUri: $authorizationRequest->getBoundRedirectUri(), ); $this->authCodeRepository->persistNewAuthCode($authCode); @@ -398,13 +403,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 = [ - ClientRule::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, @@ -413,9 +503,15 @@ public function respondToAccessTokenRequest( ); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientRule::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(); @@ -441,44 +537,15 @@ public function respondToAccessTokenRequest( // $this->validateClient($request); // } - $encryptedAuthCode = $this->getRequestParameter('code', $request); + $this->validateAuthorizationCode($authCodePayload, $client, $request, $storedAuthCodeEntity); - 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); - - 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'); - } - - $this->validateAuthorizationCode($authCodePayload, $client, $request, $storedAuthCodeEntity); + $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); @@ -542,6 +609,10 @@ public function respondToAccessTokenRequest( $scopes, $authCodePayload->auth_code_id, $claims, + $storedAuthCodeEntity->getFlowTypeEnum(), + $storedAuthCodeEntity->getAuthorizationDetails(), + $storedAuthCodeEntity->getBoundClientId(), + $storedAuthCodeEntity->getBoundRedirectUri(), ); $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); $responseType->setAccessToken($accessToken); @@ -662,6 +733,7 @@ public function validateAuthorizationRequestWithRequestRules( ResultBagInterface $resultBag, ): OAuth2AuthorizationRequest { $rulesToExecute = [ + ClientIdRule::class, RequestObjectRule::class, PromptRule::class, MaxAgeRule::class, @@ -680,7 +752,7 @@ 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 */ @@ -778,6 +850,17 @@ public function validateAuthorizationRequestWithRequestRules( $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); $authorizationRequest->setAuthorizationDetails($authorizationDetails); + // 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()) { + // The generic client was used. Make sure to store actually used client_id and redirect_uri params. + /** @var string $clientId */ + $clientId = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $authorizationRequest->setBoundClientId($clientId); + + $authorizationRequest->setBoundRedirectUri($redirectUri); + } + return $authorizationRequest; } diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index 4b804621..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\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,7 +138,7 @@ 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 */ diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index 64329e65..dadc6e26 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -108,7 +108,7 @@ public function respondToAccessTokenRequest( DateInterval $accessTokenTTL, ): ResponseTypeInterface { - // TODO mivanci client authentication + // TODO mivanci client authentication? $this->loggerService->debug( 'Pre-authorized code grant respondToAccessTokenRequest', @@ -200,10 +200,12 @@ public function respondToAccessTokenRequest( } } - // TODO handle authorization_details parameter, add to response. + // TODO handle authorization_details parameter, add to + // * access token + // * and response itself. //dd($authorizationDetails); - + // TODO mivanci add flow, authorization details, bound client_id and redirect_uri to access token. // Issue and persist new access token $accessToken = $this->issueAccessToken( $accessTokenTTL, diff --git a/src/Server/Grants/Traits/IssueAccessTokenTrait.php b/src/Server/Grants/Traits/IssueAccessTokenTrait.php index 6660ec92..8ac538e3 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,10 @@ protected function issueAccessToken( array $scopes = [], ?string $authCodeId = null, ?array $requestedClaims = null, + ?FlowTypeEnum $flowTypeEnum = null, + ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, ): AccessTokenEntityInterface { $maxGenerationAttempts = AbstractGrant::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; @@ -70,6 +75,10 @@ protected function issueAccessToken( $userIdentifier, $authCodeId, $requestedClaims, + flowTypeEnum: $flowTypeEnum, + authorizationDetails: $authorizationDetails, + boundClientId: $boundClientId, + boundRedirectUri: $boundRedirectUri, ); $this->accessTokenRepository->persistNewAccessToken($accessToken); return $accessToken; diff --git a/src/Server/RequestRules/Rules/ClientIdRule.php b/src/Server/RequestRules/Rules/ClientIdRule.php new file mode 100644 index 00000000..b329c179 --- /dev/null +++ b/src/Server/RequestRules/Rules/ClientIdRule.php @@ -0,0 +1,59 @@ +debug('ClientIdRule::checkRule'); + + /** @var ?string $clientId */ + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $allowedServerRequestMethods, + ) ?? $request->getServerParams()['PHP_AUTH_USER'] ?? null; + + if ($clientId === null) { + throw OidcServerException::invalidRequest('client_id'); + } + + return new Result($this->getKey(), $clientId); + } +} diff --git a/src/Server/RequestRules/Rules/RedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php similarity index 99% rename from src/Server/RequestRules/Rules/RedirectUriRule.php rename to src/Server/RequestRules/Rules/ClientRedirectUriRule.php index 9a9bd792..ec1de262 100644 --- a/src/Server/RequestRules/Rules/RedirectUriRule.php +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -18,7 +18,7 @@ use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; -class RedirectUriRule extends AbstractRule +class ClientRedirectUriRule extends AbstractRule { public function __construct( RequestParamsResolver $requestParamsResolver, diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 9c962900..71f5b867 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -161,7 +161,8 @@ public function checkRule( return new Result($this->getKey(), $this->getGenericVciClient()); } else { $this->loggerService->debug( - 'ClientRule: Verifiable Credential capabilities with non-registered clients are not enabled.', + 'ClientRule: Not a VCI request, or VCI capabilities not enabled, or VCI with non-registered' . + ' clients not enabled.', ); } diff --git a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php index ed087d3e..1c719cdd 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php @@ -39,7 +39,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/CodeChallengeRule.php b/src/Server/RequestRules/Rules/CodeChallengeRule.php index 40389576..4900c17a 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeRule.php @@ -30,7 +30,7 @@ public function checkRule( /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $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/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index aa3d9481..b8f8a2e1 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -61,7 +61,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(); diff --git a/src/Server/RequestRules/Rules/PromptRule.php b/src/Server/RequestRules/Rules/PromptRule.php index 25cc18a8..69dfafbb 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -66,7 +66,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(); diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index 4e0e2d49..ad972051 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -69,7 +69,7 @@ public function checkRule( /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $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/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 84f48cb4..9d151edc 100644 --- a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php +++ b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php @@ -28,7 +28,7 @@ public function checkRule( ): ?ResultInterface { $loggerService->debug('RequiredOpenIdScopeRule: Checking if required openid scope is present.'); /** @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 */ diff --git a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php index 07f83b0a..d31aa45e 100644 --- a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php +++ b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php @@ -27,7 +27,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(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index e1eb7884..2206bb09 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: Running.'); + /** @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/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index aac07abb..1278e9f9 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -59,6 +59,10 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest */ protected ?array $authorizationDetails = null; + protected ?string $boundClientId = null; + + protected ?string $boundRedirectUri = null; + /** * Verifiable Credential Issuer state. * @@ -266,4 +270,24 @@ 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/TokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php index fde70be9..807cd408 100644 --- a/src/Server/ResponseTypes/TokenResponse.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. @@ -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/Services/Container.php b/src/Services/Container.php index dd91c127..236f29ef 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -80,6 +80,7 @@ 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\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; @@ -88,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; @@ -397,7 +397,7 @@ public function __construct() $loggerService, $federationCache, ), - new RedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), + new ClientRedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver), new PromptRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), new MaxAgeRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), @@ -443,6 +443,7 @@ public function __construct() $userRepository, $this->services[IdTokenBuilder::class], $privateKey, + $loggerService, ); $this->services[TokenResponse::class] = $tokenResponseFactory->build(); diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index c23f6b24..8256ba90 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -184,6 +184,21 @@ public function migrate(): void $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')"); + } } private function versionsTableName(): string @@ -607,6 +622,62 @@ private function version20250913163000(): void ,); } + 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 + ,); + } + /** * @param string[] $columnNames */ diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 5986de2a..d81b996c 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -50,6 +50,7 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::EndSessionEndpoint->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::EndSession->value); $this->metadata[ClaimsEnum::JwksUri->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::Jwks->value); + // TODO mivanci Resolve supported scopes from ScopeRepository (also include those from VCI). $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getScopes()); $this->metadata[ClaimsEnum::ResponseTypesSupported->value] = ['code', 'token', 'id_token', 'id_token token']; $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index 79d44265..877420eb 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -104,6 +104,8 @@ public function testCanGetState(): void 'flow_type' => null, 'tx_code' => null, 'authorization_details' => null, + 'bound_client_id' => null, + 'bound_redirect_uri' => 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/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 65355a6e..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\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,7 +47,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/RedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php index 637464e8..33a31f4e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php @@ -15,17 +15,17 @@ 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\ClientRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; 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; @@ -54,12 +54,12 @@ protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, ?ModuleConfig $moduleConfig = null, - ): RedirectUriRule { + ): ClientRedirectUriRule { $requestParamsResolver ??= $this->requestParamsResolverStub; $helpers ??= $this->helpers; $moduleConfig ??= $this->moduleConfigStub; - return new RedirectUriRule( + return new ClientRedirectUriRule( $requestParamsResolver, $helpers, $moduleConfig, diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 08704abb..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\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\JwksResolver; @@ -40,7 +40,7 @@ protected function setUp(): void $this->resultBagStub = $this->createStub(ResultBag::class); $this->resultBagStub->method('getOrFail')->willReturnMap([ [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], - [RedirectUriRule::class, new Result(RedirectUriRule::class, 'https://example.com/redirect')], + [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/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/TokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index 6eee2c0d..c389b565 100644 --- a/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -34,6 +34,7 @@ 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; /** @@ -59,6 +60,7 @@ class TokenResponseTest extends TestCase protected CryptKey $privateKey; protected IdTokenBuilder $idTokenBuilder; protected Stub $claimSetEntityFactoryStub; + protected MockObject $loggerMock; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -119,6 +121,8 @@ 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(): TokenResponse @@ -127,6 +131,7 @@ protected function prepareMockedInstance(): TokenResponse $this->identityProviderMock, $this->idTokenBuilder, $this->privateKey, + $this->loggerMock, ); $tokenResponse->setNonce(null); From 759e68931ac030962ed372f45019cd74015ef93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 16 Sep 2025 09:25:15 +0200 Subject: [PATCH 57/70] Update AT state --- .../src/Repositories/AccessTokenRepositoryTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 2c096334..1eab1395 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -116,6 +116,10 @@ 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, ]; $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); From 46ae3411a77c5a833dbc4563b2800b5a3cc25f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 17 Sep 2025 09:51:44 +0200 Subject: [PATCH 58/70] Check for flow type when issuing credentials --- .../CredentialIssuerCredentialController.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index c27e3a9e..08c8d59f 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -100,6 +100,17 @@ public function credential(Request $request): Response ); } + if ( + ($flowType = $accessToken->getFlowTypeEnum()) === null || + $flowType->isVciFlow() === false + ) { + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token is not intended for verifiable credential issuance.', + 401, + ); + } + // TODO mivanci Validate credential request $credentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null; From 19377612849b4e3314984ba702c7345b0efd13c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 18 Sep 2025 11:12:40 +0200 Subject: [PATCH 59/70] Remove no clinet_id ability --- config/module_oidc.php.dist | 4 - src/Controllers/AuthorizationController.php | 2 + .../CredentialIssuerCredentialController.php | 269 +++++++++++++++--- src/Factories/AuthorizationServerFactory.php | 3 + src/ModuleConfig.php | 10 - src/Server/AuthorizationServer.php | 47 ++- src/Server/Grants/AuthCodeGrant.php | 75 ++++- src/Server/Grants/PreAuthCodeGrant.php | 34 ++- .../RequestRules/Rules/AcrValuesRule.php | 2 + .../Rules/AuthorizationDetailsRule.php | 2 +- .../Rules/ClientRedirectUriRule.php | 1 + src/Server/RequestRules/Rules/ClientRule.php | 30 +- .../Rules/CodeChallengeMethodRule.php | 2 + .../RequestRules/Rules/CodeChallengeRule.php | 2 + .../RequestRules/Rules/IssuerStateRule.php | 2 +- src/Server/RequestRules/Rules/MaxAgeRule.php | 2 + src/Server/RequestRules/Rules/PromptRule.php | 2 + .../RequestRules/Rules/RequestObjectRule.php | 2 + .../Rules/RequestedClaimsRule.php | 2 + .../Rules/RequiredOpenIdScopeRule.php | 11 +- .../Rules/ScopeOfflineAccessRule.php | 2 + src/Server/RequestRules/Rules/ScopeRule.php | 2 +- src/Server/RequestRules/Rules/StateRule.php | 2 + src/Server/ResponseTypes/TokenResponse.php | 2 +- src/Services/Container.php | 1 + .../src/Server/Grants/AuthCodeGrantTest.php | 1 + 26 files changed, 405 insertions(+), 109 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 58d23b2f..29124d5a 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -526,10 +526,6 @@ $config = [ 'openid-credential-offer://', ], - // Allow or disallow clients to request verifiable credentials using Authorization Code Grant without client ID. - // Default is disallowed (false). - ModuleConfig::OPTION_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID => false, - // (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. diff --git a/src/Controllers/AuthorizationController.php b/src/Controllers/AuthorizationController.php index 07b31be4..a439e6cd 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/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 08c8d59f..0e4fed0e 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -73,7 +73,10 @@ public function credential(Request $request): Response [HttpMethodsEnum::POST], ); - $this->loggerService->debug('Verifiable Credential request data: ', $requestData); + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Verifiable Credential request data: ', + $requestData, + ); $authorization = $this->resourceServer->validateAuthenticatedRequest( $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), @@ -104,6 +107,11 @@ public function credential(Request $request): Response ($flowType = $accessToken->getFlowTypeEnum()) === null || $flowType->isVciFlow() === false ) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Access token is not intended for verifiable' . + ' credential issuance.', + ['access_token' => $accessToken], + ); return $this->routes->newJsonErrorResponse( 'invalid_token', 'Access token is not intended for verifiable credential issuance.', @@ -111,65 +119,237 @@ public function credential(Request $request): Response ); } - // TODO mivanci Validate credential request + 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).', + ); - $credentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null; + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Credential configuration ID must not be used together with credential identifier.', + ); + } - if (!is_string($credentialFormatId)) { - throw OidcServerException::serverError('Credential format missing in request.'); + // 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.', + ['authorization_detail' => $authorizationDetail], + ); + continue; + } + + if ($credentialIdentifier === $authorizationDetailCredentialConfigurationId) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Credential identifier used in flow.', + ['credential_identifier' => $credentialIdentifier], + ); + $isCredentialIdentifierUsedInFlow = true; + break; + } + } + + if (!$isCredentialIdentifierUsedInFlow) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential identifier not used in flow.', + ['credential_identifier' => $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 ( - !in_array($credentialFormatId, [ - CredentialFormatIdentifiersEnum::JwtVcJson->value, - CredentialFormatIdentifiersEnum::DcSdJwt->value, - CredentialFormatIdentifiersEnum::VcSdJwt->value, // Deprecated value, but let's support it for now. - ]) - ) { - return $this->routes->newJsonErrorResponse( - 'unsupported_credential_type', - sprintf('Credential format ID "%s" is not supported.', $credentialFormatId), + 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.', + ); + } } - // TODO mivanci Check / handle credential_identifier parameter. + if (!is_string($resolvedCredentialIdentifier)) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: No credential identifier found in request. ' . + 'Falling back to resolution from format and credential type.', + ); - $credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null; + $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; - /** @psalm-suppress MixedAssignment */ - if (is_null($credentialConfigurationId)) { // TODO mivanci Update this to newest draft. // Check per draft 14 (Sphereon wallet case). + /** @psalm-suppress MixedAssignment */ if ( - $credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value && + $requestedCredentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value && is_array( $credentialDefinitionType = - $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value] ?? null, + $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value] ?? null, ) ) { - $credentialConfigurationId = + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential configuration ID ' . + 'from credential definition type.', + ['credentialDefinitionType' => $credentialDefinitionType], + ); + $fallbackCredentialConfigurationId = $this->moduleConfig->getCredentialConfigurationIdForCredentialDefinitionType( $credentialDefinitionType, ); } elseif ( - in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true) && + in_array($requestedCredentialFormatId, self::SD_JWT_FORMAT_IDS, true) && is_string($vct = $requestData[ClaimsEnum::Vct->value] ?? null) ) { - $credentialConfigurationId = $vct; + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential configuration ID ' . + 'from VCT.', + ['vct' => $vct], + ); + $fallbackCredentialConfigurationId = $vct; } - } - if (!is_string($credentialConfigurationId)) { + 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.', ); } - if (!is_array($this->moduleConfig->getCredentialConfiguration($credentialConfigurationId))) { + $resolvedCredentialConfiguration = $this->moduleConfig->getCredentialConfiguration( + $resolvedCredentialIdentifier, + ); + if (!is_array($resolvedCredentialConfiguration)) { return $this->routes->newJsonErrorResponse( 'unsupported_credential_type', - sprintf('Credential configuration ID "%s" is not supported.', $credentialConfigurationId), + 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).', ); } @@ -242,9 +422,32 @@ public function credential(Request $request): Response $sub = $didKey; } else { $this->loggerService->warning( - 'Proof currently not supported. ', + '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(); @@ -260,13 +463,13 @@ public function credential(Request $request): Response // 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($credentialConfigurationId); + $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( - $credentialConfigurationId, + $resolvedCredentialIdentifier, ); foreach ($attributeToCredentialClaimPathMap as $mapEntry) { if (!is_array($mapEntry)) { @@ -393,7 +596,7 @@ public function credential(Request $request): Response ], ClaimsEnum::Type->value => [ CredentialTypesEnum::VerifiableCredential->value, - $credentialConfigurationId, + $resolvedCredentialIdentifier, ], //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), ClaimsEnum::Issuer->value => $issuerDid, @@ -422,7 +625,7 @@ public function credential(Request $request): Response ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), ClaimsEnum::Sub->value => $sub, ClaimsEnum::Jti->value => $vcId, - ClaimsEnum::Vct->value => $credentialConfigurationId, + ClaimsEnum::Vct->value => $resolvedCredentialIdentifier, ]; if ($proof instanceof OpenId4VciProof) { diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 9efafe0e..a49f81a4 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -28,6 +28,7 @@ use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; +use SimpleSAML\Module\oidc\Services\LoggerService; class AuthorizationServerFactory { @@ -43,6 +44,7 @@ public function __construct( private readonly RequestRulesManager $requestRulesManager, private readonly CryptKey $privateKey, private readonly PreAuthCodeGrant $preAuthCodeGrant, + private readonly LoggerService $loggerService, ) { } @@ -56,6 +58,7 @@ public function build(): AuthorizationServer $this->moduleConfig->getEncryptionKey(), $this->tokenResponse, $this->requestRulesManager, + $this->loggerService, ); $authorizationServer->enableGrantType( diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 7f30c10b..8fa811f4 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -110,8 +110,6 @@ class ModuleConfig '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_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID = - 'allow_vci_authorization_code_requests_without_client_id'; final public const OPTION_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS_FOR_VCI = 'allowed_redirect_uri_prefixes_for_non_registered_clients_for_vci'; @@ -1026,14 +1024,6 @@ public function getAllowNonRegisteredClientsForVci(): bool return $this->config()->getOptionalBoolean(self::OPTION_ALLOW_NON_REGISTERED_CLIENTS_FOR_VCI, false); } - public function getAllowVciAuthorizationCodeRequestsWithoutClientId(): bool - { - return $this->config()->getOptionalBoolean( - self::OPTION_ALLOW_VCI_AUTHORIZATION_CODE_REQUESTS_WITHOUT_CLIENT_ID, - false, - ); - } - public function getAllowedRedirectUriPrefixesForNonRegisteredClientsForVci(): array { return $this->config()->getOptionalArray( diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index e7607edd..0c0367b4 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -25,6 +25,7 @@ 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,6 +79,8 @@ public function __construct( */ public function validateAuthorizationRequest(ServerRequestInterface $request): OAuth2AuthorizationRequest { + $this->loggerService?->debug('AuthorizationServer::validateAuthorizationRequest'); + $rulesToExecute = [ StateRule::class, ClientRule::class, @@ -91,10 +95,20 @@ 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(); @@ -102,16 +116,47 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O $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 a3d59cfc..53315271 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -204,6 +204,8 @@ public function __construct( */ public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool { + $this->loggerService->debug('AuthCodeGrant::canRespondToAuthorizationRequest'); + $requestParams = $this->requestParamsResolver->getAllBasedOnAllowedMethods( $request, $this->allowedAuthorizationHttpMethods, @@ -732,6 +734,8 @@ public function validateAuthorizationRequestWithRequestRules( ServerRequestInterface $request, ResultBagInterface $resultBag, ): OAuth2AuthorizationRequest { + $this->loggerService->debug('AuthCodeGrant::validateAuthorizationRequestWithRequestRules'); + $rulesToExecute = [ ClientIdRule::class, RequestObjectRule::class, @@ -758,6 +762,12 @@ public function validateAuthorizationRequestWithRequestRules( /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $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); $this->requestRulesManager->setData('scope_delimiter_string', self::SCOPE_DELIMITER_STRING); @@ -769,9 +779,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); @@ -786,25 +800,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.'); } + $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 ( - (! $this->isOidcCandidate($oAuth2AuthorizationRequest)) && + (! $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( @@ -812,16 +847,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(); @@ -832,35 +870,54 @@ 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); - $authorizationRequest->setFlowType( - $isVciAuthorizationCodeRequest ? - FlowTypeEnum::VciAuthorizationCode : - FlowTypeEnum::OidcAuthorizationCode, - ); + $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); // 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 $clientId */ - $clientId = $resultBag->getOrFail(ClientIdRule::class)->getValue(); - $authorizationRequest->setBoundClientId($clientId); + /** @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/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index dadc6e26..4c5922d9 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -12,6 +12,7 @@ use League\OAuth2\Server\ResponseTypes\RedirectResponse; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; @@ -19,6 +20,7 @@ use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AuthorizationDetailsRule; use SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -111,7 +113,7 @@ public function respondToAccessTokenRequest( // TODO mivanci client authentication? $this->loggerService->debug( - 'Pre-authorized code grant respondToAccessTokenRequest', + 'PreAuthCodeGrant::respondToAccessTokenRequest: Request parameters: ', $this->requestParamsResolver->getAllFromRequest($request), ); @@ -182,28 +184,21 @@ public function respondToAccessTokenRequest( } } - $authorizationDetails = null; - $authorizationDetailsParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( - ParamsEnum::AuthorizationDetails->value, + $resultBag = $this->requestRulesManager->check( $request, + [AuthorizationDetailsRule::class], + false, $this->allowedTokenHttpMethods, ); - if (!empty($authorizationDetailsParam)) { - /** @psalm-suppress MixedAssignment */ - $authorizationDetails = json_decode($authorizationDetailsParam, true, 512, JSON_THROW_ON_ERROR); - } - - if ($authorizationDetails !== null) { - if (!is_array($authorizationDetails)) { - throw OidcServerException::invalidRequest(ParamsEnum::AuthorizationDetails->value); - } - } + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $this->allowedTokenHttpMethods, + ); - // TODO handle authorization_details parameter, add to - // * access token - // * and response itself. - //dd($authorizationDetails); + /** @var ?array $authorizationDetails */ + $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); // TODO mivanci add flow, authorization details, bound client_id and redirect_uri to access token. // Issue and persist new access token @@ -213,6 +208,9 @@ public function respondToAccessTokenRequest( $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)); 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 index 18c3a72b..58470163 100644 --- a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php +++ b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php @@ -37,7 +37,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - $loggerService->debug('AuthorizationDetailsRule: Running.'); + $loggerService->debug('AuthorizationDetailsRule::checkRule.'); $authorizationDetailsParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::AuthorizationDetails->value, diff --git a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php index ec1de262..ddc67e4e 100644 --- a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -40,6 +40,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->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.'); diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 71f5b867..ca95c3a6 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -74,14 +74,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - - $this->loggerService->debug( - 'ClientRule: Request parameters:', - $this->requestParamsResolver->getAllBasedOnAllowedMethods( - $request, - $allowedServerRequestMethods, - ), - ); + $loggerService->debug('ClientRule::checkRule.'); /** @var ?string $clientId */ $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( @@ -91,26 +84,7 @@ public function checkRule( ) ?? $request->getServerParams()['PHP_AUTH_USER'] ?? null; if ($clientId === null) { - $this->loggerService->debug( - 'ClientRule: Client ID not found in request parameters or PHP_AUTH_USER.', - ); - // Check to see if this is a Verifiable Credential Request. Is yes, check if VCI is - // enabled, and if client_id is allowed to be empty. We know that this is a VCI request because the - // Issuer State parameter must be present. - if ( - $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && - $this->moduleConfig->getVerifiableCredentialEnabled() && - $this->moduleConfig->getAllowVciAuthorizationCodeRequestsWithoutClientId() && - $this->moduleConfig->getAllowNonRegisteredClientsForVci() - ) { - // We will use a VCI generic client in this case. - $this->loggerService->warning( - 'ClientRule: VCI authorization code request without client_id detected.' . - ' Using generic VCI client.', - ); - - return new Result($this->getKey(), $this->getGenericVciClient()); - } + $this->loggerService->debug('ClientRule: Client ID not found in request parameters or PHP_AUTH_USER.'); throw OidcServerException::invalidRequest('client_id'); } diff --git a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php index 1c719cdd..33d5f70a 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php @@ -38,6 +38,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('CodeChallengeMethodRule::checkRule'); + /** @var string $redirectUri */ $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ diff --git a/src/Server/RequestRules/Rules/CodeChallengeRule.php b/src/Server/RequestRules/Rules/CodeChallengeRule.php index 4900c17a..feb37160 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeRule.php @@ -27,6 +27,8 @@ 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(ClientRule::class)->getValue(); /** @var string $redirectUri */ diff --git a/src/Server/RequestRules/Rules/IssuerStateRule.php b/src/Server/RequestRules/Rules/IssuerStateRule.php index 5e47dcc5..f309b8ba 100644 --- a/src/Server/RequestRules/Rules/IssuerStateRule.php +++ b/src/Server/RequestRules/Rules/IssuerStateRule.php @@ -37,7 +37,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - $loggerService->debug('IssuerStateRule: Running issuer state rule.'); + $loggerService->debug('IssuerStateRule::checkRule'); $issuerState = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::IssuerState->value, diff --git a/src/Server/RequestRules/Rules/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index b8f8a2e1..e5731a7f 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -46,6 +46,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('MaxAgeRule::checkRule'); + $requestParams = $this->requestParamsResolver->getAllBasedOnAllowedMethods( $request, $allowedServerRequestMethods, diff --git a/src/Server/RequestRules/Rules/PromptRule.php b/src/Server/RequestRules/Rules/PromptRule.php index 69dfafbb..8a994f45 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -47,6 +47,8 @@ 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(ClientRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index ad972051..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, diff --git a/src/Server/RequestRules/Rules/RequestedClaimsRule.php b/src/Server/RequestRules/Rules/RequestedClaimsRule.php index 68f3c583..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, diff --git a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php index 9d151edc..5fa0dc86 100644 --- a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php +++ b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php @@ -26,7 +26,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - $loggerService->debug('RequiredOpenIdScopeRule: Checking if required openid scope is present.'); + $loggerService->debug('RequiredOpenIdScopeRule::checkRule.'); + /** @var string $redirectUri */ $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ @@ -39,6 +40,11 @@ public function checkRule( fn($scopeEntity) => $scopeEntity->getIdentifier() === 'openid', ); + $loggerService->debug( + 'RequiredOpenIdScopeRule: Is openid scope present: ', + ['isOpenIdScopePresent' => $isOpenIdScopePresent], + ); + try { if (! $isOpenIdScopePresent) { throw OidcServerException::invalidRequest( @@ -52,8 +58,9 @@ public function checkRule( } } catch (\Throwable $e) { if ($this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods)) { - $loggerService->error('RequiredOpenIdScopeRule: Skippping openid scope check for VCI request.'); + $loggerService->info('RequiredOpenIdScopeRule: Skippping openid scope check for VCI request.'); } else { + $loggerService->error('RequiredOpenIdScopeRule: Scope openid is required.'); throw $e; } } diff --git a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php index d31aa45e..ee4188b2 100644 --- a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php +++ b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php @@ -26,6 +26,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ScopeOfflineAccessRule::checkRule'); + /** @var string $redirectUri */ $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index 2206bb09..bc6b753c 100644 --- a/src/Server/RequestRules/Rules/ScopeRule.php +++ b/src/Server/RequestRules/Rules/ScopeRule.php @@ -39,7 +39,7 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - $loggerService->debug('ScopeRule: Running.'); + $loggerService->debug('ScopeRule::checkRule.'); /** @var string $redirectUri */ $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); 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/ResponseTypes/TokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php index 807cd408..96fbaee7 100644 --- a/src/Server/ResponseTypes/TokenResponse.php +++ b/src/Server/ResponseTypes/TokenResponse.php @@ -105,7 +105,7 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra ) { $extraParams = [ ...$extraParams, -// ...$this->prepareVciAuthorizationDetailsExtraParam($accessToken), + ...$this->prepareVciAuthorizationDetailsExtraParam($accessToken), ]; } diff --git a/src/Services/Container.php b/src/Services/Container.php index 236f29ef..9a375595 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -517,6 +517,7 @@ public function __construct() $requestRuleManager, $privateKey, $this->services[PreAuthCodeGrant::class], + $loggerService, ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index d875d77d..d991c6a1 100644 --- a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php @@ -76,6 +76,7 @@ public function testCanCreateInstance(): void $this->refreshTokenIssuerStub, $this->helpersStub, $this->loggerMock, + $this->moduleConfigStub, ), ); } From 36d09d08b6fa3d805b53fbc355e129121809113a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 18 Sep 2025 12:14:33 +0200 Subject: [PATCH 60/70] Add grant_type param to VCI api --- routing/routes/routes.php | 4 +- .../Api/VciCredentialOfferApiController.php | 217 ++++++++++++++++++ .../Api/VciCredentialOfferController.php | 95 -------- ...redentialIssuerConfigurationController.php | 5 +- .../CredentialIssuerCredentialController.php | 3 +- .../JwtVcIssuerConfigurationController.php | 5 +- 6 files changed, 229 insertions(+), 100 deletions(-) create mode 100644 src/Controllers/Api/VciCredentialOfferApiController.php delete mode 100644 src/Controllers/Api/VciCredentialOfferController.php diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 61b759de..16b7b01c 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -12,7 +12,7 @@ use SimpleSAML\Module\oidc\Controllers\Admin\ConfigController; use SimpleSAML\Module\oidc\Controllers\Admin\FederationTestController; use SimpleSAML\Module\oidc\Controllers\Admin\VerifiableCredentailsTestController; -use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialOfferController; +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; @@ -149,6 +149,6 @@ $routes->add( RoutesEnum::ApiVciCredentialOffer->name, RoutesEnum::ApiVciCredentialOffer->value, - )->controller([VciCredentialOfferController::class, 'credentialOffer']) + )->controller([VciCredentialOfferApiController::class, 'credentialOffer']) ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php new file mode 100644 index 00000000..57ab8f93 --- /dev/null +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -0,0 +1,217 @@ +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/Api/VciCredentialOfferController.php b/src/Controllers/Api/VciCredentialOfferController.php deleted file mode 100644 index b2309b6e..00000000 --- a/src/Controllers/Api/VciCredentialOfferController.php +++ /dev/null @@ -1,95 +0,0 @@ -moduleConfig->getApiEnabled()) { - throw OidcServerException::forbidden('API capabilities not enabled.'); - } - } - - /** - */ - public function credentialOffer(Request $request): Response - { - $this->loggerService->debug('VCI credential offer request data: ', $request->getPayload()->all()); - try { - $this->authorization->requireTokenForAnyOfScope( - $request, - [ApiScopesEnum::VciCredentialOffer, ApiScopesEnum::VciAll, ApiScopesEnum::All], - ); - } catch (AuthorizationException $e) { - return $this->routes->newJsonErrorResponse( - error: 'unauthorized', - description: $e->getMessage(), - httpCode: Response::HTTP_UNAUTHORIZED, - ); - } - - $input = $request->getPayload()->all(); - /** @psalm-suppress MixedAssignment */ - $userAttributes = $input['user_attributes'] ?? []; - $userAttributes = is_array($userAttributes) ? $userAttributes : []; - - $selectedCredentialConfigurationId = $input['credential_configuration_id'] ?? null; - - if (!is_string($selectedCredentialConfigurationId)) { - return $this->routes->newJsonErrorResponse( - error: 'invalid_request', - description: 'No credential configuration ID provided.', - httpCode: Response::HTTP_BAD_REQUEST, - ); - } - - $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, - ); - } - - $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( - [$selectedCredentialConfigurationId], - $userAttributes, - $useTxCode, - $usersEmailAttributeName, - ); - - return $this->routes->newJsonResponse( - data: [ - 'credential_offer_uri' => $credentialOfferUri, - ], - ); - } -} diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php index fc42f900..95959a5e 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use Symfony\Component\HttpFoundation\Response; @@ -27,9 +28,11 @@ class CredentialIssuerConfigurationController public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Routes $routes, + protected readonly LoggerService $loggerService, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { - throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } } diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 0e4fed0e..2a3dc1a8 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -55,7 +55,8 @@ public function __construct( protected readonly Did $did, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { - throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } } diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php index af675f59..410a9cf6 100644 --- a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -16,6 +16,7 @@ use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use Symfony\Component\HttpFoundation\Response; @@ -28,9 +29,11 @@ class JwtVcIssuerConfigurationController public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Routes $routes, + protected readonly LoggerService $loggerService, ) { if (!$this->moduleConfig->getVerifiableCredentialEnabled()) { - throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled'); + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); } } From f1e2af5e09e8e6a918961d04349ffab4cf140a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 18 Sep 2025 14:31:20 +0200 Subject: [PATCH 61/70] Move to generic client instantiation to client repo --- src/Admin/Authorization.php | 32 +++++++++++++++++-- .../CredentialIssuerCredentialController.php | 9 +++--- src/Factories/CredentialOfferUriFactory.php | 12 ++----- src/Repositories/ClientRepository.php | 12 +++++++ src/Server/RequestRules/Rules/ClientRule.php | 14 +------- tests/unit/src/Admin/AuthorizationTest.php | 9 ++++-- 6 files changed, 58 insertions(+), 30 deletions(-) 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/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 2a3dc1a8..728356dd 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -69,13 +69,15 @@ public function __construct( */ 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::credential: Verifiable Credential request data: ', + 'CredentialIssuerCredentialController: Request data: ', $requestData, ); @@ -83,7 +85,6 @@ public function credential(Request $request): Response $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), ); - // TODO mivanci validate access token $accessToken = $this->accessTokenRepository->findById( (string)$authorization->getAttribute('oauth_access_token_id'), ); @@ -109,8 +110,8 @@ public function credential(Request $request): Response $flowType->isVciFlow() === false ) { $this->loggerService->warning( - 'CredentialIssuerCredentialController::credential: Access token is not intended for verifiable' . - ' credential issuance.', + 'CredentialIssuerCredentialController::credential: Access token is not intended for Verifiable' . + ' Credential Issuance.', ['access_token' => $accessToken], ); return $this->routes->newJsonErrorResponse( diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php index b40e9695..5d314ba4 100644 --- a/src/Factories/CredentialOfferUriFactory.php +++ b/src/Factories/CredentialOfferUriFactory.php @@ -13,7 +13,6 @@ use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; -use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\IssuerStateEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; @@ -36,7 +35,6 @@ public function __construct( protected readonly SspBridge $sspBridge, protected readonly AuthCodeRepository $authCodeRepository, protected readonly AuthCodeEntityFactory $authCodeEntityFactory, - protected readonly ClientEntityFactory $clientEntityFactory, protected readonly ClientRepository $clientRepository, protected readonly LoggerService $loggerService, protected readonly UserRepository $userRepository, @@ -132,13 +130,9 @@ public function buildPreAuthorized( ); // 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. - $client = $this->clientEntityFactory->getGenericForVci(); - if ($this->clientRepository->findById($client->getIdentifier()) === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } + // 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 { diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index fa59d855..e8087edc 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -564,4 +564,16 @@ protected function preparePdoState(array $state): array 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/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index ca95c3a6..0d89c2ae 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -132,7 +132,7 @@ public function checkRule( 'Falling back to generic VCI client.', ); - return new Result($this->getKey(), $this->getGenericVciClient()); + 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' . @@ -365,16 +365,4 @@ public function resolveFromFederation( return $registrationClient; } - - protected function getGenericVciClient(): ClientEntityInterface - { - $client = $this->clientEntityFactory->getGenericForVci(); - if ($this->clientRepository->findById($client->getIdentifier()) === null) { - $this->clientRepository->add($client); - } else { - $this->clientRepository->update($client); - } - - return $client; - } } 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'); From 94af021664afeed0063005389057778f0a29b942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 18 Sep 2025 15:20:39 +0200 Subject: [PATCH 62/70] Add main auth code validation --- src/Factories/CryptKeyFactory.php | 2 +- src/Server/Grants/PreAuthCodeGrant.php | 44 +++++++++++++++++--------- src/Services/OpMetadataService.php | 1 - 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index 51559621..908d464b 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -22,7 +22,7 @@ public function buildPrivateKey(): CryptKey return new CryptKey( $this->moduleConfig->getProtocolPrivateKeyPath(), $this->moduleConfig->getProtocolPrivateKeyPassPhrase(), - false, // TODO mivanci Return to true + true, ); } diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php index 4c5922d9..5c7e0a24 100644 --- a/src/Server/Grants/PreAuthCodeGrant.php +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -142,22 +142,9 @@ public function respondToAccessTokenRequest( throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); } - if (!$preAuthorizedCode->isVciPreAuthorized()) { - $this->loggerService->error( - 'Pre-authorized code is not pre-authorized. Value was: ' . $preAuthorizedCodeId, - ); - throw OidcServerException::invalidGrant('Pre-authorized code is not pre-authorized.'); - } - - if ($preAuthorizedCode->isRevoked()) { - $this->loggerService->error('Pre-authorized code is revoked. Value was: ' . $preAuthorizedCodeId); - throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); - } - $client = $preAuthorizedCode->getClient(); - // TODO validate code - // $this->validateAuthorizationCode($preAuthorizedCode, $client, $request); + $this->validateAuthorizationCode($preAuthorizedCode, $client, $request, $preAuthorizedCode); // Validate Transaction Code. if (($preAuthorizedCodeTxCode = $preAuthorizedCode->getTxCode()) !== null) { @@ -200,7 +187,6 @@ public function respondToAccessTokenRequest( /** @var ?array $authorizationDetails */ $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); - // TODO mivanci add flow, authorization details, bound client_id and redirect_uri to access token. // Issue and persist new access token $accessToken = $this->issueAccessToken( $accessTokenTTL, @@ -238,6 +224,34 @@ protected function validateAuthorizationCode( 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.'); } /** diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index d81b996c..5986de2a 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -50,7 +50,6 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::EndSessionEndpoint->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::EndSession->value); $this->metadata[ClaimsEnum::JwksUri->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::Jwks->value); - // TODO mivanci Resolve supported scopes from ScopeRepository (also include those from VCI). $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getScopes()); $this->metadata[ClaimsEnum::ResponseTypesSupported->value] = ['code', 'token', 'id_token', 'id_token token']; $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; From cfc93cc94ebb6f29544f0265f52ce1b240a1b141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 26 Sep 2025 16:19:56 +0200 Subject: [PATCH 63/70] Add API documentation --- docs/api.md | 145 ++++++++++++++++++ docs/index.md | 3 + .../Api/VciCredentialOfferApiController.php | 3 - 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/index.md 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/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php index 57ab8f93..58f29524 100644 --- a/src/Controllers/Api/VciCredentialOfferApiController.php +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -16,9 +16,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -/** - * TODO mivanci Add API documentation. - */ class VciCredentialOfferApiController { /** From 092bf542561d3c2ecd33f3e232d2d1347c269332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 10 Oct 2025 14:31:25 +0200 Subject: [PATCH 64/70] Merge with version 7 --- docs/2-oidc-installation.md | 41 +++++++++++++++++++++++++++++++++++- docs/3-oidc-configuration.md | 6 ++++++ docs/6-oidc-upgrade.md | 3 +++ 3 files changed, 49 insertions(+), 1 deletion(-) 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 9cc19855..919d3dc8 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -3,6 +3,9 @@ This is an upgrade guide from versions 1 → 7. Review the changes and apply those relevant to your deployment. +## TODO mivanci +* Move to specific simplesamlphp/openid release (composer.json). + ## Version 6 to 7 New features: From 07e79778a748e1549324806de069b407e042ac62 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 24 Oct 2025 10:47:08 +0200 Subject: [PATCH 65/70] Reflect issuer_state in access_token (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the VC issuer from authorization server. The `issuer_state` is not checked in the authorization endpoint anymore, but simply accepted as an opaque identifier and afterwards reflected in the access token. The issuer can compare the previously offered `issuer_state` against the `issuer_state` in the access token. --------- Co-authored-by: Marko Ivančić --- docker/conformance.sql | 2 ++ .../CredentialIssuerCredentialController.php | 32 ++++++++++++++++++- src/Entities/AccessTokenEntity.php | 10 ++++++ src/Entities/AuthCodeEntity.php | 7 ++++ .../Entities/AccessTokenEntityFactory.php | 2 ++ .../Entities/AuthCodeEntityFactory.php | 4 +++ src/Factories/RequestRulesManagerFactory.php | 4 +-- src/Repositories/AccessTokenRepository.php | 8 +++-- src/Repositories/AuthCodeRepository.php | 9 ++++-- src/Server/Grants/AuthCodeGrant.php | 25 +++++++++++++++ .../Grants/Traits/IssueAccessTokenTrait.php | 2 ++ .../RequestRules/Rules/IssuerStateRule.php | 25 --------------- .../Validators/BearerTokenValidator.php | 6 ++-- src/Services/DatabaseMigration.php | 30 +++++++++++++++++ .../AccessTokenRepositoryTest.php | 1 + .../unit/src/Entities/AuthCodeEntityTest.php | 1 + 16 files changed, 129 insertions(+), 39 deletions(-) diff --git a/docker/conformance.sql b/docker/conformance.sql index 42aa9078..bf09b946 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -65,6 +65,7 @@ CREATE TABLE oidc_access_token ( 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) @@ -92,6 +93,7 @@ CREATE TABLE oidc_auth_code ( 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, CONSTRAINT FK_97D32CA719EB6921 FOREIGN KEY (client_id) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 728356dd..c4ee2e85 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -53,6 +54,7 @@ public function __construct( 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.'); @@ -121,6 +123,30 @@ public function credential(Request $request): Response ); } + $issuerState = $accessToken->getIssuerState(); + if (!is_string($issuerState)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Issuer state missing in access token.', + ['access_token' => $accessToken], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Issuer state missing in access token.', + 401, + ); + } + + if ($this->issuerStateRepository->findValid($issuerState) === null) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Issuer state not valid.', + ['issuer_state' => $issuerState], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Issuer state not valid.', + ); + } + if ( isset($requestData[ClaimsEnum::CredentialConfigurationId->value]) && isset($requestData[ClaimsEnum::CredentialIdentifier->value]) @@ -652,7 +678,11 @@ public function credential(Request $request): Response throw new OpenIdException('Invalid credential format ID.'); } - $this->loggerService->debug('response', [ + $this->loggerService->debug('Revoking issuer state.', ['issuerState' => $issuerState]); + ; + $this->issuerStateRepository->revoke($issuerState); + + $this->loggerService->debug('Returning credential response.', [ 'credentials' => [ ['credential' => $verifiableCredential->getToken()], ], diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index 834902a1..2fef7aaa 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -74,6 +74,7 @@ public function __construct( 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); @@ -125,6 +126,7 @@ public function getState(): array null, 'bound_client_id' => $this->boundClientId, 'bound_redirect_uri' => $this->boundRedirectUri, + 'issuer_state' => $this->issuerState, ]; } @@ -166,6 +168,9 @@ 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); } @@ -189,4 +194,9 @@ 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 e8e10f30..2cacb7e1 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -49,6 +49,7 @@ public function __construct( 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; @@ -81,6 +82,7 @@ public function getState(): array null, 'bound_client_id' => $this->boundClientId, 'bound_redirect_uri' => $this->boundRedirectUri, + 'issuer_state' => $this->issuerState, ]; } @@ -113,4 +115,9 @@ public function getBoundRedirectUri(): ?string { return $this->boundRedirectUri; } + + public function getIssuerState(): ?string + { + return $this->issuerState; + } } diff --git a/src/Factories/Entities/AccessTokenEntityFactory.php b/src/Factories/Entities/AccessTokenEntityFactory.php index b3ed8f3a..2c1a5417 100644 --- a/src/Factories/Entities/AccessTokenEntityFactory.php +++ b/src/Factories/Entities/AccessTokenEntityFactory.php @@ -40,6 +40,7 @@ public function fromData( ?array $authorizationDetails = null, ?string $boundClientId = null, ?string $boundRedirectUri = null, + ?string $issuerState = null, ): AccessTokenEntity { return new AccessTokenEntity( $id, @@ -56,6 +57,7 @@ public function fromData( authorizationDetails: $authorizationDetails, boundClientId: $boundClientId, boundRedirectUri: $boundRedirectUri, + issuerState: $issuerState, ); } diff --git a/src/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index 4449e74b..0304b804 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -31,6 +31,7 @@ public function fromData( ?string $userIdentifier = null, ?string $redirectUri = null, ?string $nonce = null, + ?string $issuerState = null, bool $isRevoked = false, ?FlowTypeEnum $flowTypeEnum = null, ?string $txCode = null, @@ -52,6 +53,7 @@ public function fromData( $authorizationDetails, $boundClientId, $boundRedirectUri, + $issuerState, ); } @@ -94,6 +96,7 @@ public function fromState(array $state): AuthCodeEntity $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']) ? @@ -112,6 +115,7 @@ public function fromState(array $state): AuthCodeEntity $userIdentifier, $redirectUri, $nonce, + $issuerState, $isRevoked, $flowType, $txCode, diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index a05bf4f0..27de4e3c 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -10,7 +10,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; -use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; @@ -66,7 +65,6 @@ public function __construct( private readonly JwksResolver $jwksResolver, private readonly FederationParticipationValidator $federationParticipationValidator, private readonly SspBridge $sspBridge, - private readonly IssuerStateRepository $issuerStateRepository, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, ) { @@ -89,6 +87,7 @@ private function getDefaultRules(): array { return [ new StateRule($this->requestParamsResolver, $this->helpers), + new IssuerStateRule($this->requestParamsResolver, $this->helpers), new ClientRule( $this->requestParamsResolver, $this->helpers, @@ -147,7 +146,6 @@ private function getDefaultRules(): array $this->protocolCache, ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), - new IssuerStateRule($this->requestParamsResolver, $this->helpers, $this->issuerStateRepository), new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), new ClientIdRule($this->requestParamsResolver, $this->helpers), ]; diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 9d55097b..ac535490 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -113,7 +113,8 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo flow_type, authorization_details, bound_client_id, - bound_redirect_uri + bound_redirect_uri, + issuer_state ) " . "VALUES ( :id, @@ -127,7 +128,8 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo :flow_type, :authorization_details, :bound_client_id, - :bound_redirect_uri + :bound_redirect_uri, + :issuer_state )", $this->getTableName(), ); @@ -267,7 +269,7 @@ private function update(AccessTokenEntity $accessTokenEntity): void . "client_id = :client_id, is_revoked = :is_revoked, auth_code_id = :auth_code_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 WHERE id = :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 a24afdf3..f083cfc8 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -84,7 +84,8 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity tx_code, authorization_details, bound_client_id, - bound_redirect_uri + bound_redirect_uri, + issuer_state ) VALUES ( :id, :scopes, @@ -98,7 +99,8 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity :tx_code, :authorization_details, :bound_client_id, - :bound_redirect_uri + :bound_redirect_uri, + :issuer_state ) EOS, $this->getTableName(), @@ -224,7 +226,8 @@ private function update(AuthCodeEntity $authCodeEntity): void tx_code = :tx_code, authorization_details = :authorization_details, bound_client_id = :bound_client_id, - bound_redirect_uri = :bound_redirect_uri + bound_redirect_uri = :bound_redirect_uri, + issuer_state = :issuer_state WHERE id = :id EOS , diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 53315271..5693e220 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -27,6 +27,7 @@ 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; @@ -347,6 +348,7 @@ protected function issueOidcAuthCode( $userIdentifier, $redirectUri, $authorizationRequest->getNonce(), + $authorizationRequest->getIssuerState(), flowTypeEnum: $flowType, authorizationDetails: $authorizationRequest->getAuthorizationDetails(), boundClientId: $authorizationRequest->getBoundClientId(), @@ -615,6 +617,7 @@ public function respondToAccessTokenRequest( $storedAuthCodeEntity->getAuthorizationDetails(), $storedAuthCodeEntity->getBoundClientId(), $storedAuthCodeEntity->getBoundRedirectUri(), + $storedAuthCodeEntity->getIssuerState(), ); $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); $responseType->setAccessToken($accessToken); @@ -893,6 +896,28 @@ public function validateAuthorizationRequestWithRequestRules( ); $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()) { diff --git a/src/Server/Grants/Traits/IssueAccessTokenTrait.php b/src/Server/Grants/Traits/IssueAccessTokenTrait.php index 8ac538e3..6e8f47b6 100644 --- a/src/Server/Grants/Traits/IssueAccessTokenTrait.php +++ b/src/Server/Grants/Traits/IssueAccessTokenTrait.php @@ -55,6 +55,7 @@ protected function issueAccessToken( ?array $authorizationDetails = null, ?string $boundClientId = null, ?string $boundRedirectUri = null, + ?string $issuerState = null, ): AccessTokenEntityInterface { $maxGenerationAttempts = AbstractGrant::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; @@ -79,6 +80,7 @@ protected function issueAccessToken( authorizationDetails: $authorizationDetails, boundClientId: $boundClientId, boundRedirectUri: $boundRedirectUri, + issuerState: $issuerState, ); $this->accessTokenRepository->persistNewAccessToken($accessToken); return $accessToken; diff --git a/src/Server/RequestRules/Rules/IssuerStateRule.php b/src/Server/RequestRules/Rules/IssuerStateRule.php index f309b8ba..7ba9bf2d 100644 --- a/src/Server/RequestRules/Rules/IssuerStateRule.php +++ b/src/Server/RequestRules/Rules/IssuerStateRule.php @@ -5,27 +5,15 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; -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\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; class IssuerStateRule extends AbstractRule { - public function __construct( - RequestParamsResolver $requestParamsResolver, - Helpers $helpers, - protected readonly IssuerStateRepository $issuerStateRepository, - ) { - parent::__construct($requestParamsResolver, $helpers); - } - /** * @inheritDoc */ @@ -37,25 +25,12 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - $loggerService->debug('IssuerStateRule::checkRule'); - $issuerState = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::IssuerState->value, $request, $allowedServerRequestMethods, ); - if ($issuerState === null) { - return null; - } - - if ($this->issuerStateRepository->findValid($issuerState) === null) { - $loggerService->error('IssuerStateRule: Invalid issuer state: ' . $issuerState); - throw OidcServerException::invalidRequest(ParamsEnum::IssuerState->value); - } - - $loggerService->debug('IssuerStateRule: Valid issuer state: ' . $issuerState); - return new Result($this->getKey(), $issuerState); } } diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 3eaded18..b1afbd23 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -72,13 +72,11 @@ public function setPublicKey(CryptKey $key): void */ protected function initJwtConfiguration(): void { + /** @psalm-suppress ArgumentTypeCoercion */ $this->jwtConfiguration = Configuration::forSymmetricSigner( $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( $this->moduleConfig->getProtocolSigner(), diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 8256ba90..06020ef2 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -199,6 +199,16 @@ public function migrate(): void $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 @@ -678,6 +688,26 @@ private function version20250917163000(): void ,); } + 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/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 1eab1395..6ace21ac 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -120,6 +120,7 @@ public function setUp(): void '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/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index 877420eb..6b0be802 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -106,6 +106,7 @@ public function testCanGetState(): void 'authorization_details' => null, 'bound_client_id' => null, 'bound_redirect_uri' => null, + 'issuer_state' => null, ], ); } From 76c5b225ccf0f646e46863215bdf189943a810b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 27 Oct 2025 12:41:47 +0100 Subject: [PATCH 66/70] Don't handle issuer state for pre-auth code flow --- .../CredentialIssuerCredentialController.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index c4ee2e85..3d9a8e46 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -7,6 +7,7 @@ use Base64Url\Base64Url; use League\OAuth2\Server\ResourceServer; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; @@ -124,7 +125,10 @@ public function credential(Request $request): Response } $issuerState = $accessToken->getIssuerState(); - if (!is_string($issuerState)) { + if ( + !is_string($issuerState) && + ($accessToken->getFlowTypeEnum() === FlowTypeEnum::VciAuthorizationCode) + ) { $this->loggerService->error( 'CredentialIssuerCredentialController::credential: Issuer state missing in access token.', ['access_token' => $accessToken], @@ -136,7 +140,7 @@ public function credential(Request $request): Response ); } - if ($this->issuerStateRepository->findValid($issuerState) === null) { + if (is_string($issuerState) && $this->issuerStateRepository->findValid($issuerState) === null) { $this->loggerService->warning( 'CredentialIssuerCredentialController::credential: Issuer state not valid.', ['issuer_state' => $issuerState], @@ -678,9 +682,10 @@ public function credential(Request $request): Response throw new OpenIdException('Invalid credential format ID.'); } - $this->loggerService->debug('Revoking issuer state.', ['issuerState' => $issuerState]); - ; - $this->issuerStateRepository->revoke($issuerState); + if (is_string($issuerState)) { + $this->loggerService->debug('Revoking issuer state.', ['issuerState' => $issuerState]); + $this->issuerStateRepository->revoke($issuerState); + } $this->loggerService->debug('Returning credential response.', [ 'credentials' => [ From 846158900adc305da2f354da8c5ce7a87812592f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 27 Oct 2025 13:30:17 +0100 Subject: [PATCH 67/70] Include issuer_state when building access token from data --- src/Factories/Entities/AccessTokenEntityFactory.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Factories/Entities/AccessTokenEntityFactory.php b/src/Factories/Entities/AccessTokenEntityFactory.php index 2c1a5417..f0ba1037 100644 --- a/src/Factories/Entities/AccessTokenEntityFactory.php +++ b/src/Factories/Entities/AccessTokenEntityFactory.php @@ -110,6 +110,7 @@ public function fromState(array $state): AccessTokenEntity $boundClientId = empty($state['bound_client_id']) ? null : (string)$state['bound_client_id']; $boundRedirectUri = empty($state['bound_redirect_uri']) ? null : (string)$state['bound_redirect_uri']; + $issuerState = empty($state['issuer_state']) ? null : (string)$state['issuer_state']; return $this->fromData( $id, @@ -124,6 +125,7 @@ public function fromState(array $state): AccessTokenEntity $authorizationDetails, $boundClientId, $boundRedirectUri, + $issuerState, ); } } From 8378963ecf7316a922104935560986bf3de832e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 27 Oct 2025 13:54:02 +0100 Subject: [PATCH 68/70] Fix issuerState update statement --- src/Repositories/IssuerStateRepository.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Repositories/IssuerStateRepository.php b/src/Repositories/IssuerStateRepository.php index 4e165a5b..a91e1138 100644 --- a/src/Repositories/IssuerStateRepository.php +++ b/src/Repositories/IssuerStateRepository.php @@ -103,6 +103,8 @@ public function update(IssuerStateEntity $issuerState): void << Date: Mon, 27 Oct 2025 15:15:09 +0100 Subject: [PATCH 69/70] Clean up logging a bit --- .../CredentialIssuerCredentialController.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php index 3d9a8e46..ec0f5391 100644 --- a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -115,7 +115,7 @@ public function credential(Request $request): Response $this->loggerService->warning( 'CredentialIssuerCredentialController::credential: Access token is not intended for Verifiable' . ' Credential Issuance.', - ['access_token' => $accessToken], + ['accessTokenState' => $accessToken->getState()], ); return $this->routes->newJsonErrorResponse( 'invalid_token', @@ -131,7 +131,7 @@ public function credential(Request $request): Response ) { $this->loggerService->error( 'CredentialIssuerCredentialController::credential: Issuer state missing in access token.', - ['access_token' => $accessToken], + ['accessTokenState' => $accessToken->getState()], ); return $this->routes->newJsonErrorResponse( 'invalid_token', @@ -143,7 +143,7 @@ public function credential(Request $request): Response if (is_string($issuerState) && $this->issuerStateRepository->findValid($issuerState) === null) { $this->loggerService->warning( 'CredentialIssuerCredentialController::credential: Issuer state not valid.', - ['issuer_state' => $issuerState], + ['issuerState' => $issuerState], ); return $this->routes->newJsonErrorResponse( 'invalid_token', @@ -202,7 +202,7 @@ public function credential(Request $request): Response ) { $this->loggerService->warning( 'CredentialIssuerCredentialController::credential: Unusable authorization detail.', - ['authorization_detail' => $authorizationDetail], + ['authorizationDetail' => $authorizationDetail], ); continue; } @@ -210,7 +210,7 @@ public function credential(Request $request): Response if ($credentialIdentifier === $authorizationDetailCredentialConfigurationId) { $this->loggerService->debug( 'CredentialIssuerCredentialController::credential: Credential identifier used in flow.', - ['credential_identifier' => $credentialIdentifier], + ['credentialIdentifier' => $credentialIdentifier], ); $isCredentialIdentifierUsedInFlow = true; break; @@ -220,7 +220,7 @@ public function credential(Request $request): Response if (!$isCredentialIdentifierUsedInFlow) { $this->loggerService->error( 'CredentialIssuerCredentialController::credential: Credential identifier not used in flow.', - ['credential_identifier' => $credentialIdentifier], + ['credentialIdentifier' => $credentialIdentifier], ); return $this->routes->newJsonErrorResponse( 'invalid_credential_request', From 5e268a87af54f1390773bd6d30a37185e49cb1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 21 Nov 2025 13:05:24 +0100 Subject: [PATCH 70/70] Update docs --- composer.json | 2 +- docs/1-oidc.md | 20 +++++++++++++++++++ docs/6-oidc-upgrade.md | 9 ++++++--- .../VerifiableCredentialsFactory.php | 1 + 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index f07bf201..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": "dev-wip-vci", + "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/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/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index b878306e..7604c11c 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -3,15 +3,18 @@ This is an upgrade guide from versions 1 → 7. Review the changes and apply those relevant to your deployment. -## TODO mivanci -* Move to specific simplesamlphp/openid release (composer.json). - ## Version 6 to 7 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/src/Factories/VerifiableCredentialsFactory.php b/src/Factories/VerifiableCredentialsFactory.php index 85cdfda5..6ffb2e51 100644 --- a/src/Factories/VerifiableCredentialsFactory.php +++ b/src/Factories/VerifiableCredentialsFactory.php @@ -28,6 +28,7 @@ public function build(): VerifiableCredentials $supportedAlgorithms = new SupportedAlgorithms( new SignatureAlgorithmBag( SignatureAlgorithmEnum::from($this->moduleConfig->getProtocolSigner()->algorithmId()), + SignatureAlgorithmEnum::RS256, SignatureAlgorithmEnum::RS384, SignatureAlgorithmEnum::RS512, SignatureAlgorithmEnum::ES256,