diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 95dc7655..1d1d062f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -266,6 +266,13 @@ jobs: - name: Run form_post implicit tests run: | ./conformance-suite/scripts/run-test-plan.py "oidcc-formpost-implicit-certification-test-plan[server_metadata=discovery][client_registration=static_client]" ./main/conformance-tests/conformance-implicit-ci.json + - name: Run Dynamic registration conformance tests + # The only remaining non-passing tests are two OP-wide gaps (signed UserInfo + # and signing-key rotation), which are recorded as expected failures in + # conformance-tests/dynamic-warnings.json, so this step is a blocking gate. + # See docs/5-oidc-conformance.md for the inventory. + run: | + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/dynamic-warnings.json --expected-skips-file ./main/conformance-tests/dynamic-skips.json "oidcc-dynamic-certification-test-plan[response_type=code]" ./main/conformance-tests/conformance-dynamic-ci.json - name: Stop SSP working-directory: ./main run: | diff --git a/composer.json b/composer.json index 50612cc3..84fd2e48 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/container": "^2.0", "psr/log": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~v0.3.5", + "simplesamlphp/openid": "~0.3.8", "spomky-labs/base64url": "^2.0", "symfony/expression-language": "^7.4", "symfony/psr-http-message-bridge": "^7.4", diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 30489c86..755981e0 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -448,6 +448,24 @@ $config = [ */ ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => false, + /** + * Guzzle HTTP client options for the protocol-layer outbound requests made by the underlying `openid` + * library, such as fetching a client's 'jwks_uri' or a 'request_uri'. The array is passed through verbatim + * to the Guzzle client; see https://docs.guzzlephp.org/en/stable/request-options.html for the full list. + * + * Leave empty (the default) to use the library's secure defaults (TLS verification enabled). The typical + * use for a non-empty value is testing against endpoints that present self-signed certificates (for + * example, the OpenID conformance suite), by disabling TLS verification: + * + * ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS => [ + * 'verify' => false, + * ], + * + * SECURITY WARNING: disabling TLS verification ('verify' => false) exposes these fetches to + * man-in-the-middle attacks. Only use it in development/testing, NEVER in production. + */ + ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS => [], + /** * Settings regarding Authentication Processing Filters. * Note: An OIDC authN state array will not contain all the keys which are @@ -570,6 +588,112 @@ $config = [ */ ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => 20, + /*************************************************************************** + * (optional) OpenID Connect Dynamic Client Registration (DCR) related + * options. If not enabled (the default), Dynamic Client Registration + * capabilities will be disabled. + **************************************************************************/ + + /** + * Enable or disable OpenID Connect Dynamic Client Registration (DCR), as + * described in the OpenID Connect Dynamic Client Registration 1.0 + * specification (which is also compatible with RFC 7591). Default is + * disabled (false). + * + * When enabled, the module serves: + * - a Client Registration Endpoint (HTTP POST to .../oidc/register) which + * creates a new client from the supplied client metadata and returns its + * client_id, client_secret (for confidential clients), a + * registration_access_token and a registration_client_uri; and + * - a Client Configuration Endpoint (HTTP GET to + * .../oidc/register?client_id=...) which returns the current client + * registration when called with the registration_access_token as an HTTP + * Bearer token. + * + * When enabled, the registration endpoint is also advertised as the + * 'registration_endpoint' claim in the OP discovery metadata. + * + * Note that dynamically registered clients are stored like any other client + * and are visible / manageable in the admin UI. + */ + ModuleConfig::OPTION_DCR_ENABLED => false, + + /** + * Access-control mode for the registration (create) endpoint. Only relevant + * if Dynamic Client Registration is enabled. Possible values: + * + * - DcrRegistrationAuthEnum::Open (the default): open registration, meaning + * anyone may register a client without authenticating. In this mode you + * should protect the endpoint from abuse using rate limiting at the + * web-server level. + * - DcrRegistrationAuthEnum::InitialAccessToken: callers must present a + * valid Initial Access Token (provisioned out-of-band) as an HTTP Bearer + * token to register. The accepted tokens are configured using + * the OPTION_DCR_INITIAL_ACCESS_TOKENS option below. + */ + ModuleConfig::OPTION_DCR_REGISTRATION_AUTH => + \SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum::Open->value, + + /** + * Allowlist of Initial Access Tokens (opaque, randomly generated strings) + * accepted by the registration endpoint. This option is only consulted when + * the access mode (OPTION_DCR_REGISTRATION_AUTH) is set to + * DcrRegistrationAuthEnum::InitialAccessToken; in 'open' mode it is ignored. + * + * A registration request must then carry one of these tokens as an HTTP + * Bearer token. Use long, high-entropy values and treat them as secrets. + * + * Format: string[] (array of strings) + */ + ModuleConfig::OPTION_DCR_INITIAL_ACCESS_TOKENS => [ +// 'a-long-random-secret-token', + ], + + /** + * Enable or disable impersonation protection for Dynamic Client + * Registration, as recommended by Section 9.1 of the OpenID Connect Dynamic + * Client Registration 1.0 specification. Default is enabled (true). + * + * When enabled, the host component of the logo_uri, policy_uri and tos_uri + * client metadata values (if provided) must match the host of one of the + * registered redirect_uris. Otherwise, the registration is rejected with an + * 'invalid_client_metadata' error. This mitigates a rogue client trying to + * impersonate a legitimate one by reusing its branding (logo) or links. + * + * You may want to disable this (set to false) if your clients legitimately + * host these resources on a different domain than their redirect URIs (for + * example, on a shared CDN or marketing domain). Note that the client_uri + * (the client home page) is intentionally NOT subject to this check. + */ + ModuleConfig::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED => true, + + /** + * Default scopes assigned to a Dynamic Client Registration (DCR) client that + * registers WITHOUT an explicit 'scope'. The OpenID Connect Dynamic Client + * Registration 1.0 specification makes 'scope' OPTIONAL and lets the OP + * assign a default set; this option controls that set. + * + * If this option is omitted (commented out), it defaults to ALL scopes this + * OP supports, so a scope-less dynamic client may request any supported scope + * (including 'offline_access', i.e. refresh tokens). To restrict what a + * scope-less dynamic client receives, set an explicit list below; only values + * that are actually supported by this OP are kept. + * + * This applies ONLY to Dynamic registrations. Manual (admin) and OpenID + * Federation automatic registrations are NOT affected: a federated client + * with no 'scope' in its metadata still defaults to 'openid' only. + * + * Note: an explicit but unsupported 'scope' in a registration request is NOT + * treated as "not specified" - the unsupported values are dropped and the + * client ends up with 'openid' only (it does not receive this default set). + * + * Format: string[] (array of scope names) + */ +// ModuleConfig::OPTION_DCR_DEFAULT_SCOPES => [ +// 'openid', +// 'offline_access', +// ], + /*************************************************************************** * (optional) OpenID Federation-related options. If these are not set, * OpenID Federation capabilities will be disabled. diff --git a/conformance-tests/conformance-dynamic-ci.json b/conformance-tests/conformance-dynamic-ci.json new file mode 100644 index 00000000..fac721ac --- /dev/null +++ b/conformance-tests/conformance-dynamic-ci.json @@ -0,0 +1,1059 @@ +{ + "alias": "simplesamlphp-module-oidc", + "description": "oidc-provider OIDC - Dynamic Client Registration (CI). The conformance suite registers clients dynamically via the registration_endpoint advertised in discovery.", + "server": { + "discoveryUrl": "https://op.local.stack-dev.cirrusidentity.com/.well-known/openid-configuration" + }, + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Post Login Redirect", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/postredirect*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "match": "https://op.local.stack-dev.cirrusidentity.com/session/end*", + "tasks": [ + { + "task": "Choose logout option", + "match": "https://op.local.stack-dev.cirrusidentity.com/session/end*", + "commands": [ + [ + "click", + "css", + "button[autofocus] " + ] + ] + }, + { + "task": "process user choice, wait for redirect back", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/session/end/confirm", + "commands": [ + [ + "wait", + "contains", + "/test/a/simplesamlphp-module-oidc/post", + 10 + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/post*" + } + ] + }, + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "nothing to do. We redirect to postback", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session**", + "commands": [] + } + ] + } + ], + "override": { + "oidcc-prompt-login": { + "browser": [ + { + "comment": "updates placeholder during the second authorization", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Enter your username and password", + "update-image-placeholder-optional" + ], + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + } + ] + }, + "oidcc-max-age-1": { + "browser": [ + { + "comment": "updates placeholder during the second authorization", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Enter your username and password", + "update-image-placeholder-optional" + ], + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + } + ] + }, + "oidcc-ensure-registered-redirect-uri": { + "browser": [ + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect redirect uri mismatch error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Check the `redirect_uri` parameter", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-ensure-redirect-uri-in-authorization-request": { + "browser": [ + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect redirect uri mismatch error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Bad request received", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-redirect-uri-query-added": { + "browser": [ + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect redirect uri mismatch error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Bad request received", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-redirect-uri-query-mismatch": { + "browser": [ + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect redirect uri mismatch error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Bad request received", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-registration-logo-uri": { + "browser": [ + { + "comment": "expect a login page with logo", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect a login page with logo", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Enter your username and password", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-registration-policy-uri": { + "browser": [ + { + "comment": "expect a login page with policy document link", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect a login page with policy document link", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Enter your username and password", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-registration-tos-uri": { + "browser": [ + { + "comment": "expect a login page with TOS document link", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Expect a login page with TOS document link", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Enter your username and password", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-bad-post-logout-redirect-uri": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "post_logout_redirect_uri not registered", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-query-added-to-post-logout-redirect-uri": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "post_logout_redirect_uri not registered", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-modified-id-token-hint": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Token signer mismatch", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-bad-id-token-hint": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "The token was not issued by the given issuers", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-no-id-token-hint": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "expect an immediate error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect error page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "id_token_hint is mandatory when post_logout_redirect_uri is included", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-no-params": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "wait for the logout success", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect success page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Logout Successful", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-no-post-logout-redirect-uri": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "wait for the logout success", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect success page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Logout Successful", + "update-image-placeholder" + ] + ] + } + ] + } + ] + }, + "oidcc-rp-initiated-logout-only-state": { + "browser": [ + { + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/authorization*", + "tasks": [ + { + "task": "Login", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "text", + "name", + "username", + "student", + "optional" + ], + [ + "text", + "name", + "password", + "studentpass", + "optional" + ], + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Consent", + "optional": true, + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/core/loginuserpass*", + "commands": [ + [ + "click", + "id", + "submit_button" + ] + ] + }, + { + "task": "Verify Complete", + "match": "*/test/a/simplesamlphp-module-oidc/callback*", + "commands": [ + [ + "wait", + "id", + "submission_complete", + 10 + ] + ] + } + ] + }, + { + "comment": "wait for the logout success", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "tasks": [ + { + "task": "Expect success page", + "match": "https://op.local.stack-dev.cirrusidentity.com/simplesaml/module.php/oidc/end-session*", + "commands": [ + [ + "wait", + "xpath", + "//*", + 10, + "Logout Successful", + "update-image-placeholder" + ] + ] + } + ] + } + ] + } + } +} diff --git a/conformance-tests/dynamic-skips.json b/conformance-tests/dynamic-skips.json new file mode 100644 index 00000000..cddadf85 --- /dev/null +++ b/conformance-tests/dynamic-skips.json @@ -0,0 +1,20 @@ +[ + { + "comment": "Optional feature not supported: id_token_signed_response_alg=none (the OP always signs ID Tokens). The suite skips this test.", + "test-name": "oidcc-idtoken-unsigned*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*" + }, + { + "comment": "Optional feature not supported: sector_identifier_uri (pairwise sector grouping). The suite skips this test.", + "test-name": "oidcc-registration-sector-uri*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*" + }, + { + "comment": "Optional feature not supported: sector_identifier_uri (pairwise sector grouping). The suite skips this test.", + "test-name": "oidcc-registration-sector-bad*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*" + } +] diff --git a/conformance-tests/dynamic-warnings.json b/conformance-tests/dynamic-warnings.json new file mode 100644 index 00000000..0bf4f275 --- /dev/null +++ b/conformance-tests/dynamic-warnings.json @@ -0,0 +1,47 @@ +[ + { + "comment": "OP-wide gap (not DCR): discovery does not advertise userinfo_signing_alg_values_supported (signed UserInfo responses are not supported).", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "CheckDiscEndpointUserinfoSigningAlgValuesSupportedContainsRS256", + "expected-result": "failure" + }, + { + "comment": "OP-wide gap (not DCR): signed (JWT) UserInfo responses are not supported.", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "EnsureContentTypeApplicationJwt", + "expected-result": "failure" + }, + { + "comment": "OP-wide gap (not DCR): signed (JWT) UserInfo responses are not supported.", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "ValidateUserInfoResponseSignature", + "expected-result": "failure" + }, + { + "comment": "OP-wide gap (not DCR): signed (JWT) UserInfo responses are not supported.", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "ExtractSignedUserInfoFromUserInfoEndpointResponse", + "expected-result": "failure" + }, + { + "comment": "OP-wide gap (not DCR): the OP does not rotate its signing keys on demand. Not a DCR test (server_metadata variant only).", + "test-name": "oidcc-server-rotate-keys*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "VerifyNewJwksHasNewSigningKey", + "expected-result": "failure" + } +] diff --git a/docker/conformance.sql b/docker/conformance.sql index 2da2433d..74a9721c 100644 --- a/docker/conformance.sql +++ b/docker/conformance.sql @@ -32,6 +32,7 @@ INSERT INTO oidc_migration_versions VALUES('20251021000002'); INSERT INTO oidc_migration_versions VALUES('20260109000001'); INSERT INTO oidc_migration_versions VALUES('20260218163000'); INSERT INTO oidc_migration_versions VALUES('20260608130000'); +INSERT INTO oidc_migration_versions VALUES('20260624000001'); CREATE TABLE oidc_user ( id VARCHAR(191) PRIMARY KEY NOT NULL, claims TEXT, @@ -62,15 +63,16 @@ CREATE TABLE oidc_client ( created_at TIMESTAMP NULL DEFAULT NULL, expires_at TIMESTAMP NULL DEFAULT NULL, is_generic BOOLEAN NOT NULL DEFAULT false, - extra_metadata TEXT NULL + extra_metadata TEXT NULL, + registration_access_token VARCHAR(255) NULL ); -- Used 'nginx' host for back-channel logout url (https://nginx: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://nginx: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://nginx: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('_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://nginx:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, NULL, 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, 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, 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, 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://nginx:8443/test/a/simplesamlphp-module-oidc/backchannel_logout',NULL,NULL, NULL, NULL, NULL, NULL, 'manual', NULL, NULL, NULL, false, NULL, NULL); CREATE TABLE oidc_access_token ( id VARCHAR(191) PRIMARY KEY NOT NULL, scopes TEXT, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 83bb8fa3..5a634700 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,6 +12,12 @@ services: # - oidc-rp oidc-op: hostname: op.local.stack-dev.cirrusidentity.com + # The conformance suite tells the OP to fetch client jwks_uri / request_uri + # from its own host (https://localhost.emobix.co.uk:8443/...). That hostname + # is not resolvable from inside the OP container, so map it to the Docker + # host gateway, where the conformance suite is published on port 8443. + extra_hosts: + - "localhost.emobix.co.uk:host-gateway" build: context: . dockerfile: docker/Dockerfile diff --git a/docker/ssp/module_oidc.php b/docker/ssp/module_oidc.php index 9be5023e..c5ff6cfa 100644 --- a/docker/ssp/module_oidc.php +++ b/docker/ssp/module_oidc.php @@ -145,4 +145,30 @@ \SimpleSAML\Module\oidc\Codebooks\ApiScopesEnum::All, // Gives access to the whole API. ], ], + + // OpenID Connect Dynamic Client Registration (DCR). Enabled here so the + // OpenID conformance "dynamic" certification test plan can register clients + // against this OP. Open registration (no Initial Access Token) is used, + // matching what the official dynamic certification profile exercises. + ModuleConfig::OPTION_DCR_ENABLED => true, + ModuleConfig::OPTION_DCR_REGISTRATION_AUTH => + \SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum::Open->value, + // The conformance suite registers logo_uri/policy_uri/tos_uri on hosts that + // intentionally differ from the redirect_uris (e.g. tos_uri=https://openid.net), + // so impersonation protection must be off for the dynamic cert plan. The + // module default remains enabled (secure) for normal deployments. + ModuleConfig::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED => false, + + // Advertise 'claims_supported' in discovery metadata (RECOMMENDED by OpenID + // Connect Discovery and checked by the dynamic certification profile). The + // module default is false; enabled here for conformance. + ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => true, + + // The conformance suite serves client jwks_uri / request_uri over a per-instance + // self-signed TLS certificate (CN=localhost) that the OP would otherwise reject. + // Disable TLS verification for the openid library's protocol-layer HTTP fetches so + // those tests can run. NEVER do this in production. + ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS => [ + 'verify' => false, + ], ]; diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index df0364d9..7cf951ec 100644 --- a/docs/3-oidc-configuration.md +++ b/docs/3-oidc-configuration.md @@ -14,6 +14,7 @@ It complements the inline comments in `config/module_oidc.php`. - Attribute translation - Auth Proc filters (OIDC) - Client registration permissions +- OpenID Connect Dynamic Client Registration - Running multiple OPs on one server ## Caching protocol artifacts @@ -355,6 +356,41 @@ Users can visit the following link for administration: - [https://example.com/simplesaml/module.php/oidc/clients/](https://example.com/simplesaml/module.php/oidc/clients/) +## OpenID Connect Dynamic Client Registration + +The module can let Relying Parties register themselves dynamically, as described +by [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) +(which is also compatible with RFC 7591). It exposes: + +- a **Client Registration Endpoint** (`POST .../oidc/register`) that creates a + client and returns its `client_id`, `client_secret` (for confidential + clients), a `registration_access_token` and a `registration_client_uri`; and +- a **Client Configuration Endpoint** (`GET .../oidc/register?client_id=...`) + that returns the current registration when called with the + `registration_access_token` as a bearer token. + +When enabled, the registration endpoint is advertised as `registration_endpoint` +in the OP discovery metadata. Dynamically registered clients are stored like any +other client and are visible in the admin UI. + +The feature is **disabled by default**. It is configured through the following +options in `config/module_oidc.php` (see the inline comments there for the full +details and defaults): + +- `OPTION_DCR_ENABLED` — master switch for the feature. +- `OPTION_DCR_REGISTRATION_AUTH` — access-control mode: `open` registration + (the default) or `initial_access_token` (require a bearer Initial Access + Token). +- `OPTION_DCR_INITIAL_ACCESS_TOKENS` — the accepted Initial Access Tokens, + consulted only in `initial_access_token` mode. +- `OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED` — when on (the default), + the host of `logo_uri` / `policy_uri` / `tos_uri` must match the host of one of + the registered `redirect_uris` (spec Section 9.1). + +> **Security note:** open registration lets anyone create a client, so protect +> the endpoint with rate limiting at the web-server level, or require an Initial +> Access Token. + ## Running multiple OPs on one server A single module instance is designed to serve exactly one OpenID Provider diff --git a/docs/5-oidc-conformance.md b/docs/5-oidc-conformance.md index 43b6989a..21f626a7 100644 --- a/docs/5-oidc-conformance.md +++ b/docs/5-oidc-conformance.md @@ -80,8 +80,74 @@ conformance-suite/scripts/run-test-plan.py \ conformance-suite/scripts/run-test-plan.py \ "oidcc-rp-initiated-logout-certification-test-plan[response_type=code][client_registration=static_client]" \ ${OIDC_MODULE_FOLDER}/conformance-tests/conformance-rp-initiated-logout-ci.json + +# Dynamic Client Registration (DCR) +conformance-suite/scripts/run-test-plan.py \ + --expected-failures-file ${OIDC_MODULE_FOLDER}/conformance-tests/dynamic-warnings.json \ + --expected-skips-file ${OIDC_MODULE_FOLDER}/conformance-tests/dynamic-skips.json \ + "oidcc-dynamic-certification-test-plan[response_type=code]" \ + ${OIDC_MODULE_FOLDER}/conformance-tests/conformance-dynamic-ci.json ``` +### Dynamic Client Registration notes + +In `dynamic_client` mode the +conformance suite registers its own clients by POSTing client metadata to the +`registration_endpoint` advertised in discovery — and updates/deletes them via +the Client Configuration Endpoint — so it exercises the module's DCR endpoint +(`RegistrationController`) directly. No static `client` blocks are needed in +`conformance-dynamic-ci.json`. + +The module also supports Initial Access Token registration +(`DcrRegistrationAuthEnum::InitialAccessToken` plus `OPTION_DCR_INITIAL_ACCESS_TOKENS`), +but the official dynamic certification profile does not exercise that mode. To +test it manually, switch the OP to that mode and POST to the registration +endpoint with a configured token as an HTTP Bearer token. + +#### Default scopes for scope-less DCR clients + +`scope` is OPTIONAL in a registration request. When a **Dynamic** registration +omits it, the client is assigned the set configured by +`OPTION_DCR_DEFAULT_SCOPES`, which **defaults to all scopes the OP supports** +(including `offline_access`). This lets a scope-less dynamic client request any +supported scope, e.g. obtain a refresh token via `offline_access`. To restrict +this, set an explicit list in config. This applies to Dynamic registrations +only: manual (admin) and OpenID Federation automatic registrations still default +to `openid` only. An explicit but *unsupported* `scope` is not treated as +"omitted" — the unsupported values are dropped and the client ends up with +`openid` only (it does not receive the default set). + +### Known non-passing tests in the dynamic plan + +The DCR functionality passes. With the conformance image configuration, the whole +plan runs to a clean (exit 0) result: the only two non-passing tests are OP-wide +gaps unrelated to Dynamic Client Registration, recorded as expected failures in +`conformance-tests/dynamic-warnings.json` (condition-by-condition, so the runner +reports them as *expected*): + +- **OP-wide gaps (not DCR):** `oidcc-userinfo-rs256` (signed/JWT UserInfo responses + are not supported) and `oidcc-server-rotate-keys` (the OP does not rotate its + signing keys on demand). + +`conformance-tests/dynamic-skips.json` holds the genuinely optional tests the suite +itself skips: `oidcc-idtoken-unsigned` (needs `id_token_signed_response_alg=none`) +and the two `*-sector-*` tests (need `sector_identifier_uri`). + +Tests that previously failed only because of the conformance suite's self-signed +TLS certificate — `oidcc-registration-jwks-uri`, `oidcc-request-uri-unsigned`, +`oidcc-request-uri-signed-rs256` and `oidcc-refresh-token-rp-key-rotation` — now +pass because the conformance image sets `OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS` to +disable TLS verification for the `openid` library's outbound fetches (see "HTTP +client options" in `config/module_oidc.php.dist`). `oidcc-refresh-token` passes +because scope-less dynamic clients are granted `offline_access` by default (see +"Default scopes for scope-less DCR clients") and the `refresh_token` grant +authenticates `private_key_jwt` clients the same way the `authorization_code` grant +does. `request_uri` by reference works because dynamically-registered `request_uris` +are now persisted and exact-matched at the authorization endpoint. + +Because the plan is deterministic, the GitHub Actions step is a blocking gate (no +`continue-on-error`). + Prerequisites: run the docker deploy image for conformance tests (see README) and the conformance test image first. diff --git a/docs/9-oidc-dcr-client-metadata.md b/docs/9-oidc-dcr-client-metadata.md new file mode 100644 index 00000000..bba01650 --- /dev/null +++ b/docs/9-oidc-dcr-client-metadata.md @@ -0,0 +1,104 @@ +# OIDC Module - Dynamic Client Registration metadata support + +This matrix tracks how the module handles each client metadata field defined by +[OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) +(Section 2) and [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591) (OAuth 2.0 +Dynamic Client Registration), when received at the Dynamic Client Registration +(DCR) endpoint. + +The intent is that this table is the source of truth for the per-field policy. +Each field falls into one of these behaviors: + +- **Honored** — validated (where applicable), persisted, used to drive OP + behavior, and returned in the registration/read response. +- **Validated + echoed** — validated and stored/returned, but informational only + (no behavioral enforcement on the OP). +- **Inferred only** — read to derive something else, then discarded (not stored, + not returned, not enforced). +- **Rejected** — if the client requests it and the OP cannot honor it, the + registration is rejected with `invalid_client_metadata` (rather than silently + ignoring it and behaving differently than the client asked). +- **Ignored** — accepted but dropped; not returned (the omission signals to the + client that it was not registered). + +Per RFC 7591 §3.2.1 the registration response returns the metadata the OP +actually registered (including OP-applied defaults), so the response is the +contract for what was honored. + +## Matrix + +| Field | Current behavior | Proposed behavior | Notes | +|---|---|---|---| +| `redirect_uris` | Honored | Honored | Required; scheme required, fragment rejected. | +| `client_name` | Honored | Honored | Defaults to client_id. | +| `scope` | Honored | Honored | DCR default = `OPTION_DCR_DEFAULT_SCOPES`. | +| `grant_types` | **Honored** (persist + echo + enforce) | Honored | Default `["authorization_code"]`. Enforced for the code grant; refresh grant exempt (see note). | +| `response_types` | **Honored** (persist + echo + enforce) | Honored | Default `["code"]`. Enforced at the authorization endpoint. | +| `token_endpoint_auth_method` | **Honored** (persist + echo + enforce) | Honored | Default `client_secret_basic` (or `none` for public). Enforced at the token endpoint. | +| `jwks` | Honored | Honored | Stored (column). | +| `jwks_uri` | Honored | Honored | Stored (column); fetched for client auth / request objects. | +| `signed_jwks_uri` | Honored | Honored | Stored (column). | +| `request_uris` | Honored | Honored | Persisted; exact-matched for request_uri by reference; fragment allowed. | +| `post_logout_redirect_uris` | Honored | Honored | | +| `backchannel_logout_uri` | Honored | Honored | | +| `id_token_signed_response_alg` | Honored (rejects unsupported) | Honored | Precedent for "reject unsupported". | +| `application_type` | Validated + echoed | Validated + echoed | `web` / `native`. | +| `contacts` | Validated + echoed | Validated + echoed | | +| `logo_uri` | Validated + echoed | Validated + echoed | Subject to impersonation protection. | +| `policy_uri` | Validated + echoed | Validated + echoed | Subject to impersonation protection. | +| `tos_uri` | Validated + echoed | Validated + echoed | Subject to impersonation protection. | +| `client_uri` | Validated + echoed | Validated + echoed | Excluded from impersonation protection. | +| `client_registration_types` | Honored (federation) | Honored | OpenID Federation. | +| `subject_type` | Ignored | **Reject** if not `public` | Only `public` is supported (no pairwise). | +| `sector_identifier_uri` | Ignored | **Reject** if requested | Pairwise/sector grouping not supported (conformance `*-sector-*` skip). | +| `userinfo_signed_response_alg` | Ignored | **Reject** if requested | Signed UserInfo not supported (conformance `userinfo-rs256` gap). | +| `userinfo_encrypted_response_alg` / `..._enc` | Ignored | **Reject** if requested | Response encryption not supported. | +| `id_token_encrypted_response_alg` / `..._enc` | Ignored | **Reject** if requested | Response encryption not supported. | +| `request_object_signing_alg` | Ignored | TBD | Decide once request object policy is finalized. | +| `request_object_encryption_alg` / `..._enc` | Ignored | **Reject** if requested | Request object encryption not supported. | +| `token_endpoint_auth_signing_alg` | Ignored | TBD | Relevant to `private_key_jwt` / `client_secret_jwt`. | +| `default_max_age` | Ignored | TBD (validate + store + enforce) | | +| `require_auth_time` | Ignored | TBD (validate + store + enforce) | | +| `default_acr_values` | Ignored | TBD (validate + store) | | +| `initiate_login_uri` | Ignored | TBD (validate + store + echo) | | +| `backchannel_logout_session_required` | Ignored | TBD | | +| `frontchannel_logout_uri` / `..._session_required` | Ignored | **Reject** if requested | Front-channel logout not supported. | +| `software_id` / `software_version` | Ignored | TBD (store + echo) | RFC 7591 informational. | +| `software_statement` | Ignored | TBD | RFC 7591; only if signed-statement trust is implemented. | + +## Enforcement policy + +Per-client enforcement of `grant_types` / `response_types` / +`token_endpoint_auth_method` is **presence-based**: a field is enforced for a +client only when that client has it explicitly registered. Dynamically registered +clients always do (the OIDC DCR defaults are applied at registration); clients +that do not have it configured are not constrained. This avoids regressing +manually-managed clients while still honoring the registered metadata. All client +metadata is stored in the existing `extra_metadata` JSON column (no DB migration), +and will be exposed as editable fields in the admin UI. + +## Implementation order + +1. **`grant_types`, `response_types`, `token_endpoint_auth_method`** — promoted + from "inferred only" to persisted + echoed + enforced (presence-based), and + exposed as editable fields in the admin UI (multi-selects for grant/response + types, a select for the auth method), stored in `extra_metadata`. **Done** + (conformance plan stays green). +2. **Reject** the unsupported security-relevant fields (signed/encrypted + UserInfo, response/request-object encryption, `subject_type` non-`public`, + `sector_identifier_uri`, front-channel logout) instead of silently ignoring. +3. **Validate + store (+ enforce/echo)** the remaining benign behavioral fields + (`default_max_age`, `require_auth_time`, `default_acr_values`, + `initiate_login_uri`, `software_*`). + +## Note: `grant_types` vs `offline_access` / refresh tokens + +Strict `grant_types` enforcement interacts subtly with refresh tokens. A client +may register `grant_types: ["authorization_code"]` (no `refresh_token`), be +granted `offline_access`, receive a refresh token, and then use the +`refresh_token` grant. The OpenID conformance `oidcc-refresh-token` test does +exactly this. Strictly requiring `refresh_token` in `grant_types` before allowing +the refresh grant would therefore break that flow. The chosen policy must account +for this (e.g. treat a client granted `offline_access` as implicitly permitted to +use the `refresh_token` grant, or add `refresh_token` to the registered +`grant_types` when `offline_access` is in scope). diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 0be7317f..53a4f33b 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -21,6 +21,7 @@ use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController; use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController; +use SimpleSAML\Module\oidc\Controllers\RegistrationController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -105,6 +106,19 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); + // OpenID Connect Dynamic Client Registration. + // POST registers a new client (create). The Client Configuration Endpoint + // supports GET (read), PUT (update) and DELETE (delete) of an existing + // registration, authenticated with the Registration Access Token. + $routes->add(RoutesEnum::Registration->name, RoutesEnum::Registration->value) + ->controller([RegistrationController::class, 'registration']) + ->methods([ + HttpMethodsEnum::GET->value, + HttpMethodsEnum::POST->value, + HttpMethodsEnum::PUT->value, + HttpMethodsEnum::DELETE->value, + ]); + /***************************************************************************************************************** * OAuth 2.0 Authorization Server ****************************************************************************************************************/ diff --git a/routing/services/services.yml b/routing/services/services.yml index 16e0d221..31fdbdcb 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -51,6 +51,9 @@ services: SimpleSAML\Module\oidc\Server\TokenIssuers\: resource: '../../src/Server/TokenIssuers/*' + SimpleSAML\Module\oidc\Server\Registration\: + resource: '../../src/Server/Registration/*' + SimpleSAML\Module\oidc\ModuleConfig: ~ SimpleSAML\Module\oidc\Helpers: ~ SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection: ~ diff --git a/src/Codebooks/DcrRegistrationAuthEnum.php b/src/Codebooks/DcrRegistrationAuthEnum.php new file mode 100644 index 00000000..9c72ed15 --- /dev/null +++ b/src/Codebooks/DcrRegistrationAuthEnum.php @@ -0,0 +1,24 @@ + Translate::noop('Manual'), self::FederatedAutomatic => Translate::noop('Federated Automatic'), + self::Dynamic => Translate::noop('Dynamic'), }; } } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 9f86c543..fa685d76 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -42,6 +42,8 @@ enum RoutesEnum: string case UserInfo = 'userinfo'; case Jwks = 'jwks'; case EndSession = 'end-session'; + // OpenID Connect Dynamic Client Registration endpoint (create + read). + case Registration = 'register'; /***************************************************************************************************************** * OAuth 2.0 Authorization Server diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 0a2a4bde..af2d5760 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -370,6 +370,19 @@ protected function buildClientEntityFromFormData( $data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : []; $extraMetadata[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] = $allowedResponseModes; + /** @var mixed $grantTypes */ + $grantTypes = $data[ClaimsEnum::GrantTypes->value] ?? null; + $extraMetadata[ClaimsEnum::GrantTypes->value] = is_array($grantTypes) ? + $grantTypes : []; + /** @var mixed $responseTypes */ + $responseTypes = $data[ClaimsEnum::ResponseTypes->value] ?? null; + $extraMetadata[ClaimsEnum::ResponseTypes->value] = is_array($responseTypes) ? + $responseTypes : []; + /** @var mixed $tokenEndpointAuthMethod */ + $tokenEndpointAuthMethod = $data[ClaimsEnum::TokenEndpointAuthMethod->value] ?? null; + $extraMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] = is_string($tokenEndpointAuthMethod) ? + $tokenEndpointAuthMethod : null; + // Per-client authproc filters. These are administrator-only (settable // here, via the admin UI), and are deliberately never accepted from // client-supplied registration metadata. See diff --git a/src/Controllers/RegistrationController.php b/src/Controllers/RegistrationController.php new file mode 100644 index 00000000..43502f01 --- /dev/null +++ b/src/Controllers/RegistrationController.php @@ -0,0 +1,338 @@ +moduleConfig->getDcrEnabled()) { + $this->logger->error('RegistrationController: registration endpoint is disabled.'); + return $this->routes->newResponse('', Response::HTTP_NOT_FOUND); + } + + return match (strtoupper($request->getMethod())) { + HttpMethodsEnum::POST->value => $this->register($request), + HttpMethodsEnum::GET->value => $this->read($request), + HttpMethodsEnum::PUT->value => $this->update($request), + HttpMethodsEnum::DELETE->value => $this->delete($request), + default => $this->routes->newResponse( + '', + Response::HTTP_METHOD_NOT_ALLOWED, + ['Allow' => implode(', ', [ + HttpMethodsEnum::GET->value, + HttpMethodsEnum::POST->value, + HttpMethodsEnum::PUT->value, + HttpMethodsEnum::DELETE->value, + ])], + ), + }; + } catch (OAuthServerException $exception) { + $this->logger->error( + 'RegistrationController: error processing registration request: ' . $exception->getMessage(), + ); + return $this->errorResponder->forExceptionJson($exception); + } catch (\Throwable $exception) { + $this->logger->error( + 'RegistrationController: error processing registration request: ' . $exception->getMessage(), + ); + + return $this->errorResponder->forExceptionJson( + OidcServerException::serverError('Unable to process the registration request.'), + ); + } + } + + /** + * Handle a Client Registration Request (Section 3.1). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function register(Request $request): Response + { + $this->guardAccess($request); + + $metadata = $this->parseMetadata($request); + $metadata = $this->clientMetadataValidator->validate($metadata); + + $client = $this->clientEntityFactory->fromRegistrationData($metadata, RegistrationTypeEnum::Dynamic); + + // Issue a Registration Access Token (RAT); only its hash is persisted, + // the plaintext is returned once. + $registrationAccessToken = $this->helpers->random()->getIdentifier(); + $client->setRegistrationAccessTokenHash($this->hashToken($registrationAccessToken)); + + $this->clientRepository->add($client); + + $response = $this->buildClientInformationResponse($client); + $response[ClaimsEnum::RegistrationAccessToken->value] = $registrationAccessToken; + + return $this->jsonResponse($response, Response::HTTP_CREATED); + } + + /** + * Handle a Client Read Request (Section 4.2) at the Client Configuration + * Endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function read(Request $request): Response + { + $client = $this->authenticateConfigurationRequest($request); + + return $this->jsonResponse($this->buildClientInformationResponse($client), Response::HTTP_OK); + } + + /** + * Handle a Client Update Request (RFC 7592, Section 2.2) at the Client + * Configuration Endpoint. The request fully replaces the client's metadata. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function update(Request $request): Response + { + $client = $this->authenticateConfigurationRequest($request); + + $metadata = $this->parseMetadata($request); + + // If the body carries client_id / client_secret, they MUST match the + // current client (RFC 7592, Section 2.2). The client_secret is then + // dropped so it cannot be used to override the stored value. + /** @var mixed $bodyClientId */ + $bodyClientId = $metadata[ClaimsEnum::ClientId->value] ?? null; + if ($bodyClientId !== null && $bodyClientId !== $client->getIdentifier()) { + throw OidcServerException::invalidClientMetadata('The client_id must match the client being updated.'); + } + /** @var mixed $bodyClientSecret */ + $bodyClientSecret = $metadata[ClaimsEnum::ClientSecret->value] ?? null; + if ($bodyClientSecret !== null && $bodyClientSecret !== $client->getSecret()) { + throw OidcServerException::invalidClientMetadata( + 'The client_secret must match the client being updated.', + ); + } + unset($metadata[ClaimsEnum::ClientSecret->value]); + + $metadata = $this->clientMetadataValidator->validate($metadata); + + $updatedClient = $this->clientEntityFactory->fromRegistrationData( + $metadata, + RegistrationTypeEnum::Dynamic, + existingClient: $client, + ); + + $this->clientRepository->update($updatedClient); + + return $this->jsonResponse($this->buildClientInformationResponse($updatedClient), Response::HTTP_OK); + } + + /** + * Handle a Client Delete Request (RFC 7592, Section 2.3) at the Client + * Configuration Endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function delete(Request $request): Response + { + $client = $this->authenticateConfigurationRequest($request); + + $this->clientRepository->delete($client); + + return $this->routes->newResponse('', Response::HTTP_NO_CONTENT); + } + + /** + * Authenticate a Client Configuration Endpoint request (read / update / + * delete) using the client_id query parameter and the Registration Access + * Token, returning the resolved client. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function authenticateConfigurationRequest(Request $request): ClientEntityInterface + { + /** @var mixed $clientId */ + $clientId = $request->query->all()[ClaimsEnum::ClientId->value] ?? null; + $token = $this->helpers->http()->getBearerToken($request->headers->get('Authorization')); + + if (!is_string($clientId) || $clientId === '' || $token === null) { + throw OidcServerException::accessDenied('A valid client_id and Registration Access Token are required.'); + } + + $client = $this->clientRepository->findById($clientId); + $expectedHash = $client?->getRegistrationAccessTokenHash(); + + // Per Section 4.4, never reveal whether a client exists: respond 401 + // for every failure case (not 404). + if ( + $client === null || + $client->getRegistrationType() !== RegistrationTypeEnum::Dynamic || + $expectedHash === null || + !hash_equals($expectedHash, $this->hashToken($token)) + ) { + throw OidcServerException::accessDenied('Invalid Registration Access Token.'); + } + + return $client; + } + + /** + * Enforce the configured access-control mode for the registration endpoint. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function guardAccess(Request $request): void + { + if ($this->moduleConfig->getDcrRegistrationAuth() !== DcrRegistrationAuthEnum::InitialAccessToken) { + return; + } + + $token = $this->helpers->http()->getBearerToken($request->headers->get('Authorization')); + $allowedTokens = $this->moduleConfig->getDcrInitialAccessTokens(); + + if ($token === null) { + throw OidcServerException::accessDenied('A valid Initial Access Token is required.'); + } + + foreach ($allowedTokens as $allowedToken) { + if (hash_equals($allowedToken, $token)) { + return; + } + } + + throw OidcServerException::accessDenied('The provided Initial Access Token is not valid.'); + } + + /** + * Parse and JSON-decode the request body into a metadata array. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function parseMetadata(Request $request): array + { + $body = $request->getContent(); + + try { + /** @var mixed $decoded */ + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw OidcServerException::invalidClientMetadata('The request body must be a valid JSON object.'); + } + + if (!is_array($decoded) || array_is_list($decoded)) { + throw OidcServerException::invalidClientMetadata('The request body must be a JSON object.'); + } + + return $decoded; + } + + /** + * Build the Client Information Response (Section 3.2 / 4.3) from the + * persisted client. + */ + protected function buildClientInformationResponse(ClientEntityInterface $client): array + { + $response = [ + ClaimsEnum::ClientId->value => $client->getIdentifier(), + ClaimsEnum::ClientIdIssuedAt->value => $client->getCreatedAt()?->getTimestamp(), + ClaimsEnum::RegistrationClientUri->value => $this->routes->getModuleUrl( + RoutesEnum::Registration->value, + [ClaimsEnum::ClientId->value => $client->getIdentifier()], + ), + ClaimsEnum::RedirectUris->value => $client->getRedirectUris(), + ClaimsEnum::ClientName->value => $client->getName(), + ClaimsEnum::Scope->value => implode(' ', $client->getScopes()), + ]; + + if ($client->isConfidential()) { + $response[ClaimsEnum::ClientSecret->value] = $client->getSecret(); + // 0 indicates the client secret does not expire. + $response[ClaimsEnum::ClientSecretExpiresAt->value] = 0; + } + + if (($idTokenSignedResponseAlg = $client->getIdTokenSignedResponseAlg()) !== null) { + $response[ClaimsEnum::IdTokenSignedResponseAlg->value] = $idTokenSignedResponseAlg; + } + + if (($requestUris = $client->getRequestUris()) !== []) { + $response[ClaimsEnum::RequestUris->value] = $requestUris; + } + + $response[ClaimsEnum::GrantTypes->value] = $client->getGrantTypes(); + $response[ClaimsEnum::ResponseTypes->value] = $client->getResponseTypes(); + $response[ClaimsEnum::TokenEndpointAuthMethod->value] = $client->getTokenEndpointAuthMethod(); + + // Echo back the stored informational ("store & echo") metadata. + $extraMetadata = $client->getExtraMetadata(); + foreach (ClientEntityFactory::STORE_AND_ECHO_METADATA_KEYS as $key) { + if (array_key_exists($key, $extraMetadata)) { + /** @psalm-suppress MixedAssignment */ + $response[$key] = $extraMetadata[$key]; + } + } + + return $response; + } + + protected function hashToken(string $token): string + { + return hash(self::HASH_ALGORITHM, $token); + } + + protected function jsonResponse(array $body, int $status): Response + { + return $this->routes->newJsonResponse( + $body, + $status, + ['Cache-Control' => 'no-store', 'Pragma' => 'no-cache'], + ); + } +} diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 72ed1354..02fb9eb2 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -23,7 +23,10 @@ use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum; +use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; class ClientEntity implements ClientEntityInterface { @@ -55,6 +58,11 @@ class ClientEntity implements ClientEntityInterface public const string KEY_EXPIRES_AT = 'expires_at'; public const string KEY_IS_GENERIC = 'is_generic'; public const string KEY_EXTRA_METADATA = 'extra_metadata'; + /** + * Hash of the OpenID Connect Dynamic Client Registration Access Token, used to authenticate read requests at + * the Client Configuration Endpoint. The plaintext token is shown to the client only once (at registration). + */ + public const string KEY_REGISTRATION_ACCESS_TOKEN = 'registration_access_token'; public const string KEY_ALLOWED_RESPONSE_MODES = 'allowed_response_modes'; /** * Per-client Authentication Processing Filters. Stored as an entry inside @@ -120,6 +128,7 @@ class ClientEntity implements ClientEntityInterface private ?DateTimeImmutable $expiresAt; private bool $isGeneric; private ?array $extraMetadata; + private ?string $registrationAccessToken; /** * @param string[] $redirectUri @@ -154,6 +163,7 @@ public function __construct( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ) { $this->identifier = $identifier; $this->secret = $secret; @@ -179,6 +189,7 @@ public function __construct( $this->expiresAt = $expiresAt; $this->isGeneric = $isGeneric; $this->extraMetadata = $extraMetadata; + $this->registrationAccessToken = $registrationAccessToken; } /** @@ -220,6 +231,7 @@ public function getState(): array self::KEY_EXTRA_METADATA => is_null($this->extraMetadata) ? null : json_encode($this->extraMetadata, JSON_THROW_ON_ERROR), + self::KEY_REGISTRATION_ACCESS_TOKEN => $this->registrationAccessToken, ]; } @@ -256,7 +268,11 @@ public function toArray(): array ClaimsEnum::RequirePushedAuthorizationRequests->value => $this->getRequirePushedAuthorizationRequests(), ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(), ClaimsEnum::RequestUris->value => $this->getRequestUris(), + ClaimsEnum::GrantTypes->value => $this->getGrantTypes(), + ClaimsEnum::ResponseTypes->value => $this->getResponseTypes(), + ClaimsEnum::TokenEndpointAuthMethod->value => $this->getTokenEndpointAuthMethod(), self::KEY_AUTH_PROC_FILTERS => $this->getAuthProcFilters(), + self::KEY_REGISTRATION_ACCESS_TOKEN => $this->registrationAccessToken, ]; } @@ -401,6 +417,20 @@ public function getExtraMetadata(): array return $this->extraMetadata ?? []; } + /** + * Hash of the Registration Access Token associated with this client, or null if none was issued (e.g. clients + * not created via OIDC Dynamic Client Registration). + */ + public function getRegistrationAccessTokenHash(): ?string + { + return $this->registrationAccessToken; + } + + public function setRegistrationAccessTokenHash(?string $registrationAccessTokenHash): void + { + $this->registrationAccessToken = $registrationAccessTokenHash; + } + public function getIdTokenSignedResponseAlg(): ?string { if (!is_array($this->extraMetadata)) { @@ -495,4 +525,62 @@ public function getRequestUris(): array return $stringUris; } + + /** + * The OAuth 2.0 grant types the client is registered to use. Defaults to ["authorization_code"] + * (OpenID Connect Dynamic Client Registration 1.0 default) when not explicitly registered. + * + * @return string[] + */ + public function getGrantTypes(): array + { + /** @var mixed $grantTypes */ + $grantTypes = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::GrantTypes->value] ?? null) : null; + + if (!is_array($grantTypes)) { + return [GrantTypesEnum::AuthorizationCode->value]; + } + + return array_values(array_filter($grantTypes, 'is_string')); + } + + /** + * The OAuth 2.0 response types the client is registered to use. Defaults to ["code"] + * (OpenID Connect Dynamic Client Registration 1.0 default) when not explicitly registered. + * + * @return string[] + */ + public function getResponseTypes(): array + { + /** @var mixed $responseTypes */ + $responseTypes = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::ResponseTypes->value] ?? null) : null; + + if (!is_array($responseTypes)) { + return [ResponseTypesEnum::Code->value]; + } + + return array_values(array_filter($responseTypes, 'is_string')); + } + + /** + * The client authentication method the client is registered to use at the token endpoint. Defaults to + * 'client_secret_basic' for confidential clients and 'none' for public clients when not explicitly registered + * (OpenID Connect Dynamic Client Registration 1.0). + */ + public function getTokenEndpointAuthMethod(): string + { + /** @var mixed $method */ + $method = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] ?? null) : null; + + if (is_string($method) && $method !== '') { + return $method; + } + + return $this->isConfidential() ? + TokenEndpointAuthMethodsEnum::ClientSecretBasic->value : + TokenEndpointAuthMethodsEnum::None->value; + } } diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index 6d66c544..ad25b385 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -81,6 +81,8 @@ public function isExpired(): bool; public function isGeneric(): bool; public function getExtraMetadata(): array; + public function getRegistrationAccessTokenHash(): ?string; + public function setRegistrationAccessTokenHash(?string $registrationAccessTokenHash): void; public function getIdTokenSignedResponseAlg(): ?string; public function getAllowedResponseModes(): array; public function getRequirePushedAuthorizationRequests(): bool; @@ -90,6 +92,18 @@ public function getRequireSignedRequestObject(): bool; */ public function getRequestUris(): array; + /** + * @return string[] + */ + public function getGrantTypes(): array; + + /** + * @return string[] + */ + public function getResponseTypes(): array; + + public function getTokenEndpointAuthMethod(): string; + /** * @return array */ diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 79224cd4..8f00053e 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -21,6 +21,25 @@ class ClientEntityFactory { + /** + * Informational ("store & echo") client metadata that is persisted as-is + * into the extra metadata blob when present in registration data, so it + * can be echoed back in registration/read responses. These carry no + * behavioral enforcement on the OP. Format/security validation + * (and impersonation protection) happens at the registration boundary; + * see \SimpleSAML\Module\oidc\Server\Registration\ClientMetadataValidator. + * + * @var string[] + */ + public const array STORE_AND_ECHO_METADATA_KEYS = [ + ClaimsEnum::LogoUri->value, + ClaimsEnum::ClientUri->value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ClaimsEnum::Contacts->value, + ClaimsEnum::ApplicationType->value, + ]; + public function __construct( private readonly SspBridge $sspBridge, private readonly Helpers $helpers, @@ -61,6 +80,7 @@ public function fromData( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ): ClientEntityInterface { return new ClientEntity( $id, @@ -87,6 +107,7 @@ public function fromData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -148,9 +169,21 @@ public function fromRegistrationData( throw OidcServerException::accessDenied('redirect URIs missing'); $redirectUris = $this->helpers->arr()->ensureStringValues($metadata[ClaimsEnum::RedirectUris->value]); - $scopes = $metadata[ClaimsEnum::Scope->value] ?? $existingClient?->getScopes(); - $scopes = is_array($scopes) ? $this->helpers->arr()->ensureStringValues($scopes) : - $this->helpers->str()->convertScopesStringToArray((string)$scopes); + // Resolve the requested scopes: from this request's metadata, falling back to an existing client's scopes + // (e.g. on a DCR update that omits `scope`). null here means scopes were genuinely not specified. + $requestedScopes = $metadata[ClaimsEnum::Scope->value] ?? $existingClient?->getScopes(); + if ($requestedScopes === null) { + // No scope was specified. For Dynamic Client Registration, assign the configured default scope set + // (OIDC DCR 1.0 lets the OP assign a default set). Manual and OpenID Federation automatic registrations + // keep the conservative `openid`-only default. Note: an explicit but unsupported `scope` is NOT treated + // as "not specified" - it falls through to the supported-scope filter below and ends up as `openid` only. + $scopes = $registrationType === RegistrationTypeEnum::Dynamic + ? $this->moduleConfig->getDcrDefaultScopes() + : [ScopesEnum::OpenId->value]; + } else { + $scopes = is_array($requestedScopes) ? $this->helpers->arr()->ensureStringValues($requestedScopes) : + $this->helpers->str()->convertScopesStringToArray((string)$requestedScopes); + } // Filter to only allowed scopes $scopes = array_filter( $scopes, @@ -214,6 +247,10 @@ public function fromRegistrationData( $isGeneric = $existingClient?->isGeneric() ?? false; + // Carry over any Registration Access Token hash from an existing client. For a newly registered client this + // is null here; the registration controller generates and assigns the token after building the entity. + $registrationAccessToken = $existingClient?->getRegistrationAccessTokenHash(); + $extraMetadata = $existingClient?->getExtraMetadata() ?? []; // Handle any other supported client metadata as extra metadata. @@ -243,6 +280,71 @@ public function fromRegistrationData( $extraMetadata[ClaimsEnum::IdTokenSignedResponseAlg->value] = $idTokenSignedResponseAlg; + // request_uris: persisted into extra metadata so that Request Objects passed by reference (request_uri, + // RFC 9101) can be exact-matched at the authorization endpoint when require_request_uri_registration is on + // (see ClientEntity::getRequestUris() and RequestParamsResolver::isHttpsRequestUriFetchAllowed()). Unlike + // the store-and-echo keys below this one IS behaviorally enforced. When omitted on update, any existing + // value is preserved (it is already carried over from the existing client's extra metadata above). + if ( + isset($metadata[ClaimsEnum::RequestUris->value]) && + is_array($metadata[ClaimsEnum::RequestUris->value]) + ) { + $extraMetadata[ClaimsEnum::RequestUris->value] = $this->helpers->arr()->ensureStringValues( + $metadata[ClaimsEnum::RequestUris->value], + ); + } + + // grant_types / response_types / token_endpoint_auth_method: persisted so they can be returned in the + // registration response (RFC 7591 Section 3.2.1) and, going forward, enforced. For Dynamic registrations + // the OIDC DCR 1.0 defaults are applied when the client does not provide them; manual and federation + // registrations are left untouched (any existing value is carried over from the existing client above). + if (isset($metadata[ClaimsEnum::GrantTypes->value]) && is_array($metadata[ClaimsEnum::GrantTypes->value])) { + $extraMetadata[ClaimsEnum::GrantTypes->value] = $this->helpers->arr()->ensureStringValues( + $metadata[ClaimsEnum::GrantTypes->value], + ); + } elseif ( + $registrationType === RegistrationTypeEnum::Dynamic && + !array_key_exists(ClaimsEnum::GrantTypes->value, $extraMetadata) + ) { + $extraMetadata[ClaimsEnum::GrantTypes->value] = [GrantTypesEnum::AuthorizationCode->value]; + } + + if ( + isset($metadata[ClaimsEnum::ResponseTypes->value]) && + is_array($metadata[ClaimsEnum::ResponseTypes->value]) + ) { + $extraMetadata[ClaimsEnum::ResponseTypes->value] = $this->helpers->arr()->ensureStringValues( + $metadata[ClaimsEnum::ResponseTypes->value], + ); + } elseif ( + $registrationType === RegistrationTypeEnum::Dynamic && + !array_key_exists(ClaimsEnum::ResponseTypes->value, $extraMetadata) + ) { + $extraMetadata[ClaimsEnum::ResponseTypes->value] = [ResponseTypesEnum::Code->value]; + } + + if ( + isset($metadata[ClaimsEnum::TokenEndpointAuthMethod->value]) && + is_string($metadata[ClaimsEnum::TokenEndpointAuthMethod->value]) + ) { + $extraMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] = + $metadata[ClaimsEnum::TokenEndpointAuthMethod->value]; + } elseif ( + $registrationType === RegistrationTypeEnum::Dynamic && + !array_key_exists(ClaimsEnum::TokenEndpointAuthMethod->value, $extraMetadata) + ) { + $extraMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] = $isConfidential ? + TokenEndpointAuthMethodsEnum::ClientSecretBasic->value : + TokenEndpointAuthMethodsEnum::None->value; + } + + // Persist informational ("store & echo") metadata so it can be returned in registration/read responses. + foreach (self::STORE_AND_ECHO_METADATA_KEYS as $storeAndEchoKey) { + if (array_key_exists($storeAndEchoKey, $metadata)) { + /** @psalm-suppress MixedAssignment */ + $extraMetadata[$storeAndEchoKey] = $metadata[$storeAndEchoKey]; + } + } return $this->fromData( $id, @@ -269,6 +371,7 @@ public function fromRegistrationData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -404,6 +507,10 @@ public function fromState(array $state): ClientEntityInterface null : json_decode((string)$state[ClientEntity::KEY_EXTRA_METADATA], true, 512, JSON_THROW_ON_ERROR); + $registrationAccessToken = empty($state[ClientEntity::KEY_REGISTRATION_ACCESS_TOKEN]) ? + null : + (string)$state[ClientEntity::KEY_REGISTRATION_ACCESS_TOKEN]; + return $this->fromData( $id, $secret, @@ -429,6 +536,7 @@ public function fromState(array $state): ClientEntityInterface $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } diff --git a/src/Factories/Grant/RefreshTokenGrantFactory.php b/src/Factories/Grant/RefreshTokenGrantFactory.php index c12c26bb..388b4086 100644 --- a/src/Factories/Grant/RefreshTokenGrantFactory.php +++ b/src/Factories/Grant/RefreshTokenGrantFactory.php @@ -20,6 +20,7 @@ use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Grants\RefreshTokenGrant; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; class RefreshTokenGrantFactory { @@ -28,6 +29,7 @@ public function __construct( private readonly RefreshTokenRepository $refreshTokenRepository, private readonly AccessTokenEntityFactory $accessTokenEntityFactory, private readonly RefreshTokenIssuer $refreshTokenIssuer, + private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, ) { } @@ -37,6 +39,7 @@ public function build(): RefreshTokenGrant $this->refreshTokenRepository, $this->accessTokenEntityFactory, $this->refreshTokenIssuer, + $this->authenticatedOAuth2ClientResolver, ); $refreshTokenGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/JwksFactory.php b/src/Factories/JwksFactory.php index 2e277c54..30661cd4 100644 --- a/src/Factories/JwksFactory.php +++ b/src/Factories/JwksFactory.php @@ -29,6 +29,7 @@ public function build(): Jwks maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), cache: $this->federationCache?->cache, logger: $this->loggerService, + httpClientConfig: $this->moduleConfig->getProtocolHttpClientOptions(), ); } } diff --git a/src/Factories/RequestObjectFactory.php b/src/Factories/RequestObjectFactory.php index 65fd09a5..d71739c9 100644 --- a/src/Factories/RequestObjectFactory.php +++ b/src/Factories/RequestObjectFactory.php @@ -27,6 +27,7 @@ public function build(): RequestObject supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), logger: $this->loggerService, + httpClientConfig: $this->moduleConfig->getProtocolHttpClientOptions(), ); } } diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index e85685ce..798b33a3 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -25,6 +25,9 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; +use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum; +use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; use Traversable; /** @@ -385,6 +388,26 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co array_keys($this->getAllowedResponseModesValues()), ); + /** @var mixed $grantTypes */ + $grantTypes = $values[ClaimsEnum::GrantTypes->value] ?? null; + $grantTypes = is_array($grantTypes) ? $grantTypes : []; + $values[ClaimsEnum::GrantTypes->value] = array_values( + array_intersect($grantTypes, array_keys($this->getSupportedGrantTypes())), + ); + + /** @var mixed $responseTypes */ + $responseTypes = $values[ClaimsEnum::ResponseTypes->value] ?? null; + $responseTypes = is_array($responseTypes) ? $responseTypes : []; + $values[ClaimsEnum::ResponseTypes->value] = array_values( + array_intersect($responseTypes, array_keys($this->getSupportedResponseTypes())), + ); + + /** @var mixed $tokenEndpointAuthMethod */ + $tokenEndpointAuthMethod = $values[ClaimsEnum::TokenEndpointAuthMethod->value] ?? ''; + $tokenEndpointAuthMethod = is_string($tokenEndpointAuthMethod) ? trim($tokenEndpointAuthMethod) : ''; + $values[ClaimsEnum::TokenEndpointAuthMethod->value] = $tokenEndpointAuthMethod === '' ? + null : $tokenEndpointAuthMethod; + $authProcFilters = trim((string)($values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? '')); try { /** @psalm-suppress MixedAssignment */ @@ -476,6 +499,28 @@ public function setDefaults(object|array $values, bool $erase = false): static $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES], ) ? $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : []; + /** @var mixed $grantTypes */ + $grantTypes = $values[ClaimsEnum::GrantTypes->value] ?? null; + $grantTypes = is_array($grantTypes) ? $grantTypes : []; + $values[ClaimsEnum::GrantTypes->value] = array_values( + array_intersect($grantTypes, array_keys($this->getSupportedGrantTypes())), + ); + + /** @var mixed $responseTypes */ + $responseTypes = $values[ClaimsEnum::ResponseTypes->value] ?? null; + $responseTypes = is_array($responseTypes) ? $responseTypes : []; + $values[ClaimsEnum::ResponseTypes->value] = array_values( + array_intersect($responseTypes, array_keys($this->getSupportedResponseTypes())), + ); + + /** @var mixed $tokenEndpointAuthMethod */ + $tokenEndpointAuthMethod = $values[ClaimsEnum::TokenEndpointAuthMethod->value] ?? null; + $values[ClaimsEnum::TokenEndpointAuthMethod->value] = (is_string($tokenEndpointAuthMethod) && + array_key_exists( + $tokenEndpointAuthMethod, + $this->getSupportedTokenEndpointAuthMethods(), + )) ? $tokenEndpointAuthMethod : null; + /** @var mixed $authProcFilters */ $authProcFilters = $values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null; $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = (is_array($authProcFilters) && $authProcFilters !== []) ? @@ -588,6 +633,27 @@ protected function buildForm(): void $this->addTextArea(ClaimsEnum::RequestUris->value, 'Request URIs (OIDC Core / JAR, one per line)', null, 5) ->setHtmlAttribute('class', 'full-width'); + $this->addMultiSelect( + ClaimsEnum::GrantTypes->value, + Translate::noop('Grant Types'), + $this->getSupportedGrantTypes(), + 3, + )->setHtmlAttribute('class', 'full-width'); + + $this->addMultiSelect( + ClaimsEnum::ResponseTypes->value, + Translate::noop('Response Types'), + $this->getSupportedResponseTypes(), + 3, + )->setHtmlAttribute('class', 'full-width'); + + $this->addSelect( + ClaimsEnum::TokenEndpointAuthMethod->value, + Translate::noop('Token Endpoint Authentication Method'), + )->setHtmlAttribute('class', 'full-width') + ->setItems($this->getSupportedTokenEndpointAuthMethods(), false) + ->setPrompt(Translate::noop('-')); + $this->addTextArea( ClientEntity::KEY_AUTH_PROC_FILTERS, Translate::noop('Authentication Processing Filters'), @@ -640,6 +706,57 @@ protected function getAllowedResponseModesValues(): array return array_combine($supported, $supported); } + /** + * Grant types the client may be registered to use (value => label), matching the OP's + * grant_types_supported. + * + * @return array + */ + protected function getSupportedGrantTypes(): array + { + $supported = [ + GrantTypesEnum::AuthorizationCode->value, + GrantTypesEnum::Implicit->value, + GrantTypesEnum::RefreshToken->value, + ]; + + return array_combine($supported, $supported); + } + + /** + * Response types the client may be registered to use (value => label), matching the OP's + * response_types_supported. + * + * @return array + */ + protected function getSupportedResponseTypes(): array + { + $supported = [ + ResponseTypesEnum::Code->value, + ResponseTypesEnum::IdToken->value, + ResponseTypesEnum::IdTokenToken->value, + ]; + + return array_combine($supported, $supported); + } + + /** + * Token endpoint authentication methods the client may be registered to use (value => label). + * + * @return array + */ + protected function getSupportedTokenEndpointAuthMethods(): array + { + $supported = [ + TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, + TokenEndpointAuthMethodsEnum::ClientSecretPost->value, + TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value, + TokenEndpointAuthMethodsEnum::None->value, + ]; + + return array_combine($supported, $supported); + } + /** * @throws \Exception */ diff --git a/src/Helpers/Http.php b/src/Helpers/Http.php index 8ed69d59..511ba963 100644 --- a/src/Helpers/Http.php +++ b/src/Helpers/Http.php @@ -38,4 +38,30 @@ public function getAllRequestParamsBasedOnAllowedMethods( default => null, }; } + + /** + * Extract a Bearer token from an Authorization header value (RFC 6750, + * Section 2.1), or null if no (non-empty) Bearer token is present. The + * "Bearer" scheme is matched case-insensitively. + * + * This operates on the raw header string (rather than a request object) so + * it can be used uniformly regardless of the HTTP request abstraction in + * use (PSR-7 ServerRequestInterface, Symfony HttpFoundation Request, ...). + * Callers pass the header value, e.g. PSR `$request->getHeaderLine('Authorization')` + * or Symfony `$request->headers->get('Authorization')`. + */ + public function getBearerToken(?string $authorizationHeaderValue): ?string + { + if ($authorizationHeaderValue === null) { + return null; + } + + if (preg_match('/^Bearer\s+(.+)$/i', $authorizationHeaderValue, $matches) !== 1) { + return null; + } + + $token = trim($matches[1]); + + return $token === '' ? null : $token; + } } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index e8fb2b39..308fc04a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -22,6 +22,7 @@ use SimpleSAML\Configuration; use SimpleSAML\Error\ConfigurationError; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\DcrRegistrationAuthEnum; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; @@ -101,6 +102,7 @@ class ModuleConfig final public const string OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION = 'protocol_client_entity_cache_duration'; final public const string OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED = 'protocol_discover_show_claims_supported'; + final public const string OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS = 'protocol_http_client_options'; final public const string OPTION_VCI_ENABLED = 'vci_enabled'; final public const string OPTION_VCI_CREDENTIAL_CONFIGURATIONS_SUPPORTED = @@ -126,7 +128,12 @@ class ModuleConfig final public const string OPTION_TIMESTAMP_VALIDATION_LEEWAY = 'timestamp_validation_leeway'; final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; - + final public const string OPTION_DCR_ENABLED = 'dcr_enabled'; + final public const string OPTION_DCR_REGISTRATION_AUTH = 'dcr_registration_auth'; + final public const string OPTION_DCR_INITIAL_ACCESS_TOKENS = 'dcr_initial_access_tokens'; + final public const string OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED = + 'dcr_impersonation_protection_enabled'; + final public const string OPTION_DCR_DEFAULT_SCOPES = 'dcr_default_scopes'; final public const string OPTION_PAR_REQUEST_URI_TTL = 'par_request_uri_ttl'; final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'require_pushed_authorization_requests'; final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'require_signed_request_object'; @@ -662,7 +669,7 @@ public function getProtocolUserEntityCacheDuration(): DateInterval } /** - * Get cache duration for client entities (user data), with given default + * Get cache duration for client entities (user data), with the given default * * @throws \Exception */ @@ -684,6 +691,33 @@ public function getProtocolDiscoveryShowClaimsSupported(): bool ); } + /** + * Guzzle HTTP client options for the protocol-layer outbound fetches performed by the `openid` library + * (e.g. fetching a client's `jwks_uri` or a `request_uri`). The array is passed through verbatim to the + * underlying Guzzle client, see https://docs.guzzlephp.org/en/stable/request-options.html + * + * Default is an empty array (the library's secure defaults apply, i.e. TLS verification ON). The primary + * intended use is testing against endpoints with self-signed certificates (e.g. the OpenID conformance + * suite) by setting `['verify' => false]`. DO NOT disable TLS verification in production. + * + * @return array + * @throws \Exception + */ + public function getProtocolHttpClientOptions(): array + { + $options = $this->config()->getOptionalArray(self::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS, []); + + // Guzzle request options are keyed by string option names; normalize keys to satisfy that contract. + $normalized = []; + /** @var mixed $value */ + foreach ($options as $key => $value) { + /** @psalm-suppress MixedAssignment */ + $normalized[(string)$key] = $value; + } + + return $normalized; + } + /***************************************************************************************************************** * OpenID Federation related config. @@ -974,6 +1008,98 @@ public function getVciEnabled(): bool } + /***************************************************************************************************************** + * OpenID Connect Dynamic Client Registration related config. + ****************************************************************************************************************/ + + /** + * Master switch for the OIDC Dynamic Client Registration capability. When + * disabled (default), the registration and client-configuration endpoints + * are not served, and `registration_endpoint` is not advertised in OP + * metadata. + */ + public function getDcrEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_DCR_ENABLED, false); + } + + /** + * Access-control mode for the registration endpoint: open registration + * (default) or gated behind an Initial Access Token. + */ + public function getDcrRegistrationAuth(): DcrRegistrationAuthEnum + { + return DcrRegistrationAuthEnum::from( + $this->config()->getOptionalString( + self::OPTION_DCR_REGISTRATION_AUTH, + DcrRegistrationAuthEnum::Open->value, + ), + ); + } + + /** + * Static allowlist of opaque Initial Access Tokens accepted by the + * registration endpoint when the access mode is + * DcrRegistrationAuthEnum::InitialAccessToken. Issuance is out-of-band + * (per spec). + * + * @return string[] + */ + public function getDcrInitialAccessTokens(): array + { + $tokens = $this->config()->getOptionalArray(self::OPTION_DCR_INITIAL_ACCESS_TOKENS, []); + + $stringTokens = []; + /** @var mixed $token */ + foreach ($tokens as $token) { + if (is_string($token) && $token !== '') { + $stringTokens[] = $token; + } + } + + return $stringTokens; + } + + /** + * Whether impersonation protection (OIDC Dynamic Client Registration 1.0, + * Section 9.1) is enforced. When on (default), the host of `logo_uri`, + * `policy_uri` and `tos_uri` must match the host of one of the registered + * `redirect_uris`, otherwise registration is rejected. + */ + public function getDcrImpersonationProtectionEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED, true); + } + + /** + * Scopes assigned to a Dynamic Client Registration (DCR) client that registers without an explicit `scope`. + * OpenID Connect Dynamic Client Registration 1.0 makes `scope` OPTIONAL and lets the OP assign a default set; + * this controls that set. When the option is not configured, it defaults to all scopes this OP supports (so a + * scope-less dynamic client can request any supported scope, including offline_access). This applies only to + * Dynamic registrations; manual and OpenID Federation automatic registrations are unaffected. + * + * @return string[] + * @throws \Exception + */ + public function getDcrDefaultScopes(): array + { + $configured = $this->config()->getOptionalArray( + self::OPTION_DCR_DEFAULT_SCOPES, + array_keys($this->getScopes()), + ); + + $scopes = []; + /** @var mixed $scope */ + foreach ($configured as $scope) { + if (is_string($scope) && $scope !== '') { + $scopes[] = $scope; + } + } + + return $scopes; + } + + /** * @throws ConfigurationError * @return non-empty-array diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 11db59a0..7f240daf 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -360,7 +360,8 @@ public function add(ClientEntityInterface $client): void created_at, expires_at, is_generic, - extra_metadata + extra_metadata, + registration_access_token ) VALUES ( :id, @@ -386,7 +387,8 @@ public function add(ClientEntityInterface $client): void :created_at, :expires_at, :is_generic, - :extra_metadata + :extra_metadata, + :registration_access_token ) EOS , @@ -459,7 +461,8 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo created_at = :created_at, expires_at = :expires_at, is_generic = :is_generic, - extra_metadata = :extra_metadata + extra_metadata = :extra_metadata, + registration_access_token = :registration_access_token WHERE id = :id EOF , diff --git a/src/Server/Exceptions/OidcServerException.php b/src/Server/Exceptions/OidcServerException.php index 0c1c1a88..1878ad7e 100644 --- a/src/Server/Exceptions/OidcServerException.php +++ b/src/Server/Exceptions/OidcServerException.php @@ -217,6 +217,36 @@ public static function accessDenied( return $e; } + /** + * The authenticated client is not authorized to use this authorization grant type or response type + * (RFC 6749 sections 4.1.2.1 / 5.2). + * + * @param string|null $hint + * @param string|null $redirectUri + * @param \Throwable|null $previous + * @param string|null $state + * @param \SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface|null $responseMode + */ + public static function unauthorizedClient( + ?string $hint = null, + ?string $redirectUri = null, + ?Throwable $previous = null, + ?string $state = null, + ?ResponseModeInterface $responseMode = null, + ): OidcServerException { + return new self( + 'The client is not authorized to request a token using this method.', + 10, + 'unauthorized_client', + 400, + $hint, + $redirectUri, + $previous, + $state, + $responseMode, + ); + } + /** * Prompt none requires that user should be authenticated. * @@ -366,6 +396,35 @@ public static function invalidClientMetadata( ); } + /** + * Invalid redirect URI error, as defined by the OAuth 2.0 Dynamic Client + * Registration Protocol (RFC 7591, section 3.2.2) and OpenID Connect + * Dynamic Client Registration 1.0 (section 3.3). The value of one or more + * redirect_uris is invalid. + * + * @see https://www.rfc-editor.org/rfc/rfc7591#section-3.2.2 + * + * @param string|null $hint + * @param \Throwable|null $previous + * + * @return self + * @psalm-suppress LessSpecificImplementedReturnType + */ + public static function invalidRedirectUri( + ?string $hint = null, + ?Throwable $previous = null, + ): OidcServerException { + return new self( + 'The value of one or more redirect_uris is invalid.', + 14, + ErrorsEnum::InvalidRedirectUri->value, + 400, + $hint, + null, + $previous, + ); + } + /** * Returns the current payload. * diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index 7279014c..aa93e488 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -60,6 +60,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseTypeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeOfflineAccessRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; @@ -73,6 +74,8 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -525,6 +528,22 @@ public function respondToAccessTokenRequest( // it is predefined as the ClientRule result and authenticated against by ClientAuthenticationRule above. $client = $authorizationClientEntity; + // Per-client grant_types enforcement: if the client explicitly registered grant_types, it must include + // 'authorization_code' to exchange a code here. Enforced only when explicitly registered, preserving + // behavior for manually-managed clients that do not have it configured. The refresh_token grant is + // intentionally NOT gated on grant_types (see RefreshTokenGrant): a refresh token is only issued when + // offline_access was granted and consented, which is itself the authorization to refresh. + /** @var mixed $registeredGrantTypes */ + $registeredGrantTypes = $client->getExtraMetadata()[ClaimsEnum::GrantTypes->value] ?? null; + if ( + is_array($registeredGrantTypes) && + !in_array(GrantTypesEnum::AuthorizationCode->value, $registeredGrantTypes, true) + ) { + throw OidcServerException::unauthorizedClient( + 'The client is not authorized to use the authorization_code grant type.', + ); + } + $resolvedClientAuthenticationMethod = $authorizationClientEntity->isGeneric() ? null : $resultBag->getOrFail(ClientAuthenticationRule::class)->getValue(); @@ -758,6 +777,7 @@ public function validateAuthorizationRequestWithRequestRules( $rulesToExecute = [ ClientIdRule::class, + ResponseTypeRule::class, RequestObjectRule::class, PromptRule::class, MaxAgeRule::class, diff --git a/src/Server/Grants/RefreshTokenGrant.php b/src/Server/Grants/RefreshTokenGrant.php index 2ba9bb65..31492d74 100644 --- a/src/Server/Grants/RefreshTokenGrant.php +++ b/src/Server/Grants/RefreshTokenGrant.php @@ -6,6 +6,8 @@ use Exception; use League\OAuth2\Server\Entities\AccessTokenEntityInterface as OAuth2AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Grant\RefreshTokenGrant as OAuth2RefreshTokenGrant; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\RequestEvent; @@ -16,6 +18,7 @@ use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Traits\IssueAccessTokenTrait; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; +use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use function is_null; use function json_decode; @@ -53,11 +56,38 @@ public function __construct( RefreshTokenRepositoryInterface $refreshTokenRepository, AccessTokenEntityFactory $accessTokenEntityFactory, protected readonly RefreshTokenIssuer $refreshTokenIssuer, + protected readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, ) { parent::__construct($refreshTokenRepository); $this->accessTokenEntityFactory = $accessTokenEntityFactory; } + /** + * Authenticate the client at the refresh token endpoint without requiring a `client_id` request + * parameter. The league default (AbstractGrant::validateClient) resolves the client from a + * client_id parameter or HTTP Basic username, which a private_key_jwt client does not send - it + * conveys its identity via the `client_assertion` JWT. This mirrors how the authorization_code + * grant authenticates the caller (via ClientAuthenticationRule, which uses the same resolver), so + * all supported authentication methods (private_key_jwt, client_secret_basic, client_secret_post + * and public/none) work consistently across the token endpoint. + * + * The refresh token is still bound to a specific client: validateOldRefreshToken() checks that the + * authenticated client matches the client the refresh token was issued to. + * + * @throws \League\OAuth2\Server\Exception\OAuthServerException + */ + protected function validateClient(ServerRequestInterface $request): ClientEntityInterface + { + $resolvedClientAuthenticationMethod = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); + + if ($resolvedClientAuthenticationMethod === null) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + throw OAuthServerException::invalidClient($request); + } + + return $resolvedClientAuthenticationMethod->getClient(); + } + /** * @throws \JsonException * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException diff --git a/src/Server/Registration/ClientMetadataValidator.php b/src/Server/Registration/ClientMetadataValidator.php new file mode 100644 index 00000000..d88938a4 --- /dev/null +++ b/src/Server/Registration/ClientMetadataValidator.php @@ -0,0 +1,275 @@ +value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ]; + + /** + * All URI metadata fields whose format is validated. + */ + private const array URI_CLAIMS = [ + ClaimsEnum::LogoUri->value, + ClaimsEnum::ClientUri->value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ]; + + public function __construct( + private readonly ModuleConfig $moduleConfig, + ) { + } + + /** + * Validate the incoming registration metadata. Returns the metadata unchanged on success. + * + * @param array $metadata + * @return array + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function validate(array $metadata): array + { + $redirectUris = $this->validateRedirectUris($metadata); + $this->validateInformationalUris($metadata); + $this->validateRequestUris($metadata); + $this->validateContacts($metadata); + $this->validateApplicationType($metadata); + + if ($this->moduleConfig->getDcrImpersonationProtectionEnabled()) { + $this->enforceImpersonationProtection($metadata, $redirectUris); + } + + return $metadata; + } + + /** + * redirect_uris is REQUIRED; it must be a non-empty array of valid absolute URIs. + * + * @param array $metadata + * @return string[] the validated redirect URIs + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateRedirectUris(array $metadata): array + { + $redirectUris = $metadata[ClaimsEnum::RedirectUris->value] ?? null; + + if (!is_array($redirectUris) || $redirectUris === []) { + throw OidcServerException::invalidRedirectUri('redirect_uris is required and must be a non-empty array.'); + } + + $validated = []; + /** @var mixed $redirectUri */ + foreach ($redirectUris as $redirectUri) { + // Lenient: a redirect URI must be an absolute URI (have a scheme), but we intentionally do not require + // an http(s) host, so native/custom-scheme and loopback redirect URIs remain valid. + if (!is_string($redirectUri) || !$this->hasScheme($redirectUri)) { + throw OidcServerException::invalidRedirectUri('One or more redirect_uris values are invalid.'); + } + // OIDC Core 3.1.2.1: the redirect_uri MUST NOT include a fragment component. + if ($this->hasFragment($redirectUri)) { + throw OidcServerException::invalidRedirectUri('A redirect_uri must not contain a fragment component.'); + } + $validated[] = $redirectUri; + } + + return $validated; + } + + /** + * logo_uri, client_uri, policy_uri and tos_uri must be valid absolute URIs when present. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateInformationalUris(array $metadata): void + { + foreach (self::URI_CLAIMS as $claim) { + if (!array_key_exists($claim, $metadata)) { + continue; + } + + /** @var mixed $value */ + $value = $metadata[$claim]; + if (!is_string($value) || !$this->isValidAbsoluteUri($value)) { + throw OidcServerException::invalidClientMetadata(sprintf('Invalid "%s" value.', $claim)); + } + } + } + + /** + * request_uris, when present, must be an array of absolute https URIs. A fragment component is permitted: + * OpenID Connect Core 1.0 Section 6.2 allows the request_uri to carry a base64url-encoded SHA-256 hash of the + * referenced Request Object as its fragment. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateRequestUris(array $metadata): void + { + if (!array_key_exists(ClaimsEnum::RequestUris->value, $metadata)) { + return; + } + + /** @var mixed $requestUris */ + $requestUris = $metadata[ClaimsEnum::RequestUris->value]; + if (!is_array($requestUris)) { + throw OidcServerException::invalidClientMetadata('request_uris must be an array.'); + } + + /** @var mixed $requestUri */ + foreach ($requestUris as $requestUri) { + $scheme = is_string($requestUri) ? parse_url($requestUri, PHP_URL_SCHEME) : null; + if ( + !is_string($requestUri) || + !is_string($scheme) || + strtolower($scheme) !== 'https' || + $this->extractHost($requestUri) === null + ) { + throw OidcServerException::invalidClientMetadata( + 'Each request_uris value must be a valid https URI.', + ); + } + } + } + + /** + * contacts, when present, must be an array of non-empty strings. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateContacts(array $metadata): void + { + if (!array_key_exists(ClaimsEnum::Contacts->value, $metadata)) { + return; + } + + /** @var mixed $contacts */ + $contacts = $metadata[ClaimsEnum::Contacts->value]; + if (!is_array($contacts)) { + throw OidcServerException::invalidClientMetadata('contacts must be an array.'); + } + + /** @var mixed $contact */ + foreach ($contacts as $contact) { + if (!is_string($contact) || $contact === '') { + throw OidcServerException::invalidClientMetadata('contacts must be an array of non-empty strings.'); + } + } + } + + /** + * application_type, when present, must be one of the defined values (web or native). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateApplicationType(array $metadata): void + { + if (!array_key_exists(ClaimsEnum::ApplicationType->value, $metadata)) { + return; + } + + /** @var mixed $applicationType */ + $applicationType = $metadata[ClaimsEnum::ApplicationType->value]; + if ( + !is_string($applicationType) || + ApplicationTypesEnum::tryFrom($applicationType) === null + ) { + throw OidcServerException::invalidClientMetadata('Invalid application_type value.'); + } + } + + /** + * Impersonation protection (OIDC Dynamic Client Registration 1.0, Section 9.1): each protected informational + * URI must share a host with one of the registered redirect_uris, to mitigate a rogue client supplying the + * branding (logo) or links of a legitimate one. + * + * @param string[] $redirectUris + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function enforceImpersonationProtection(array $metadata, array $redirectUris): void + { + $allowedHosts = []; + foreach ($redirectUris as $redirectUri) { + $host = $this->extractHost($redirectUri); + if ($host !== null) { + $allowedHosts[$host] = true; + } + } + + foreach (self::IMPERSONATION_PROTECTED_URI_CLAIMS as $claim) { + if (!array_key_exists($claim, $metadata)) { + continue; + } + + // Format was already validated; value is a valid absolute URI string here. + $host = $this->extractHost((string)$metadata[$claim]); + if ($host === null || !array_key_exists($host, $allowedHosts)) { + throw OidcServerException::invalidClientMetadata(sprintf( + 'The host of "%s" must match the host of one of the redirect_uris ' + . '(impersonation protection is enabled).', + $claim, + )); + } + } + } + + private function isValidAbsoluteUri(string $uri): bool + { + return filter_var($uri, FILTER_VALIDATE_URL) !== false && $this->extractHost($uri) !== null; + } + + /** + * Whether the URI has a (non-empty) scheme component, i.e. is an absolute URI. + */ + private function hasScheme(string $uri): bool + { + $scheme = parse_url($uri, PHP_URL_SCHEME); + + return is_string($scheme) && $scheme !== ''; + } + + /** + * Whether the URI has a fragment component (the part after '#'). + */ + private function hasFragment(string $uri): bool + { + $fragment = parse_url($uri, PHP_URL_FRAGMENT); + + return is_string($fragment) && $fragment !== ''; + } + + /** + * Extract the lower-cased host component of a URI, or null if absent. + */ + private function extractHost(string $uri): ?string + { + $host = parse_url($uri, PHP_URL_HOST); + + return is_string($host) && $host !== '' ? strtolower($host) : null; + } +} diff --git a/src/Server/RequestRules/Rules/ResponseTypeRule.php b/src/Server/RequestRules/Rules/ResponseTypeRule.php index cdf53487..7867d991 100644 --- a/src/Server/RequestRules/Rules/ResponseTypeRule.php +++ b/src/Server/RequestRules/Rules/ResponseTypeRule.php @@ -5,12 +5,14 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode; use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -48,15 +50,33 @@ public function checkRule( ); } - // No need to validate the value against a list of supported response types here: this rule only runs from - // within a grant's request validation, which is reached only after AuthorizationServer has matched the - // request to a grant via canRespondToAuthorizationRequest(). By grant selection therefore - // already rejects unsupported response types (unsupportedResponseType) before this point. - // TODO: Also, we currently don't store allowed response types per client, so nothing to validate in that - // sense either. This should be fixed in the future, for example in DCR implementation. + // No need to validate the value against the globally supported response types here: this rule only runs + // from within a grant's request validation, which is reached only after AuthorizationServer has matched + // the request to a grant via canRespondToAuthorizationRequest(), so grant selection already rejects + // globally unsupported response types before this point. $responseType = (string)$requestParams[ParamsEnum::ResponseType->value]; + // Per-client enforcement: if the client has explicitly registered response_types, the requested + // response_type must be one of them. We enforce only when the value was explicitly registered (present in + // the client's metadata); clients that do not have it configured are not constrained, preserving behavior + // for manually-managed clients. Dynamically registered clients always have it (the OIDC DCR default is + // applied at registration). + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + /** @var mixed $registeredResponseTypes */ + $registeredResponseTypes = ($client instanceof ClientEntityInterface) ? + ($client->getExtraMetadata()[ClaimsEnum::ResponseTypes->value] ?? null) : null; + + if (is_array($registeredResponseTypes) && !in_array($responseType, $registeredResponseTypes, true)) { + $loggerService->error( + 'ResponseTypeRule: response_type not registered for client.', + ['response_type' => $responseType, 'registered' => $registeredResponseTypes], + ); + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); + $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); + throw OidcServerException::unsupportedResponseType($redirectUri, $state, $responseMode); + } + return new Result($this->getKey(), $responseType); } } diff --git a/src/Services/Api/Authorization.php b/src/Services/Api/Authorization.php index 5e33f670..9f6b72c6 100644 --- a/src/Services/Api/Authorization.php +++ b/src/Services/Api/Authorization.php @@ -7,6 +7,7 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; +use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; @@ -23,6 +24,7 @@ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly SspBridge $sspBridge, protected readonly RequestParamsResolver $requestParamsResolver, + protected readonly Helpers $helpers, ) { } @@ -79,17 +81,9 @@ public function requireTokenForAnyOfScope(Request $request, array $requiredScope 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), - ), - ); + $bearerToken = $this->helpers->http()->getBearerToken($request->headers->get(self::KEY_AUTHORIZATION)); + if ($bearerToken !== null) { + return $bearerToken; } // Fallback to token parameter. diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index a282f109..abe3c833 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -225,6 +225,11 @@ public function migrate(): void $this->version20260608130000(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260608130000')"); } + + if (!in_array('20260624000001', $versions, true)) { + $this->version20260624000001(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260624000001')"); + } } private function versionsTableName(): string @@ -771,6 +776,20 @@ private function version20260608130000(): void $this->database->write("CREATE INDEX $idxParExpiresAt ON $parTableName (expires_at)"); } + /** + * Add storage for the OpenID Connect Dynamic Client Registration Access Token (a hash of it), used to + * authenticate read requests at the Client Configuration Endpoint. + */ + private function version20260624000001(): void + { + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $this->database->write(<<< EOT + ALTER TABLE {$clientTableName} + ADD registration_access_token VARCHAR(255) NULL +EOT + ,); + } + /** * @param string[] $columnNames diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 83b125a7..c660665b 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -62,6 +62,10 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::EndSessionEndpoint->value] = $this->routes->getModuleUrl(RoutesEnum::EndSession->value); $this->metadata[ClaimsEnum::JwksUri->value] = $this->routes->getModuleUrl(RoutesEnum::Jwks->value); + if ($this->moduleConfig->getDcrEnabled()) { + $this->metadata[ClaimsEnum::RegistrationEndpoint->value] = + $this->routes->getModuleUrl(RoutesEnum::Registration->value); + } $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getScopes()); $this->metadata[ClaimsEnum::ResponseTypesSupported->value] = ['code', 'id_token', 'id_token token']; $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; @@ -91,6 +95,7 @@ private function initMetadata(): void $grantTypesSupported = [ GrantTypesEnum::AuthorizationCode->value, + GrantTypesEnum::Implicit->value, GrantTypesEnum::RefreshToken->value, ]; if ($this->moduleConfig->getVciEnabled()) { diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php index 481994a4..3b55e934 100644 --- a/src/Utils/AuthenticatedOAuth2ClientResolver.php +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -14,6 +14,7 @@ use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientAssertionTypesEnum; use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; @@ -42,11 +43,17 @@ public function forAnySupportedMethod( ?ClientEntityInterface $preFetchedClient = null, ): ?ResolvedClientAuthenticationMethod { try { - return + $resolved = $this->forPrivateKeyJwt($request, $preFetchedClient) ?? $this->forClientSecretBasic($request, $preFetchedClient) ?? $this->forClientSecretPost($request, $preFetchedClient) ?? $this->forPublicClient($request, $preFetchedClient); + + if ($resolved !== null) { + $this->enforceRegisteredTokenEndpointAuthMethod($resolved); + } + + return $resolved; } catch (\Throwable $exception) { $this->loggerService->error( 'Error while trying to resolve authenticated client: ' . @@ -56,6 +63,35 @@ public function forAnySupportedMethod( } } + /** + * If the client has explicitly registered a token_endpoint_auth_method, the method it actually authenticated + * with must match it. Enforced only when explicitly registered, preserving behavior for manually-managed + * clients that do not have it configured. Throwing here results in client authentication failing (the caller + * treats a null resolution as invalid_client). + * + * @throws AuthorizationException + */ + protected function enforceRegisteredTokenEndpointAuthMethod( + ResolvedClientAuthenticationMethod $resolved, + ): void { + /** @var mixed $registeredMethod */ + $registeredMethod = $resolved->getClient()->getExtraMetadata()[ClaimsEnum::TokenEndpointAuthMethod->value] + ?? null; + + if (!is_string($registeredMethod)) { + return; + } + + $usedMethod = $resolved->getClientAuthenticationMethod()->value; + if ($registeredMethod !== $usedMethod) { + throw new AuthorizationException(sprintf( + 'Client authenticated with "%s" but is registered to use "%s" (token_endpoint_auth_method).', + $usedMethod, + $registeredMethod, + )); + } + } + /** * @throws AuthorizationException */ diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig index 99a50724..1b54a6cd 100644 --- a/templates/clients/includes/form.twig +++ b/templates/clients/includes/form.twig @@ -168,6 +168,33 @@ {{ form.request_uris.getError }} {% endif %} + + {{ form.grant_types.control | raw }} + + {% trans %}Grant types this Client is allowed to use. If none are selected, the Client is not restricted by grant type. Note that the refresh_token grant is allowed whenever the Client was granted the offline_access scope, regardless of this setting.{% endtrans %} + + {% if form.grant_types.hasErrors %} + {{ form.grant_types.getError }} + {% endif %} + + + {{ form.response_types.control | raw }} + + {% trans %}Response types this Client is allowed to use at the authorization endpoint. If none are selected, the Client is not restricted by response type.{% endtrans %} + + {% if form.response_types.hasErrors %} + {{ form.response_types.getError }} + {% endif %} + + + {{ form.token_endpoint_auth_method.control | raw }} + + {% trans %}Client authentication method the Client must use at the token endpoint. If not set, the method is not enforced.{% endtrans %} + + {% if form.token_endpoint_auth_method.hasErrors %} + {{ form.token_endpoint_auth_method.getError }} + {% endif %} +