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 caad53c7a..4a38bf23b 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 6c17691a8..4f1d4ebf5 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 000000000..50824eeaf --- /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 000000000..0d85c687a --- /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 a4efab84d..88814a640 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 f1c3a1a63..f07bf2010 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 8c56930bb..be115c273 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 60f08be9e..19359dfc7 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 5f4494a71..c41d30d14 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 50824eeaf..9984d16a3 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 0d85c687a..6a3e82eef 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 4a9fe3b83..3b377614d 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 921724bb4..5986de2ac 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 56a5e5890..f9603d3a6 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 c41d30d14..bdd539834 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 c41d30d14..bdd539834 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 f02f9e054..1d2b9554d 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 965038998..1ccefa52d 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 1ccefa52d..683d388c6 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 90c3454bc..0708ac879 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 bdd539834..c41d30d14 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 cff64f67c..8bb56868f 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 4a38bf23b..9a92de376 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 4f1d4ebf5..bdfad0bd9 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 85e35a6c1..ac27d21d7 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 6a3e82eef..de00135cf 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 000000000..be366859f --- /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 48986fcb1..54505a73e 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 3b377614d..d0a96a5d9 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 7783aed2b..84e034c3c 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 d91342317..715ac06e5 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 000000000..db1dcb398 --- /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 9a92de376..2f0c2c121 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 19359dfc7..8dc339ef7 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 bb8630ada..7bf5395cd 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 bdfad0bd9..77ccee71e 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 e05a6dc1f..fadd64aa7 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 000000000..02ecef577 --- /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 54cf5d38c..31ed11b5c 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 a72a53c4c..db6168a4a 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 000000000..abe4e1800 --- /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 54505a73e..7e26f6e5e 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 000000000..85cdfda5a --- /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 dfaac1cfb..2656f7ecd 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 000000000..7a4e9b24a --- /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 3570fbad1..0d1ea320c 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 000000000..38b2e4fea --- /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 2f0c2c121..1e142f5a8 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 02ecef577..9cbabdf46 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 de00135cf..5f1fa4a9e 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 be366859f..e9590132c 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 db6168a4a..5b90a4520 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 abe4e1800..9e241c3ff 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 7a4e9b24a..daca234d9 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 0d1ea320c..f33e4ca2d 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 e9590132c..fa1123258 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 fa1123258..7a75c300e 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 a7cc02ea3..c5a7768ff 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 7a75c300e..e17294978 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 e17294978..54767fa45 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 c5a7768ff..515596213 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 54767fa45..b9713b023 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 b9713b023..05c8c321a 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 05c8c321a..bfc77840d 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 bfc77840d..fbaf66998 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 fbaf66998..929b2352e 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 929b2352e..6d9796d02 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 6d9796d02..a7ca6bdcf 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 a7ca6bdcf..febec0588 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 e68c67753..9fb49e399 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 febec0588..e26340ed8 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 0a371aa4b..fac096858 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 e26340ed8..def76cdd8 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 def76cdd8..c8c78e7cb 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 f33e4ca2d..c11cf5e75 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 be115c273..eead71a5e 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 9cbabdf46..d6170a04c 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 5f1fa4a9e..4c21c9265 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 c8c78e7cb..7e2471d14 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 0f708ce26..7767824a9 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 000000000..582d5cc70 --- /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 daca234d9..707fe267b 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 fac096858..3eaded18c 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 715ac06e5..71948fb06 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 38b2e4fea..8045c23f4 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 7e2471d14..2cdb76043 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 d6170a04c..cd447dff6 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 0e3f79a1f..484892793 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 8045c23f4..1932af633 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 2cdb76043..bd8e83752 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 484892793..14a1fdfa3 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 bd8e83752..a38288c6b 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 c11cf5e75..8fe159ab4 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 000000000..7a72dd2b7 --- /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 000000000..51da160a7 --- /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 8dc339ef7..c78cd35cb 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 cd447dff6..944c2cb38 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 a38288c6b..019df1059 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 14a1fdfa3..a63c0af17 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 8fe159ab4..c11cf5e75 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 7a72dd2b7..000000000 --- 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 9fb49e399..27e37a2c9 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 df898ef26..8205089fe 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 307eea9e7..54e24c2db 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 1e142f5a8..4847dec5c 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 77ccee71e..3df1ca0ce 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 000000000..af675f594 --- /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 71948fb06..e9781103a 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 db1dcb398..c6d64e20b 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 8205089fe..eb79b61df 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 019df1059..cb1068a95 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 6fc5f21f7..a9c5a09db 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 e9781103a..955aef8ef 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 cb1068a95..dc58edb7a 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 dc58edb7a..2485075aa 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 2485075aa..35de19848 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 eb79b61df..ed6bd22b0 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 4847dec5c..8fa73589b 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 000000000..00006ac2d --- /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 000000000..6958133bc --- /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 35de19848..7b5dafa59 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 bdbad3253..7b4f18a8b 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 a9c5a09db..595e6d5ad 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 000000000..4456c0b79 --- /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 8fa73589b..4cb5967a9 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 6e44dbf23..9f8724afd 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 afb081925..10fa3198c 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 6958133bc..932c48bdb 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 7b4f18a8b..42f3ec114 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 4456c0b79..7e1024c23 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 955aef8ef..b04e062b0 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 10fa3198c..19a9bf6a2 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 932c48bdb..0d7fd05b2 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 000000000..b9c6b3018 --- /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 c11cf5e75..6144e9abb 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 4cb5967a9..61b759de7 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 9f8724afd..6e44dbf23 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 19a9bf6a2..a4ad0e24a 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 0d7fd05b2..6143476e0 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 b9c6b3018..4993ac605 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 7e1024c23..83024ae79 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 b04e062b0..f77635774 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 ed6bd22b0..65318f136 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 a4ad0e24a..9f049e7d0 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 6143476e0..8c8d3dfb4 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 d98fe3472..0f95bba0b 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 4993ac605..bb3185d6b 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 be0cdee2e..435ae1793 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 595e6d5ad..dadc7535c 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 46d468327..8c08425c7 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 707fe267b..df293547c 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 a4936e887..08381a7c9 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 1932af633..88d1ada4b 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 65318f136..13a8f52e9 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 cb57e66d1..f15208491 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 000000000..beb044c5e --- /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 bb3185d6b..879015d0a 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 000000000..49297469b --- /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 dadc7535c..34bcbd4ba 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 000000000..18c879562 --- /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 08381a7c9..e3b658852 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 13a8f52e9..8cf7dacdc 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 000000000..24a458246 --- /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 9f049e7d0..b63358357 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 8c8d3dfb4..b2309b6e7 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 7b5dafa59..c27e3a9e0 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 31ed11b5c..54e653882 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 879015d0a..2540aa47d 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 49297469b..7b683dd03 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 34bcbd4ba..dd97786de 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 18c879562..4e165a5b7 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 83024ae79..66979574c 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 6144e9abb..89629bee4 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 e3b658852..ee408536c 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 88d1ada4b..e792eb0ec 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 b9cc457e1..ac7ddbbc4 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 4479ddf35..d875d77d2 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 daa8bf190..d24ed378f 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 4194f9e85..26f5546dd 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 b63358357..fe5f26d75 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 ee408536c..60943297f 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 8cf7dacdc..9b5f84865 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 4c21c9265..f5725412a 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 f5725412a..fc42f9009 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 bf28d4da9..df7e8c74c 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 65c83e98d..da165133d 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 2656f7ecd..1ee143de6 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 4e2026bcc..4b804621a 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 62b522ca4..478d46899 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 b377377f0..e58fe4aa9 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 38a9e431b..403895764 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 5f96672d3..8b3767eb3 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 38c4a8098..0d64ea7e1 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 60fd38cc5..46a709784 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 22a9f95ff..07409ed5c 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 a1f74a248..4e0e2d49e 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 d8b279701..68f3c583c 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 ae67f5881..07f83b0af 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 89629bee4..1a3162909 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 9556d8d33..4e58fd240 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 671badb70..65355a6e0 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 3650edde2..cfdb5fc98 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 69c9392c7..08704abbe 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 20d165c19..a17f677da 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 9b5f84865..58d23b2fa 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 7767824a9..77ce0e48a 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 2540aa47d..5713870d1 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 42f3ec114..f6d759e5a 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 df7e8c74c..b108b2fc3 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 dd97786de..73883e38f 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 1ee143de6..df725cbe2 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 fa9d124e0..59636c651 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 e58fe4aa9..9c9629006 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 000000000..5e47dcc52 --- /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 0d64ea7e1..aa3d94812 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 46a709784..25cc18a89 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 07409ed5c..9a9bd7920 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 b6695c023..84f48cb4e 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 c4c664e76..b094b7661 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 4dbba7d9f..59bb7716f 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 1a3162909..cb6a6fe0a 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 11d23565e..c18600c7c 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 e792eb0ec..4679cb2dd 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 4e58fd240..abf0eb9ef 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 cfdb5fc98..637464e89 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 deae3f3d3..d03abdcd1 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 26f5546dd..91846fadb 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 000000000..9214059d9 --- /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 5713870d1..b40e96956 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 435ae1793..abe74c016 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 8c08425c7..86d9718e2 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 df725cbe2..6635c3462 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 59636c651..dbbb3c759 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 60943297f..21d4a696b 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 ac7ddbbc4..28c4b8cde 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 dbbb3c759..c6768b234 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 91846fadb..70a5deef2 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 cce08a322..4f4605a14 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 0fd28f7c8..00f66db22 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 abe74c016..f298c4886 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 b108b2fc3..8397ac2fe 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 86d9718e2..7dfd9b674 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 6635c3462..6903280fa 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 c6768b234..64329e65c 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 000000000..18c3a72b9 --- /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 b094b7661..aac07abb7 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 21d4a696b..c23f6b24f 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 28c4b8cde..0f4891714 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 c78cd35cb..b3f284b69 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 4f4605a14..a3f71d61e 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 54e653882..9efafe0ef 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 acc031bd5..fcb8bd2b3 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 7c212e929..fde70be92 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 cb6a6fe0a..dd91c1274 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 0f4891714..79d44265a 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 e57d28916..6eee2c0d3 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 70a5deef2..42aa90787 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 9214059d9..9362a15da 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 b98fe7cfd..834902a1f 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 a3f71d61e..e8e10f30b 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 d834d41e7..ef1da0a45 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 fd7947742..b14ca5172 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 f4672e98d..b3ed8f3aa 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 f298c4886..4449e74b0 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 f6d759e5a..549ff6639 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 8397ac2fe..a05bf4f0b 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 fcb8bd2b3..93983eeec 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 73883e38f..7f30c10b4 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 6c7d16e51..9d55097b4 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 7dfd9b674..a24afdf3b 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 27bc952ae..fa59d8554 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 da165133d..e7607eddf 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 6903280fa..a3d59cfcc 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 4b804621a..fc088e97b 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 64329e65c..dadc6e264 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 6660ec92b..8ac538e34 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 000000000..b329c1794 --- /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 9a9bd7920..ec1de2623 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 9c9629006..71f5b8671 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 ed087d3e3..1c719cdd9 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 403895764..4900c17ab 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 aa3d94812..b8f8a2e1b 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 25cc18a89..69dfafbbc 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 4e0e2d49e..ad9720514 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 e96529c00..16034d173 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 84f48cb4e..9d151edcc 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 07f83b0af..d31aa45ed 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 e1eb78843..2206bb099 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 aac07abb7..1278e9f98 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 fde70be92..807cd4083 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 dd91c1274..236f29efb 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 c23f6b24f..8256ba907 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 5986de2ac..d81b996c6 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 79d44265a..877420eb0 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 49a709cc9..0ed42a756 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 4d0217d70..f01343ca6 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 65355a6e0..1755ea6f7 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 637464e89..33a31f4e1 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 08704abbe..45861adb4 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 8a6d377d2..6bfbd34e4 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 9f6dcedfb..05668b797 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 c40143b9c..1916686ed 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 6eee2c0d3..c389b5659 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 2c0963347..1eab13954 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 c27e3a9e0..08c8d59fd 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 58d23b2fa..29124d5a9 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 07b31be44..a439e6cd0 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 08c8d59fd..0e4fed0e9 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 9efafe0ef..a49f81a4f 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 7f30c10b4..8fa811f49 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 e7607eddf..0c0367b43 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 a3d59cfcc..533152711 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 dadc6e264..4c5922d9e 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 f0f1c0dfa..7d02cf1fd 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 18c3a72b9..584701633 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 ec1de2623..ddc67e4e9 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 71f5b8671..ca95c3a6e 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 1c719cdd9..33d5f70a5 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 4900c17ab..feb371608 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 5e47dcc52..f309b8ba5 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 b8f8a2e1b..e5731a7ff 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 69dfafbbc..8a994f451 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 ad9720514..81c05812d 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 68f3c583c..3a7d60b3e 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 9d151edcc..5fa0dc862 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 d31aa45ed..ee4188b2d 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 2206bb099..bc6b753c7 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 a8ba6d3f6..d60d31d79 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 807cd4083..96fbaee72 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 236f29efb..9a3755957 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 d875d77d2..d991c6a17 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 61b759de7..16b7b01cf 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 000000000..57ab8f939 --- /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 b2309b6e7..000000000 --- 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 fc42f9009..95959a5e5 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 0e4fed0e9..2a3dc1a8e 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 af675f594..410a9cf6a 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 2f7a24c7c..7bc352205 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 2a3dc1a8e..728356ddc 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 b40e96956..5d314ba44 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 fa59d8554..e8087edc5 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 ca95c3a6e..0d89c2ae8 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 e35421c1b..ee64df2a0 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 515596213..908d464b0 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 4c5922d9e..5c7e0a24b 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 d81b996c6..5986de2ac 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 000000000..68b8ee62a --- /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 000000000..bbce5f8ee --- /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 57ab8f939..58f295247 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 1efe263fb..776ede2b9 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 12ad22c7a..d660dc1e7 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 9cc198558..919d3dc8f 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 42aa90787..bf09b9460 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 728356ddc..c4ee2e853 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 834902a1f..2fef7aaa4 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 e8e10f30b..2cacb7e1f 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 b3ed8f3aa..2c1a5417f 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 4449e74b0..0304b8042 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 a05bf4f0b..27de4e3c6 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 9d55097b4..ac5354901 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 a24afdf3b..f083cfc83 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 533152711..5693e2200 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 8ac538e34..6e8f47b6a 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 f309b8ba5..7ba9bf2d2 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 3eaded18c..b1afbd232 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 8256ba907..06020ef2f 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 1eab13954..6ace21ac5 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 877420eb0..6b0be8021 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 c4ee2e853..3d9a8e461 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 2c1a5417f..f0ba10374 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 4e165a5b7..a91e1138c 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 3d9a8e461..ec0f53911 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 f07bf2010..89d3718e1 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 3bfd3c569..60fbdc519 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 b878306ef..7604c11cf 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 85cdfda5a..6ffb2e51d 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,