diff --git a/composer.json b/composer.json index 6bac59e6..49b5e769 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,9 @@ "testcontainers/testcontainers": "^0.2", "nimut/phpunit-merger": "^2.0" }, + "conflict": { + "rector/rector": "2.3.0" + }, "config": { "preferred-install": { "*": "dist" @@ -79,7 +82,7 @@ }, "scripts": { "pre-commit": [ - "vendor/bin/phpcbf", + "vendor/bin/phpcbf -pn", "vendor/bin/phpcs -p", "vendor/bin/psalm", "vendor/bin/phpunit" diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 98ac0cb7..037920d7 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -3,6 +3,13 @@ declare(strict_types=1); /* + * | + * \ ___ / _________ + * _ / \ _ GÉANT | * * | Co-Funded by + * | ~ | Trust & Identity | * * | the European + * \_/ Incubator |__*_*__| Union + * = + * * This file is part of the simplesamlphp-module-oidc. * * Copyright (C) 2018 by the Spanish Research and Academic Network. @@ -13,80 +20,132 @@ 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\AtContextsEnum; +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. - * However, all the key values have been preserved from previous module versions. - */ $config = [ /** - * (optional) Issuer (OP) identifier which will be used as an issuer (iss) claim in tokens. If not set, it will - * fall back to current HTTP scheme, host and port number if no standard port is used. - * Description of issuer from OIDC Core specification: "Verifiable Identifier for an Issuer. An Issuer Identifier - * is a case-sensitive URL using the https scheme that contains scheme, host, and optionally, port number and - * path components and no query or fragment components." + * (optional) Issuer (OP) identifier that will be used as an issuer (iss) + * claim in tokens. If not set, it will fall back to the current HTTP + * scheme, host and port number if no standard port is used. + * Description of the issuer from OIDC Core specification: "Verifiable + * Identifier for an Issuer. An Issuer Identifier is a case-sensitive URL + * using the https scheme that contains scheme, host, and optionally, + * port number and path components and no query or fragment components." */ // ModuleConfig::OPTION_ISSUER => 'https://op.example.org', /** - * PKI (public / private key) settings related to OIDC protocol. These keys will be used, for example, to - * sign ID Token JWT. - */ - // (optional) The private key passphrase. -// ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret', - // The certificate and private key filenames, with given defaults. - ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, - - /** - * (optional) Key rollover settings related to OIDC protocol. If set, this new private / public key pair will only - * be published on JWKS endpoint as available, so Relying Parties can pick them up for future use. The signing - * of artifacts will still be done using the 'current' private key (settings above). After some time, when all - * RPs have fetched all public keys from JWKS endpoint, simply set these new keys as active values for above - * PKI options. + * Protocol (Connect) signature algorithm and key-pair definitions, + * representing supported algorithms for signing, for example, ID Token JWS. + * The order in which the entries are set is important. The entry set + * first will have higher priority during signing algorithm negotiation + * with the client. If the client doesn't designate the desired signing + * algorithm, the first algorithm in the list will be used for signing (the + * first entry represents the default algorithm and signing key). Note that + * the OpenID Connect specification designates `RS256` as the signing + * algorithm that should be used by default, so you would probably want + * to use that algorithm as the default (first) one. However, you are free + * to set other default (first) algorithm as needed. + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Just add those entries later in + * the list, so they can be published on the OP JWKS discovery endpoint. + * + * The format is an array of associative arrays, where each array value + * consists of the following properties (keys): + * - ModuleConfig::KEY_ALGORITHM - \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum case + * representing the algorithm. + * - ModuleConfig::KEY_PRIVATE_KEY_FILENAME - the name of the file + * containing a private key in PEM format, which is available in SSP `cert` + * folder. + * - ModuleConfig::KEY_PUBLIC_KEY_FILENAME - the name of the file containing + * the corresponding public key in PEM format, which is available in + * the SSP `cert` folder. + * - ModuleConfig::KEY_PRIVATE_KEY_PASSWORD - private key password, if + * needed. + * - ModuleConfig::KEY_KEY_ID - Optional string representing key identifier. + * Use if you need to manually set key identifiers to be published. If not + * set, a public key thumbprint will be generated on the fly and used as a + * key ID. + * + * Note: in v7 of the module, a new way of automatic key ID generation is + * used. In previous versions, a hash of a public key file was used as a + * key ID. In v7, a public key thumbprint is used. If you are migrating from + * a previous version of the module, and you want to keep the old signing + * key, you should manually set the key ID to the previous value + * so that clients know that the key did not change. */ -// // (optional) The (new) private key passphrase. -// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', -// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module.key', -// ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_rsa_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_rsa_01.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'rsa-connect-signing-key-2026', // Optional + ], + // Example for additionally supported ES256 algorithm with EC keys. + // Delete it if not needed: + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_ec_p256_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_ec_p256_01.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'ec-connect-signing-key-01', // Optional + ], + ], /** - * Token related options. + * Authorization code and tokens TTL (validity duration), with given + * examples. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php */ - // Authorization code and tokens TTL (validity duration), with given examples. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', // 10 minutes ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', // 1 month ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', // 1 hour, - // Token signer, with given default. - // See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer - ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, -// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, -// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, + /** + * (optional) Timestamp Validation Leeway - additional time tolerance + * allowed for timestamp validation. This is used when validating + * timestamps like Expiration Time (exp), Issued At (iat), Not + * Before (nbf), and similar claims on JWS artifacts. + * If not set, falls back to 'PT1M' (1 minute). + * + * For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php + */ + ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', /** - * Authentication related options. + * The default authentication source to be used for authentication if the + * authentication source is not specified on a particular client. */ - // The default authentication source to be used for authentication if the auth source is not specified on - // particular client. ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', - // The attribute name that contains the user identifier returned from IdP. By default, this attribute will be - // dynamically added to the 'sub' claim in the attribute-to-claim translation table (you will probably want - // to use this attribute as the 'sub' claim since it designates unique identifier for the user). + /** + * The attribute name that contains the user identifier returned from IdP. + * By default, this attribute will be dynamically added to the 'sub' + * claim in the attribute-to-claim translation table (you will probably want + * to use this attribute as the 'sub' claim since it designates a unique + * identifier for the user). + */ ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', - // The default translate table from SAML attributes to OIDC claims. If you don't want to support specific default - // claim, set it to an empty array. + /** + * The default translate table from SAML attributes to OIDC claims. If you + * don't want to support a specific default claim, set it to an empty array. + */ ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE => [ /* * The basic format is * * 'claimName' => [ * 'type' => 'string|int|bool|json', - * // For non JSON types + * // For non-JSON types * 'attributes' => ['samlAttribute1', 'samlAttribute2'] * // For JSON types * 'claims => [ @@ -94,13 +153,16 @@ $config = [ * ] * ] * - * For convenience the default type is "string" so type does not need to be defined. - * If "attributes" is not set, then it is assumed that the rest of the values are saml - * attribute names. + * For convenience the default type is "string" so the type does not + * need to be defined. If the "attributes" key is not set, then it is + * assumed that the rest of the values are SAML attribute names. * - * Note on 'sub' claim: by default, the list of attributes for 'sub' claim will also contain attribute defined - * in 'useridattr' setting. You will probably want to use this attribute as the 'sub' claim since it - * designates unique identifier for the user, However, override as necessary. + * Note on the 'sub' claim: by default, the list of attributes for 'sub' + * claim will also contain an attribute defined in the + * `ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE` setting. + * You will probably want to use this attribute as the 'sub' claim since + * it designates a unique identifier for the user. However, override as + * necessary. */ // 'sub' => [ // 'attribute-defined-in-useridattr', // will be dynamically added if the list for 'sub' claim is not set. @@ -132,7 +194,7 @@ $config = [ // 'description', // ], // 'picture' => [ -// // Empty. Previously 'jpegPhoto' however spec calls for a URL to photo, not an actual photo. +// // Empty. Previously 'jpegPhoto' however, spec calls for a URL to a photo, not an actual photo. // ], // 'website' => [ // // Empty @@ -160,7 +222,7 @@ $config = [ // 'type' => 'bool', // 'attributes' => [], // ], -// // address is a json object. Set the 'formatted' sub-claim to postalAddress +// // address is a JSON object. Set the 'formatted' subclaim to postalAddress // 'address' => [ // 'type' => 'json', // 'claims' => [ @@ -184,7 +246,10 @@ $config = [ // ], ], - // Optional custom scopes. You can create as many scopes as you want and assign claims to them. + /** + * Optional custom scopes. You can create as many scopes as you want and + * assign claims to them. + */ ModuleConfig::OPTION_AUTH_CUSTOM_SCOPES => [ // 'private' => [ // The key represents the scope name. // 'description' => 'private scope', @@ -194,13 +259,16 @@ $config = [ // ], ], - // Optional list of the Authentication Context Class References that this OP supports. - // If populated, this list will be available in OP discovery document (OP Metadata) as 'acr_values_supported'. - // @see https://datatracker.ietf.org/doc/html/rfc6711 - // @see https://www.iana.org/assignments/loa-profiles/loa-profiles.xhtml - // @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken (acr claim) - // @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values parameter) - // Syntax: string[] (array of strings) + /** + * Optional list of the Authentication Context Class References that this OP + * supports. If populated, this list will be available in the OP discovery + * document (OP Metadata) as 'acr_values_supported'. + * @see https://datatracker.ietf.org/doc/html/rfc6711 + * @see https://www.iana.org/assignments/loa-profiles/loa-profiles.xhtml + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken (acr claim) + * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest (acr_values parameter) + * Syntax: string[] (array of strings) + */ ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED => [ // 'https://refeds.org/assurance/profile/espresso', // 'https://refeds.org/assurance/profile/cappuccino', @@ -216,9 +284,13 @@ $config = [ // '...', ], - // If this OP supports ACRs, indicate which usable auth source supports which ACRs. - // Order of ACRs is important, more important ones being first. - // Syntax: array (array with auth source as key and value being array of ACR values as strings) + /** + * If this OP supports ACRs, indicate which usable auth source supports + * which ACRs. Order of ACRs is important, more important ones being first. + * Syntax: array (array with an auth source as a key and + * value being array of ACR values as strings) + */ + ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP => [ // 'example-userpass' => ['1', '0'], // 'default-sp' => ['http://id.incommon.org/assurance/bronze', '2', '1', '0'], @@ -234,67 +306,92 @@ $config = [ // ], ], - // If this OP supports ACRs, indicate if authentication using cookie should be forced to specific ACR value. - // If this option is set to null, no specific ACR will be forced for cookie authentication and the resulting ACR - // will be one of the ACRs supported on used auth source during authentication, that is, session creation. - // If this option is set to specific ACR, with ACR value being one of the ACR value this OP supports, it will be - // set to that ACR for cookie authentication. - // For example, OIDC Core Spec notes that authentication using a long-lived browser cookie is one example where - // the use of "level 0" is appropriate: + /** + * If this OP supports ACRs, indicate if authentication using cookie should + * be forced to specific ACR value. If this option is set to null, no + * specific ACR will be forced for cookie authentication, and the resulting + * ACR will be one of the ACRs supported on a used auth source during + * authentication, that is, session creation. If this option is set to + * a specific ACR, with ACR value being one of the ACR values this OP + * supports, it will be set to that ACR for cookie authentication. + * For example, OIDC Core Spec notes that authentication using a long-lived + * browser cookie is one example where the use of "level 0" is appropriate. + */ // ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => '0', ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => null, - // Choose if OP discovery document will include 'claims_supported' claim, which is recommended per OpenID Connect - // Discovery specification https://openid.net/specs/openid-connect-discovery-1_0.html. The list will include all - // claims for which "SAML attribute to OIDC claim translation" has been defined above. + /** + * Choose if an OP discovery document will include the 'claims_supported' + * claim, which is recommended per OpenID Connect Discovery specification + * https://openid.net/specs/openid-connect-discovery-1_0.html. The list will + * include all claims for which "SAML attribute to OIDC claim translation" + * has been defined above. + */ ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => false, - // Settings regarding Authentication Processing Filters. - // Note: OIDC authN state array will not contain all the keys which are available during SAML authN, - // like Service Provider metadata, etc. - // - // At the moment, the following SAML authN data will be available during OIDC authN in the sate array: - // - ['Attributes'], ['Authority'], ['AuthnInstant'], ['Expire'] - // Source and destination will have entity IDs corresponding to the OP issuer ID and Client ID respectively. - // - ['Source']['entityid'] - contains OpenId Provider issuer ID - // - ['Destination']['entityid'] - contains Relying Party (OIDC Client) ID - // In addition to that, the following OIDC related data will be available in the state array: - // - ['Oidc']['OpenIdProviderMetadata'] - contains information otherwise available from the OIDC configuration URL. - // - ['Oidc']['RelyingPartyMetadata'] - contains information about the OIDC client making the authN request. - // - ['Oidc']['AuthorizationRequestParameters'] - contains relevant authorization request query parameters. - // - // List of authproc filters which will run for every OIDC authN. Add filters as described in docs for SAML authproc - // @see https://simplesamlphp.org/docs/stable/simplesamlphp-authproc + /** + * Settings regarding Authentication Processing Filters. + * Note: An OIDC authN state array will not contain all the keys which are + * available during SAML authN, like Service Provider metadata, etc. + * + * At the moment, the following SAML authN data will be available during + * OIDC authN in the state array: + * - ['Attributes'], ['Authority'], ['AuthnInstant'], ['Expire'] + * Source and destination will have entity IDs corresponding to the OP + * issuer ID and Client ID respectively. + * - ['Source']['entityid'] - contains OpenId Provider issuer ID + * - ['Destination']['entityid'] - contains Relying Party (OIDC Client) ID. + * In addition to that, the following OIDC related data will be available + * in the state array: + * - ['Oidc']['OpenIdProviderMetadata'] - contains information otherwise + * available from the OIDC configuration URL. + * - ['Oidc']['RelyingPartyMetadata'] - contains information about the OIDC + * client making the authN request. + * - ['Oidc']['AuthorizationRequestParameters'] - contains relevant + * authorization request query parameters. + * + * List of authproc filters which will run for every OIDC authN. Add filters + * as described in docs for SAML authproc. + * @see https://simplesamlphp.org/docs/stable/simplesamlphp-authproc + */ ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS => [ // Add authproc filters here ], - // (optional) Dedicated OIDC protocol cache adapter, used to cache artifacts like access tokens, authorization - // codes, refresh tokens, client data, user data, etc. It will also be used for token reuse check in protocol - // context. Setting this option is recommended in production environments. If set to null, no caching will - // be used. Can be set to any Symfony Cache Adapter class, like in examples below. If set, make sure to - // also give proper adapter arguments for its instantiation below. - // @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + /** + * (optional) Dedicated OIDC protocol cache adapter, used to cache artifacts + * like access tokens, authorization codes, refresh tokens, client data, + * user data, etc. It will also be used for token reuse check in the + * protocol context. Setting this option is recommended in production + * environments. If set to null, no caching will be used. Can be set to + * any Symfony Cache Adapter class, like in the examples below. If set, + * make sure to also give proper adapter arguments for its instantiation + * below. + * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + */ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => null, // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\FilesystemAdapter::class, // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\MemcachedAdapter::class, - // Protocol cache adapter arguments used for adapter instantiation. Refer to documentation for particular - // adapter on which arguments are needed to create its instance, in the order of constructor arguments. - // See examples below. + /** + * Protocol cache adapter arguments used for adapter instantiation. Refer + * to documentation for a particular adapter on which arguments are needed + * to create its instance, in the order of constructor arguments. See + * examples below. + */ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // Adapter arguments here... ], // Example for FileSystemAdapter: // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // 'openidFederation', // Namespace, subdirectory of main cache directory -// 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) -// '/path/to/main/cache/directory' // Must be writable. Can be set to null to use system temporary directory. +// 60 * 60 * 6, // Default lifetime in seconds (used when a particular cache item doesn't define its own lifetime) +// '/path/to/main/cache/directory' // Must be writable. Can be set to null to use the system temporary directory. // ], // Example for MemcachedAdapter: // ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // // First argument is a connection instance, so we can use the helper method to create it. In this example a -// // single server is used. Refer to documentation on how to use multiple servers, and / or to provide other +// // single server is used. Refer to documentation on how to use multiple servers and / or to provide other // // options. // \Symfony\Component\Cache\Adapter\MemcachedAdapter::createConnection( // 'memcached://localhost' @@ -303,99 +400,160 @@ $config = [ // // 'memcached://localhost:11222?socket_recv_size=1&socket_send_size=2' // ), // 'openidProtocol', // Namespace, key prefix. -// 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) +// 60 * 60 * 6, // Default lifetime in seconds (used when a particular cache item doesn't define its own lifetime) // ], /** - * Protocol cache duration for particular entities. This is only relevant if protocol cache adapter is set up. - * For duration format info, check https://www.php.net/manual/en/dateinterval.construct.php. - */ - // Cache duration for user entities (authenticated users data). If not set, cache duration will be the same as - // session duration. + * Protocol cache duration for user entities (authenticated users data). + * If not set, cache duration will be the same as session duration. + * This is only relevant if a protocol cache adapter is set up. For duration + * format info, check + * https://www.php.net/manual/en/dateinterval.construct.php. + */ // ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => 'PT1H', // 1 hour ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, // Fallback to session duration - // Cache duration for client entities, with given default. + /** + * Protocol cache duration for client entities, with a given default. + * This is only relevant if a protocol cache adapter is set up. For duration + * format info, check + * https://www.php.net/manual/en/dateinterval.construct.php. + */ ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', // 10 minutes - // Cache duration for Authorization Code, Access Token, and Refresh Token will fall back to their TTL. + /** + * Note: cache duration for Authorization Code, Access Token, and Refresh + * Token will fall back to their TTL. + */ /** - * Cron related options. + * Cron tag used to run a storage cleanup script using the cron module. */ - // Cron tag used to run storage cleanup script using the cron module. ModuleConfig::OPTION_CRON_TAG => 'hourly', /** - * Admin backend UI related options. + * Permissions which let the module expose functionality to specific users. + * In the below configuration, a user's eduPersonEntitlement attribute is + * examined. If the user tries to do something that requires the 'client' + * permission (such as registering their own client), then they will need + * one of the eduPersonEntitlements from the `client` permission array. + * A permission can be disabled by commenting it out. */ - // Permissions which let the module expose functionality to specific users. In the below configuration, a user's - // eduPersonEntitlement attribute is examined. If the user tries to do something that requires the 'client' - // permission (such as registering their own client), then they will need one of the eduPersonEntitlements - // from the `client` permission array. A permission can be disabled by commenting it out. ModuleConfig::OPTION_ADMIN_UI_PERMISSIONS => [ // Attribute to inspect to determine user's permissions 'attribute' => 'eduPersonEntitlement', - // Which entitlements allow for registering, editing, delete a client. OIDC clients are owned by the creator + // Which entitlements allow for registering, editing, deleting a client? + // The creator owns OIDC clients 'client' => ['urn:example:oidc:manage:client'], ], - // Pagination options. + /** + * Pagination options, for example, on the client listing page. + */ ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => 20, + /*************************************************************************** + * (optional) OpenID Federation-related options. If these are not set, + * OpenID Federation capabilities will be disabled. + **************************************************************************/ + /** - * (optional) OpenID Federation related options. If these are not set, OpenID Federation capabilities will be - * disabled. + * Enable or disable federation capabilities. Default is disabled (false). */ - - // Enable or disable federation capabilities. Default is disabled (false). ModuleConfig::OPTION_FEDERATION_ENABLED => false, - // Trust Anchors which are valid for this entity. The key represents the Trust Anchor Entity ID, while the value can - // be the Trust Anchor's JWKS JSON object string value, or null. If JWKS is provided, it will be used to validate - // Trust Anchor Configuration Statement in addition to using JWKS acquired during Trust Chain resolution. If - // JWKS is not provided (value null), the validity of Trust Anchor Configuration Statement will "only" be - // validated by the JWKS acquired during Trust Chain resolution, meaning that security will rely "only" - // on protection implied from using TLS on endpoints used during Trust Chain resolution. + /** + * Federation signature algorithm and key-pair definitions, representing + * supported algorithms for signing, for example, Entity Statements. + * The first algorithm in the list will be used for signing (the + * first entry represents the default algorithm and signing key). + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Add those entries later in + * the list, so they can be published in Federation JWKS. + * + * Note that these keys SHOULD NOT be the same as the ones used in the + * protocol (Connect) itself. + * + * The format is the same as for the protocol (Connect) signature key pairs + * (option ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS) + */ + ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_federation_rsa_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_federation_rsa_01.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'rsa-federation-signing-key-01', // Optional + ], + ], + + /** + * Trust Anchors which are valid for this entity. The key represents the + * Trust Anchor Entity ID, while the value can be the Trust Anchor's JWKS + * JSON object string value, or null. If JWKS is provided, it will be used + * to validate Trust Anchor Configuration Statement in addition to using + * JWKS acquired during Trust Chain resolution. If JWKS is not provided + * (value null), the validity of Trust Anchor Configuration Statement will + * "only" be validated by the JWKS acquired during Trust Chain resolution, + * meaning that security will rely "only" on protection implied from using + * TLS on endpoints used during Trust Chain resolution. + */ ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [ // phpcs:ignore // 'https://ta.example.org/' => '{"keys":[{"kty": "RSA","alg": "RS256","use": "sig","kid": "Nzb...9Xs","e": "AQAB","n": "pnXB...ub9J"}]}', // 'https://ta2.example.org/' => null, ], - // Federation authority hints. An array of strings representing the Entity Identifiers of Intermediate Entities - // (or Trust Anchors). Required if this entity has a Superior entity above it. + /** + * Federation authority hints. An array of strings representing the Entity + * Identifiers of Intermediate Entities (or Trust Anchors). Required if + * this entity has a Superior entity above it. + */ ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ // 'https://intermediate.example.org/', ], - // (optional) Federation Trust Mark tokens. An array of tokens (signed JWTs), each representing a Trust Mark - // issued to this entity. This option is primarily intended for long-lasting or non-expiring tokens, so it - // is not necessary to dynamically fetch / refresh them. + /** + * (optional) Federation Trust Mark tokens. An array of tokens + * (signed JWTs), each representing a Trust Mark issued to this entity. + * This option is primarily intended for long-lasting or non-expiring + * tokens, so it is not necessary to dynamically fetch / refresh them. + */ ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS => [ // 'eyJ...GHg', ], - // (optional) Federation Trust Marks for dynamic fetching. An array of key-value pairs, where key is Trust Mark Type - // and value is Trust Mark Issuer ID, each representing a Trust Mark issued to this entity. Each Trust Mark Type - // in this array will be dynamically fetched from the noted Trust Mark Issuer as necessary. If federation - // caching is enabled (recommended), fetched Trust Marks will also be cached until their expiry. + /** + * (optional) Federation Trust Marks for dynamic fetching. An array of + * key-value pairs, where key is Trust Mark Type and value is Trust Mark + * Issuer ID, each representing a Trust Mark issued to this entity. Each + * Trust Mark Type in this array will be dynamically fetched from the noted + * Trust Mark Issuer as necessary. If federation caching is enabled + * (recommended), fetched Trust Marks will also be cached until their + * expiry. + */ ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS => [ // 'trust-mark-type' => 'trust-mark-issuer-id', ], - // (optional) Federation participation limit by Trust Marks. This is an array with the following format: - // [ - // 'trust-anchor-id' => [ - // 'limit-id' => [ - // 'trust-mark-type', - // 'trust-mark-type-2', - // ], - // ], - // ], - // Check example below on how this can be used. If federation participation limit is configured for particular - // Trust Anchor ID, at least one combination of "limit ID" => "trust mark list" should be defined. + /** + * (optional) Federation participation limit by Trust Marks. This is an + * array with the following format: + * [ + * 'trust-anchor-id' => [ + * 'limit-id' => [ + * 'trust-mark-type', + * 'trust-mark-type-2', + * ], + * ], + * ], + * Check the example below on how this can be used. If a federation + * participation limit is configured for a particular Trust Anchor ID, at + * least one combination of "limit ID" => "trust mark list" should be + * defined. + */ ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS => [ - // We are limiting federation participation using Trust Marks for 'https://ta.example.org/'. + // We are limiting federation participation using Trust Marks for + // 'https://ta.example.org/'. 'https://ta.example.org/' => [ // Entities must have (at least) one Trust Mark from the list below. \SimpleSAML\Module\oidc\Codebooks\LimitsEnum::OneOf->value => [ @@ -410,73 +568,73 @@ $config = [ ], ], - // (optional) Trust Mark Status Endpoint Usage Policy. Check the TrustMarkStatusEndpointUsagePolicyEnum for the - // available options. Default is RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, meaning that the - // Trust Mark Status Endpoint will be used to check the status of non-expiring Trust Marks if the - // Trust Mark Status Endpoint is provided by the Trust Mark Issuer. + /** + * (optional) Trust Mark Status Endpoint Usage Policy. Check the + * TrustMarkStatusEndpointUsagePolicyEnum for the available options. Default + * is RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, meaning that + * the Trust Mark Status Endpoint will be used to check the status of + * non-expiring Trust Marks if the Trust Mark Status Endpoint is provided + * by the Trust Mark Issuer. + */ ModuleConfig::OPTION_FEDERATION_TRUST_MARK_STATUS_ENDPOINT_USAGE_POLICY => \SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, - // (optional) Dedicated federation cache adapter, used to cache federation artifacts like trust chains, entity - // statements, etc. It will also be used for token reuse check in federation context. Setting this option is - // recommended in production environments. If set to null, no caching will be used. Can be set to any - // Symfony Cache Adapter class. If set, make sure to also give proper adapter arguments for its - // instantiation below. See examples for protocol cache adapter option. - // @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + /** + * (optional) Dedicated federation cache adapter, used to cache federation + * artifacts like trust chains, entity statements, etc. It will also be + * used for token reuse check in federation context. Setting this option is + * recommended in production environments. If set to null, no caching will + * be used. Can be set to any Symfony Cache Adapter class. If set, make + * sure to also give proper adapter arguments for its instantiation below. + * See examples for a protocol cache adapter option. + * @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters + */ ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => null, - // Federation cache adapter arguments used for adapter instantiation. Refer to documentation for particular - // adapter on which arguments are needed to create its instance, in the order of constructor arguments. - // See examples for protocol cache adapter option. + /** + * Federation cache adapter arguments used for adapter instantiation. Refer + * to documentation for a particular adapter on which arguments are needed + * to create its instance, in the order of constructor arguments. + * See examples for a protocol cache adapter option. + */ ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [ // Adapter arguments here... ], - // Maximum federation cache duration for fetched artifacts. Federation cache duration will typically be resolved - // based on the expiry of the fetched artifact. For example, when caching fetched entity statements, cache - // duration will be based on the 'exp' claim (expiration time). Since those claims are set by issuer (can - // be long), it could be desirable to limit the maximum time, so that items in cache get refreshed more - // regularly (and changes propagate more quickly). This is only relevant if federation cache adapter - // is set up. For duration format info, check https://www.php.net/manual/en/dateinterval.construct.php. - ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours - /** - * PKI settings related to OpenID Federation. These keys will be used, for example, to sign federation - * entity statements. Note that these keys SHOULD NOT be the same as the ones used in OIDC protocol itself. + * Maximum federation cache duration for fetched artifacts. Federation cache + * duration will typically be resolved based on the expiry of the fetched + * artifact. For example, when caching fetched entity statements, cache + * duration will be based on the 'exp' claim (expiration time). Since those + * claims are set by issuer (can be long), it could be desirable to limit + * the maximum time so that items in the cache get refreshed more regularly + * (and changes propagate more quickly). This is only relevant if a + * federation cache adapter is set up. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php. */ - // The federation private key passphrase (optional). -// ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret', - // The federation certificate and private key filenames, with given defaults. - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, + ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours /** - * (optional) Key rollover settings related to OpenID Federation. Check the OIDC protocol key rollover description - * on how this works. + * Federation entity statement duration which determines the Expiration Time + * (exp) claim set in entity statement JWSs published by this OP. If not + * set, a default of 1 day will be used. For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php */ - // The federation (new) private key passphrase (optional). -// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', -// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module_federation.key', -// ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', - - // Federation token signer, with given default. - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, - - // Federation entity statement duration which determines the Expiration Time (exp) claim set in entity - // statement JWSs published by this OP. If not set, default of 1 day will be used. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION => 'P1D', // 1 day - // Cache duration for federation entity statements produced by this OP. This can be used to avoid calculating JWS - // signature on every HTTP request for OP Configuration statement, Subordinate Statements... This is only - // relevant if federation cache adapter is set up. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php. + /** + * Cache duration for federation entity statements produced by this OP. + * This can be used to avoid calculating JWS signature on every HTTP request + * for an OP Configuration statement, Subordinate Statements... This is only + * relevant if a federation cache adapter is set up. For duration format + * info, check https://www.php.net/manual/en/dateinterval.construct.php. + */ ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED => 'PT2M', // 2 minutes - // Common federation entity parameters: - // https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + /** + * Common federation entity parameters: + * https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + */ ModuleConfig::OPTION_ORGANIZATION_NAME => null, ModuleConfig::OPTION_DISPLAY_NAME => null, ModuleConfig::OPTION_DESCRIPTION => null, @@ -490,9 +648,533 @@ $config = [ ModuleConfig::OPTION_POLICY_URI => null, ModuleConfig::OPTION_INFORMATION_URI => null, ModuleConfig::OPTION_ORGANIZATION_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_VCI_ENABLED => false, + + /** + * Verifiable Credential signature algorithm and key-pair definitions, + * representing supported algorithms for signing verifiable credentials. + * The first algorithm in the list will be used for signing (the + * first entry represents the default algorithm and signing key). + * You can also use this config option to advertise any (new) keys, for + * example, for key-rollover scenarios. Add those entries later in + * the list, so they can be published in appropriate JWKS. + * + * Note that these keys SHOULD NOT be the same as the ones used in the + * protocol (Connect) itself. + * + * The format is the same as for the protocol (Connect) signature key pairs + * (option ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS) + * + * NOTE: for the time being, only one key-pair is supported. + */ + ModuleConfig::OPTION_VCI_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::ES256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_vci_ec_p256_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_vci_ec_p256_01.pub', +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'ec-vci-signing-key-01', // Optional + ], + ], + + /** + * Allow or disallow non-registered clients to request verifiable + * credentials. Default is disallowed (false). + */ + ModuleConfig::OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS => 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_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS => [ + 'openid-credential-offer://', + ], + /** - * @deprecated In Draft-43 of OIDFed specification, metadata claim 'homepage_uri' has been renamed to - * 'organization_uri'. Use 'organization_uri' instead. + * (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_HOMEPAGE_URI => null, + ModuleConfig::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED => [ + // Sample for 'jwt_vc_json' format with notes about required and + // optional fields. + 'ResearchAndScholarshipCredentialJwtVcJson' => [ + // REQUIRED + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value, + // OPTIONAL + ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialJwtVcJson', + + // OPTIONAL + // cryptographic_binding_methods_supported + + // OPTIONAL - will be set / overridden to the protocol signing + // algorithm. + // credential_signing_alg_values_supported + + // OPTIONAL + // proof_types_supported + + // OPTIONAL + // cryptographic_binding_methods_supported + + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'ResearchAndScholarshipCredentialJwtVcJson', + ClaimsEnum::Locale->value => 'en-US', + + // OPTIONAL + // logo + + // OPTIONAL + ClaimsEnum::Description->value => 'Research and Scholarship Credential', + + // OPTIONAL + // background_color + + // OPTIONAL + // background_image + + // OPTIONAL + // text_color + ], + ], + + // OPTIONAL A.1.1.2. https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-vc-signed-as-a-jwt-not-usin + ClaimsEnum::Claims->value => [ + /** + * https://refeds.org/category/research-and-scholarship + * + * The R&S attribute bundle consists (abstractly) of the + * following required data elements: + * + * shared user identifier + * person name + * email address + * + * and one optional data element: + * + * affiliation + * + * where shared user identifier is a persistent, non-reassigned, + * non-targeted identifier defined to be either of the + * following: + * + * eduPersonPrincipalName (if non-reassigned) + * eduPersonPrincipalName + eduPersonTargetedID + * + * and where person name is defined to be either (or both) of + * the following: + * + * displayName + * givenName + sn + * + * and where email address is defined to be the mail attribute, + * and where affiliation is defined to be the + * eduPersonScopedAffiliation attribute. + * + * All of the above attributes are defined or referenced in the + * [eduPerson] specification. The specific naming and format of + * these attributes is guided by the protocol in use. For SAML + * 2.0 the [SAMLAttr] profile MUST be used. This specification + * may be extended to reference other protocol-specific + * formulations as circumstances warrant. + */ + [ + // REQUIRED + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName'], + // OPTIONAL + ClaimsEnum::Mandatory->value => true, + // OPTIONAL + ClaimsEnum::Display->value => [ + [ + // OPTIONAL + ClaimsEnum::Name->value => 'Principal Name', + // OPTIONAL + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonTargetedID'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Targeted ID', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'displayName'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Display Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'givenName'], + ClaimsEnum::Mandatory->value => false, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Given Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'sn'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Last Name', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'mail'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Email Address', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonScopedAffiliation'], + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Scoped Affiliation', + ClaimsEnum::Locale->value => LanguageTagsEnum::EnUs->value, + ], + ], + ], + ], + + // REQUIRED + ClaimsEnum::CredentialDefinition->value => [ + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + 'ResearchAndScholarshipCredentialJwtVcJson', + ], + ], + ], + + // Sample for 'dc+sd-jwt' format without notes about required and + // optional fields. + 'ResearchAndScholarshipCredentialDcSdJwt' => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::DcSdJwt->value, + 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', + ], + + 'ResearchAndScholarshipCredentialVcSdJwt' => [ + ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::VcSdJwt->value, + ClaimsEnum::Scope->value => 'ResearchAndScholarshipCredentialVcSdJwt', + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'ResearchAndScholarshipCredentialVcSdJwt', + ClaimsEnum::Locale->value => 'en-US', + ClaimsEnum::Description->value => 'Research and Scholarship Credential', + ], + ], + ClaimsEnum::Claims->value => [ + [ + ClaimsEnum::Path->value => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName'], + ClaimsEnum::Mandatory->value => true, + ClaimsEnum::Display->value => [ + [ + ClaimsEnum::Name->value => 'Principal Name', + 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, + ], + ], + ], + ], + + /** + * VCDM 2.0 context is REQUIRED for 'vc+sd-jwt' format. + */ + ClaimsEnum::AtContext->value => [ + AtContextsEnum::W3OrgNsCredentialsV2->value, + ], + + // REQUIRED + /** @see https://www.w3.org/TR/vc-data-model-2.0/#types */ + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + 'ResearchAndScholarshipCredentialVcSdJwt', + ], + ], + ], + + /** + * 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_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP => [ + 'ResearchAndScholarshipCredentialJwtVcJson' => [ + ['eduPersonPrincipalName' => [ClaimsEnum::Credential_Subject->value, 'eduPersonPrincipalName']], + ['eduPersonTargetedID' => [ClaimsEnum::Credential_Subject->value, 'eduPersonTargetedID']], + ['displayName' => [ClaimsEnum::Credential_Subject->value, 'displayName']], + ['givenName' => [ClaimsEnum::Credential_Subject->value, 'givenName']], + ['sn' => [ClaimsEnum::Credential_Subject->value, 'sn']], + ['mail' => [ClaimsEnum::Credential_Subject->value, 'mail']], + ['eduPersonScopedAffiliation' => [ClaimsEnum::Credential_Subject->value, 'eduPersonScopedAffiliation']], + ], + 'ResearchAndScholarshipCredentialDcSdJwt' => [ + ['eduPersonPrincipalName' => ['eduPersonPrincipalName']], + ['eduPersonTargetedID' => ['eduPersonTargetedID']], + ['displayName' => ['displayName']], + ['givenName' => ['givenName']], + ['sn' => ['sn']], + ['mail' => ['mail']], + ['eduPersonScopedAffiliation' => ['eduPersonScopedAffiliation']], + ], + 'ResearchAndScholarshipCredentialVcSdJwt' => [ + ['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']], + ], + ], + + /** + * (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_VCI_ISSUER_STATE_TTL => 'PT10M', // 10 minutes + + /** + * Map of authentication sources and user's email attribute names. This + * enables you to define a specific attribute name which contains the + * user's email address, per authentication source. This is used, for + * example, to send Transaction Code in the case of pre-authorized + * codes for Verifiable Credential Issuance. If not set, the default + * user's email attribute name will be used (see the option below). + * + * Format is: 'authentication-source-id' => 'email-attribute-name'. + */ + ModuleConfig::OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP => [ + 'example-auth-source-id' => 'mail', + ], + + /** + * The default name of the attribute which contains the user's email + * address. If not set, it will fall back to 'mail'. + */ + ModuleConfig::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME => 'mail', + + + /*************************************************************************** + * (optional) API-related options. + **************************************************************************/ + + /** + * (optional) Enable or disable API capabilities. Default is disabled + * (false). If API capabilities are enabled, you can enable or disable + * specific API endpoints as needed and set up API tokens to allow + * access to those endpoints. If API capabilities are disabled, all API + * endpoints will be inaccessible regardless of the settings for + * specific endpoints and API tokens. + * + */ + ModuleConfig::OPTION_API_ENABLED => false, + + /** + * (optional) API Enable VCI Credential Offer API endpoint. Default is + * disabled (false). Only relevant if API capabilities are enabled. + */ + ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => false, + + /** + * (optional) API Enable OAuth2 Token Introspection API endpoint. Default + * is disabled (false). Only relevant if API capabilities are enabled. + */ + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_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. +// ], +// 'strong-random-token-string-2' => [ +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All, // Gives access to all OAuth2-related endpoints. +// \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection, // Gives access to the token introspection endpoint. +// ], + ], ]; diff --git a/conformance-tests/conformance-rp-initiated-logout-ci.json b/conformance-tests/conformance-rp-initiated-logout-ci.json index 34cc99a9..24d3ef54 100644 --- a/conformance-tests/conformance-rp-initiated-logout-ci.json +++ b/conformance-tests/conformance-rp-initiated-logout-ci.json @@ -556,7 +556,7 @@ "xpath", "//*", 10, - "The JWT string is missing the Signature part", + "The algorithm \"none\" is not supported.", "update-image-placeholder" ] ] @@ -620,7 +620,7 @@ "xpath", "//*", 10, - "The token was not issued by the given issuers", + "Issuer claim", "update-image-placeholder" ] ] diff --git a/docker/apache-override.cf b/docker/apache-override.cf index 307eea9e..54e24c2d 100644 --- a/docker/apache-override.cf +++ b/docker/apache-override.cf @@ -1,6 +1,9 @@ RewriteEngine On RewriteRule ^/.well-known/openid-configuration(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/openid-configuration$1 [PT] RewriteRule ^/.well-known/openid-federation(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/openid-federation$1 [PT] +RewriteRule ^/.well-known/openid-credential-issuer(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/openid-credential-issuer$1 [PT] +RewriteRule ^/.well-known/oauth-authorization-server(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/oauth-authorization-server$1 [PT] +RewriteRule ^/.well-known/jwt-vc-issuer(.*) /${SSP_APACHE_ALIAS}module.php/oidc/.well-known/jwt-vc-issuer$1 [PT] # Leave Authorization header with Bearer tokens available in requests. # Solution 1: diff --git a/docker/conformance.sql b/docker/conformance.sql index 4194f9e8..1bbcf6cc 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -14,6 +14,23 @@ INSERT INTO oidc_migration_versions VALUES('20210916153400'); INSERT INTO oidc_migration_versions VALUES('20210916173400'); INSERT INTO oidc_migration_versions VALUES('20240603141400'); INSERT INTO oidc_migration_versions VALUES('20240605145700'); +INSERT INTO oidc_migration_versions VALUES('20240820132400'); +INSERT INTO oidc_migration_versions VALUES('20240828153300'); +INSERT INTO oidc_migration_versions VALUES('20240830153300'); +INSERT INTO oidc_migration_versions VALUES('20240902120000'); +INSERT INTO oidc_migration_versions VALUES('20240905120000'); +INSERT INTO oidc_migration_versions VALUES('20240906120000'); +INSERT INTO oidc_migration_versions VALUES('20250818163000'); +INSERT INTO oidc_migration_versions VALUES('20250908163000'); +INSERT INTO oidc_migration_versions VALUES('20250912163000'); +INSERT INTO oidc_migration_versions VALUES('20250913163000'); +INSERT INTO oidc_migration_versions VALUES('20250915163000'); +INSERT INTO oidc_migration_versions VALUES('20250916163000'); +INSERT INTO oidc_migration_versions VALUES('20250917163000'); +INSERT INTO oidc_migration_versions VALUES('20251021000001'); +INSERT INTO oidc_migration_versions VALUES('20251021000002'); +INSERT INTO oidc_migration_versions VALUES('20260109000001'); +INSERT INTO oidc_migration_versions VALUES('20260218163000'); CREATE TABLE oidc_user ( id VARCHAR(191) PRIMARY KEY NOT NULL, claims TEXT, @@ -43,30 +60,36 @@ CREATE TABLE oidc_client ( updated_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP NULL DEFAULT NULL, expires_at TIMESTAMP NULL DEFAULT NULL, - is_federated BOOLEAN NOT NULL DEFAULT false + is_generic BOOLEAN NOT NULL DEFAULT false, + extra_metadata TEXT NULL ); -- 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, NULL); +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, NULL); +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, NULL); +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, NULL); +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, NULL); CREATE TABLE oidc_access_token ( id VARCHAR(191) PRIMARY KEY NOT NULL, scopes TEXT, expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - user_id VARCHAR(191) NOT NULL, + user_id VARCHAR(191) NOT NULL, client_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, auth_code_id varchar(191) DEFAULT NULL, requested_claims TEXT NULL, - CONSTRAINT FK_43C1650EA76ED395 FOREIGN KEY (user_id) - REFERENCES oidc_user (id) ON DELETE CASCADE, - CONSTRAINT FK_43C1650E19EB6921 FOREIGN KEY (client_id) - REFERENCES oidc_client (id) ON DELETE CASCADE + flow_type CHAR(64) NULL, + authorization_details TEXT NULL, + bound_client_id TEXT NULL, + bound_redirect_uri TEXT NULL, + issuer_state TEXT NULL, + CONSTRAINT FK_43C1650EA76ED395 FOREIGN KEY (user_id) + REFERENCES oidc_user (id) ON DELETE CASCADE, + CONSTRAINT FK_43C1650E19EB6921 FOREIGN KEY (client_id) + REFERENCES oidc_client (id) ON DELETE CASCADE ); CREATE TABLE oidc_refresh_token ( - id VARCHAR(191) PRIMARY KEY NOT NULL, + id VARCHAR(191) PRIMARY KEY NOT NULL, expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, access_token_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, @@ -78,14 +101,20 @@ CREATE TABLE oidc_auth_code ( id VARCHAR(191) PRIMARY KEY NOT NULL, scopes TEXT, expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - user_id VARCHAR(191) NOT NULL, + user_id VARCHAR(191) NOT NULL, client_id VARCHAR(191) NOT NULL, is_revoked BOOLEAN NOT NULL DEFAULT false, redirect_uri TEXT NOT NULL, nonce TEXT NULL, + flow_type CHAR(64) DEFAULT NULL, + tx_code varchar(191) DEFAULT NULL, + authorization_details TEXT NULL, + bound_client_id TEXT NULL, + bound_redirect_uri TEXT NULL, + issuer_state TEXT NULL, CONSTRAINT FK_97D32CA7A76ED395 FOREIGN KEY (user_id) - REFERENCES oidc_user (id) ON DELETE CASCADE, + REFERENCES oidc_user (id) ON DELETE CASCADE, CONSTRAINT FK_97D32CA719EB6921 FOREIGN KEY (client_id) - REFERENCES oidc_client (id) ON DELETE CASCADE + REFERENCES oidc_client (id) ON DELETE CASCADE ); CREATE TABLE oidc_allowed_origin ( client_id varchar(191) NOT NULL, @@ -98,4 +127,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/docker/ssp/config-override.php b/docker/ssp/config-override.php index 0aa1e88f..29fdb306 100644 --- a/docker/ssp/config-override.php +++ b/docker/ssp/config-override.php @@ -11,7 +11,6 @@ 'database.dsn' => getenv('DB.DSN') ?: 'sqlite:/var/simplesamlphp/data/mydb.sq3', 'database.username' => getenv('DB.USERNAME') ?: 'user', 'database.password' => getenv('DB.PASSWORD') ?: 'password', - 'language.i18n.backend' => 'gettext/gettext', 'logging.level' => 7, - 'usenewui' => false, + ] + $config; \ No newline at end of file diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index bd16a2d7..e44f3049 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -21,7 +21,15 @@ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, +// ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'private-key-password', // Optional +// ModuleConfig::KEY_KEY_ID => 'rsa-connect-signing-key-2026', // Optional + ], + ], ModuleConfig::OPTION_AUTH_SOURCE => 'example-userpass', @@ -120,4 +128,16 @@ ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // Use defaults ], + + ModuleConfig::OPTION_API_ENABLED => true, + + ModuleConfig::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED => true, + + ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED => true, + + ModuleConfig::OPTION_API_TOKENS => [ + 'strong-random-token-string' => [ + \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. + ], + ], ]; diff --git a/docs/1-oidc.md b/docs/1-oidc.md index b4dcb14c..e1c12d98 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -13,8 +13,7 @@ Supported flows: ## Note on OpenID Federation (OIDFed) -OpenID Federation support is in draft, as is the -[specification](https://openid.net/specs/openid-federation-1_0). You can +OpenID Federation support is in draft phase. You can expect breaking changes in future releases related to OIDFed capabilities. OIDFed can be enabled or disabled in the module configuration. @@ -30,6 +29,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 @@ -58,3 +77,4 @@ Upgrading? See the [upgrade guide](6-oidc-upgrade.md). - Conformance tests: [OpenID Conformance](5-oidc-conformance.md) - Upgrading between versions: [Upgrade guide](6-oidc-upgrade.md) - Common questions: [FAQ](7-oidc-faq.md) +- API documentation: [API](8-api.md) diff --git a/docs/2-oidc-installation.md b/docs/2-oidc-installation.md index 1efe263f..001dfc60 100644 --- a/docs/2-oidc-installation.md +++ b/docs/2-oidc-installation.md @@ -22,8 +22,8 @@ cp modules/oidc/config/module_oidc.php.dist config/module_oidc.php ## 3. Configure the database -The module uses SimpleSAMLphp's database feature to store access and -refresh tokens, user data, and other artifacts. Edit `config/config.php` +The module uses SimpleSAMLphp's database feature to store Access and +Refresh tokens, user data, and other artifacts. Edit `config/config.php` and ensure at least the following parameters are set: ```php @@ -34,44 +34,135 @@ and ensure at least the following parameters are set: Note: SQLite, PostgreSQL, and MySQL are supported. -## 4. Create RSA key pairs +## 4. Create signature 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. +In order to sign JWS artifacts (ID Tokens, Entity Statements, Verifiable +Credentials, etc.), you must create a public / private key pair for each +signature algorithm that you want to support. You should use different +keys for protocol (Connect), Federation and Verifiable Credential (VCI) +operations. You must have at least one algorithm / key-pair for protocol +(Connect), and for Federation and VCI if you use those features. -Generate private keys without a passphrase: +### RSA key pair generation, for `RS256/384/512` and `PS256/384/512` algorithms + +Generate private keys without a password: + +```bash +openssl genrsa -out cert/oidc_module_connect_rsa_01.key 3072 +openssl genrsa -out cert/oidc_module_federation_rsa_01.key 3072 +openssl genrsa -out cert/oidc_module_vci_rsa_01.key 3072 +``` + +Generate private keys with a password: + +```bash +openssl genrsa -passout pass:somePassword -out cert/oidc_module_connect_rsa_01.key 3072 +openssl genrsa -passout pass:somePassword -out cert/oidc_module_federation_rsa_01.key 3072 +openssl genrsa -passout pass:somePassword -out cert/oidc_module_vci_rsa_01.key 3072 +``` + +Extract public keys: + +Without password: + +```bash +openssl rsa -in cert/oidc_module_connect_rsa_01.key -pubout -out cert/oidc_module_connect_rsa_01.pub +openssl rsa -in cert/oidc_module_federation_rsa_01.key -pubout -out cert/oidc_module_federation_rsa_01.pub +openssl rsa -in cert/oidc_module_vci_rsa_01.key -pubout -out cert/oidc_module_vci_rsa_01.pub +``` + +With a password: + +```bash +openssl rsa -in cert/oidc_module_connect_rsa_01.key -passin pass:somePassword -pubout -out cert/oidc_module_connect_rsa_01.pub +openssl rsa -in cert/oidc_module_federation_rsa_01.key -passin pass:somePassword -pubout -out cert/oidc_module_federation_rsa_01.pub +openssl rsa -in cert/oidc_module_vci_rsa_01.key -passin pass:somePassword -pubout -out cert/oidc_module_vci_rsa_01.pub +``` + +Enter algorithm, key file names, and a password (if used) in `config/module_oidc.php` accordingly. + +### EC key pair generation, per curve for different algorithms + +If you prefer to use Elliptic Curve Cryptography (ECC) instead of RSA. + +Generate private EC P‑256 keys without a password, usable for `ES256` algorithm: + +```bash +openssl ecparam -genkey -name prime256v1 -noout -out cert/oidc_module_connect_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 -noout -out cert/oidc_module_federation_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 -noout -out cert/oidc_module_vci_ec_p256_01.key +``` + +Generate private EC P‑256 keys with a password, usable for `ES256` algorithm: + +```bash +openssl ecparam -genkey -name prime256v1 | openssl ec -AES-128-CBC -passout pass:somePassword -out cert/oidc_module_connect_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 | openssl ec -AES-128-CBC -passout pass:somePassword -out cert/oidc_module_federation_ec_p256_01.key +openssl ecparam -genkey -name prime256v1 | openssl ec -AES-128-CBC -passout pass:somePassword -out cert/oidc_module_vci_ec_p256_01.key +``` + +Extract public keys: + +Without password: + +```bash +openssl ec -in cert/oidc_module_connect_ec_p256_01.key -pubout -out cert/oidc_module_connect_ec_p256_01.pub +openssl ec -in cert/oidc_module_federation_ec_p256_01.key -pubout -out cert/oidc_module_federation_ec_p256_01.pub +openssl ec -in cert/oidc_module_vci_ec_p256_01.key -pubout -out cert/oidc_module_vci_ec_p256_01.pub +``` + +With a password: + +```bash +openssl ec -in cert/oidc_module_connect_ec_p256_01.key -passin pass:somePassword -pubout -out cert/oidc_module_connect_ec_p256_01.pub +openssl ec -in cert/oidc_module_federation_ec_p256_01.key -passin pass:somePassword -pubout -out cert/oidc_module_federation_ec_p256_01.pub +openssl ec -in cert/oidc_module_vci_ec_p256_01.key -passin pass:somePassword -pubout -out cert/oidc_module_vci_ec_p256_01.pub +``` + +For other curves, replace the `-name` option value depending on which +algorithm you want to support: +- `-name secp384r1`: usable for `ES384` algorithm +- `-name secp521r1`: usable for `ES512` algorithm + +Enter algorithm, key file names, and a password (if used) in `config/module_oidc.php` accordingly. + +### Ed25519 key pair generation, for `EdDSA` algorithm + +Generate private keys without a password: ```bash -openssl genrsa -out cert/oidc_module.key 3072 -openssl genrsa -out cert/oidc_module_federation.key 3072 +openssl genpkey -algorithm ED25519 -out cert/oidc_module_connect_ed25519_01.key +openssl genpkey -algorithm ED25519 -out cert/oidc_module_federation_ed25519_01.key +openssl genpkey -algorithm ED25519 -out cert/oidc_module_vci_ed25519_01.key ``` -Generate private keys with a passphrase: +Generate private keys with a password: ```bash -openssl genrsa -passout pass:myPassPhrase -out cert/oidc_module.key 3072 -openssl genrsa -passout pass:myPassPhrase -out cert/oidc_module_federation.key 3072 +openssl genpkey -algorithm ED25519 -AES-128-CBC -pass pass:somePassword -out cert/oidc_module_connect_ed25519_01.key +openssl genpkey -algorithm ED25519 -AES-128-CBC -pass pass:somePassword -out cert/oidc_module_federation_ed25519_01.key +openssl genpkey -algorithm ED25519 -AES-128-CBC -pass pass:somePassword -out cert/oidc_module_vci_ed25519_01.key ``` Extract public keys: -Without passphrase: +Without password: ```bash -openssl rsa -in cert/oidc_module.key -pubout -out cert/oidc_module.crt -openssl rsa -in cert/oidc_module_federation.key -pubout -out cert/oidc_module_federation.crt +openssl pkey -in cert/oidc_module_connect_ed25519_01.key -pubout -out cert/oidc_module_connect_ed25519_01.pub +openssl pkey -in cert/oidc_module_federation_ed25519_01.key -pubout -out cert/oidc_module_federation_ed25519_01.pub +openssl pkey -in cert/oidc_module_vci_ed25519_01.key -pubout -out cert/oidc_module_vci_ed25519_01.pub ``` -With a passphrase: +With a password: ```bash -openssl rsa -in cert/oidc_module.key -passin pass:myPassPhrase -pubout -out cert/oidc_module.crt -openssl rsa -in cert/oidc_module_federation.key -passin pass:myPassPhrase -pubout -out cert/oidc_module_federation.crt +openssl pkey -in cert/oidc_module_connect_ed25519_01.key -passin pass:somePassword -pubout -out cert/oidc_module_connect_ed25519_01.pub +openssl pkey -in cert/oidc_module_federation_ed25519_01.key -passin pass:somePassword -pubout -out cert/oidc_module_federation_ed25519_01.pub +openssl pkey -in cert/oidc_module_vci_ed25519_01.key -passin pass:somePassword -pubout -out cert/oidc_module_vci_ed25519_01.pub ``` -If you use different file names or a passphrase, update -`config/module_oidc.php` accordingly. +Enter algorithm, key file names, and a password (if used) in `config/module_oidc.php` accordingly. ## 5. Enable the module diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index 12ad22c7..d660dc1e 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -54,6 +54,12 @@ There you can see discovery URLs. Typical discovery endpoints are: [https://yourserver/simplesaml/module.php/oidc/.well-known/openid-configuration](https://yourserver/simplesaml/module.php/oidc/.well-known/openid-configuration) - OpenID Federation configuration: [https://yourserver/simplesaml/module.php/oidc/.well-known/openid-federation](https://yourserver/simplesaml/module.php/oidc/.well-known/openid-federation) +- OpenID for Verifiable Credential Issuance configuration: +[https://yourserver/simplesaml/module.php/oidc/.well-known/openid-credential-issuer](https://yourserver/simplesaml/module.php/oidc/.well-known/openid-credential-issuer) +- OAuth2 Authorization Server configuration: +[https://yourserver/simplesaml/module.php/oidc/.well-known/oauth-authorization-server](https://yourserver/simplesaml/module.php/oidc/.well-known/oauth-authorization-server) +- JWT VC Issuer configuration: +[https://yourserver/simplesaml/module.php/oidc/.well-known/jwt-vc-issuer](https://yourserver/simplesaml/module.php/oidc/.well-known/jwt-vc-issuer) You may publish these as ".well-known" URLs at the web root using your web server. For example, for `openid-configuration`: diff --git a/docs/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 8567c86a..7e248fdf 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -1,8 +1,118 @@ # OIDC Module - Upgrade guide -This is an upgrade guide from versions 1 → 6. Review the changes and +This is an upgrade guide from versions 1 → 7. Review the changes and apply those relevant to your deployment. +In general, when upgrading any of the SimpleSAMLphp modules or the +SimpleSAMLphp instance itself, you should clear the SimpleSAMLphp +cache after the upgrade. In newer versions of SimpleSAMLphp, the +following command is available to do that: + +```shell +composer clear-symfony-cache +``` + +## Version 6 to 7 + +As the database schema has been updated, you will have to run the DB migrations +to bring your local database schema up to date. + +New features: + +- Instance can now be configured to support multiple algorithms and signature +keys for protocol (Connect), Federation, and VCI purposes. This was introduced +to support signature algorithm negotiation with the clients. +- Clients can now be configured with new properties: + - ID Token Signing Algorithm (`id_token_signed_response_alg`) +- Optional OAuth2 Token Introspection endpoint, as per RFC7662. Check the API +documentation for more details. +- Initial support for OpenID for Verifiable Credential Issuance +(OpenID4VCI). Note that the implementation is experimental. You should not use +it in production. + +New configuration options: + +- `ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` - (required) enables +defining multiple protocol (Connect) related signing algorithms and key pairs. +- `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` - (required if +federation capabilities are enabled) enables defining multiple key pairs for +Federation purposes like signing Entity Statements, publishing new key for +key roll-ower scenarios, etc. +- `ModuleConfig::OPTION_VCI_SIGNATURE_KEY_PAIRS` - (required if VCI +capabilities are enabled) enables defining multiple key pairs for +VCI purposes like signing Verifiable Credentials, publishing new key for +key roll-ower scenarios, etc. +- `ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY` - optional, used for +setting allowed time tolerance for timestamp validation in artifacts like JWSs. +multiple Federation-related signing algorithms and key pairs. +- `ModuleConfig::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED` - +optional, enables the OAuth2 token introspection endpoint as per RFC7662. +- Several new options regarding experimental support for OpenID4VCI. + +Major impact changes: + +- The following configuration options related to the protocol (Connect) +signature algorithm and key pair are removed: + - `ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME` + - `ModuleConfig::OPTION_TOKEN_SIGNER` + - `ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME` + + Instead of those options, now you must use option + `ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` in which you can define + all supported signature keys for protocol (Connect) purposes. +- The following configuration options related to Federation signature algorithm +and key pair are removed: + - `ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME` + - `ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER` + - `ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE` + - `ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME` + - `ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME` + + Instead of those options, now you must use option + `ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS` in which you can define + all the supported signature keys for Federation purposes. +- Config option `ModuleConfig::OPTION_HOMEPAGE_URI` is removed. Use +`ModuleConfig::OPTION_ORGANIZATION_URI` instead. +- New algorithm for generating Key ID claim value (`kid`) for signature keys +is used. Previously, key ID was based on public key file hash. In v7, key ID +is a thumbprint of the public key as per +https://datatracker.ietf.org/doc/html/rfc7638. If you want to keep using your +current signature keys, you will probably want to keep the old `kid` values, +so that the clients know the keys did not change. You can set the old +`kid` value manually for signature keys in +`ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS` and +`ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS`. Once you do a key +roll-over, you can omit setting the `kid` manually, so you start using the +automatically generated thumbprint. +- In v6 of the module, when defining custom scopes, there was a possibility to +use standard claims with the 'are_multiple_claim_values_allowed' option. +This would allow multiple values (array of values) for standard claims which +have a single value by specification. All [standard claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) +are now hardcoded to have a single value, even when the +'are_multiple_claim_values_allowed' option is enabled. +- OpenID Federation specific endpoints for subordinate listing and fetching +statements about subordinates are removed, as the final specification +explicitly states that leaf entities must not have those endpoints. +This effectively means that this OP implementation can only be a leaf entity +in the federation context, and not a federation operator or intermediary entity. + +Medium impact changes: + +Low-impact changes: +- Client property `is_federated` has been removed, as the OP implementation +can now only be a leaf entity in the federation context, and not a federation +operator or intermediary entity. Previously, this property was used to +indicate whether the client is a federated client or not, but now it is not +needed since the OP implementation can only be a leaf entity +- Admin menu item "OIDC" has been renamed to "OIDC OP" to better reflect +the main purpose of the module. + ## Version 6.3 to 6.4 This is a minor release in order to enable installation of the module with @@ -39,7 +149,7 @@ find appropriate. - Entity Identifier - Supported OpenID Federation Registration Types - Federation JWKS - - Protocol JWKS, JWKS URI and Signed JWKS URI, + - Protocol JWKS, JWKS URI, and Signed JWKS URI, - Registration type (manual, federated_automatic, or other in the future) - Is Federated flag (indicates participation in federation context) - Timestamps: created_at, updated_at, expires_at diff --git a/docs/8-api.md b/docs/8-api.md new file mode 100644 index 00000000..7444dc31 --- /dev/null +++ b/docs/8-api.md @@ -0,0 +1,260 @@ +# 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. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2All`: Access to all OAuth2-related endpoints. +* `\SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::OAuth2TokenIntrospection`: Access to the OAuth2 token introspection 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\"}}}}" +} +``` + +### Token Introspection + +Enables token introspection for OAuth2 access tokens and refresh tokens as per +[RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662). + +#### Path + +`/api/oauth2/token-introspection` + +#### Method + +`POST` + +#### Authorization + +Access is granted if: +* The client is authenticated using one of the supported OAuth2 client +authentication methods (Basic, Post, Private Key JWT, Bearer). +* Or, if the request is authorized using an API Bearer Token with +the appropriate scope. + +#### Request + +The request is sent with `application/x-www-form-urlencoded` encoding with the +following parameters: + +* __token__ (string, mandatory): The string value of the token. +* __token_type_hint__ (string, optional): A hint about the type of the +token submitted for introspection. Allowed values: + * `access_token` + * `refresh_token` + +#### Response + +The response is a JSON object with the following fields: + +* __active__ (boolean, mandatory): Indicator of whether or not the presented +token is currently active. +* __scope__ (string, optional): A JSON string containing a space-separated +list of scopes associated with this token. +* __client_id__ (string, optional): Client identifier for the OAuth 2.0 client +that requested this token. +* __token_type__ (string, optional): Type of the token as defined in OAuth 2.0. +* __exp__ (integer, optional): Expiration time. +* __iat__ (integer, optional): Issued at time. +* __nbf__ (integer, optional): Not before time. +* __sub__ (string, optional): Subject identifier for the user who +authorized the token. +* __aud__ (string/array, optional): Audience for the token. +* __iss__ (string, optional): Issuer of the token. +* __jti__ (string, optional): Identifier for the token. + +If the token is not active, only the `active` field with a value of +`false` is returned. + +#### Sample 1 + +Introspect an active access token using an API Bearer Token. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/oauth2/token-introspection' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Bearer ***' \ +--data-urlencode 'token=access-token-string' +``` + +Response: + +```json +{ + "active": true, + "scope": "openid profile email", + "client_id": "test-client", + "token_type": "Bearer", + "exp": 1712662800, + "iat": 1712659200, + "sub": "user-id", + "aud": "test-client", + "iss": "https://idp.mivanci.incubator.hexaa.eu", + "jti": "token-id" +} +``` + +#### Sample 2 + +Introspect a refresh token using an API Bearer Token. + +Request: + +```shell +curl --location 'https://idp.mivanci.incubator.hexaa.eu/ssp/module.php/oidc/api/oauth2/token-introspection' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Authorization: Bearer ***' \ +--data-urlencode 'token=refresh-token-string' \ +--data-urlencode 'token_type_hint=refresh_token' +``` + +Response: + +```json +{ + "active": true, + "scope": "openid profile", + "client_id": "test-client", + "exp": 1715251200, + "sub": "user-id", + "aud": "test-client", + "jti": "refresh-token-id" +} +``` diff --git a/hooks/hook_adminmenu.php b/hooks/hook_adminmenu.php index 1ef3cee5..ea0e4431 100644 --- a/hooks/hook_adminmenu.php +++ b/hooks/hook_adminmenu.php @@ -21,7 +21,7 @@ function oidc_hook_adminmenu(Template &$template): void $oidcMenuEntry = [ ModuleConfig::MODULE_NAME => [ 'url' => $moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value), - 'name' => Translate::noop('OIDC'), + 'name' => Translate::noop('OIDC OP'), ], ]; diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index cb57e66d..f1520849 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -18,6 +18,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\Container; @@ -64,6 +65,10 @@ function oidc_hook_cron(array &$croninfo): void $refreshTokenRepository = $container->get(RefreshTokenRepository::class); $refreshTokenRepository->removeExpired(); + /** @var \SimpleSAML\Module\oidc\Repositories\IssuerStateRepository $issuerStateRepository */ + $issuerStateRepository = $container->get(IssuerStateRepository::class); + $issuerStateRepository->removeInvalid(); + $croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.'; } catch (Exception $e) { $message = 'Module `oidc` clean up cron script failed: ' . $e->getMessage(); diff --git a/public/assets/js/src/test-verifiable-credential-issuance.js b/public/assets/js/src/test-verifiable-credential-issuance.js new file mode 100644 index 00000000..24a45824 --- /dev/null +++ b/public/assets/js/src/test-verifiable-credential-issuance.js @@ -0,0 +1,25 @@ +(function () { + 'use strict'; + + // Handle option changes based on Grant Type + function togglePreAuthorizedCodeOptions() { + if (grantTypeSelect.value === "urn:ietf:params:oauth:grant-type:pre-authorized_code") { + useTxCodeCheckbox.disabled = false; + usersEmailAttributeNameInput.disabled = false; + } else { + useTxCodeCheckbox.disabled = true; + useTxCodeCheckbox.checked = false; + usersEmailAttributeNameInput.disabled = true; + } + } + + const grantTypeSelect = document.getElementById("grantType"); + + // Get references to options + const useTxCodeCheckbox = document.getElementById("useTxCode"); + const usersEmailAttributeNameInput = document.getElementById("usersEmailAttributeName"); + + grantTypeSelect.addEventListener("change", togglePreAuthorizedCodeOptions); + + togglePreAuthorizedCodeOptions(); +})(); \ No newline at end of file diff --git a/routing/routes/routes.php b/routing/routes/routes.php index caad53c7..29bad41b 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -10,14 +10,21 @@ use SimpleSAML\Module\oidc\Controllers\AccessTokenController; use SimpleSAML\Module\oidc\Controllers\Admin\ClientController; use SimpleSAML\Module\oidc\Controllers\Admin\ConfigController; -use SimpleSAML\Module\oidc\Controllers\Admin\TestController; +use SimpleSAML\Module\oidc\Controllers\Admin\FederationTestController; +use SimpleSAML\Module\oidc\Controllers\Admin\VerifiableCredentailsTestController; +use SimpleSAML\Module\oidc\Controllers\Api\VciCredentialOfferApiController; use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; -use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController; use SimpleSAML\Module\oidc\Controllers\JwksController; +use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; +use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController; 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\Module\oidc\Controllers\VerifiableCredentials\NonceController; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -38,6 +45,8 @@ ->controller([ConfigController::class, 'protocolSettings']); $routes->add(RoutesEnum::AdminConfigFederation->name, RoutesEnum::AdminConfigFederation->value) ->controller([ConfigController::class, 'federationSettings']); + $routes->add(RoutesEnum::AdminConfigVerifiableCredential->name, RoutesEnum::AdminConfigVerifiableCredential->value) + ->controller([ConfigController::class, 'verifiableCredentialSettings']); // Client management @@ -62,11 +71,16 @@ // Testing $routes->add(RoutesEnum::AdminTestTrustChainResolution->name, RoutesEnum::AdminTestTrustChainResolution->value) - ->controller([TestController::class, 'trustChainResolution']) + ->controller([FederationTestController::class, 'trustChainResolution']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); $routes->add(RoutesEnum::AdminTestTrustMarkValidation->name, RoutesEnum::AdminTestTrustMarkValidation->value) - ->controller([TestController::class, 'trustMarkValidation']) + ->controller([FederationTestController::class, 'trustMarkValidation']) ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + $routes->add( + RoutesEnum::AdminTestVerifiableCredentialIssuance->name, + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + )->controller([VerifiableCredentailsTestController::class, 'verifiableCredentialIssuance']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); /***************************************************************************************************************** * OpenID Connect @@ -86,6 +100,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 ****************************************************************************************************************/ @@ -94,11 +115,43 @@ ->controller([EntityStatementController::class, 'configuration']) ->methods([HttpMethodsEnum::GET->value]); - $routes->add(RoutesEnum::FederationFetch->name, RoutesEnum::FederationFetch->value) - ->controller([EntityStatementController::class, 'fetch']) + /***************************************************************************************************************** + * OpenID for Verifiable Credential Issuance + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::CredentialIssuerConfiguration->name, RoutesEnum::CredentialIssuerConfiguration->value) + ->controller([CredentialIssuerConfigurationController::class, 'configuration']) ->methods([HttpMethodsEnum::GET->value]); - $routes->add(RoutesEnum::FederationList->name, RoutesEnum::FederationList->value) - ->controller([SubordinateListingsController::class, 'list']) + $routes->add(RoutesEnum::CredentialIssuerCredential->name, RoutesEnum::CredentialIssuerCredential->value) + ->controller([CredentialIssuerCredentialController::class, 'credential']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); + + $routes->add(RoutesEnum::CredentialIssuerNonce->name, RoutesEnum::CredentialIssuerNonce->value) + ->controller([NonceController::class, 'nonce']) + ->methods([HttpMethodsEnum::POST->value]); + + /***************************************************************************************************************** + * SD-JWT-based Verifiable Credentials (SD-JWT VC) + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::JwtVcIssuerConfiguration->name, RoutesEnum::JwtVcIssuerConfiguration->value) + ->controller([JwtVcIssuerConfigurationController::class, 'configuration']) ->methods([HttpMethodsEnum::GET->value]); + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + $routes->add( + RoutesEnum::ApiVciCredentialOffer->name, + RoutesEnum::ApiVciCredentialOffer->value, + )->controller([VciCredentialOfferApiController::class, 'credentialOffer']) + ->methods([HttpMethodsEnum::POST->value]); + + $routes->add( + RoutesEnum::ApiOAuth2TokenIntrospection->name, + RoutesEnum::ApiOAuth2TokenIntrospection->value, + )->controller(TokenIntrospectionController::class) + ->methods([HttpMethodsEnum::POST->value]); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index 60f08be9..84054d20 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -60,9 +60,12 @@ services: factory: ['@SimpleSAML\Module\oidc\Factories\Grant\ImplicitGrantFactory', 'build'] SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant: factory: ['@SimpleSAML\Module\oidc\Factories\Grant\RefreshTokenGrantFactory', 'build'] + SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant: + factory: ['@SimpleSAML\Module\oidc\Factories\Grant\PreAuthCodeGrantFactory', 'build'] + # Responses - SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse: - factory: ['@SimpleSAML\Module\oidc\Factories\IdTokenResponseFactory', 'build'] + SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse: + factory: ['@SimpleSAML\Module\oidc\Factories\TokenResponseFactory', 'build'] oidc.key.private: class: League\OAuth2\Server\CryptKey @@ -72,30 +75,20 @@ services: class: League\OAuth2\Server\CryptKey factory: ['@SimpleSAML\Module\oidc\Factories\CryptKeyFactory', 'buildPublicKey'] - SimpleSAML\Module\oidc\Factories\ResourceServerFactory: - arguments: - $publicKey: '@oidc.key.public' SimpleSAML\Module\oidc\Factories\AuthorizationServerFactory: arguments: $privateKey: '@oidc.key.private' - SimpleSAML\Module\oidc\Factories\IdTokenResponseFactory: - arguments: - $privateKey: '@oidc.key.private' - SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory: + SimpleSAML\Module\oidc\Factories\TokenResponseFactory: arguments: $privateKey: '@oidc.key.private' - SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator: - arguments: - $publicKey: '@oidc.key.public' + SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory: ~ + SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator: ~ + SimpleSAML\Module\oidc\Server\ResourceServer: ~ SimpleSAML\Module\oidc\Server\AuthorizationServer: factory: ['@SimpleSAML\Module\oidc\Factories\AuthorizationServerFactory', 'build'] - # OAuth2 Server - League\OAuth2\Server\ResourceServer: - factory: ['@SimpleSAML\Module\oidc\Factories\ResourceServerFactory', 'build'] - # Utils SimpleSAML\Module\oidc\Utils\Debug\ArrayLogger: ~ SimpleSAML\Module\oidc\Utils\FederationParticipationValidator: ~ @@ -103,6 +96,7 @@ services: SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~ SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ + SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver: ~ SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor: factory: ['@SimpleSAML\Module\oidc\Factories\ClaimTranslatorExtractorFactory', 'build'] SimpleSAML\Module\oidc\Utils\FederationCache: @@ -125,8 +119,15 @@ services: factory: [ '@SimpleSAML\Module\oidc\Factories\CoreFactory', 'build' ] SimpleSAML\OpenID\Federation: factory: [ '@SimpleSAML\Module\oidc\Factories\FederationFactory', 'build' ] + SimpleSAML\OpenID\VerifiableCredentials: + factory: [ '@SimpleSAML\Module\oidc\Factories\VerifiableCredentialsFactory', 'build' ] SimpleSAML\OpenID\Jwks: factory: [ '@SimpleSAML\Module\oidc\Factories\JwksFactory', 'build' ] + SimpleSAML\OpenID\Jwk: ~ + SimpleSAML\OpenID\Did: ~ + SimpleSAML\OpenID\Jws: + factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ] + # SSP SimpleSAML\Database: diff --git a/src/Admin/Authorization.php b/src/Admin/Authorization.php index 2f7a24c7..7bc35220 100644 --- a/src/Admin/Authorization.php +++ b/src/Admin/Authorization.php @@ -9,17 +9,20 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Services\AuthContextService; +use SimpleSAML\Module\oidc\Services\LoggerService; class Authorization { public function __construct( protected readonly SspBridge $sspBridge, protected readonly AuthContextService $authContextService, + protected readonly LoggerService $loggerService, ) { } public function isAdmin(): bool { + $this->loggerService->debug('Authorization::isAdmin'); return $this->sspBridge->utils()->auth()->isAdmin(); } @@ -28,10 +31,19 @@ public function isAdmin(): bool */ public function requireAdmin(bool $forceAdminAuthentication = false): void { + $this->loggerService->debug('Authorization::requireAdmin'); + $this->loggerService->debug( + 'Authorization: Force admin authentication:', + ['forceAdminAuthentication' => $forceAdminAuthentication], + ); if ($forceAdminAuthentication) { + $this->loggerService->debug('Authorization: Forcing admin authentication.'); try { $this->sspBridge->utils()->auth()->requireAdmin(); } catch (Exception $exception) { + $this->loggerService->error( + 'Authorization: Forcing admin authentication failed: ' . $exception->getMessage(), + ); throw new AuthorizationException( Translate::noop('Unable to initiate SimpleSAMLphp admin authentication.'), $exception->getCode(), @@ -41,7 +53,10 @@ public function requireAdmin(bool $forceAdminAuthentication = false): void } if (! $this->isAdmin()) { + $this->loggerService->error('Authorization: User is NOT admin.'); throw new AuthorizationException(Translate::noop('SimpleSAMLphp admin access required.')); + } else { + $this->loggerService->debug('Authorization: User is admin.'); } } @@ -50,16 +65,29 @@ public function requireAdmin(bool $forceAdminAuthentication = false): void */ public function requireAdminOrUserWithPermission(string $permission): void { + $this->loggerService->debug('Authorization::requireAdminOrUserWithPermission'); + $this->loggerService->debug('Authorization: For permission: ' . $permission); + if ($this->isAdmin()) { + $this->loggerService->debug('Authorization: User is admin, returning.'); return; + } else { + $this->loggerService->debug('Authorization: User is not (authenticated as) admin.'); } try { + $this->loggerService->debug('Authorization: Checking for user permission.'); $this->authContextService->requirePermission($permission); - } catch (\Exception) { - // TODO mivanci v7 log this exception + $this->loggerService->debug('Authorization: User has permission, returning.'); + return; + } catch (\Exception $exception) { + $this->loggerService->warning( + 'Authorization: User permission check failed: ' . $exception->getMessage(), + ); } + $this->loggerService->debug('Authorization: Falling back to admin authentication.'); + // If we get here, the user does not have the required permission, or permissions are not enabled. // Fallback to admin authentication. $this->requireAdmin(true); diff --git a/src/Bridges/OAuth2Bridge.php b/src/Bridges/OAuth2Bridge.php new file mode 100644 index 00000000..0eebec4d --- /dev/null +++ b/src/Bridges/OAuth2Bridge.php @@ -0,0 +1,66 @@ +moduleConfig->getEncryptionKey(); + + try { + return $encryptionKey instanceof Key ? + Crypto::encrypt($unencryptedData, $encryptionKey) : + Crypto::encryptWithPassword($unencryptedData, $encryptionKey); + } catch (\Exception $e) { + throw new OidcException('Error encrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * Bridge `decrypt` function, which can be used instead of + * \League\OAuth2\Server\CryptTrait::decrypt() + * + * @param string $encryptedData + * @param Key|string $encryptionKey + * @return string + * @throws OidcException + */ + public function decrypt( + string $encryptedData, + null|Key|string $encryptionKey = null, + ): string { + $encryptionKey ??= $this->moduleConfig->getEncryptionKey(); + + try { + return $encryptionKey instanceof Key ? + Crypto::decrypt($encryptedData, $encryptionKey) : + Crypto::decryptWithPassword($encryptedData, $encryptionKey); + } catch (\Exception $e) { + throw new OidcException('Error decrypting data: ' . $e->getMessage(), (int)$e->getCode(), $e); + } + } +} diff --git a/src/Codebooks/ApiScopesEnum.php b/src/Codebooks/ApiScopesEnum.php new file mode 100644 index 00000000..b87ba692 --- /dev/null +++ b/src/Codebooks/ApiScopesEnum.php @@ -0,0 +1,18 @@ + true, + default => false, + }; + } + + public function isVciFlow(): bool + { + return match ($this) { + self::VciAuthorizationCode, self::VciPreAuthorizedCode => true, + default => false, + }; + } +} diff --git a/src/Codebooks/ParametersEnum.php b/src/Codebooks/ParametersEnum.php index bb8630ad..7bf5395c 100644 --- a/src/Codebooks/ParametersEnum.php +++ b/src/Codebooks/ParametersEnum.php @@ -7,4 +7,6 @@ enum ParametersEnum: string { case ClientId = 'client_id'; + case CredentialOffer = 'credential_offer'; + case CredentialOfferUri = 'credential_offer_uri'; } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6c17691a..607a180b 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -12,6 +12,7 @@ enum RoutesEnum: string case AdminConfigProtocol = 'admin/config/protocol'; case AdminConfigFederation = 'admin/config/federation'; + case AdminConfigVerifiableCredential = 'admin/config/verifiable-credential'; case AdminMigrations = 'admin/migrations'; case AdminMigrationsRun = 'admin/migrations/run'; @@ -27,6 +28,7 @@ enum RoutesEnum: string // Testing case AdminTestTrustChainResolution = 'admin/test/trust-chain-resolution'; case AdminTestTrustMarkValidation = 'admin/test/trust-mark-validation'; + case AdminTestVerifiableCredentialIssuance = 'admin/test/verifiable-credential-issuance'; /***************************************************************************************************************** @@ -40,6 +42,13 @@ enum RoutesEnum: string case Jwks = 'jwks'; case EndSession = 'end-session'; + /***************************************************************************************************************** + * OAuth 2.0 Authorization Server + ****************************************************************************************************************/ + + // OAuth 2.0 Authorization Server Metadata https://www.rfc-editor.org/rfc/rfc8414.html + case OAuth2Configuration = '.well-known/oauth-authorization-server'; + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ @@ -47,4 +56,25 @@ enum RoutesEnum: string case FederationConfiguration = '.well-known/openid-federation'; case FederationFetch = 'federation/fetch'; case FederationList = 'federation/list'; + + /***************************************************************************************************************** + * OpenID for Verifiable Credential Issuance + ****************************************************************************************************************/ + + case CredentialIssuerConfiguration = '.well-known/openid-credential-issuer'; + case CredentialIssuerCredential = 'credential-issuer/credential'; + case CredentialIssuerNonce = 'credential-issuer/nonce'; + + /***************************************************************************************************************** + * SD-JWT-based Verifiable Credentials (SD-JWT VC) + ****************************************************************************************************************/ + + case JwtVcIssuerConfiguration = '.well-known/jwt-vc-issuer'; + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + case ApiVciCredentialOffer = 'api/vci/credential-offer'; + case ApiOAuth2TokenIntrospection = 'api/oauth2/token-introspection'; } diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index ec0f2c74..7f7ff7ef 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -25,6 +25,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\Module\oidc\Utils\Routes; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -118,7 +119,7 @@ public function resetSecret(Request $request): Response $this->clientRepository->update($client, $authedUserId); $message = Translate::noop('Client secret has been reset.'); - $this->logger->info($message, $client->getState()); + $this->logger->info($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); return $this->routes->newRedirectResponseToModuleUrl( @@ -181,14 +182,14 @@ public function add(): Response if ($this->clientRepository->findById($client->getIdentifier())) { $message = Translate::noop('Client with generated ID already exists.'); - $this->logger->warning($message, $client->getState()); + $this->logger->warning($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); } elseif ( ($entityIdentifier = $client->getEntityIdentifier()) && $this->clientRepository->findByEntityIdentifier($entityIdentifier) ) { $message = Translate::noop('Client with given entity identifier already exists.'); - $this->logger->warning($message, $client->getState()); + $this->logger->warning($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); } else { $this->clientRepository->add($client); @@ -199,7 +200,7 @@ public function add(): Response /** @var string[] $allowedOrigins */ $this->allowedOriginRepository->set($client->getIdentifier(), $allowedOrigins); $message = Translate::noop('Client has been added.'); - $this->logger->info($message, $client->getState()); + $this->logger->info($message, [ParametersEnum::ClientId->value => $client->getIdentifier()]); $this->sessionMessagesService->addMessage($message); return $this->routes->newRedirectResponseToModuleUrl( @@ -238,6 +239,9 @@ public function edit(Request $request): Response $clientData = $originalClient->toArray(); $clientData['allowed_origin'] = $clientAllowedOrigins; + + // Handle extra metadata + $form->setDefaults($clientData); if ($form->isSuccess()) { @@ -252,6 +256,7 @@ public function edit(Request $request): Response $originalClient->getCreatedAt(), $originalClient->getExpiresAt(), $originalClient->getOwner(), + $originalClient->isGeneric(), ); // We have to make sure that the Entity Identifier is unique. @@ -311,6 +316,7 @@ protected function buildClientEntityFromFormData( ?\DateTimeImmutable $createdAt = null, ?\DateTimeImmutable $expiresAt = null, ?string $owner = null, + bool $isGeneric = false, ): ClientEntityInterface { /** @var array $data */ $data = $form->getValues('array'); @@ -342,7 +348,15 @@ protected function buildClientEntityFromFormData( $jwksUri = empty($data[ClientEntity::KEY_JWKS_URI]) ? null : (string)$data[ClientEntity::KEY_JWKS_URI]; $signedJwksUri = empty($data[ClientEntity::KEY_SIGNED_JWKS_URI]) ? null : (string)$data[ClientEntity::KEY_SIGNED_JWKS_URI]; - $isFederated = (bool)$data[ClientEntity::KEY_IS_FEDERATED]; + + $idTokenSignedResponseAlg = isset($data[ClaimsEnum::IdTokenSignedResponseAlg->value]) && + is_string($data[ClaimsEnum::IdTokenSignedResponseAlg->value]) ? + $data[ClaimsEnum::IdTokenSignedResponseAlg->value] : + null; + + $extraMetadata = [ + ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg, + ]; return $this->clientEntityFactory->fromData( $identifier, @@ -367,7 +381,8 @@ protected function buildClientEntityFromFormData( $updatedAt, $createdAt, $expiresAt, - $isFederated, + $isGeneric, + $extraMetadata, ); } } diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index 5eb24c3f..4f8db3bc 100644 --- a/src/Controllers/Admin/ConfigController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -84,14 +84,20 @@ function (string $token): Federation\TrustMark { * @var non-empty-string $trustMarkIssuerId */ foreach ($dynamicTrustMarks as $trustMarkType => $trustMarkIssuerId) { - $trustMarkIssuerConfigurationStatement = $this->federation->entityStatementFetcher() - ->fromCacheOrWellKnownEndpoint($trustMarkIssuerId); + try { + $trustMarkIssuerConfigurationStatement = $this->federation->entityStatementFetcher() + ->fromCacheOrWellKnownEndpoint($trustMarkIssuerId); - $trustMarks[] = $this->federation->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint( - $trustMarkType, - $this->moduleConfig->getIssuer(), - $trustMarkIssuerConfigurationStatement, - ); + $trustMarks[] = $this->federation->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint( + $trustMarkType, + $this->moduleConfig->getIssuer(), + $trustMarkIssuerConfigurationStatement, + ); + } catch (\Exception $e) { + $message = Translate::noop('Error fetching dynamic trust mark: ') . + "trust_mark_type => $trustMarkType, issuer_id => $trustMarkIssuerId. " . $e->getMessage(); + $this->sessionMessagesService->addMessage($message); + } } } @@ -104,4 +110,15 @@ function (string $token): Federation\TrustMark { RoutesEnum::AdminConfigFederation->value, ); } + + public function verifiableCredentialSettings(): Response + { + return $this->templateFactory->build( + 'oidc:config/verifiable-credential.twig', + [ + 'moduleConfig' => $this->moduleConfig, + ], + RoutesEnum::AdminConfigVerifiableCredential->value, + ); + } } diff --git a/src/Controllers/Admin/TestController.php b/src/Controllers/Admin/FederationTestController.php similarity index 99% rename from src/Controllers/Admin/TestController.php rename to src/Controllers/Admin/FederationTestController.php index 87e2086b..f9d60ebc 100644 --- a/src/Controllers/Admin/TestController.php +++ b/src/Controllers/Admin/FederationTestController.php @@ -17,7 +17,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -class TestController +class FederationTestController { protected readonly Federation $federationWithArrayLogger; diff --git a/src/Controllers/Admin/VerifiableCredentailsTestController.php b/src/Controllers/Admin/VerifiableCredentailsTestController.php new file mode 100644 index 00000000..9b4ebc27 --- /dev/null +++ b/src/Controllers/Admin/VerifiableCredentailsTestController.php @@ -0,0 +1,177 @@ +authorization->requireAdmin(true); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\CredentialOfferException + * @psalm-suppress MixedAssignment, InternalMethod + */ + public function verifiableCredentialIssuance(Request $request): Response + { + if (!$this->moduleConfig->getVciEnabled()) { + return $this->templateFactory->build( + 'oidc:tests/verifiable-credential-issuance.twig', + ['setupErrors' => ['Verifiable Credential functionalities are not enabled.']], + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + ); + } + + $credentialConfigurationIdsSupported = $this->moduleConfig->getVciCredentialConfigurationIdsSupported(); + if (empty($credentialConfigurationIdsSupported)) { + return $this->templateFactory->build( + 'oidc:tests/verifiable-credential-issuance.twig', + ['setupErrors' => ['No credential configuration IDs configured.']], + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + ); + } + + $session = $this->sessionService->getCurrentSession(); + $allowedMethods = [HttpMethodsEnum::GET, HttpMethodsEnum::POST]; + + if ($request->request->has('clear')) { + $selectedAuthSourceId = $session->getData('vci', 'auth_source_id'); + if (is_string($selectedAuthSourceId)) { + $authSource = $this->authSimpleFactory->forAuthSourceId($selectedAuthSourceId); + if ($authSource->isAuthenticated()) { + $authSource->logout(); + } + } + $session->deleteData('vci', 'auth_source_id'); + $session->deleteData('vci', 'credential_configuration_id'); + + return $this->routes->newRedirectResponseToModuleUrl( + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + ); + } + + $authSourceId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + 'authSourceId', + $request, + $allowedMethods, + ) ?? $session->getData('vci', 'auth_source_id'); + + $credentialConfigurationId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + 'credentialConfigurationId', + $request, + $allowedMethods, + ) ?? $session->getData('vci', 'credential_configuration_id'); + + $grantType = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + 'grantType', + $request, + $allowedMethods, + ); + + $useTxCode = (bool) $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + 'useTxCode', + $request, + $allowedMethods, + ); + + $usersEmailAttributeName = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + 'usersEmailAttributeName', + $request, + $allowedMethods, + ); + + $authSourceIds = array_filter( + $this->sspBridge->auth()->source()->getSources(), + fn (string $id): bool => $id !== 'admin', + ); + + $authSource = is_string($authSourceId) ? $this->authSimpleFactory->forAuthSourceId($authSourceId) : null; + + if ($authSource instanceof Simple && $grantType === GrantTypesEnum::PreAuthorizedCode->value) { + if (!$authSource->isAuthenticated()) { + $session->setData('vci', 'auth_source_id', $authSourceId); + $session->setData('vci', 'credential_configuration_id', $credentialConfigurationId); + $authSource->login(['ReturnTo' => $this->routes->urlAdminTestVerifiableCredentialIssuance()]); + } + } + + $credentialOfferUri = null; + if (is_string($credentialConfigurationId)) { + if ($grantType === GrantTypesEnum::PreAuthorizedCode->value && $authSource?->isAuthenticated()) { + $usersEmailAttributeName = is_string($usersEmailAttributeName) && + trim($usersEmailAttributeName) !== '' ? + trim($usersEmailAttributeName) : + $this->moduleConfig->getUsersEmailAttributeNameForAuthSourceId( + is_string($authSourceId) ? $authSourceId : '', + ); + + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( + [$credentialConfigurationId], + $authSource->getAttributes(), + $useTxCode, + $usersEmailAttributeName, + ); + } elseif ($grantType === GrantTypesEnum::AuthorizationCode->value) { + $credentialOfferUri = $this->credentialOfferUriFactory->buildForAuthorization( + [$credentialConfigurationId], + ); + } + } + + $credentialOfferQrUri = is_string($credentialOfferUri) + ? 'https://quickchart.io/qr?size=200&margin=1&text=' . urlencode($credentialOfferUri) + : null; + + return $this->templateFactory->build( + 'oidc:tests/verifiable-credential-issuance.twig', + [ + 'setupErrors' => [], + 'credentialOfferQrUri' => $credentialOfferQrUri, + 'credentialOfferUri' => $credentialOfferUri, + 'authSourceIds' => $authSourceIds, + 'authSourceActionRoute' => $this->routes->urlAdminTestVerifiableCredentialIssuance(), + 'authSource' => $authSource, + 'credentialConfigurationIdsSupported' => $credentialConfigurationIdsSupported, + 'selectedCredentialConfigurationId' => $credentialConfigurationId, + 'defaultUsersEmailAttributeName' => $this->moduleConfig->getDefaultUsersEmailAttributeName(), + 'usersEmailAttributeName' => $usersEmailAttributeName, + 'grantTypesSupported' => [ + GrantTypesEnum::PreAuthorizedCode->value => Translate::noop('Pre-authorized Code'), + GrantTypesEnum::AuthorizationCode->value => Translate::noop('Authorization Code'), + ], + ], + RoutesEnum::AdminTestVerifiableCredentialIssuance->value, + ); + } +} diff --git a/src/Controllers/Api/VciCredentialOfferApiController.php b/src/Controllers/Api/VciCredentialOfferApiController.php new file mode 100644 index 00000000..58e84f81 --- /dev/null +++ b/src/Controllers/Api/VciCredentialOfferApiController.php @@ -0,0 +1,220 @@ +moduleConfig->getApiEnabled()) { + $this->loggerService->warning('API capabilities not enabled.'); + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + + if (!$this->moduleConfig->getVciEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + /** + * @throws OidcServerException + */ + public function credentialOffer(Request $request): Response + { + if (!$this->moduleConfig->getApiVciCredentialOfferEndpointEnabled()) { + $this->loggerService->warning('Credential Offer API endpoint not enabled.'); + throw OidcServerException::forbidden('Credential Offer API endpoint not enabled.'); + } + + $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->getVciCredentialConfiguration($credentialConfigurationId); + + if (!is_array($credentialConfiguration)) { + $this->loggerService->error( + 'VciCredentialOfferApiController: Provided Credential Configuration ID is not supported.', + ['credentialConfigurationId' => $credentialConfigurationId], + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Provided credential configuration ID (credential_configuration_id) is not supported.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $grantType = $input['grant_type'] ?? null; + + if (!is_string($grantType)) { + $this->loggerService->error('VciCredentialOfferApiController: Grant Type (grant_type) not provided.'); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'No credential Grant Type (grant_type) provided.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $grantTypeEnum = GrantTypesEnum::tryFrom($grantType); + + if (!$grantTypeEnum instanceof GrantTypesEnum) { + $this->loggerService->error( + 'VciCredentialOfferApiController: Invalid credential Grant Type (grant_type) provided.', + ['grantType' => $grantType], + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Invalid credential Grant Type (grant_type) provided.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + if (!$grantTypeEnum->canBeUsedForVerifiableCredentialIssuance()) { + $this->loggerService->error( + 'VciCredentialOfferApiController: Provided Grant Type can not be used for verifiable credential' . + ' issuance.', + ['grantType' => $grantType], + ); + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Provided Grant Type can not be used for verifiable credential issuance.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $credentialOfferUri = null; + + if ($grantTypeEnum === GrantTypesEnum::AuthorizationCode) { + $this->loggerService->debug( + 'VciCredentialOfferApiController: AuthorizationCode Grant Type provided. Building credential ' . + 'offer for Authorization Code Flow.', + ); + $credentialOfferUri = $this->credentialOfferUriFactory->buildForAuthorization( + [$credentialConfigurationId], + ); + } + + if ($grantTypeEnum === GrantTypesEnum::PreAuthorizedCode) { + $this->loggerService->debug( + 'VciCredentialOfferApiController: PreAuthorizedCode Grant Type provided. Building credential ' . + 'offer for Pre-authorized Code Flow.', + ); + + /** @psalm-suppress MixedAssignment */ + $userAttributes = $input['user_attributes'] ?? []; + $userAttributes = is_array($userAttributes) ? $userAttributes : []; + $useTxCode = boolval($input['use_tx_code'] ?? false); + /** @psalm-suppress MixedAssignment */ + $usersEmailAttributeName = $input['users_email_attribute_name'] ?? null; + $usersEmailAttributeName = is_string($usersEmailAttributeName) ? $usersEmailAttributeName : null; + /** @psalm-suppress MixedAssignment */ + $authenticationSourceId = $input['authentication_source_id'] ?? null; + $authenticationSourceId = is_string($authenticationSourceId) ? $authenticationSourceId : null; + + if (is_null($usersEmailAttributeName) && is_string($authenticationSourceId)) { + $usersEmailAttributeName = $this->moduleConfig->getUsersEmailAttributeNameForAuthSourceId( + $authenticationSourceId, + ); + } + + $this->loggerService->debug( + 'VciCredentialOfferApiController: PreAuthorizedCode data:', + [ + 'userAttributes' => $userAttributes, + 'useTxCode' => $useTxCode, + 'authenticationSourceId' => $authenticationSourceId, + 'usersEmailAttributeName' => $usersEmailAttributeName, + ], + ); + + $credentialOfferUri = $this->credentialOfferUriFactory->buildPreAuthorized( + [$credentialConfigurationId], + $userAttributes, + $useTxCode, + $usersEmailAttributeName, + ); + } + + if ($credentialOfferUri !== null) { + $data = [ + 'credential_offer_uri' => $credentialOfferUri, + ]; + + $this->loggerService->debug( + 'VciCredentialOfferApiController: Credential Offer URI built successfully, returning data:', + $data, + ); + return $this->routes->newJsonResponse( + data: $data, + ); + } + + $this->loggerService->debug( + 'VciCredentialOfferApiController: Credential Offer URI NOT built for provided Grant Type.', + ['grantType' => $grantType], + ); + + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'No implementation for provided Grant Type.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } +} diff --git a/src/Controllers/AuthorizationController.php b/src/Controllers/AuthorizationController.php index fa4c6079..00d13570 100644 --- a/src/Controllers/AuthorizationController.php +++ b/src/Controllers/AuthorizationController.php @@ -59,8 +59,10 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface { $queryParameters = $request->getQueryParams(); $state = null; + $this->loggerService->debug('AuthorizationController::invoke: Request parameters: ', $queryParameters); if (!isset($queryParameters[ProcessingChain::AUTHPARAM])) { + $this->loggerService->debug('AuthorizationController::invoke: No AuthProcId query param.'); $authorizationRequest = $this->authorizationServer->validateAuthorizationRequest($request); $state = $this->authenticationService->processRequest($request, $authorizationRequest); // processState will trigger a redirect diff --git a/src/Controllers/EndSessionController.php b/src/Controllers/EndSessionController.php index 267aa57f..ebd2c202 100644 --- a/src/Controllers/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -15,6 +15,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -60,11 +61,15 @@ public function __invoke(ServerRequestInterface $request): Response // If id_token_hint was provided, resolve session ID $idTokenHint = $logoutRequest->getIdTokenHint(); if ($idTokenHint !== null) { - $sidClaim = empty($idTokenHint->claims()->get('sid')) ? - null : - (string)$idTokenHint->claims()->get('sid'); + /** @psalm-suppress MixedAssignment */ + $sidClaim = $idTokenHint->getPayloadClaim(ClaimsEnum::Sid->value); + $sidClaim = is_string($sidClaim) && $sidClaim !== '' ? $sidClaim : null; } + $this->loggerService->debug( + 'EndSession: ID Token Hint Session ID: ' . var_export($sidClaim, true), + ); + // Check if RP is requesting logout for session that previously existed (not this current session). // Claim 'sid' from 'id_token_hint' logout parameter indicates for which session should log out be // performed (sid is session ID used when ID token was issued during authn). If the requested @@ -73,19 +78,31 @@ public function __invoke(ServerRequestInterface $request): Response $sidClaim !== null && $this->sessionService->getCurrentSession()->getSessionId() !== $sidClaim ) { + $this->loggerService->debug('Not current session: ' . $sidClaim); try { if (($sidSession = $this->sessionService->getSessionById($sidClaim)) !== null) { + $this->loggerService->debug('Found session for ID: ' . $sidClaim); $sidSessionValidAuthorities = $sidSession->getAuthorities(); if (! empty($sidSessionValidAuthorities)) { + $this->loggerService->debug( + 'Valid session authorities: ' . implode(', ', $sidSessionValidAuthorities), + ); $wasLogoutActionCalled = true; // Create a SessionLogoutTicket so that the sid is available in the static logoutHandler() $this->sessionLogoutTicketStoreBuilder->getInstance()->add($sidClaim); // Initiate logout for every valid auth source for the requested session. foreach ($sidSessionValidAuthorities as $authSourceId) { + $this->loggerService->debug( + 'Initiating logout for auth source ID: ' . $authSourceId, + ); $sidSession->doLogout($authSourceId); } + } else { + $this->loggerService->debug('Session authorities not found for ID: ' . $sidClaim); } + } else { + $this->loggerService->debug('Session not found for ID: ' . $sidClaim); } } catch (Throwable $exception) { $this->loggerService->warning( @@ -96,13 +113,21 @@ public function __invoke(ServerRequestInterface $request): Response $currentSessionValidAuthorities = $this->sessionService->getCurrentSession()->getAuthorities(); if (!empty($currentSessionValidAuthorities)) { + $this->loggerService->debug( + 'Current session authorities: ' . implode(', ', $currentSessionValidAuthorities), + ); $wasLogoutActionCalled = true; // Initiate logout for every valid auth source for the current session. foreach ($this->sessionService->getCurrentSession()->getAuthorities() as $authSourceId) { $this->sessionService->getCurrentSession()->doLogout($authSourceId); } + } else { + $this->loggerService->debug( + 'Current session authorities not found for ID: ' . var_export($sidClaim, true), + ); } + $this->loggerService->debug('Was logout action called: ' . var_export($wasLogoutActionCalled, true)); // Set indication for OIDC initiated logout back to false, so that the logoutHandler() method does not // run for other logout initiated actions, like (currently) re-authentication... $this->sessionService->setIsOidcInitiatedLogout(false); @@ -189,14 +214,32 @@ public static function logoutHandler(): void protected function resolveResponse(LogoutRequest $logoutRequest, bool $wasLogoutActionCalled): Response { if (($postLogoutRedirectUri = $logoutRequest->getPostLogoutRedirectUri()) !== null) { - if ($logoutRequest->getState() !== null) { + $this->loggerService->debug( + 'Logout request includes post-logout redirect URI: ' . $postLogoutRedirectUri, + ); + + if (($logoutState = $logoutRequest->getState()) !== null) { + $this->loggerService->debug( + 'Appending logout request state: ' . $logoutState, + ); $postLogoutRedirectUri .= (!str_contains($postLogoutRedirectUri, '?')) ? '?' : '&'; - $postLogoutRedirectUri .= http_build_query(['state' => $logoutRequest->getState()]); + $postLogoutRedirectUri .= http_build_query(['state' => $logoutState]); + } else { + $this->loggerService->debug( + 'No state provided for post logout', + ); } + $this->loggerService->debug( + 'Final post logout redirect URI: ' . $postLogoutRedirectUri, + ); return new RedirectResponse($postLogoutRedirectUri); } + $this->loggerService->debug( + 'No post logout redirect URI provided for logout. Showing template.', + ); + return $this->templateFactory->build( templateName: 'oidc:/logout.twig', data: [ diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index 58fe43b1..55d3784f 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -6,10 +6,7 @@ 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\Services\JsonWebKeySetService; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\FederationCache; @@ -18,11 +15,9 @@ use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; -use SimpleSAML\OpenID\Codebooks\ErrorsEnum; use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum; -use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Federation; -use Symfony\Component\HttpFoundation\Request; +use SimpleSAML\OpenID\Jwks; use Symfony\Component\HttpFoundation\Response; class EntityStatementController @@ -34,16 +29,14 @@ class EntityStatementController * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function __construct( - private readonly ModuleConfig $moduleConfig, - private readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, - private readonly JsonWebKeySetService $jsonWebKeySetService, - private readonly OpMetadataService $opMetadataService, - private readonly ClientRepository $clientRepository, - private readonly Helpers $helpers, - private readonly Routes $routes, - private readonly Federation $federation, - private readonly LoggerService $loggerService, - private readonly ?FederationCache $federationCache, + protected readonly ModuleConfig $moduleConfig, + protected readonly Jwks $jwks, + protected readonly OpMetadataService $opMetadataService, + protected readonly Helpers $helpers, + protected readonly Routes $routes, + protected readonly Federation $federation, + protected readonly LoggerService $loggerService, + protected readonly ?FederationCache $federationCache, ) { if (!$this->moduleConfig->getFederationEnabled()) { throw OidcServerException::forbidden('federation capabilities not enabled'); @@ -55,7 +48,6 @@ public function __construct( * * @return \Symfony\Component\HttpFoundation\Response * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \ReflectionException * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \Psr\SimpleCache\InvalidArgumentException */ @@ -71,63 +63,68 @@ public function configuration(): Response return $this->prepareEntityStatementResponse((string)$cachedEntityConfigurationToken); } - $builder = $this->jsonWebTokenBuilderService->getFederationJwtBuilder() - ->withHeader(ClaimsEnum::Typ->value, JwtTypesEnum::EntityStatementJwt->value) - ->relatedTo($this->moduleConfig->getIssuer()) // This is entity configuration (statement about itself). - ->expiresAt( - $this->helpers->dateTime()->getUtc()->add($this->moduleConfig->getFederationEntityStatementDuration()), - )->withClaim( - ClaimsEnum::Jwks->value, - ['keys' => array_values($this->jsonWebKeySetService->federationKeys()),], - ) - ->withClaim( - ClaimsEnum::Metadata->value, - [ - EntityTypesEnum::FederationEntity->value => [ - // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters - ...(array_filter( - [ - ClaimsEnum::OrganizationName->value => $this->moduleConfig->getOrganizationName(), - ClaimsEnum::DisplayName->value => $this->moduleConfig->getDisplayName(), - ClaimsEnum::Description->value => $this->moduleConfig->getDescription(), - ClaimsEnum::Keywords->value => $this->moduleConfig->getKeywords(), - ClaimsEnum::Contacts->value => $this->moduleConfig->getContacts(), - ClaimsEnum::LogoUri->value => $this->moduleConfig->getLogoUri(), - ClaimsEnum::PolicyUri->value => $this->moduleConfig->getPolicyUri(), - ClaimsEnum::InformationUri->value => $this->moduleConfig->getInformationUri(), - ClaimsEnum::OrganizationUri->value => $this->moduleConfig->getOrganizationUri(), - ], - )), - ClaimsEnum::FederationFetchEndpoint->value => $this->routes->urlFederationFetch(), - ClaimsEnum::FederationListEndpoint->value => $this->routes->urlFederationList(), - // TODO v7 mivanci Add when ready. Use ClaimsEnum for keys. - // https://openid.net/specs/openid-federation-1_0.html#name-federation-entity - //'federation_resolve_endpoint', - //'federation_trust_mark_status_endpoint', - //'federation_trust_mark_list_endpoint', - //'federation_trust_mark_endpoint', - //'federation_historical_keys_endpoint', - //'endpoint_auth_signing_alg_values_supported' - // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters - //'signed_jwks_uri', - //'jwks_uri', - //'jwks', - ], - // OP metadata with additional federation related claims. - EntityTypesEnum::OpenIdProvider->value => [ - ...$this->opMetadataService->getMetadata(), - ClaimsEnum::ClientRegistrationTypesSupported->value => [ - ClientRegistrationTypesEnum::Automatic->value, + $currentTimestamp = $this->helpers->dateTime()->getUtc()->getTimestamp(); + + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getFederationSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + + $payload = [ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => $this->federation->helpers()->random()->string(), + // 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 => $jwks, + ClaimsEnum::Metadata->value => [ + EntityTypesEnum::FederationEntity->value => [ + // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + ...(array_filter( + [ + ClaimsEnum::OrganizationName->value => $this->moduleConfig->getOrganizationName(), + ClaimsEnum::DisplayName->value => $this->moduleConfig->getDisplayName(), + ClaimsEnum::Description->value => $this->moduleConfig->getDescription(), + ClaimsEnum::Keywords->value => $this->moduleConfig->getKeywords(), + ClaimsEnum::Contacts->value => $this->moduleConfig->getContacts(), + ClaimsEnum::LogoUri->value => $this->moduleConfig->getLogoUri(), + ClaimsEnum::PolicyUri->value => $this->moduleConfig->getPolicyUri(), + ClaimsEnum::InformationUri->value => $this->moduleConfig->getInformationUri(), + ClaimsEnum::OrganizationUri->value => $this->moduleConfig->getOrganizationUri(), ], + )), + ClaimsEnum::FederationFetchEndpoint->value => $this->routes->urlFederationFetch(), + ClaimsEnum::FederationListEndpoint->value => $this->routes->urlFederationList(), + // TODO v7 mivanci Add when ready. Use ClaimsEnum for keys. + // https://openid.net/specs/openid-federation-1_0.html#name-federation-entity + //'federation_resolve_endpoint', + //'federation_trust_mark_status_endpoint', + //'federation_trust_mark_list_endpoint', + //'federation_trust_mark_endpoint', + //'federation_historical_keys_endpoint', + //'endpoint_auth_signing_alg_values_supported' + // Common https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters + //'signed_jwks_uri', + //'jwks_uri', + //'jwks', + ], + // OP metadata with additional federation related claims. + EntityTypesEnum::OpenIdProvider->value => [ + ...$this->opMetadataService->getMetadata(), + ClaimsEnum::ClientRegistrationTypesSupported->value => [ + ClientRegistrationTypesEnum::Automatic->value, ], ], - ); + ], + ]; if ( is_array($authorityHints = $this->moduleConfig->getFederationAuthorityHints()) && (!empty($authorityHints)) ) { - $builder = $builder->withClaim(ClaimsEnum::AuthorityHints->value, $authorityHints); + $payload[ClaimsEnum::AuthorityHints->value] = $authorityHints; } $trustMarks = []; @@ -190,16 +187,28 @@ 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); + $signingKeyPair = $this->moduleConfig + ->getFederationSignatureKeyPairBag() + ->getFirstOrFail(); - $entityConfigurationToken = $jws->toString(); + $header = [ + ClaimsEnum::Kid->value => $signingKeyPair->getKeyPair()->getKeyId(), + ]; + + /** @psalm-suppress ArgumentTypeCoercion */ + $entityConfigurationToken = $this->federation->entityStatementFactory()->fromData( + $signingKeyPair->getKeyPair()->getPrivateKey(), + $signingKeyPair->getSignatureAlgorithm(), + $payload, + $header, + )->getToken(); $this->federationCache?->set( $entityConfigurationToken, @@ -211,100 +220,6 @@ public function configuration(): Response return $this->prepareEntityStatementResponse($entityConfigurationToken); } - public function fetch(Request $request): Response - { - $subject = $request->query->getString(ClaimsEnum::Sub->value); - - if (empty($subject)) { - return $this->routes->newJsonErrorResponse( - ErrorsEnum::InvalidRequest->value, - sprintf('Missing parameter %s', ClaimsEnum::Sub->value), - 400, - ); - } - - /** @var non-empty-string $subject */ - - $cachedSubordinateStatement = $this->federationCache?->get( - null, - self::KEY_RP_SUBORDINATE_ENTITY_STATEMENT, - $subject, - ); - - if (!is_null($cachedSubordinateStatement)) { - return $this->prepareEntityStatementResponse((string)$cachedSubordinateStatement); - } - - $client = $this->clientRepository->findFederatedByEntityIdentifier($subject); - if (empty($client)) { - return $this->routes->newJsonErrorResponse( - ErrorsEnum::NotFound->value, - sprintf('Subject not found (%s)', $subject), - 404, - ); - } - - $jwks = $client->getFederationJwks(); - if (empty($jwks)) { - return $this->routes->newJsonErrorResponse( - ErrorsEnum::InvalidClient->value, - sprintf('Subject does not contain JWKS claim (%s)', $subject), - 401, - ); - } - - $builder = $this->jsonWebTokenBuilderService->getFederationJwtBuilder() - ->withHeader(ClaimsEnum::Typ->value, JwtTypesEnum::EntityStatementJwt->value) - ->relatedTo($subject) - ->expiresAt( - $this->helpers->dateTime()->getUtc()->add($this->moduleConfig->getFederationEntityStatementDuration()), - )->withClaim( - ClaimsEnum::Jwks->value, - $jwks, - ) - ->withClaim( - ClaimsEnum::Metadata->value, - [ - EntityTypesEnum::OpenIdRelyingParty->value => [ - ClaimsEnum::ClientName->value => $client->getName(), - ClaimsEnum::ClientId->value => $client->getIdentifier(), - ClaimsEnum::RedirectUris->value => $client->getRedirectUris(), - ClaimsEnum::Scope->value => implode(' ', $client->getScopes()), - ClaimsEnum::ClientRegistrationTypes->value => $client->getClientRegistrationTypes(), - // Optional claims... - ...(array_filter( - [ - ClaimsEnum::BackChannelLogoutUri->value => $client->getBackChannelLogoutUri(), - ClaimsEnum::PostLogoutRedirectUris->value => $client->getPostLogoutRedirectUri(), - ], - )), - // TODO v7 mivanci Continue - // https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata - // https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#client-metadata - ], - ], - ); - - // TODO v7 mivanci Continue - // Note: claims which can be present in subordinate statements: - // * metadata_policy - // * constraints - // * metadata_policy_crit - - $jws = $this->jsonWebTokenBuilderService->getSignedFederationJwt($builder); - - $subordinateStatementToken = $jws->toString(); - - $this->federationCache?->set( - $subordinateStatementToken, - $this->moduleConfig->getFederationEntityStatementCacheDurationForProduced(), - self::KEY_RP_SUBORDINATE_ENTITY_STATEMENT, - $subject, - ); - - return $this->prepareEntityStatementResponse($subordinateStatementToken); - } - protected function prepareEntityStatementResponse(string $entityStatementToken): Response { return $this->routes->newResponse( diff --git a/src/Controllers/Federation/SubordinateListingsController.php b/src/Controllers/Federation/SubordinateListingsController.php deleted file mode 100644 index ef6b6a65..00000000 --- a/src/Controllers/Federation/SubordinateListingsController.php +++ /dev/null @@ -1,69 +0,0 @@ -moduleConfig->getFederationEnabled()) { - throw OidcServerException::forbidden('federation capabilities not enabled'); - } - } - - public function list(Request $request): Response - { - // If an unsupported query parameter is provided, we have to respond with an error: "If the responder does not - // support this feature, it MUST use the HTTP status code 400 and the content type application/json, with - // the error code unsupported_parameter." - - // Currently, we don't support any of the mentioned params in the spec, so let's return an error for - // any of them. - $unsupportedParams = [ - ParamsEnum::EntityType->value, - ParamsEnum::TrustMarked->value, - ParamsEnum::TrustMarkType->value, - ParamsEnum::Intermediate->value, - ]; - - $requestedParams = array_keys($request->query->all()); - - if (!empty($intersectedParams = array_intersect($unsupportedParams, $requestedParams))) { - return $this->routes->newJsonErrorResponse( - ErrorsEnum::UnsupportedParameter->value, - 'Unsupported parameter: ' . implode(', ', $intersectedParams), - 400, - ); - } - - $subordinateEntityIdList = array_filter(array_map( - function (ClientEntityInterface $clientEntity): ?string { - return $clientEntity->getEntityIdentifier(); - }, - $this->clientRepository->findAllFederated(), - )); - - return $this->routes->newJsonResponse( - $subordinateEntityIdList, - headers: ['Access-Control-Allow-Origin' => '*'], - ); - } -} diff --git a/src/Controllers/JwksController.php b/src/Controllers/JwksController.php index d2e12ad1..8ad7fc72 100644 --- a/src/Controllers/JwksController.php +++ b/src/Controllers/JwksController.php @@ -18,22 +18,39 @@ use Laminas\Diactoros\Response\JsonResponse; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Jwks; use Symfony\Component\HttpFoundation\Response; class JwksController { public function __construct( - private readonly JsonWebKeySetService $jsonWebKeySetService, - private readonly PsrHttpBridge $psrHttpBridge, + protected readonly PsrHttpBridge $psrHttpBridge, + protected readonly ModuleConfig $moduleConfig, + protected readonly Jwks $jwks, ) { } + /** + * @throws \SimpleSAML\Error\ConfigurationError + */ public function __invoke(): JsonResponse { - return new JsonResponse([ - 'keys' => array_values($this->jsonWebKeySetService->protocolKeys()), - ]); + $federationPublicKeys = $this->moduleConfig->getFederationEnabled() + ? $this->moduleConfig->getFederationSignatureKeyPairBag()->getAllPublicKeys() + : []; + + $vciPublicKeys = $this->moduleConfig->getVciEnabled() + ? $this->moduleConfig->getVciSignatureKeyPairBag()->getAllPublicKeys() + : []; + + return new JsonResponse( + $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + ...$federationPublicKeys, + ...$vciPublicKeys, + )->jsonSerialize(), + ); } public function jwks(): Response diff --git a/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php new file mode 100644 index 00000000..46b385d0 --- /dev/null +++ b/src/Controllers/OAuth2/OAuth2ServerConfigurationController.php @@ -0,0 +1,53 @@ +opMetadataService->getMetadata(); + + if ( + $this->moduleConfig->getApiEnabled() && + $this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled() + ) { + $configuration[ClaimsEnum::IntrospectionEndpoint->value] = $this->routes->urlApiOAuth2TokenIntrospection(); + $configuration[ClaimsEnum::IntrospectionEndpointAuthMethodsSupported->value] = [ + ClientAuthenticationMethodsEnum::ClientSecretBasic->value, + ClientAuthenticationMethodsEnum::ClientSecretPost->value, + ClientAuthenticationMethodsEnum::PrivateKeyJwt->value, + AccessTokenTypesEnum::Bearer->value, + ]; + $configuration[ClaimsEnum::IntrospectionEndpointAuthSigningAlgValuesSupported->value] = $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(); + } + + return $this->routes->newJsonResponse( + $configuration, + ); + + // TODO mivanci Add ability for claim 'signed_metadata' when moving to simplesamlphp/openid, as per + // https://www.rfc-editor.org/rfc/rfc8414.html#section-2.1, with caching support. + } +} diff --git a/src/Controllers/OAuth2/TokenIntrospectionController.php b/src/Controllers/OAuth2/TokenIntrospectionController.php new file mode 100644 index 00000000..bf2bf1a5 --- /dev/null +++ b/src/Controllers/OAuth2/TokenIntrospectionController.php @@ -0,0 +1,256 @@ +moduleConfig->getApiEnabled()) { + $this->loggerService->warning('API capabilities not enabled.'); + throw OidcServerException::forbidden('API capabilities not enabled.'); + } + + if (!$this->moduleConfig->getApiOAuth2TokenIntrospectionEndpointEnabled()) { + $this->loggerService->warning('OAuth2 Token Introspection API endpoint not enabled.'); + throw OidcServerException::forbidden('OAuth2 Token Introspection API endpoint not enabled.'); + } + } + + public function __invoke(Request $request): Response + { + try { + $this->ensureAuthenticatedClient($request); + } catch (AuthorizationException $e) { + $this->loggerService->error( + 'TokenIntrospectionController::invoke: AuthorizationException: ' . $e->getMessage(), + ); + return $this->routes->newJsonErrorResponse( + error: 'unauthorized', + description: $e->getMessage(), + httpCode: Response::HTTP_UNAUTHORIZED, + ); + } + + $allowedMethods = [HttpMethodsEnum::POST]; + + $tokenParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Token->value, + $request, + $allowedMethods, + ); + + if (!$tokenParam) { + return $this->routes->newJsonErrorResponse( + error: 'invalid_request', + description: 'Missing token parameter.', + httpCode: Response::HTTP_BAD_REQUEST, + ); + } + + $tokenTypeHintParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::TokenTypeHint->value, + $request, + $allowedMethods, + ); + + $payload = null; + if (is_null($tokenTypeHintParam)) { + $payload = $this->resolveAccessTokenPayload($tokenParam) ?? + $this->resolveRefreshTokenPayload($tokenParam); + } elseif ($tokenTypeHintParam === 'access_token') { + $payload = $this->resolveAccessTokenPayload($tokenParam); + } elseif ($tokenTypeHintParam === 'refresh_token') { + $payload = $this->resolveRefreshTokenPayload($tokenParam); + } + + $payload = $payload ?? ['active' => false]; + + return $this->routes->newJsonResponse($payload); + } + + protected function resolveAccessTokenPayload(string $tokenParam): ?array + { + try { + $accessToken = $this->bearerTokenValidator->ensureValidAccessToken($tokenParam); + } catch (\Throwable $e) { + $this->loggerService->error('Access token validation failed: ' . $e->getMessage()); + return null; + } + + // See \SimpleSAML\Module\oidc\Entities\AccessTokenEntity::convertToJWT + // for claims set on the access token. + + $scopeClaim = null; + /** @psalm-suppress MixedAssignment */ + $accessTokenScopes = $accessToken->getPayloadClaim('scopes'); + if (is_array($accessTokenScopes)) { + $scopeClaim = $this->prepareScopeString($accessTokenScopes); + } + + $clientId = is_array($audience = $accessToken->getAudience()) ? $audience[0] ?? null : null; + + return array_filter([ + 'active' => true, + 'scope' => $scopeClaim, + 'client_id' => $clientId, + 'token_type' => 'Bearer', + ClaimsEnum::Exp->value => $accessToken->getExpirationTime(), + ClaimsEnum::Iat->value => $accessToken->getIssuedAt(), + ClaimsEnum::Nbf->value => $accessToken->getNotBefore(), + ClaimsEnum::Sub->value => $accessToken->getSubject(), + ClaimsEnum::Aud->value => $accessToken->getAudience(), + ClaimsEnum::Iss->value => $accessToken->getIssuer(), + ClaimsEnum::Jti->value => $accessToken->getJwtId(), + ]); + } + + /** + * @psalm-suppress MixedAssignment + */ + public function resolveRefreshTokenPayload(string $tokenParam): ?array + { + try { + $decryptedToken = $this->oAuth2Bridge->decrypt($tokenParam); + $tokenData = json_decode($decryptedToken, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->loggerService->error('Refresh token decrypting failed: ' . $e->getMessage()); + return null; + } + + if (!is_array($tokenData)) { + $this->loggerService->error('Refresh token has unexpected type.'); + return null; + } + + // See \League\OAuth2\Server\ResponseTypes\BearerTokenResponse::generateHttpResponse for claims set on + // the refresh token. + + $expireTime = is_int($expireTime = $tokenData['expire_time'] ?? null) ? $expireTime : null; + + if (is_null($expireTime)) { + $this->loggerService->error('Refresh token has no expiration time.'); + return null; + } + + if ($expireTime < time()) { + $this->loggerService->error('Refresh token has expired.'); + return null; + } + + $refreshTokenId = is_string($refreshTokenId = $tokenData['refresh_token_id'] ?? null) ? $refreshTokenId : null; + + if (is_null($refreshTokenId)) { + $this->loggerService->error('Refresh token has no ID.'); + return null; + } + + try { + if ($this->refreshTokenRepository->isRefreshTokenRevoked($refreshTokenId)) { + $this->loggerService->error('Refresh token has been revoked.'); + return null; + } + } catch (OidcServerException $e) { + $this->loggerService->error('Refresh token revocation check failed: ' . $e->getMessage()); + return null; + } + + $scopeClaim = null; + $refreshTokenScopes = $tokenData['scopes'] ?? null; + if (is_array($refreshTokenScopes)) { + $scopeClaim = $this->prepareScopeString($refreshTokenScopes); + } + + $clientId = is_string($clientId = $tokenData['client_id'] ?? null) ? $clientId : null; + + return array_filter([ + 'active' => true, + 'scope' => $scopeClaim, + 'client_id' => $clientId, + ClaimsEnum::Exp->value => $expireTime, + ClaimsEnum::Sub->value => is_string($tokenData['user_id'] ?? null) ? $tokenData['user_id'] : null, + ClaimsEnum::Aud->value => $clientId, + ClaimsEnum::Jti->value => $refreshTokenId, + ]); + } + + protected function prepareScopeString(array $scopes): string + { + $scopes = array_filter( + $scopes, + static fn($scope) => is_string($scope) && !empty($scope), + ); + + return implode(' ', $scopes); + } + + /** + * @throws AuthorizationException + */ + protected function ensureAuthenticatedClient(Request $request): void + { + $this->loggerService->debug('TokenIntrospectionController::ensureAuthenticatedClient - start'); + $this->loggerService->debug('Trying supported OAuth2 client authentication methods.'); + + // First, try regular OAuth2 client authentication methods. + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); + + if ( + $resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod && + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() + ) { + $this->loggerService->debug( + sprintf( + 'Client %s authenticated using supported OAuth2 client authentication method %s.', + $resolvedClientAuthenticationMethod->getClient()->getIdentifier(), + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->value, + ), + ); + + return; + } + + $this->loggerService->debug('No regular OAuth2 client authentication method found.'); + $this->loggerService->debug('Trying API client authentication method.'); + + $this->apiAuthorization->requireTokenForAnyOfScope( + $request, + [ApiScopesEnum::OAuth2TokenIntrospection, ApiScopesEnum::OAuth2All, ApiScopesEnum::All], + ); + + $this->loggerService->debug('API client authenticated.'); + } +} diff --git a/src/Controllers/UserInfoController.php b/src/Controllers/UserInfoController.php index 982b0eac..f228047a 100644 --- a/src/Controllers/UserInfoController.php +++ b/src/Controllers/UserInfoController.php @@ -18,7 +18,6 @@ use Laminas\Diactoros\Response\JsonResponse; use League\OAuth2\Server\Exception\OAuthServerException; -use League\OAuth2\Server\ResourceServer; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error; @@ -29,6 +28,7 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php new file mode 100644 index 00000000..f36a25b9 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerConfigurationController.php @@ -0,0 +1,117 @@ +moduleConfig->getVciEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + public function configuration(): Response + { + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p + + $signatureKeyPair = $this->moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail(); + + $credentialConfigurationsSupported = $this->moduleConfig->getVciCredentialConfigurationsSupported(); + + // For now, we only support one credential signing algorithm. + /** @psalm-suppress MixedAssignment */ + foreach ($credentialConfigurationsSupported as $credentialConfigurationId => $credentialConfiguration) { + if (is_array($credentialConfiguration)) { + // Draft 17 + $credentialConfiguration[ClaimsEnum::CredentialSigningAlgValuesSupported->value] = [ + $signatureKeyPair->getSignatureAlgorithm()->value, + ]; + $credentialConfiguration[ClaimsEnum::CryptographicBindingMethodsSupported->value] = [ + 'jwk', + ]; + $credentialConfiguration[ClaimsEnum::ProofTypesSupported->value] = [ + 'jwt' => [ + ClaimsEnum::ProofSigningAlgValuesSupported->value => $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(), + ], + ]; + $credentialConfigurationsSupported[$credentialConfigurationId] = $credentialConfiguration; + } + } + + $configuration = [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + + // OPTIONAL // WND + // authorization_servers + + // REQUIRED + ClaimsEnum::CredentialEndpoint->value => $this->routes->urlCredentialIssuerCredential(), + + // OPTIONAL + ClaimsEnum::NonceEndpoint->value => $this->routes->urlCredentialIssuerNonce(), + + // 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', + ClaimsEnum::Description->value => $this->moduleConfig->getDescription() ?? 'SimpleSAMLphp Demo VCI', + ClaimsEnum::LogoUri->value => [ + ClaimsEnum::Uri->value => $this->moduleConfig->getLogoUri(), + ClaimsEnum::AltText->value => ($this->moduleConfig->getOrganizationName() ?? 'VCI') . ' logo', + ], + ], + + ], + + ClaimsEnum::CredentialConfigurationsSupported->value => $credentialConfigurationsSupported, + + ]; + + return $this->routes->newJsonResponse($configuration); + } +} diff --git a/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php new file mode 100644 index 00000000..b61ccb32 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/CredentialIssuerCredentialController.php @@ -0,0 +1,810 @@ +value, + CredentialFormatIdentifiersEnum::VcSdJwt->value, + ]; + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function __construct( + protected readonly ResourceServer $resourceServer, + protected readonly AccessTokenRepository $accessTokenRepository, + protected readonly ModuleConfig $moduleConfig, + protected readonly Routes $routes, + protected readonly PsrHttpBridge $psrHttpBridge, + protected readonly VerifiableCredentials $verifiableCredentials, + protected readonly LoggerService $loggerService, + protected readonly RequestParamsResolver $requestParamsResolver, + protected readonly UserRepository $userRepository, + protected readonly Did $did, + protected readonly IssuerStateRepository $issuerStateRepository, + protected readonly NonceService $nonceService, + ) { + if (!$this->moduleConfig->getVciEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \ReflectionException + * @throws OpenIdException + */ + public function credential(Request $request): Response + { + $this->loggerService->debug('CredentialIssuerCredentialController::credential'); + + $requestData = $this->requestParamsResolver->getAllFromRequestBasedOnAllowedMethods( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + [HttpMethodsEnum::POST], + ); + + $this->loggerService->debug( + 'CredentialIssuerCredentialController: Request data: ', + $requestData, + ); + + $authorization = $this->resourceServer->validateAuthenticatedRequest( + $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request), + ); + + $accessToken = $this->accessTokenRepository->findById( + (string)$authorization->getAttribute('oauth_access_token_id'), + ); + + if (! $accessToken instanceof AccessTokenEntity) { + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token not found.', + 401, + ); + } + + if ($accessToken->isRevoked()) { + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token is revoked.', + 401, + ); + } + + if ( + ($flowType = $accessToken->getFlowTypeEnum()) === null || + $flowType->isVciFlow() === false + ) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Access token is not intended for Verifiable' . + ' Credential Issuance.', + ['accessTokenState' => $accessToken->getState()], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_token', + 'Access token is not intended for verifiable credential issuance.', + 401, + ); + } + + $issuerState = $accessToken->getIssuerState(); + if ( + !is_string($issuerState) && + ($accessToken->getFlowTypeEnum() === FlowTypeEnum::VciAuthorizationCode) + ) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Issuer state missing in access token.', + ['accessTokenState' => $accessToken->getState()], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Issuer state missing in access token.', + 401, + ); + } + + if (is_string($issuerState) && $this->issuerStateRepository->findValid($issuerState) === null) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Issuer state not valid.', + ['issuerState' => $issuerState], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Issuer state not valid.', + 401, + ); + } + + if ( + isset($requestData[ClaimsEnum::CredentialConfigurationId->value]) && + isset($requestData[ClaimsEnum::CredentialIdentifier->value]) + ) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential configuration ID ' . + '(credential_configuration_id) present in request together with credential identifier ' . + '(credential_identifier).', + ); + + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Credential configuration ID must not be used together with credential identifier.', + 400, + ); + } + + // 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.', + 400, + ); + } + + $isCredentialIdentifierUsedInFlow = false; + foreach ($authorizationDetails as $authorizationDetail) { + + /** @psalm-suppress MixedAssignment */ + if ( + !is_array($authorizationDetail) || + !isset($authorizationDetail[ClaimsEnum::Type->value]) || + $authorizationDetail[ClaimsEnum::Type->value] !== 'openid_credential' || + !isset($authorizationDetail[ClaimsEnum::CredentialConfigurationId->value]) || + !is_string( + $authorizationDetailCredentialConfigurationId = + $authorizationDetail[ClaimsEnum::CredentialConfigurationId->value], + ) + ) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: Unusable authorization detail.', + ['authorizationDetail' => $authorizationDetail], + ); + continue; + } + + if ($credentialIdentifier === $authorizationDetailCredentialConfigurationId) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Credential identifier used in flow.', + ['credentialIdentifier' => $credentialIdentifier], + ); + $isCredentialIdentifierUsedInFlow = true; + break; + } + } + + if (!$isCredentialIdentifierUsedInFlow) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential identifier not used in flow.', + ['credentialIdentifier' => $credentialIdentifier], + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Credential identifier not used in flow.', + 400, + ); + } + + $resolvedCredentialIdentifier = $credentialIdentifier; + + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential identifier from ' . + 'credential_identifier parameter.', + ['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier], + ); + } else { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: No authorization details found in access' . + ' token. Skipping credential identifier resolution from credential_identifier parameter.', + ); + } + + if (!is_string($resolvedCredentialIdentifier)) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential identifier from ' . + 'credential_configuration_id parameter.', + ); + + /** @psalm-suppress MixedAssignment */ + $credentialConfigurationId = $requestData[ClaimsEnum::CredentialConfigurationId->value] ?? null; + + if (is_string($credentialConfigurationId)) { + /** @psalm-suppress MixedAssignment */ + $resolvedCredentialIdentifier = $credentialConfigurationId; + + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential identifier from ' . + 'credential_configuration_id parameter.', + ['resolvedCredentialIdentifier' => $resolvedCredentialIdentifier], + ); + } else { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential configuration ID missing in ' . + 'request.', + ); + } + } + + if (!is_string($resolvedCredentialIdentifier)) { + $this->loggerService->warning( + 'CredentialIssuerCredentialController::credential: No credential identifier found in request. ' . + 'Falling back to resolution from format and credential type.', + ); + + $requestedCredentialFormatId = $requestData[ClaimsEnum::Format->value] ?? null; + + if (!is_string($requestedCredentialFormatId)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential format missing in request.', + ); + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Can not resolve credential format.', + 400, + ); + } + + 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), + 400, + ); + } + + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential format.', + ['requestedCredentialFormatId' => $requestedCredentialFormatId], + ); + + $fallbackCredentialConfigurationId = null; + + // TODO mivanci Update this to newest draft. + // Check per draft 14 (Sphereon wallet case). + /** @psalm-suppress MixedAssignment */ + if ( + $requestedCredentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value && + is_array( + $credentialDefinitionType = + $requestData[ClaimsEnum::CredentialDefinition->value][ClaimsEnum::Type->value] ?? null, + ) + ) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential configuration ID ' . + 'from credential definition type.', + ['credentialDefinitionType' => $credentialDefinitionType], + ); + $fallbackCredentialConfigurationId = + $this->moduleConfig->getVciCredentialConfigurationIdForCredentialDefinitionType( + $credentialDefinitionType, + ); + } elseif ( + in_array($requestedCredentialFormatId, self::SD_JWT_FORMAT_IDS, true) && + is_string($vct = $requestData[ClaimsEnum::Vct->value] ?? null) + ) { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolving credential configuration ID ' . + 'from VCT.', + ['vct' => $vct], + ); + $fallbackCredentialConfigurationId = $vct; + } + + if (!is_string($fallbackCredentialConfigurationId)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Could not resolve credential from ' . + 'format and credential type.', + ); + } else { + $this->loggerService->debug( + 'CredentialIssuerCredentialController::credential: Resolved credential configuration ID ' . + 'from format and credential type.', + ['fallbackCredentialConfigurationId' => $fallbackCredentialConfigurationId], + ); + + $resolvedCredentialIdentifier = $fallbackCredentialConfigurationId; + } + } + if (!is_string($resolvedCredentialIdentifier)) { + return $this->routes->newJsonErrorResponse( + 'invalid_credential_request', + 'Can not resolve credential configuration ID.', + 400, + ); + } + + $resolvedCredentialConfiguration = $this->moduleConfig->getVciCredentialConfiguration( + $resolvedCredentialIdentifier, + ); + if (!is_array($resolvedCredentialConfiguration)) { + return $this->routes->newJsonErrorResponse( + 'unsupported_credential_type', + sprintf('Credential ID "%s" is not supported.', $resolvedCredentialIdentifier), + 400, + ); + } + + $credentialFormatId = $resolvedCredentialConfiguration[ClaimsEnum::Format->value] ?? null; + if (!is_string($credentialFormatId)) { + $this->loggerService->error( + 'CredentialIssuerCredentialController::credential: Credential format ID missing in ' . + 'resolved credential configuration.', + ['resolvedCredentialConfiguration' => $resolvedCredentialConfiguration], + ); + throw OidcServerException::serverError( + 'Credential format ID missing in resolved credential configuration (format is mandatory).', + ); + } + + $userId = $accessToken->getUserIdentifier(); + if (!is_string($userId)) { + throw OidcServerException::invalidRequest('User identifier not available in Access Token.'); + } + $userEntity = $this->userRepository->getUserEntityByIdentifier($userId); + if ($userEntity === null) { + throw OidcServerException::invalidRequest('User not found.'); + } + + // Placeholder sub identifier. Will do if proof is not provided. + $sub = $this->moduleConfig->getIssuer() . '/sub/' . $userId; + + $proof = null; + // Validate proof, if provided. + // TODO mivanci consider making proof mandatory (in issuer metadata). + /** @psalm-suppress MixedAssignment */ + if ( + isset($requestData['proof']['proof_type']) && + isset($requestData['proof']['jwt']) && + $requestData['proof']['proof_type'] === 'jwt' && + is_string($proofJwt = $requestData['proof']['jwt']) && + $proofJwt !== '' + ) { + $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); + + // Verify nonce + $nonce = $proof->getNonce(); + if (is_string($nonce) && $nonce !== '') { + $this->loggerService->debug('Proof nonce: ' . $nonce); + + if (!$this->nonceService->validateNonce($nonce)) { + $this->loggerService->warning('Proof nonce is invalid or expired. Nonce was: ' . $nonce); + return $this->routes->newJsonErrorResponse( + error: 'invalid_nonce', + description: 'c_nonce is invalid or expired.', + httpCode: 400, + ); + } + + $this->loggerService->debug('Proof nonce validated successfully.'); + } else { + $this->loggerService->warning('Nonce not present in proof, skipping nonce validation.'); + } + + // Set it as a subject identifier (bind it). + $sub = $didKey; + } else { + $this->loggerService->warning( + 'Proof currently not supported (no did:key:z). ', + ['header' => $proof->getHeader(), 'payload' => $proof->getPayload()], + ); + // TODO mivanci Consider adding support for other proof keys, like in sample for Lissi ('jwk') + /** + * 'header' => + * array ( + * 'alg' => 'ES256', + * 'typ' => 'openid4vci-proof+jwt', + * 'jwk' => + * array ( + * 'kty' => 'EC', + * 'crv' => 'P-256', + * 'x' => '7d1peDK5BTcnw45yGrRHcJJOxYrEj2sOvBnIXRyhxEM', + * 'y' => 'Z5x8pVp85PouIYkvQT2eJWZP3YgfUXPc6BIhJ2pETbM', + * ), + * ), + * 'payload' => + * array ( + * 'aud' => 'https://idp.mivanci.incubator.hexaa.eu', + * 'nonce' => NULL, + * 'iat' => 1758102462, + * 'iss' => '9c481dc3-2ad0-4fe0-881d-c32ad02fe0fc', + * ), + * ) + */ + } + } catch (\Exception $e) { + $message = 'Error processing proof JWT: ' . $e->getMessage(); + $this->loggerService->error($message); + return $this->routes->newJsonErrorResponse( + 'invalid_proof', + $message, + 400, + ); + } + } + + $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->getVciValidCredentialClaimPathsFor($resolvedCredentialIdentifier); + + // Map user attributes to credential claims + $credentialSubject = []; // For JwtVcJson + $disclosureBag = $this->verifiableCredentials->disclosureBagFactory()->build(); // For DcSdJwt + $attributeToCredentialClaimPathMap = $this->moduleConfig->getVciUserAttributeToCredentialClaimPathMapFor( + $resolvedCredentialIdentifier, + ); + foreach ($attributeToCredentialClaimPathMap as $mapEntry) { + if (!is_array($mapEntry)) { + $this->loggerService->warning( + sprintf( + 'Attribute to credential claim path map entry is not an array. Value was: %s', + var_export($mapEntry, true), + ), + ); + continue; + } + + $userAttributeName = key($mapEntry); + if (!is_string($userAttributeName)) { + $this->loggerService->warning( + sprintf( + 'User attribute name from map entry is not a string. Map entry was: %s', + var_export($mapEntry, true), + ), + ); + continue; + } + + /** @psalm-suppress MixedAssignment */ + $credentialClaimPath = current($mapEntry); + if (!is_array($credentialClaimPath)) { + $this->loggerService->warning( + sprintf( + 'Credential claim path for user attribute name %s is not an array. Value was: %s', + $userAttributeName, + var_export($credentialClaimPath, true), + ), + ); + continue; + } + $credentialClaimPath = array_filter($credentialClaimPath, 'is_string'); + if (!in_array($credentialClaimPath, $validClaimPaths)) { + $this->loggerService->warning( + 'Attribute "%s" does not use one of valid credential claim paths.', + $mapEntry, + ); + continue; + } + + if (!isset($userAttributes[$userAttributeName])) { + $this->loggerService->warning( + 'Attribute "%s" does not exist in user attributes.', + $mapEntry, + ); + continue; + } + + // Normalize to string for single array values. + /** @psalm-suppress MixedAssignment */ + $attributeValue = is_array($userAttributes[$userAttributeName]) && + count($userAttributes[$userAttributeName]) === 1 ? + reset($userAttributes[$userAttributeName]) : + $userAttributes[$userAttributeName]; + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { + $this->verifiableCredentials->helpers()->arr()->setNestedValue( + $credentialSubject, + $attributeValue, + ...$credentialClaimPath, + ); + } + + if (in_array($credentialFormatId, self::SD_JWT_FORMAT_IDS, true)) { + // For now, we will only support disclosures for object properties. + $claimName = array_pop($credentialClaimPath); + if (!is_string($claimName)) { + $message = sprintf( + 'Invalid credential claim path for user attribute name %s. Can not extract claim name.' . + ' Path was: %s', + $userAttributeName, + print_r($credentialClaimPath, true), + ); + $this->loggerService->error($message); + continue; + } + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::VcSdJwt->value) { + array_unshift($credentialClaimPath, ClaimsEnum::Credential_Subject->value); + } + + /** @psalm-suppress ArgumentTypeCoercion */ + $disclosure = $this->verifiableCredentials->disclosureFactory()->build( + value: $attributeValue, + name: $claimName, + path: $credentialClaimPath, + saltBlacklist: $disclosureBag->salts(), + ); + + $disclosureBag->add($disclosure); + } + } + + // Make sure that the subject identifier is in credentialSubject claim. + $this->setCredentialClaimValue( + $credentialSubject, + [ClaimsEnum::Credential_Subject->value, ClaimsEnum::Id->value], + $sub, + ); + + // TODO mivanci Add support for multiple signature key pairs. For now, we only support (first) one. + $vciSignatureKeyPair = $this->moduleConfig + ->getVciSignatureKeyPairBag() + ->getFirstOrFail(); + + $signingKey = $vciSignatureKeyPair->getKeyPair()->getPrivateKey(); + + $publicKey = $vciSignatureKeyPair->getKeyPair()->getPublicKey(); + + $base64PublicKey = json_encode($publicKey->jwk()->all(), JSON_UNESCAPED_SLASHES); + if ($base64PublicKey === false) { + throw new \RuntimeException('Could not encode public key to JSON.'); + } + $base64PublicKey = Base64Url::encode($base64PublicKey); + + $issuerDid = 'did:jwk:' . $base64PublicKey; + + $issuedAt = new \DateTimeImmutable(); + + $vcId = $this->moduleConfig->getIssuer() . '/vc/' . uniqid(); + $signatureAlgorithm = $vciSignatureKeyPair->getSignatureAlgorithm(); + + $verifiableCredential = null; + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::JwtVcJson->value) { + $verifiableCredential = $this->verifiableCredentials->jwtVcJsonFactory()->fromData( + $signingKey, + $signatureAlgorithm, + [ + ClaimsEnum::Vc->value => [ + ClaimsEnum::AtContext->value => [ + AtContextsEnum::W3Org2018CredentialsV1->value, + ], + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + $resolvedCredentialIdentifier, + ], + //ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::Issuance_Date->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Id->value => $vcId, + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], + ], + //ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, + ], + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', + ], + ); + } + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::DcSdJwt->value) { + $sdJwtPayload = [ + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, + ClaimsEnum::Vct->value => $resolvedCredentialIdentifier, + ]; + + if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { + $sdJwtPayload[ClaimsEnum::Cnf->value] = [ + ClaimsEnum::Kid->value => $proofKeyId, + ]; + } + + $verifiableCredential = $this->verifiableCredentials->sdJwtVcFactory()->fromData( + $signingKey, + $signatureAlgorithm, + $sdJwtPayload, + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', + ], + disclosureBag: $disclosureBag, + ); + } + + if ($credentialFormatId === CredentialFormatIdentifiersEnum::VcSdJwt->value) { + $sdJwtPayload = [ + ClaimsEnum::AtContext->value => [ + AtContextsEnum::W3OrgNsCredentialsV2->value, + ], + ClaimsEnum::Id->value => $vcId, + ClaimsEnum::Type->value => [ + CredentialTypesEnum::VerifiableCredential->value, + $resolvedCredentialIdentifier, + ], + ClaimsEnum::Issuer->value => $issuerDid, + ClaimsEnum::ValidFrom->value => $issuedAt->format(\DateTimeInterface::RFC3339), + ClaimsEnum::Credential_Subject->value => + $credentialSubject[ClaimsEnum::Credential_Subject->value] ?? [], + ClaimsEnum::Iss->value => $issuerDid, + ClaimsEnum::Iat->value => $issuedAt->getTimestamp(), + ClaimsEnum::Nbf->value => $issuedAt->getTimestamp(), + ClaimsEnum::Sub->value => $sub, + ClaimsEnum::Jti->value => $vcId, + ]; + + if ($proof instanceof OpenId4VciProof && is_string($proofKeyId = $proof->getKeyId())) { + $sdJwtPayload[ClaimsEnum::Cnf->value] = [ + ClaimsEnum::Kid->value => $proofKeyId, + ]; + } + + $verifiableCredential = $this->verifiableCredentials->vcSdJwtFactory()->fromData( + $signingKey, + $signatureAlgorithm, + $sdJwtPayload, + [ + ClaimsEnum::Kid->value => $issuerDid . '#0', + ], + ); + } + + if ($verifiableCredential === null) { + throw new OpenIdException('Invalid credential format ID.'); + } + + if (is_string($issuerState)) { + $this->loggerService->debug('Revoking issuer state.', ['issuerState' => $issuerState]); + $this->issuerStateRepository->revoke($issuerState); + } + + $this->loggerService->debug('Returning credential response.', [ + 'credentials' => [ + ['credential' => $verifiableCredential->getToken()], + ], + ],); + + return $this->routes->newJsonResponse( + [ + 'credentials' => [ + ['credential' => $verifiableCredential->getToken()], + ], + ], + ); + } + + /** + * Helper method to set a claim value at a path. Supports creating nested arrays dynamically. + * @psalm-suppress UnusedVariable, MixedAssignment + * @param array-key[] $path + */ + protected function setCredentialClaimValue(array &$claims, array $path, mixed $value): void + { + $temp = &$claims; + + foreach ($path as $key) { + if (!is_array($temp)) { + $temp = []; + } + + if (!isset($temp[$key])) { + $temp[$key] = []; + } + + $temp = &$temp[$key]; + } + + // If the value is an array and holds only one element, we will set the value directly. + if (is_array($value) && count($value) === 1) { + $temp = $value[0]; + } else { + $temp = $value; + } + } +} diff --git a/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php new file mode 100644 index 00000000..b4b6e984 --- /dev/null +++ b/src/Controllers/VerifiableCredentials/JwtVcIssuerConfigurationController.php @@ -0,0 +1,49 @@ +moduleConfig->getVciEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + public function configuration(): Response + { + $configuration = [ + ClaimsEnum::Issuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::JwksUri->value => $this->moduleConfig->getModuleUrl(RoutesEnum::Jwks->value), + ]; + + return $this->routes->newJsonResponse($configuration); + } +} diff --git a/src/Controllers/VerifiableCredentials/NonceController.php b/src/Controllers/VerifiableCredentials/NonceController.php new file mode 100644 index 00000000..1f33362e --- /dev/null +++ b/src/Controllers/VerifiableCredentials/NonceController.php @@ -0,0 +1,46 @@ +moduleConfig->getVciEnabled()) { + $this->loggerService->warning('Verifiable Credential capabilities not enabled.'); + throw OidcServerException::forbidden('Verifiable Credential capabilities not enabled.'); + } + } + + /** + * @throws \Exception + */ + public function nonce(): Response + { + $this->loggerService->debug('NonceController::nonce'); + + $nonce = $this->nonceService->generateNonce(); + + return $this->routes->newJsonResponse( + ['c_nonce' => $nonce], + 200, + [ + 'Cache-Control' => 'no-store', + 'Access-Control-Allow-Origin' => '*', + ], + ); + } +} diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index b98fe7cf..9b46904c 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -17,18 +17,19 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Token; -use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; 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; use SimpleSAML\Module\oidc\Entities\Traits\RevokeTokenTrait; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\Jws\ParsedJws; use Stringable; /** @@ -62,13 +63,17 @@ public function __construct( OAuth2ClientEntityInterface $clientEntity, array $scopes, DateTimeImmutable $expiryDateTime, - CryptKey $privateKey, - protected JsonWebTokenBuilderService $jsonWebTokenBuilderService, + protected readonly Jws $jws, + protected readonly ModuleConfig $moduleConfig, int|string|null $userIdentifier = null, ?string $authCodeId = null, ?array $requestedClaims = null, ?bool $isRevoked = false, - ?Configuration $jwtConfiguration = null, + protected readonly ?FlowTypeEnum $flowTypeEnum = null, + protected readonly ?array $authorizationDetails = null, + protected readonly ?string $boundClientId = null, + protected readonly ?string $boundRedirectUri = null, + protected readonly ?string $issuerState = null, ) { $this->setIdentifier($id); $this->setClient($clientEntity); @@ -76,14 +81,12 @@ public function __construct( $this->addScope($scope); } $this->setExpiryDateTime($expiryDateTime); - $this->setPrivateKey($privateKey); $this->setUserIdentifier($userIdentifier); $this->setAuthCodeId($authCodeId); $this->setRequestedClaims($requestedClaims ?? []); if ($isRevoked) { $this->revoke(); } - $jwtConfiguration !== null ? $this->jwtConfiguration = $jwtConfiguration : $this->initJwtConfiguration(); } /** @@ -114,6 +117,13 @@ public function getState(): array 'is_revoked' => $this->isRevoked(), 'auth_code_id' => $this->getAuthCodeId(), 'requested_claims' => json_encode($this->requestedClaims, JSON_THROW_ON_ERROR), + 'flow_type' => $this->flowTypeEnum?->value, + 'authorization_details' => is_array($this->authorizationDetails) ? + json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR) : + null, + 'bound_client_id' => $this->boundClientId, + 'bound_redirect_uri' => $this->boundRedirectUri, + 'issuer_state' => $this->issuerState, ]; } @@ -124,7 +134,7 @@ public function getState(): array */ public function __toString(): string { - return $this->stringRepresentation = $this->convertToJWT()->toString(); + return $this->stringRepresentation = $this->convertToJWT()->getToken(); } /** @@ -137,25 +147,64 @@ public function toString(): ?string } /** - * Implemented instead of original AccessTokenTrait::convertToJWT() method in order to remove microseconds from - * timestamps and to add claims like iss, etc., by using our own JWT builder service. + * Implemented instead of original AccessTokenTrait::convertToJWT() method + * in order to remove microseconds from timestamps and to add claims + * like iss, etc. * - * @return \Lcobucci\JWT\Token * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \Exception */ - protected function convertToJWT(): Token + protected function convertToJWT(): ParsedJws { - /** @psalm-suppress ArgumentTypeCoercion */ - $jwtBuilder = $this->jsonWebTokenBuilderService->getProtocolJwtBuilder() - ->permittedFor($this->getClient()->getIdentifier()) - ->identifiedBy((string)$this->getIdentifier()) - ->issuedAt(new DateTimeImmutable()) - ->canOnlyBeUsedAfter(new DateTimeImmutable()) - ->expiresAt($this->getExpiryDateTime()) - ->relatedTo((string) $this->getUserIdentifier()) - ->withClaim('scopes', $this->getScopes()); - - return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($jwtBuilder); + $protocolSignatureKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairBag()->getFirstOrFail(); + $currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp(); + + $payload = array_filter([ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => (string)$this->getIdentifier(), + ClaimsEnum::Aud->value => $this->getClient()->getIdentifier(), + ClaimsEnum::Nbf->value => $currentTimestamp, + ClaimsEnum::Exp->value => $this->expiryDateTime->getTimestamp(), + ClaimsEnum::Sub->value => (string)$this->getUserIdentifier(), + 'scopes' => $this->getScopes(), + ClaimsEnum::IssuerState->value => $this->issuerState, + ]); + + $header = [ + ClaimsEnum::Kid->value => $protocolSignatureKeyPair->getKeyPair()->getKeyId(), + ]; + + return $this->jws->parsedJwsFactory()->fromData( + $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(), + $protocolSignatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + ); + } + + public function getFlowTypeEnum(): ?FlowTypeEnum + { + return $this->flowTypeEnum; + } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } + + public function getIssuerState(): ?string + { + return $this->issuerState; } } diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index d98fe347..2cacb7e1 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -19,6 +19,7 @@ use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\MementoInterface; use SimpleSAML\Module\oidc\Entities\Traits\OidcAuthCodeTrait; @@ -43,6 +44,12 @@ public function __construct( ?string $redirectUri = null, ?string $nonce = null, bool $isRevoked = false, + protected readonly ?FlowTypeEnum $flowTypeEnum = null, + protected readonly ?string $txCode = null, + protected readonly ?array $authorizationDetails = null, + protected readonly ?string $boundClientId = null, + protected readonly ?string $boundRedirectUri = null, + protected readonly ?string $issuerState = null, ) { $this->identifier = $id; $this->client = $client; @@ -68,6 +75,49 @@ public function getState(): array 'is_revoked' => $this->isRevoked(), 'redirect_uri' => $this->getRedirectUri(), 'nonce' => $this->getNonce(), + 'flow_type' => $this->flowTypeEnum?->value, + 'tx_code' => $this->txCode, + 'authorization_details' => is_array($this->authorizationDetails) ? + json_encode($this->authorizationDetails, JSON_THROW_ON_ERROR) : + null, + 'bound_client_id' => $this->boundClientId, + 'bound_redirect_uri' => $this->boundRedirectUri, + 'issuer_state' => $this->issuerState, ]; } + + public function isVciPreAuthorized(): bool + { + return $this->flowTypeEnum === FlowTypeEnum::VciPreAuthorizedCode; + } + + public function getTxCode(): ?string + { + return $this->txCode; + } + + public function getFlowTypeEnum(): ?FlowTypeEnum + { + return $this->flowTypeEnum; + } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } + + public function getIssuerState(): ?string + { + return $this->issuerState; + } } diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 6543c355..a0ffb92b 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -21,6 +21,7 @@ use League\OAuth2\Server\Entities\Traits\EntityTrait; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; class ClientEntity implements ClientEntityInterface @@ -51,7 +52,8 @@ class ClientEntity implements ClientEntityInterface public const string KEY_UPDATED_AT = 'updated_at'; public const string KEY_CREATED_AT = 'created_at'; public const string KEY_EXPIRES_AT = 'expires_at'; - public const string KEY_IS_FEDERATED = 'is_federated'; + public const string KEY_IS_GENERIC = 'is_generic'; + public const string KEY_EXTRA_METADATA = 'extra_metadata'; private string $secret; @@ -94,7 +96,8 @@ class ClientEntity implements ClientEntityInterface private ?DateTimeImmutable $updatedAt; private ?DateTimeImmutable $createdAt; private ?DateTimeImmutable $expiresAt; - private bool $isFederated; + private bool $isGeneric; + private ?array $extraMetadata; /** * @param string[] $redirectUri @@ -127,7 +130,8 @@ public function __construct( ?DateTimeImmutable $updatedAt = null, ?DateTimeImmutable $createdAt = null, ?DateTimeImmutable $expiresAt = null, - bool $isFederated = false, + bool $isGeneric = false, + ?array $extraMetadata = null, ) { $this->identifier = $identifier; $this->secret = $secret; @@ -151,7 +155,8 @@ public function __construct( $this->updatedAt = $updatedAt; $this->createdAt = $createdAt; $this->expiresAt = $expiresAt; - $this->isFederated = $isFederated; + $this->isGeneric = $isGeneric; + $this->extraMetadata = $extraMetadata; } /** @@ -189,7 +194,10 @@ public function getState(): array self::KEY_UPDATED_AT => $this->getUpdatedAt()?->format('Y-m-d H:i:s'), 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(), + self::KEY_EXTRA_METADATA => is_null($this->extraMetadata) ? + null : + json_encode($this->extraMetadata, JSON_THROW_ON_ERROR), ]; } @@ -218,7 +226,10 @@ public function toArray(): array self::KEY_UPDATED_AT => $this->updatedAt, self::KEY_CREATED_AT => $this->createdAt, self::KEY_EXPIRES_AT => $this->expiresAt, - self::KEY_IS_FEDERATED => $this->isFederated, + self::KEY_IS_GENERIC => $this->isGeneric, + + // Extra metadata + ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(), ]; } @@ -353,8 +364,28 @@ public function isExpired(): bool return $this->expiresAt !== null && $this->expiresAt < new DateTimeImmutable(); } - public function isFederated(): bool + public function isGeneric(): bool { - return $this->isFederated; + return $this->isGeneric; + } + + public function getExtraMetadata(): array + { + return $this->extraMetadata ?? []; + } + + public function getIdTokenSignedResponseAlg(): ?string + { + if (!is_array($this->extraMetadata)) { + return null; + } + + $idTokenSignedResponseAlg = $this->extraMetadata['id_token_signed_response_alg'] ?? null; + + if (!is_string($idTokenSignedResponseAlg)) { + return null; + } + + return $idTokenSignedResponseAlg; } } diff --git a/src/Entities/Interfaces/AuthCodeEntityInterface.php b/src/Entities/Interfaces/AuthCodeEntityInterface.php index 0fd28f7c..00f66db2 100644 --- a/src/Entities/Interfaces/AuthCodeEntityInterface.php +++ b/src/Entities/Interfaces/AuthCodeEntityInterface.php @@ -6,7 +6,7 @@ use League\OAuth2\Server\Entities\AuthCodeEntityInterface as OAuth2AuthCodeEntityInterface; -interface AuthCodeEntityInterface extends OAuth2AuthCodeEntityInterface +interface AuthCodeEntityInterface extends OAuth2AuthCodeEntityInterface, TokenRevokableInterface { /** * @return string|null diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index fd794774..dea9ff66 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -78,5 +78,8 @@ public function getUpdatedAt(): ?DateTimeImmutable; public function getCreatedAt(): ?DateTimeImmutable; public function getExpiresAt(): ?DateTimeImmutable; public function isExpired(): bool; - public function isFederated(): bool; + public function isGeneric(): bool; + + public function getExtraMetadata(): array; + public function getIdTokenSignedResponseAlg(): ?string; } diff --git a/src/Entities/IssuerStateEntity.php b/src/Entities/IssuerStateEntity.php new file mode 100644 index 00000000..beb044c5 --- /dev/null +++ b/src/Entities/IssuerStateEntity.php @@ -0,0 +1,57 @@ + $this->getValue(), + 'created_at' => $this->getCreatedAt()->format('Y-m-d H:i:s'), + 'expires_at' => $this->getExpirestAt()->format('Y-m-d H:i:s'), + 'is_revoked' => $this->isRevoked(), + ]; + } + + public function getValue(): string + { + return $this->value; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpirestAt(): DateTimeImmutable + { + return $this->expirestAt; + } + + public function isRevoked(): bool + { + return $this->isRevoked; + } + + public function revoke(): void + { + $this->isRevoked = true; + } +} diff --git a/src/Factories/AuthSimpleFactory.php b/src/Factories/AuthSimpleFactory.php index 0f708ce2..77ce0e48 100644 --- a/src/Factories/AuthSimpleFactory.php +++ b/src/Factories/AuthSimpleFactory.php @@ -16,6 +16,7 @@ namespace SimpleSAML\Module\oidc\Factories; +use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use SimpleSAML\Auth\Simple; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\ModuleConfig; @@ -31,7 +32,7 @@ public function __construct( * @codeCoverageIgnore * @throws \Exception */ - public function build(ClientEntityInterface $clientEntity): Simple + public function build(OAuth2ClientEntityInterface $clientEntity): Simple { $authSourceId = $this->resolveAuthSourceId($clientEntity); @@ -52,8 +53,19 @@ public function getDefaultAuthSource(): Simple * * @throws \Exception */ - public function resolveAuthSourceId(ClientEntityInterface $client): string + public function resolveAuthSourceId(OAuth2ClientEntityInterface $client): string { - return $client->getAuthSourceId() ?? $this->moduleConfig->getDefaultAuthSourceId(); + $defaultAuthSourceId = $this->moduleConfig->getDefaultAuthSourceId(); + + if ($client instanceof ClientEntityInterface) { + $client->getAuthSourceId() ?? $this->moduleConfig->getDefaultAuthSourceId(); + } + + return $defaultAuthSourceId; + } + + public function forAuthSourceId(string $authSourceId): Simple + { + return new Simple($authSourceId); } } diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 54cf5d38..9970b948 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -24,9 +24,11 @@ use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\ImplicitGrant; +use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; +use SimpleSAML\Module\oidc\Services\LoggerService; class AuthorizationServerFactory { @@ -38,9 +40,11 @@ public function __construct( private readonly AuthCodeGrant $authCodeGrant, private readonly ImplicitGrant $implicitGrant, private readonly RefreshTokenGrant $refreshTokenGrant, - private readonly IdTokenResponse $idTokenResponse, + private readonly TokenResponse $tokenResponse, private readonly RequestRulesManager $requestRulesManager, private readonly CryptKey $privateKey, + private readonly PreAuthCodeGrant $preAuthCodeGrant, + private readonly LoggerService $loggerService, ) { } @@ -52,8 +56,9 @@ public function build(): AuthorizationServer $this->scopeRepository, $this->privateKey, $this->moduleConfig->getEncryptionKey(), - $this->idTokenResponse, + $this->tokenResponse, $this->requestRulesManager, + $this->loggerService, ); $authorizationServer->enableGrantType( @@ -71,6 +76,13 @@ public function build(): AuthorizationServer $this->moduleConfig->getAccessTokenDuration(), ); + if ($this->moduleConfig->getVciEnabled()) { + $authorizationServer->enableGrantType( + $this->preAuthCodeGrant, + $this->moduleConfig->getAccessTokenDuration(), + ); + } + return $authorizationServer; } } diff --git a/src/Factories/CoreFactory.php b/src/Factories/CoreFactory.php index 90c3454b..dba9cd26 100644 --- a/src/Factories/CoreFactory.php +++ b/src/Factories/CoreFactory.php @@ -6,10 +6,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Core; -use SimpleSAML\OpenID\SupportedAlgorithms; class CoreFactory { @@ -25,19 +22,8 @@ public function __construct( */ public function build(): Core { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), - SignatureAlgorithmEnum::RS384, - SignatureAlgorithmEnum::RS512, - SignatureAlgorithmEnum::ES256, - SignatureAlgorithmEnum::ES384, - SignatureAlgorithmEnum::ES512, - ), - ); - return new Core( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), logger: $this->loggerService, ); } diff --git a/src/Factories/CredentialOfferUriFactory.php b/src/Factories/CredentialOfferUriFactory.php new file mode 100644 index 00000000..e052ba97 --- /dev/null +++ b/src/Factories/CredentialOfferUriFactory.php @@ -0,0 +1,306 @@ + 0) { + try { + $issuerState = $this->issuerStateEntityFactory->buildNew(); + $this->issuerStateRepository->persist($issuerState); + break; + } catch (\Throwable $e) { + if ($issuerStateGenerationAttempts === 0) { + $this->loggerService->error( + 'All attempts to generate Issuer State failed: ' . $e->getMessage(), + ); + throw new OpenIdException('Failed to generate issuer state.', previous: $e); + } + + $this->loggerService->warning('Failed to generate Issuer State: ' . $e->getMessage()); + } + } + + /** @psalm-var \SimpleSAML\Module\oidc\Entities\IssuerStateEntity $issuerState */ + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + ...$credentialConfigurationIds, + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::AuthorizationCode->value => [ + ClaimsEnum::IssuerState->value => $issuerState->getValue(), + ], + ], + ], + ); + + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + return "openid-credential-offer://?$parameterName=$credentialOfferValue"; + } + + /** + * @param string[] $credentialConfigurationIds + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + public function buildPreAuthorized( + array $credentialConfigurationIds, + array $userAttributes, + bool $useTxCode = false, + string $userEmailAttributeName = null, + ): string { + if (empty($credentialConfigurationIds)) { + throw new RuntimeException('No credential configuration IDs provided.'); + } + + $credentialConfigurationIdsSupported = $this->moduleConfig->getVciCredentialConfigurationIdsSupported(); + + if (empty($credentialConfigurationIdsSupported)) { + throw new RuntimeException('No credential configuration IDs configured.'); + } + + if (array_diff($credentialConfigurationIds, $credentialConfigurationIdsSupported)) { + throw new RuntimeException('Unsupported credential configuration IDs provided.'); + } + + // TODO mivanci Wallet (client) credential_offer_endpoint metadata + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#client-metadata + + $scopes = array_map( + fn (string $scope) => new ScopeEntity($scope), + ['openid', ...$credentialConfigurationIds], + ); + + // Currently, we need a dedicated client for which the PreAuthZed code will be bound to. + // TODO mivanci: Remove requirement for dedicated client for (pre-)authorization codes once the dynamic + // client registration is enabled. + $client = $this->clientRepository->getGenericForVci(); + + $userId = null; + try { + /** @psalm-suppress MixedAssignment */ + $userId = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $this->moduleConfig->getUserIdentifierAttribute(), + ); + + if (!is_scalar($userId)) { + throw new RuntimeException('User identifier attribute value is not a string.'); + } + $userId = strval($userId); + } catch (\Throwable $e) { + $this->loggerService->warning( + 'Could not extract user identifier from user attributes: ' . $e->getMessage(), + $userAttributes, + ); + } + + if ($userId === null) { + $this->loggerService->warning('Falling back to user attributes hash for user identifier.'); + $sortedAttributes = $userAttributes; + $this->verifiableCredentials->helpers()->arr()->hybridSort($sortedAttributes); + $userId = 'vci_credential_offer_preauthz_' . hash('sha256', serialize($sortedAttributes)); + $this->loggerService->info( + 'Generated user identifier based on user attributes: ' . $userId, + $userAttributes, + ); + } + + $oldUserEntity = $this->userRepository->getUserEntityByIdentifier($userId); + + $userEntity = $this->userEntityFactory->fromData($userId, $userAttributes); + + if ($oldUserEntity instanceof UserEntity) { + $this->userRepository->update($userEntity); + } else { + $this->userRepository->add($userEntity); + } + + $txCode = null; + $userEmail = null; + $userEmailAttributeName ??= $this->moduleConfig->getDefaultUsersEmailAttributeName(); + if ($useTxCode) { + $userEmail = $this->getUserEmail($userEmailAttributeName, $userAttributes); + $txCodeDescription = 'Please provide the one-time code that was sent to e-mail ' . $userEmail; + $txCode = $this->buildTxCode($txCodeDescription); + $this->loggerService->debug( + 'Generated TxCode for sending by email: ' . $txCode->getCodeAsString(), + $txCode->jsonSerialize(), + ); + } + + $authCodeIdGenerationAttempts = 3; + while ($authCodeIdGenerationAttempts-- > 0) { + try { + $authCode = $this->authCodeEntityFactory->fromData( + id: $this->sspBridge->utils()->random()->generateID(), + client: $client, + scopes: $scopes, + expiryDateTime: (new DateTimeImmutable())->add($this->moduleConfig->getAuthCodeDuration()), + userIdentifier: $userId, + redirectUri: 'openid-credential-offer://', + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + txCode: $txCode instanceof VerifiableCredentials\TxCode ? $txCode->getCodeAsString() : null, + ); + $this->authCodeRepository->persistNewAuthCode($authCode); + break; + } catch (\Throwable $e) { + if ($authCodeIdGenerationAttempts === 0) { + $this->loggerService->error( + 'All attempts to generate Authorization Code failed: ' . $e->getMessage(), + ); + throw new OpenIdException('Failed to generate Authorization Code.', previous: $e); + } + + $this->loggerService->warning('Failed to generate Authorization Code ID: ' . $e->getMessage()); + } + } + + /** @psalm-var \SimpleSAML\Module\oidc\Entities\AuthCodeEntity $authCode */ + + $credentialOffer = $this->verifiableCredentials->credentialOfferFactory()->from( + parameters: [ + ClaimsEnum::CredentialIssuer->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::CredentialConfigurationIds->value => [ + ...$credentialConfigurationIds, + ], + ClaimsEnum::Grants->value => [ + GrantTypesEnum::PreAuthorizedCode->value => [ + ClaimsEnum::PreAuthorizedCode->value => $authCode->getIdentifier(), + ...(array_filter( + [ + ClaimsEnum::TxCode->value => $txCode instanceof VerifiableCredentials\TxCode ? + $txCode->jsonSerialize() : + null, + ], + )), + ], + ], + ], + ); + + if ($txCode instanceof VerifiableCredentials\TxCode && $userEmail !== null) { + $this->sendTxCodeByEmail($txCode, $userEmail); + } + + $credentialOfferValue = $credentialOffer->jsonSerialize(); + $parameterName = ParametersEnum::CredentialOfferUri->value; + if (is_array($credentialOfferValue)) { + $parameterName = ParametersEnum::CredentialOffer->value; + $credentialOfferValue = json_encode($credentialOfferValue); + } + + return "openid-credential-offer://?$parameterName=$credentialOfferValue"; + } + + /** + * @param mixed[] $userAttributes + * @throws RuntimeException + */ + public function getUserEmail(string $userEmailAttributeName, array $userAttributes): string + { + try { + $userEmail = $this->sspBridge->utils()->attributes()->getExpectedAttribute( + $userAttributes, + $userEmailAttributeName, + true, + ); + } catch (Exception $e) { + throw new RuntimeException('Could not extract user email from user attributes: ' . $e->getMessage()); + } + + if (!is_string($userEmail)) { + throw new RuntimeException('User email attribute value is not a string.'); + } + + return $userEmail; + } + + public function buildTxCode( + string $description, + int|string $txCode = null, + ): TxCode { + $txCode ??= rand(1000, 9999); + + return $this->verifiableCredentials->txCodeFactory()->build( + $txCode, + $description, + ); + } + + public function sendTxCodeByEmail(TxCode $txCode, string $email, string $subject = null): void + { + $subject ??= 'Your one-time code'; + + $email = $this->emailFactory->build( + subject: $subject, + to: $email, + ); + + $email->setText('Use the following code to complete the transaction.'); + + $email->setData([ + 'Transaction Code' => $txCode->getCodeAsString(), + ]); + + $email->send(); + } +} diff --git a/src/Factories/CryptKeyFactory.php b/src/Factories/CryptKeyFactory.php index a7cc02ea..176334fd 100644 --- a/src/Factories/CryptKeyFactory.php +++ b/src/Factories/CryptKeyFactory.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Factories; use League\OAuth2\Server\CryptKey; +use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\ModuleConfig; class CryptKeyFactory @@ -19,9 +20,15 @@ public function __construct( */ public function buildPrivateKey(): CryptKey { + $defaultSignatureKeyPairConfig = $this->getDefaultProtocolSignatureKeyPairConfig(); + + $privateKeyFilename = $defaultSignatureKeyPairConfig[ModuleConfig::KEY_PRIVATE_KEY_FILENAME]; + $privateKeyPassword = $defaultSignatureKeyPairConfig[ModuleConfig::KEY_PRIVATE_KEY_PASSWORD] ?? null; + return new CryptKey( - $this->moduleConfig->getProtocolPrivateKeyPath(), - $this->moduleConfig->getProtocolPrivateKeyPassPhrase(), + $privateKeyFilename, + $privateKeyPassword, + true, ); } @@ -30,6 +37,33 @@ public function buildPrivateKey(): CryptKey */ public function buildPublicKey(): CryptKey { - return new CryptKey($this->moduleConfig->getProtocolCertPath()); + $defaultSignatureKeyPairConfig = $this->getDefaultProtocolSignatureKeyPairConfig(); + $publicKeyFilename = $defaultSignatureKeyPairConfig[ModuleConfig::KEY_PUBLIC_KEY_FILENAME]; + return new CryptKey($publicKeyFilename, null, false); + } + + /** + * @return array{ + * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, + * private_key_filename: non-empty-string, + * public_key_filename: non-empty-string, + * private_key_password: ?non-empty-string, + * key_id: ?non-empty-string + * } + * @throws ConfigurationError + * + */ + protected function getDefaultProtocolSignatureKeyPairConfig(): array + { + $defaultProtocolKeyPair = $this->moduleConfig->getProtocolSignatureKeyPairs(); + + /** @psalm-suppress MixedAssignment */ + $defaultProtocolKeyPair = $defaultProtocolKeyPair[array_key_first($defaultProtocolKeyPair)]; + + if (!is_array($defaultProtocolKeyPair)) { + throw new ConfigurationError('Invalid protocol signature key pairs config.'); + } + + return $this->moduleConfig->getValidatedSignatureKeyPairArray($defaultProtocolKeyPair); } } diff --git a/src/Factories/EmailFactory.php b/src/Factories/EmailFactory.php new file mode 100644 index 00000000..582d5cc7 --- /dev/null +++ b/src/Factories/EmailFactory.php @@ -0,0 +1,29 @@ +privateKey, - $this->jsonWebTokenBuilderService, + $this->jws, + $this->moduleConfig, $userIdentifier, $authCodeId, $requestedClaims, $isRevoked, + flowTypeEnum: $flowTypeEnum, + authorizationDetails: $authorizationDetails, + boundClientId: $boundClientId, + boundRedirectUri: $boundRedirectUri, + issuerState: $issuerState, ); } @@ -90,6 +101,17 @@ 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']; + $issuerState = empty($state['issuer_state']) ? null : (string)$state['issuer_state']; + return $this->fromData( $id, $client, @@ -99,6 +121,11 @@ public function fromState(array $state): AccessTokenEntity $authCodeId, $stateRequestedClaims, $isRevoked, + $flowType, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, + $issuerState, ); } } diff --git a/src/Factories/Entities/AuthCodeEntityFactory.php b/src/Factories/Entities/AuthCodeEntityFactory.php index be0cdee2..0304b804 100644 --- a/src/Factories/Entities/AuthCodeEntityFactory.php +++ b/src/Factories/Entities/AuthCodeEntityFactory.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; @@ -30,7 +31,13 @@ public function fromData( ?string $userIdentifier = null, ?string $redirectUri = null, ?string $nonce = null, + ?string $issuerState = null, bool $isRevoked = false, + ?FlowTypeEnum $flowTypeEnum = null, + ?string $txCode = null, + ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, ): AuthCodeEntity { return new AuthCodeEntity( $id, @@ -41,6 +48,12 @@ public function fromData( $redirectUri, $nonce, $isRevoked, + $flowTypeEnum, + $txCode, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, + $issuerState, ); } @@ -81,6 +94,18 @@ public function fromState(array $state): AuthCodeEntity $redirectUri = empty($state['redirect_uri']) ? null : (string)$state['redirect_uri']; $nonce = empty($state['nonce']) ? null : (string)$state['nonce']; $isRevoked = (bool) $state['is_revoked']; + $flowType = empty($state['flow_type']) ? null : FlowTypeEnum::tryFrom((string)$state['flow_type']); + $txCode = empty($state['tx_code']) ? null : (string)$state['tx_code']; + $issuerState = empty($state['issuer_state']) ? null : (string)$state['issuer_state']; + + /** @psalm-suppress MixedAssignment */ + $authorizationDetails = isset($state['authorization_details']) && is_string($state['authorization_details']) ? + json_decode($state['authorization_details'], true, 512, JSON_THROW_ON_ERROR) : + null; + $authorizationDetails = is_array($authorizationDetails) ? $authorizationDetails : null; + + $boundClientId = empty($state['bound_client_id']) ? null : (string)$state['bound_client_id']; + $boundRedirectUri = empty($state['bound_redirect_uri']) ? null : (string)$state['bound_redirect_uri']; return $this->fromData( $id, @@ -90,7 +115,13 @@ public function fromState(array $state): AuthCodeEntity $userIdentifier, $redirectUri, $nonce, + $issuerState, $isRevoked, + $flowType, + $txCode, + $authorizationDetails, + $boundClientId, + $boundRedirectUri, ); } } diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index d0c54589..df9c30cf 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -5,7 +5,6 @@ namespace SimpleSAML\Module\oidc\Factories\Entities; use DateTimeImmutable; -use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\ClientEntity; @@ -13,11 +12,9 @@ use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; -use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\ApplicationTypesEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; -use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum; use SimpleSAML\OpenID\Codebooks\ScopesEnum; use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; @@ -28,7 +25,6 @@ public function __construct( private readonly SspBridge $sspBridge, private readonly Helpers $helpers, private readonly ModuleConfig $moduleConfig, - private readonly RequestParamsResolver $requestParamsResolver, ) { } @@ -63,7 +59,8 @@ public function fromData( ?DateTimeImmutable $updatedAt = null, ?DateTimeImmutable $createdAt = null, ?DateTimeImmutable $expiresAt = null, - bool $isFederated = false, + bool $isGeneric = false, + ?array $extraMetadata = null, ): ClientEntityInterface { return new ClientEntity( $id, @@ -88,7 +85,8 @@ public function fromData( $updatedAt, $createdAt, $expiresAt, - $isFederated, + $isGeneric, + $extraMetadata, ); } @@ -106,7 +104,6 @@ public function fromRegistrationData( ?ClientEntityInterface $existingClient = null, ?string $clientIdentifier = null, ?array $federationJwks = null, - ?ServerRequestInterface $authorizationRequest = null, ): ClientEntityInterface { $id = $clientIdentifier ?? $existingClient?->getIdentifier() ?? @@ -139,7 +136,6 @@ public function fromRegistrationData( $isConfidential = $existingClient?->isConfidential() ?? $this->determineIsConfidential( $metadata, - $authorizationRequest, ); $owner = $existingClient?->getOwner(); @@ -189,7 +185,21 @@ public function fromRegistrationData( // $expiresAt = $expiresAt; - $isFederated = $existingClient?->isFederated() ?? false; + $isGeneric = $existingClient?->isGeneric() ?? false; + + $extraMetadata = $existingClient?->getExtraMetadata() ?? []; + + // Handle any other supported client metadata as extra metadata. + // id_token_signed_response_alg + $idTokenSignedResponseAlg = isset($metadata[ClaimsEnum::IdTokenSignedResponseAlg->value]) && + is_string($metadata[ClaimsEnum::IdTokenSignedResponseAlg->value]) ? + $metadata[ClaimsEnum::IdTokenSignedResponseAlg->value] : + $existingClient?->getIdTokenSignedResponseAlg(); + + // TODO mivanci Check if id_token_signed_response_alg is supported. + + $extraMetadata[ClaimsEnum::IdTokenSignedResponseAlg->value] = $idTokenSignedResponseAlg; + return $this->fromData( $id, @@ -214,13 +224,13 @@ public function fromRegistrationData( $updatedAt, $createdAt, $expiresAt, - $isFederated, + $isGeneric, + $extraMetadata, ); } protected function determineIsConfidential( array $metadata, - ?ServerRequestInterface $authorizationRequest, ): bool { if ( array_key_exists(ClaimsEnum::ApplicationType->value, $metadata) && @@ -257,14 +267,6 @@ protected function determineIsConfidential( return false; } - if ( - $authorizationRequest && - $this->requestParamsResolver->get(ParamsEnum::CodeChallenge->value, $authorizationRequest) - ) { - // Usage of code_challenge parameter indicates public client. - return false; - } - // Assume confidential client. return true; } @@ -352,7 +354,12 @@ public function fromState(array $state): ClientEntityInterface $expiresAt = empty($state[ClientEntity::KEY_EXPIRES_AT]) ? null : $this->helpers->dateTime()->getUtc((string)$state[ClientEntity::KEY_EXPIRES_AT]); - $isFederated = (bool)$state[ClientEntity::KEY_IS_FEDERATED]; + $isGeneric = (bool)$state[ClientEntity::KEY_IS_GENERIC]; + + /** @var ?mixed[] $extraMetadata */ + $extraMetadata = empty($state[ClientEntity::KEY_EXTRA_METADATA]) ? + null : + json_decode((string)$state[ClientEntity::KEY_EXTRA_METADATA], true, 512, JSON_THROW_ON_ERROR); return $this->fromData( $id, @@ -377,7 +384,33 @@ public function fromState(array $state): ClientEntityInterface $updatedAt, $createdAt, $expiresAt, - $isFederated, + $isGeneric, + $extraMetadata, + ); + } + + public function getGenericForVci(): ClientEntityInterface + { + $clientId = 'vci_' . + hash('sha256', 'vci_' . $this->moduleConfig->sspConfig()->getString('secretsalt')); + + $clientSecret = $this->helpers->random()->getIdentifier(); + + $credentialConfigurationIdsSupported = $this->moduleConfig->getVciCredentialConfigurationIdsSupported(); + + $createdAt = $this->helpers->dateTime()->getUtc(); + + return $this->fromData( + id: $clientId, + secret: $clientSecret, + name: 'VCI Generic Client', + description: 'Generic client for Verifiable Credential Issuance flows.', + redirectUri: ['openid-credential-offer://'], + scopes: ['openid', ...$credentialConfigurationIdsSupported], + isEnabled: true, + updatedAt: $createdAt, + createdAt: $createdAt, + isGeneric: true, ); } } diff --git a/src/Factories/Entities/IssuerStateEntityFactory.php b/src/Factories/Entities/IssuerStateEntityFactory.php new file mode 100644 index 00000000..b0e75b43 --- /dev/null +++ b/src/Factories/Entities/IssuerStateEntityFactory.php @@ -0,0 +1,85 @@ +helpers->random()->getIdentifier()); + + $createdAt ??= $this->helpers->dateTime()->getUtc(); + $expiresAt ??= $createdAt->add($this->moduleConfig->getVciIssuerStateDuration()); + + return $this->fromData($value, $createdAt, $expiresAt, $isRevoked); + } + + /** + * @param string $value Issuer State Entity value, max 64 characters. + * @throws OpenIdException + */ + public function fromData( + string $value, + DateTimeImmutable $createdAt, + DateTimeImmutable $expiresAt, + bool $isRevoked = false, + ): IssuerStateEntity { + if (strlen($value) > 64) { + throw new OpenIdException('Invalid Issuer State Entity value.'); + } + + return new IssuerStateEntity($value, $createdAt, $expiresAt, $isRevoked); + } + + /** + * @param mixed[] $state + * @return IssuerStateEntity + * @throws OpenIdException + */ + public function fromState(array $state): IssuerStateEntity + { + if ( + !is_string($value = $state['value']) || + !is_string($createdAt = $state['created_at']) || + !is_string($expiresAt = $state['expires_at']) + ) { + throw new OpenIdException('Invalid Issuer State Entity state.'); + } + + if (strlen($value) > 64) { + throw new OpenIdException('Invalid Issuer State Entity value.'); + } + + $isRevoked = (bool)($state['is_revoked'] ?? true); + + return new IssuerStateEntity( + $value, + $this->helpers->dateTime()->getUtc($createdAt), + $this->helpers->dateTime()->getUtc($expiresAt), + $isRevoked, + ); + } +} diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index b2db10f4..65a4c610 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -7,10 +7,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Federation; -use SimpleSAML\OpenID\SupportedAlgorithms; class FederationFactory { @@ -27,20 +24,10 @@ public function __construct( */ public function build(): Federation { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), - SignatureAlgorithmEnum::RS384, - SignatureAlgorithmEnum::RS512, - SignatureAlgorithmEnum::ES256, - SignatureAlgorithmEnum::ES384, - SignatureAlgorithmEnum::ES512, - ), - ); - return new Federation( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), cache: $this->federationCache?->cache, logger: $this->loggerService, defaultTrustMarkStatusEndpointUsagePolicyEnum: diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index a72a53c4..5b90a452 100644 --- a/src/Factories/Grant/AuthCodeGrantFactory.php +++ b/src/Factories/Grant/AuthCodeGrantFactory.php @@ -26,6 +26,7 @@ use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; class AuthCodeGrantFactory @@ -41,6 +42,7 @@ public function __construct( private readonly AuthCodeEntityFactory $authCodeEntityFactory, private readonly RefreshTokenIssuer $refreshTokenIssuer, private readonly Helpers $helpers, + private readonly LoggerService $loggerService, ) { } @@ -60,6 +62,7 @@ public function build(): AuthCodeGrant $this->authCodeEntityFactory, $this->refreshTokenIssuer, $this->helpers, + $this->loggerService, ); $authCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/Grant/PreAuthCodeGrantFactory.php b/src/Factories/Grant/PreAuthCodeGrantFactory.php new file mode 100644 index 00000000..9e241c3f --- /dev/null +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -0,0 +1,71 @@ +authCodeRepository, + $this->accessTokenRepository, + $this->refreshTokenRepository, + $this->moduleConfig->getAuthCodeDuration(), + $this->requestRulesManager, + $this->requestParamsResolver, + $this->accessTokenEntityFactory, + $this->authCodeEntityFactory, + $this->refreshTokenIssuer, + $this->helpers, + $this->loggerService, + ); + $preAuthCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); + + return $preAuthCodeGrant; + } +} diff --git a/src/Factories/JwksFactory.php b/src/Factories/JwksFactory.php index 5991e17e..2e277c54 100644 --- a/src/Factories/JwksFactory.php +++ b/src/Factories/JwksFactory.php @@ -7,10 +7,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; -use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Jwks; -use SimpleSAML\OpenID\SupportedAlgorithms; class JwksFactory { @@ -27,14 +24,8 @@ public function __construct( */ public function build(): Jwks { - $supportedAlgorithms = new SupportedAlgorithms( - new SignatureAlgorithmBag( - SignatureAlgorithmEnum::from($this->moduleConfig->getFederationSigner()->algorithmId()), - ), - ); - return new Jwks( - supportedAlgorithms: $supportedAlgorithms, + supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), cache: $this->federationCache?->cache, logger: $this->loggerService, diff --git a/src/Factories/JwsFactory.php b/src/Factories/JwsFactory.php new file mode 100644 index 00000000..3d543322 --- /dev/null +++ b/src/Factories/JwsFactory.php @@ -0,0 +1,28 @@ +moduleConfig->getSupportedAlgorithms(), + supportedSerializers: $this->moduleConfig->getSupportedSerializers(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), + logger: $this->loggerService, + ); + } +} diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index bf28d4da..9b77e7c0 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -14,16 +14,19 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AuthorizationDetailsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IssuerStateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; @@ -35,13 +38,16 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Services\AuthenticationService; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Jwks; class RequestRulesManagerFactory { @@ -54,7 +60,6 @@ public function __construct( private readonly ScopeRepository $scopeRepository, private readonly CodeChallengeVerifiersRepository $codeChallengeVerifiersRepository, private readonly ClaimTranslatorExtractor $claimTranslatorExtractor, - private readonly CryptKeyFactory $cryptKeyFactory, private readonly RequestParamsResolver $requestParamsResolver, private readonly ClientEntityFactory $clientEntityFactory, private readonly Federation $federation, @@ -62,6 +67,9 @@ public function __construct( private readonly JwksResolver $jwksResolver, private readonly FederationParticipationValidator $federationParticipationValidator, private readonly SspBridge $sspBridge, + private readonly Jwks $jwks, + private readonly Core $core, + private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, ) { @@ -84,7 +92,8 @@ private function getDefaultRules(): array { return [ new StateRule($this->requestParamsResolver, $this->helpers), - new ClientIdRule( + new IssuerStateRule($this->requestParamsResolver, $this->helpers), + new ClientRule( $this->requestParamsResolver, $this->helpers, $this->clientRepository, @@ -93,9 +102,10 @@ private function getDefaultRules(): array $this->federation, $this->jwksResolver, $this->federationParticipationValidator, + $this->logger, $this->federationCache, ), - new RedirectUriRule($this->requestParamsResolver, $this->helpers), + new ClientRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), new RequestObjectRule($this->requestParamsResolver, $this->helpers, $this->jwksResolver), new PromptRule( $this->requestParamsResolver, @@ -127,7 +137,8 @@ private function getDefaultRules(): array $this->requestParamsResolver, $this->helpers, $this->moduleConfig, - $this->cryptKeyFactory, + $this->jwks, + $this->core, ), new PostLogoutRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->clientRepository), new UiLocalesRule($this->requestParamsResolver, $this->helpers), @@ -136,11 +147,11 @@ private function getDefaultRules(): array new ClientAuthenticationRule( $this->requestParamsResolver, $this->helpers, - $this->moduleConfig, - $this->jwksResolver, - $this->protocolCache, + $this->authenticatedOAuth2ClientResolver, ), new CodeVerifierRule($this->requestParamsResolver, $this->helpers), + new AuthorizationDetailsRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), + new ClientIdRule($this->requestParamsResolver, $this->helpers), ]; } } diff --git a/src/Factories/ResourceServerFactory.php b/src/Factories/ResourceServerFactory.php deleted file mode 100644 index 12245c9d..00000000 --- a/src/Factories/ResourceServerFactory.php +++ /dev/null @@ -1,41 +0,0 @@ -accessTokenRepository, - $this->publicKey, - $this->authorizationValidator, - ); - } -} diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 48986fcb..7e26f6e5 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -141,6 +141,20 @@ protected function includeDefaultMenuItems(): void Translate::noop('Test Trust Mark Validation'), ), ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigVerifiableCredential->value), + Translate::noop('Verifiable Credential Settings'), + ), + ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value), + Translate::noop('Test Verifiable Credential Issuance'), + ), + ); } public function setShowMenu(bool $showMenu): TemplateFactory diff --git a/src/Factories/IdTokenResponseFactory.php b/src/Factories/TokenResponseFactory.php similarity index 71% rename from src/Factories/IdTokenResponseFactory.php rename to src/Factories/TokenResponseFactory.php index acc031bd..93983eee 100644 --- a/src/Factories/IdTokenResponseFactory.php +++ b/src/Factories/TokenResponseFactory.php @@ -19,28 +19,31 @@ use League\OAuth2\Server\CryptKey; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\UserRepository; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Services\LoggerService; -class IdTokenResponseFactory +class TokenResponseFactory { public function __construct( private readonly ModuleConfig $moduleConfig, private readonly UserRepository $userRepository, private readonly IdTokenBuilder $idTokenBuilder, private readonly CryptKey $privateKey, + private readonly LoggerService $loggerService, ) { } - public function build(): IdTokenResponse + public function build(): TokenResponse { - $idTokenResponse = new IdTokenResponse( + $tokenResponse = new TokenResponse( $this->userRepository, $this->idTokenBuilder, $this->privateKey, + $this->loggerService, ); - $idTokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); + $tokenResponse->setEncryptionKey($this->moduleConfig->getEncryptionKey()); - return $idTokenResponse; + return $tokenResponse; } } diff --git a/src/Factories/VerifiableCredentialsFactory.php b/src/Factories/VerifiableCredentialsFactory.php new file mode 100644 index 00000000..869af3a3 --- /dev/null +++ b/src/Factories/VerifiableCredentialsFactory.php @@ -0,0 +1,30 @@ +moduleConfig->getSupportedAlgorithms(), + logger: $this->loggerService, + ); + } +} diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index 10699b46..694a206e 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; use Traversable; @@ -279,6 +280,10 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $signedJwksUri = trim((string)$values['signed_jwks_uri']); $values['signed_jwks_uri'] = empty($signedJwksUri) ? null : $signedJwksUri; + $idTokenSignedResponseAlg = trim((string)$values[ClaimsEnum::IdTokenSignedResponseAlg->value]); + $values[ClaimsEnum::IdTokenSignedResponseAlg->value] = empty($idTokenSignedResponseAlg) ? + null : $idTokenSignedResponseAlg; + return $values; } @@ -413,8 +418,11 @@ protected function buildForm(): void $this->addText('signed_jwks_uri', 'Signed JWKS URI') ->setHtmlAttribute('class', 'full-width'); - $this->addCheckbox('is_federated', '{oidc:client:is_federated}') - ->setHtmlAttribute('class', 'full-width'); + // TODO mivanci Properly fetch the list of supported algos + $this->addSelect('id_token_signed_response_alg', Translate::noop('ID Token Signing Algorithm')) + ->setHtmlAttribute('class', 'full-width') + ->setItems(['RS256'], false) + ->setPrompt(Translate::noop('-')); } /** diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 2f42d085..c27af248 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -17,35 +17,41 @@ namespace SimpleSAML\Module\oidc; use DateInterval; -use Lcobucci\JWT\Signer; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use ReflectionClass; use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ScopesEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\Serializers\JwsSerializerBag; +use SimpleSAML\OpenID\Serializers\JwsSerializerEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\SupportedSerializers; +use SimpleSAML\OpenID\ValueAbstracts; +use SimpleSAML\OpenID\ValueAbstracts\KeyPairFilenameConfig; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfig; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; class ModuleConfig { final public const string MODULE_NAME = 'oidc'; protected const string KEY_DESCRIPTION = 'description'; - - /** - * Default file name for module configuration. Can be overridden in constructor, for example, for testing purposes. - */ + public const string KEY_ALGORITHM = 'algorithm'; + public const string KEY_PRIVATE_KEY_FILENAME = 'private_key_filename'; + public const string KEY_PUBLIC_KEY_FILENAME = 'public_key_filename'; + public const string KEY_PRIVATE_KEY_PASSWORD = 'private_key_password'; + public const string KEY_KEY_ID = 'key_id'; final public const string DEFAULT_FILE_NAME = 'module_oidc.php'; - final public const string OPTION_PKI_PRIVATE_KEY_PASSPHRASE = 'pass_phrase'; - final public const string OPTION_PKI_PRIVATE_KEY_FILENAME = 'privatekey'; final public const string DEFAULT_PKI_PRIVATE_KEY_FILENAME = 'oidc_module.key'; - final public const string OPTION_PKI_CERTIFICATE_FILENAME = 'certificate'; final public const string DEFAULT_PKI_CERTIFICATE_FILENAME = 'oidc_module.crt'; final public const string OPTION_TOKEN_AUTHORIZATION_CODE_TTL = 'authCodeDuration'; final public const string OPTION_TOKEN_REFRESH_TOKEN_TTL = 'refreshTokenDuration'; final public const string OPTION_TOKEN_ACCESS_TOKEN_TTL = 'accessTokenDuration'; - final public const string OPTION_TOKEN_SIGNER = 'signer'; final public const string OPTION_AUTH_SOURCE = 'auth'; final public const string OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE = 'useridattr'; final public const string OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE = 'translate'; @@ -58,11 +64,7 @@ class ModuleConfig final public const string OPTION_CRON_TAG = 'cron_tag'; final public const string OPTION_ADMIN_UI_PERMISSIONS = 'permissions'; final public const string OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE = 'items_per_page'; - final public const string OPTION_FEDERATION_TOKEN_SIGNER = 'federation_token_signer'; - final public const string OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE = 'federation_private_key_passphrase'; - final public const string OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME = 'federation_private_key_filename'; final public const string DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME = 'oidc_module_federation.key'; - final public const string OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME = 'federation_certificate_filename'; final public const string DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME = 'oidc_module_federation.crt'; final public const string OPTION_ISSUER = 'issuer'; final public const string OPTION_FEDERATION_ENTITY_STATEMENT_DURATION = 'federation_entity_statement_duration'; @@ -75,7 +77,6 @@ class ModuleConfig final public const string OPTION_LOGO_URI = 'logo_uri'; final public const string OPTION_POLICY_URI = 'policy_uri'; final public const string OPTION_INFORMATION_URI = 'information_uri'; - final public const string OPTION_HOMEPAGE_URI = 'homepage_uri'; final public const string OPTION_ORGANIZATION_URI = 'organization_uri'; final public const string OPTION_FEDERATION_ENABLED = 'federation_enabled'; final public const string OPTION_FEDERATION_CACHE_ADAPTER = 'federation_cache_adapter'; @@ -97,15 +98,28 @@ class ModuleConfig final public const string OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED = 'protocol_discover_show_claims_supported'; - final public const string OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE = 'new_private_key_passphrase'; - final public const string OPTION_PKI_NEW_PRIVATE_KEY_FILENAME = 'new_privatekey'; - final public const string OPTION_PKI_NEW_CERTIFICATE_FILENAME = 'new_certificate'; - - final public const string OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE = - 'federation_new_private_key_passphrase'; - final public const string OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME = 'federation_new_private_key_filename'; - final public const string OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME = 'federation_new_certificate_filename'; - + final public const string OPTION_VCI_ENABLED = 'vci_enabled'; + final public const string OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED = + 'vci_credential_configurations_supported'; + final public const string OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP = + 'vci_user_attribute_to_credential_claim_path_map'; + final public const string OPTION_API_ENABLED = 'api_enabled'; + final public const string OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED = + 'api_vci_credential_offer_endpoint_enabled'; + final public const string OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED = + 'api_oauth2_token_introspection_endpoint_enabled'; + final public const string OPTION_API_TOKENS = 'api_tokens'; + final public const string OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME = 'users_email_attribute_name'; + final public const string OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP = + 'auth_sources_to_users_email_attribute_name_map'; + final public const string OPTION_VCI_ISSUER_STATE_TTL = 'vci_issuer_state_ttl'; + final public const string OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS = 'vci_allow_non_registered_clients'; + final public const string OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS = + 'vci_allowed_redirect_uri_prefixes_for_non_registered_clients'; + final public const string OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS = 'protocol_signature_key_pairs'; + final public const string OPTION_FEDERATION_SIGNATURE_KEY_PAIRS = 'federation_signature_key_pairs'; + final public const string OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; + final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -136,6 +150,11 @@ class ModuleConfig * @var Configuration SimpleSAMLphp configuration instance. */ private readonly Configuration $sspConfig; + protected ?SignatureKeyPairBag $protocolSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $protocolSignatureKeyPairConfigBag = null; + protected ?SignatureKeyPairBag $federationSignatureKeyPairBag = null; + protected ?SignatureKeyPairBag $vciSignatureKeyPairBag = null; + protected ?SignatureKeyPairConfigBag $vciSignatureKeyPairConfigBag = null; /** * @throws \Exception @@ -144,7 +163,8 @@ public function __construct( string $fileName = self::DEFAULT_FILE_NAME, // Primarily used for easy (unit) testing overrides. array $overrides = [], // Primarily used for easy (unit) testing overrides. ?Configuration $sspConfig = null, - private readonly SspBridge $sspBridge = new SspBridge(), + protected readonly SspBridge $sspBridge = new SspBridge(), + protected readonly ValueAbstracts $valueAbstracts = new ValueAbstracts(), ) { $this->moduleConfig = Configuration::loadFromArray( array_merge(Configuration::getConfig($fileName)->toArray(), $overrides), @@ -187,31 +207,31 @@ function (array $scope, string $name): void { $acrValuesSupported = $this->getAcrValuesSupported(); foreach ($acrValuesSupported as $acrValueSupported) { - if (! is_string($acrValueSupported)) { + if (!is_string($acrValueSupported)) { throw new ConfigurationError('Config option acrValuesSupported should contain strings only.'); } } $authSourcesToAcrValuesMap = $this->getAuthSourcesToAcrValuesMap(); foreach ($authSourcesToAcrValuesMap as $authSource => $acrValues) { - if (! is_string($authSource)) { + if (!is_string($authSource)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have string keys ' . 'indicating auth sources.'); } - if (! is_array($acrValues)) { + if (!is_array($acrValues)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have array ' . 'values containing supported ACRs for each auth source key.'); } /** @psalm-suppress MixedAssignment */ foreach ($acrValues as $acrValue) { - if (! is_string($acrValue)) { + if (!is_string($acrValue)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have array ' . 'values with strings only.'); } - if (! in_array($acrValue, $acrValuesSupported, true)) { + if (!in_array($acrValue, $acrValuesSupported, true)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have ' . 'supported ACR values only.'); } @@ -220,8 +240,8 @@ function (array $scope, string $name): void { $forcedAcrValueForCookieAuthentication = $this->getForcedAcrValueForCookieAuthentication(); - if (! is_null($forcedAcrValueForCookieAuthentication)) { - if (! in_array($forcedAcrValueForCookieAuthentication, $acrValuesSupported, true)) { + if (!is_null($forcedAcrValueForCookieAuthentication)) { + if (!in_array($forcedAcrValueForCookieAuthentication, $acrValuesSupported, true)) { throw new ConfigurationError('Config option forcedAcrValueForCookieAuthentication should have' . ' null value or string value indicating particular supported ACR.'); } @@ -261,30 +281,13 @@ public function getModuleUrl(?string $path = null): string return $base; } - /** - * @param class-string $className - * @throws \SimpleSAML\Error\ConfigurationError - * @throws \ReflectionException - */ - protected function instantiateSigner(string $className): Signer - { - $class = new ReflectionClass($className); - $signer = $class->newInstance(); - - if (!$signer instanceof Signer) { - throw new ConfigurationError(sprintf('Unsupported signer class provided (%s).', $className)); - } - - return $signer; - } - /***************************************************************************************************************** * OpenID Connect related config. ****************************************************************************************************************/ /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException * @return non-empty-string + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function getIssuer(): string { @@ -334,74 +337,76 @@ public function getUserIdentifierAttribute(): string return $this->config()->getString(ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE); } - /** - * Get signer for OIDC protocol. - * - * @throws \ReflectionException - * @throws \Exception - */ - public function getProtocolSigner(): Signer + public function getSupportedAlgorithms(): SupportedAlgorithms { - /** @psalm-var class-string $signerClassname */ - $signerClassname = $this->config()->getOptionalString( - self::OPTION_TOKEN_SIGNER, - Sha256::class, + return new SupportedAlgorithms( + new SignatureAlgorithmBag( + SignatureAlgorithmEnum::RS256, + SignatureAlgorithmEnum::RS384, + SignatureAlgorithmEnum::RS512, + SignatureAlgorithmEnum::ES256, + SignatureAlgorithmEnum::ES384, + SignatureAlgorithmEnum::ES512, + SignatureAlgorithmEnum::PS256, + SignatureAlgorithmEnum::PS384, + SignatureAlgorithmEnum::PS512, + SignatureAlgorithmEnum::EdDSA, + ), ); - - return $this->instantiateSigner($signerClassname); } - /** - * Get the path to the private key used in OIDC protocol. - * @throws \Exception - */ - public function getProtocolPrivateKeyPath(): string + public function getSupportedSerializers(): SupportedSerializers { - $keyName = $this->config()->getOptionalString( - self::OPTION_PKI_PRIVATE_KEY_FILENAME, - self::DEFAULT_PKI_PRIVATE_KEY_FILENAME, + return new SupportedSerializers( + new JwsSerializerBag( + JwsSerializerEnum::Compact, + ), ); - return $this->sspBridge->utils()->config()->getCertPath($keyName); } /** - * Get the OIDC protocol private key passphrase. - * @return ?string - * @throws \Exception + * @throws ConfigurationError + * @return non-empty-array */ - public function getProtocolPrivateKeyPassPhrase(): ?string + public function getProtocolSignatureKeyPairs(): array { - return $this->config()->getOptionalString(self::OPTION_PKI_PRIVATE_KEY_PASSPHRASE, null); + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS); + + if (empty($signatureKeyPairs)) { + throw new ConfigurationError('At least one protocol signature key-pair pair must be provided.'); + } + + return $signatureKeyPairs; } /** - * Get the path to the public certificate used in OIDC protocol. - * @return string The file system path - * @throws \Exception + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getProtocolCertPath(): string + public function getProtocolSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_CERTIFICATE_FILENAME, - self::DEFAULT_PKI_CERTIFICATE_FILENAME, + if ($this->protocolSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { + return $this->protocolSignatureKeyPairConfigBag; + } + + return $this->protocolSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( + $this->getProtocolSignatureKeyPairs(), ); - return $this->sspBridge->utils()->config()->getCertPath($certName); } /** - * Get the path to the new public certificate to be used in OIDC protocol. - * @return ?string Null if not set, or file system path - * @throws \Exception + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getProtocolNewCertPath(): ?string + public function getProtocolSignatureKeyPairBag(): SignatureKeyPairBag { - $certName = $this->config()->getOptionalString(self::OPTION_PKI_NEW_CERTIFICATE_FILENAME, null); - - if (is_string($certName)) { - return $this->sspBridge->utils()->config()->getCertPath($certName); + if ($this->protocolSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->protocolSignatureKeyPairBag; } - return null; + return $this->protocolSignatureKeyPairBag = $this->valueAbstracts + ->signatureKeyPairBagFactory() + ->fromConfig($this->getProtocolSignatureKeyPairConfigBag()); } /** @@ -440,7 +445,7 @@ public function getForcedAcrValueForCookieAuthentication(): ?string return null; } - return (string) $value; + return (string)$value; } /** @@ -448,7 +453,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(), + ); } /** @@ -538,66 +548,26 @@ public function getFederationEnabled(): bool } /** - * @throws \ReflectionException * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion */ - public function getFederationSigner(): Signer - { - /** @psalm-var class-string $signerClassname */ - $signerClassname = $this->config()->getOptionalString( - self::OPTION_FEDERATION_TOKEN_SIGNER, - Sha256::class, - ); - - return $this->instantiateSigner($signerClassname); - } - - public function getFederationPrivateKeyPath(): string - { - $keyName = $this->config()->getOptionalString( - self::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - self::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ); - - return $this->sspBridge->utils()->config()->getCertPath($keyName); - } - - public function getFederationPrivateKeyPassPhrase(): ?string - { - return $this->config()->getOptionalString(self::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE, null); - } - - /** - * Return the path to the federation public certificate - * @throws \Exception - */ - public function getFederationCertPath(): string + public function getFederationSignatureKeyPairBag(): SignatureKeyPairBag { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME, - self::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - ); + if ($this->federationSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->federationSignatureKeyPairBag; + } - return $this->sspBridge->utils()->config()->getCertPath($certName); - } + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS); - /** - * Return the path to the new federation public certificate - * @return ?string The file system path or null if not set. - * @throws \Exception - */ - public function getFederationNewCertPath(): ?string - { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME, - null, - ); - - if (is_string($certName)) { - return $this->sspBridge->utils()->config()->getCertPath($certName); + if (empty($signatureKeyPairs)) { + throw new ConfigurationError('At least one federation signature key-pair pair should be provided.'); } - return null; + $signatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag($signatureKeyPairs); + + return $this->federationSignatureKeyPairBag = $this->valueAbstracts + ->signatureKeyPairBagFactory() + ->fromConfig($signatureKeyPairConfigBag); } /** @@ -732,21 +702,6 @@ public function getInformationUri(): ?string ); } - /** - * @return string|null - * TODO mivanci v7 Remove in next major release, as well as config constant. - * In Draft-43 of OIDFed specification, metadata claim 'homepage_uri' has been renamed to - * 'organization_uri'. Use 'organization_uri' instead. - * @see self::getOrganizationUri() - */ - public function getHomepageUri(): ?string - { - return $this->config()->getOptionalString( - self::OPTION_HOMEPAGE_URI, - null, - ); - } - public function getOrganizationUri(): ?string { return $this->config()->getOptionalString( @@ -787,9 +742,9 @@ public function getFederationTrustAnchors(): array } /** - * @throws \SimpleSAML\Error\ConfigurationError * @return non-empty-array * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType + * @throws \SimpleSAML\Error\ConfigurationError */ public function getFederationTrustAnchorIds(): array { @@ -860,4 +815,441 @@ public function isFederationParticipationLimitedByTrustMarksFor(string $trustAnc { return !empty($this->getTrustMarksNeededForFederationParticipationFor($trustAnchorId)); } + + + /***************************************************************************************************************** + * OpenID Verifiable Credential Issuance related config. + ****************************************************************************************************************/ + + public function getVciEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_VCI_ENABLED, false); + } + + + /** + * @throws ConfigurationError + * @return non-empty-array + */ + public function getVciSignatureKeyPairs(): array + { + + $signatureKeyPairs = $this->config()->getArray(ModuleConfig::OPTION_VCI_SIGNATURE_KEY_PAIRS); + + if (empty($signatureKeyPairs)) { + throw new ConfigurationError('At least one VCI signature key-pair pair must be provided.'); + } + + return $signatureKeyPairs; + } + + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getVciSignatureKeyPairConfigBag(): SignatureKeyPairConfigBag + { + if ($this->vciSignatureKeyPairConfigBag instanceof SignatureKeyPairConfigBag) { + return $this->vciSignatureKeyPairConfigBag; + } + + return $this->vciSignatureKeyPairConfigBag = $this->getSignatureKeyPairConfigBag( + $this->getVciSignatureKeyPairs(), + ); + } + + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @psalm-suppress MixedAssignment, ArgumentTypeCoercion + */ + public function getVciSignatureKeyPairBag(): SignatureKeyPairBag + { + if ($this->vciSignatureKeyPairBag instanceof SignatureKeyPairBag) { + return $this->vciSignatureKeyPairBag; + } + + return $this->vciSignatureKeyPairBag = $this->valueAbstracts + ->signatureKeyPairBagFactory() + ->fromConfig($this->getVciSignatureKeyPairConfigBag()); + } + + public function getVciCredentialConfigurationsSupported(): array + { + return $this->config()->getOptionalArray(self::OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED, []); + } + + /** + * @param string $credentialConfigurationId + * @return mixed[]|null + * @throws \SimpleSAML\Error\ConfigurationError + */ + public function getVciCredentialConfiguration(string $credentialConfigurationId): ?array + { + $credentialConfiguration = $this->getVciCredentialConfigurationsSupported()[$credentialConfigurationId] ?? null; + + if (is_null($credentialConfiguration)) { + return null; + } + + if (!is_array($credentialConfiguration)) { + throw new ConfigurationError( + sprintf( + 'Invalid configuration for credential configuration %s: %s', + $credentialConfigurationId, + var_export($credentialConfiguration, true), + ), + ); + } + + return $credentialConfiguration; + } + + /** + * @return array + */ + public function getVciCredentialConfigurationIdsSupported(): array + { + return array_map( + 'strval', + array_keys($this->getVciCredentialConfigurationsSupported()), + ); + } + + /** + * 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->getVciEnabled()) { + return []; + } + + $vciScopes = []; + foreach ($this->getVciCredentialConfigurationIdsSupported() as $credentialConfigurationId) { + $vciScopes[$credentialConfigurationId] = ['description' => $credentialConfigurationId]; + } + return $vciScopes; + } + + public function getVciCredentialConfigurationIdForCredentialDefinitionType(array $credentialDefinitionType): ?string + { + foreach ( + $this->getVciCredentialConfigurationsSupported() as $credentialConfigurationId => $credentialConfiguration + ) { + if (!is_array($credentialConfiguration)) { + continue; + } + + $credentialDefinition = $credentialConfiguration[ClaimsEnum::CredentialDefinition->value] ?? null; + + if (!is_array($credentialDefinition)) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $configuredType = $credentialDefinition[ClaimsEnum::Type->value] ?? null; + + if ($configuredType === $credentialDefinitionType) { + return (string)$credentialConfigurationId; + } + } + + return null; + } + + /** + * Extract and parse the claims path definition from the credential configuration supported. + * Returns an array of valid paths for the claims. + */ + public function getVciValidCredentialClaimPathsFor(string $credentialConfigurationId): array + { + $claimsConfig = $this->getVciCredentialConfigurationsSupported()[$credentialConfigurationId] + [ClaimsEnum::Claims->value] ?? []; + + $validPaths = []; + + if (!is_array($claimsConfig)) { + return $validPaths; + } + + /** @psalm-suppress MixedAssignment */ + foreach ($claimsConfig as $claim) { + if (is_array($claim)) { + /** @psalm-suppress MixedAssignment */ + $validPaths[] = $claim[ClaimsEnum::Path->value] ?? null; + } + } + + return array_filter($validPaths); + } + + public function getVciUserAttributeToCredentialClaimPathMap(): array + { + return $this->config()->getOptionalArray(self::OPTION_VCI_USER_ATTRIBUTE_TO_CREDENTIAL_CLAIM_PATH_MAP, []); + } + + public function getVciUserAttributeToCredentialClaimPathMapFor(string $credentialConfigurationId): array + { + /** @psalm-suppress MixedAssignment */ + $map = $this->getVciUserAttributeToCredentialClaimPathMap()[$credentialConfigurationId] ?? []; + + if (is_array($map)) { + return $map; + } + + return []; + } + + /** + * Get Issuer State Duration (TTL) if set. If not set, it will fall back to Authorization Code Duration. + * + * @return DateInterval + * @throws \Exception + */ + public function getVciIssuerStateDuration(): DateInterval + { + $issuerStateDuration = $this->config()->getOptionalString(self::OPTION_VCI_ISSUER_STATE_TTL, null); + + if (is_null($issuerStateDuration)) { + return $this->getAuthCodeDuration(); + } + + return new DateInterval( + $this->config()->getString(self::OPTION_VCI_ISSUER_STATE_TTL), + ); + } + + public function getVciAllowNonRegisteredClients(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_VCI_ALLOW_NON_REGISTERED_CLIENTS, false); + } + + public function getVciAllowedRedirectUriPrefixesForNonRegisteredClients(): array + { + return $this->config()->getOptionalArray( + self::OPTION_VCI_ALLOWED_REDIRECT_URI_PREFIXES_FOR_NON_REGISTERED_CLIENTS, + ['openid-credential-offer://',], + ); + } + + + /***************************************************************************************************************** + * API-related config. + ****************************************************************************************************************/ + + public function getApiEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_ENABLED, false); + } + + public function getApiVciCredentialOfferEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_VCI_CREDENTIAL_OFFER_ENDPOINT_ENABLED, false); + } + + public function getApiOAuth2TokenIntrospectionEndpointEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_API_OAUTH2_TOKEN_INTROSPECTION_ENDPOINT_ENABLED, false); + } + + /** + * @return mixed[]|null + */ + public function getApiTokens(): ?array + { + return $this->config()->getOptionalArray(self::OPTION_API_TOKENS, null); + } + + /** + * @param string $token + * @return mixed[] + */ + public function getApiTokenScopes(string $token): ?array + { + /** @psalm-suppress MixedAssignment */ + $tokenScopes = $this->getApiTokens()[$token] ?? null; + + if (is_array($tokenScopes)) { + return $tokenScopes; + } + + return null; + } + + public function getAuthSourcesToUsersEmailAttributeMap(): array + { + return $this->config()->getOptionalArray(self::OPTION_AUTH_SOURCES_TO_USERS_EMAIL_ATTRIBUTE_NAME_MAP, []); + } + + public function getUsersEmailAttributeNameForAuthSourceId(string $authSource): string + { + /** @psalm-suppress MixedAssignment */ + $attributeName = $this->getAuthSourcesToUsersEmailAttributeMap()[$authSource] ?? null; + + if (is_string($attributeName)) { + return $attributeName; + } + + return $this->getDefaultUsersEmailAttributeName(); + } + + public function getDefaultUsersEmailAttributeName(): string + { + return $this->config()->getOptionalString(self::OPTION_DEFAULT_USERS_EMAIL_ATTRIBUTE_NAME, 'mail'); + } + + /** + * @return array{ + * algorithm: \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum, + * private_key_filename: non-empty-string, + * public_key_filename: non-empty-string, + * private_key_password: ?non-empty-string, + * key_id: ?non-empty-string + * } + * @throws ConfigurationError * + */ + public function getValidatedSignatureKeyPairArray(mixed $signatureKeyPair): array + { + if (!is_array($signatureKeyPair)) { + throw new ConfigurationError( + 'Invalid value for signature key pair. Expected array, got "' . + var_export($signatureKeyPair, true) . '".', + ); + } + + $algorithm = $signatureKeyPair[self::KEY_ALGORITHM] ?? null; + if (!$algorithm instanceof SignatureAlgorithmEnum) { + throw new ConfigurationError( + 'Invalid protocol signature algorithm encountered. Expected instance of ' . + SignatureAlgorithmEnum::class, + ); + } + + $privateKeyFilename = $signatureKeyPair[self::KEY_PRIVATE_KEY_FILENAME] ?? null; + if ((!is_string($privateKeyFilename)) || $privateKeyFilename === '') { + throw new ConfigurationError( + sprintf( + 'Unexpected value for private key filename. Expected a non-empty string, got "%s".', + var_export($privateKeyFilename, true), + ), + ); + } + $privateKeyFilename = $this->sspBridge->utils()->config()->getCertPath($privateKeyFilename); + if (!file_exists($privateKeyFilename)) { + throw new ConfigurationError( + sprintf( + 'Private key file does not exist: %s', + $privateKeyFilename, + ), + ); + } + /** @var non-empty-string $privateKeyFilename */ + + $publicKeyFilename = $signatureKeyPair[self::KEY_PUBLIC_KEY_FILENAME] ?? null; + if ((!is_string($publicKeyFilename)) || $publicKeyFilename === '') { + throw new ConfigurationError( + sprintf( + 'Unexpected value for public key filename. Expected a non-empty string, got "%s".', + var_export($publicKeyFilename, true), + ), + ); + } + $publicKeyFilename = $this->sspBridge->utils()->config()->getCertPath($publicKeyFilename); + if (!file_exists($publicKeyFilename)) { + throw new ConfigurationError( + sprintf( + 'Public key file does not exist: %s', + $publicKeyFilename, + ), + ); + } + /** @var non-empty-string $publicKeyFilename */ + + $privateKeyPassword = $signatureKeyPair[self::KEY_PRIVATE_KEY_PASSWORD] ?? null; + if ( + ((!is_string($privateKeyPassword)) && (!is_null($privateKeyPassword))) || + $privateKeyPassword === '' + ) { + throw new ConfigurationError( + sprintf( + 'Unexpected value for private key password. Expected a non-empty string or null, got "%s".', + var_export($privateKeyPassword, true), + ), + ); + } + + $keyId = $signatureKeyPair[self::KEY_KEY_ID] ?? null; + if ( + ((!is_string($keyId)) && (!is_null($keyId))) || + $keyId === '' + ) { + throw new ConfigurationError( + sprintf( + 'Unexpected value for key ID signature key pair. Expected a non-empty string or null, got "%s".', + var_export($keyId, true), + ), + ); + } + + + return [ + self::KEY_ALGORITHM => $algorithm, + self::KEY_PRIVATE_KEY_FILENAME => $privateKeyFilename, + self::KEY_PUBLIC_KEY_FILENAME => $publicKeyFilename, + self::KEY_PRIVATE_KEY_PASSWORD => $privateKeyPassword, + self::KEY_KEY_ID => $keyId, + ]; + } + + /** + * @throws ConfigurationError + * @psalm-suppress MixedAssignment + */ + protected function getSignatureKeyPairConfigBag(array $signatureKeyPairs): SignatureKeyPairConfigBag + { + $signatureKeyPairConfigBag = new SignatureKeyPairConfigBag(); + + foreach ($signatureKeyPairs as $signatureKeyPair) { + /** + * @var SignatureAlgorithmEnum $algorithm + * @var non-empty-string $privateKeyFilename + * @var non-empty-string $publicKeyFilename + * @var ?non-empty-string $privateKeyPassword + * @var ?non-empty-string $keyId + */ + [ + self::KEY_ALGORITHM => $algorithm, + self::KEY_PRIVATE_KEY_FILENAME => $privateKeyFilename, + self::KEY_PUBLIC_KEY_FILENAME => $publicKeyFilename, + self::KEY_PRIVATE_KEY_PASSWORD => $privateKeyPassword, + self::KEY_KEY_ID => $keyId, + ] = $this->getValidatedSignatureKeyPairArray($signatureKeyPair); + + $signatureKeyPairConfigBag->add(new SignatureKeyPairConfig( + $algorithm, + new KeyPairFilenameConfig( + $privateKeyFilename, + $publicKeyFilename, + $privateKeyPassword, + $keyId, + ), + )); + } + + return $signatureKeyPairConfigBag; + } + + public function getTimestampValidationLeeway(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString( + self::OPTION_TIMESTAMP_VALIDATION_LEEWAY, + 'PT1M', + ), + ); + } } diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 7be4ac56..422aa238 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -102,8 +102,36 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo } $stmt = sprintf( - "INSERT INTO %s (id, scopes, expires_at, user_id, client_id, is_revoked, auth_code_id, requested_claims) " - . "VALUES (:id, :scopes, :expires_at, :user_id, :client_id, :is_revoked, :auth_code_id, :requested_claims)", + "INSERT INTO %s ( + id, + scopes, + expires_at, + user_id, + client_id, + is_revoked, + auth_code_id, + requested_claims, + flow_type, + authorization_details, + bound_client_id, + bound_redirect_uri, + issuer_state + ) " + . "VALUES ( + :id, + :scopes, + :expires_at, + :user_id, + :client_id, + :is_revoked, + :auth_code_id, + :requested_claims, + :flow_type, + :authorization_details, + :bound_client_id, + :bound_redirect_uri, + :issuer_state + )", $this->getTableName(), ); @@ -242,7 +270,9 @@ private function update(AccessTokenEntity $accessTokenEntity): void $stmt = sprintf( "UPDATE %s SET scopes = :scopes, expires_at = :expires_at, user_id = :user_id, " . "client_id = :client_id, is_revoked = :is_revoked, auth_code_id = :auth_code_id, " - . "requested_claims = :requested_claims WHERE id = :id", + . "requested_claims = :requested_claims, flow_type = :flow_type, " . + "authorization_details = :authorization_details, bound_client_id = :bound_client_id, " . + "bound_redirect_uri = :bound_redirect_uri, issuer_state = :issuer_state WHERE id = :id", $this->getTableName(), ); diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index a4fe3301..e8cd0920 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -71,8 +71,39 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity } $stmt = sprintf( - "INSERT INTO %s (id, scopes, expires_at, user_id, client_id, is_revoked, redirect_uri, nonce) " - . "VALUES (:id, :scopes, :expires_at, :user_id, :client_id, :is_revoked, :redirect_uri, :nonce)", + <<getTableName(), ); @@ -94,7 +125,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)); @@ -191,7 +222,13 @@ private function update(AuthCodeEntity $authCodeEntity): void client_id = :client_id, is_revoked = :is_revoked, redirect_uri = :redirect_uri, - nonce = :nonce + nonce = :nonce, + flow_type = :flow_type, + tx_code = :tx_code, + authorization_details = :authorization_details, + bound_client_id = :bound_client_id, + bound_redirect_uri = :bound_redirect_uri, + issuer_state = :issuer_state WHERE id = :id EOS , diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 0caf7f01..11db59a0 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -200,7 +200,6 @@ public function findFederatedByEntityIdentifier( if ( is_null($clientEntity->getEntityIdentifier()) || (! $clientEntity->isEnabled()) || - (! $clientEntity->isFederated()) || (!is_array($clientEntity->getFederationJwks())) || $clientEntity->isExpired() ) { @@ -271,12 +270,10 @@ public function findAllFederated(?string $owner = null): array WHERE entity_identifier IS NOT NULL AND federation_jwks IS NOT NULL AND - is_enabled = :is_enabled AND - is_federated = :is_federated + is_enabled = :is_enabled EOS, [ 'is_enabled' => [true, PDO::PARAM_BOOL], - 'is_federated' => [true, PDO::PARAM_BOOL], ], $owner, ); @@ -362,7 +359,8 @@ public function add(ClientEntityInterface $client): void updated_at, created_at, expires_at, - is_federated + is_generic, + extra_metadata ) VALUES ( :id, @@ -387,7 +385,8 @@ public function add(ClientEntityInterface $client): void :updated_at, :created_at, :expires_at, - :is_federated + :is_generic, + :extra_metadata ) EOS , @@ -459,7 +458,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_generic = :is_generic, + extra_metadata = :extra_metadata WHERE id = :id EOF , @@ -552,12 +552,24 @@ protected function preparePdoState(array $state): array { $isEnabled = (bool)($state[ClientEntity::KEY_IS_ENABLED] ?? false); $isConfidential = (bool)($state[ClientEntity::KEY_IS_CONFIDENTIAL] ?? false); - $isFederated = (bool)($state[ClientEntity::KEY_IS_FEDERATED] ?? false); + $isGeneric = (bool)($state[ClientEntity::KEY_IS_GENERIC] ?? false); $state[ClientEntity::KEY_IS_ENABLED] = [$isEnabled, PDO::PARAM_BOOL]; $state[ClientEntity::KEY_IS_CONFIDENTIAL] = [$isConfidential, PDO::PARAM_BOOL]; - $state[ClientEntity::KEY_IS_FEDERATED] = [$isFederated, PDO::PARAM_BOOL]; + $state[ClientEntity::KEY_IS_GENERIC] = [$isGeneric, PDO::PARAM_BOOL]; return $state; } + + public function getGenericForVci(): ClientEntityInterface + { + $client = $this->clientEntityFactory->getGenericForVci(); + if ($this->findById($client->getIdentifier()) === null) { + $this->add($client); + } else { + $this->update($client); + } + + return $client; + } } diff --git a/src/Repositories/IssuerStateRepository.php b/src/Repositories/IssuerStateRepository.php new file mode 100644 index 00000000..ef28f5ef --- /dev/null +++ b/src/Repositories/IssuerStateRepository.php @@ -0,0 +1,190 @@ +protocolCache?->get(null, $this->getCacheKey($value)); + + if (!is_array($data)) { + $stmt = $this->database->read( + "SELECT * FROM {$this->getTableName()} WHERE value = :value", + [ + 'value' => $value, + ], + ); + + if (empty($rows = $stmt->fetchAll())) { + return null; + } + + /** @var array $data */ + $data = current($rows); + } + + $issuerState = $this->issuerStateEntityFactory->fromState($data); + + $this->protocolCache?->set( + $issuerState->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $issuerState->getExpirestAt()->getTimestamp(), + ), + $this->getCacheKey($issuerState->getValue()), + ); + + return $issuerState; + } + + public function findValid(string $value): ?IssuerStateEntity + { + $issuerState = $this->find($value); + + if ($issuerState === null) { + return null; + } + + if ($issuerState->getExpirestAt() < $this->helpers->dateTime()->getUtc()) { + return null; + } + + if ($issuerState->isRevoked()) { + return null; + } + + return $issuerState; + } + + public function revoke(string $value): void + { + $issuerState = $this->find($value); + + if ($issuerState === null) { + return; + } + + $issuerState->revoke(); + $this->update($issuerState); + } + + public function update(IssuerStateEntity $issuerState): void + { + $stmt = sprintf( + <<getTableName(), + ); + + $this->database->write( + $stmt, + $this->preparePdoState($issuerState->getState()), + ); + + $this->protocolCache?->set( + $issuerState->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $issuerState->getExpirestAt()->getTimestamp(), + ), + $this->getCacheKey($issuerState->getValue()), + ); + } + + public function persist(IssuerStateEntity $issuerState): void + { + $stmt = sprintf( + <<getTableName(), + ); + + $this->database->write( + $stmt, + $this->preparePdoState($issuerState->getState()), + ); + + $this->protocolCache?->set( + $issuerState->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $issuerState->getExpirestAt()->getTimestamp(), + ), + $this->getCacheKey($issuerState->getValue()), + ); + } + + /** + * Remove invalid issuer state entities (expired or revoked). + * @return void + */ + public function removeInvalid(): void + { + $stmt = sprintf( + <<getTableName(), + ); + + $data = [ + 'expires_at' => $this->helpers->dateTime()->getUtc()->format(DateFormatsEnum::DB_DATETIME->value), + 'is_revoked' => true, + ]; + + $this->database->write($stmt, $this->preparePdoState($data)); + } + + protected function preparePdoState(array $state): array + { + $isRevoked = (bool)($state['is_revoked'] ?? true); + + $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + + return $state; + } +} diff --git a/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php b/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php index 8baf4eb1..c0274000 100644 --- a/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php +++ b/src/Server/Associations/Interfaces/RelyingPartyAssociationInterface.php @@ -14,4 +14,17 @@ public function getSessionId(): ?string; public function setSessionId(?string $sessionId): void; public function getBackChannelLogoutUri(): ?string; public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void; + + /** + * Get id_token_signed_response_alg metadata parameter used by the client. + * + * @return string|null + */ + public function getClientIdTokenSignedResponseAlg(): ?string; + + /** + * Set id_token_signed_response_alg metadata parameter used by the client. + * @param string|null $idTokenSignedResponseAlg + */ + public function setClientIdTokenSignedResponseAlg(?string $idTokenSignedResponseAlg): void; } diff --git a/src/Server/Associations/RelyingPartyAssociation.php b/src/Server/Associations/RelyingPartyAssociation.php index cebbffff..5ce20b77 100644 --- a/src/Server/Associations/RelyingPartyAssociation.php +++ b/src/Server/Associations/RelyingPartyAssociation.php @@ -16,6 +16,7 @@ public function __construct( * Registered back-channel logout URI for the client. */ protected ?string $backChannelLogoutUri = null, + protected ?string $idTokenSignedResponseAlg = null, ) { } @@ -58,4 +59,14 @@ public function setBackChannelLogoutUri(?string $backChannelLogoutUri): void { $this->backChannelLogoutUri = $backChannelLogoutUri; } + + public function getClientIdTokenSignedResponseAlg(): ?string + { + return $this->idTokenSignedResponseAlg; + } + + public function setClientIdTokenSignedResponseAlg(?string $idTokenSignedResponseAlg): void + { + $this->idTokenSignedResponseAlg = $idTokenSignedResponseAlg; + } } diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 65c83e98..f0cc0585 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -18,13 +18,14 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\AuthorizationValidatableWithRequestRules; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; class AuthorizationServer extends OAuth2AuthorizationServer @@ -51,6 +52,7 @@ public function __construct( Key|string $encryptionKey, ?ResponseTypeInterface $responseType = null, ?RequestRulesManager $requestRulesManager = null, + protected readonly ?LoggerService $loggerService = null, ) { parent::__construct( $clientRepository, @@ -77,10 +79,12 @@ public function __construct( */ public function validateAuthorizationRequest(ServerRequestInterface $request): OAuth2AuthorizationRequest { + $this->loggerService?->debug('AuthorizationServer::validateAuthorizationRequest'); + $rulesToExecute = [ StateRule::class, - ClientIdRule::class, - RedirectUriRule::class, + ClientRule::class, + ClientRedirectUriRule::class, ]; try { @@ -91,27 +95,68 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O [HttpMethodsEnum::GET, HttpMethodsEnum::POST], ); } catch (OidcServerException $exception) { - $reason = sprintf("%s %s", $exception->getMessage(), $exception->getHint() ?? ''); + $reason = sprintf( + "AuthorizationServer: %s %s", + $exception->getMessage(), + $exception->getHint() ?? '', + ); + $this->loggerService?->error($reason); throw new BadRequest($reason); } + $this->loggerService?->debug( + 'AuthorizationServer: Result bag validated', + ['rulesToExecute' => $rulesToExecute], + ); + // state and redirectUri is used here, so we can return HTTP redirect error in case of invalid response_type. /** @var ?string $state */ $state = $resultBag->getOrFail(StateRule::class)->getValue(); /** @var string $redirectUri */ - $redirectUri = $resultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $resultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); foreach ($this->enabledGrantTypes as $grantType) { + $this->loggerService?->debug( + 'AuthorizationServer: Checking if grant type can respond to authorization request: ' . + $grantType::class, + ); if ($grantType->canRespondToAuthorizationRequest($request)) { + $this->loggerService?->debug( + 'AuthorizationServer: Grant type can respond to authorization request: ' . + $grantType::class, + ); + if (! $grantType instanceof AuthorizationValidatableWithRequestRules) { + $this->loggerService?->error( + 'AuthorizationServer: grant type must be validatable with ' . + 'already validated result bag: ' . $grantType::class, + ); throw OidcServerException::serverError('grant type must be validatable with already validated ' . 'result bag'); } + $this->loggerService?->debug( + sprintf( + 'AuthorizationServer: Grant type class: %s, identifier: %s ', + $grantType::class, + $grantType->getIdentifier(), + ), + ); + return $grantType->validateAuthorizationRequestWithRequestRules($request, $resultBag); + } else { + $this->loggerService?->debug( + 'AuthorizationServer: Grant type can NOT respond to ' . + 'authorization request: ' . $grantType::class, + ); } } + $this->loggerService?->error( + 'AuthorizationServer: Not a single registered grant type can respond to authorization ' . + 'request.', + ['requestQueryParams' => $request->getQueryParams()], + ); throw OidcServerException::unsupportedResponseType($redirectUri, $state); } @@ -140,7 +185,7 @@ public function validateLogoutRequest(ServerRequestInterface $request): LogoutRe throw new BadRequest($reason); } - /** @var \Lcobucci\JWT\UnencryptedToken|null $idTokenHint */ + /** @var \SimpleSAML\OpenID\Core\IdToken|null $idTokenHint */ $idTokenHint = $resultBag->getOrFail(IdTokenHintRule::class)->getValue(); /** @var string|null $postLogoutRedirectUri */ $postLogoutRedirectUri = $resultBag->getOrFail(PostLogoutRedirectUriRule::class)->getValue(); diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index dfaac1cf..dbbbce0b 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -21,13 +21,18 @@ use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use LogicException; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; +use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; +use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface; +use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\AuthCodeRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; @@ -38,15 +43,19 @@ use SimpleSAML\Module\oidc\Server\Grants\Traits\IssueAccessTokenTrait; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; +use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AuthorizationDetailsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IssuerStateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; @@ -59,7 +68,9 @@ 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\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -165,6 +176,7 @@ public function __construct( protected AuthCodeEntityFactory $authCodeEntityFactory, protected RefreshTokenIssuer $refreshTokenIssuer, protected Helpers $helpers, + protected LoggerService $loggerService, ) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); @@ -194,6 +206,8 @@ public function __construct( */ public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool { + $this->loggerService->debug('AuthCodeGrant::canRespondToAuthorizationRequest'); + $requestParams = $this->requestParamsResolver->getAllBasedOnAllowedMethods( $request, $this->allowedAuthorizationHttpMethods, @@ -267,8 +281,7 @@ public function completeOidcAuthorizationRequest( $authorizationRequest->getClient(), $user->getIdentifier(), $finalRedirectUri, - $authorizationRequest->getScopes(), - $authorizationRequest->getNonce(), + $authorizationRequest, ); $payload = [ @@ -285,6 +298,8 @@ public function completeOidcAuthorizationRequest( 'claims' => $authorizationRequest->getClaims(), 'acr' => $authorizationRequest->getAcr(), 'session_id' => $authorizationRequest->getSessionId(), + // Do not add anything else to the payload, as it will make it dangerously long to send it as a query + // parameter. Use storage instead. ]; $jsonPayload = json_encode($payload, JSON_THROW_ON_ERROR); @@ -304,7 +319,6 @@ public function completeOidcAuthorizationRequest( } /** - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * @throws \League\OAuth2\Server\Exception\OAuthServerException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException */ @@ -313,8 +327,7 @@ protected function issueOidcAuthCode( OAuth2ClientEntityInterface $client, string $userIdentifier, string $redirectUri, - array $scopes = [], - ?string $nonce = null, + AuthorizationRequest $authorizationRequest, ): AuthCodeEntityInterface { $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; @@ -322,16 +335,25 @@ protected function issueOidcAuthCode( throw OidcServerException::serverError('Unexpected auth code repository entity type.'); } + $flowType = $authorizationRequest->isVciRequest() ? + FlowTypeEnum::VciAuthorizationCode : + FlowTypeEnum::OidcAuthorizationCode; + while ($maxGenerationAttempts-- > 0) { try { $authCode = $this->authCodeEntityFactory->fromData( $this->generateUniqueIdentifier(), $client, - $scopes, + $authorizationRequest->getScopes(), (new DateTimeImmutable())->add($authCodeTTL), $userIdentifier, $redirectUri, - $nonce, + $authorizationRequest->getNonce(), + $authorizationRequest->getIssuerState(), + flowTypeEnum: $flowType, + authorizationDetails: $authorizationRequest->getAuthorizationDetails(), + boundClientId: $authorizationRequest->getBoundClientId(), + boundRedirectUri: $authorizationRequest->getBoundRedirectUri(), ); $this->authCodeRepository->persistNewAuthCode($authCode); @@ -386,13 +408,98 @@ public function respondToAccessTokenRequest( // OAuth2 implementation //[$clientId] = $this->getClientCredentials($request); + $this->loggerService->debug( + 'AuthCodeGrant::respondToAccessTokenRequest', + $this->requestParamsResolver->getAllBasedOnAllowedMethods($request, $this->allowedTokenHttpMethods), + ); + + $encryptedAuthCode = $this->getRequestParameter('code', $request); + + if ($encryptedAuthCode === null) { + $this->loggerService->debug('Code parameter not provided.'); + throw OAuthServerException::invalidRequest('code'); + } + + try { + /** + * @noinspection PhpUndefinedClassInspection + * @psalm-var AuthCodePayloadObject $authCodePayload + */ + $authCodePayload = json_decode($this->decrypt($encryptedAuthCode), null, 512, JSON_THROW_ON_ERROR); + } catch (LogicException $e) { + throw OAuthServerException::invalidRequest('code', 'Cannot decrypt the authorization code', $e); + } + + if (!property_exists($authCodePayload, 'auth_code_id')) { + throw OAuthServerException::invalidRequest('code', 'Authorization code malformed'); + } + + if (! is_a($this->authCodeRepository, AuthCodeRepository::class)) { + throw OidcServerException::serverError('Unexpected auth code repository entity type.'); + } + + $storedAuthCodeEntity = $this->authCodeRepository->findById($authCodePayload->auth_code_id); + + if ($storedAuthCodeEntity === null) { + throw OAuthServerException::invalidGrant('Authorization code not found'); + } + + // Client used during authorization request. + $authorizationClientEntity = $storedAuthCodeEntity->getClient(); + + if (! $authorizationClientEntity instanceof ClientEntity) { + throw OidcServerException::serverError('Unexpected Client Entity instance.'); + } + $rulesToExecute = [ - ClientIdRule::class, - RedirectUriRule::class, - ClientAuthenticationRule::class, CodeVerifierRule::class, ]; + if (! $authorizationClientEntity->isGeneric()) { + $this->loggerService->debug('Executing standard rules for non-generic clients.'); + $rulesToExecute = [ + ClientRule::class, + ClientRedirectUriRule::class, + ClientAuthenticationRule::class, + ...$rulesToExecute, + ]; + } else { + $this->loggerService->debug('Generic client encountered. Checking for authorization bound params.'); + // We used generic client in the flow, so check for bound client_id and redirect_uri. + // Currently used client_id and redirect_uri must be the same as in authorization request. + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $this->allowedTokenHttpMethods, + ); + + // For now, we require client_id, however, in the future this will have to be resolved based on used + // client authentication... + if (! $clientId) { + throw OidcServerException::invalidRequest('client_id'); + } + + if ($clientId !== $storedAuthCodeEntity->getBoundClientId()) { + throw OAuthServerException::invalidGrant('Authorization code not intended for this client_id.'); + } + + $redirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::RedirectUri->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (! $redirectUri) { + throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); + } + + if ($redirectUri !== $storedAuthCodeEntity->getBoundRedirectUri()) { + throw OAuthServerException::invalidGrant('Authorization code not intended for this redirect_uri.'); + } + + $this->requestRulesManager->predefineResult(new Result(ClientRule::class, $authorizationClientEntity)); + } + $resultBag = $this->requestRulesManager->check( $request, $rulesToExecute, @@ -401,16 +508,27 @@ public function respondToAccessTokenRequest( ); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); - /** @var ?string $clientAuthenticationParam */ - $clientAuthenticationParam = $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); + $client = $authorizationClientEntity->isGeneric() ? + $authorizationClientEntity : + $resultBag->getOrFail(ClientRule::class)->getValue(); + + /** @var ?ResolvedClientAuthenticationMethod $resolvedClientAuthenticationMethod */ + $resolvedClientAuthenticationMethod = $authorizationClientEntity->isGeneric() ? + null : + $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); + /** @var ?string $codeVerifier */ $codeVerifier = $resultBag->getOrFail(CodeVerifierRule::class)->getValue(); $utilizedClientAuthenticationParams = []; - if (!is_null($clientAuthenticationParam)) { - $utilizedClientAuthenticationParams[] = $clientAuthenticationParam; + if ( + $resolvedClientAuthenticationMethod instanceof ResolvedClientAuthenticationMethod && + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNotNone() + ) { + $utilizedClientAuthenticationParams[] = $resolvedClientAuthenticationMethod + ->getClientAuthenticationMethod() + ->value; } if (!is_null($codeVerifier)) { $utilizedClientAuthenticationParams[] = ParamsEnum::CodeVerifier->value; @@ -429,30 +547,15 @@ public function respondToAccessTokenRequest( // $this->validateClient($request); // } - $encryptedAuthCode = $this->getRequestParameter('code', $request); - - if ($encryptedAuthCode === null) { - throw OAuthServerException::invalidRequest('code'); - } - - try { - /** - * @noinspection PhpUndefinedClassInspection - * @psalm-var AuthCodePayloadObject $authCodePayload - */ - $authCodePayload = json_decode($this->decrypt($encryptedAuthCode), null, 512, JSON_THROW_ON_ERROR); + $this->validateAuthorizationCode($authCodePayload, $client, $request, $storedAuthCodeEntity); - $this->validateAuthorizationCode($authCodePayload, $client, $request); + $scopes = $this->scopeRepository->finalizeScopes( + $this->validateScopes($authCodePayload->scopes), + $this->getIdentifier(), + $client, + $authCodePayload->user_id, + ); - $scopes = $this->scopeRepository->finalizeScopes( - $this->validateScopes($authCodePayload->scopes), - $this->getIdentifier(), - $client, - $authCodePayload->user_id, - ); - } catch (LogicException $e) { - throw OAuthServerException::invalidRequest('code', 'Cannot decrypt the authorization code', $e); - } // OAuth2 implementation // $codeVerifier = $this->getRequestParameter('code_verifier', $request); @@ -516,6 +619,11 @@ public function respondToAccessTokenRequest( $scopes, $authCodePayload->auth_code_id, $claims, + $storedAuthCodeEntity->getFlowTypeEnum(), + $storedAuthCodeEntity->getAuthorizationDetails(), + $storedAuthCodeEntity->getBoundClientId(), + $storedAuthCodeEntity->getBoundRedirectUri(), + $storedAuthCodeEntity->getIssuerState(), ); $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); $responseType->setAccessToken($accessToken); @@ -563,9 +671,6 @@ public function respondToAccessTokenRequest( $responseType->setRefreshToken($refreshToken); } } - if (! is_a($this->authCodeRepository, AuthCodeRepositoryInterface::class)) { - throw OidcServerException::serverError('Unexpected auth code repository entity type.'); - } // Revoke used auth code $this->authCodeRepository->revokeAuthCode($authCodePayload->auth_code_id); @@ -586,20 +691,13 @@ protected function validateAuthorizationCode( object $authCodePayload, OAuth2ClientEntityInterface $client, ServerRequestInterface $request, + AuthCodeEntity $storedAuthCodeEntity, ): void { /** * @noinspection PhpUndefinedClassInspection * @psalm-var AuthCodePayloadObject $authCodePayload */ - if (!property_exists($authCodePayload, 'auth_code_id')) { - throw OAuthServerException::invalidRequest('code', 'Authorization code malformed'); - } - - if (! is_a($this->authCodeRepository, AuthCodeRepositoryInterface::class)) { - throw OidcServerException::serverError('Unexpected auth code repository entity type.'); - } - if (! is_a($this->accessTokenRepository, AccessTokenRepositoryInterface::class)) { throw OidcServerException::serverError('Unexpected access token repository entity type.'); } @@ -612,7 +710,7 @@ protected function validateAuthorizationCode( throw OAuthServerException::invalidGrant('Authorization code has expired'); } - if ($this->authCodeRepository->isAuthCodeRevoked($authCodePayload->auth_code_id) === true) { + if ($storedAuthCodeEntity->isRevoked()) { // Code is reused, all related tokens must be revoked, per https://tools.ietf.org/html/rfc6749#section-4.1.2 $this->accessTokenRepository->revokeByAuthCodeId($authCodePayload->auth_code_id); $this->refreshTokenRepository->revokeByAuthCodeId($authCodePayload->auth_code_id); @@ -645,7 +743,10 @@ public function validateAuthorizationRequestWithRequestRules( ServerRequestInterface $request, ResultBagInterface $resultBag, ): OAuth2AuthorizationRequest { + $this->loggerService->debug('AuthCodeGrant::validateAuthorizationRequestWithRequestRules'); + $rulesToExecute = [ + ClientIdRule::class, RequestObjectRule::class, PromptRule::class, MaxAgeRule::class, @@ -656,17 +757,25 @@ public function validateAuthorizationRequestWithRequestRules( RequiredOpenIdScopeRule::class, CodeChallengeRule::class, CodeChallengeMethodRule::class, + IssuerStateRule::class, + AuthorizationDetailsRule::class, ]; // Since we have already validated redirect_uri, and we have state, make it available for other checkers. $this->requestRulesManager->predefineResultBag($resultBag); /** @var string $redirectUri */ - $redirectUri = $resultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $resultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $resultBag->getOrFail(StateRule::class)->getValue(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $resultBag->getOrFail(ClientRule::class)->getValue(); + + $this->loggerService->debug('AuthCodeGrant: Resolved data:', [ + 'redirectUri' => $redirectUri, + 'state' => $state, + 'clientId' => $client->getIdentifier(), + ]); // Some rules have to have certain things available in order to work properly... $this->requestRulesManager->setData('default_scope', $this->defaultScope); @@ -679,9 +788,13 @@ public function validateAuthorizationRequestWithRequestRules( $this->allowedAuthorizationHttpMethods, ); + $this->loggerService->debug('AuthCodeGrant: executed rules.', ['rulesToExecute' => $rulesToExecute]); + /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes */ $scopes = $resultBag->getOrFail(ScopeRule::class)->getValue(); + $this->loggerService->debug('AuthCodeGrant: Resolved scopes: ', ['scopes' => $scopes]); + $oAuth2AuthorizationRequest = new OAuth2AuthorizationRequest(); $oAuth2AuthorizationRequest->setClient($client); @@ -696,17 +809,46 @@ public function validateAuthorizationRequestWithRequestRules( /** @var ?string $codeChallenge */ $codeChallenge = $resultBag->getOrFail(CodeChallengeRule::class)->getValue(); if ($codeChallenge) { + $this->loggerService->debug('AuthCodeGrant: Code challenge: ', [ + 'codeChallenge' => $codeChallenge, + ]); /** @var string $codeChallengeMethod */ $codeChallengeMethod = $resultBag->getOrFail(CodeChallengeMethodRule::class)->getValue(); $oAuth2AuthorizationRequest->setCodeChallenge($codeChallenge); $oAuth2AuthorizationRequest->setCodeChallengeMethod($codeChallengeMethod); + } else { + $this->loggerService->debug('AuthCodeGrant: No code challenge present.'); } - if (! $this->isOidcCandidate($oAuth2AuthorizationRequest)) { + $isOidcCandidate = $this->isOidcCandidate($oAuth2AuthorizationRequest); + + + + $this->loggerService->debug('AuthCodeGrant: Is OIDC candidate: ', [ + 'isOidcCandidate' => $isOidcCandidate, + ]); + + $isVciAuthorizationCodeRequest = $this->requestParamsResolver->isVciAuthorizationCodeRequest( + $request, + $this->allowedAuthorizationHttpMethods, + ); + + $this->loggerService->debug('AuthCodeGrant: Is VCI authorization code request: ', [ + 'isVciAuthorizationCodeRequest' => $isVciAuthorizationCodeRequest, + ]); + + + if ( + (! $isOidcCandidate) && + (! $isVciAuthorizationCodeRequest) + ) { + $this->loggerService->debug('Not an OIDC nor VCI request, returning as OAuth2 request.'); return $oAuth2AuthorizationRequest; } + $this->loggerService->debug('AuthCodeGrant: OIDC or VCI request, continuing with request setup.'); + $authorizationRequest = AuthorizationRequest::fromOAuth2AuthorizationRequest($oAuth2AuthorizationRequest); $nonce = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( @@ -714,16 +856,19 @@ public function validateAuthorizationRequestWithRequestRules( $request, $this->allowedAuthorizationHttpMethods, ); + $this->loggerService->debug('AuthCodeGrant: Nonce: ', ['nonce' => $nonce]); if ($nonce !== null) { $authorizationRequest->setNonce($nonce); } $maxAge = $resultBag->get(MaxAgeRule::class); + $this->loggerService->debug('AuthCodeGrant: MaxAge: ', ['maxAge' => $maxAge]); if (null !== $maxAge) { $authorizationRequest->setAuthTime((int) $maxAge->getValue()); } $requestClaims = $resultBag->get(RequestedClaimsRule::class); + $this->loggerService->debug('AuthCodeGrant: Requested claims: ', ['requestClaims' => $requestClaims]); if (null !== $requestClaims) { /** @var ?array $requestClaimValues */ $requestClaimValues = $requestClaims->getValue(); @@ -734,8 +879,76 @@ public function validateAuthorizationRequestWithRequestRules( /** @var array|null $acrValues */ $acrValues = $resultBag->getOrFail(AcrValuesRule::class)->getValue(); + $this->loggerService->debug('AuthCodeGrant: ACR values: ', ['acrValues' => $acrValues]); $authorizationRequest->setRequestedAcrValues($acrValues); + + $authorizationRequest->setIsVciRequest($isVciAuthorizationCodeRequest); + $flowType = $isVciAuthorizationCodeRequest ? + FlowTypeEnum::VciAuthorizationCode : FlowTypeEnum::OidcAuthorizationCode; + $this->loggerService->debug('AuthCodeGrant: FlowType: ', ['flowType' => $flowType]); + $authorizationRequest->setFlowType($flowType); + + /** @var ?string $issuerState */ + $issuerState = $resultBag->get(IssuerStateRule::class)?->getValue(); + $this->loggerService->debug('AuthCodeGrant: Issuer state: ', ['issuerState' => $issuerState]); + $authorizationRequest->setIssuerState($issuerState); + + /** @var ?array $authorizationDetails */ + $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); + $this->loggerService->debug( + 'AuthCodeGrant: Authorization details: ', + ['authorizationDetails' => $authorizationDetails], + ); + $authorizationRequest->setAuthorizationDetails($authorizationDetails); + + // TODO This is a band-aid fix for having credential claims in the userinfo endpoint when + // only VCI authorizationDetails are supplied. This requires configuring a matching OIDC scope + // that has all the credential type claims as well. + if (is_array($authorizationDetails)) { + /** @psalm-suppress MixedAssignment */ + foreach ($authorizationDetails as $authorizationDetail) { + if ( + is_array($authorizationDetail) && + (isset($authorizationDetail['type'])) && + ($authorizationDetail['type']) === 'openid_credential' + ) { + /** @psalm-suppress MixedAssignment */ + $credentialConfigurationId = $authorizationDetail['credential_configuration_id'] ?? null; + if (is_string($credentialConfigurationId)) { + $scopes[] = new ScopeEntity($credentialConfigurationId); + } + } + } + $this->loggerService->debug('authorizationDetails Resolved Scopes: ', ['scopes' => $scopes]); + $authorizationRequest->setScopes($scopes); + } + + // Check if we are using a generic client for this request. This can happen for non-registered clients + // in VCI flows. This can be removed once the VCI clients (wallets) are properly registered using DCR. + if ($client->isGeneric()) { + $this->loggerService->debug( + 'AuthCodeGrant: Generic client is used for authorization request.', + ['genericClientId' => $client->getIdentifier()], + ); + // The generic client was used. Make sure to store actually used client_id and redirect_uri params. + /** @var string $clientIdParam */ + $clientIdParam = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $this->loggerService->debug( + 'AuthCodeGrant: Binding client_id param to request: ', + ['clientIdParam' => $clientIdParam], + ); + $authorizationRequest->setBoundClientId($clientIdParam); + + $this->loggerService->debug( + 'AuthCodeGrant: Binding redirect_uri param to request: ', + ['redirectUri' => $redirectUri], + ); + $authorizationRequest->setBoundRedirectUri($redirectUri); + } + + $this->loggerService->debug('AuthCodeGrant: Finished setting up authorization request.'); + return $authorizationRequest; } diff --git a/src/Server/Grants/ImplicitGrant.php b/src/Server/Grants/ImplicitGrant.php index 4e2026bc..2677ca98 100644 --- a/src/Server/Grants/ImplicitGrant.php +++ b/src/Server/Grants/ImplicitGrant.php @@ -24,10 +24,10 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; @@ -138,11 +138,11 @@ public function validateAuthorizationRequestWithRequestRules( $this->requestRulesManager->predefineResultBag($resultBag); /** @var string $redirectUri */ - $redirectUri = $resultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $resultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $resultBag->getOrFail(StateRule::class)->getValue(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $resultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $resultBag->getOrFail(ClientRule::class)->getValue(); // Some rules need certain things available in order to work properly... $this->requestRulesManager->setData('default_scope', $this->defaultScope); @@ -261,7 +261,7 @@ private function completeOidcAuthorizationRequest(AuthorizationRequest $authoriz $responseParams['expires_in'] = $accessToken->getExpiryDateTime()->getTimestamp() - time(); } - $idToken = $this->idTokenBuilder->build( + $idToken = $this->idTokenBuilder->buildFor( $user, $accessToken, $authorizationRequest->getAddClaimsToIdToken(), @@ -272,7 +272,7 @@ private function completeOidcAuthorizationRequest(AuthorizationRequest $authoriz $authorizationRequest->getSessionId(), ); - $responseParams['id_token'] = $idToken->toString(); + $responseParams['id_token'] = $idToken->getToken(); $response = new RedirectResponse(); diff --git a/src/Server/Grants/PreAuthCodeGrant.php b/src/Server/Grants/PreAuthCodeGrant.php new file mode 100644 index 00000000..5c7e0a24 --- /dev/null +++ b/src/Server/Grants/PreAuthCodeGrant.php @@ -0,0 +1,290 @@ +value; + } + + /** + * Reimplemented to disable authz requests (code is pre-authorized). + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @return bool + */ + public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool + { + return false; + } + + /** + * Check if the authorization request is OIDC candidate (can respond with ID token). + */ + public function isOidcCandidate( + OAuth2AuthorizationRequest $authorizationRequest, + ): bool { + return false; + } + + /** + * @inheritDoc + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \JsonException + */ + public function completeAuthorizationRequest( + OAuth2AuthorizationRequest $authorizationRequest, + ): ResponseTypeInterface { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * This is reimplementation of OAuth2 completeAuthorizationRequest method with addition of nonce handling. + * + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + * @throws \JsonException + */ + public function completeOidcAuthorizationRequest( + AuthorizationRequest $authorizationRequest, + ): RedirectResponse { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + */ + protected function issueOidcAuthCode( + DateInterval $authCodeTTL, + OAuth2ClientEntityInterface $client, + string $userIdentifier, + string $redirectUri, + AuthorizationRequest $authorizationRequest, + ): AuthCodeEntityInterface { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * Reimplementation for Pre-authorized Code. + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \League\OAuth2\Server\ResponseTypes\ResponseTypeInterface $responseType + * @param \DateInterval $accessTokenTTL + * + * @return \League\OAuth2\Server\ResponseTypes\ResponseTypeInterface + * + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \JsonException + * @throws \Throwable + * + */ + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + DateInterval $accessTokenTTL, + ): ResponseTypeInterface { + + // TODO mivanci client authentication? + + $this->loggerService->debug( + 'PreAuthCodeGrant::respondToAccessTokenRequest: Request parameters: ', + $this->requestParamsResolver->getAllFromRequest($request), + ); + + $preAuthorizedCodeId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::PreAuthorizedCode->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (empty($preAuthorizedCodeId)) { + $this->loggerService->error('Empty pre-authorized code ID.'); + throw OidcServerException::invalidRequest(ParamsEnum::PreAuthorizedCode->value); + } + + if (!is_a($this->authCodeRepository, AuthCodeRepository::class)) { + throw OidcServerException::serverError('Unexpected auth code repository entity type.'); + } + + $preAuthorizedCode = $this->authCodeRepository->findById($preAuthorizedCodeId); + + if ( + is_null($preAuthorizedCode) || + !is_a($preAuthorizedCode, AuthCodeEntity::class) + ) { + $this->loggerService->error('Invalid pre-authorized code ID. Value was: ' . $preAuthorizedCodeId); + throw OidcServerException::invalidGrant('Invalid pre-authorized code.'); + } + + $client = $preAuthorizedCode->getClient(); + + $this->validateAuthorizationCode($preAuthorizedCode, $client, $request, $preAuthorizedCode); + + // Validate Transaction Code. + if (($preAuthorizedCodeTxCode = $preAuthorizedCode->getTxCode()) !== null) { + $this->loggerService->debug('Validating transaction code ' . $preAuthorizedCodeTxCode); + $txCodeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::TxCode->value, + $request, + $this->allowedTokenHttpMethods, + ); + + if (empty($txCodeParam)) { + $this->loggerService->warning('Empty transaction code parameter.'); + throw OidcServerException::invalidRequest(ParamsEnum::TxCode->value, 'Transaction Code is missing.'); + } + + $this->loggerService->debug('Transaction code parameter value: ' . $txCodeParam); + + if ($preAuthorizedCodeTxCode !== $txCodeParam) { + $this->loggerService->warning( + 'Transaction code parameter value does not match pre-authorized code transaction code.', + ['txCodeParam' => $txCodeParam, 'preAuthorizedCodeTxCode' => $preAuthorizedCodeTxCode,], + ); + throw OidcServerException::invalidRequest(ParamsEnum::TxCode->value, 'Transaction Code is invalid.'); + } + } + + $resultBag = $this->requestRulesManager->check( + $request, + [AuthorizationDetailsRule::class], + false, + $this->allowedTokenHttpMethods, + ); + + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $this->allowedTokenHttpMethods, + ); + + /** @var ?array $authorizationDetails */ + $authorizationDetails = $resultBag->get(AuthorizationDetailsRule::class)?->getValue(); + + // Issue and persist new access token + $accessToken = $this->issueAccessToken( + $accessTokenTTL, + $client, + $preAuthorizedCode->getUserIdentifier() ? (string) $preAuthorizedCode->getUserIdentifier() : null, + [], // TODO mivanci handle scopes + $preAuthorizedCodeId, + flowTypeEnum: FlowTypeEnum::VciPreAuthorizedCode, + authorizationDetails: $authorizationDetails, + boundClientId: $clientId, + ); + + $this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request)); + $responseType->setAccessToken($accessToken); + + + // TODO mivanci revoke pre-authorized code or let it expire only after access token is issued? + // $this->authCodeRepository->revokeAuthCode($preAuthorizedCode); + + return $responseType; + } + + /** + * Reimplementation because of private parent access + * + * @param object $authCodePayload + * @param \League\OAuth2\Server\Entities\ClientEntityInterface $client + * @param \Psr\Http\Message\ServerRequestInterface $request + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function validateAuthorizationCode( + object $authCodePayload, + OAuth2ClientEntityInterface $client, + ServerRequestInterface $request, + AuthCodeEntity $storedAuthCodeEntity, + ): void { + $this->loggerService->debug('PreAuthCodeGrant::validateAuthorizationCode'); + + if (!$storedAuthCodeEntity->isVciPreAuthorized()) { + $this->loggerService->error( + 'Pre-authorized code is not pre-authorized. ID was: ', + ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], + ); + throw OidcServerException::invalidGrant('Pre-authorized code is not pre-authorized.'); + } + + if ($storedAuthCodeEntity->getExpiryDateTime()->getTimestamp() < time()) { + $this->loggerService->error( + 'Pre-authorized code is expired. ID was: ', + ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], + ); + + throw OidcServerException::invalidGrant('Pre-authorized code is expired.'); + } + + if ($storedAuthCodeEntity->isRevoked()) { + $this->loggerService->error( + 'Pre-authorized code is revoked. ID was: ', + ['preAuthCodeId' => $storedAuthCodeEntity->getIdentifier()], + ); + throw OidcServerException::invalidGrant('Pre-authorized code is revoked.'); + } + + $this->loggerService->debug('PreAuthCodeGrant::validateAuthorizationCode passed.'); + } + + /** + * @inheritDoc + * @throws \Throwable + */ + public function validateAuthorizationRequestWithRequestRules( + ServerRequestInterface $request, + ResultBagInterface $resultBag, + ): OAuth2AuthorizationRequest { + throw OidcServerException::serverError('Not implemented'); + } + + /** + * @param \League\OAuth2\Server\Entities\AccessTokenEntityInterface $accessToken + * @param string|null $authCodeId + * @return \SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface|null + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException + */ + protected function issueRefreshToken( + OAuth2AccessTokenEntityInterface $accessToken, + ?string $authCodeId = null, + ): ?RefreshTokenEntityInterface { + if (! is_a($accessToken, AccessTokenEntityInterface::class)) { + throw OidcServerException::serverError('Unexpected access token entity type.'); + } + + return $this->refreshTokenIssuer->issue( + $accessToken, + $this->refreshTokenTTL, + $authCodeId, + self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS, + ); + } +} diff --git a/src/Server/Grants/Traits/IssueAccessTokenTrait.php b/src/Server/Grants/Traits/IssueAccessTokenTrait.php index 6660ec92..6e8f47b6 100644 --- a/src/Server/Grants/Traits/IssueAccessTokenTrait.php +++ b/src/Server/Grants/Traits/IssueAccessTokenTrait.php @@ -9,6 +9,7 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Grant\AbstractGrant; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; @@ -50,6 +51,11 @@ protected function issueAccessToken( array $scopes = [], ?string $authCodeId = null, ?array $requestedClaims = null, + ?FlowTypeEnum $flowTypeEnum = null, + ?array $authorizationDetails = null, + ?string $boundClientId = null, + ?string $boundRedirectUri = null, + ?string $issuerState = null, ): AccessTokenEntityInterface { $maxGenerationAttempts = AbstractGrant::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS; @@ -70,6 +76,11 @@ protected function issueAccessToken( $userIdentifier, $authCodeId, $requestedClaims, + flowTypeEnum: $flowTypeEnum, + authorizationDetails: $authorizationDetails, + boundClientId: $boundClientId, + boundRedirectUri: $boundRedirectUri, + issuerState: $issuerState, ); $this->accessTokenRepository->persistNewAccessToken($accessToken); return $accessToken; diff --git a/src/Server/RequestRules/Rules/AcrValuesRule.php b/src/Server/RequestRules/Rules/AcrValuesRule.php index f0f1c0df..7d02cf1f 100644 --- a/src/Server/RequestRules/Rules/AcrValuesRule.php +++ b/src/Server/RequestRules/Rules/AcrValuesRule.php @@ -25,6 +25,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('AcrValuesRule::checkRule'); + $acrValues = [ 'essential' => false, 'values' => [], diff --git a/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php new file mode 100644 index 00000000..4b4ff89f --- /dev/null +++ b/src/Server/RequestRules/Rules/AuthorizationDetailsRule.php @@ -0,0 +1,139 @@ +debug('AuthorizationDetailsRule::checkRule.'); + + $authorizationDetailsParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::AuthorizationDetails->value, + $request, + $allowedServerRequestMethods, + ); + + if ($authorizationDetailsParam === null) { + $loggerService->debug('AuthorizationDetailsRule: No authorization_details parameter.'); + return null; + } + + $loggerService->debug( + 'AuthorizationDetailsRule: authorization_details parameter value: ' . $authorizationDetailsParam, + ); + + try { + $authorizationDetails = json_decode($authorizationDetailsParam, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $loggerService->error( + 'AuthorizationDetailsRule: Could not JSON decode authorization_details parameter value.', + ); + return null; + } + + if (!is_array($authorizationDetails)) { + $loggerService->error('AuthorizationDetailsRule: authorization_details parameter value is not an array.'); + return null; + } + + if (empty($authorizationDetails)) { + $loggerService->error('AuthorizationDetailsRule: authorization_details parameter value is empty.'); + return null; + } + + // Since we only use AuthorizationDetailsRule for VCI, we will throw as per RAR spec. + // https://www.rfc-editor.org/rfc/rfc9396.html#name-authorization-error-respons + if (! $this->moduleConfig->getVciEnabled()) { + $loggerService->error('AuthorizationDetailsRule: Rich Authorization Requests are not used by this server.'); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Rich Authorization Requests are not used by this server.', + ); + } + + // Check for known authorization_details and their types. + // Currently, only 'vci' is supported, which defines type as per: + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-using-authorization-details + foreach ($authorizationDetails as $authorizationDetail) { + if (!is_array($authorizationDetail)) { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value is not an array.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Malformed authorization_details parameter value.', + ); + } + + if (!isset($authorizationDetail['type'])) { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value has no type.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Authorization details parameter value has no type.', + ); + } + + if ($authorizationDetail['type'] !== 'openid_credential') { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value has unknown type.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Authorization details parameter value has unknown type.', + ); + } + + if (!isset($authorizationDetail['credential_configuration_id'])) { + $loggerService->error( + 'AuthorizationDetailsRule: authorization_details parameter value has no' . + ' credential_configuration_id.', + ); + throw OidcServerException::invalidRequest( + 'authorization_details', + 'Authorization details parameter value has no credential_configuration_id.', + ); + } + } + + $loggerService->debug( + 'AuthorizationDetailsRule: authorization_details decoded.', + ['authorization_details' => $authorizationDetails,], + ); + + return new Result($this->getKey(), $authorizationDetails); + } +} diff --git a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php index edc4cecf..7a062bd6 100644 --- a/src/Server/RequestRules/Rules/ClientAuthenticationRule.php +++ b/src/Server/RequestRules/Rules/ClientAuthenticationRule.php @@ -5,32 +5,22 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; 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\JwksResolver; -use SimpleSAML\Module\oidc\Utils\ProtocolCache; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Codebooks\ClientAssertionTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\Codebooks\ParamsEnum; class ClientAuthenticationRule extends AbstractRule { - protected const string KEY_CLIENT_ASSERTION_JTI = 'client_assertion_jti'; - - public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, - protected ModuleConfig $moduleConfig, - protected JwksResolver $jwksResolver, - protected ?ProtocolCache $protocolCache, + protected AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -47,102 +37,34 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { - /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); - - // We will only perform client authentication if the client type is confidential. - if (!$client->isConfidential()) { - return new Result($this->getKey(), null); - } - // Let's check if client secret is provided. - /** @var ?string $clientSecret */ - $clientSecret = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::ClientSecret->value, - $request, - $allowedServerRequestMethods, - ) ?? $request->getServerParams()['PHP_AUTH_PW'] ?? null; + $loggerService->debug('ClientAuthenticationRule::checkRule'); - if ($clientSecret) { - hash_equals($client->getSecret(), $clientSecret) || throw OidcServerException::invalidClient($request); - return new Result($this->getKey(), ParamsEnum::ClientSecret->value); - } + // TODO mivanci Instead of ClientRule which mandates client, this should + // be refactored to use optional client_id parameter and then + // fetch client if present. + /** @var ?\SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $preFetchedClient */ + $preFetchedClient = $currentResultBag->get(ClientRule::class)?->getValue(); - // No client_secret provided, meaning client_secret_post or client_secret_basic client authentication methods - // were not used. Let's check for private_key_jwt method. - $clientAssertionParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::ClientAssertion->value, - $request, - $allowedServerRequestMethods, + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod( + request: $request, + preFetchedClient: $preFetchedClient, ); - if (is_null($clientAssertionParam)) { + if (is_null($resolvedClientAuthenticationMethod)) { throw OidcServerException::accessDenied('Not a single client authentication method presented.'); } - // private_key_jwt authentication method is used. - // Check the expected assertion type param. - $clientAssertionType = $this->requestParamsResolver->getfromRequestBasedOnAllowedMethods( - ParamsEnum::ClientAssertionType->value, - $request, - $allowedServerRequestMethods, - ); - - if ($clientAssertionType !== ClientAssertionTypesEnum::JwtBaerer->value) { - throw OidcServerException::invalidRequest(ParamsEnum::ClientAssertionType->value); - } - - $clientAssertion = $this->requestParamsResolver->parseClientAssertionToken($clientAssertionParam); - - // Check if the Client Assertion token has already been used. Only applicable if we have cache available. - if ($this->protocolCache) { - ($this->protocolCache->has(self::KEY_CLIENT_ASSERTION_JTI, $clientAssertion->getJwtId()) === false) - || throw OidcServerException::invalidRequest( - ParamsEnum::ClientAssertion->value, - 'Client Assertion reused.', - ); - } - - ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( - 'Can not validate Client Assertion, client JWKS not available.', - ); - - try { - $clientAssertion->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { + // Ensure we that the method is not 'None' if client is confidential. + if ( + $resolvedClientAuthenticationMethod->getClientAuthenticationMethod()->isNone() && + $resolvedClientAuthenticationMethod->getClient()->isConfidential() + ) { throw OidcServerException::accessDenied( - 'Client Assertion validation failed: ' . $exception->getMessage(), + 'Confidential client must use an authentication method other than "none".', ); } - ($client->getIdentifier() === $clientAssertion->getIssuer()) || throw OidcServerException::accessDenied( - 'Invalid Client Assertion Issuer claim.', - ); - - ($client->getIdentifier() === $clientAssertion->getSubject()) || throw OidcServerException::accessDenied( - 'Invalid Client Assertion Subject claim.', - ); - - // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. - // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization - // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. - $expectedAudience = [ - $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), - $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), - $this->moduleConfig->getIssuer(), - ]; - - (!empty(array_intersect($expectedAudience, $clientAssertion->getAudience()))) || - throw OidcServerException::accessDenied('Invalid Client Assertion Audience claim.'); - - // Everything seems ok. Save it in cache so we can check for reuse. - $this->protocolCache?->set( - $clientAssertion->getJwtId(), - $this->helpers->dateTime()->getSecondsToExpirationTime($clientAssertion->getExpirationTime()), - self::KEY_CLIENT_ASSERTION_JTI, - $clientAssertion->getJwtId(), - ); - - return new Result($this->getKey(), ParamsEnum::ClientAssertion->value); + return new Result($this->getKey(), $resolvedClientAuthenticationMethod); } } diff --git a/src/Server/RequestRules/Rules/ClientIdRule.php b/src/Server/RequestRules/Rules/ClientIdRule.php index acb0a84e..b329c179 100644 --- a/src/Server/RequestRules/Rules/ClientIdRule.php +++ b/src/Server/RequestRules/Rules/ClientIdRule.php @@ -5,48 +5,19 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Error\ConfigurationError; -use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; -use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; -use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; -use SimpleSAML\Module\oidc\Forms\ClientForm; -use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\FederationCache; -use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; -use SimpleSAML\Module\oidc\Utils\JwksResolver; -use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; -use SimpleSAML\OpenID\Federation; -use Throwable; +/** + * Resolve a client instance based on a client_id or request object. + */ class ClientIdRule extends AbstractRule { - protected const string KEY_REQUEST_OBJECT_JTI = 'request_object_jti'; - - - public function __construct( - RequestParamsResolver $requestParamsResolver, - Helpers $helpers, - protected ClientRepository $clientRepository, - protected ModuleConfig $moduleConfig, - protected ClientEntityFactory $clientEntityFactory, - protected Federation $federation, - protected JwksResolver $jwksResolver, - protected FederationParticipationValidator $federationParticipationValidator, - protected ?FederationCache $federationCache = null, - ) { - parent::__construct($requestParamsResolver, $helpers); - } - /** * @inheritDoc * @throws \JsonException @@ -70,8 +41,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ClientIdRule::checkRule'); + /** @var ?string $clientId */ - $clientId = $this->requestParamsResolver->getBasedOnAllowedMethods( + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::ClientId->value, $request, $allowedServerRequestMethods, @@ -81,172 +54,6 @@ public function checkRule( throw OidcServerException::invalidRequest('client_id'); } - $client = $this->clientRepository->getClientEntity($clientId); - - if ($client instanceof ClientEntityInterface) { - return new Result($this->getKey(), $client); - } - - // If federation capabilities are not enabled, we don't have anything else to do. - if ($this->moduleConfig->getFederationEnabled() === false) { - throw OidcServerException::invalidClient($request); - } - - // Federation is enabled. - // Check if we have a request object available. If not, we don't have anything else to do. - $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::Request->value, - $request, - $allowedServerRequestMethods, - ); - - if (is_null($requestParam)) { - throw OidcServerException::invalidClient($request); - } - - // We have a request object available. We must verify that it is the one compatible with OpenID Federation - // specification (not only Core specification). - try { - $requestObject = $this->requestParamsResolver->parseFederationRequestObjectToken($requestParam); - } catch (Throwable $exception) { - throw OidcServerException::invalidRequest( - ParamsEnum::Request->value, - 'Request object error: ' . $exception->getMessage(), - $exception, - ); - } - - // We have a Federation compatible Request Object. - // The Audience (aud) value MUST be or include the OP's Issuer Identifier URL. - (in_array($this->moduleConfig->getIssuer(), $requestObject->getAudience(), true)) || - throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Invalid audience.'); - - // Check for reuse of the Request Object. Request Object MUST only be used once (by OpenID Federation spec). - if ($this->federationCache) { - ($this->federationCache->has(self::KEY_REQUEST_OBJECT_JTI, $requestObject->getJwtId()) === false) - || throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Request Object reused.'); - } - - $clientEntityId = $requestObject->getIssuer(); - // Make sure that the Client ID is valid URL. - (preg_match(ClientForm::REGEX_HTTP_URI_PATH, $requestObject->getIssuer())) || - throw OidcServerException::invalidRequest(ParamsEnum::Request->value, 'Client ID is not valid URI.'); - - // We are ready to resolve trust chain. - // TODO mivanci v7 Request Object can contain trust_chain claim, so also implement resolving using that claim. - // Note that this is only possible if we have JWKS configured for common TA, so we can check TA Configuration - // signature. - try { - $trustChain = $this->federation->trustChainResolver()->for( - $clientEntityId, - $this->moduleConfig->getFederationTrustAnchorIds(), - )->getShortest(); - } catch (ConfigurationError $exception) { - throw OidcServerException::serverError( - 'invalid OIDC configuration: ' . $exception->getMessage(), - $exception, - ); - } catch (Throwable $exception) { - throw OidcServerException::invalidTrustChain( - 'error while trying to resolve trust chain: ' . $exception->getMessage(), - null, - $exception, - ); - } - - // Validate TA with locally saved JWKS, if available. - $trustAnchorEntityConfiguration = $trustChain->getResolvedTrustAnchor(); - $localTrustAnchorJwksJson = $this->moduleConfig - ->getTrustAnchorJwksJson($trustAnchorEntityConfiguration->getIssuer()); - if (!is_null($localTrustAnchorJwksJson)) { - /** @psalm-suppress MixedArgument */ - $localTrustAnchorJwks = $this->federation->helpers()->json()->decode($localTrustAnchorJwksJson); - if (!is_array($localTrustAnchorJwks)) { - throw OidcServerException::serverError('Unexpected JWKS format.'); - } - $trustAnchorEntityConfiguration->verifyWithKeySet($localTrustAnchorJwks); - } - - $clientFederationEntity = $trustChain->getResolvedLeaf(); - - if ($clientFederationEntity->getIssuer() !== $clientEntityId) { - throw OidcServerException::invalidTrustChain( - 'Client entity ID mismatch in request object and configuration statement.', - ); - } - try { - $clientMetadata = $trustChain->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty); - } catch (Throwable $exception) { - throw OidcServerException::invalidTrustChain( - 'Error while trying to resolve relying party metadata: ' . $exception->getMessage(), - null, - $exception, - ); - } - - if (is_null($clientMetadata)) { - throw OidcServerException::invalidTrustChain('No relying party metadata available.'); - } - - // We have client metadata resolved. Check if the client exists in storage, as it may be previously registered - // but marked as expired. - $existingClient = $this->clientRepository->findById($clientEntityId); - - if ($existingClient && ($existingClient->isEnabled() === false)) { - throw OidcServerException::accessDenied('Client is disabled.'); - } - - if ($existingClient && ($existingClient->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic)) { - throw OidcServerException::accessDenied( - 'Unexpected existing client registration type: ' . $existingClient->getRegistrationType()->value, - ); - } - - // Resolve client registration metadata - $registrationClient = $this->clientEntityFactory->fromRegistrationData( - $clientMetadata, - RegistrationTypeEnum::FederatedAutomatic, - $this->helpers->dateTime()->getFromTimestamp($trustChain->getResolvedExpirationTime()), - $existingClient, - $clientEntityId, - $clientFederationEntity->getJwks()->getValue(), - $request, - ); - - ($clientJwks = $this->jwksResolver->forClient($registrationClient)) || - throw OidcServerException::accessDenied('Client JWKS not available.'); - - // Verify signature on Request Object using client JWKS. - $requestObject->verifyWithKeySet($clientJwks); - - // Check if federation participation is limited by Trust Marks. - if ( - $this->moduleConfig->isFederationParticipationLimitedByTrustMarksFor( - $trustAnchorEntityConfiguration->getIssuer(), - ) - ) { - $this->federationParticipationValidator->byTrustMarksFor($trustChain); - } - - // All is verified, We can persist (new) client registration. - if ($existingClient) { - $this->clientRepository->update($registrationClient); - } else { - $this->clientRepository->add($registrationClient); - } - - // Mark Request Object as used. - $this->federationCache?->set( - $requestObject->getJwtId(), - $this->helpers->dateTime()->getSecondsToExpirationTime($requestObject->getExpirationTime()), - self::KEY_REQUEST_OBJECT_JTI, - $requestObject->getJwtId(), - ); - - // We will also update result for RequestParameterRule (inject value from here), since the request object - // is already resolved. - $currentResultBag->add(new Result(RequestObjectRule::class, $requestObject->getPayload())); - - return new Result($this->getKey(), $registrationClient); + return new Result($this->getKey(), $clientId); } } diff --git a/src/Server/RequestRules/Rules/ClientRedirectUriRule.php b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php new file mode 100644 index 00000000..3c00c763 --- /dev/null +++ b/src/Server/RequestRules/Rules/ClientRedirectUriRule.php @@ -0,0 +1,123 @@ +debug('RedirectUriRule::checkRule'); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + if (! $client instanceof ClientEntityInterface) { + throw new LogicException('Can not check redirect_uri, client is not ClientEntityInterface.'); + } + + $redirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::RedirectUri->value, + $request, + $allowedServerRequestMethods, + ); + + // On OAuth2 redirect_uri is optional if there is only one registered, however we will always require it + // since this is OIDC oriented package and in OIDC this parameter is required. + if ($redirectUri === null) { + throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); + } + + $clientRedirectUri = $client->getRedirectUri(); + + try { + if (is_string($clientRedirectUri) && (strcmp($clientRedirectUri, $redirectUri) !== 0)) { + throw OidcServerException::invalidClient($request); + } elseif ( + is_array($clientRedirectUri) && + in_array($redirectUri, $clientRedirectUri, true) === false + ) { + throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); + } + } catch (\Throwable $exception) { + if ( + $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && + $this->moduleConfig->getVciEnabled() && + $this->moduleConfig->getVciAllowNonRegisteredClients() + ) { + $loggerService->debug( + 'RedirectUriRule: Verifiable Credential capabilities with non-registered clients are enabled. ' . + 'Checking for allowed redirect URI prefixes.', + ); + + /** @psalm-suppress MixedAssignment */ + foreach ( + $this->moduleConfig->getVciAllowedRedirectUriPrefixesForNonRegisteredClients( + ) as $clientRedirectUriPrefix + ) { + if (str_starts_with($redirectUri, (string)$clientRedirectUriPrefix)) { + $loggerService->debug( + 'RedirectUriRule: Redirect URI param starts with allowed redirect URI prefix, continuing.', + ['redirect_uri' => $redirectUri, 'redirect_uri_prefix' => $clientRedirectUriPrefix], + ); + + return new Result($this->getKey(), $redirectUri); + } + } + + $loggerService->error( + 'RedirectUriRule: Redirect URI param does not start with allowed redirect URI prefix, stopping.', + ['redirect_uri' => $redirectUri], + ); + + throw $exception; + } else { + $loggerService->debug( + 'RedirectUriRule: Verifiable Credential capabilities with non-registered clients are not enabled. ', + ); + $loggerService->error( + 'RedirectUriRule: Redirect URI param does not correspond to the client redirect URI.', + ['redirect_uri' => $redirectUri, 'client_redirect_uri' => $clientRedirectUri], + ); + throw $exception; + } + } + + $loggerService->debug( + 'RedirectUriRule: Redirect URI param corresponds to the client redirect URI.', + ['redirect_uri' => $redirectUri, 'client_redirect_uri' => $clientRedirectUri], + ); + + return new Result($this->getKey(), $redirectUri); + } +} diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php new file mode 100644 index 00000000..ac6a1160 --- /dev/null +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -0,0 +1,367 @@ +debug('ClientRule::checkRule.'); + + /** @var ?string $clientId */ + $clientId = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $allowedServerRequestMethods, + ) ?? $request->getServerParams()['PHP_AUTH_USER'] ?? null; + + if ($clientId === null) { + $this->loggerService->debug('ClientRule: Client ID not found in request parameters or PHP_AUTH_USER.'); + + throw OidcServerException::invalidRequest('client_id'); + } + + $this->loggerService->debug( + 'ClientRule: Client ID: ' . $clientId, + ); + + $client = $this->clientRepository->getClientEntity($clientId); + + if ($client instanceof ClientEntityInterface) { + $this->loggerService->debug( + 'ClientRule: Client found in storage: ' . $client->getIdentifier(), + ); + return new Result($this->getKey(), $client); + } + + // If federation capabilities are not enabled, we don't have anything else to do. + if ($this->moduleConfig->getFederationEnabled()) { + $this->loggerService->debug( + 'ClientRule: Federation capabilities are enabled.', + ); + + $client = $this->resolveFromFederation($request, $allowedServerRequestMethods, $currentResultBag); + + if ($client instanceof ClientEntityInterface) { + $this->loggerService->debug( + 'ClientRule: Client resolved from federation: ' . $client->getIdentifier(), + ); + return new Result($this->getKey(), $client); + } + } else { + $this->loggerService->debug( + 'ClientRule: Federation capabilities are not enabled.', + ); + } + + if ( + $this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods) && + $this->moduleConfig->getVciEnabled() && + $this->moduleConfig->getVciAllowNonRegisteredClients() + ) { + $this->loggerService->debug( + 'ClientRule: Verifiable Credential capabilities with non-registered clients are enabled. ' . + 'Falling back to generic VCI client.', + ); + + return new Result($this->getKey(), $this->clientRepository->getGenericForVci()); + } else { + $this->loggerService->debug( + 'ClientRule: Not a VCI request, or VCI capabilities not enabled, or VCI with non-registered' . + ' clients not enabled.', + ); + } + + $this->loggerService->debug('ClientRule: Client could not be resolved.'); + + throw OidcServerException::invalidClient($request); + } + + /** + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods + */ + public function resolveFromFederation( + ServerRequestInterface $request, + array $allowedMethods, + ResultBagInterface $currentResultBag, + ): ?ClientEntityInterface { + $this->loggerService->debug('ClientRule: Resolving client from federation.'); + // Federation is enabled. + // Check if we have a request object available. If not, we don't have anything else to do. + $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Request->value, + $request, + $allowedMethods, + ); + + if (is_null($requestParam)) { + $this->loggerService->error('ClientRule: No request param available, nothing to do.'); + return null; + } + + $this->loggerService->debug('ClientRule: Request param available.', ['requestParam' => $requestParam]); + + // We have a request object available. We must verify that it is the one compatible with OpenID Federation + // specification (not only Core specification). + try { + $requestObject = $this->requestParamsResolver->parseFederationRequestObjectToken($requestParam); + } catch (Throwable $exception) { + $this->loggerService->error('ClientRule: Request object error: ' . $exception->getMessage()); + return null; + } + + $this->loggerService->debug('ClientRule: Request object parsed successfully.'); + + // We have a Federation-compatible Request Object. + // The Audience (aud) value MUST be or include the OP's Issuer Identifier URL. + if (! in_array($this->moduleConfig->getIssuer(), $requestObject->getAudience(), true)) { + $this->loggerService->error( + 'ClientRule: Request object audience mismatch.', + ['expected' => $this->moduleConfig->getIssuer(), 'actual' => $requestObject->getAudience()], + ); + return null; + } + + // Check for reuse of the Request Object. Request Object MUST only be used once (by OpenID Federation spec). + if ( + $this->federationCache && + $this->federationCache->has(self::KEY_REQUEST_OBJECT_JTI, $requestObject->getJwtId()) + ) { + $this->loggerService->error( + 'ClientRule: Request object reused.', + ['request_object_jti' => $requestObject->getJwtId()], + ); + return null; + } + + $clientEntityId = $requestObject->getIssuer(); + // Make sure that the Client Entity ID is valid URL. + if (!preg_match(ClientForm::REGEX_HTTP_URI_PATH, $clientEntityId)) { + $this->loggerService->error( + 'ClientRule: Client Entity ID is not valid URI.', + ['client_id' => $clientEntityId], + ); + return null; + } + + $this->loggerService->debug('ClientRule: Client Entity ID is valid URI.'); + + // We are ready to resolve trust chain. + // TODO mivanci v7 Request Object can contain trust_chain claim, so also implement resolving using that claim. + // Note that this is only possible if we have JWKS configured for common TA, so we can check TA Configuration + // signature. + try { + $this->loggerService->debug('ClientRule: Resolving trust chain.'); + $trustChain = $this->federation->trustChainResolver()->for( + $clientEntityId, + $this->moduleConfig->getFederationTrustAnchorIds(), + )->getShortest(); + } catch (ConfigurationError $exception) { + $this->loggerService->error('ClientRule: Invalid OIDC configuration: ' . $exception->getMessage()); + return null; + } catch (Throwable $exception) { + $this->loggerService->error( + 'ClientRule: Error while trying to resolve trust chain: ' . $exception->getMessage(), + ); + return null; + } + + // Validate TA with locally saved JWKS, if available. + $trustAnchorEntityConfiguration = $trustChain->getResolvedTrustAnchor(); + $localTrustAnchorJwksJson = $this->moduleConfig + ->getTrustAnchorJwksJson($trustAnchorEntityConfiguration->getIssuer()); + if (!is_null($localTrustAnchorJwksJson)) { + $this->loggerService->debug('ClientRule: Validating TA with locally saved JWKS.'); + /** @psalm-suppress MixedArgument */ + $localTrustAnchorJwks = $this->federation->helpers()->json()->decode($localTrustAnchorJwksJson); + if (!is_array($localTrustAnchorJwks)) { + $this->loggerService->error( + 'ClientRule: Unexpected JWKS format for locally saved Trust Anchor JWKS.', + ); + return null; + } + $trustAnchorEntityConfiguration->verifyWithKeySet($localTrustAnchorJwks); + $this->loggerService->debug('ClientRule: TA with locally saved JWKS validated successfully.'); + } + + $clientFederationEntity = $trustChain->getResolvedLeaf(); + + if ($clientFederationEntity->getIssuer() !== $clientEntityId) { + $this->loggerService->error( + 'Client entity ID mismatch in request object and configuration statement.', + ['expected' => $clientFederationEntity->getIssuer(), 'actual' => $clientEntityId], + ); + } + + try { + $this->loggerService->debug('ClientRule: Resolving relying party metadata.'); + $clientMetadata = $trustChain->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty); + } catch (Throwable $exception) { + $this->loggerService->error( + 'ClientRule: Error while trying to resolve relying party metadata: ' . $exception->getMessage(), + ); + return null; + } + + if (is_null($clientMetadata)) { + $this->loggerService->error('ClientRule: No relying party metadata available.'); + return null; + } + + // We have client metadata resolved. Check if the client exists in storage, as it may be previously registered + // but marked as expired. + $existingClient = $this->clientRepository->findById($clientEntityId); + + if ($existingClient && ($existingClient->isEnabled() === false)) { + $this->loggerService->error('ClientRule: Client is disabled:'); + return null; + } + + if ($existingClient && ($existingClient->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic)) { + $this->loggerService->error( + 'Unexpected existing client registration type: ' . $existingClient->getRegistrationType()->value, + ); + return null; + } + + // Resolve client registration metadata + $registrationClient = $this->clientEntityFactory->fromRegistrationData( + $clientMetadata, + RegistrationTypeEnum::FederatedAutomatic, + $this->helpers->dateTime()->getFromTimestamp($trustChain->getResolvedExpirationTime()), + $existingClient, + $clientEntityId, + $clientFederationEntity->getJwks()->getValue(), + ); + + $clientJwks = $this->jwksResolver->forClient($registrationClient); + if (!is_array($clientJwks)) { + $this->loggerService->debug('ClientRule: Client JWKS not available.'); + return null; + } + + // Verify signature on Request Object using client JWKS. + try { + $requestObject->verifyWithKeySet($clientJwks); + } catch (JwsException $e) { + $this->loggerService->error( + 'ClientRule: Request object signature verification failed: ' . $e->getMessage(), + ); + return null; + } + + // Check if federation participation is limited by Trust Marks. + if ( + $this->moduleConfig->isFederationParticipationLimitedByTrustMarksFor( + $trustAnchorEntityConfiguration->getIssuer(), + ) + ) { + $this->loggerService->debug('ClientRule: Verifying trust marks for federation participation.'); + try { + $this->federationParticipationValidator->byTrustMarksFor($trustChain); + } catch (Throwable $e) { + $this->loggerService->error( + 'ClientRule: Trust marks for federation participation verification failed: ' . $e->getMessage(), + ); + return null; + } + } + + $this->loggerService->debug('ClientRule: All verified, persisting client registration.'); + + // All is verified, We can persist (new) client registration. + if ($existingClient) { + $this->clientRepository->update($registrationClient); + } else { + $this->clientRepository->add($registrationClient); + } + + // Mark Request Object as used. + try { + $this->federationCache?->set( + $requestObject->getJwtId(), + $this->helpers->dateTime()->getSecondsToExpirationTime($requestObject->getExpirationTime()), + self::KEY_REQUEST_OBJECT_JTI, + $requestObject->getJwtId(), + ); + } catch (Throwable $e) { + $this->loggerService->error( + 'ClientRule: Error while trying to mark request object as used: ' . $e->getMessage(), + ); + } + + // We will also update a result for RequestParameterRule (inject value from here), since the request object + // is already resolved. + $currentResultBag->add(new Result(RequestObjectRule::class, $requestObject->getPayload())); + + return $registrationClient; + } +} diff --git a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php index ed087d3e..33d5f70a 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeMethodRule.php @@ -38,8 +38,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('CodeChallengeMethodRule::checkRule'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/CodeChallengeRule.php b/src/Server/RequestRules/Rules/CodeChallengeRule.php index 38a9e431..feb37160 100644 --- a/src/Server/RequestRules/Rules/CodeChallengeRule.php +++ b/src/Server/RequestRules/Rules/CodeChallengeRule.php @@ -27,10 +27,12 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('CodeChallengeRule::checkRule'); + /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/CodeVerifierRule.php b/src/Server/RequestRules/Rules/CodeVerifierRule.php index 5f96672d..8b3767eb 100644 --- a/src/Server/RequestRules/Rules/CodeVerifierRule.php +++ b/src/Server/RequestRules/Rules/CodeVerifierRule.php @@ -27,7 +27,7 @@ public function checkRule( array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $codeVerifier = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::CodeVerifier->value, diff --git a/src/Server/RequestRules/Rules/IdTokenHintRule.php b/src/Server/RequestRules/Rules/IdTokenHintRule.php index c1160b01..8feccbf2 100644 --- a/src/Server/RequestRules/Rules/IdTokenHintRule.php +++ b/src/Server/RequestRules/Rules/IdTokenHintRule.php @@ -4,12 +4,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\SignedWith; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Factories\CryptKeyFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -20,15 +15,17 @@ use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; -use Throwable; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Jwks; class IdTokenHintRule extends AbstractRule { public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, - protected ModuleConfig $moduleConfig, - protected CryptKeyFactory $cryptKeyFactory, + protected readonly ModuleConfig $moduleConfig, + protected readonly Jwks $jwks, + protected readonly Core $core, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -58,16 +55,6 @@ public function checkRule( return new Result($this->getKey(), $idTokenHintParam); } - // TODO v7 mivanci Fix: unmockable services... inject instead. - $privateKey = $this->cryptKeyFactory->buildPrivateKey(); - $publicKey = $this->cryptKeyFactory->buildPublicKey(); - /** @psalm-suppress ArgumentTypeCoercion */ - $jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfig->getProtocolSigner(), - InMemory::plainText($privateKey->getKeyContents(), $privateKey->getPassPhrase() ?? ''), - InMemory::plainText($publicKey->getKeyContents()), - ); - if (empty($idTokenHintParam)) { throw OidcServerException::invalidRequest( ParamsEnum::IdTokenHint->value, @@ -78,23 +65,25 @@ public function checkRule( ); } - try { - /** @var \Lcobucci\JWT\UnencryptedToken $idTokenHint */ - $idTokenHint = $jwtConfig->parser()->parse($idTokenHintParam); + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + + $idTokenHint = $this->core->idTokenFactory()->fromToken($idTokenHintParam); - /** @psalm-suppress ArgumentTypeCoercion */ - $jwtConfig->validator()->assert( - $idTokenHint, - new IssuedBy($this->moduleConfig->getIssuer()), - // Note: although logout spec does not mention it, validating signature seems like an important check - // to make. However, checking the signature in a key roll-over scenario will fail for ID tokens - // signed with previous key... - new SignedWith( - $this->moduleConfig->getProtocolSigner(), - InMemory::plainText($publicKey->getKeyContents()), - ), + if ($idTokenHint->getIssuer() !== $this->moduleConfig->getIssuer()) { + throw OidcServerException::invalidRequest( + ParamsEnum::IdTokenHint->value, + 'Invalid ID Token Hint Issuer', + null, + null, + $state, ); - } catch (Throwable $exception) { + } + + try { + $idTokenHint->verifyWithKeySet($jwks); + } catch (\Throwable $exception) { throw OidcServerException::invalidRequest( ParamsEnum::IdTokenHint->value, $exception->getMessage(), @@ -104,6 +93,7 @@ public function checkRule( ); } + return new Result($this->getKey(), $idTokenHint); } } diff --git a/src/Server/RequestRules/Rules/IssuerStateRule.php b/src/Server/RequestRules/Rules/IssuerStateRule.php new file mode 100644 index 00000000..7ba9bf2d --- /dev/null +++ b/src/Server/RequestRules/Rules/IssuerStateRule.php @@ -0,0 +1,36 @@ +requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::IssuerState->value, + $request, + $allowedServerRequestMethods, + ); + + return new Result($this->getKey(), $issuerState); + } +} diff --git a/src/Server/RequestRules/Rules/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index 38c4a809..e5731a7f 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -46,13 +46,15 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('MaxAgeRule::checkRule'); + $requestParams = $this->requestParamsResolver->getAllBasedOnAllowedMethods( $request, $allowedServerRequestMethods, ); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $authSimple = $this->authSimpleFactory->build($client); @@ -61,7 +63,7 @@ public function checkRule( } /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var ?string $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); @@ -94,7 +96,7 @@ public function checkRule( $requestParams, ); - $this->authenticationService->authenticate($client, $loginParams); + $this->authenticationService->authenticateForClient($client, $loginParams); } return new Result($this->getKey(), $lastAuth); diff --git a/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php b/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php index d27dace8..258d8186 100644 --- a/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php +++ b/src/Server/RequestRules/Rules/PostLogoutRedirectUriRule.php @@ -41,7 +41,7 @@ public function checkRule( /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); - /** @var \Lcobucci\JWT\UnencryptedToken|null $idTokenHint */ + /** @var \SimpleSAML\OpenID\Core\IdToken|null $idTokenHint */ $idTokenHint = $currentResultBag->getOrFail(IdTokenHintRule::class)->getValue(); $postLogoutRedirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( @@ -61,19 +61,7 @@ public function checkRule( throw OidcServerException::invalidRequest('id_token_hint', $hint); } - $claims = $idTokenHint->claims()->all(); - - if (empty($claims['aud'])) { - throw OidcServerException::invalidRequest( - ParamsEnum::IdTokenHint->value, - 'aud claim not present', - null, - null, - $state, - ); - } - /** @var string[] $auds */ - $auds = is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $auds = $idTokenHint->getAudience(); $isPostLogoutRedirectUriRegistered = false; foreach ($auds as $aud) { diff --git a/src/Server/RequestRules/Rules/PromptRule.php b/src/Server/RequestRules/Rules/PromptRule.php index 60fd38cc..8a994f45 100644 --- a/src/Server/RequestRules/Rules/PromptRule.php +++ b/src/Server/RequestRules/Rules/PromptRule.php @@ -47,8 +47,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('PromptRule::checkRule'); + /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $authSimple = $this->authSimpleFactory->build($client); @@ -66,7 +68,7 @@ public function checkRule( throw OAuthServerException::invalidRequest(ParamsEnum::Prompt->value, 'Invalid prompt parameter'); } /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var ?string $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); @@ -88,7 +90,7 @@ public function checkRule( $requestParams, ); - $this->authenticationService->authenticate($client, $loginParams); + $this->authenticationService->authenticateForClient($client, $loginParams); } return null; diff --git a/src/Server/RequestRules/Rules/RedirectUriRule.php b/src/Server/RequestRules/Rules/RedirectUriRule.php deleted file mode 100644 index 22a9f95f..00000000 --- a/src/Server/RequestRules/Rules/RedirectUriRule.php +++ /dev/null @@ -1,61 +0,0 @@ -getOrFail(ClientIdRule::class)->getValue(); - if (! $client instanceof ClientEntityInterface) { - throw new LogicException('Can not check redirect_uri, client is not ClientEntityInterface.'); - } - - $redirectUri = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( - ParamsEnum::RedirectUri->value, - $request, - $allowedServerRequestMethods, - ); - - // On OAuth2 redirect_uri is optional if there is only one registered, however we will always require it - // since this is OIDC oriented package and in OIDC this parameter is required. - if ($redirectUri === null) { - throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); - } - - $clientRedirectUri = $client->getRedirectUri(); - if (is_string($clientRedirectUri) && (strcmp($clientRedirectUri, $redirectUri) !== 0)) { - throw OidcServerException::invalidClient($request); - } elseif ( - is_array($clientRedirectUri) && - in_array($redirectUri, $clientRedirectUri, true) === false - ) { - throw OidcServerException::invalidRequest(ParamsEnum::RedirectUri->value); - } - - return new Result($this->getKey(), $redirectUri); - } -} diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index a1f74a24..81c05812 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -38,6 +38,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequestObjectRule::checkRule'); + $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::Request->value, $request, @@ -67,9 +69,9 @@ public function checkRule( // It is protected, we must validate it. /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); diff --git a/src/Server/RequestRules/Rules/RequestedClaimsRule.php b/src/Server/RequestRules/Rules/RequestedClaimsRule.php index d8b27970..3a7d60b3 100644 --- a/src/Server/RequestRules/Rules/RequestedClaimsRule.php +++ b/src/Server/RequestRules/Rules/RequestedClaimsRule.php @@ -37,6 +37,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequestedClaimsRule::checkRule'); + /** @psalm-suppress MixedAssignment We'll check the type. */ $claimsParam = $this->requestParamsResolver->getBasedOnAllowedMethods( ParamsEnum::Claims->value, @@ -56,7 +58,7 @@ public function checkRule( return null; } /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); $authorizedClaims = []; foreach ($client->getScopes() as $scope) { diff --git a/src/Server/RequestRules/Rules/RequiredNonceRule.php b/src/Server/RequestRules/Rules/RequiredNonceRule.php index e96529c0..16034d17 100644 --- a/src/Server/RequestRules/Rules/RequiredNonceRule.php +++ b/src/Server/RequestRules/Rules/RequiredNonceRule.php @@ -28,7 +28,7 @@ public function checkRule( array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php index b6695c02..5fa0dc86 100644 --- a/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php +++ b/src/Server/RequestRules/Rules/RequiredOpenIdScopeRule.php @@ -26,8 +26,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('RequiredOpenIdScopeRule::checkRule.'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] $validScopes */ @@ -38,15 +40,29 @@ public function checkRule( fn($scopeEntity) => $scopeEntity->getIdentifier() === 'openid', ); - if (! $isOpenIdScopePresent) { - throw OidcServerException::invalidRequest( - 'scope', - 'Scope openid is required', - null, - $redirectUri, - $state, - $useFragmentInHttpErrorResponses, - ); + $loggerService->debug( + 'RequiredOpenIdScopeRule: Is openid scope present: ', + ['isOpenIdScopePresent' => $isOpenIdScopePresent], + ); + + try { + if (! $isOpenIdScopePresent) { + throw OidcServerException::invalidRequest( + 'scope', + 'Scope openid is required', + null, + $redirectUri, + $state, + $useFragmentInHttpErrorResponses, + ); + } + } catch (\Throwable $e) { + if ($this->requestParamsResolver->isVciAuthorizationCodeRequest($request, $allowedServerRequestMethods)) { + $loggerService->info('RequiredOpenIdScopeRule: Skippping openid scope check for VCI request.'); + } else { + $loggerService->error('RequiredOpenIdScopeRule: Scope openid is required.'); + throw $e; + } } return new Result($this->getKey(), true); diff --git a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php index ae67f588..ee4188b2 100644 --- a/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php +++ b/src/Server/RequestRules/Rules/ScopeOfflineAccessRule.php @@ -26,12 +26,14 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ScopeOfflineAccessRule::checkRule'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ - $client = $currentResultBag->getOrFail(ClientIdRule::class)->getValue(); + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] $validScopes */ $validScopes = $currentResultBag->getOrFail(ScopeRule::class)->getValue(); diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index e1eb7884..bc6b753c 100644 --- a/src/Server/RequestRules/Rules/ScopeRule.php +++ b/src/Server/RequestRules/Rules/ScopeRule.php @@ -39,8 +39,10 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('ScopeRule::checkRule.'); + /** @var string $redirectUri */ - $redirectUri = $currentResultBag->getOrFail(RedirectUriRule::class)->getValue(); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); /** @var string|null $state */ $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); /** @var string $defaultScope */ @@ -48,12 +50,16 @@ public function checkRule( /** @var non-empty-string $scopeDelimiterString */ $scopeDelimiterString = $data['scope_delimiter_string'] ?? ' '; + $loggerService->debug('ScopeRule: defaultScope: ' . ($defaultScope ? $defaultScope : 'N/A')); + ; + $scopeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::Scope->value, $request, $allowedServerRequestMethods, ) ?? $defaultScope; + $loggerService->debug('ScopeRule: scopeParam: ' . $scopeParam); $scopes = $this->helpers->str()->convertScopesStringToArray($scopeParam, $scopeDelimiterString); $validScopes = []; @@ -62,9 +68,10 @@ public function checkRule( $scope = $this->scopeRepository->getScopeEntityByIdentifier($scopeItem); if ($scope instanceof ScopeEntityInterface === false) { + $loggerService->error('ScopeRule: Invalid scope: ' . $scopeItem); throw OidcServerException::invalidScope($scopeItem, $redirectUri, $state); } - + $loggerService->debug('ScopeRule: Valid scope: ' . $scopeItem); $validScopes[] = $scope; } diff --git a/src/Server/RequestRules/Rules/StateRule.php b/src/Server/RequestRules/Rules/StateRule.php index a8ba6d3f..d60d31d7 100644 --- a/src/Server/RequestRules/Rules/StateRule.php +++ b/src/Server/RequestRules/Rules/StateRule.php @@ -25,6 +25,8 @@ public function checkRule( bool $useFragmentInHttpErrorResponses = false, array $allowedServerRequestMethods = [HttpMethodsEnum::GET], ): ?ResultInterface { + $loggerService->debug('StateRule::checkRule'); + $state = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::State->value, $request, diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index c4c664e7..1278e9f9 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestTypes; use League\OAuth2\Server\RequestTypes\AuthorizationRequest as OAuth2AuthorizationRequest; +use SimpleSAML\Module\oidc\Codebooks\FlowTypeEnum; class AuthorizationRequest extends OAuth2AuthorizationRequest { @@ -44,6 +45,31 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest */ protected ?string $sessionId = null; + /** + * Indicates if the request is related to Verifiable Credential Issuance (VCI request). + * + * @var bool + */ + protected bool $isVciRequest = false; + + protected ?FlowTypeEnum $flowType = null; + + /** + * @var mixed[]|null + */ + protected ?array $authorizationDetails = null; + + protected ?string $boundClientId = null; + + protected ?string $boundRedirectUri = null; + + /** + * Verifiable Credential Issuer state. + * + * @var string|null + */ + protected ?string $issuerState = null; + public static function fromOAuth2AuthorizationRequest( OAuth2AuthorizationRequest $oAuth2authorizationRequest, ): AuthorizationRequest { @@ -204,4 +230,64 @@ public function setSessionId(?string $sessionId): void { $this->sessionId = $sessionId; } + + public function isVciRequest(): bool + { + return $this->isVciRequest; + } + + public function setIsVciRequest(bool $isVciRequest): void + { + $this->isVciRequest = $isVciRequest; + } + + public function getIssuerState(): ?string + { + return $this->issuerState; + } + + public function setIssuerState(?string $issuerState): void + { + $this->issuerState = $issuerState; + } + + public function getFlowType(): ?FlowTypeEnum + { + return $this->flowType; + } + + public function setFlowType(?FlowTypeEnum $flowType): void + { + $this->flowType = $flowType; + } + + public function getAuthorizationDetails(): ?array + { + return $this->authorizationDetails; + } + + public function setAuthorizationDetails(?array $authorizationDetails): void + { + $this->authorizationDetails = $authorizationDetails; + } + + public function getBoundClientId(): ?string + { + return $this->boundClientId; + } + + public function setBoundClientId(?string $boundClientId): void + { + $this->boundClientId = $boundClientId; + } + + public function getBoundRedirectUri(): ?string + { + return $this->boundRedirectUri; + } + + public function setBoundRedirectUri(?string $boundRedirectUri): void + { + $this->boundRedirectUri = $boundRedirectUri; + } } diff --git a/src/Server/RequestTypes/LogoutRequest.php b/src/Server/RequestTypes/LogoutRequest.php index ab825bd1..140a5861 100644 --- a/src/Server/RequestTypes/LogoutRequest.php +++ b/src/Server/RequestTypes/LogoutRequest.php @@ -4,7 +4,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestTypes; -use Lcobucci\JWT\UnencryptedToken; +use SimpleSAML\OpenID\Core\IdToken; class LogoutRequest { @@ -14,7 +14,7 @@ public function __construct( * current authenticated session with the Client. This is used as an indication of the identity of the * End-User that the RP is requesting be logged out by the OP. */ - protected ?UnencryptedToken $idTokenHint = null, + protected ?IdToken $idTokenHint = null, /** * URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been * performed. The value MUST have been previously registered with the OP.An id_token_hint is also @@ -35,12 +35,12 @@ public function __construct( ) { } - public function getIdTokenHint(): ?UnencryptedToken + public function getIdTokenHint(): ?IdToken { return $this->idTokenHint; } - public function setIdTokenHint(?UnencryptedToken $idTokenHint): LogoutRequest + public function setIdTokenHint(?IdToken $idTokenHint): LogoutRequest { $this->idTokenHint = $idTokenHint; return $this; diff --git a/src/Server/ResourceServer.php b/src/Server/ResourceServer.php new file mode 100644 index 00000000..e1b18d44 --- /dev/null +++ b/src/Server/ResourceServer.php @@ -0,0 +1,24 @@ +bearerTokenValidator->validateAuthorization($request); + } +} diff --git a/src/Server/ResponseTypes/IdTokenResponse.php b/src/Server/ResponseTypes/TokenResponse.php similarity index 64% rename from src/Server/ResponseTypes/IdTokenResponse.php rename to src/Server/ResponseTypes/TokenResponse.php index 7c212e92..19ecdace 100644 --- a/src/Server/ResponseTypes/IdTokenResponse.php +++ b/src/Server/ResponseTypes/TokenResponse.php @@ -28,6 +28,7 @@ use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\NonceResponseTypeInterface; use SimpleSAML\Module\oidc\Server\ResponseTypes\Interfaces\SessionIdResponseTypeInterface; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Services\LoggerService; /** * Class IdTokenResponse. @@ -37,7 +38,7 @@ * * @see https://github.com/steverhoades/oauth2-openid-connect-server/blob/master/src/IdTokenResponse.php */ -class IdTokenResponse extends BearerTokenResponse implements +class TokenResponse extends BearerTokenResponse implements // phpcs:ignore NonceResponseTypeInterface, // phpcs:ignore @@ -71,6 +72,7 @@ public function __construct( private readonly IdentityProviderInterface $identityProvider, protected IdTokenBuilder $idTokenBuilder, CryptKey $privateKey, + protected LoggerService $loggerService, ) { $this->privateKey = $privateKey; } @@ -82,14 +84,36 @@ public function __construct( */ protected function getExtraParams(AccessTokenEntityInterface $accessToken): array { - if (false === $this->isOpenIDRequest($accessToken->getScopes())) { - return []; - } - if ($accessToken instanceof AccessTokenEntity === false) { throw new RuntimeException('AccessToken must be ' . AccessTokenEntity::class); } + $extraParams = []; + + if ($this->isOpenIDRequest($accessToken->getScopes())) { + $extraParams = [ + ...$extraParams, + ...$this->prepareIdTokenExtraParam($accessToken), + ]; + } + + // For VCI, in token response for authorization code flow we need to return authorization details. + if ( + ($flowType = $accessToken->getFlowTypeEnum()) !== null && + $flowType->isVciFlow() && + $accessToken->getAuthorizationDetails() !== null + ) { + $extraParams = [ + ...$extraParams, + ...$this->prepareVciAuthorizationDetailsExtraParam($accessToken), + ]; + } + + return array_filter($extraParams); + } + + protected function prepareIdTokenExtraParam(AccessTokenEntity $accessToken): array + { $userIdentifier = $accessToken->getUserIdentifier(); if (empty($userIdentifier)) { @@ -102,7 +126,8 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra throw OidcServerException::accessDenied('No user available for provided user identifier.'); } - $token = $this->idTokenBuilder->build( + //$token = $this->idTokenBuilder->build( + $token = $this->idTokenBuilder->buildFor( $userEntity, $accessToken, false, @@ -114,10 +139,46 @@ protected function getExtraParams(AccessTokenEntityInterface $accessToken): arra ); return [ - 'id_token' => $token->toString(), + 'id_token' => $token->getToken(), ]; } + protected function prepareVciAuthorizationDetailsExtraParam(AccessTokenEntity $accessToken): array + { + $normalizedAuthorizationDetails = []; + + $this->loggerService->debug( + 'TokenResponse::prepareAuthorizationDetailsExtraParam', + ['accessTokenAuthorizationDetails' => $accessToken->getAuthorizationDetails()], + ); + + if (($accessTokenAuthorizationDetails = $accessToken->getAuthorizationDetails()) === null) { + return $normalizedAuthorizationDetails; + } + + /** @psalm-suppress MixedAssignment */ + foreach ($accessTokenAuthorizationDetails as $authorizationDetail) { + if ( + (isset($authorizationDetail['type'])) && + ($authorizationDetail['type']) === 'openid_credential' + ) { + /** @psalm-suppress MixedAssignment */ + $credentialConfigurationId = $authorizationDetail['credential_configuration_id'] ?? null; + if ($credentialConfigurationId !== null) { + $authorizationDetail['credential_identifiers'] = [$credentialConfigurationId]; + } + $normalizedAuthorizationDetails[] = $authorizationDetail; + } + } + + $this->loggerService->debug( + 'TokenResponse::prepareAuthorizationDetailsExtraParam. Summarized authorization details: ', + ['authorizationDetails' => $normalizedAuthorizationDetails], + ); + + return ['authorization_details' => $normalizedAuthorizationDetails]; + } + /** * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * diff --git a/src/Server/Validators/BearerTokenValidator.php b/src/Server/Validators/BearerTokenValidator.php index 3bcec36d..852cd02a 100644 --- a/src/Server/Validators/BearerTokenValidator.php +++ b/src/Server/Validators/BearerTokenValidator.php @@ -4,92 +4,37 @@ namespace SimpleSAML\Module\oidc\Server\Validators; -use DateInterval; -use DateTimeZone; -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; -use League\OAuth2\Server\AuthorizationValidators\BearerTokenValidator as OAuth2BearerTokenValidator; -use League\OAuth2\Server\CryptKey; -use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; -use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface as OAuth2AccessTokenRepositoryInterface; +use League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface; use Psr\Http\Message\ServerRequestInterface; +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\OpenID\Exceptions\JwsException; +use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jws; use function apache_request_headers; use function count; -use function date_default_timezone_get; use function is_array; use function preg_replace; use function trim; -class BearerTokenValidator extends OAuth2BearerTokenValidator +class BearerTokenValidator implements AuthorizationValidatorInterface { - /** @var \Lcobucci\JWT\Configuration */ - protected Configuration $jwtConfiguration; - - /** @var \League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface */ - protected OAuth2AccessTokenRepositoryInterface $accessTokenRepository; - - /** @var \League\OAuth2\Server\CryptKey */ - protected $publicKey; - - /** - * @throws \Exception - */ public function __construct( - AccessTokenRepositoryInterface $accessTokenRepository, - CryptKey $publicKey, - ?DateInterval $jwtValidAtDateLeeway = null, - protected LoggerService $loggerService = new LoggerService(), + protected readonly AccessTokenRepository $accessTokenRepository, + protected readonly ModuleConfig $moduleConfig, + protected readonly Jws $jws, + protected readonly Jwks $jwks, + protected readonly LoggerService $loggerService, ) { - parent::__construct($accessTokenRepository, $jwtValidAtDateLeeway); - $this->accessTokenRepository = $accessTokenRepository; - $this->setPublicKey($publicKey); - } - - /** - * Set the public key - * - * @param \League\OAuth2\Server\CryptKey $key - * @throws \Exception - */ - public function setPublicKey(CryptKey $key): void - { - $this->publicKey = $key; - - $this->initJwtConfiguration(); - } - - /** - * Initialise the JWT configuration. - * @throws \Exception - */ - protected function initJwtConfiguration(): void - { - $this->jwtConfiguration = Configuration::forSymmetricSigner( - new Sha256(), - InMemory::plainText('empty', 'empty'), - ); - - /** @psalm-suppress DeprecatedMethod, ArgumentTypeCoercion */ - $this->jwtConfiguration->setValidationConstraints( - new StrictValidAt(new SystemClock(new DateTimeZone(date_default_timezone_get()))), - new SignedWith( - new Sha256(), - InMemory::plainText($this->publicKey->getKeyContents(), $this->publicKey->getPassPhrase() ?? ''), - ), - ); } /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function validateAuthorization(ServerRequestInterface $request): ServerRequestInterface { @@ -121,7 +66,7 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe $this->loggerService->warning( 'Apache stripping of Authorization Bearer request header encountered. You should modify your' . ' Apache configuration to preserve to Authorization Bearer token in requests to avoid performance ' . - 'implications. Check the OIDC module README file on how to do that.', + 'implications. Check the OIDC module documentation on how to do that.', ); $jwt = $accessToken; } @@ -131,38 +76,59 @@ public function validateAuthorization(ServerRequestInterface $request): ServerRe } try { - // Attempt to parse the JWT - /** @var \Lcobucci\JWT\Token\Plain $token */ - $token = $this->jwtConfiguration->parser()->parse($jwt); - } catch (\Lcobucci\JWT\Exception $exception) { + $token = $this->ensureValidAccessToken($jwt); + } catch (\Throwable $exception) { throw OidcServerException::accessDenied($exception->getMessage(), null, $exception); } - try { - // Attempt to validate the JWT - $constraints = $this->jwtConfiguration->validationConstraints(); - $this->jwtConfiguration->validator()->assert($token, ...$constraints); - } catch (RequiredConstraintsViolated) { - throw OidcServerException::accessDenied('Access token could not be verified'); + if (is_null($jti = $token->getJwtId()) || empty($jti)) { + throw OidcServerException::accessDenied('Access token malformed (jti missing or unexpected type)'); } - $claims = $token->claims(); + // Return the request with additional attributes + return $request + ->withAttribute('oauth_access_token_id', $jti) + ->withAttribute('oauth_client_id', $this->convertSingleRecordAudToString($token->getAudience())) + ->withAttribute('oauth_user_id', $token->getSubject()) + ->withAttribute('oauth_scopes', $token->getPayloadClaim('scopes')); + } - if (is_null($jti = $claims->get('jti')) || empty($jti) || !is_string($jti)) { - throw OidcServerException::accessDenied('Access token malformed (jti missing or unexpected type)'); + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function ensureValidAccessToken(string $accessTokenJwt): Jws\ParsedJws + { + // Attempt to parse the JWT + $token = $this->jws->parsedJwsFactory()->fromToken($accessTokenJwt); + + // Attempt to validate the JWT + $jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators( + ...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(), + )->jsonSerialize(); + $token->verifyWithKeySet($jwks); + + $token->getExpirationTime(); + + if (is_null($iss = $token->getIssuer()) || empty($iss)) { + throw new JwsException('Access token malformed (iss missing or unexpected type)'); } - // Check if token has been revoked + if ($iss !== $this->moduleConfig->getIssuer()) { + throw new JwsException('Access token malformed (iss does not match)'); + } + + if (is_null($jti = $token->getJwtId()) || empty($jti)) { + throw new JwsException('Access token malformed (jti missing or unexpected type)'); + } + + // Check if the token has been revoked if ($this->accessTokenRepository->isAccessTokenRevoked($jti)) { - throw OidcServerException::accessDenied('Access token has been revoked'); + throw new JwsException('Access token has been revoked'); } - // Return the request with additional attributes - return $request - ->withAttribute('oauth_access_token_id', $jti) - ->withAttribute('oauth_client_id', $this->convertSingleRecordAudToString($claims->get('aud'))) - ->withAttribute('oauth_user_id', $claims->get('sub')) - ->withAttribute('oauth_scopes', $claims->get('scopes')); + return $token; } protected function getTokenFromAuthorizationBearer(string $authorizationHeader): string @@ -178,7 +144,7 @@ protected function getTokenFromAuthorizationBearer(string $authorizationHeader): * @return array|string * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ - protected function convertSingleRecordAudToString(mixed $aud): array|string + public function convertSingleRecordAudToString(mixed $aud): array|string { if (is_string($aud)) { return $aud; @@ -192,6 +158,6 @@ protected function convertSingleRecordAudToString(mixed $aud): array|string } } - throw OidcServerException::accessDenied('Unexpected sub claim value.'); + throw OidcServerException::accessDenied('Unexpected aud claim value.'); } } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php new file mode 100644 index 00000000..5e33f670 --- /dev/null +++ b/src/Services/Api/Authorization.php @@ -0,0 +1,110 @@ +sspBridge->utils()->auth()->requireAdmin(); + } catch (\Throwable $exception) { + throw new AuthorizationException( + Translate::noop('Unable to initiate admin authentication.'), + previous: $exception, + ); + } + } + + if (! $this->sspBridge->utils()->auth()->isAdmin()) { + throw new AuthorizationException(Translate::noop('SimpleSAMLphp Admin access required.')); + } + } + + /** + * @param \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum[] $requiredScopes + * + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + */ + public function requireTokenForAnyOfScope(Request $request, array $requiredScopes): void + { + try { + $this->requireSimpleSAMLphpAdmin(); + return; + } catch (Throwable) { + // Not admin, check for token. + } + + if (empty($token = $this->findToken($request))) { + throw new AuthorizationException(Translate::noop('Authorization token not provided.')); + } + + if (empty($tokenScopes = $this->moduleConfig->getApiTokenScopes($token))) { + throw new AuthorizationException(Translate::noop('Authorization 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('Authorization token is not authorized for this action.')); + } + } + + protected function findToken(Request $request): ?string + { + if ( + is_string($authorizationHeader = $request->headers->get(self::KEY_AUTHORIZATION)) + && str_starts_with($authorizationHeader, 'Bearer ') + ) { + return trim( + (string) preg_replace( + '/^\s*Bearer\s/', + '', + (string)$request->headers->get(self::KEY_AUTHORIZATION), + ), + ); + } + + // Fallback to token parameter. + $token = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + self::KEY_TOKEN, + $request, + [ + HttpMethodsEnum::GET, + HttpMethodsEnum::POST, + ], + ); + if ($token = trim((string) $token)) { + return $token; + } + + return null; + } +} diff --git a/src/Services/AuthContextService.php b/src/Services/AuthContextService.php index 1ca2dfd2..dd482a3f 100644 --- a/src/Services/AuthContextService.php +++ b/src/Services/AuthContextService.php @@ -81,10 +81,16 @@ public function requirePermission(string $neededPermission): void /** * @throws \Exception */ - private function authenticate(): Simple + public function authenticate(): Simple { $simple = $this->authSimpleFactory->getDefaultAuthSource(); $simple->requireAuth(); return $simple; } + + public function logout(): void + { + $simple = $this->authSimpleFactory->getDefaultAuthSource(); + $simple->logout(); + } } diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php index 4dbba7d9..39392b90 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 @@ -301,6 +312,7 @@ protected function addRelyingPartyAssociation(ClientEntityInterface $oidcClient, (string)($claims['sub'] ?? $user->getIdentifier()), $this->getSessionId(), $oidcClient->getBackChannelLogoutUri(), + $oidcClient->getIdTokenSignedResponseAlg(), ), ); } diff --git a/src/Services/Container.php b/src/Services/Container.php index c09acd24..f0a0bbef 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -20,7 +20,6 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\StreamFactory; use Laminas\Diactoros\UploadedFileFactory; -use League\OAuth2\Server\ResourceServer; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseFactoryInterface; @@ -44,6 +43,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\IssuerStateEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\RefreshTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ScopeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; @@ -51,12 +51,13 @@ use SimpleSAML\Module\oidc\Factories\FormFactory; use SimpleSAML\Module\oidc\Factories\Grant\AuthCodeGrantFactory; use SimpleSAML\Module\oidc\Factories\Grant\ImplicitGrantFactory; +use SimpleSAML\Module\oidc\Factories\Grant\PreAuthCodeGrantFactory; use SimpleSAML\Module\oidc\Factories\Grant\RefreshTokenGrantFactory; -use SimpleSAML\Module\oidc\Factories\IdTokenResponseFactory; use SimpleSAML\Module\oidc\Factories\JwksFactory; +use SimpleSAML\Module\oidc\Factories\JwsFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; -use SimpleSAML\Module\oidc\Factories\ResourceServerFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; +use SimpleSAML\Module\oidc\Factories\TokenResponseFactory; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; @@ -65,18 +66,21 @@ use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\ImplicitGrant; +use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientAuthenticationRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule; @@ -84,7 +88,6 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; @@ -94,11 +97,14 @@ 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\ResourceServer; +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\Services\NonceService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder; use SimpleSAML\Module\oidc\Utils\FederationCache; @@ -110,6 +116,7 @@ use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jws; use SimpleSAML\Session; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -161,9 +168,6 @@ public function __construct() ); $this->services[FormFactory::class] = $formFactory; - $jsonWebKeySetService = new JsonWebKeySetService($moduleConfig); - $this->services[JsonWebKeySetService::class] = $jsonWebKeySetService; - $sessionService = new SessionService($session); $this->services[SessionService::class] = $sessionService; @@ -225,14 +229,37 @@ public function __construct() $federation = $federationFactory->build(); $this->services[Federation::class] = $federation; - $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation); + $httpFoundationFactory = new HttpFoundationFactory(); + $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; + + $serverRequestFactory = new ServerRequestFactory(); + $this->services[ServerRequestFactoryInterface::class] = $serverRequestFactory; + + $responseFactory = new ResponseFactory(); + $this->services[ResponseFactoryInterface::class] = $responseFactory; + + $streamFactory = new StreamFactory(); + $this->services[StreamFactoryInterface::class] = $streamFactory; + + $uploadedFileFactory = new UploadedFileFactory(); + $this->services[UploadedFileFactoryInterface::class] = $uploadedFileFactory; + + $psrHttpBridge = new PsrHttpBridge( + $httpFoundationFactory, + $serverRequestFactory, + $responseFactory, + $streamFactory, + $uploadedFileFactory, + ); + $this->services[PsrHttpBridge::class] = $psrHttpBridge; + + $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation, $psrHttpBridge); $this->services[RequestParamsResolver::class] = $requestParamsResolver; $clientEntityFactory = new ClientEntityFactory( $sspBridge, $helpers, $moduleConfig, - $requestParamsResolver, ); $this->services[ClientEntityFactory::class] = $clientEntityFactory; @@ -280,17 +307,20 @@ public function __construct() $cryptKeyFactory = new CryptKeyFactory($moduleConfig); - $publicKey = $cryptKeyFactory->buildPublicKey(); $privateKey = $cryptKeyFactory->buildPrivateKey(); - $jsonWebTokenBuilderService = new JsonWebTokenBuilderService($moduleConfig); - $this->services[JsonWebTokenBuilderService::class] = $jsonWebTokenBuilderService; + $jwsFactory = new JwsFactory($moduleConfig, $loggerService); + $this->services[JwsFactory::class] = $jwsFactory; + + $jws = $jwsFactory->build(); + $this->services[Jws::class] = $jws; + $accessTokenEntityFactory = new AccessTokenEntityFactory( $helpers, - $privateKey, - $jsonWebTokenBuilderService, $scopeEntityFactory, + $jws, + $moduleConfig, ); $this->services[AccessTokenEntityFactory::class] = $accessTokenEntityFactory; @@ -327,6 +357,21 @@ public function __construct() ); $this->services[AllowedOriginRepository::class] = $allowedOriginRepository; + $issuerStateEntityFactory = new IssuerStateEntityFactory( + $moduleConfig, + $helpers, + ); + $this->services[IssuerStateEntityFactory::class] = $issuerStateEntityFactory; + + $issuerStateRepository = new IssuerStateRepository( + $moduleConfig, + $database, + $protocolCache, + $issuerStateEntityFactory, + $helpers, + ); + $this->services[IssuerStateRepository::class] = $issuerStateRepository; + $databaseMigration = new DatabaseMigration($database); $this->services[DatabaseMigration::class] = $databaseMigration; @@ -340,7 +385,6 @@ public function __construct() $moduleConfig, $processingChainFactory, $stateService, - $helpers, $requestParamsResolver, $userEntityFactory, ); @@ -364,9 +408,21 @@ public function __construct() ); $this->services[FederationParticipationValidator::class] = $federationParticipationValidator; + $authenticatedOAuth2ClientResolver = new AuthenticatedOAuth2ClientResolver( + clientRepository: $clientRepository, + requestParamsResolver: $requestParamsResolver, + loggerService: $loggerService, + psrHttpBridge: $psrHttpBridge, + jwksResolver: $jwksResolver, + moduleConfig: $moduleConfig, + helpers: $helpers, + protocolCache: $protocolCache, + ); + $this->services[AuthenticatedOAuth2ClientResolver::class] = $authenticatedOAuth2ClientResolver; + $requestRules = [ new StateRule($requestParamsResolver, $helpers), - new ClientIdRule( + new ClientRule( $requestParamsResolver, $helpers, $clientRepository, @@ -375,9 +431,10 @@ public function __construct() $federation, $jwksResolver, $federationParticipationValidator, + $loggerService, $federationCache, ), - new RedirectUriRule($requestParamsResolver, $helpers), + new ClientRedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver), new PromptRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), new MaxAgeRule($requestParamsResolver, $helpers, $authSimpleFactory, $authenticationService, $sspBridge), @@ -389,7 +446,13 @@ public function __construct() new AddClaimsToIdTokenRule($requestParamsResolver, $helpers), new RequiredNonceRule($requestParamsResolver, $helpers), new ResponseTypeRule($requestParamsResolver, $helpers), - new IdTokenHintRule($requestParamsResolver, $helpers, $moduleConfig, $cryptKeyFactory), + new IdTokenHintRule( + $requestParamsResolver, + $helpers, + $moduleConfig, + $jwks, + $core, + ), new PostLogoutRedirectUriRule($requestParamsResolver, $helpers, $clientRepository), new UiLocalesRule($requestParamsResolver, $helpers), new AcrValuesRule($requestParamsResolver, $helpers), @@ -397,19 +460,21 @@ public function __construct() new ClientAuthenticationRule( $requestParamsResolver, $helpers, - $moduleConfig, - $jwksResolver, - $protocolCache, + $authenticatedOAuth2ClientResolver, ), new CodeVerifierRule($requestParamsResolver, $helpers), ]; $requestRuleManager = new RequestRulesManager($requestRules, $loggerService); $this->services[RequestRulesManager::class] = $requestRuleManager; - $idTokenBuilder = new IdTokenBuilder($jsonWebTokenBuilderService, $claimTranslatorExtractor); + $idTokenBuilder = new IdTokenBuilder( + $claimTranslatorExtractor, + $core, + $moduleConfig, + ); $this->services[IdTokenBuilder::class] = $idTokenBuilder; - $logoutTokenBuilder = new LogoutTokenBuilder($jsonWebTokenBuilderService); + $logoutTokenBuilder = new LogoutTokenBuilder($moduleConfig, $loggerService); $this->services[LogoutTokenBuilder::class] = $logoutTokenBuilder; $sessionLogoutTicketStoreDb = new LogoutTicketStoreDb($database); @@ -418,13 +483,14 @@ public function __construct() $sessionLogoutTicketStoreBuilder = new LogoutTicketStoreBuilder($sessionLogoutTicketStoreDb); $this->services[LogoutTicketStoreBuilder::class] = $sessionLogoutTicketStoreBuilder; - $idTokenResponseFactory = new IdTokenResponseFactory( + $tokenResponseFactory = new TokenResponseFactory( $moduleConfig, $userRepository, $this->services[IdTokenBuilder::class], $privateKey, + $loggerService, ); - $this->services[IdTokenResponse::class] = $idTokenResponseFactory->build(); + $this->services[TokenResponse::class] = $tokenResponseFactory->build(); $this->services[Helpers::class] = $helpers; @@ -447,6 +513,7 @@ public function __construct() $authCodeEntityFactory, $refreshTokenIssuer, $helpers, + $loggerService, ); $this->services[AuthCodeGrant::class] = $authCodeGrantFactory->build(); @@ -468,6 +535,21 @@ public function __construct() ); $this->services[RefreshTokenGrant::class] = $refreshTokenGrantFactory->build(); + $preAuthCodeGrantFactory = new PreAuthCodeGrantFactory( + $moduleConfig, + $authCodeRepository, + $accessTokenRepository, + $refreshTokenRepository, + $requestRuleManager, + $requestParamsResolver, + $accessTokenEntityFactory, + $authCodeEntityFactory, + $refreshTokenIssuer, + $helpers, + $loggerService, + ); + $this->services[PreAuthCodeGrant::class] = $preAuthCodeGrantFactory->build(); + $authorizationServerFactory = new AuthorizationServerFactory( $moduleConfig, $clientRepository, @@ -476,48 +558,34 @@ public function __construct() $this->services[AuthCodeGrant::class], $this->services[ImplicitGrant::class], $this->services[RefreshTokenGrant::class], - $this->services[IdTokenResponse::class], + $this->services[TokenResponse::class], $requestRuleManager, $privateKey, + $this->services[PreAuthCodeGrant::class], + $loggerService, ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); - $bearerTokenValidator = new BearerTokenValidator($accessTokenRepository, $publicKey); + $bearerTokenValidator = new BearerTokenValidator( + $accessTokenRepository, + $moduleConfig, + $jws, + $jwks, + $loggerService, + ); $this->services[BearerTokenValidator::class] = $bearerTokenValidator; - $resourceServerFactory = new ResourceServerFactory( - $accessTokenRepository, - $publicKey, + $resourceServer = new ResourceServer( $bearerTokenValidator, ); - $this->services[ResourceServer::class] = $resourceServerFactory->build(); + $this->services[ResourceServer::class] = $resourceServer; - $httpFoundationFactory = new HttpFoundationFactory(); - $this->services[HttpFoundationFactory::class] = $httpFoundationFactory; - - $serverRequestFactory = new ServerRequestFactory(); - $this->services[ServerRequestFactoryInterface::class] = $serverRequestFactory; - - $responseFactory = new ResponseFactory(); - $this->services[ResponseFactoryInterface::class] = $responseFactory; - - $streamFactory = new StreamFactory(); - $this->services[StreamFactoryInterface::class] = $streamFactory; - - $uploadedFileFactory = new UploadedFileFactory(); - $this->services[UploadedFileFactoryInterface::class] = $uploadedFileFactory; - - $psrHttpBridge = new PsrHttpBridge( - $httpFoundationFactory, - $serverRequestFactory, - $responseFactory, - $streamFactory, - $uploadedFileFactory, - ); - $this->services[PsrHttpBridge::class] = $psrHttpBridge; $errorResponder = new ErrorResponder($psrHttpBridge); $this->services[ErrorResponder::class] = $errorResponder; + + $nonceService = new NonceService($jws, $moduleConfig, $loggerService); + $this->services[NonceService::class] = $nonceService; } /** diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index a4936e88..2eadd226 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; @@ -163,6 +164,61 @@ public function migrate(): void $this->version20240906120000(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20240906120000')"); } + + if (!in_array('20250818163000', $versions, true)) { + $this->version20250818163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250818163000')"); + } + + if (!in_array('20250908163000', $versions, true)) { + $this->version20250908163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250908163000')"); + } + + if (!in_array('20250912163000', $versions, true)) { + $this->version20250912163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250912163000')"); + } + + if (!in_array('20250913163000', $versions, true)) { + $this->version20250913163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250913163000')"); + } + + if (!in_array('20250915163000', $versions, true)) { + $this->version20250915163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250915163000')"); + } + + if (!in_array('20250916163000', $versions, true)) { + $this->version20250916163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250916163000')"); + } + + if (!in_array('20250917163000', $versions, true)) { + $this->version20250917163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20250917163000')"); + } + + if (!in_array('20251021000001', $versions, true)) { + $this->version20251021000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20251021000001')"); + } + + if (!in_array('20251021000002', $versions, true)) { + $this->version20251021000002(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20251021000002')"); + } + + if (!in_array('20260109000001', $versions, true)) { + $this->version20260109000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260109000001')"); + } + + if (!in_array('20260218163000', $versions, true)) { + $this->version20260218163000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260218163000')"); + } } private function versionsTableName(): string @@ -531,6 +587,158 @@ private function version20240906120000(): void ,); } + private function version20250818163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD is_pre_authorized BOOLEAN NOT NULL DEFAULT false; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD tx_code VARCHAR(191) NULL; +EOT + ,); + } + + private function version20250908163000(): void + { + $issuerStateTableName = $this->database->applyPrefix(IssuerStateRepository::TABLE_NAME); + $this->database->write(<<< EOT + CREATE TABLE $issuerStateTableName ( + value CHAR(64) PRIMARY KEY NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_revoked BOOLEAN NOT NULL DEFAULT false + ) +EOT + ,); + } + + private function version20250912163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + DROP COLUMN is_pre_authorized; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD flow_type CHAR(64) NULL; +EOT + ,); + } + + private function version20250913163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD authorization_details TEXT NULL; +EOT + ,); + } + + private function version20250915163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD bound_client_id TEXT NULL; +EOT + ,); + + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + ADD is_generic BOOLEAN NOT NULL DEFAULT false; +EOT + ,); + } + + private function version20250916163000(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD bound_redirect_uri TEXT NULL; +EOT + ,); + } + + private function version20250917163000(): void + { + $accessTokenTableName = $this->database->applyPrefix(AccessTokenRepository::TABLE_NAME); + + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD flow_type CHAR(64) NULL; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD authorization_details TEXT NULL; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD bound_client_id TEXT NULL; +EOT + ,); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD bound_redirect_uri TEXT NULL; +EOT + ,); + } + + private function version20251021000001(): void + { + $authCodeTableName = $this->database->applyPrefix(AuthCodeRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$authCodeTableName} + ADD issuer_state TEXT NULL +EOT + ,); + } + + private function version20251021000002(): void + { + $accessTokenTableName = $this->database->applyPrefix(AccessTokenRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$accessTokenTableName} + ADD issuer_state TEXT NULL +EOT + ,); + } + + private function version20260109000001(): void + { + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + ADD extra_metadata TEXT NULL +EOT + ,); + } + + + private function version20260218163000(): void + { + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + DROP COLUMN is_federated; +EOT + ,); + } + /** * @param string[] $columnNames */ diff --git a/src/Services/IdTokenBuilder.php b/src/Services/IdTokenBuilder.php index ac5f7c7c..87adba25 100644 --- a/src/Services/IdTokenBuilder.php +++ b/src/Services/IdTokenBuilder.php @@ -5,31 +5,33 @@ namespace SimpleSAML\Module\oidc\Services; use Base64Url\Base64Url; -use DateTimeImmutable; -use Lcobucci\JWT\Builder; -use Lcobucci\JWT\Token\RegisteredClaims; -use Lcobucci\JWT\UnencryptedToken; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\UserEntityInterface; use RuntimeException; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClaimSetInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\EntityStringRepresentationInterface; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\IdToken; class IdTokenBuilder { public function __construct( - private readonly JsonWebTokenBuilderService $jsonWebTokenBuilderService, - private readonly ClaimTranslatorExtractor $claimExtractor, + protected readonly ClaimTranslatorExtractor $claimExtractor, + protected readonly Core $core, + protected readonly ModuleConfig $moduleConfig, ) { } /** - * @throws \Exception - * @psalm-suppress ArgumentTypeCoercion + * @psalm-suppress MixedAssignment */ - public function build( + public function buildFor( UserEntityInterface $userEntity, AccessTokenEntity $accessToken, bool $addClaimsFromScopes, @@ -38,42 +40,57 @@ public function build( ?int $authTime, ?string $acr, ?string $sessionId, - ): UnencryptedToken { - if (false === is_a($userEntity, ClaimSetInterface::class)) { + ): IdToken { + if (!is_a($userEntity, ClaimSetInterface::class)) { throw new RuntimeException('UserEntity must implement ClaimSetInterface'); } - // Add required id_token claims - $builder = $this->getBuilder($accessToken, $userEntity); - - if (null !== $nonce) { - $builder = $builder->withClaim('nonce', $nonce); - } - - if (null !== $authTime) { - $builder = $builder->withClaim('auth_time', $authTime); + $client = $accessToken->getClient(); + if (! $client instanceof ClientEntity) { + throw new RuntimeException('Client is expected to be instance of ' . ClientEntity::class); } - if ($addAccessTokenHash) { - $builder = $builder->withClaim( - 'at_hash', - $this->generateAccessTokenHash( - $accessToken, - $this->jsonWebTokenBuilderService->getProtocolSigner()->algorithmId(), - ), - ); - } + $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); - if (null !== $acr) { - $builder = $builder->withClaim('acr', $acr); - } + // ID Token signing algorithm that the client wants. + $clientIdTokenSignedResponseAlg = $client->getIdTokenSignedResponseAlg(); - if (null !== $sessionId) { - $builder = $builder->withClaim('sid', $sessionId); + if (is_string($clientIdTokenSignedResponseAlg)) { + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstByAlgorithmOrFail( + SignatureAlgorithmEnum::from($clientIdTokenSignedResponseAlg), + ); } - // Need a claim factory here to reduce the number of claims by provided scope. - $claims = $this->claimExtractor->extract($accessToken->getScopes(), $userEntity->getClaims()); + $currentTimestamp = $this->core->helpers()->dateTime()->getUtc()->getTimestamp(); + + $payload = array_filter([ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Jti->value => $this->core->helpers()->random()->string(), + ClaimsEnum::Aud->value => $client->getIdentifier(), + ClaimsEnum::Nbf->value => $currentTimestamp, + ClaimsEnum::Exp->value => $accessToken->getExpiryDateTime()->getTimestamp(), + ClaimsEnum::Sub->value => $this->core->helpers()->type()->ensureNonEmptyString( + $userEntity->getIdentifier(), + ), + ClaimsEnum::Nonce->value => $nonce, + ClaimsEnum::AuthTime->value => $authTime, + ClaimsEnum::ATHash->value => $addAccessTokenHash ? + $this->generateAccessTokenHash( + $accessToken, + $protocolSignatureKeyPair->getSignatureAlgorithm()->value, + ) : + null, + ClaimsEnum::Acr->value => $acr, + ClaimsEnum::Sid->value => $sessionId, + ]); + + // Reduce the number of claims by provided scope. + $claims = $this->claimExtractor->extract( + $accessToken->getScopes(), + $userEntity->getClaims(), + ); $requestedClaims = $accessToken->getRequestedClaims(); $additionalClaims = $this->claimExtractor->extractAdditionalIdTokenClaims( $requestedClaims, @@ -81,81 +98,48 @@ public function build( ); $claims = array_merge($additionalClaims, $claims); - /** - * @var string $claimName - * @var mixed $claimValue - */ foreach ($claims as $claimName => $claimValue) { - switch ($claimName) { - case RegisteredClaims::AUDIENCE: - if (is_array($claimValue)) { - /** @psalm-suppress MixedAssignment */ - foreach ($claimValue as $aud) { - $builder = $builder->permittedFor((string)$aud); - } - } else { - $builder = $builder->permittedFor((string)$claimValue); - } - break; - case RegisteredClaims::EXPIRATION_TIME: - /** @noinspection PhpUnnecessaryStringCastInspection */ - $builder = $builder->expiresAt(new DateTimeImmutable('@' . (string)$claimValue)); - break; - case RegisteredClaims::ID: - $builder = $builder->identifiedBy((string)$claimValue); - break; - case RegisteredClaims::ISSUED_AT: - /** @noinspection PhpUnnecessaryStringCastInspection */ - $builder = $builder->issuedAt(new DateTimeImmutable('@' . (string)$claimValue)); - break; - case RegisteredClaims::ISSUER: - $builder = $builder->issuedBy((string)$claimValue); - break; - case RegisteredClaims::NOT_BEFORE: - /** @noinspection PhpUnnecessaryStringCastInspection */ - $builder = $builder->canOnlyBeUsedAfter(new DateTimeImmutable('@' . (string)$claimValue)); - break; - case RegisteredClaims::SUBJECT: - $builder = $builder->relatedTo((string)$claimValue); - break; - default: - if ($addClaimsFromScopes || array_key_exists($claimName, $additionalClaims)) { - $builder = $builder->withClaim($claimName, $claimValue); - } + if ( + is_string($claimName) && + $claimName !== '' && + ($addClaimsFromScopes || array_key_exists($claimName, $additionalClaims)) + ) { + $payload[$claimName] = $claimValue; } } - return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($builder); - } + $header = [ + ClaimsEnum::Kid->value => $protocolSignatureKeyPair->getKeyPair()->getKeyId(), + ]; - /** - * @throws \League\OAuth2\Server\Exception\OAuthServerException - */ - protected function getBuilder( - AccessTokenEntityInterface $accessToken, - UserEntityInterface $userEntity, - ): Builder { - /** @psalm-suppress ArgumentTypeCoercion */ - return $this->jsonWebTokenBuilderService - ->getProtocolJwtBuilder() - ->permittedFor($accessToken->getClient()->getIdentifier()) - ->identifiedBy($accessToken->getIdentifier()) - ->canOnlyBeUsedAfter(new DateTimeImmutable('now')) - ->expiresAt($accessToken->getExpiryDateTime()) - ->relatedTo((string)$userEntity->getIdentifier()); + return $this->core->idTokenFactory()->fromData( + $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(), + $protocolSignatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + ); } /** - * @param string $jwsAlgorithm JWS Algorithm designation (like RS256, RS384...) + * @param string $jwsAlgorithm JWS Algorithm designation (like RS256, + * RS384...). */ - protected function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, string $jwsAlgorithm): string + public function generateAccessTokenHash(AccessTokenEntityInterface $accessToken, string $jwsAlgorithm): string { - $validBitLengths = [256, 384, 512]; + if ($jwsAlgorithm === SignatureAlgorithmEnum::EdDSA->value) { + $hashAlgorithm = 'sha512'; + $hashByteLength = 32; // 256 bits / 8 + } else { + $validBitLengths = [256, 384, 512]; + + $jwsAlgorithmBitLength = (int) substr($jwsAlgorithm, 2); - $jwsAlgorithmBitLength = (int) substr($jwsAlgorithm, 2); + if (!in_array($jwsAlgorithmBitLength, $validBitLengths, true)) { + throw new RuntimeException(sprintf('JWS algorithm not supported (%s)', $jwsAlgorithm)); + } - if (!in_array($jwsAlgorithmBitLength, $validBitLengths, true)) { - throw new RuntimeException(sprintf('JWS algorithm not supported (%s)', $jwsAlgorithm)); + $hashAlgorithm = 'sha' . $jwsAlgorithmBitLength; + $hashByteLength = $jwsAlgorithmBitLength / 2 / 8; } if ($accessToken instanceof EntityStringRepresentationInterface === false) { @@ -163,14 +147,10 @@ protected function generateAccessTokenHash(AccessTokenEntityInterface $accessTok EntityStringRepresentationInterface::class); } - // Try to use toString() so that it uses the string representation if it was already cast to string, - // otherwise, use the cast version. + // Try to use toString() so that it uses the string representation if + // it was already cast to string, otherwise, use the cast version. $accessTokenString = $accessToken->toString() ?? (string) $accessToken; - $hashAlgorithm = 'sha' . $jwsAlgorithmBitLength; - - $hashByteLength = $jwsAlgorithmBitLength / 2 / 8; - return Base64Url::encode( substr( hash( diff --git a/src/Services/JsonWebKeySetService.php b/src/Services/JsonWebKeySetService.php deleted file mode 100644 index f02f9e05..00000000 --- a/src/Services/JsonWebKeySetService.php +++ /dev/null @@ -1,133 +0,0 @@ -prepareProtocolJwkSet(); - - $this->prepareFederationJwkSet(); - } - - /** - * @return \Jose\Component\Core\JWK[] - */ - public function protocolKeys(): array - { - return $this->protocolJwkSet->all(); - } - - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - public function federationKeys(): array - { - if (is_null($this->federationJwkSet)) { - throw OidcServerException::serverError('OpenID Federation public key not set.'); - } - - return $this->federationJwkSet->all(); - } - - /** - * @throws \ReflectionException - * @throws \SimpleSAML\Error\Exception - */ - protected function prepareProtocolJwkSet(): void - { - $protocolPublicKeyPath = $this->moduleConfig->getProtocolCertPath(); - - if (!file_exists($protocolPublicKeyPath)) { - throw new Error\Exception("OIDC protocol public key file does not exists: $protocolPublicKeyPath."); - } - - $jwk = JWKFactory::createFromKeyFile($protocolPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), - ]); - - $keys = [$jwk]; - - if ( - ($protocolNewPublicKeyPath = $this->moduleConfig->getProtocolNewCertPath()) && - file_exists($protocolNewPublicKeyPath) - ) { - $newJwk = JWKFactory::createFromKeyFile($protocolNewPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), - ]); - - $keys[] = $newJwk; - } - - $this->protocolJwkSet = new JWKSet($keys); - } - - protected function prepareFederationJwkSet(): void - { - $federationPublicKeyPath = $this->moduleConfig->getFederationCertPath(); - - if (!file_exists($federationPublicKeyPath)) { - return; - } - - $federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), - ]); - - $keys = [$federationJwk]; - - if ( - ($federationNewPublicKeyPath = $this->moduleConfig->getFederationNewCertPath()) && - file_exists($federationNewPublicKeyPath) - ) { - $federationNewJwk = JWKFactory::createFromKeyFile($federationNewPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationNewPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), - ]); - - $keys[] = $federationNewJwk; - } - - $this->federationJwkSet = new JWKSet($keys); - } -} diff --git a/src/Services/JsonWebTokenBuilderService.php b/src/Services/JsonWebTokenBuilderService.php deleted file mode 100644 index c371a10c..00000000 --- a/src/Services/JsonWebTokenBuilderService.php +++ /dev/null @@ -1,163 +0,0 @@ -protocolJwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfig->getProtocolSigner(), - InMemory::file( - $this->moduleConfig->getProtocolPrivateKeyPath(), - $this->moduleConfig->getProtocolPrivateKeyPassPhrase() ?? '', - ), - InMemory::plainText('empty', 'empty'), - ); - - // According to OpenID Federation specification, we need to use different signing keys for federation related - // functions. Since we won't force OP implementor to enable federation support, this part is optional. - if ($this->moduleConfig->getFederationEnabled()) { - $this->federationJwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfig->getFederationSigner(), - InMemory::file( - $this->moduleConfig->getFederationPrivateKeyPath(), - $this->moduleConfig->getFederationPrivateKeyPassPhrase() ?? '', - ), - InMemory::plainText('empty', 'empty'), - ); - } - } - - /** - * Get JWT Builder which uses OIDC protocol related signing configuration. - * - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - public function getProtocolJwtBuilder(): Builder - { - return $this->getDefaultJwtBuilder($this->protocolJwtConfig); - } - - /** - * Get JWT Builder which uses OpenID Federation related signing configuration. - * - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - public function getFederationJwtBuilder(): Builder - { - if (is_null($this->federationJwtConfig)) { - throw OidcServerException::serverError('Federation JWT PKI configuration is not set.'); - } - - return $this->getDefaultJwtBuilder($this->federationJwtConfig); - } - - /** - * Get default JWT Builder by using the provided configuration, with predefined claims like iss, iat, jti. - * - * @throws OidcServerException - */ - public function getDefaultJwtBuilder(Configuration $configuration): Builder - { - /** @psalm-suppress ArgumentTypeCoercion */ - // Ignore microseconds when handling dates. - return $configuration->builder(ChainedFormatter::withUnixTimestampDates()) - ->issuedBy($this->moduleConfig->getIssuer()) - ->issuedAt(new DateTimeImmutable('now')) - ->identifiedBy($this->helpers->random()->getIdentifier()); - } - - /** - * Get signed JWT using the OIDC protocol JWT signing configuration. - * - * @throws \Exception - */ - public function getSignedProtocolJwt(Builder $builder): UnencryptedToken - { - $headers = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($this->moduleConfig->getProtocolCertPath()), - ]; - - return $this->getSignedJwt($builder, $this->protocolJwtConfig, $headers); - } - - /** - * Get signed JWT using the OpenID Federation JWT signing configuration. - * - * @throws \Exception - */ - public function getSignedFederationJwt(Builder $builder): UnencryptedToken - { - if (is_null($this->federationJwtConfig)) { - throw OidcServerException::serverError('Federation JWT PKI configuration is not set.'); - } - - $headers = [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($this->moduleConfig->getFederationCertPath()), - ]; - - return $this->getSignedJwt($builder, $this->federationJwtConfig, $headers); - } - - /** - * Get signed JWT for provided builder and JWT signing configuration, and optionally with any additional headers to - * include. - */ - public function getSignedJwt( - Builder $builder, - Configuration $jwtConfig, - array $headers = [], - ): UnencryptedToken { - /** - * @var non-empty-string $headerKey - * @psalm-suppress MixedAssignment - */ - foreach ($headers as $headerKey => $headerValue) { - $builder = $builder->withHeader($headerKey, $headerValue); - } - - return $builder->getToken($jwtConfig->signer(), $jwtConfig->signingKey()); - } - - /** - * @throws \ReflectionException - */ - public function getProtocolSigner(): Signer - { - return $this->moduleConfig->getProtocolSigner(); - } -} diff --git a/src/Services/LogoutTokenBuilder.php b/src/Services/LogoutTokenBuilder.php index d561dfc5..230ad882 100644 --- a/src/Services/LogoutTokenBuilder.php +++ b/src/Services/LogoutTokenBuilder.php @@ -4,14 +4,28 @@ namespace SimpleSAML\Module\oidc\Services; +use SimpleSAML\Module\oidc\Factories\CoreFactory; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; +use SimpleSAML\OpenID\Core; use stdClass; class LogoutTokenBuilder { + protected Core $core; + public function __construct( - protected JsonWebTokenBuilderService $jsonWebTokenBuilderService = new JsonWebTokenBuilderService(), + protected ModuleConfig $moduleConfig = new ModuleConfig(), + protected LoggerService $loggerService = new LoggerService(), + ?CoreFactory $coreFactory = null, ) { + $this->core = ($coreFactory ?? new CoreFactory( + $this->moduleConfig, + $this->loggerService, + ))->build(); } /** @@ -21,18 +35,42 @@ public function __construct( */ public function forRelyingPartyAssociation(RelyingPartyAssociationInterface $relyingPartyAssociation): string { - $logoutTokenBuilder = $this->jsonWebTokenBuilderService - ->getProtocolJwtBuilder() - ->withHeader('typ', 'logout+jwt') - ->permittedFor($relyingPartyAssociation->getClientId()) - ->relatedTo($relyingPartyAssociation->getUserId()) - ->withClaim('events', ['http://schemas.openid.net/event/backchannel-logout' => new stdClass()]) - ; - - if ($relyingPartyAssociation->getSessionId() !== null) { - $logoutTokenBuilder = $logoutTokenBuilder->withClaim('sid', $relyingPartyAssociation->getSessionId()); + $protocolSignatureKeyPairBag = $this->moduleConfig->getProtocolSignatureKeyPairBag(); + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstOrFail(); + + // ID Token signing algorithm that the client wants. As per spec, the + // same algorithm should be used for Logout Token. + if (is_string($idTokenSignedResponseAlg = $relyingPartyAssociation->getClientIdTokenSignedResponseAlg())) { + $protocolSignatureKeyPair = $protocolSignatureKeyPairBag->getFirstByAlgorithmOrFail( + SignatureAlgorithmEnum::from($idTokenSignedResponseAlg), + ); } - return $this->jsonWebTokenBuilderService->getSignedProtocolJwt($logoutTokenBuilder)->toString(); + $currentTimestamp = $this->core->helpers()->dateTime()->getUtc()->getTimestamp(); + + $payload = array_filter([ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Exp->value => $this->core->helpers()->dateTime()->getUtc()->add( + $this->moduleConfig->getAuthCodeDuration(), + )->getTimestamp(), + ClaimsEnum::Jti->value => $this->core->helpers()->random()->string(), + ClaimsEnum::Aud->value => $relyingPartyAssociation->getClientId(), + ClaimsEnum::Sub->value => $relyingPartyAssociation->getUserId(), + ClaimsEnum::Events->value => ['http://schemas.openid.net/event/backchannel-logout' => new stdClass()], + ClaimsEnum::Sid->value => $relyingPartyAssociation->getSessionId(), + ]); + + $header = [ + ClaimsEnum::Kid->value => $protocolSignatureKeyPair->getKeyPair()->getKeyId(), + ClaimsEnum::Typ->value => JwtTypesEnum::LogoutJwt->value, + ]; + + return $this->core->logoutTokenFactory()->fromData( + $protocolSignatureKeyPair->getKeyPair()->getPrivateKey(), + $protocolSignatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + )->getToken(); } } diff --git a/src/Services/NonceService.php b/src/Services/NonceService.php new file mode 100644 index 00000000..aa146b44 --- /dev/null +++ b/src/Services/NonceService.php @@ -0,0 +1,80 @@ +moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail(); + $currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp(); + + // Nonce is valid for 5 minutes (300 seconds) + // TODO mivanci Consider making this configurable. + $expiryTimestamp = $currentTimestamp + 300; + + $payload = [ + ClaimsEnum::Iss->value => $this->moduleConfig->getIssuer(), + ClaimsEnum::Iat->value => $currentTimestamp, + ClaimsEnum::Exp->value => $expiryTimestamp, + 'nonce_val' => bin2hex(random_bytes(16)), + ]; + + $header = [ + ClaimsEnum::Kid->value => $signatureKeyPair->getKeyPair()->getKeyId(), + ]; + + return $this->jws->parsedJwsFactory()->fromData( + $signatureKeyPair->getKeyPair()->getPrivateKey(), + $signatureKeyPair->getSignatureAlgorithm(), + $payload, + $header, + )->getToken(); + } + + public function validateNonce(string $nonce): bool + { + try { + $parsedJws = $this->jws->parsedJwsFactory()->fromToken($nonce); + + // Verify signature + $signatureKeyPair = $this->moduleConfig->getVciSignatureKeyPairBag()->getFirstOrFail(); + $parsedJws->verifyWithKey($signatureKeyPair->getKeyPair()->getPublicKey()->jwk()->all()); + + // Verify issuer + if ($parsedJws->getIssuer() !== $this->moduleConfig->getIssuer()) { + $this->loggerService->warning('Nonce validation failed: invalid issuer.'); + return false; + } + + // Verify expiration. This is also done in the JWS factory class. + $currentTimestamp = $this->jws->helpers()->dateTime()->getUtc()->getTimestamp(); + if ($parsedJws->getExpirationTime() < $currentTimestamp) { + $this->loggerService->warning('Nonce validation failed: expired.'); + return false; + } + + $this->loggerService->debug('Nonce validation succeeded.'); + return true; + } catch (\Exception $e) { + $this->loggerService->warning('Nonce validation failed: ' . $e->getMessage()); + return false; + } + } +} diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 921724bb..91537d7f 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; /** @@ -36,7 +37,17 @@ public function __construct( */ private function initMetadata(): void { - $signer = $this->moduleConfig->getProtocolSigner(); + // Signature algorithms that this OP can use to sign JWS artifacts. + $protocolSignatureAlgorithmNames = $this->moduleConfig + ->getProtocolSignatureKeyPairBag() + ->getAllAlgorithmNamesUnique(); + + // Signature algorithms that this OP can use to validate signature on + // signed JWS artifacts. + $supportedSignatureAlgorithmNames = $this->moduleConfig + ->getSupportedAlgorithms() + ->getSignatureAlgorithmBag() + ->getAllNamesUnique(); $this->metadata = []; $this->metadata[ClaimsEnum::Issuer->value] = $this->moduleConfig->getIssuer(); @@ -52,25 +63,31 @@ private function initMetadata(): void $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']; - $this->metadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] = [ - $signer->algorithmId(), - ]; + $this->metadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] = $protocolSignatureAlgorithmNames; $this->metadata[ClaimsEnum::CodeChallengeMethodsSupported->value] = ['plain', 'S256']; $this->metadata[ClaimsEnum::TokenEndpointAuthMethodsSupported->value] = [ TokenEndpointAuthMethodsEnum::ClientSecretPost->value, TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value, ]; - $this->metadata[ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value] = [ - $signer->algorithmId(), - ]; + $this->metadata[ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value] = + $supportedSignatureAlgorithmNames; $this->metadata[ClaimsEnum::RequestParameterSupported->value] = true; $this->metadata[ClaimsEnum::RequestObjectSigningAlgValuesSupported->value] = [ 'none', - $signer->algorithmId(), + ...$supportedSignatureAlgorithmNames, ]; $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->getVciEnabled()) { + $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 +99,10 @@ private function initMetadata(): void $claimsSupported = $this->claimTranslatorExtractor->getSupportedClaims(); $this->metadata[ClaimsEnum::ClaimsSupported->value] = $claimsSupported; } + + // https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-oauth-20-authorization-serv + // OPTIONAL + // pre-authorized_grant_anonymous_access_supported // TODO mivanci Make configurable } /** diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php new file mode 100644 index 00000000..62b174b3 --- /dev/null +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -0,0 +1,417 @@ +forPrivateKeyJwt($request, $preFetchedClient) ?? + $this->forClientSecretBasic($request, $preFetchedClient) ?? + $this->forClientSecretPost($request, $preFetchedClient) ?? + $this->forPublicClient($request, $preFetchedClient); + } catch (\Throwable $exception) { + $this->loggerService->error( + 'Error while trying to resolve authenticated client: ' . + $exception->getMessage(), + ); + return null; + } + } + + /** + * @throws AuthorizationException + */ + public function forPublicClient( + ServerRequestInterface|Request $request, + ?ClientEntityInterface $preFetchedClient, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve public client for request client ID.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + [HttpMethodsEnum::GET, HttpMethodsEnum::POST], + ); + + if (!is_string($clientId) || $clientId === '') { + $this->loggerService->debug( + 'No client ID available in HTTP request, skipping for public client.', + ); + return null; + } + + $this->loggerService->debug('Client ID from HTTP request: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + if ($client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is confidential, aborting for public client.', + ); + throw new AuthorizationException('Client is confidential.'); + } + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::None, + ); + } + + /** + * @throws AuthorizationException + */ + public function forClientSecretBasic( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from basic auth.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $authorizationHeader = $request->getHeader('Authorization')[0] ?? null; + + if (!is_string($authorizationHeader)) { + $this->loggerService->debug( + 'No authorization header available for basic auth, skipping.', + ); + return null; + } + + if (!str_starts_with($authorizationHeader, 'Basic ')) { + $this->loggerService->debug( + 'Authorization header is not in basic auth format, skipping.', + ); + return null; + } + + $decodedAuthorizationHeader = base64_decode(substr($authorizationHeader, 6), true); + + if ($decodedAuthorizationHeader === false) { + $this->loggerService->debug( + 'Authorization header Basic value is invalid, skipping.', + ); + return null; + } + + if (!str_contains($decodedAuthorizationHeader, ':')) { + $this->loggerService->debug( + 'Authorization header Basic value is invalid, skipping.', + ); + return null; + } + + $parts = explode(':', $decodedAuthorizationHeader, 2); + $clientId = $parts[0]; + $clientSecret = $parts[1] ?? ''; + + if ($clientId === '') { + $this->loggerService->debug( + 'No client ID available in basic auth header, skipping.', + ); + return null; + } + + $this->loggerService->debug('Client ID from basic auth: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + // Only do secret validation for confidential clients. Public clients + // should not have a secret provided. + if (!$client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is not confidential, aborting basic auth validation.', + ); + throw new AuthorizationException('Client is not confidential.'); + } + + if ($clientSecret === '') { + $this->loggerService->error('No client secret available in basic auth header.'); + throw new AuthorizationException('No client secret available in basic auth header.'); + } + + $this->loggerService->debug('Client secret provided for basic auth, validating credentials.'); + + $this->validateClientSecret($client, $clientSecret); + + $this->loggerService->debug('Client credentials from basic auth validated.'); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::ClientSecretBasic, + ); + } + + /** + * For client_secret_post authentication method. + * + * @throws AuthorizationException + */ + public function forClientSecretPost( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from HTTP POST body.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $clientId = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + [HttpMethodsEnum::POST], + ); + $clientSecret = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientSecret->value, + $request, + [HttpMethodsEnum::POST], + ); + + if (!is_string($clientId) || $clientId === '') { + $this->loggerService->debug( + 'No client ID available in HTTP POST body, skipping client_secret_post.', + ); + return null; + } + + if (!is_string($clientSecret) || $clientSecret === '') { + $this->loggerService->debug( + 'No client secret available in HTTP POST body, skipping client_secret_post.', + ); + return null; + } + + $this->loggerService->debug('Client ID from HTTP POST body: ' . $clientId); + + $client = $this->resolveClientOrFail($clientId, $preFetchedClient); + + // Only do secret validation for confidential clients. Public clients + // should not have a secret provided. + if (!$client->isConfidential()) { + $this->loggerService->debug( + 'Client with ID ' . $clientId . ' is not confidential, aborting client_secret_post.', + ); + throw new AuthorizationException('Client is not confidential.'); + } + + $this->loggerService->debug('Client secret provided for HTTP POST body, validating credentials.'); + + $this->validateClientSecret($client, $clientSecret); + + $this->loggerService->debug('Client credentials from HTTP POST body validated.'); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\ClientAssertionException + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function forPrivateKeyJwt( + Request|ServerRequestInterface $request, + ?ClientEntityInterface $preFetchedClient = null, + ): ?ResolvedClientAuthenticationMethod { + $this->loggerService->debug('Trying to resolve authenticated client from private key JWT.'); + + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + + $allowedServerRequestMethods = [HttpMethodsEnum::POST]; + + $clientAssertionParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientAssertion->value, + $request, + $allowedServerRequestMethods, + ); + + if (!is_string($clientAssertionParam)) { + $this->loggerService->debug('No client assertion available, skipping.'); + return null; + } + + $this->loggerService->debug('Client assertion param received: ' . $clientAssertionParam); + + // private_key_jwt authentication method is used. + // Check the expected assertion type param. + $clientAssertionType = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientAssertionType->value, + $request, + $allowedServerRequestMethods, + ); + + if ($clientAssertionType !== ClientAssertionTypesEnum::JwtBaerer->value) { + $this->loggerService->debug( + 'Client assertion type is not expected value, skipping.', + ['expected' => ClientAssertionTypesEnum::JwtBaerer->value, 'actual' => $clientAssertionType], + ); + return null; + } + + $clientAssertion = $this->requestParamsResolver->parseClientAssertionToken($clientAssertionParam); + + $client = $this->resolveClientOrFail($clientAssertion->getIssuer(), $preFetchedClient); + + ($jwks = $this->jwksResolver->forClient($client)) || throw new AuthorizationException( + 'Can not validate Client Assertion, client JWKS not available.', + ); + + try { + $clientAssertion->verifyWithKeySet($jwks); + } catch (\Throwable $exception) { + throw new AuthorizationException( + 'Client Assertion validation failed: ' . $exception->getMessage(), + ); + } + + // Check if the Client Assertion token has already been used. Only + // applicable if we have a cache available. + if ($this->protocolCache) { + ($this->protocolCache->has(self::KEY_CLIENT_ASSERTION_JTI, $clientAssertion->getJwtId()) === false) + || throw new AuthorizationException('Client Assertion reused.'); + } + + ($client->getIdentifier() === $clientAssertion->getIssuer()) || throw new AuthorizationException( + 'Invalid Client Assertion Issuer claim.', + ); + + ($client->getIdentifier() === $clientAssertion->getSubject()) || throw new AuthorizationException( + 'Invalid Client Assertion Subject claim.', + ); + + // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. + // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization + // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. + $expectedAudience = [ + $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), + $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), + $this->moduleConfig->getIssuer(), + ]; + + (!empty(array_intersect($expectedAudience, $clientAssertion->getAudience()))) || + throw new AuthorizationException('Invalid Client Assertion Audience claim.'); + + // Everything seems ok. Save it in a cache so we can check for reuse. + $this->protocolCache?->set( + $clientAssertion->getJwtId(), + $this->helpers->dateTime()->getSecondsToExpirationTime($clientAssertion->getExpirationTime()), + self::KEY_CLIENT_ASSERTION_JTI, + $clientAssertion->getJwtId(), + ); + + return new ResolvedClientAuthenticationMethod( + $client, + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + ); + } + + public function findActiveClient(string $clientId): ?ClientEntityInterface + { + $client = $this->clientRepository->findById($clientId); + + if (is_null($client)) { + $this->loggerService->debug('No client with ID ' . $clientId . ' found.'); + return null; + } + + if (!$client->isEnabled()) { + $this->loggerService->warning('Client with ID ' . $clientId . ' is disabled.'); + return null; + } + + if ($client->isExpired()) { + $this->loggerService->warning('Client with ID ' . $clientId . ' is expired.'); + return null; + } + + $this->loggerService->debug('Client with ID ' . $clientId . ' is active, returning its instance.'); + return $client; + } + + /** + * @throws AuthorizationException + */ + protected function resolveClientOrFail( + string $clientId, + ?ClientEntityInterface $preFetchedClient, + ): ClientEntityInterface { + $client = $preFetchedClient ?: $this->findActiveClientOrFail($clientId); + + if ($client->getIdentifier() !== $clientId) { + $this->loggerService->error( + 'Client ID does not match, expected: ' . $clientId . ', actual: ' . $client->getIdentifier(), + ); + throw new AuthorizationException('Client ID does not match.'); + } + + return $client; + } + + /** + * @throws AuthorizationException + */ + public function findActiveClientOrFail(string $clientId): ClientEntityInterface + { + return $this->findActiveClient($clientId) ?? throw new AuthorizationException( + 'Client with ID ' . $clientId . ' is not active (either not found, not enabled, or expired).', + ); + } + + /** + * @throws AuthorizationException + */ + public function validateClientSecret(ClientEntityInterface $client, string $clientSecret): void + { + hash_equals($client->getSecret(), $clientSecret) || throw new AuthorizationException( + 'Client secret is not valid.', + ); + } +} diff --git a/src/Utils/ClaimTranslatorExtractor.php b/src/Utils/ClaimTranslatorExtractor.php index f04760cb..5e1ac974 100644 --- a/src/Utils/ClaimTranslatorExtractor.php +++ b/src/Utils/ClaimTranslatorExtractor.php @@ -51,26 +51,25 @@ class ClaimTranslatorExtractor */ final public const array MANDATORY_SINGLE_VALUE_CLAIMS = [ 'sub', - // TODO mivanci v7 Uncomment the rest of the claims, as this was a potential breaking change in v6. -// 'name', -// 'given_name', -// 'family_name', -// 'middle_name', -// 'nickname', -// 'preferred_username', -// 'profile', -// 'picture', -// 'website', -// 'email', -// 'email_verified', -// 'gender', -// 'birthdate', -// 'zoneinfo', -// 'locale', -// 'phone_number', -// 'phone_number_verified', -// 'address', -// 'updated_at', + 'name', + 'given_name', + 'family_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'email', + 'email_verified', + 'gender', + 'birthdate', + 'zoneinfo', + 'locale', + 'phone_number', + 'phone_number_verified', + 'address', + 'updated_at', ]; diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 11d23565..09176946 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -5,47 +5,57 @@ namespace SimpleSAML\Module\oidc\Utils; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use Symfony\Component\HttpFoundation\Request; /** - * Resolve authorization params from HTTP request (based or not based on used method), and from Request Object param if - * present. + * Resolve authorization params from an HTTP request (based or not based on + * a used method), and from Request Object param if present. */ class RequestParamsResolver { public function __construct( - protected Helpers $helpers, - protected Core $core, - protected Federation $federation, + protected readonly Helpers $helpers, + protected readonly Core $core, + protected readonly Federation $federation, + protected readonly PsrHttpBridge $psrHttpBridge, ) { } /** * Get all HTTP request params (not from Request Object). * - * @param \Psr\Http\Message\ServerRequestInterface $request - * @return array + * @return mixed[] */ - public function getAllFromRequest(ServerRequestInterface $request): array + public function getAllFromRequest(Request|ServerRequestInterface $request): array { + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + return $this->helpers->http()->getAllRequestParams($request); } /** - * Get all HTTP request params based on allowed methods (not from Request Object). + * Get all HTTP request params based on allowed methods (not from + * Request Object). * - * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods - * @return array + * @return mixed[] */ public function getAllFromRequestBasedOnAllowedMethods( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): array { + if ($request instanceof Request) { + $request = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + } + return $this->helpers->http()->getAllRequestParamsBasedOnAllowedMethods( $request, $allowedMethods, @@ -57,7 +67,7 @@ public function getAllFromRequestBasedOnAllowedMethods( * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function getAll(ServerRequestInterface $request): array + public function getAll(Request|ServerRequestInterface $request): array { $requestParams = $this->getAllFromRequest($request); @@ -69,13 +79,14 @@ public function getAll(ServerRequestInterface $request): array /** - * Get all request params based on allowed methods, including those from Request Object if present. + * Get all request params based on allowed methods, including those from + * Request Object if present. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getAllBasedOnAllowedMethods( - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods, ): array { $requestParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); @@ -87,24 +98,25 @@ public function getAllBasedOnAllowedMethods( } /** - * Get param value from HTTP request or Request Object if present. + * Get param value from an HTTP request or Request Object if present. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function get(string $paramKey, ServerRequestInterface $request): mixed + public function get(string $paramKey, Request|ServerRequestInterface $request): mixed { return $this->getAll($request)[$paramKey] ?? null; } /** - * Get param value from HTTP request or Request Object if present, based on allowed methods. + * Get param value from an HTTP request or Request Object if present, + * based on allowed methods. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): mixed { $allParams = $this->getAllBasedOnAllowedMethods($request, $allowedMethods); @@ -112,18 +124,18 @@ public function getBasedOnAllowedMethods( } /** - * Get param value as null or string from HTTP request or Request Object if present, based on allowed methods. - * This is convenience method, since in most cases params will be strings (or absent). + * Get param value as null or string from an HTTP request or Request Object + * if present, based on allowed methods. This is a convenience method, + * since in most cases params will be strings (or absent). * * @param string $paramKey - * @param \Psr\Http\Message\ServerRequestInterface $request * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods * @return string|null * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getAsStringBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): ?string { /** @psalm-suppress MixedAssignment */ @@ -133,13 +145,14 @@ public function getAsStringBasedOnAllowedMethods( } /** - * Get param value from HTTP request (not from Request Object), based on allowed methods. + * Get param value from an HTTP request (not from Request Object), based + * on allowed methods. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods */ public function getFromRequestBasedOnAllowedMethods( string $paramKey, - ServerRequestInterface $request, + Request|ServerRequestInterface $request, array $allowedMethods = [HttpMethodsEnum::GET], ): ?string { $allParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); @@ -148,7 +161,8 @@ public function getFromRequestBasedOnAllowedMethods( } /** - * Check if Request Object is present as request param and parse it to use its claims as params. + * Check if Request Object is present as a request param and parse it to + * use its claims as params. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -176,8 +190,8 @@ public function parseRequestObjectToken(string $token): Core\RequestObject } /** - * Parse the Request Object token according to OpenID Federation specification. - * Note that this won't do signature validation of it. + * Parse the Request Object token according to OpenID Federation + * specification. Note that this won't do signature validation of it. * * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\RequestObjectException @@ -197,4 +211,27 @@ public function parseClientAssertionToken(string $clientAssertionParam): Core\Cl { return $this->core->clientAssertionFactory()->fromToken($clientAssertionParam); } + + /** + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function isVciAuthorizationCodeRequest( + Request|ServerRequestInterface $request, + array $allowedMethods, + ): bool { + return + // Only applies to VCI Authorization Code flow. + $this->getAsStringBasedOnAllowedMethods( + ParamsEnum::ResponseType->value, + $request, + $allowedMethods, + ) === 'code' && + // Issuer State is only used for VCI Authorization Code flow requests, so use it as a form of detection. + is_string($this->getAsStringBasedOnAllowedMethods( + ParamsEnum::IssuerState->value, + $request, + $allowedMethods, + )); + } } diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index d9134231..815d04b2 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -146,6 +146,20 @@ public function urlAdminTestTrustMarkValidation(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminTestTrustMarkValidation->value, $parameters); } + public function urlAdminTestVerifiableCredentialIssuance(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminTestVerifiableCredentialIssuance->value, $parameters); + } + + /***************************************************************************************************************** + * OAuth 2.0 Authorization Server + ****************************************************************************************************************/ + + public function urlOAuth2Configuration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::OAuth2Configuration->value, $parameters); + } + /***************************************************************************************************************** * OpenID Connect URLs. ****************************************************************************************************************/ @@ -198,4 +212,46 @@ 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); + } + + public function urlCredentialIssuerNonce(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::CredentialIssuerNonce->value, $parameters); + } + + /***************************************************************************************************************** + * SD-JWT-based Verifiable Credentials (SD-JWT VC) + ****************************************************************************************************************/ + + public function urlJwtVcIssuerConfiguration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::JwtVcIssuerConfiguration->value, $parameters); + } + + /***************************************************************************************************************** + * API + ****************************************************************************************************************/ + + public function urlApiVciCredentialOffer(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiVciCredentialOffer->value, $parameters); + } + + public function urlApiOAuth2TokenIntrospection(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::ApiOAuth2TokenIntrospection->value, $parameters); + } } diff --git a/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php new file mode 100644 index 00000000..9e0f3590 --- /dev/null +++ b/src/ValueAbstracts/ResolvedClientAuthenticationMethod.php @@ -0,0 +1,27 @@ +client; + } + + public function getClientAuthenticationMethod(): ClientAuthenticationMethodsEnum + { + return $this->clientAuthenticationMethod; + } +} diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig index cf085f33..51b1a869 100644 --- a/templates/clients/includes/form.twig +++ b/templates/clients/includes/form.twig @@ -141,29 +141,17 @@ {{ form.jwks.getError }} {% endif %} -
-

{{ 'OpenID Federation Related Properties'|trans }}

- - {% trans %}In order for an entity to participate in federation contexts (for example, to be listed as subordinate to this OP), it must have an Entity Identifier and Federation JWKS set. {% endtrans %} - - - - + + {{ form.id_token_signed_response_alg.control | raw }} - {% trans %}Choose if the client is allowed to participate in federation context or not.{% endtrans %} + {% trans %}JWS alg algorithm for signing the ID Token issued to this Client. If not set, the default one from configuration file will be used.{% endtrans %} + {% if form.id_token_signed_response_alg.hasErrors %} + {{ form.id_token_signed_response_alg.getError }} + {% endif %} + +
+

{{ 'OpenID Federation Related Properties'|trans }}

{{ form.entity_identifier.control | raw }} diff --git a/templates/clients/show.twig b/templates/clients/show.twig index f9b2d5ff..307de574 100644 --- a/templates/clients/show.twig +++ b/templates/clients/show.twig @@ -205,6 +205,14 @@ {% endif %} + + + {{ 'ID Token Signing Algorithm'|trans }} + + + {{ client.idTokenSignedResponseAlg|default('N/A'|trans) }} + + {{ 'Owner'|trans }} @@ -226,14 +234,6 @@ - - - {{ 'Is Federated'|trans }} - - - {{ (client.isFederated ? 'Yes' : 'No')|trans }} - - {{ 'Entity Identifier'|trans }} diff --git a/templates/config/federation.twig b/templates/config/federation.twig index 0474898c..ecc81772 100644 --- a/templates/config/federation.twig +++ b/templates/config/federation.twig @@ -34,10 +34,10 @@ >{{ moduleConfig.getPolicyUri }}
{{ 'Homepage URI'|trans }}: - {{ moduleConfig.getHomepageUri }} + >{{ moduleConfig.getOrganizationUri }}
{{ 'Contacts'|trans }}: {% if moduleConfig.getContacts is not empty %} @@ -54,18 +54,16 @@ {{ moduleConfig.getFederationEntityStatementDuration|date("%mm %dd %hh %i' %s''") }}

-

{{ 'PKI'|trans }}

-

- {{ 'Private Key'|trans }}: {{ moduleConfig.getFederationPrivateKeyPath }} -
- {{ 'Private Key Password Set'|trans }}: - {{ moduleConfig.getFederationPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} -
- {{ 'Public Key'|trans }}: {{ moduleConfig.getFederationCertPath }} -

-

- {{ 'Signing Algorithm'|trans }}: {{ moduleConfig.getFederationSigner.algorithmId }} -

+

{{ 'Signature algorithms and public keys'|trans }}

+ + {% for signatureKeyPair in moduleConfig.getFederationSignatureKeyPairBag.getAll %} +

+ - {{ 'Algorithm'|trans }}: {{ signatureKeyPair.getSignatureAlgorithm.value }} + + {{- signatureKeyPair.getKeyPair.getPublicKey.jsonSerialize|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+ {% endfor %}

{{ 'Trust Anchors'|trans }}

{% if moduleConfig.getFederationTrustAnchors is not empty %} diff --git a/templates/config/migrations.twig b/templates/config/migrations.twig index 127b11bc..a3850af8 100644 --- a/templates/config/migrations.twig +++ b/templates/config/migrations.twig @@ -22,7 +22,7 @@ {% endif %}
- {{ 'Before running the migrations, make sure that the database user has proper privileges to change the scheme (for example, alter, create, drop, index). After running the migrations, it is a good practice to remove those privileges.'|trans }} + {{ 'Before running the migrations, make sure that the database user has proper privileges to change the scheme (for example, alter, create, drop, index). After running the migrations, it is a good practice to remove those privileges.'|trans|raw }}
{% endblock oidcContent -%} diff --git a/templates/config/protocol.twig b/templates/config/protocol.twig index 0d5c256f..081f755b 100644 --- a/templates/config/protocol.twig +++ b/templates/config/protocol.twig @@ -28,18 +28,17 @@ {{ moduleConfig.getRefreshTokenDuration|date("%mm %dd %hh %i' %s''") }}

-

{{ 'PKI'|trans }}

-

- {{ 'Private Key'|trans }}: {{ moduleConfig.getProtocolPrivateKeyPath }} -
- {{ 'Private Key Password Set'|trans }}: - {{ moduleConfig.getProtocolPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} -
- {{ 'Public Key'|trans }}: {{ moduleConfig.getProtocolCertPath }} -

-

- {{ 'Signing Algorithm'|trans }}: {{ moduleConfig.getProtocolSigner.algorithmId }} -

+

{{ 'Signature algorithms and public keys'|trans }}

+ + {% for signatureKeyPair in moduleConfig.getProtocolSignatureKeyPairBag.getAll %} +

+ - {{ 'Algorithm'|trans }}: {{ signatureKeyPair.getSignatureAlgorithm.value }} + + {{- signatureKeyPair.getKeyPair.getPublicKey.jsonSerialize|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+ {% endfor %} +

{{ 'Authentication'|trans }}

diff --git a/templates/config/verifiable-credential.twig b/templates/config/verifiable-credential.twig new file mode 100644 index 00000000..e102a45e --- /dev/null +++ b/templates/config/verifiable-credential.twig @@ -0,0 +1,24 @@ +{% set subPageTitle = 'Verifiable Credential Settings'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} +

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

+ +

{{ 'Entity'|trans }}

+

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

+

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

+

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

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

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

+ {% else %} + + {% if not (credentialOfferUri and credentialOfferQrUri) %} + +

+ {{ 'To test Verifiable Credential issuance, choose authentication source, desired Credential Configuration ID, Grant Type, and click Proceed.'|trans }} + {{ '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 %} + + + + + + {% trans %}Grant Type to be used in credential issuance.{% endtrans %} + + +
+ + + + {% trans %}Check if you want to use Transaction Code protection for pre-authorized code grant.{% endtrans %} + {% trans %}If selected, server will send the transaction code to user's email address.{% endtrans %} + + + + + + {% trans %}If Transaction Code protection is used, this attribute will be used to get user's email address to which the transaction code will be sent.{% endtrans %} + {% trans %}Default value for attribute name is taken from module configuration, however, override if necessary.{% endtrans %} + +
+ +
+ +
+
+ {% else %} + {% if authSource and authSource.isAuthenticated %} +

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

+ {% endif %} + +

+ {{ 'Credential Offer:'|trans }} + + {{- credentialOfferUri -}} + +

+ QR Code + +
+
+ +
+
+ {% endif %} + + {% endif %} + +{% endblock oidcContent -%} + +{% block postload %} + {{ parent() }} + + +{% endblock %} \ No newline at end of file diff --git a/tests/cert/oidc_module.crt b/tests/cert/oidc_module.crt new file mode 100644 index 00000000..af6b560a --- /dev/null +++ b/tests/cert/oidc_module.crt @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq2EgIfAMG2XpfjRCAUHQ +QqLAbrOL4s+JwL0X3jO2imVzbBp9MadLzGQhczyhKHM0B9YumHSwNjD5hrQQso02 +CaGientCzhm9PqerjffZ+0B4+FfGws0ozwyfA7hW1arhBx92D8r7Hw0HHu1QwSQ2 +N4zlmCea7shWTC4CoO8ECJcPDYe2/wABLCPZm0dcv/sPYln7HAiukI2fxwMpf3yQ +XihcQQXdHHoOncUn7QlibUQj//Zxk0obEkJAUyqxFKa+3cuToFkfSH7bGs8YnI3q +y/YzmsutI3keeEIQOTNAtLgauqZ4CW+Your+9vsUXaNjNshnObNHWLPc37DJaRyA +pwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/cert/oidc_module.key b/tests/cert/oidc_module.key new file mode 100644 index 00000000..3b0e8bbb --- /dev/null +++ b/tests/cert/oidc_module.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAq2EgIfAMG2XpfjRCAUHQQqLAbrOL4s+JwL0X3jO2imVzbBp9 +MadLzGQhczyhKHM0B9YumHSwNjD5hrQQso02CaGientCzhm9PqerjffZ+0B4+FfG +ws0ozwyfA7hW1arhBx92D8r7Hw0HHu1QwSQ2N4zlmCea7shWTC4CoO8ECJcPDYe2 +/wABLCPZm0dcv/sPYln7HAiukI2fxwMpf3yQXihcQQXdHHoOncUn7QlibUQj//Zx +k0obEkJAUyqxFKa+3cuToFkfSH7bGs8YnI3qy/YzmsutI3keeEIQOTNAtLgauqZ4 +CW+Your+9vsUXaNjNshnObNHWLPc37DJaRyApwIDAQABAoIBAE0+msARNTPILITQ +wwtUAa13M+rxjFRvnLQ9xptFjbo1Xd/U1Kbjs9ttKlKJek4EFuiNVjUrKx1R17Yq +RPhlg3y12MkB86t3mH+8DSwREbQYbC3rSlAVLpacJrQDi0gFHCYcvRcDM0rckWAU +MPjM/I7vN7Dr8P49WABAILku4g+IYaEDUXMGjNnLwgbK6nXCyOn9pwKSqA0SYltI +X63Ba1N13oCigR7UtHvHcRHEWzPkBJO5/pLnNfF+0Tn2z7WXVe5oxCar9nTrUfIP +aspwSjGam7cFbyutnlRLX5/Rkk7r4PkXShP++08GxYTzV7nLhwpN33W0F6B8HLEH +Nu56ihECgYEA20t5gbnRF6QFabJ277uTJ24jdpitB5UOIA8+K9/cPnBsSCfWjexY +Nl0UYuJap3W80dL6lV8VuJZZ0ojAxE+Qwh04MZZXq/Egxn6l+6TNeUCbBHaaj1pl +X0vdxIpC5BKmXhwLLtuqzVg7t3O1NK0c0tZ+kipUA7DenVT2ZHGCyA8CgYEAyBCH +/GFbqsq3Oq8CURZ9osVq5eNXrXeiIp3OlbpywDYFNPLg91mck71gT6btZeuaAH7/ +q7unxZ007ADAoKGPvRKtGa25RB5Vy+dC7F4EDdzz/UBh+fl2zg33W7toh9Kb2OGd +HycEWgU4z+ZpC5fH0v892TlAqPQTFQx+F9rx5ekCgYEAsme7qWtHjUkWUkArfKuI +cyqqVUCufB2qiTB9bupHXtDNdwJaDco6lbex7yShhd1GSRmwXTcnD63Z02sIEG1+ +oj1tSwI5vxuDg5jjZk9UDpIdy0rGQVvUXuv0toGZG72Edcmw22VAlqByrLPIttsj +OO/htv4SrZIF+c92SI8ES8cCgYEAmxktwzva69pCGE2K10A/YAv6ZoRL+aAwYvPC +LxOfWGHIwZa1Tyz6lRKQcs+vZX80IcRTA1j0pN/OIlQnAaCepW6wIaMraKK30t7T +ZBkyvWiZArGCA2AheXccV9I/JGTjC01FGNyPpBY+R/aRYzpk4K+dzCR1e0XU8VGB +A49qTtkCgYEAhQqvYFBlPo5T/W6tWxLLcrvUXIUL2zt4qpKKvdKXTbYwWyUaVp6H +Bu/fG1KQotNwpKDwgIb9Zv/obyW9wFhhNpobvvI273wyFZMps5bu50WIOggFErak +GwPXiagzBlhklsfR+TpFYQ6MTo3ymhLy2+wVIOqV/tjUaRkT7kNk98U= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/tests/config/module_oidc.php b/tests/config/module_oidc.php index c481f5f7..0a40d13b 100644 --- a/tests/config/module_oidc.php +++ b/tests/config/module_oidc.php @@ -13,18 +13,23 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -use Lcobucci\JWT\Signer\Rsa\Sha256; use SimpleSAML\Module\oidc\ModuleConfig; $config = [ ModuleConfig::OPTION_ISSUER => 'http://test.issuer', + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ], + ], + ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', @@ -61,6 +66,14 @@ ModuleConfig::OPTION_FEDERATION_ENABLED => false, + ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ], + ], + ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [ // phpcs:ignore 'https://ta.example.org/' => '{"keys":[{"kty": "RSA","alg": "RS256","use": "sig","kid": "Nzb...9Xs","e": "AQAB","n": "pnXB...ub9J"}]}', @@ -98,14 +111,6 @@ ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'abc123', - ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_ORGANIZATION_NAME => 'Foo corp', ModuleConfig::OPTION_DISPLAY_NAME => 'Foo corp', ModuleConfig::OPTION_DESCRIPTION => 'Foo provider', @@ -116,6 +121,5 @@ ModuleConfig::OPTION_LOGO_URI => 'https://example.org/logo', ModuleConfig::OPTION_POLICY_URI => 'https://example.org/policy', ModuleConfig::OPTION_INFORMATION_URI => 'https://example.org/info', - ModuleConfig::OPTION_HOMEPAGE_URI => 'https://example.org', ModuleConfig::OPTION_ORGANIZATION_URI => 'https://example.org', ]; diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 2c096334..a2bd4845 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -4,7 +4,6 @@ namespace SimpleSAML\Test\Module\oidc\integration\Repositories; -use League\OAuth2\Server\CryptKey; use PDO; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -28,7 +27,7 @@ use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\OpenID\Jws; use Testcontainers\Container\MySQLContainer; use Testcontainers\Container\PostgresContainer; use Testcontainers\Wait\WaitForHealthCheck; @@ -66,7 +65,8 @@ class AccessTokenRepositoryTest extends TestCase protected MockObject $accessTokenEntityMock; protected array $accessTokenState; protected AccessTokenEntityFactory $accessTokenEntityFactory; - protected CryptKey $privateKey; + protected MockObject $jwsMock; + protected MockObject $moduleConfigMock; public static function setUpBeforeClass(): void { @@ -116,18 +116,24 @@ public function setUp(): void 'is_revoked' => false, 'auth_code_id' => self::AUTH_CODE_ID, 'requested_claims' => '[]', + 'flow_type' => null, + 'authorization_details' => null, + 'bound_client_id' => null, + 'bound_redirect_uri' => null, + 'issuer_state' => null, ]; $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); $this->accessTokenEntityFactoryMock = $this->createMock(AccessTokenEntityFactory::class); - $certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; - $privateKeyPath = $certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - $this->privateKey = new CryptKey($privateKeyPath); + + $this->jwsMock = $this->createMock(Jws::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->accessTokenEntityFactory = new AccessTokenEntityFactory( new Helpers(), - $this->privateKey, - $this->createMock(JsonWebTokenBuilderService::class), new ScopeEntityFactory(), + $this->jwsMock, + $this->moduleConfigMock, ); } diff --git a/tests/unit/src/Admin/AuthorizationTest.php b/tests/unit/src/Admin/AuthorizationTest.php index e35421c1..ee64df2a 100644 --- a/tests/unit/src/Admin/AuthorizationTest.php +++ b/tests/unit/src/Admin/AuthorizationTest.php @@ -13,6 +13,7 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; use SimpleSAML\Module\oidc\Services\AuthContextService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Utils\Auth; #[CoversClass(Authorization::class)] @@ -22,6 +23,7 @@ class AuthorizationTest extends TestCase protected MockObject $sspBridgeUtilsMock; protected MockObject $sspBridgeUtilsAuthMock; protected MockObject $authContextServiceMock; + protected MockObject $loggerServiceMock; protected function setUp(): void { @@ -32,16 +34,19 @@ protected function setUp(): void $this->sspBridgeUtilsMock->method('auth')->willReturn($this->sspBridgeUtilsAuthMock); $this->authContextServiceMock = $this->createMock(AuthContextService::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); } protected function sut( ?SspBridge $sspBridge = null, ?AuthContextService $authContextService = null, + ?LoggerService $loggerService = null, ): Authorization { $sspBridge ??= $this->sspBridgeMock; $authContextService ??= $this->authContextServiceMock; + $loggerService ??= $this->loggerServiceMock; - return new Authorization($sspBridge, $authContextService); + return new Authorization($sspBridge, $authContextService, $loggerService); } public function testCanCreateInstance(): void @@ -100,7 +105,7 @@ public function testRequireAdminOrUserWithPermissionReturnsIfUser(): void false, true, // After requireAdmin called, isAdmin will return true ); - $this->sspBridgeUtilsAuthMock->expects($this->once())->method('requireAdmin'); + $this->sspBridgeUtilsAuthMock->expects($this->never())->method('requireAdmin'); $this->authContextServiceMock->expects($this->once())->method('requirePermission'); $this->sut()->requireAdminOrUserWithPermission('permission'); diff --git a/tests/unit/src/Bridges/OAuth2BridgeTest.php b/tests/unit/src/Bridges/OAuth2BridgeTest.php new file mode 100644 index 00000000..5963247e --- /dev/null +++ b/tests/unit/src/Bridges/OAuth2BridgeTest.php @@ -0,0 +1,81 @@ +moduleConfig = $this->createMock(ModuleConfig::class); + $this->bridge = new OAuth2Bridge($this->moduleConfig); + } + + + public function testEncryptDecryptWithPasswordFromConfig(): void + { + $password = 'secret-password'; + $this->moduleConfig->method('getEncryptionKey')->willReturn($password); + + $unencrypted = 'secret-data-2'; + $encrypted = $this->bridge->encrypt($unencrypted); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted); + $this->assertEquals($unencrypted, $decrypted); + } + + public function testEncryptDecryptWithExplicitKey(): void + { + $key = Key::createNewRandomKey(); + + $unencrypted = 'secret-data-3'; + $encrypted = $this->bridge->encrypt($unencrypted, $key); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted, $key); + $this->assertEquals($unencrypted, $decrypted); + } + + public function testEncryptDecryptWithExplicitPassword(): void + { + $password = 'secret-password-explicit'; + + $unencrypted = 'secret-data-4'; + $encrypted = $this->bridge->encrypt($unencrypted, $password); + + $this->assertNotEquals($unencrypted, $encrypted); + + $decrypted = $this->bridge->decrypt($encrypted, $password); + $this->assertEquals($unencrypted, $decrypted); + } + + + public function testDecryptThrowsOidcExceptionOnInvalidData(): void + { + $this->moduleConfig->method('getEncryptionKey')->willReturn('secret-password'); + + $this->expectException(OidcException::class); + $this->expectExceptionMessage('Error decrypting data:'); + + $this->bridge->decrypt('invalid-encrypted-data'); + } +} diff --git a/tests/unit/src/Controllers/Admin/ClientControllerTest.php b/tests/unit/src/Controllers/Admin/ClientControllerTest.php index 47e2ca36..02134ac8 100644 --- a/tests/unit/src/Controllers/Admin/ClientControllerTest.php +++ b/tests/unit/src/Controllers/Admin/ClientControllerTest.php @@ -82,7 +82,6 @@ class ClientControllerTest extends TestCase ], 'jwks_uri' => 'https://example.com/jwks', 'signed_jwks_uri' => 'https://example.com/signed-jwks', - 'is_federated' => true, ]; protected function setUp(): void diff --git a/tests/unit/src/Controllers/EndSessionControllerTest.php b/tests/unit/src/Controllers/EndSessionControllerTest.php index 1d62f7fd..4cac5640 100644 --- a/tests/unit/src/Controllers/EndSessionControllerTest.php +++ b/tests/unit/src/Controllers/EndSessionControllerTest.php @@ -6,8 +6,6 @@ use Exception; use Laminas\Diactoros\ServerRequest; -use Lcobucci\JWT\Token\DataSet; -use Lcobucci\JWT\UnencryptedToken; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -22,6 +20,8 @@ use SimpleSAML\Module\oidc\Services\SessionService; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreBuilder; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core\IdToken; use SimpleSAML\Session; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; @@ -41,7 +41,7 @@ class EndSessionControllerTest extends TestCase protected Stub $dataSetStub; protected MockObject $currentSessionMock; protected MockObject $sessionMock; - protected DataSet $dataSet; + protected array $dataSet = ['sid' => '123']; protected Stub $loggerServiceStub; protected Stub $sessionLogoutTicketStoreDbStub; protected MockObject $loggerServiceMock; @@ -61,8 +61,7 @@ public function setUp(): void $this->currentSessionMock = $this->createMock(Session::class); $this->sessionMock = $this->createMock(Session::class); $this->logoutRequestStub = $this->createStub(LogoutRequest::class); - $this->idTokenHintStub = $this->createStub(UnencryptedToken::class); - $this->dataSet = new DataSet(['sid' => '123'], ''); + $this->idTokenHintStub = $this->createStub(IdToken::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); $this->sessionLogoutTicketStoreDbStub = $this->createStub(LogoutTicketStoreDb::class); $this->templateFactoryStub = $this->createStub(TemplateFactory::class); @@ -118,7 +117,10 @@ public function testCallLogoutForSessionIdInIdTokenHint(): void $this->sessionServiceStub->method('getCurrentSession')->willReturn($this->currentSessionMock); $this->sessionMock->method('getAuthorities')->willReturn(['authId1', 'authId2']); $this->sessionServiceStub->method('getSessionById')->willReturn($this->sessionMock); - $this->idTokenHintStub->method('claims')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayload')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayloadClaim') + ->with(ClaimsEnum::Sid->value) + ->willReturn('123'); $this->logoutRequestStub->method('getIdTokenHint')->willReturn($this->idTokenHintStub); $this->authorizationServerStub->method('validateLogoutRequest')->willReturn($this->logoutRequestStub); $this->sessionLogoutTicketStoreBuilderStub->method('getInstance') @@ -143,7 +145,10 @@ public function testLogsIfSessionFromIdTokenHintNotFound(): void $this->sessionServiceStub->method('getCurrentSession')->willReturn($this->currentSessionMock); $this->sessionMock->method('getAuthorities')->willReturn(['authId1', 'authId2']); $this->sessionServiceStub->method('getSessionById')->willThrowException(new Exception()); - $this->idTokenHintStub->method('claims')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayload')->willReturn($this->dataSet); + $this->idTokenHintStub->method('getPayloadClaim') + ->with(ClaimsEnum::Sid->value) + ->willReturn('123'); $this->logoutRequestStub->method('getIdTokenHint')->willReturn($this->idTokenHintStub); $this->authorizationServerStub->method('validateLogoutRequest')->willReturn($this->logoutRequestStub); $this->sessionLogoutTicketStoreBuilderStub->method('getInstance') diff --git a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php index 56a5e589..eeda4fe0 100644 --- a/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php +++ b/tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php @@ -10,37 +10,32 @@ use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; 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\Services\JsonWebKeySetService; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Jwks; #[CoversClass(EntityStatementController::class)] class EntityStatementControllerTest extends TestCase { protected MockObject $moduleConfigMock; - protected MockObject $jsonWebTokenBuilderServiceMock; - protected MockObject $jsonWebKeySetServiceMock; + protected MockObject $jwksMock; protected MockObject $opMetadataServiceMock; - protected MockObject $clientRepositoryMock; protected MockObject $helpersMock; protected MockObject $routesMock; protected MockObject $federationMock; + protected MockObject $jwkMock; protected MockObject $loggerServiceMock; protected MockObject $federationCacheMock; protected function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->jsonWebTokenBuilderServiceMock = $this->createMock(JsonWebTokenBuilderService::class); - $this->jsonWebKeySetServiceMock = $this->createMock(JsonWebKeySetService::class); + $this->jwksMock = $this->createMock(Jwks::class); $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); - $this->clientRepositoryMock = $this->createMock(ClientRepository::class); $this->helpersMock = $this->createMock(Helpers::class); $this->routesMock = $this->createMock(Routes::class); $this->federationMock = $this->createMock(Federation::class); @@ -50,10 +45,8 @@ protected function setUp(): void protected function sut( ?ModuleConfig $moduleConfig = null, - ?JsonWebTokenBuilderService $jsonWebTokenBuilderService = null, - ?JsonWebKeySetService $jsonWebKeySetService = null, + ?Jwks $jwks = null, ?OpMetadataService $opMetadataService = null, - ?ClientRepository $clientRepository = null, ?Helpers $helpers = null, ?Routes $routes = null, ?Federation $federation = null, @@ -61,10 +54,8 @@ protected function sut( ?FederationCache $federationCache = null, ): EntityStatementController { $moduleConfig ??= $this->moduleConfigMock; - $jsonWebTokenBuilderService ??= $this->jsonWebTokenBuilderServiceMock; - $jsonWebKeySetService ??= $this->jsonWebKeySetServiceMock; + $jwks ??= $this->jwksMock; $opMetadataService ??= $this->opMetadataServiceMock; - $clientRepository ??= $this->clientRepositoryMock; $helpers ??= $this->helpersMock; $routes ??= $this->routesMock; $federation ??= $this->federationMock; @@ -73,10 +64,8 @@ protected function sut( return new EntityStatementController( $moduleConfig, - $jsonWebTokenBuilderService, - $jsonWebKeySetService, + $jwks, $opMetadataService, - $clientRepository, $helpers, $routes, $federation, diff --git a/tests/unit/src/Controllers/Federation/SubordinateListingsControllerTest.php b/tests/unit/src/Controllers/Federation/SubordinateListingsControllerTest.php deleted file mode 100644 index 60591dee..00000000 --- a/tests/unit/src/Controllers/Federation/SubordinateListingsControllerTest.php +++ /dev/null @@ -1,110 +0,0 @@ -moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->clientRepositoryMock = $this->createMock(ClientRepository::class); - $this->routesMock = $this->createMock(Routes::class); - - $this->isFederationEnabled = true; - } - - public function sut( - ?ModuleConfig $moduleConfig = null, - ?ClientRepository $clientRepository = null, - ?Routes $routes = null, - ?bool $federationEnabled = null, - ): SubordinateListingsController { - $federationEnabled = $federationEnabled ?? $this->isFederationEnabled; - $this->moduleConfigMock->method('getFederationEnabled')->willReturn($federationEnabled); - - $moduleConfig = $moduleConfig ?? $this->moduleConfigMock; - $clientRepository = $clientRepository ?? $this->clientRepositoryMock; - $routes = $routes ?? $this->routesMock; - - return new SubordinateListingsController( - $moduleConfig, - $clientRepository, - $routes, - ); - } - - public function testCanConstruct(): void - { - $this->assertInstanceOf(SubordinateListingsController::class, $this->sut()); - } - - public function testThrowsIfFederationNotEnabled(): void - { - $this->expectException(OidcServerException::class); - $this->expectExceptionMessage('refused'); - - $this->sut(federationEnabled: false); - } - - public function testCanListFederatedEntities(): void - { - $request = Request::create( - '/list', - 'GET', - [], - ); - - $client = $this->createMock(ClientEntityInterface::class); - $client->method('getEntityIdentifier')->willReturn('entity-id'); - - $federatedEntities = [ - $client, - ]; - - $this->clientRepositoryMock->expects($this->once())->method('findAllFederated') - ->willReturn($federatedEntities); - - $this->routesMock->expects($this->once())->method('newJsonResponse') - ->with([ - $client->getEntityIdentifier(), - ]); - - $this->sut()->list($request); - } - - public function testListReturnsErrorOnUnsuportedQueryParameter(): void - { - $request = Request::create( - '/list', - 'GET', - ['entity_type' => 'something'], - ); - - $this->routesMock->expects($this->once())->method('newJsonErrorResponse') - ->with(ErrorsEnum::UnsupportedParameter->value); - - $this->sut()->list($request); - } -} diff --git a/tests/unit/src/Controllers/JwksControllerTest.php b/tests/unit/src/Controllers/JwksControllerTest.php index 4b3267e1..675621b6 100644 --- a/tests/unit/src/Controllers/JwksControllerTest.php +++ b/tests/unit/src/Controllers/JwksControllerTest.php @@ -9,7 +9,10 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Controllers\JwksController; -use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jwks\Factories\JwksDecoratorFactory; +use SimpleSAML\OpenID\Jwks\JwksDecorator; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -18,19 +21,23 @@ */ class JwksControllerTest extends TestCase { - protected MockObject $jsonWebKeySetServiceMock; + protected MockObject $moduleConfigMock; + protected MockObject $jwks; protected MockObject $serverRequestMock; protected MockObject $psrHttpBridgeMock; protected MockObject $symfonyResponseMock; protected MockObject $responseHeaderBagMock; protected MockObject $httpFoundationFactoryMock; + protected MockObject $jwksDecoratorFactoryMock; + protected MockObject $jwksDecoratorMock; /** * @throws \Exception */ protected function setUp(): void { - $this->jsonWebKeySetServiceMock = $this->createMock(JsonWebKeySetService::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->jwks = $this->createMock(Jwks::class); $this->serverRequestMock = $this->createMock(ServerRequest::class); $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); @@ -41,18 +48,27 @@ protected function setUp(): void $this->httpFoundationFactoryMock = $this->createMock(HttpFoundationFactory::class); $this->httpFoundationFactoryMock->method('createResponse')->willReturn($this->symfonyResponseMock); $this->psrHttpBridgeMock->method('getHttpFoundationFactory')->willReturn($this->httpFoundationFactoryMock); + + $this->jwksDecoratorMock = $this->createMock(JwksDecorator::class); + $this->jwksDecoratorFactoryMock = $this->createMock(JwksDecoratorFactory::class); + $this->jwksDecoratorFactoryMock->method('fromJwkDecorators')->willReturn($this->jwksDecoratorMock); + + $this->jwks->method('jwksDecoratorFactory')->willReturn($this->jwksDecoratorFactoryMock); } protected function mock( - ?JsonWebKeySetService $jsonWebKeySetService = null, ?PsrHttpBridge $psrHttpBridge = null, + ?ModuleConfig $moduleConfig = null, + ?Jwks $jwks = null, ): JwksController { - $jsonWebKeySetService ??= $this->jsonWebKeySetServiceMock; $psrHttpBridge ??= $this->psrHttpBridgeMock; + $moduleConfig ??= $this->moduleConfigMock; + $jwks ??= $this->jwks; return new JwksController( - $jsonWebKeySetService, $psrHttpBridge, + $moduleConfig, + $jwks, ); } @@ -67,7 +83,7 @@ public function testItIsInitializable(): void public function testItReturnsJsonKeys(): void { $keys = [ - 0 => [ + 'keys' => [ 'kty' => 'RSA', 'n' => 'n', 'e' => 'e', @@ -77,10 +93,10 @@ public function testItReturnsJsonKeys(): void ], ]; - $this->jsonWebKeySetServiceMock->expects($this->once())->method('protocolKeys')->willReturn($keys); + $this->jwksDecoratorMock->expects($this->once())->method('jsonSerialize')->willReturn($keys); $this->assertSame( - ['keys' => $keys], + $keys, $this->mock()->__invoke()->getPayload(), ); } diff --git a/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php new file mode 100644 index 00000000..b0ea6ef0 --- /dev/null +++ b/tests/unit/src/Controllers/OAuth2/OAuth2ServerConfigurationControllerTest.php @@ -0,0 +1,129 @@ + 'http://localhost', + 'authorization_endpoint' => 'http://localhost/authorization', + 'token_endpoint' => 'http://localhost/token', + ]; + + protected MockObject $opMetadataServiceMock; + protected MockObject $routesMock; + protected MockObject $moduleConfigMock; + + protected function setUp(): void + { + $this->opMetadataServiceMock = $this->createMock(OpMetadataService::class); + $this->routesMock = $this->createMock(Routes::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->opMetadataServiceMock->method('getMetadata')->willReturn(self::OIDC_OP_METADATA); + } + + protected function mock( + ?OpMetadataService $opMetadataService = null, + ?Routes $routes = null, + ?ModuleConfig $moduleConfig = null, + ): OAuth2ServerConfigurationController { + return new OAuth2ServerConfigurationController( + $opMetadataService ?? $this->opMetadataServiceMock, + $routes ?? $this->routesMock, + $moduleConfig ?? $this->moduleConfigMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf( + OAuth2ServerConfigurationController::class, + $this->mock(), + ); + } + + public function testItReturnsConfigurationWithoutIntrospectionIfApiDisabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(self::OIDC_OP_METADATA) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } + + public function testItReturnsConfigurationWithoutIntrospectionIfIntrospectionDisabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(false); + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(self::OIDC_OP_METADATA) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } + + public function testItReturnsConfigurationWithIntrospectionEndpointEnabled(): void + { + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $signatureAlgorithmBagMock = $this->createMock(SignatureAlgorithmBag::class); + $signatureAlgorithmBagMock->method('getAllNamesUnique')->willReturn(['RS256', 'ES256']); + + $supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); + $supportedAlgorithmsMock->method('getSignatureAlgorithmBag')->willReturn($signatureAlgorithmBagMock); + + $this->moduleConfigMock->method('getSupportedAlgorithms')->willReturn($supportedAlgorithmsMock); + + $introspectionEndpoint = 'http://localhost/introspect'; + $this->routesMock->method('urlApiOAuth2TokenIntrospection')->willReturn($introspectionEndpoint); + + $expectedConfiguration = self::OIDC_OP_METADATA; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpoint->value] = $introspectionEndpoint; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpointAuthMethodsSupported->value] = [ + ClientAuthenticationMethodsEnum::ClientSecretBasic->value, + ClientAuthenticationMethodsEnum::ClientSecretPost->value, + ClientAuthenticationMethodsEnum::PrivateKeyJwt->value, + AccessTokenTypesEnum::Bearer->value, + ]; + $expectedConfiguration[ClaimsEnum::IntrospectionEndpointAuthSigningAlgValuesSupported->value] = [ + 'RS256', + 'ES256', + ]; + + $jsonResponseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($expectedConfiguration) + ->willReturn($jsonResponseMock); + + $this->assertSame($jsonResponseMock, $this->mock()->__invoke()); + } +} diff --git a/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php new file mode 100644 index 00000000..fbb9be28 --- /dev/null +++ b/tests/unit/src/Controllers/OAuth2/TokenIntrospectionControllerTest.php @@ -0,0 +1,414 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(true); + + $this->authenticatedOAuth2ClientResolverMock = $this->createMock(AuthenticatedOAuth2ClientResolver::class); + $this->routesMock = $this->createMock(Routes::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->apiAuthorizationMock = $this->createMock(Authorization::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->bearerTokenValidatorMock = $this->createMock(BearerTokenValidator::class); + $this->oAuth2BridgeMock = $this->createMock(OAuth2Bridge::class); + $this->refreshTokenRepositoryMock = $this->createMock(RefreshTokenRepository::class); + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver = null, + ?Routes $routes = null, + ?LoggerService $loggerService = null, + ?Authorization $apiAuthorization = null, + ?RequestParamsResolver $requestParamsResolver = null, + ?BearerTokenValidator $bearerTokenValidator = null, + ?OAuth2Bridge $oAuth2Bridge = null, + ?RefreshTokenRepository $refreshTokenRepository = null, + ): TokenIntrospectionController { + return new TokenIntrospectionController( + $moduleConfig ?? $this->moduleConfigMock, + $authenticatedOAuth2ClientResolver ?? $this->authenticatedOAuth2ClientResolverMock, + $routes ?? $this->routesMock, + $loggerService ?? $this->loggerServiceMock, + $apiAuthorization ?? $this->apiAuthorizationMock, + $requestParamsResolver ?? $this->requestParamsResolverMock, + $bearerTokenValidator ?? $this->bearerTokenValidatorMock, + $oAuth2Bridge ?? $this->oAuth2BridgeMock, + $refreshTokenRepository ?? $this->refreshTokenRepositoryMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf(TokenIntrospectionController::class, $this->sut()); + } + + public function testConstructThrowsForbiddenIfApiNotEnabled(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(false); + + $this->expectException(OidcServerException::class); + try { + $this->sut(); + } catch (OidcServerException $e) { + $this->assertSame('API capabilities not enabled.', $e->getHint()); + throw $e; + } + } + + public function testConstructThrowsForbiddenIfIntrospectionNotEnabled(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getApiEnabled')->willReturn(true); + $this->moduleConfigMock->method('getApiOAuth2TokenIntrospectionEndpointEnabled')->willReturn(false); + + $this->expectException(OidcServerException::class); + try { + $this->sut(); + } catch (OidcServerException $e) { + $this->assertSame('OAuth2 Token Introspection API endpoint not enabled.', $e->getHint()); + throw $e; + } + } + + private function createValidResolvedClientAuthenticationMethodMock(): MockObject&ResolvedClientAuthenticationMethod + { + $mock = $this->createMock(ResolvedClientAuthenticationMethod::class); + $mock->method('getClientAuthenticationMethod')->willReturn(ClientAuthenticationMethodsEnum::ClientSecretBasic); + $clientMock = $this->createMock(ClientEntity::class); + $clientMock->method('getIdentifier')->willReturn('client-id'); + $mock->method('getClient')->willReturn($clientMock); + + return $mock; + } + + public function testInvokeReturnsUnauthorizedOnAuthorizationException(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn(null); + + $this->apiAuthorizationMock->expects($this->once()) + ->method('requireTokenForAnyOfScope') + ->willThrowException(new AuthorizationException('Unauthorized client.')); + + $this->loggerServiceMock->expects($this->once()) + ->method('error') + ->with($this->stringContains('AuthorizationException: Unauthorized client.')); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('unauthorized', 'Unauthorized client.', 401) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsBadRequestIfMissingToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); // client is authenticated + + $this->requestParamsResolverMock->expects($this->once()) + ->method('getFromRequestBasedOnAllowedMethods') + ->with('token', $requestMock, [HttpMethodsEnum::POST]) + ->willReturn(null); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonErrorResponse') + ->with('invalid_request', 'Missing token parameter.', 400) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsActiveFalseIfTokenInvalid(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'invalid-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], null], + ]); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('invalid-token') + ->willThrowException(new \Exception('bad token')); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('invalid-token') + ->willThrowException(new \Exception('bad refresh token')); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with(['active' => false]) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeCallsAccessTokenFirstRefreshSecondIfNoHint(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'invalid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], null], + ]); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('invalid-access-token') + ->willThrowException(new \Exception('bad token')); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('invalid-access-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'ref-1', + 'scopes' => ['scope1'], + 'client_id' => 'client1', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('ref-1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client1'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeWithTokenTypeHintAccessToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'access_token'], + ]); + + $jwsMock = $this->createMock(\SimpleSAML\OpenID\Jws\ParsedJws::class); + $jwsMock->method('getPayloadClaim')->with('scopes')->willReturn(['scope2']); + $jwsMock->method('getAudience')->willReturn(['client2']); + $jwsMock->method('getExpirationTime')->willReturn(1000); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('valid-access-token') + ->willReturn($jwsMock); + + $this->oAuth2BridgeMock->expects($this->never())->method('decrypt'); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client2'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeWithTokenTypeHintRefreshToken(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-refresh-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'refresh_token'], + ]); + + $this->bearerTokenValidatorMock->expects($this->never())->method('ensureValidAccessToken'); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('valid-refresh-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'ref-1', + 'scopes' => ['scope1'], + 'client_id' => 'client3', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('ref-1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true && $data['client_id'] === 'client3'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsExpectedAccessTokenPayload(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-access-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'access_token'], + ]); + + $jwsMock = $this->createMock(\SimpleSAML\OpenID\Jws\ParsedJws::class); + $jwsMock->method('getPayloadClaim')->with('scopes')->willReturn(['scope1', 'scope2']); + $jwsMock->method('getExpirationTime')->willReturn(1000); + $jwsMock->method('getIssuedAt')->willReturn(500); + $jwsMock->method('getNotBefore')->willReturn(500); + $jwsMock->method('getSubject')->willReturn('sub1'); + $jwsMock->method('getAudience')->willReturn(['client1']); + $jwsMock->method('getIssuer')->willReturn('iss1'); + $jwsMock->method('getJwtId')->willReturn('jti1'); + + $this->bearerTokenValidatorMock->expects($this->once()) + ->method('ensureValidAccessToken') + ->with('valid-access-token') + ->willReturn($jwsMock); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with([ + 'active' => true, + 'scope' => 'scope1 scope2', + 'client_id' => 'client1', + 'token_type' => 'Bearer', + 'exp' => 1000, + 'iat' => 500, + 'nbf' => 500, + 'sub' => 'sub1', + 'aud' => ['client1'], + 'iss' => 'iss1', + 'jti' => 'jti1', + ]) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } + + public function testInvokeReturnsExpectedRefreshTokenPayload(): void + { + $requestMock = $this->createMock(Request::class); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($this->createValidResolvedClientAuthenticationMethodMock()); + + $this->requestParamsResolverMock + ->method('getFromRequestBasedOnAllowedMethods') + ->willReturnMap([ + ['token', $requestMock, [HttpMethodsEnum::POST], 'valid-refresh-token'], + ['token_type_hint', $requestMock, [HttpMethodsEnum::POST], 'refresh_token'], + ]); + + $this->oAuth2BridgeMock->expects($this->once()) + ->method('decrypt') + ->with('valid-refresh-token') + ->willReturn(json_encode([ + 'expire_time' => time() + 3600, + 'refresh_token_id' => 'jti1', + 'scopes' => ['scope1', 'scope2'], + 'client_id' => 'client1', + 'user_id' => 'sub1', + ])); + + $this->refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->with('jti1') + ->willReturn(false); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with($this->callback(function (array $data) { + return $data['active'] === true + && $data['scope'] === 'scope1 scope2' + && $data['client_id'] === 'client1' + && $data['exp'] > time() + && $data['sub'] === 'sub1' + && $data['aud'] === 'client1' + && $data['jti'] === 'jti1'; + })) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->__invoke($requestMock)); + } +} diff --git a/tests/unit/src/Controllers/UserInfoControllerTest.php b/tests/unit/src/Controllers/UserInfoControllerTest.php index 2b1a0e5c..53b9fcd5 100644 --- a/tests/unit/src/Controllers/UserInfoControllerTest.php +++ b/tests/unit/src/Controllers/UserInfoControllerTest.php @@ -5,7 +5,6 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; use Laminas\Diactoros\ServerRequest; -use League\OAuth2\Server\ResourceServer; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -18,6 +17,7 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; +use SimpleSAML\Module\oidc\Server\ResourceServer; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; diff --git a/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php b/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php new file mode 100644 index 00000000..a88f08e5 --- /dev/null +++ b/tests/unit/src/Controllers/VerifiableCredentials/NonceControllerTest.php @@ -0,0 +1,66 @@ +nonceServiceMock = $this->createMock(NonceService::class); + $this->routesMock = $this->createMock(Routes::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + } + + /** + * @throws \Exception + */ + public function testNonce(): void + { + $this->nonceServiceMock->expects($this->once()) + ->method('generateNonce') + ->willReturn('mocked_nonce'); + + $responseMock = $this->createMock(JsonResponse::class); + $this->routesMock->expects($this->once()) + ->method('newJsonResponse') + ->with( + ['c_nonce' => 'mocked_nonce'], + 200, + ['Cache-Control' => 'no-store', 'Access-Control-Allow-Origin' => '*'], + ) + ->willReturn($responseMock); + + $this->moduleConfigMock->expects($this->once()) + ->method('getVciEnabled') + ->willReturn(true); + + $sut = new NonceController( + $this->nonceServiceMock, + $this->routesMock, + $this->loggerServiceMock, + $this->moduleConfigMock, + ); + $response = $sut->nonce(); + + $this->assertSame($responseMock, $response); + } +} diff --git a/tests/unit/src/Entities/AccessTokenEntityTest.php b/tests/unit/src/Entities/AccessTokenEntityTest.php index b7d6d1c1..d7e37417 100644 --- a/tests/unit/src/Entities/AccessTokenEntityTest.php +++ b/tests/unit/src/Entities/AccessTokenEntityTest.php @@ -6,15 +6,16 @@ use DateTimeImmutable; use DateTimeZone; -use Lcobucci\JWT\UnencryptedToken; -use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\ScopeEntity; use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Entities\AccessTokenEntity @@ -36,19 +37,17 @@ class AccessTokenEntityTest extends TestCase protected ScopeEntity $scopeEntityOpenId; - /** - * @var \SimpleSAML\Module\oidc\Entities\ScopeEntity - */ protected ScopeEntity $scopeEntityProfile; - protected CryptKey $privateKey; - protected MockObject $jsonWebTokenBuilderServiceMock; protected MockObject $unencryptedTokenMock; protected DateTimeImmutable $expiryDateTime; -// protected Stub $jwtConfigurationStub; + + protected MockObject $moduleConfigMock; + protected MockObject $jwsMock; + protected MockObject $signatureKeyPairMock; + protected MockObject $signatureKeyPairBagMock; /** * @throws \Exception - * @throws \JsonException */ protected function setUp(): void { @@ -70,15 +69,19 @@ protected function setUp(): void $this->expiryDateTime = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) ->add(new \DateInterval('PT1M')); - $this->jsonWebTokenBuilderServiceMock = $this->createMock(JsonWebTokenBuilderService::class); - $this->unencryptedTokenMock = $this->createMock(UnencryptedToken::class); - $this->jsonWebTokenBuilderServiceMock->method('getSignedProtocolJwt') - ->willReturn($this->unencryptedTokenMock); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->jwsMock = $this->createMock(Jws::class); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairBagMock->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); - //$this->jwtConfigurationStub = $this->createStub(\Lcobucci\JWT\Configuration::class); // Final class :( - $certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; - $privateKeyPath = $certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - $this->privateKey = new CryptKey($privateKeyPath); + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->signatureKeyPairBagMock); } public function mock(): AccessTokenEntity @@ -88,13 +91,12 @@ public function mock(): AccessTokenEntity $this->clientEntityStub, $this->scopes, $this->expiryDateTime, - $this->privateKey, - $this->jsonWebTokenBuilderServiceMock, + $this->jwsMock, + $this->moduleConfigMock, $this->userId, $this->authCodeId, $this->requestedClaims, $this->isRevoked, - // $this->jwtConfigurationStub, ); } @@ -126,7 +128,6 @@ public function testHasProperState(): void */ public function testHasImmutableStringRepresentation(): void { - $this->unencryptedTokenMock->method('toString')->willReturn('token'); $instance = $this->mock(); $this->assertNull($instance->toString()); diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index b9cc457e..6b0be802 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -28,6 +28,7 @@ class AuthCodeEntityTest extends TestCase protected string $redirectUri; protected string $nonce; protected DateTimeImmutable $expiryDateTime; + protected ?array $authorizationDetails; /** * @throws \Exception @@ -49,6 +50,7 @@ protected function setUp(): void $this->isRevoked = false; $this->redirectUri = 'https://localhost/redirect'; $this->nonce = 'nonce'; + $this->authorizationDetails = null; } /** @@ -66,6 +68,7 @@ protected function mock(): AuthCodeEntity $this->redirectUri, $this->nonce, $this->isRevoked, + $this->authorizationDetails, ); } @@ -98,6 +101,12 @@ public function testCanGetState(): void 'is_revoked' => false, 'redirect_uri' => 'https://localhost/redirect', 'nonce' => 'nonce', + 'flow_type' => null, + 'tx_code' => null, + 'authorization_details' => null, + 'bound_client_id' => null, + 'bound_redirect_uri' => null, + 'issuer_state' => null, ], ); } diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index 49a709cc..acca0582 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -37,7 +37,7 @@ class ClientEntityTest extends TestCase protected ?DateTimeImmutable $updatedAt = null; protected ?DateTimeImmutable $createdAt = null; protected ?DateTimeImmutable $expiresAt = null; - protected bool $isFederated = false; + protected bool $isGeneric = false; protected function setUp(): void { @@ -58,7 +58,7 @@ protected function setUp(): void 'updated_at' => null, 'created_at' => null, 'expires_at' => null, - 'is_federated' => false, + 'is_generic' => false, ]; } @@ -91,7 +91,7 @@ public function mock(): ClientEntity $this->updatedAt, $this->createdAt, $this->expiresAt, - $this->isFederated, + $this->isGeneric, ); } @@ -182,7 +182,8 @@ public function testCanGetState(): void 'updated_at' => null, 'created_at' => null, 'expires_at' => null, - 'is_federated' => $this->state['is_federated'], + 'is_generic' => $this->state['is_generic'], + 'extra_metadata' => null, ], ); } @@ -218,7 +219,8 @@ public function testCanExportAsArray(): void 'updated_at' => null, 'created_at' => null, 'expires_at' => null, - 'is_federated' => false, + 'is_generic' => false, + 'id_token_signed_response_alg' => null, ], ); } diff --git a/tests/unit/src/Forms/ClientFormTest.php b/tests/unit/src/Forms/ClientFormTest.php index 1134c0b3..425c3d2e 100644 --- a/tests/unit/src/Forms/ClientFormTest.php +++ b/tests/unit/src/Forms/ClientFormTest.php @@ -80,7 +80,6 @@ public function setUp(): void ['date' => '2024-12-01 11:54:12.000000', 'timezone_type' => 3, 'timezone' => 'UTC',], ), 'expires_at' => null, - 'is_federated' => false, 'allowed_origin' => [], ]; } diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 94b84f8d..80e090a6 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -5,8 +5,6 @@ namespace SimpleSAML\Test\Module\oidc\unit; use DateInterval; -use Lcobucci\JWT\Signer; -use Lcobucci\JWT\Signer\Rsa\Sha256; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -15,10 +13,15 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\SupportedSerializers; +use SimpleSAML\OpenID\ValueAbstracts; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag; use SimpleSAML\Utils\Config; use SimpleSAML\Utils\HTTP; -use stdClass; #[CoversClass(ModuleConfig::class)] class ModuleConfigTest extends TestCase @@ -30,14 +33,20 @@ class ModuleConfigTest extends TestCase protected array $moduleConfig = [ ModuleConfig::OPTION_ISSUER => 'http://test.issuer', + ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => [ + [ + ModuleConfig::KEY_ALGORITHM => \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module_connect_rsa_01.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module_connect_rsa_01.pub', + ], + ], + ModuleConfig::OPTION_TOKEN_AUTHORIZATION_CODE_TTL => 'PT10M', ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', ModuleConfig::OPTION_CRON_TAG => 'hourly', - ModuleConfig::OPTION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE => 'uid', @@ -55,12 +64,6 @@ class ModuleConfigTest extends TestCase ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => null, - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => Sha256::class, - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'abc123', - ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ 'abc123', ], @@ -75,6 +78,7 @@ class ModuleConfigTest extends TestCase private MockObject $sspBridgeUtilsHttpMock; private MockObject $sspBridgeModuleMock; private MockObject $sspBridgeUtilsConfigMock; + private MockObject $valueAbstractMock; protected function setUp(): void { @@ -88,7 +92,9 @@ protected function setUp(): void $this->sspBridgeUtilsConfigMock = $this->createMock(Config::class); $this->sspBridgeUtilsConfigMock->method('getCertPath') - ->willReturnCallback(fn(string $filename): string => '/path/to/cert' . $filename); + ->willReturnCallback( + fn(string $filename): string => dirname(__DIR__, 2) . '/cert/' . $filename, + ); $this->sspBridgeUtilsHttpMock = $this->createMock(HTTP::class); $this->sspBridgeModuleMock = $this->createMock(SspBridge\Module::class); @@ -100,6 +106,8 @@ protected function setUp(): void $this->sspBridgeUtilsMock->method('http')->willReturn($this->sspBridgeUtilsHttpMock); $this->sspBridgeUtilsMock->method('config')->willReturn($this->sspBridgeUtilsConfigMock); + + $this->valueAbstractMock = $this->createMock(ValueAbstracts::class); } protected function sut( @@ -107,17 +115,20 @@ protected function sut( ?array $overrides = null, ?Configuration $sspConfig = null, ?SspBridge $sspBridge = null, + ?ValueAbstracts $valueAbstracts = null, ): ModuleConfig { $fileName ??= $this->fileName; $overrides ??= $this->overrides; $sspConfig ??= $this->sspConfigMock; $sspBridge ??= $this->sspBridgeMock; + $valueAbstracts ??= $this->valueAbstractMock; return new ModuleConfig( $fileName, $overrides, $sspConfig, $sspBridge, + $valueAbstracts, ); } @@ -129,32 +140,56 @@ public function testCanGetCommonOptions(): void $this->assertInstanceOf(DateInterval::class, $this->sut()->getAccessTokenDuration()); $this->assertInstanceOf(DateInterval::class, $this->sut()->getRefreshTokenDuration()); + $this->assertInstanceOf(SupportedAlgorithms::class, $this->sut()->getSupportedAlgorithms()); + $this->assertInstanceOf(SupportedSerializers::class, $this->sut()->getSupportedSerializers()); + $this->assertSame( $this->moduleConfig[ModuleConfig::OPTION_AUTH_SOURCE], $this->sut()->getDefaultAuthSourceId(), ); } - /** - * @throws \Exception - */ - public function testSigningKeyNameCanBeCustomized(): void + public function testCanGetProtocolSignatureKeyPairs(): void + { + $this->assertNotEmpty($this->sut()->getProtocolSignatureKeyPairs()); + } + + public function testGetProtocolSignatureKeyPairsThrowsOnInvalidConfigValue(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('At least one '); + + $this->sut( + overrides: [ModuleConfig::OPTION_PROTOCOL_SIGNATURE_KEY_PAIRS => []], + )->getProtocolSignatureKeyPairs(); + } + + public function testCanGetProtocolSignatureKeyPairConfigBag(): void { - // Test default cert and pem - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, - $this->sut()->getProtocolCertPath(), + $sut = $this->sut(); + + $this->assertInstanceOf( + SignatureKeyPairConfigBag::class, + $sut->getProtocolSignatureKeyPairConfigBag(), ); - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - $this->sut()->getProtocolPrivateKeyPath(), + $this->assertInstanceOf( + SignatureKeyPairConfigBag::class, + $sut->getProtocolSignatureKeyPairConfigBag(), ); + } - // Set customized - $this->overrides[ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME] = 'myPrivateKey.key'; - $this->overrides[ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME] = 'myCertificate.crt'; - $this->assertStringContainsString('myCertificate.crt', $this->sut()->getProtocolCertPath()); - $this->assertStringContainsString('myPrivateKey.key', $this->sut()->getProtocolPrivateKeyPath()); + public function testCanGetProtocolSignatureKeyPairgBag(): void + { + $sut = $this->sut(); + + $this->assertInstanceOf( + SignatureKeyPairBag::class, + $sut->getProtocolSignatureKeyPairBag(), + ); + $this->assertInstanceOf( + SignatureKeyPairBag::class, + $sut->getProtocolSignatureKeyPairBag(), + ); } public function testCanGetSspConfig(): void @@ -172,17 +207,6 @@ public function testCanGetOpenIdScopes(): void $this->assertNotEmpty($this->sut()->getScopes()); } - public function testCanGetProtocolSigner(): void - { - $this->assertInstanceOf(Signer::class, $this->sut()->getProtocolSigner()); - } - - public function testCanGetProtocolPrivateKeyPassphrase(): void - { - $this->overrides[ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE] = 'test'; - $this->assertNotEmpty($this->sut()->getProtocolPrivateKeyPassPhrase()); - } - public function testCanGetAuthProcFilters(): void { $this->assertIsArray($this->sut()->getAuthProcFilters()); @@ -225,16 +249,6 @@ public function testCanGetUserIdentifierAttribute(): void public function testCanGetCommonFederationOptions(): void { $this->assertFalse($this->sut()->getFederationEnabled()); - $this->assertInstanceOf(Signer::class, $this->sut()->getFederationSigner()); - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - $this->sut()->getFederationPrivateKeyPath(), - ); - $this->assertNotEmpty($this->sut()->getFederationPrivateKeyPassPhrase()); - $this->assertStringContainsString( - ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - $this->sut()->getFederationCertPath(), - ); $this->assertNotEmpty($this->sut()->getFederationEntityStatementDuration()); $this->assertNotEmpty($this->sut()->getFederationEntityStatementCacheDurationForProduced()); $this->assertNotEmpty($this->sut()->getFederationAuthorityHints()); @@ -247,13 +261,31 @@ public function testCanGetCommonFederationOptions(): void $this->assertNotEmpty($this->sut()->getLogoUri()); $this->assertNotEmpty($this->sut()->getPolicyUri()); $this->assertNotEmpty($this->sut()->getInformationUri()); - $this->assertNotEmpty($this->sut()->getHomepageUri()); $this->assertNotEmpty($this->sut()->getOrganizationUri()); $this->assertNotEmpty($this->sut()->getFederationCacheAdapterClass()); $this->assertIsArray($this->sut()->getFederationCacheAdapterArguments()); $this->assertNotEmpty($this->sut()->getFederationCacheMaxDurationForFetched()); $this->assertNotEmpty($this->sut()->getFederationTrustAnchors()); $this->assertNotEmpty($this->sut()->getFederationTrustAnchorIds()); + + $this->assertInstanceOf(DateInterval::class, $this->sut()->getTimestampValidationLeeway()); + } + + public function testCanGetFederationSignatureKeyPairBag(): void + { + $sut = $this->sut(); + $this->assertInstanceOf(SignatureKeyPairBag::class, $sut->getFederationSignatureKeyPairBag()); + $this->assertInstanceOf(SignatureKeyPairBag::class, $sut->getFederationSignatureKeyPairBag()); + } + + public function testGetFederationSignatureKeyPairBagThrowsOnInvalidConfigValue(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('At least one '); + + $this->sut( + overrides: [ModuleConfig::OPTION_FEDERATION_SIGNATURE_KEY_PAIRS => []], + )->getFederationSignatureKeyPairBag(); } public function testKeywordsCanBeNull(): void @@ -366,13 +398,6 @@ public function testThrowsIForcedAcrValueForCookieAuthenticationNotAllowed(): vo $this->sut(); } - public function testThrowsIfInvalidSignerProvided(): void - { - $this->overrides[ModuleConfig::OPTION_TOKEN_SIGNER] = stdClass::class; - $this->expectException(ConfigurationError::class); - $this->sut()->getProtocolSigner(); - } - public function testCanGetEncryptionKey(): void { $this->sspBridgeUtilsConfigMock->expects($this->once())->method('getSecretSalt') @@ -400,28 +425,6 @@ public function testCanGetProtocolDiscoveryShowClaimsSupported(): void ); } - public function testCanGetProtocolNewCertPath(): void - { - $this->assertNull($this->sut()->getProtocolNewCertPath()); - - $sut = $this->sut( - overrides: [ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new-cert'], - ); - - $this->assertStringContainsString('new-cert', $sut->getProtocolNewCertPath()); - } - - public function testCanGetFederationNewCertPath(): void - { - $this->assertNull($this->sut()->getFederationNewCertPath()); - - $sut = $this->sut( - overrides: [ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new-cert'], - ); - - $this->assertStringContainsString('new-cert', $sut->getFederationNewCertPath()); - } - public function testCanGetFederationDynamicTrustMarks(): void { $this->assertNull($this->sut()->getFederationDynamicTrustMarks()); @@ -498,4 +501,109 @@ public function testCanGetFederationTrustMarkStatusEndpointUsagePolicy(): void $sut->getFederationTrustMarkStatusEndpointUsagePolicy(), ); } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidValue(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Invalid value'); + + $this->sut()->getValidatedSignatureKeyPairArray('invalid'); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidSignature(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => 'invalid', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Invalid protocol signature algorithm'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPrivateKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Unexpected value for private key filename'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPrivateKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'non-existing.key', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Private key file does not exist'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnInvalidPublicKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Unexpected value for public key filename'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnNonExistingPublicKey(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'non-existing.pub', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Public key file does not exist'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyPasswordString(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Expected a non-empty string'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } + + public function testGetValidatedSignatureKeyPairArrayThrowsOnEmptyKeyIdString(): void + { + $value = [ + ModuleConfig::KEY_ALGORITHM => SignatureAlgorithmEnum::RS256, + ModuleConfig::KEY_PRIVATE_KEY_FILENAME => 'oidc_module.key', + ModuleConfig::KEY_PUBLIC_KEY_FILENAME => 'oidc_module.crt', + ModuleConfig::KEY_PRIVATE_KEY_PASSWORD => 'password', + ModuleConfig::KEY_KEY_ID => '', + ]; + + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('Expected a non-empty string'); + + $this->sut()->getValidatedSignatureKeyPairArray($value); + } } diff --git a/tests/unit/src/Repositories/ClientRepositoryTest.php b/tests/unit/src/Repositories/ClientRepositoryTest.php index 5da45064..57f3893e 100644 --- a/tests/unit/src/Repositories/ClientRepositoryTest.php +++ b/tests/unit/src/Repositories/ClientRepositoryTest.php @@ -406,7 +406,7 @@ public function testCanFindByEntityIdentifier(): void public function testCanFindFederatedByEntityIdentifier(): void { - $client = self::getClient(id: 'clientId', entityId: 'entityId', isFederated: true, federationJwks: []); + $client = self::getClient(id: 'clientId', entityId: 'entityId', federationJwks: []); $this->repository->add($client); $this->clientEntityFactoryMock->expects($this->once())->method('fromState')->willReturn($client); @@ -436,7 +436,7 @@ public function testCanNotFindFederatedByEntityIdentifierIfMissingFederationAttr public function testCanFindAllFederated(): void { - $client = self::getClient(id: 'clientId', entityId: 'entityId', isFederated: true, federationJwks: []); + $client = self::getClient(id: 'clientId', entityId: 'entityId', federationJwks: []); $this->repository->add($client); $this->clientEntityFactoryMock->expects($this->atLeastOnce())->method('fromState')->willReturn($client); @@ -469,7 +469,6 @@ public static function getClient( bool $confidential = false, ?string $owner = null, ?string $entityId = null, - bool $isFederated = false, ?array $federationJwks = null, ): ClientEntityInterface { return new ClientEntity( @@ -485,7 +484,6 @@ public static function getClient( owner: $owner, entityIdentifier: $entityId, federationJwks: $federationJwks, - isFederated: $isFederated, ); } } diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index 4479ddf3..d991c6a1 100644 --- a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php @@ -17,6 +17,7 @@ use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; /** @@ -35,6 +36,7 @@ class AuthCodeGrantTest extends TestCase protected Stub $authCodeEntityFactoryStub; protected Stub $refreshTokenIssuerStub; protected Stub $helpersStub; + protected Stub $loggerMock; /** * @throws \Exception @@ -52,6 +54,7 @@ protected function setUp(): void $this->authCodeEntityFactoryStub = $this->createStub(AuthcodeEntityFactory::class); $this->refreshTokenIssuerStub = $this->createStub(RefreshTokenIssuer::class); $this->helpersStub = $this->createStub(Helpers::class); + $this->loggerMock = $this->createMock(LoggerService::class); } /** @@ -72,6 +75,8 @@ public function testCanCreateInstance(): void $this->authCodeEntityFactoryStub, $this->refreshTokenIssuerStub, $this->helpersStub, + $this->loggerMock, + $this->moduleConfigStub, ), ); } diff --git a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php similarity index 92% rename from tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php rename to tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php index 9556d8d3..abf0eb9e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ClientIdRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ClientRuleTest.php @@ -15,7 +15,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\FederationCache; use SimpleSAML\Module\oidc\Utils\FederationParticipationValidator; @@ -24,9 +24,9 @@ use SimpleSAML\OpenID\Federation; /** - * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule + * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule */ -class ClientIdRuleTest extends TestCase +class ClientRuleTest extends TestCase { protected Stub $clientEntityStub; protected Stub $clientRepositoryStub; @@ -62,9 +62,9 @@ protected function setUp(): void $this->federationParticipationValidatorStub = $this->createStub(FederationParticipationValidator::class); } - protected function sut(): ClientIdRule + protected function sut(): ClientRule { - return new ClientIdRule( + return new ClientRule( $this->requestParamsResolverStub, $this->helpersStub, $this->clientRepositoryStub, @@ -73,13 +73,14 @@ protected function sut(): ClientIdRule $this->federationStub, $this->jwksResolverStub, $this->federationParticipationValidatorStub, + $this->loggerServiceStub, $this->federationCacheStub, ); } public function testConstruct(): void { - $this->assertInstanceOf(ClientIdRule::class, $this->sut()); + $this->assertInstanceOf(ClientRule::class, $this->sut()); } public function testCheckRuleEmptyClientIdThrows(): void @@ -111,7 +112,7 @@ public function testCheckRuleInvalidClientThrows(): void */ public function testCheckRuleForValidClientId(): void { - $this->requestParamsResolverStub->method('getBasedOnAllowedMethods')->willReturn('123'); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('123'); $this->clientRepositoryStub->method('getClientEntity')->willReturn($this->clientEntityStub); $result = $this->sut()->checkRule( diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php index 4d0217d7..f01343ca 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeMethodRuleTest.php @@ -16,8 +16,8 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -43,7 +43,7 @@ protected function setUp(): void { $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->resultBagStub = $this->createStub(ResultBagInterface::class); - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php index 671badb7..1755ea6f 100644 --- a/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/CodeChallengeRuleTest.php @@ -15,9 +15,9 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; @@ -47,12 +47,12 @@ protected function setUp(): void { $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->resultBagStub = $this->createStub(ResultBagInterface::class); - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->clientStub = $this->createStub(ClientEntityInterface::class); - $this->clientIdResult = new Result(ClientIdRule::class, $this->clientStub); + $this->clientIdResult = new Result(ClientRule::class, $this->clientStub); $this->helpers = new Helpers(); } diff --git a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php index bee541f9..d8137a8e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/IdTokenHintRuleTest.php @@ -4,15 +4,11 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\UnencryptedToken; use League\OAuth2\Server\CryptKey; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Module\oidc\Factories\CryptKeyFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; @@ -20,6 +16,10 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\Jwks; use Throwable; /** @@ -30,7 +30,6 @@ class IdTokenHintRuleTest extends TestCase protected Stub $requestStub; protected Stub $resultBagStub; protected Stub $moduleConfigStub; - protected Stub $cryptKeyFactoryStub; protected static string $certFolder; protected static string $privateKeyPath; @@ -39,20 +38,14 @@ class IdTokenHintRuleTest extends TestCase protected static CryptKey $publicKey; protected static string $issuer = 'https://example.org'; - private Configuration $jwtConfig; protected Stub $loggerServiceStub; protected Stub $requestParamsResolverStub; protected Helpers $helpers; - - public static function setUpBeforeClass(): void - { - self::$certFolder = dirname(__DIR__, 6) . '/docker/ssp/'; - self::$privateKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - self::$publicKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME; - self::$privateKey = new CryptKey(self::$privateKeyPath, null, false); - self::$publicKey = new CryptKey(self::$publicKeyPath, null, false); - } + protected MockObject $jwksMock; + protected MockObject $coreMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $idTokenMock; /** * @throws \ReflectionException @@ -65,42 +58,40 @@ protected function setUp(): void $this->resultBagStub = $this->createStub(ResultBagInterface::class); $this->moduleConfigStub = $this->createStub(ModuleConfig::class); - $this->moduleConfigStub->method('getProtocolSigner')->willReturn(new Sha256()); $this->moduleConfigStub->method('getIssuer')->willReturn(self::$issuer); - $this->cryptKeyFactoryStub = $this->createStub(CryptKeyFactory::class); - $this->cryptKeyFactoryStub->method('buildPrivateKey')->willReturn(self::$privateKey); - $this->cryptKeyFactoryStub->method('buildPublicKey')->willReturn(self::$publicKey); - - $this->jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::plainText(self::$privateKey->getKeyContents()), - InMemory::plainText(self::$publicKey->getKeyContents()), - ); - $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpers = new Helpers(); + + $this->jwksMock = $this->createMock(Jwks::class); + $this->coreMock = $this->createMock(Core::class); + $this->idTokenFactoryMock = $this->createMock(IdTokenFactory::class); + $this->idTokenMock = $this->createMock(IdToken::class); + $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); } protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, ?ModuleConfig $moduleConfig = null, - ?CryptKeyFactory $cryptKeyFactory = null, + ?Jwks $jwks = null, + ?Core $core = null, ): IdTokenHintRule { $requestParamsResolver ??= $this->requestParamsResolverStub; $helpers ??= $this->helpers; $moduleConfig ??= $this->moduleConfigStub; - $cryptKeyFactory ??= $this->cryptKeyFactoryStub; + $jwks ??= $this->jwksMock; + $core ??= $this->coreMock; return new IdTokenHintRule( $requestParamsResolver, $helpers, $moduleConfig, - $cryptKeyFactory, + $jwks, + $core, ); } @@ -139,14 +130,14 @@ public function testCheckRuleThrowsForMalformedIdToken(): void */ public function testCheckRuleThrowsForIdTokenWithInvalidSignature(): void { - $invalidSignatureJwt = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnIiwic3ViIjo' . - 'iMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.JGJ_KSiXiRsgVc5nYFTSqbaeeM3UA5DGnOTaz3' . - 'UqbyHM0ogO3rq_-8FwLRzGk-7942U6rQ1ARziLsYYsUtH7yaUTWi6xSvh_mJQuF8hl_X3OghJWeXWms42OjAkHXtB-H7LQ_bEQNV' . - 'nF8XYLsq06MoHeHxAnDkVpVcZyDrmhauAqV1PTWsC9FMMKaxfoVsIbeQ0-PV_gAgzSK5-T0bliXPUdWFjvPXJ775jqqy4ZyNJYh' . - '1_rZ1WyOrJu7AHkT9R4FNQNCw40BRzfI3S_OYBNirKAh5G0sctNwEEaJL_a3lEwKYSC-d_sZ6WBvFP8B138b7T6nPzI71tvfXE' . - 'Ru7Q7rA'; - - $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn($invalidSignatureJwt); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') + ->willReturn('invalid-it-token'); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenMock->method('verifyWithKeySet') + ->willThrowException(new \Exception('invalid-signature')); + $this->idTokenFactoryMock->method('fromToken') + ->with('invalid-it-token') + ->willReturn($this->idTokenMock); $this->expectException(Throwable::class); $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub); } @@ -158,12 +149,13 @@ public function testCheckRuleThrowsForIdTokenWithInvalidSignature(): void public function testCheckRuleThrowsForIdTokenWithInvalidIssuer(): void { $this->requestStub->method('getMethod')->willReturn('GET'); + $this->idTokenMock->method('getIssuer')->willReturn('invalid'); + $this->idTokenFactoryMock->method('fromToken') + ->with('id-token') + ->willReturn($this->idTokenMock); - $invalidIssuerJwt = $this->jwtConfig->builder()->issuedBy('invalid')->getToken( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::plainText(self::$privateKey->getKeyContents()), - )->toString(); - $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn($invalidIssuerJwt); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') + ->willReturn('id-token'); $this->expectException(Throwable::class); $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub); } @@ -175,15 +167,14 @@ public function testCheckRuleThrowsForIdTokenWithInvalidIssuer(): void */ public function testCheckRulePassesForValidIdToken(): void { - $idToken = $this->jwtConfig->builder()->issuedBy(self::$issuer)->getToken( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::plainText(self::$privateKey->getKeyContents()), - )->toString(); - - $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn($idToken); + $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') + ->willReturn('id-token'); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenFactoryMock->method('fromToken') + ->willReturn($this->idTokenMock); $result = $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub) ?? new Result(IdTokenHintRule::class); - $this->assertInstanceOf(UnencryptedToken::class, $result->getValue()); + $this->assertInstanceOf(IdToken::class, $result->getValue()); } } diff --git a/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php index b14a3a57..7bf7d279 100644 --- a/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/PostLogoutRedirectUriRuleTest.php @@ -8,6 +8,7 @@ use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use League\OAuth2\Server\CryptKey; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -22,6 +23,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core\IdToken; use Throwable; /** @@ -47,6 +49,7 @@ class PostLogoutRedirectUriRuleTest extends TestCase protected Stub $loggerServiceStub; protected Stub $requestParamsResolverStub; protected Helpers $helpers; + protected MockObject $idTokenMock; public static function setUpBeforeClass(): void { @@ -78,6 +81,8 @@ protected function setUp(): void $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpers = new Helpers(); + + $this->idTokenMock = $this->createMock(IdToken::class); } protected function sut( @@ -183,13 +188,8 @@ public function testCheckRuleThrowsWhenPostLogoutRegisteredUriNotRegistered(): v $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') ->willReturn(self::$postLogoutRedirectUri); - $jwt = $this->jwtConfig->builder() - ->issuedBy(self::$issuer) - ->permittedFor('client-id') - ->getToken( - new Sha256(), - InMemory::plainText(self::$privateKey->getKeyContents()), - ); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenMock->method('getAudience')->willReturn(['client-id']); $this->clientStub->method('getPostLogoutRedirectUri')->willReturn([ 'https://some-other-uri', @@ -199,7 +199,7 @@ public function testCheckRuleThrowsWhenPostLogoutRegisteredUriNotRegistered(): v $this->resultBagStub->method('getOrFail')->willReturnOnConsecutiveCalls( new Result(StateRule::class), - new Result(IdTokenHintRule::class, $jwt), + new Result(IdTokenHintRule::class, $this->idTokenMock), ); $this->expectException(Throwable::class); @@ -217,13 +217,8 @@ public function testCheckRuleReturnsForRegisteredPostLogoutRedirectUri(): void $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods') ->willReturn(self::$postLogoutRedirectUri); - $jwt = $this->jwtConfig->builder() - ->issuedBy(self::$issuer) - ->permittedFor('client-id') - ->getToken( - new Sha256(), - InMemory::plainText(self::$privateKey->getKeyContents()), - ); + $this->idTokenMock->method('getIssuer')->willReturn(self::$issuer); + $this->idTokenMock->method('getAudience')->willReturn(['client-id']); $this->clientStub->method('getPostLogoutRedirectUri')->willReturn([ self::$postLogoutRedirectUri, @@ -233,7 +228,7 @@ public function testCheckRuleReturnsForRegisteredPostLogoutRedirectUri(): void $this->resultBagStub->method('getOrFail')->willReturnOnConsecutiveCalls( new Result(StateRule::class), - new Result(IdTokenHintRule::class, $jwt), + new Result(IdTokenHintRule::class, $this->idTokenMock), ); $result = $this->sut()->checkRule($this->requestStub, $this->resultBagStub, $this->loggerServiceStub) ?? diff --git a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php index 3650edde..33a31f4e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RedirectUriRuleTest.php @@ -10,21 +10,22 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; /** - * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule + * @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule */ class RedirectUriRuleTest extends TestCase { - protected RedirectUriRule $rule; + protected ClientRedirectUriRule $rule; protected ResultBag $resultBag; protected Stub $clientStub; protected Stub $requestStub; @@ -32,6 +33,7 @@ class RedirectUriRuleTest extends TestCase protected Stub $loggerServiceStub; protected Stub $requestParamsResolverStub; protected Helpers $helpers; + protected Stub $moduleConfigStub; /** @@ -45,18 +47,22 @@ protected function setUp(): void $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->helpers = new Helpers(); + $this->moduleConfigStub = $this->createStub(ModuleConfig::class); } protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, - ): RedirectUriRule { + ?ModuleConfig $moduleConfig = null, + ): ClientRedirectUriRule { $requestParamsResolver ??= $this->requestParamsResolverStub; $helpers ??= $this->helpers; + $moduleConfig ??= $this->moduleConfigStub; - return new RedirectUriRule( + return new ClientRedirectUriRule( $requestParamsResolver, $helpers, + $moduleConfig, ); } @@ -76,7 +82,7 @@ public function testCheckRuleClientIdDependency(): void */ public function testCheckRuleWithInvalidClientDependancy(): void { - $this->resultBag->add(new Result(ClientIdRule::class, 'invalid')); + $this->resultBag->add(new Result(ClientRule::class, 'invalid')); $this->expectException(LogicException::class); $this->sut()->checkRule($this->requestStub, $this->resultBag, $this->loggerServiceStub); } @@ -112,7 +118,7 @@ public function testCheckRuleDifferentClientRedirectUriArrayThrows(): void $this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('invalid'); $this->clientStub->method('getRedirectUri')->willReturn([$this->redirectUri]); - $this->resultBag->add(new Result(ClientIdRule::class, $this->clientStub)); + $this->resultBag->add(new Result(ClientRule::class, $this->clientStub)); $this->expectException(OidcServerException::class); $this->sut()->checkRule($this->requestStub, $this->resultBag, $this->loggerServiceStub); @@ -137,7 +143,7 @@ public function testCheckRuleWithValidRedirectUri(): void protected function prepareValidResultBag(): ResultBag { $this->clientStub->method('getRedirectUri')->willReturn($this->redirectUri); - $this->resultBag->add(new Result(ClientIdRule::class, $this->clientStub)); + $this->resultBag->add(new Result(ClientRule::class, $this->clientStub)); return $this->resultBag; } } diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 69c9392c..45861adb 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -14,8 +14,8 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\JwksResolver; @@ -39,8 +39,8 @@ protected function setUp(): void $this->clientStub = $this->createStub(ClientEntityInterface::class); $this->resultBagStub = $this->createStub(ResultBag::class); $this->resultBagStub->method('getOrFail')->willReturnMap([ - [ClientIdRule::class, new Result(ClientIdRule::class, $this->clientStub)], - [RedirectUriRule::class, new Result(RedirectUriRule::class, 'https://example.com/redirect')], + [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], + [ClientRedirectUriRule::class, new Result(ClientRedirectUriRule::class, 'https://example.com/redirect')], ]); $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); $this->requestObjectMock = $this->createMock(RequestObject::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php index 20d165c1..a17f677d 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestedClaimsRuleTest.php @@ -13,7 +13,7 @@ use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientIdRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; @@ -44,7 +44,7 @@ protected function setUp(): void $this->clientStub = $this->createStub(ClientEntityInterface::class); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->clientStub->method('getScopes')->willReturn(['openid', 'profile', 'email']); - $this->resultBag->add(new Result(ClientIdRule::class, $this->clientStub)); + $this->resultBag->add(new Result(ClientRule::class, $this->clientStub)); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->claimSetEntityFactoryStub = $this->createStub(ClaimSetEntityFactory::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php index 8a6d377d..6bfbd34e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequiredNonceRuleTest.php @@ -12,7 +12,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -43,7 +43,7 @@ class RequiredNonceRuleTest extends TestCase */ protected function setUp(): void { - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->requestStub = $this->createStub(ServerRequestInterface::class); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php index 9f6dcedf..05668b79 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequiredOpenIdScopeRuleTest.php @@ -13,7 +13,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; @@ -42,7 +42,7 @@ class RequiredOpenIdScopeRuleTest extends TestCase */ protected function setUp(): void { - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->scopeEntities = [ diff --git a/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php index c40143b9..1916686e 100644 --- a/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/ScopeRuleTest.php @@ -17,7 +17,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Services\LoggerService; @@ -55,7 +55,7 @@ protected function setUp(): void { $this->scopeRepositoryStub = $this->createStub(ScopeRepositoryInterface::class); $this->resultBagStub = $this->createStub(ResultBagInterface::class); - $this->redirectUriResult = new Result(RedirectUriRule::class, 'https://some-uri.org'); + $this->redirectUriResult = new Result(ClientRedirectUriRule::class, 'https://some-uri.org'); $this->stateResult = new Result(StateRule::class, '123'); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->scopeEntities = [ diff --git a/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php b/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php index 93fd8283..9f5edd34 100644 --- a/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php +++ b/tests/unit/src/Server/RequestTypes/LogoutRequestTest.php @@ -4,10 +4,10 @@ namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestTypes; -use Lcobucci\JWT\UnencryptedToken; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; +use SimpleSAML\OpenID\Core\IdToken; /** * @covers \SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest @@ -25,7 +25,7 @@ class LogoutRequestTest extends TestCase */ protected function setUp(): void { - $this->idTokenHintStub = $this->createStub(UnencryptedToken::class); + $this->idTokenHintStub = $this->createStub(IdToken::class); } public function testConstructWithoutParams(): void diff --git a/tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php similarity index 66% rename from tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php rename to tests/unit/src/Server/ResponseTypes/TokenResponseTest.php index e57d2891..cf9a1b24 100644 --- a/tests/unit/src/Server/ResponseTypes/IdTokenResponseTest.php +++ b/tests/unit/src/Server/ResponseTypes/TokenResponseTest.php @@ -7,18 +7,6 @@ use DateTimeImmutable; use Exception; use Laminas\Diactoros\Response; -use Lcobucci\Clock\SystemClock; -use Lcobucci\JWT\Encoding\JoseEncoder; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Token\Parser; -use Lcobucci\JWT\Validation\Constraint\IdentifiedBy; -use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\PermittedFor; -use Lcobucci\JWT\Validation\Constraint\RelatedTo; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Constraint\StrictValidAt; -use Lcobucci\JWT\Validation\Validator; use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; @@ -31,15 +19,21 @@ use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\Interfaces\IdentityProviderInterface; -use SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse; +use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\IdTokenBuilder; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** - * @covers \SimpleSAML\Module\oidc\Server\ResponseTypes\IdTokenResponse + * @covers \SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse */ -class IdTokenResponseTest extends TestCase +class TokenResponseTest extends TestCase { final public const TOKEN_ID = 'tokenId'; final public const ISSUER = 'someIssuer'; @@ -59,6 +53,12 @@ class IdTokenResponseTest extends TestCase protected CryptKey $privateKey; protected IdTokenBuilder $idTokenBuilder; protected Stub $claimSetEntityFactoryStub; + protected MockObject $loggerMock; + protected MockObject $coreMock; + protected MockObject $protocolSignatureKeyPairBagMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $idTokenMock; + protected MockObject $signatureKeyPairMock; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -98,15 +98,7 @@ protected function setUp(): void ->willReturn($this->userEntity); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->moduleConfigMock->method('getProtocolSigner')->willReturn(new Sha256()); $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); - $this->moduleConfigMock->method('getProtocolCertPath') - ->willReturn($this->certFolder . '/oidc_module.crt'); - $this->moduleConfigMock->method('getProtocolPrivateKeyPath') - ->willReturn($this->certFolder . '/oidc_module.key'); - $this->moduleConfigMock - ->expects($this->atLeast(1)) - ->method('getProtocolPrivateKeyPassPhrase'); $this->sspConfigurationMock = $this->createMock(Configuration::class); $this->moduleConfigMock->method('config') ->willReturn($this->sspConfigurationMock); @@ -115,32 +107,53 @@ protected function setUp(): void $this->claimSetEntityFactoryStub = $this->createStub(ClaimSetEntityFactory::class); + $this->idTokenFactoryMock = $this->createMock(IdTokenFactory::class); + + $this->coreMock = $this->createMock(Core::class); + $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); + $this->idTokenBuilder = new IdTokenBuilder( - new JsonWebTokenBuilderService($this->moduleConfigMock), new ClaimTranslatorExtractor(self::USER_ID_ATTR, $this->claimSetEntityFactoryStub), + $this->coreMock, + $this->moduleConfigMock, ); + + $this->loggerMock = $this->createMock(LoggerService::class); + + $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + $this->protocolSignatureKeyPairBagMock->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); + + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); + + $this->idTokenMock = $this->createMock(IdToken::class); } - protected function prepareMockedInstance(): IdTokenResponse + protected function prepareMockedInstance(): TokenResponse { - $idTokenResponse = new IdTokenResponse( + $tokenResponse = new TokenResponse( $this->identityProviderMock, $this->idTokenBuilder, $this->privateKey, + $this->loggerMock, ); - $idTokenResponse->setNonce(null); - $idTokenResponse->setAuthTime(null); - $idTokenResponse->setAcr(null); - $idTokenResponse->setSessionId(null); + $tokenResponse->setNonce(null); + $tokenResponse->setAuthTime(null); + $tokenResponse->setAcr(null); + $tokenResponse->setSessionId(null); - return $idTokenResponse; + return $tokenResponse; } public function testItIsInitializable(): void { $this->assertInstanceOf( - IdTokenResponse::class, + TokenResponse::class, $this->prepareMockedInstance(), ); } @@ -152,6 +165,11 @@ public function testItCanGenerateResponse(): void { $this->accessTokenEntityMock->method('getRequestedClaims')->willReturn([]); $this->accessTokenEntityMock->method('getScopes')->willReturn($this->scopes); + $this->idTokenFactoryMock->method('fromData') + ->willReturn($this->idTokenMock); + $this->idTokenMock->expects($this->once()) + ->method('getToken') + ->willReturn('token'); $idTokenResponse = $this->prepareMockedInstance(); $idTokenResponse->setAccessToken($this->accessTokenEntityMock); $response = $idTokenResponse->generateHttpResponse(new Response()); @@ -186,6 +204,11 @@ public function testItCanGenerateResponseWithIndividualRequestedClaims(): void $this->accessTokenEntityMock->method('getScopes')->willReturn( [new ScopeEntity('openid')], ); + $this->idTokenFactoryMock->method('fromData') + ->willReturn($this->idTokenMock); + $this->idTokenMock->expects($this->once()) + ->method('getToken') + ->willReturn('token'); $idTokenResponse->setAccessToken($this->accessTokenEntityMock); $response = $idTokenResponse->generateHttpResponse(new Response()); @@ -229,61 +252,6 @@ protected function shouldHaveValidIdToken(string $body, $expectedClaims = []): b ); } - // Check ID token - $validator = new Validator(); - /** @var Plain $token */ - $token = (new Parser(new JoseEncoder()))->parse($result['id_token']); - - $validator->assert( - $token, - new IdentifiedBy(self::TOKEN_ID), - new IssuedBy(self::ISSUER), - new PermittedFor(self::CLIENT_ID), - new RelatedTo(self::SUBJECT), - new StrictValidAt(SystemClock::fromUTC()), - new SignedWith( - new Sha256(), - InMemory::plainText(file_get_contents($this->certFolder . '/oidc_module.crt')), - ), - ); - - if ($token->headers()->get('kid') !== self::KEY_ID) { - throw new Exception( - 'Wrong key id. Expected ' . self::KEY_ID . ' was ' . $token->headers()->get('kid'), - ); - } - $expectedClaimsKeys = array_keys($expectedClaims); - $expectedClaimsKeys = ['iss', 'iat', 'jti', 'aud', 'nbf', 'exp', 'sub', 'at_hash', ...$expectedClaimsKeys]; - $claims = array_keys($token->claims()->all()); - if ($claims !== $expectedClaimsKeys) { - throw new Exception( - 'missing expected claim. Got ' . var_export($claims, true) - . ' need ' . var_export($expectedClaimsKeys, true), - ); - } - foreach ($expectedClaims as $claim => $value) { - $valFromToken = $token->claims()->get($claim); - if ($value !== $valFromToken) { - throw new Exception( - 'Expected claim value ' . var_export($value, true) - . ' got ' . var_export($valFromToken, true), - ); - } - } - - $dateWithNoMicroseconds = ['nbf', 'exp', 'iat']; - foreach ($dateWithNoMicroseconds as $key) { - /** - * @var DateTimeImmutable $val - */ - $val = $token->claims()->get($key); - //Get format representing microseconds - $val = $val->format('u'); - if ($val !== '000000') { - throw new Exception("Value for '$key' has microseconds. micros '$val'"); - } - } - return true; } } diff --git a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php index daa8bf19..72f9aa55 100644 --- a/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php +++ b/tests/unit/src/Server/Validators/BearerTokenValidatorTest.php @@ -6,41 +6,42 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\StreamFactory; -use League\OAuth2\Server\CryptKey; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; 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; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; - -use function chmod; +use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\OpenID\Exceptions\JwsException; +use SimpleSAML\OpenID\Jwks; +use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory; +use SimpleSAML\OpenID\Jws\ParsedJws; /** * @covers \SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator */ class BearerTokenValidatorTest extends TestCase { - protected BearerTokenValidator $bearerTokenValidator; - protected static string $privateKeyPath; - protected static CryptKey $privateCryptKey; - protected static ?string $privateKey = null; - protected static string $publicKey; - protected static CryptKey $publicCryptKey; - protected static string $publicKeyPath; protected MockObject $accessTokenRepositoryMock; - protected static array $accessTokenState; - protected static AccessTokenEntity $accessTokenEntity; - protected static string $accessToken; - protected static ClientEntityInterface $clientEntity; + protected array $accessTokenState; + protected AccessTokenEntity $accessTokenEntityMock; + protected string $accessToken; + protected ClientEntityInterface $clientEntityMock; protected ServerRequestInterface $serverRequest; protected MockObject $publicKeyMock; + protected MockObject $moduleConfigMock; + protected MockObject $jwsMock; + protected MockObject $jwksMock; + protected MockObject $loggerServiceMock; + protected MockObject $parsedJwsFactoryMock; + protected MockObject $parsedJwsMock; + protected string $clientId; /** * @throws \Exception @@ -49,93 +50,68 @@ 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('getIssuer')->willReturn('issuer123'); - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException - */ - public static function setUpBeforeClass(): void - { - $tempDir = sys_get_temp_dir(); + $this->jwsMock = $this->createMock(Jws::class); + $this->jwksMock = $this->createMock(Jwks::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); - // Plant certdir config for JsonWebTokenBuilderService (since we don't inject it) - $config = [ - 'certdir' => $tempDir, - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - self::$publicKeyPath = $tempDir . '/oidc_module.crt'; - self::$privateKeyPath = $tempDir . '/oidc_module.key'; - - $pkGenerate = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - // get the private key - openssl_pkey_export($pkGenerate, self::$privateKey); - - // get the public key - self::$publicKey = openssl_pkey_get_details($pkGenerate)['key']; - - file_put_contents(self::$publicKeyPath, self::$publicKey); - file_put_contents(self::$privateKeyPath, self::$privateKey); - chmod(self::$publicKeyPath, 0600); - chmod(self::$privateKeyPath, 0600); - - self::$publicCryptKey = new CryptKey(self::$publicKeyPath); - self::$privateCryptKey = new CryptKey(self::$privateKeyPath); - - self::$clientEntity = new ClientEntity( - 'client1123', - 'secret1', - 'name1', - 'desc1', - ['redirect-uri'], - ['openid'], - true, - ); + $this->clientEntityMock = $this->createMock(ClientEntity::class); + $this->clientId = 'clientId'; + $this->clientEntityMock->method('getIdentifier')->willReturn($this->clientId); - self::$accessTokenState = [ + $this->accessTokenState = [ 'id' => 'accessToken123', + 'iss' => 'issuer123', 'scopes' => '{"openid":"openid","profile":"profile"}', 'expires_at' => date('Y-m-d H:i:s', time() + 60), 'user_id' => 'user123', - 'client_id' => self::$clientEntity->getIdentifier(), + 'client_id' => $this->clientId, 'is_revoked' => false, 'auth_code_id' => 'authCode123', ]; - self::$accessTokenEntity = new AccessTokenEntity( - 'accessToken123', - self::$clientEntity, - [new ScopeEntity('openid'), new ScopeEntity('profile')], - (new \DateTimeImmutable())->add(new \DateInterval('PT60S')), - self::$privateCryptKey, - new JsonWebTokenBuilderService(), - 'user123', - 'authCode123', - ); + $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); + + $this->accessToken = 'token'; - self::$accessToken = (string) self::$accessTokenEntity; + $this->parsedJwsFactoryMock = $this->createMock(ParsedJwsFactory::class); + $this->jwsMock->method('parsedJwsFactory')->willReturn($this->parsedJwsFactoryMock); + + $this->parsedJwsMock = $this->createMock(ParsedJws::class); + $this->parsedJwsMock->method('getJwtId')->willReturn('accessToken123'); + $this->parsedJwsMock->method('getAudience')->willReturn([$this->clientId]); + $this->parsedJwsMock->method('getIssuer')->willReturn('issuer123'); } - /** - * @return void - */ - public static function tearDownAfterClass(): void - { - unlink(self::$publicKeyPath); - unlink(self::$privateKeyPath); + protected function sut( + ?AccessTokenRepository $accessTokenRepository = null, + ?ModuleConfig $moduleConfig = null, + ?Jws $jws = null, + ?Jwks $jwks = null, + ?LoggerService $loggerService = null, + ): BearerTokenValidator { + $accessTokenRepository ??= $this->accessTokenRepositoryMock; + $moduleConfig ??= $this->moduleConfigMock; + $jws ??= $this->jwsMock; + $jwks ??= $this->jwksMock; + $loggerService ??= $this->loggerServiceMock; + + return new BearerTokenValidator( + $accessTokenRepository, + $moduleConfig, + $jws, + $jwks, + $loggerService, + ); } public function testValidatorThrowsForNonExistentAccessToken() { $this->expectException(OidcServerException::class); - $this->bearerTokenValidator->validateAuthorization($this->serverRequest); + $this->sut()->validateAuthorization($this->serverRequest); } /** @@ -143,12 +119,16 @@ public function testValidatorThrowsForNonExistentAccessToken() */ public function testValidatesForAuthorizationHeader() { - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . self::$accessToken); + $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $this->accessToken); + + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($this->parsedJwsMock); - $validatedServerRequest = $this->bearerTokenValidator->validateAuthorization($serverRequest); + $validatedServerRequest = $this->sut()->validateAuthorization($serverRequest); $this->assertSame( - self::$accessTokenState['id'], + $this->accessTokenState['id'], $validatedServerRequest->getAttribute('oauth_access_token_id'), ); } @@ -158,7 +138,7 @@ public function testValidatesForAuthorizationHeader() */ public function testValidatesForPostBodyParam() { - $bodyArray = ['access_token' => self::$accessToken]; + $bodyArray = ['access_token' => $this->accessToken]; $tempStream = (new StreamFactory())->createStream(http_build_query($bodyArray)); $serverRequest = $this->serverRequest @@ -167,10 +147,14 @@ public function testValidatesForPostBodyParam() ->withBody($tempStream) ->withParsedBody($bodyArray); - $validatedServerRequest = $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($this->parsedJwsMock); + + $validatedServerRequest = $this->sut()->validateAuthorization($serverRequest); $this->assertSame( - self::$accessTokenState['id'], + $this->accessTokenState['id'], $validatedServerRequest->getAttribute('oauth_access_token_id'), ); } @@ -179,35 +163,13 @@ public function testThrowsForUnparsableAccessToken() { $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . 'invalid'); - $this->expectException(OidcServerException::class); - - $this->bearerTokenValidator->validateAuthorization($serverRequest); - } - - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @throws \JsonException - */ - public function testThrowsForExpiredAccessToken() - { - $accessTokenEntity = new AccessTokenEntity( - 'accessToken123', - self::$clientEntity, - [new ScopeEntity('openid'), new ScopeEntity('profile')], - (new \DateTimeImmutable())->sub(new \DateInterval('PT60S')), - self::$privateCryptKey, - new JsonWebTokenBuilderService(), - 'user123', - 'authCode123', - ); - - $accessToken = (string) $accessTokenEntity; - - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $accessToken); + $this->parsedJwsFactoryMock->method('fromToken') + ->with('invalid') + ->willThrowException(new JwsException('Unparsable')); $this->expectException(OidcServerException::class); - $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->sut()->validateAuthorization($serverRequest); } /** @@ -218,16 +180,15 @@ public function testThrowsForRevokedAccessToken() { $this->accessTokenRepositoryMock->method('isAccessTokenRevoked')->willReturn(true); - $bearerTokenValidator = new BearerTokenValidator( - $this->accessTokenRepositoryMock, - self::$publicCryptKey, - ); + $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $this->accessToken); - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . self::$accessToken); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($this->parsedJwsMock); $this->expectException(OidcServerException::class); - $bearerTokenValidator->validateAuthorization($serverRequest); + $this->sut()->validateAuthorization($serverRequest); } /** @@ -236,23 +197,15 @@ public function testThrowsForRevokedAccessToken() */ public function testThrowsForEmptyAccessTokenJti() { - $accessTokenEntity = new AccessTokenEntity( - '', - self::$clientEntity, - [new ScopeEntity('openid'), new ScopeEntity('profile')], - (new \DateTimeImmutable())->add(new \DateInterval('PT60S')), - self::$privateCryptKey, - new JsonWebTokenBuilderService(), - 'user123', - 'authCode123', - ); - - $accessToken = (string) $accessTokenEntity; + $accessToken = $this->createMock(ParsedJws::class); + $this->parsedJwsFactoryMock->method('fromToken') + ->with($this->accessToken) + ->willReturn($accessToken); - $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $accessToken); + $serverRequest = $this->serverRequest->withAddedHeader('Authorization', 'Bearer ' . $this->accessToken); $this->expectException(OidcServerException::class); - $this->bearerTokenValidator->validateAuthorization($serverRequest); + $this->sut()->validateAuthorization($serverRequest); } } diff --git a/tests/unit/src/Services/AuthenticationServiceTest.php b/tests/unit/src/Services/AuthenticationServiceTest.php index deae3f3d..d03abdcd 100644 --- a/tests/unit/src/Services/AuthenticationServiceTest.php +++ b/tests/unit/src/Services/AuthenticationServiceTest.php @@ -148,7 +148,6 @@ public function mock(): AuthenticationService $this->moduleConfigMock, $this->processingChainFactoryMock, $this->stateServiceMock, - $this->helpersMock, $this->requestParamsResolverMock, $this->userEntityFactoryMock, ], @@ -332,7 +331,7 @@ public function testItAuthenticates(): void { $this->authSimpleMock->expects($this->once())->method('login')->with([]); - $this->mock()->authenticate($this->clientEntityMock); + $this->mock()->authenticateForClient($this->clientEntityMock); } /** @@ -399,7 +398,6 @@ public function testItProcessesRequest(bool $isAuthnPer): void $this->moduleConfigMock, $this->processingChainFactoryMock, $this->stateServiceMock, - $this->helpersMock, $this->requestParamsResolverMock, $this->userEntityFactoryMock, ]) @@ -408,7 +406,7 @@ public function testItProcessesRequest(bool $isAuthnPer): void $this->moduleConfigMock->method('getAuthProcFilters')->willReturn([]); $this->authSimpleMock->expects($this->once())->method('isAuthenticated')->willReturn(true); - $this->clientHelperMock->method('getFromRequest')->willReturn($this->clientEntityMock); + $this->authorizationRequestMock->method('getClient')->willReturn($this->clientEntityMock); $authenticationServiceMock->method('prepareStateArray')->with( $this->authSimpleMock, $this->clientEntityMock, @@ -490,7 +488,6 @@ public function testItRunAuthProcs(): void $this->moduleConfigMock, $this->processingChainFactoryMock, $this->stateServiceMock, - $this->helpersMock, $this->requestParamsResolverMock, $this->userEntityFactoryMock, ) extends AuthenticationService { diff --git a/tests/unit/src/Services/IdTokenBuilderTest.php b/tests/unit/src/Services/IdTokenBuilderTest.php index 5afeb866..a36c364b 100644 --- a/tests/unit/src/Services/IdTokenBuilderTest.php +++ b/tests/unit/src/Services/IdTokenBuilderTest.php @@ -4,15 +4,199 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; +use DateTimeImmutable; +use League\OAuth2\Server\Entities\UserEntityInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; +use SimpleSAML\Module\oidc\Entities\ClientEntity; +use SimpleSAML\Module\oidc\Entities\ScopeEntity; +use SimpleSAML\Module\oidc\Entities\UserEntity; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Services\IdTokenBuilder; +use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\IdToken; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; -/** - * @covers \SimpleSAML\Module\oidc\Services\IdTokenBuilder - */ +#[CoversClass(IdTokenBuilder::class)] class IdTokenBuilderTest extends TestCase { - public function testIncomplete(): never + protected MockObject $claimTranslatorExtractorMock; + protected MockObject $coreMock; + protected MockObject $moduleConfigMock; + protected MockObject $protocolSignatureKeyBagMock; + protected MockObject $protocolSignatureKeyPairMock; + protected MockObject $idTokenFactoryMock; + protected MockObject $userEntityMock; + protected MockObject $accessTokenEntityMock; + protected MockObject $clientEntityMock; + protected MockObject $accessTokenExpiryDateTimeMock; + protected MockObject $scopeEntityMock; + + protected function setUp(): void + { + $this->claimTranslatorExtractorMock = $this->createMock(ClaimTranslatorExtractor::class); + $this->coreMock = $this->createMock(Core::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->protocolSignatureKeyBagMock = $this->createMock(SignatureKeyPairBag::class); + + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyBagMock); + + $this->protocolSignatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->protocolSignatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->protocolSignatureKeyBagMock->method('getFirstOrFail') + ->willReturn($this->protocolSignatureKeyPairMock); + + + $this->idTokenFactoryMock = $this->createMock(IdTokenFactory::class); + $this->coreMock->method('idTokenFactory')->willReturn($this->idTokenFactoryMock); + + $this->userEntityMock = $this->createMock(UserEntity::class); + $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); + + + $this->clientEntityMock = $this->createMock(ClientEntity::class); + $this->accessTokenEntityMock->method('getClient')->willReturn($this->clientEntityMock); + + $this->accessTokenExpiryDateTimeMock = $this->createMock(DateTimeImmutable::class); + $this->accessTokenEntityMock->method('getExpiryDateTime') + ->willReturn($this->accessTokenExpiryDateTimeMock); + + $this->scopeEntityMock = $this->createMock(ScopeEntity::class); + $this->accessTokenEntityMock->method('getScopes')->willReturn([$this->scopeEntityMock]); + } + + protected function sut( + ?ClaimTranslatorExtractor $claimTranslatorExtractor = null, + ?Core $core = null, + ?ModuleConfig $moduleConfig = null, + ): IdTokenBuilder { + $claimTranslatorExtractor ??= $this->claimTranslatorExtractorMock; + $core ??= $this->coreMock; + $moduleConfig ??= $this->moduleConfigMock; + + return new IdTokenBuilder( + $claimTranslatorExtractor, + $core, + $moduleConfig, + ); + } + + public function testCanCreateInstance(): void { - $this->markTestIncomplete(); + $this->assertInstanceOf(IdTokenBuilder::class, $this->sut()); + } + + public function testCanBuild(): void + { + $this->moduleConfigMock->expects($this->once())->method('getIssuer') + ->willReturn('issuer'); + $this->idTokenFactoryMock->expects($this->once())->method('fromData') + ->with( + $this->anything(), + SignatureAlgorithmEnum::RS256, + $this->arrayHasKey(ClaimsEnum::Iss->value), + ); + + $this->claimTranslatorExtractorMock->expects($this->once()) + ->method('extract') + ->willReturn(['foo' => 'bar']); + + $this->claimTranslatorExtractorMock->expects($this->once()) + ->method('extractAdditionalIdTokenClaims') + ->willReturn(['additional' => 'claim']); + + $this->assertInstanceOf( + IdToken::class, + $this->sut()->buildFor( + $this->userEntityMock, + $this->accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ), + ); + } + + public function testWillNegotiateIdTokenSignatureAlgorithm(): void + { + $this->clientEntityMock->method('getIdTokenSignedResponseAlg') + ->willReturn(SignatureAlgorithmEnum::ES256->value); + + $ecSignatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $ecSignatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::ES256); + + $this->protocolSignatureKeyBagMock->expects($this->once()) + ->method('getFirstByAlgorithmOrFail') + ->with(SignatureAlgorithmEnum::ES256) + ->willReturn($ecSignatureKeyPairMock); + + $this->assertInstanceOf( + IdToken::class, + $this->sut()->buildFor( + $this->userEntityMock, + $this->accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ), + ); + } + + public function testThrowsForInvalidUserEntity(): void + { + $userEntityInterfaceMock = $this->createMock(UserEntityInterface::class); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ClaimSetInterface'); + + $this->sut()->buildFor( + $userEntityInterfaceMock, + $this->accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ); + } + + public function testThrowsForInvalidClientEntity(): void + { + $accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); + $accessTokenEntityMock->method('getClient')->willReturn( + $this->createMock(\League\OAuth2\Server\Entities\ClientEntityInterface::class), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('ClientEntity'); + + $this->sut()->buildFor( + $this->userEntityMock, + $accessTokenEntityMock, + true, + true, + null, + null, + null, + null, + ); } } diff --git a/tests/unit/src/Services/JsonWebKeySetServiceTest.php b/tests/unit/src/Services/JsonWebKeySetServiceTest.php deleted file mode 100644 index 4aca2450..00000000 --- a/tests/unit/src/Services/JsonWebKeySetServiceTest.php +++ /dev/null @@ -1,175 +0,0 @@ - 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - $pkGenerateNew = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - $pkGenerateFederation = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - $pkGenerateFederationNew = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - // get the public key - $pkGenerateDetails = openssl_pkey_get_details($pkGenerate); - $pkGenerateDetailsNew = openssl_pkey_get_details($pkGenerateNew); - $pkGenerateDetailsFederation = openssl_pkey_get_details($pkGenerateFederation); - $pkGenerateDetailsFederationNew = openssl_pkey_get_details($pkGenerateFederationNew); - self::$pkGeneratePublic = $pkGenerateDetails['key']; - self::$pkGeneratePublicNew = $pkGenerateDetailsNew['key']; - self::$pkGeneratePublicFederation = $pkGenerateDetailsFederation['key']; - self::$pkGeneratePublicFederationNew = $pkGenerateDetailsFederationNew['key']; - - file_put_contents(sys_get_temp_dir() . '/oidc_module.crt', self::$pkGeneratePublic); - file_put_contents(sys_get_temp_dir() . '/new_oidc_module.crt', self::$pkGeneratePublicNew); - file_put_contents(sys_get_temp_dir() . '/oidc_module_federation.crt', self::$pkGeneratePublicFederation); - file_put_contents( - sys_get_temp_dir() . '/new_oidc_module_federation.crt', - self::$pkGeneratePublicFederationNew, - ); - - Configuration::setPreLoadedConfig( - Configuration::loadFromArray([ - ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', - ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', - ]), - ModuleConfig::DEFAULT_FILE_NAME, - ); - } - - /** - * @return void - */ - public static function tearDownAfterClass(): void - { - Configuration::clearInternalState(); - unlink(sys_get_temp_dir() . '/oidc_module.crt'); - unlink(sys_get_temp_dir() . '/new_oidc_module.crt'); - unlink(sys_get_temp_dir() . '/oidc_module_federation.crt'); - unlink(sys_get_temp_dir() . '/new_oidc_module_federation.crt'); - } - - /** - * @return void - * @throws \SimpleSAML\Error\Exception - */ - public function testProtocolKeys() - { - $config = [ - 'certdir' => sys_get_temp_dir(), - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - $kid = FingerprintGenerator::forString(self::$pkGeneratePublic); - $jwk = JWKFactory::createFromKey(self::$pkGeneratePublic, null, [ - 'kid' => $kid, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $kidNew = FingerprintGenerator::forString(self::$pkGeneratePublicNew); - $jwkNew = JWKFactory::createFromKey(self::$pkGeneratePublicNew, null, [ - 'kid' => $kidNew, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $JWKSet = new JWKSet([$jwk, $jwkNew]); - - $jsonWebKeySetService = new JsonWebKeySetService(new ModuleConfig()); - - $this->assertEquals($JWKSet->all(), $jsonWebKeySetService->protocolKeys()); - } - - /** - * @throws \SimpleSAML\Error\Exception - */ - public function testProtocolCertificateFileNotFound(): void - { - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/OIDC protocol public key file does not exists/'); - - $config = [ - 'certdir' => __DIR__, - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - new JsonWebKeySetService(new ModuleConfig()); - } - - public function testFederationKeys(): void - { - $config = [ - 'certdir' => sys_get_temp_dir(), - ]; - Configuration::loadFromArray($config, '', 'simplesaml'); - - $kid = FingerprintGenerator::forString(self::$pkGeneratePublicFederation); - $jwk = JWKFactory::createFromKey(self::$pkGeneratePublicFederation, null, [ - 'kid' => $kid, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $kidNew = FingerprintGenerator::forString(self::$pkGeneratePublicFederationNew); - $jwkNew = JWKFactory::createFromKey(self::$pkGeneratePublicFederationNew, null, [ - 'kid' => $kidNew, - 'use' => 'sig', - 'alg' => 'RS256', - ]); - - $JWKSet = new JWKSet([$jwk, $jwkNew]); - - $jsonWebKeySetService = new JsonWebKeySetService(new ModuleConfig()); - - $this->assertEquals($JWKSet->all(), $jsonWebKeySetService->federationKeys()); - } -} diff --git a/tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php b/tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php deleted file mode 100644 index db69df27..00000000 --- a/tests/unit/src/Services/JsonWebTokenBuilderServiceTest.php +++ /dev/null @@ -1,120 +0,0 @@ -moduleConfigStub = $this->createStub(ModuleConfig::class); - $this->moduleConfigStub->method('getProtocolSigner')->willReturn(self::$signerSha256); - $this->moduleConfigStub->method('getProtocolPrivateKeyPath')->willReturn(self::$privateKeyPath); - $this->moduleConfigStub->method('getProtocolCertPath')->willReturn(self::$publicKeyPath); - $this->moduleConfigStub->method('getIssuer')->willReturn(self::$selfUrlHost); - } - - /** - * @throws \ReflectionException - * @throws \League\OAuth2\Server\Exception\OAuthServerException - */ - public function testCanCreateBuilderInstance(): void - { - $builderService = new JsonWebTokenBuilderService($this->moduleConfigStub); - - $this->assertInstanceOf( - Builder::class, - $builderService->getProtocolJwtBuilder(), - ); - } - - /** - * @throws \ReflectionException - * @throws \League\OAuth2\Server\Exception\OAuthServerException - * @throws \Exception - */ - public function testCanGenerateSignedJwtToken(): void - { - $builderService = new JsonWebTokenBuilderService($this->moduleConfigStub); - $tokenBuilder = $builderService->getProtocolJwtBuilder(); - - $unencryptedToken = $builderService->getSignedProtocolJwt($tokenBuilder); - - $this->assertInstanceOf(UnencryptedToken::class, $unencryptedToken); - $this->assertSame(self::$selfUrlHost, $unencryptedToken->claims()->get('iss')); - - // Check token signature - $token = $unencryptedToken->toString(); - - $jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file( - $this->moduleConfigStub->getProtocolPrivateKeyPath(), - $this->moduleConfigStub->getProtocolPrivateKeyPassPhrase() ?? '', - ), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ); - - $parsedToken = $jwtConfig->parser()->parse($token); - - $this->assertTrue( - $jwtConfig->validator()->validate( - $parsedToken, - new IssuedBy(self::$selfUrlHost), - new SignedWith( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ), - ), - ); - } - - /** - * @throws \ReflectionException - */ - public function testCanReturnCurrentSigner(): void - { - $this->assertSame( - self::$signerSha256, - (new JsonWebTokenBuilderService($this->moduleConfigStub))->getProtocolSigner(), - ); - } -} diff --git a/tests/unit/src/Services/LogoutTokenBuilderTest.php b/tests/unit/src/Services/LogoutTokenBuilderTest.php index fe7b9218..62840cb0 100644 --- a/tests/unit/src/Services/LogoutTokenBuilderTest.php +++ b/tests/unit/src/Services/LogoutTokenBuilderTest.php @@ -4,19 +4,21 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Validation\Constraint\IssuedBy; -use Lcobucci\JWT\Validation\Constraint\PermittedFor; -use Lcobucci\JWT\Validation\Constraint\RelatedTo; -use Lcobucci\JWT\Validation\Constraint\SignedWith; -use PHPUnit\Framework\MockObject\Stub; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SimpleSAML\Module\oidc\Factories\CoreFactory; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Associations\Interfaces\RelyingPartyAssociationInterface; -use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\LogoutTokenBuilder; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\Factories\LogoutTokenFactory; +use SimpleSAML\OpenID\Jwk\JwkDecorator; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Services\LogoutTokenBuilder @@ -38,21 +40,19 @@ class LogoutTokenBuilderTest extends TestCase /** * @var mixed */ - private Stub $moduleConfigStub; + private MockObject $moduleConfigMock; /** * @var mixed */ - private Stub $relyingPartyAssociationStub; - private JsonWebTokenBuilderService $jsonWebTokenBuilderService; + private MockObject $relyingPartyAssociationMock; + private MockObject $loggerServiceMock; + private MockObject $coreFactoryMock; + private MockObject $protocolSignatureKeyPairBagMock; + private MockObject $signatureKeyPairMock; + private MockObject $coreMock; + private MockObject $logoutTokenFactoryMock; - public static function setUpBeforeClass(): void - { - self::$certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; - self::$privateKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; - self::$publicKeyPath = self::$certFolder . ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME; - self::$signerSha256 = new Sha256(); - } /** * @throws \ReflectionException @@ -61,59 +61,110 @@ public static function setUpBeforeClass(): void */ public function setUp(): void { - $this->moduleConfigStub = $this->createStub(ModuleConfig::class); - $this->moduleConfigStub->method('getProtocolSigner')->willReturn(self::$signerSha256); - $this->moduleConfigStub->method('getProtocolPrivateKeyPath')->willReturn(self::$privateKeyPath); - $this->moduleConfigStub->method('getProtocolCertPath')->willReturn(self::$publicKeyPath); - $this->moduleConfigStub->method('getIssuer')->willReturn(self::$selfUrlHost); - - $this->relyingPartyAssociationStub = $this->createStub(RelyingPartyAssociationInterface::class); - $this->relyingPartyAssociationStub->method('getClientId')->willReturn(self::$clientId); - $this->relyingPartyAssociationStub->method('getUserId')->willReturn(self::$userId); - $this->relyingPartyAssociationStub->method('getSessionId')->willReturn(self::$sessionId); - $this->relyingPartyAssociationStub + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + + $this->relyingPartyAssociationMock = $this->createMock(RelyingPartyAssociationInterface::class); + $this->relyingPartyAssociationMock->method('getClientId')->willReturn(self::$clientId); + $this->relyingPartyAssociationMock->method('getUserId')->willReturn(self::$userId); + $this->relyingPartyAssociationMock->method('getSessionId')->willReturn(self::$sessionId); + $this->relyingPartyAssociationMock ->method('getBackChannelLogoutUri') ->willReturn(self::$backChannelLogoutUri); - $this->jsonWebTokenBuilderService = new JsonWebTokenBuilderService($this->moduleConfigStub); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + + $this->coreFactoryMock = $this->createMock(CoreFactory::class); + + $this->protocolSignatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->coreMock = $this->createMock(Core::class); + $this->coreFactoryMock->method('build')->willReturn($this->coreMock); + + $this->logoutTokenFactoryMock = $this->createMock(LogoutTokenFactory::class); + + $this->coreMock->method('logoutTokenFactory')->willReturn($this->logoutTokenFactoryMock); + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?LoggerService $loggerService = null, + ?CoreFactory $coreFactory = null, + ): LogoutTokenBuilder { + $moduleConfig ??= $this->moduleConfigMock; + $loggerService ??= $this->loggerServiceMock; + $coreFactory ??= $this->coreFactoryMock; + + return new LogoutTokenBuilder( + $moduleConfig, + $loggerService, + $coreFactory, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(LogoutTokenBuilder::class, $this->sut()); } /** * @throws \ReflectionException * @throws \Exception */ - public function testCanGenerateSignedTokenForRelyingPartyAssociation(): void + public function testForRelyingPartyAssociationCallsLogoutTokenFactory(): void { - $logoutTokenBuilder = new LogoutTokenBuilder($this->jsonWebTokenBuilderService); - - $token = $logoutTokenBuilder->forRelyingPartyAssociation($this->relyingPartyAssociationStub); - - // Check token validity - $jwtConfig = Configuration::forAsymmetricSigner( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file( - $this->moduleConfigStub->getProtocolPrivateKeyPath(), - $this->moduleConfigStub->getProtocolPrivateKeyPassPhrase() ?? '', - ), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ); + $this->moduleConfigMock->expects($this->once()) + ->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); + + $this->protocolSignatureKeyPairBagMock->expects($this->once()) + ->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); + + $this->moduleConfigMock->expects($this->once()) + ->method('getIssuer') + ->willReturn('issuerId'); + + $this->logoutTokenFactoryMock->expects($this->once()) + ->method('fromData') + ->with( + $this->isInstanceOf(JwkDecorator::class), + $this->isInstanceOf(SignatureAlgorithmEnum::class), + $this->arrayHasKey(ClaimsEnum::Iss->value), + $this->arrayHasKey(ClaimsEnum::Kid->value), + ); + + $this->sut()->forRelyingPartyAssociation($this->relyingPartyAssociationMock); + } - $parsedToken = $jwtConfig->parser()->parse($token); - - $this->assertTrue( - $jwtConfig->validator()->validate( - $parsedToken, - new IssuedBy(self::$selfUrlHost), - new PermittedFor(self::$clientId), - new RelatedTo(self::$userId), - new SignedWith( - $this->moduleConfigStub->getProtocolSigner(), - InMemory::file($this->moduleConfigStub->getProtocolCertPath()), - ), - ), - ); + public function testForRelyingPartyAssociationUsesNegotiatedSignatureKeyPair(): void + { + $this->moduleConfigMock->expects($this->once()) + ->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->protocolSignatureKeyPairBagMock); + + $this->protocolSignatureKeyPairBagMock->expects($this->once()) + ->method('getFirstOrFail') + ->willReturn($this->signatureKeyPairMock); + + $this->relyingPartyAssociationMock->expects($this->once()) + ->method('getClientIdTokenSignedResponseAlg') + ->willReturn('ES256'); - $this->assertTrue($parsedToken->headers()->has('typ')); - $this->assertSame($parsedToken->headers()->get('typ'), self::$logoutTokenType); + $negotiatedSignatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $negotiatedSignatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::ES256); + + $this->protocolSignatureKeyPairBagMock->expects($this->once()) + ->method('getFirstByAlgorithmOrFail') + ->with(SignatureAlgorithmEnum::ES256) + ->willReturn($negotiatedSignatureKeyPairMock); + + $this->sut()->forRelyingPartyAssociation( + $this->relyingPartyAssociationMock, + ); } } diff --git a/tests/unit/src/Services/NonceServiceTest.php b/tests/unit/src/Services/NonceServiceTest.php new file mode 100644 index 00000000..d6ed0206 --- /dev/null +++ b/tests/unit/src/Services/NonceServiceTest.php @@ -0,0 +1,148 @@ +jwsMock = $this->createMock(Jws::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->parsedJwsFactoryMock = $this->createMock(ParsedJwsFactory::class); + $this->parsedJwsMock = $this->createMock(ParsedJws::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->dateTimeHelperMock = $this->createMock(DateTime::class); + + $this->jwsMock->method('parsedJwsFactory')->willReturn($this->parsedJwsFactoryMock); + $this->jwsMock->method('helpers')->willReturn($this->helpersMock); + $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairBagMock->method('getFirstOrFail')->willReturn($this->signatureKeyPairMock); + $this->moduleConfigMock->method('getVciSignatureKeyPairBag')->willReturn($this->signatureKeyPairBagMock); + } + + public function testGenerateNonce(): void + { + $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); + + $privateKeyMock = $this->createMock(JwkDecorator::class); + $keyPairMock = $this->createMock(KeyPair::class); + $keyPairMock->method('getPrivateKey')->willReturn($privateKeyMock); + $keyPairMock->method('getKeyId')->willReturn('key1'); + $this->signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); + $this->signatureKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256); + + $this->parsedJwsFactoryMock->expects($this->once()) + ->method('fromData') + ->willReturn($this->parsedJwsMock); + + $this->parsedJwsMock->method('getToken')->willReturn('mocked_token'); + + $sut = new NonceService($this->jwsMock, $this->moduleConfigMock, $this->loggerServiceMock); + $nonce = $sut->generateNonce(); + + $this->assertEquals('mocked_token', $nonce); + } + + public function testValidateNonceSuccess(): void + { + $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); + + $jwkMock = $this->createMock(JWK::class); + $jwkMock->method('all')->willReturn(['kty' => 'EC']); + $publicKeyMock = $this->createMock(JwkDecorator::class); + $publicKeyMock->method('jwk')->willReturn($jwkMock); + + $keyPairMock = $this->createMock(KeyPair::class); + $keyPairMock->method('getPublicKey')->willReturn($publicKeyMock); + $this->signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); + + $this->parsedJwsMock->method('getIssuer')->willReturn('https://issuer.example.com'); + $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); + $this->parsedJwsMock->method('getExpirationTime') + ->willReturn((new \DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() + 100); + + $sut = new NonceService($this->jwsMock, $this->moduleConfigMock, $this->loggerServiceMock); + $this->assertTrue($sut->validateNonce('valid_token')); + } + + public function testValidateNonceInvalidIssuer(): void + { + $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); + + $jwkMock = $this->createMock(JWK::class); + $jwkMock->method('all')->willReturn(['kty' => 'EC']); + $publicKeyMock = $this->createMock(JwkDecorator::class); + $publicKeyMock->method('jwk')->willReturn($jwkMock); + + $keyPairMock = $this->createMock(KeyPair::class); + $keyPairMock->method('getPublicKey')->willReturn($publicKeyMock); + $this->signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); + + $this->parsedJwsMock->method('getIssuer')->willReturn('https://other.example.com'); + $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); + + $sut = new NonceService($this->jwsMock, $this->moduleConfigMock, $this->loggerServiceMock); + $this->assertFalse($sut->validateNonce('invalid_issuer_token')); + } + + public function testValidateNonceExpired(): void + { + $this->dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable('2024-01-01 00:00:00')); + $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); + + $jwkMock = $this->createMock(JWK::class); + $jwkMock->method('all')->willReturn(['kty' => 'EC']); + $publicKeyMock = $this->createMock(JwkDecorator::class); + $publicKeyMock->method('jwk')->willReturn($jwkMock); + + $keyPairMock = $this->createMock(KeyPair::class); + $keyPairMock->method('getPublicKey')->willReturn($publicKeyMock); + $this->signatureKeyPairMock->method('getKeyPair')->willReturn($keyPairMock); + + $this->parsedJwsMock->method('getIssuer')->willReturn('https://issuer.example.com'); + $this->moduleConfigMock->method('getIssuer')->willReturn('https://issuer.example.com'); + $this->parsedJwsMock->method('getExpirationTime') + ->willReturn((new \DateTimeImmutable('2024-01-01 00:00:00'))->getTimestamp() - 10); + + $sut = new NonceService($this->jwsMock, $this->moduleConfigMock, $this->loggerServiceMock); + $this->assertFalse($sut->validateNonce('expired_token')); + } +} diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 43ce4fa5..f354b630 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -4,14 +4,18 @@ namespace SimpleSAML\Test\Module\oidc\unit\Services; -use Lcobucci\JWT\Signer\Rsa; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\OpMetadataService; use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair; +use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag; /** * @covers \SimpleSAML\Module\oidc\Services\OpMetadataService @@ -20,6 +24,10 @@ class OpMetadataServiceTest extends TestCase { protected MockObject $moduleConfigMock; protected MockObject $claimTranslatorExtractorMock; + protected MockObject $signatureAlgorithmBag; + protected MockObject $supportedAlgorithmsMock; + protected MockObject $signatureKeyPairBagMock; + protected MockObject $signatureKeyPairMock; /** * @throws \Exception @@ -46,11 +54,31 @@ public function setUp(): void }); $this->moduleConfigMock->method('getAcrValuesSupported')->willReturn(['1']); - $signer = $this->createMock(Rsa::class); - $signer->method('algorithmId')->willReturn('RS256'); - $this->moduleConfigMock->method('getProtocolSigner')->willReturn($signer); - $this->claimTranslatorExtractorMock = $this->createMock(ClaimTranslatorExtractor::class); + + $this->signatureAlgorithmBag = $this->createMock(SignatureAlgorithmBag::class); + $this->signatureAlgorithmBag->method('getAllNamesUnique') + ->willReturn(['RS256']); + + $this->supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); + $this->supportedAlgorithmsMock->method('getSignatureAlgorithmBag') + ->willReturn($this->signatureAlgorithmBag); + + $this->moduleConfigMock->method('getSupportedAlgorithms') + ->willReturn($this->supportedAlgorithmsMock); + + $this->signatureKeyPairMock = $this->createMock(SignatureKeyPair::class); + $this->signatureKeyPairMock->method('getSignatureAlgorithm') + ->willReturn(SignatureAlgorithmEnum::RS256); + + $this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class); + $this->signatureKeyPairBagMock->method('getAll') + ->willReturn([$this->signatureKeyPairMock]); + $this->signatureKeyPairBagMock->method('getAllAlgorithmNamesUnique') + ->willReturn(['RS256']); + + $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') + ->willReturn($this->signatureKeyPairBagMock); } /** diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php new file mode 100644 index 00000000..b788ec7c --- /dev/null +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -0,0 +1,721 @@ +clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($this->psrHttpFactoryMock); + $this->jwksResolverMock = $this->createMock(JwksResolver::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getModuleUrl') + ->willReturnMap([ + [RoutesEnum::Token->value, self::TOKEN_ENDPOINT], + [RoutesEnum::Authorization->value, 'https://example.org/oidc/authorization.php'], + ]); + $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); + $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); + $this->protocolCacheStub = $this->createStub(ProtocolCache::class); + + $this->serverRequestMock = $this->createMock(ServerRequestInterface::class); + + $this->clientEntityMock = $this->createMock(ClientEntityInterface::class); + $this->clientEntityMock->method('getIdentifier')->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isEnabled')->willReturn(true); + $this->clientEntityMock->method('isExpired')->willReturn(false); + + $this->clientAssertionMock = $this->createMock(ClientAssertion::class); + $this->clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $this->clientAssertionMock->method('getSubject')->willReturn(self::CLIENT_ID); + $this->clientAssertionMock->method('getAudience')->willReturn([self::TOKEN_ENDPOINT]); + $this->clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + $this->clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); + } + + protected function sut(?ProtocolCache $protocolCache = null): AuthenticatedOAuth2ClientResolver + { + return new AuthenticatedOAuth2ClientResolver( + $this->clientRepositoryMock, + $this->requestParamsResolverMock, + $this->loggerServiceMock, + $this->psrHttpBridgeMock, + $this->jwksResolverMock, + $this->moduleConfigMock, + $this->helpersMock, + $protocolCache, + ); + } + + // ----------------------------------------------------------------------- + // Construction + // ----------------------------------------------------------------------- + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(AuthenticatedOAuth2ClientResolver::class, $this->sut()); + } + + // ----------------------------------------------------------------------- + // forPublicClient + // ----------------------------------------------------------------------- + + public function testForPublicClientReturnsNullWhenNoClientIdInRequest(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(null); + + $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); + } + + public function testForPublicClientReturnsNullWhenClientIdIsEmptyString(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(''); + + $this->assertNull($this->sut()->forPublicClient($this->serverRequestMock, null)); + } + + public function testForPublicClientThrowsWhenClientIsConfidential(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forPublicClient($this->serverRequestMock, null); + } + + public function testForPublicClientThrowsWhenClientNotFound(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forPublicClient($this->serverRequestMock, null); + } + + public function testForPublicClientReturnsResolvedResultForPublicClient(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forPublicClient($this->serverRequestMock, null); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame(ClientAuthenticationMethodsEnum::None, $result->getClientAuthenticationMethod()); + } + + public function testForPublicClientUsesPreFetchedClientWhenProvided(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(self::CLIENT_ID); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + // Repository must NOT be called when a pre-fetched client is provided. + $this->clientRepositoryMock->expects($this->never())->method('findById'); + + $result = $this->sut()->forPublicClient($this->serverRequestMock, $this->clientEntityMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + } + + // ----------------------------------------------------------------------- + // forClientSecretBasic + // ----------------------------------------------------------------------- + + public function testForClientSecretBasicReturnsNullWhenNoAuthorizationHeader(): void + { + $this->serverRequestMock->method('getHeader')->with('Authorization')->willReturn([]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenHeaderIsNotBasic(): void + { + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn(['Bearer some-token']); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenBase64DecodeFailsStrictMode(): void + { + // Characters outside [A-Za-z0-9+/=] are invalid in strict mode. + $invalidBase64 = 'Basic !!!'; + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$invalidBase64]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenDecodedValueHasNoColon(): void + { + // Valid base64 of a string with no colon. + $encoded = 'Basic ' . base64_encode('clientidonly'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicReturnsNullWhenClientIdIsEmpty(): void + { + // Colon present but client ID part is empty: ":secret" + $encoded = 'Basic ' . base64_encode(':some-secret'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + + $this->assertNull($this->sut()->forClientSecretBasic($this->serverRequestMock)); + } + + public function testForClientSecretBasicThrowsWhenClientIsNotConfidential(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicThrowsWhenSecretIsEmpty(): void + { + // Colon present but secret part is empty: "clientid:" + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicThrowsWhenSecretIsInvalid(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':wrong-secret'); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretBasic($this->serverRequestMock); + } + + public function testForClientSecretBasicReturnsResolvedResultOnSuccess(): void + { + $encoded = 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET); + $this->serverRequestMock->method('getHeader')->with('Authorization') + ->willReturn([$encoded]); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forClientSecretBasic($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + $result->getClientAuthenticationMethod(), + ); + } + + public function testForClientSecretBasicConvertsSymfonyRequestToPsr(): void + { + $symfonyRequest = Request::create('/', 'POST'); + + $psrRequest = $this->createMock(ServerRequestInterface::class); + $psrRequest->method('getHeader')->with('Authorization')->willReturn([]); + + $this->psrHttpFactoryMock->expects($this->once()) + ->method('createRequest') + ->with($symfonyRequest) + ->willReturn($psrRequest); + + $result = $this->sut()->forClientSecretBasic($symfonyRequest); + + $this->assertNull($result); + } + + // ----------------------------------------------------------------------- + // forClientSecretPost + // ----------------------------------------------------------------------- + + public function testForClientSecretPostReturnsNullWhenNoClientIdInPostBody(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(null); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostReturnsNullWhenClientIdIsEmpty(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(''); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostThrowsWhenClientIsNotConfidential(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, self::CLIENT_SECRET); + $this->clientEntityMock->method('isConfidential')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostReturnsNullWhenSecretIsNull(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, null); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostReturnsNullWhenSecretIsEmpty(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, ''); + + $this->assertNull($this->sut()->forClientSecretPost($this->serverRequestMock)); + } + + public function testForClientSecretPostThrowsWhenSecretIsInvalid(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, 'wrong-secret'); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->expectException(AuthorizationException::class); + + $this->sut()->forClientSecretPost($this->serverRequestMock); + } + + public function testForClientSecretPostReturnsResolvedResultOnSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls(self::CLIENT_ID, self::CLIENT_SECRET); + $this->clientEntityMock->method('isConfidential')->willReturn(true); + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $result = $this->sut()->forClientSecretPost($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::ClientSecretPost, + $result->getClientAuthenticationMethod(), + ); + } + + // ----------------------------------------------------------------------- + // forPrivateKeyJwt + // ----------------------------------------------------------------------- + + public function testForPrivateKeyJwtReturnsNullWhenNoClientAssertionParam(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturn(null); + + $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); + } + + public function testForPrivateKeyJwtReturnsNullWhenAssertionTypeIsNotJwtBearer(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', 'unexpected_type'); + + $this->assertNull($this->sut()->forPrivateKeyJwt($this->serverRequestMock)); + } + + public function testForPrivateKeyJwtThrowsWhenJwksNotAvailable(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(null); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('client JWKS not available'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenSignatureVerificationFails(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->clientAssertionMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Signature mismatch')); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Client Assertion validation failed'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenJtiAlreadyUsed(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('has') + ->with('client_assertion_jti', 'unique-jti-value') + ->willReturn(true); // JTI already in cache → replay attempt + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Client Assertion reused'); + + $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenIssuerClaimDoesNotMatchClientId(): void + { + // The assertion issuer is CLIENT_ID, but we pass a pre-fetched client with a different + // identifier. resolveClientOrFail will detect the mismatch and throw. + $mismatchedClient = $this->createMock(ClientEntityInterface::class); + $mismatchedClient->method('getIdentifier')->willReturn('different-client-id'); + $mismatchedClient->method('isEnabled')->willReturn(true); + $mismatchedClient->method('isExpired')->willReturn(false); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + + // Pass the mismatched client as a pre-fetched client to trigger the ID check. + $this->sut()->forPrivateKeyJwt($this->serverRequestMock, $mismatchedClient); + } + + public function testForPrivateKeyJwtThrowsWhenSubjectClaimDoesNotMatchClientId(): void + { + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getSubject')->willReturn('different-subject'); + $clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Subject claim'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtThrowsWhenAudienceClaimDoesNotContainExpectedValue(): void + { + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionMock->method('getIssuer')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getSubject')->willReturn(self::CLIENT_ID); + $clientAssertionMock->method('getAudience')->willReturn(['https://unrelated-aud.example.org']); + $clientAssertionMock->method('getJwtId')->willReturn('unique-jti-value'); + $clientAssertionMock->method('getExpirationTime')->willReturn(time() + 60); + + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Audience claim'); + + $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtReturnsResolvedResultOnSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + $result = $this->sut()->forPrivateKeyJwt($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame($this->clientEntityMock, $result->getClient()); + $this->assertSame( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + $result->getClientAuthenticationMethod(), + ); + } + + public function testForPrivateKeyJwtStoresJtiInCacheAfterSuccess(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('has')->willReturn(false); + $protocolCacheMock->expects($this->once()) + ->method('set') + ->with( + 'unique-jti-value', + 60, + 'client_assertion_jti', + 'unique-jti-value', + ); + + $this->sut($protocolCacheMock)->forPrivateKeyJwt($this->serverRequestMock); + } + + public function testForPrivateKeyJwtSkipsJtiCheckWhenNoCacheProvided(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnOnConsecutiveCalls('some-assertion-token', ClientAssertionTypesEnum::JwtBaerer->value); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + // No cache passed — must succeed without any replay check. + $result = $this->sut(null)->forPrivateKeyJwt($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + } + + // ----------------------------------------------------------------------- + // forAnySupportedMethod + // ----------------------------------------------------------------------- + + public function testForAnySupportedMethodReturnsNullWhenNoMethodMatches(): void + { + // All four methods return null (no matching credentials anywhere). + $this->serverRequestMock->method('getHeader')->with('Authorization')->willReturn([]); + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn(null); + + $this->assertNull($this->sut()->forAnySupportedMethod($this->serverRequestMock)); + } + + public function testForAnySupportedMethodReturnsNullAndLogsErrorOnException(): void + { + // Trigger a hard exception to verify the catch-all swallows it and logs. + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willThrowException(new \RuntimeException('Unexpected error')); + + $this->loggerServiceMock->expects($this->once())->method('error'); + + $result = $this->sut()->forAnySupportedMethod($this->serverRequestMock); + + $this->assertNull($result); + } + + public function testForAnySupportedMethodPrefersPrivateKeyJwtOverOtherMethods(): void + { + // private_key_jwt assertion present — should resolve first and win. + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnCallback(function (string $paramKey) { + if ($paramKey === ParamsEnum::ClientAssertion->value) { + return 'some-assertion-token'; + } + if ($paramKey === ParamsEnum::ClientAssertionType->value) { + return ClientAssertionTypesEnum::JwtBaerer->value; + } + return null; + }); + $this->requestParamsResolverMock->method('parseClientAssertionToken') + ->willReturn($this->clientAssertionMock); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + $this->jwksResolverMock->method('forClient')->willReturn(['keys' => []]); + $this->dateTimeHelperMock->method('getSecondsToExpirationTime')->willReturn(60); + + // forClientSecretBasic will be tried after forPrivateKeyJwt succeeds and short-circuits, + // so getHeader should never actually be reached. The PSR bridge is never used here + // because the request is already a ServerRequestInterface. + + $result = $this->sut()->forAnySupportedMethod($this->serverRequestMock); + + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $result); + $this->assertSame( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + $result->getClientAuthenticationMethod(), + ); + } + + // ----------------------------------------------------------------------- + // findActiveClient + // ----------------------------------------------------------------------- + + public function testFindActiveClientReturnsNullWhenClientNotFound(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsNullWhenClientIsDisabled(): void + { + $disabledClient = $this->createMock(ClientEntityInterface::class); + $disabledClient->method('getIdentifier')->willReturn(self::CLIENT_ID); + $disabledClient->method('isEnabled')->willReturn(false); + $this->clientRepositoryMock->method('findById')->willReturn($disabledClient); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsNullWhenClientIsExpired(): void + { + $expiredClient = $this->createMock(ClientEntityInterface::class); + $expiredClient->method('getIdentifier')->willReturn(self::CLIENT_ID); + $expiredClient->method('isEnabled')->willReturn(true); + $expiredClient->method('isExpired')->willReturn(true); + $this->clientRepositoryMock->method('findById')->willReturn($expiredClient); + + $this->assertNull($this->sut()->findActiveClient(self::CLIENT_ID)); + } + + public function testFindActiveClientReturnsClientWhenActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->assertSame($this->clientEntityMock, $this->sut()->findActiveClient(self::CLIENT_ID)); + } + + // ----------------------------------------------------------------------- + // findActiveClientOrFail + // ----------------------------------------------------------------------- + + public function testFindActiveClientOrFailThrowsWhenClientNotActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn(null); + + $this->expectException(AuthorizationException::class); + + $this->sut()->findActiveClientOrFail(self::CLIENT_ID); + } + + public function testFindActiveClientOrFailReturnsClientWhenActive(): void + { + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); + + $this->assertSame($this->clientEntityMock, $this->sut()->findActiveClientOrFail(self::CLIENT_ID)); + } + + // ----------------------------------------------------------------------- + // validateClientSecret + // ----------------------------------------------------------------------- + + public function testValidateClientSecretThrowsWhenSecretDoesNotMatch(): void + { + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + + $this->expectException(AuthorizationException::class); + + $this->sut()->validateClientSecret($this->clientEntityMock, 'wrong-secret'); + } + + public function testValidateClientSecretDoesNotThrowWhenSecretMatches(): void + { + $this->clientEntityMock->method('getSecret')->willReturn(self::CLIENT_SECRET); + + // Must not throw. + $this->sut()->validateClientSecret($this->clientEntityMock, self::CLIENT_SECRET); + $this->addToAssertionCount(1); + } +} diff --git a/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php b/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php index 03e4beae..d7f54b54 100644 --- a/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php +++ b/tests/unit/src/Utils/ClaimTranslatorExtractorTest.php @@ -357,33 +357,138 @@ public function testWillReleaseSingleValueClaimsIfMultiValueNotAllowed(): void public function testWillReleaseSingleValueClaimsForMandatorySingleValueClaims(): void { - - // TODO mivanci v7 Test for mandatory single value claims in other scopes, as per - // \SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor::MANDATORY_SINGLE_VALUE_CLAIMS $claimSet = new ClaimSetEntity( - 'customScopeWithSubClaim', - ['sub'], + 'customScope', + [ + 'sub', + 'name', + 'given_name', + 'family_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'email', + 'email_verified', + 'gender', + 'birthdate', + 'zoneinfo', + 'locale', + 'phone_number', + 'phone_number_verified', + 'address', + 'updated_at', + ], ); $translate = [ - 'sub' => [ - 'subAttribute', + 'sub' => ['subAttribute'], + 'name' => ['nameAttribute'], + 'given_name' => ['givenNameAttribute'], + 'family_name' => ['familyNameAttribute'], + 'middle_name' => ['middleNameAttribute'], + 'nickname' => ['nicknameAttribute'], + 'preferred_username' => ['preferredUsernameAttribute'], + 'profile' => ['profileAttribute'], + 'picture' => ['pictureAttribute'], + 'website' => ['websiteAttribute'], + 'email' => ['emailAttribute'], + 'email_verified' => ['emailVerifiedAttribute'], + 'gender' => ['genderAttribute'], + 'birthdate' => ['birthdateAttribute'], + 'zoneinfo' => ['zoneinfoAttribute'], + 'locale' => ['localeAttribute'], + 'phone_number' => ['phoneNumberAttribute'], + 'phone_number_verified' => ['phoneNumberVerifiedAttribute'], + 'address' => [ + 'type' => 'json', + 'claims' => [ + 'formatted' => ['addressAttribute'], + ], ], + 'updated_at' => ['updatedAtAttribute'], ]; $userAttributes = [ - 'subAttribute' => ['1', '2', '3'], + 'subAttribute' => ['id1', 'id2', 'id3'], + 'nameAttribute' => ['name1', 'name2', 'name3'], + 'givenNameAttribute' => ['givenName1', 'givenName2', 'givenName3'], + 'familyNameAttribute' => ['familyName1', 'familyName2', 'familyName3'], + 'middleNameAttribute' => ['middleName1', 'middleName2', 'middleName3'], + 'nicknameAttribute' => ['nickname1', 'nickname2', 'nickname3'], + 'preferredUsernameAttribute' => ['preferredUsername1', 'preferredUsername2', 'preferredUsername3'], + 'profileAttribute' => ['profileUrl1', 'profileUrl2', 'profileUrl3'], + 'pictureAttribute' => ['pictureUrl1', 'pictureUrl2', 'pictureUrl3'], + 'websiteAttribute' => ['websiteUrl1', 'websiteUrl2', 'websiteUrl3'], + 'emailAttribute' => ['email1', 'email2', 'email3'], + 'emailVerifiedAttribute' => [true, false], + 'genderAttribute' => ['gender1', 'gender2', 'gender3'], + 'birthdateAttribute' => ['birthdate1', 'birthdate2', 'birthdate3'], + 'zoneinfoAttribute' => ['zoneinfo1', 'zoneinfo2', 'zoneinfo3'], + 'localeAttribute' => ['locale1', 'locale2', 'locale3'], + 'phoneNumberAttribute' => ['phoneNumber1', 'phoneNumber2', 'phoneNumber3'], + 'phoneNumberVerifiedAttribute' => [true, false], + 'addressAttribute' => ['address1', 'address2', 'address3'], + 'updatedAtAttribute' => [123, 456], ]; - $claimTranslator = $this->mock([$claimSet], $translate, ['sub']); + $claimTranslator = $this->mock( + [$claimSet], + $translate, + [ + 'sub', + 'name', + 'given_name', + 'family_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'profile', + 'picture', + 'website', + 'email', + 'email_verified', + 'gender', + 'birthdate', + 'zoneinfo', + 'locale', + 'phone_number', + 'phone_number_verified', + 'address', + 'updated_at', + ], + ); $releasedClaims = $claimTranslator->extract( - ['openid'], + ['customScope'], $userAttributes, ); - $expectedClaims = ['sub' => '1']; + $expectedClaims = [ + 'sub' => 'id1', + 'name' => 'name1', + 'given_name' => 'givenName1', + 'family_name' => 'familyName1', + 'middle_name' => 'middleName1', + 'nickname' => 'nickname1', + 'preferred_username' => 'preferredUsername1', + 'profile' => 'profileUrl1', + 'picture' => 'pictureUrl1', + 'website' => 'websiteUrl1', + 'email' => 'email1', + 'email_verified' => true, + 'gender' => 'gender1', + 'birthdate' => 'birthdate1', + 'zoneinfo' => 'zoneinfo1', + 'locale' => 'locale1', + 'phone_number' => 'phoneNumber1', + 'phone_number_verified' => true, + 'address' => ['formatted' => 'address1'], + 'updated_at' => 123, + ]; - $this->assertSame($expectedClaims, $releasedClaims); + $this->assertEquals($expectedClaims, $releasedClaims); } } diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index 0cbc269a..a183bf91 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; @@ -26,6 +27,7 @@ class RequestParamsResolverTest extends TestCase protected MockObject $requestObjectMock; protected MockObject $requestObjectFactoryMock; protected MockObject $federationMock; + protected MockObject $psrHttpBridgeMock; protected array $queryParams = [ 'a' => 'b', @@ -54,18 +56,21 @@ protected function setUp(): void $this->coreMock = $this->createMock(Core::class); $this->coreMock->method('requestObjectFactory')->willReturn($this->requestObjectFactoryMock); $this->federationMock = $this->createMock(Federation::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); } protected function mock( ?MockObject $helpersMock = null, ?MockObject $coreMock = null, ?MockObject $federationMock = null, + ?MockObject $psrHttpBridgeMock = null, ): RequestParamsResolver { $helpersMock ??= $this->helpersMock; $coreMock ??= $this->coreMock; $federationMock ??= $this->federationMock; + $psrHttpBridgeMock ??= $this->psrHttpBridgeMock; - return new RequestParamsResolver($helpersMock, $coreMock, $federationMock); + return new RequestParamsResolver($helpersMock, $coreMock, $federationMock, $psrHttpBridgeMock); } public function testCanCreateInstance(): void diff --git a/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php new file mode 100644 index 00000000..73ea7dfd --- /dev/null +++ b/tests/unit/src/ValueAbstracts/ResolvedClientAuthenticationMethodTest.php @@ -0,0 +1,52 @@ +clientMock = $this->createMock(ClientEntityInterface::class); + } + + protected function sut( + ?ClientEntityInterface $client = null, + ?ClientAuthenticationMethodsEnum $clientAuthenticationMethod = null, + ): ResolvedClientAuthenticationMethod { + $client ??= $this->clientMock; + $clientAuthenticationMethod ??= ClientAuthenticationMethodsEnum::ClientSecretBasic; + + return new ResolvedClientAuthenticationMethod( + $client, + $clientAuthenticationMethod, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ResolvedClientAuthenticationMethod::class, $this->sut()); + } + + public function testCanGetProperties(): void + { + $sut = $this->sut( + $this->clientMock, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + + $this->assertSame($this->clientMock, $sut->getClient()); + $this->assertSame(ClientAuthenticationMethodsEnum::ClientSecretPost, $sut->getClientAuthenticationMethod()); + } +}