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..b5e3ae8c 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,136 @@ $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', +// ], + + /** + * Whether a client registered through Dynamic Client Registration (DCR) is + * created ENABLED and therefore immediately usable. + * + * When true (default), a dynamically registered client can be used right + * away. Set to false to create such clients DISABLED, so an administrator + * must review and enable them in the admin UI before they can complete + * authorization / token flows ("register, then approve"). While disabled, the + * client can still read and manage its own registration (RFC 7592) using the + * registration access token it received - it simply cannot obtain tokens until + * an administrator enables it. + * + * Note: there is no standard way to signal "pending approval" back to the + * client in the registration response (it receives a normal success response), + * so you may need to communicate the review step out-of-band. + * + * This applies ONLY to Dynamic registrations. OpenID Federation automatic + * registrations are always created enabled (they are vouched for by their + * trust chain). + * + * Format: bool (default: true) + */ + ModuleConfig::OPTION_DCR_REGISTERED_CLIENTS_ENABLED => true, + /*************************************************************************** * (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..8812a5dc --- /dev/null +++ b/conformance-tests/dynamic-warnings.json @@ -0,0 +1,38 @@ +[ + { + "comment": "Signed UserInfo responses are not supported, so the OP now rejects a registration requesting userinfo_signed_response_alg with invalid_client_metadata (400) rather than silently ignoring it. The test therefore fails at the dynamic registration step.", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "EnsureHttpStatusCodeIs201", + "expected-result": "failure" + }, + { + "comment": "Signed UserInfo responses are not supported; the registration requesting userinfo_signed_response_alg is rejected (see EnsureHttpStatusCodeIs201).", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "CheckNoErrorFromDynamicRegistrationEndpoint", + "expected-result": "failure" + }, + { + "comment": "Signed UserInfo responses are not supported; the registration requesting userinfo_signed_response_alg is rejected (see EnsureHttpStatusCodeIs201).", + "test-name": "oidcc-userinfo-rs256*", + "configuration-filename": "*conformance-dynamic-ci.json", + "variant": "*", + "current-block": "", + "condition": "ExtractDynamicRegistrationResponse", + "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/1-oidc.md b/docs/1-oidc.md index 38bff044..2ad1feb4 100644 --- a/docs/1-oidc.md +++ b/docs/1-oidc.md @@ -30,6 +30,9 @@ OpenID Connect: object (passed by value and by reference) - [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) — `/.well-known/openid-configuration` +- [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) + — the Client Registration Endpoint (`registration_endpoint`); disabled by + default. See the [DCR note](#note-on-dynamic-client-registration-dcr) below - [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) - [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) - [OAuth 2.0 Form Post Response Mode](https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html) @@ -50,6 +53,10 @@ OAuth 2.0: - [JWT-Secured Authorization Request, JAR (RFC 9101)](https://www.rfc-editor.org/rfc/rfc9101) — `request` and `request_uri` - [OAuth 2.0 Pushed Authorization Requests, PAR (RFC 9126)](https://www.rfc-editor.org/rfc/rfc9126) +- [OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591) + and [OAuth 2.0 Dynamic Client Registration Management Protocol (RFC 7592)](https://www.rfc-editor.org/rfc/rfc7592) + — client register / read / update / delete at the `registration_endpoint`; + disabled by default. See the [DCR note](#note-on-dynamic-client-registration-dcr) below Drafts / experimental (see the notes below for scope and caveats): @@ -58,6 +65,28 @@ Drafts / experimental (see the notes below for scope and caveats): - OpenID for Verifiable Credential Issuance, OpenID4VCI (draft 15; experimental, not for production) +## Note on Dynamic Client Registration (DCR) + +The OP can let clients register themselves at the Client Registration Endpoint +(`registration_endpoint`, served at `/module.php/oidc/register`), +implementing OpenID Connect Dynamic Client Registration 1.0 / RFC 7591 (create, +read) and RFC 7592 (update, delete via the Client Configuration Endpoint, +authenticated with the Registration Access Token issued at registration). + +DCR is **disabled by default**. When disabled, the registration endpoint returns +`404` and is not advertised in discovery. When enabled, registration can be open +or gated by an Initial Access Token, and impersonation protection +(`logo_uri`/`policy_uri`/`tos_uri` host matching) is on by default. + +Most standard client metadata is supported. Metadata for features this OP does +not implement is **rejected** with `invalid_client_metadata` rather than silently +ignored — namely `subject_type` other than `public`, `sector_identifier_uri`, +signed/encrypted UserInfo, ID Token / Request Object encryption, and front-channel +logout. The full per-field policy (honored / validated / rejected) is documented in +[DCR client metadata support](9-oidc-dcr-client-metadata.md). See the +[upgrade guide](6-oidc-upgrade.md#version-6-to-7) for the configuration options, +the client properties involved, and guidance for existing clients. + ## Note on OpenID Federation (OIDFed) OpenID Federation support is in draft phase. You can @@ -134,6 +163,11 @@ plans against the module (using the OpenID conformance suite). See (`oidcc-rp-initiated-logout-certification-test-plan`) - OpenID Connect Back-Channel Logout (`oidcc-backchannel-rp-initiated-logout-certification-test-plan`) +- OpenID Connect Core: Dynamic OP + (`oidcc-dynamic-certification-test-plan`) — exercises Dynamic Client + Registration. A few tests in this plan cover OP behaviours that are not DCR and + are not (yet) supported; they are tracked as expected failures. See + [OpenID Conformance](5-oidc-conformance.md) for details. Some specifications are not covered by these OpenID Connect certification profiles. In particular, PAR (RFC 9126) and the `request` / `request_uri` @@ -166,6 +200,8 @@ Upgrading? See the [upgrade guide](6-oidc-upgrade.md). [Configuration](3-oidc-configuration.md#endpoint-locations-and-well-known-urls) - Running with containers: [Using Docker](4-oidc-docker.md) - Conformance tests: [OpenID Conformance](5-oidc-conformance.md) +- Dynamic Client Registration metadata support: + [DCR client metadata](9-oidc-dcr-client-metadata.md) - Upgrading between versions: [Upgrade guide](6-oidc-upgrade.md) - Common questions: [FAQ](7-oidc-faq.md) - API documentation: [API](8-api.md) diff --git a/docs/3-oidc-configuration.md b/docs/3-oidc-configuration.md index df0364d9..53788178 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,45 @@ 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` / `PUT` / `DELETE` + `.../oidc/register?client_id=...`, RFC 7592) to read, update (full replace) or + delete a dynamically registered client, called with the + `registration_access_token` as a bearer token. Per RFC 7592 the read and update + responses include a `registration_access_token`; because the OP stores only its + hash, the token is **rotated** on each successful read/update — the response + returns a new token that the client must use for subsequent requests. + +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/6-oidc-upgrade.md b/docs/6-oidc-upgrade.md index 71f3db71..0639b862 100644 --- a/docs/6-oidc-upgrade.md +++ b/docs/6-oidc-upgrade.md @@ -99,6 +99,70 @@ given, the attributes are consulted in priority order and the first one actually present in the released attributes is used, both as the internal user identifier and as the default source for the `sub` claim. The single-string form continues to work unchanged, so existing configurations are unaffected. +- Support for OpenID Connect Dynamic Client Registration (DCR), as per OpenID +Connect Dynamic Client Registration 1.0 / RFC 7591 (register, read) and RFC 7592 +(update, delete). A new Client Registration Endpoint (`registration_endpoint`, +served at `/module.php/oidc/register`) lets clients register themselves +(HTTP `POST`) and then read (`GET`), update (`PUT`), or delete (`DELETE`) their +registration via the Client Configuration Endpoint, authenticated with the +Registration Access Token issued at registration. DCR is **disabled by default**; +when enabled it is advertised as `registration_endpoint` in discovery, registration +can be open or gated by an Initial Access Token, and impersonation protection +(`logo_uri`/`policy_uri`/`tos_uri` host matching) is on by default. The full +per-field metadata policy (honored / validated / rejected) is documented in +[DCR client metadata support](9-oidc-dcr-client-metadata.md). + - As part of this, several client properties are now first-class: they are + validated, persisted, returned in the registration response, enforced where + applicable, and editable in the client administration UI. New client properties: + - Grant Types (`grant_types`) + - Response Types (`response_types`) + - Token Endpoint Authentication Method (`token_endpoint_auth_method`) + - Default Max Age (`default_max_age`) + - Require `auth_time` (`require_auth_time`) + - Default ACR Values (`default_acr_values`) + - Initiate Login URI (`initiate_login_uri`) + - Software ID and Software Version (`software_id`, `software_version`) + - Logo URI, Client URI, Policy URI, Terms of Service URI (`logo_uri`, + `client_uri`, `policy_uri`, `tos_uri`) + - Application Type (`application_type`) + - Contacts (`contacts`) + - New rules / enforcement related to these properties: + - `grant_types`, `response_types`, and `token_endpoint_auth_method` are + enforced **per client, but only when explicitly registered for that client** + (presence-based): the client is restricted to the grant types / response types + / authentication method it registered. The `refresh_token` grant is + intentionally exempt from `grant_types` enforcement — a refresh token is only + issued when `offline_access` was granted and consented, which is itself the + authorization to refresh. + - `default_max_age` and `default_acr_values` are applied when the authorization + request omits `max_age` / `acr_values`; `require_auth_time` forces the + `auth_time` claim into the ID Token. + - A `redirect_uri` that contains a fragment component is rejected (OIDC Core). + - Metadata for features this OP does not support is **rejected** with + `invalid_client_metadata` rather than silently ignored: `subject_type` other + than `public`, `sector_identifier_uri`, signed/encrypted UserInfo, ID Token / + Request Object encryption, and front-channel logout. + - A client that registers without a `scope` is granted the configured default + scope set (`OPTION_DCR_DEFAULT_SCOPES`), which defaults to all scopes the OP + supports, so dynamic clients can obtain refresh tokens via `offline_access`. + - **Transition for existing clients:** enforcement of `grant_types`, + `response_types`, and `token_endpoint_auth_method` is presence-based, so existing + clients (which do not have these properties set) are **not** restricted and + require no action — their behavior is unchanged. The OIDC DCR defaults are + applied only to newly, dynamically registered clients, not retroactively. In the + admin UI, a pre-upgrade client shows these new fields as **unset** (no grant + types / response types selected, no authentication method chosen) — that is the + honest representation of "not registered", and saving such a client does **not** + silently impose the spec defaults or otherwise constrain it. To start constraining + an existing client to specific grant types / response types / authentication + method, select them explicitly and save; leaving them empty preserves the + unconstrained behavior. (Implementation note: in v7 the client getters for these + fields return the raw registered value — empty / null when unset — rather than the + OIDC DCR spec default, so the stored value is the single source of truth; the + defaults are applied where it matters, i.e. when a client registers dynamically. A + future major version may switch the getters to fall back to the spec defaults.) + DCR is also opt-in (disabled by default), so unless you enable it, nothing changes + for your deployment. New configuration options: @@ -139,6 +203,38 @@ representation of a `\Defuse\Crypto\Key` to use as the encryption key. If not set (default), the SimpleSAMLphp secret salt is used as before. See the config template for how to generate the key and for the important caveat about invalidating already-issued artifacts when the key changes. +- `ModuleConfig::OPTION_DCR_ENABLED` - optional, enables Dynamic Client +Registration and advertises the `registration_endpoint` in discovery (default +`false`). +- `ModuleConfig::OPTION_DCR_REGISTRATION_AUTH` - optional, access-control mode for +the registration (create) endpoint: `DcrRegistrationAuthEnum::Open` (default) for +open registration, or `DcrRegistrationAuthEnum::InitialAccessToken` to require a +configured Initial Access Token. +- `ModuleConfig::OPTION_DCR_INITIAL_ACCESS_TOKENS` - optional, the set of accepted +Initial Access Tokens (only consulted when the registration auth mode is +`InitialAccessToken`). +- `ModuleConfig::OPTION_DCR_IMPERSONATION_PROTECTION_ENABLED` - optional, when on +(default `true`) the host of `logo_uri`/`policy_uri`/`tos_uri` must match the host +of one of the registered `redirect_uris`, mitigating a rogue client reusing a +legitimate client's branding. +- `ModuleConfig::OPTION_DCR_DEFAULT_SCOPES` - optional, the scopes assigned to a +DCR client that registers without an explicit `scope`. Defaults to all scopes the +OP supports (so scope-less dynamic clients can request `offline_access`); set an +explicit list to restrict it. Applies to Dynamic registrations only; manual and +OpenID Federation registrations are unaffected. +- `ModuleConfig::OPTION_DCR_REGISTERED_CLIENTS_ENABLED` - optional, whether a +dynamically registered client is created enabled and immediately usable (default +`true`). Set to `false` to create DCR clients disabled, so an administrator reviews +and enables them before use ("register, then approve"); while disabled the client +can still manage its own registration (RFC 7592) but cannot obtain tokens. Applies +to Dynamic registrations only; OpenID Federation automatic registrations are always +created enabled. +- `ModuleConfig::OPTION_PROTOCOL_HTTP_CLIENT_OPTIONS` - optional, Guzzle HTTP +client options (passed through verbatim) for the protocol-layer outbound fetches +made by the underlying `openid` library, such as fetching a client `jwks_uri` or a +`request_uri`. Defaults to the library's secure settings; the primary use is +testing against endpoints with self-signed certificates by setting +`['verify' => false]`. Do NOT disable TLS verification in production. - Several new options regarding experimental support for OpenID4VCI. Major impact changes: @@ -225,11 +321,13 @@ Low-impact changes: the client identity is conveyed by the client authentication method itself, in line with the specifications. For example, with `private_key_jwt` the client is identified by the assertion's `iss`/`sub` claims, and with `client_secret_basic` -by the `Authorization` header. Requests that still send `client_id` are -unaffected, and the authenticated client is always validated against the client -the authorization code was issued to. Note that for non-registered (generic VCI) -clients the `client_id` parameter is still required, as their identity cannot be -derived from a credential. +by the `Authorization` header. This now applies consistently to both the +`authorization_code` and `refresh_token` grants (previously the `refresh_token` +grant rejected `private_key_jwt` clients that did not also send `client_id`). +Requests that still send `client_id` are unaffected, and the authenticated client +is always validated against the client the authorization code / refresh token was +issued to. Note that for non-registered (generic VCI) clients the `client_id` +parameter is still required, as their identity cannot be derived from a credential. - Client property `is_federated` has been removed, as the OP implementation can now only be a leaf entity in the federation context, and not a federation operator or intermediary entity. Previously, this property was used to diff --git a/docs/9-oidc-dcr-client-metadata.md b/docs/9-oidc-dcr-client-metadata.md new file mode 100644 index 00000000..a2898b30 --- /dev/null +++ b/docs/9-oidc-dcr-client-metadata.md @@ -0,0 +1,195 @@ +# 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 | DCR default `["authorization_code"]` stored at registration. Unsupported values rejected (`ModuleConfig`). Enforced for the code grant (presence + non-empty); refresh grant exempt (see note). | +| `response_types` | **Honored** (persist + echo + enforce) | Honored | DCR default `["code"]` stored at registration. Unsupported values rejected. Enforced at the authorization endpoint (presence + non-empty). | +| `token_endpoint_auth_method` | **Honored** (persist + echo + enforce) | Honored | DCR default `client_secret_basic` (or `none` for public) stored at registration. Unsupported values rejected. Enforced at the token endpoint (presence). Also the primary signal for the client type (see below): `none` ⇒ public, any real method ⇒ confidential. | +| `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 | Admin-editable. `web` / `native`. Constrains `redirect_uris` (see below) and is a secondary signal for the client type. | +| `contacts` | Validated + echoed | Validated + echoed | Admin-editable. | +| `logo_uri` | Validated + echoed | Validated + echoed | Admin-editable. Subject to impersonation protection (DCR path). | +| `policy_uri` | Validated + echoed | Validated + echoed | Admin-editable. Subject to impersonation protection (DCR path). | +| `tos_uri` | Validated + echoed | Validated + echoed | Admin-editable. Subject to impersonation protection (DCR path). | +| `client_uri` | Validated + echoed | Validated + echoed | Admin-editable. Excluded from impersonation protection. | +| `client_registration_types` | Honored (federation) | Honored | OpenID Federation. | +| `subject_type` | **Reject** if not `public` | Reject if not `public` | Only `public` is supported (no pairwise). | +| `sector_identifier_uri` | **Reject** if requested | Reject | Pairwise/sector grouping not supported. | +| `userinfo_signed_response_alg` | **Reject** if requested | Reject | Signed UserInfo not supported (conformance `userinfo-rs256`). | +| `userinfo_encrypted_response_alg` / `..._enc` | **Reject** if requested | Reject | Response encryption not supported. | +| `id_token_encrypted_response_alg` / `..._enc` | **Reject** if requested | Reject | Response encryption not supported. | +| `request_object_signing_alg` | Ignored | TBD | Decide once request object policy is finalized. | +| `request_object_encryption_alg` / `..._enc` | **Reject** if requested | Reject | Request object encryption not supported. | +| `token_endpoint_auth_signing_alg` | Ignored | TBD | Relevant to `private_key_jwt` / `client_secret_jwt`. | +| `default_max_age` | **Honored** (validate + store + echo + enforce) | Honored | Admin-editable. Default applied when max_age omitted (MaxAgeRule). | +| `require_auth_time` | **Honored** (validate + store + echo + enforce) | Honored | Admin-editable. Forces auth_time into the ID Token (MaxAgeRule -> AuthCodeGrant). | +| `default_acr_values` | **Honored** (validate + store + echo + enforce) | Honored | Constrained to `acr_values_supported`: DCR rejects unsupported values; the admin field is a multi-select of the supported ACRs. Default applied when acr_values omitted (AcrValuesRule). | +| `initiate_login_uri` | **Validated + echoed** | Validated + echoed | Admin-editable; https URI. Informational. | +| `backchannel_logout_session_required` | Ignored | TBD | | +| `frontchannel_logout_uri` / `..._session_required` | **Reject** if requested | Reject | Front-channel logout not supported. | +| `software_id` / `software_version` | **Validated + echoed** | Validated + echoed | Admin-editable. RFC 7591 informational. | +| `software_statement` | Ignored | TBD | RFC 7591; only if signed-statement trust is implemented. | + +## `response_type` ↔ `grant_type` correspondence + +OpenID Connect Dynamic Client Registration 1.0 requires that the `grant_types` +list include the grant type(s) corresponding to each registered `response_type` +(`code` → `authorization_code`; `id_token` / `id_token token` → `implicit`). The OP +**normalizes** rather than rejects: when a client registers `response_types`, the +required grant types are merged into `grant_types` (and echoed back per RFC 7591 +§3.2.1), so a client that legally omits `grant_types` while declaring a non-`code` +response type still gets a consistent, usable registration. The same normalization +runs on save in the admin UI, and the admin form additionally selects the required +grant types live as the response types are chosen (the JavaScript and the server +share one correspondence map, `ResponseTypeGrantTypeCorrespondence`). `refresh_token` +has no response-type correspondence (it is gated by `offline_access`). + +## Client type (confidential / public) + +The OAuth 2.0 client type (RFC 6749 §2.1) is not a DCR metadata field, but it is a +real, stored client property (`is_confidential`) that the OP and the underlying +OAuth2 library need at runtime (e.g. PKCE requirement for public clients, whether a +client secret is required / echoed). It is kept in lockstep with +`token_endpoint_auth_method`, which is the DCR signal for it: **`none` ⇒ public, any +real authentication method ⇒ confidential**, with `application_type: native` as a +secondary hint (⇒ public) used only when no auth method is resolved. The full +precedence is: **auth method (if set) → `native` → explicit/default**. This is derived +at registration, re-derived on RFC 7592 updates, and applied identically in the admin +form (live in the UI via `client-form.js` and normalized on save). When neither an auth +method nor `native` is present (e.g. a federation/manual client), the explicit +`is_confidential` value stands. (Consequence: to make a `native` client confidential, +give it a real authentication method.) + +## RFC 7592 update semantics (full replace) + +A client update at the Client Configuration Endpoint (HTTP `PUT`) is a **full +replace**, not a merge, per RFC 7592 §2.2: client-settable metadata that the update +request omits is reset to its OP default (or removed), so the client must send the +complete intended metadata set on every update. Server-managed and admin-only +properties are preserved across the update — the client identifier and secret, +`created_at`, the registration type, the registration access token, and in +particular any administrator-set `authproc` (which a registering client can never +set). This applies to Dynamic (DCR) registrations; manual (admin UI) and OpenID +Federation registrations are unaffected. + +## `redirect_uris` constraints by `application_type` + +Per OpenID Connect Dynamic Client Registration 1.0 (Section 2, `application_type`), +the OP verifies every registered `redirect_uris` value against the client's +`application_type` (default `web`) **at the DCR registration endpoint**: + +- **native:** only custom URI schemes, or loopback URLs (`localhost`, `127.0.0.1`, + `[::1]`), are allowed; a non-loopback `http`/`https` redirect URI is rejected with + `invalid_redirect_uri`. +- **web using the implicit grant:** every `redirect_uris` value must use `https` and + must not use `localhost` as the host. (Scoped to web clients that use the implicit + grant — determined from `grant_types`/`response_types`; code-only web clients are + not constrained by this rule.) + +These checks run on the **DCR path only** (`ClientMetadataValidator`). The admin UI +does not enforce them — an administrator is trusted and may, for example, manage a +client with mixed redirect URIs — but the admin form documents the rules under the +Application Type field. The fragment-rejection and absolute-URI checks apply to all +`redirect_uris` regardless of `application_type`. + +## 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. For the array-valued +fields (`grant_types`, `response_types`) a present-but-**empty** list also counts as +"not configured" and is not enforced (it never means "allow nothing"; this matches +the admin form's "if none are selected, the client is not restricted"). Dynamically +registered clients always have these stored (the OIDC DCR defaults are applied at +registration); clients that do not have them configured are not constrained. This +avoids regressing manually-managed and pre-DCR clients while still honoring the +registered metadata. All client metadata is stored in the existing `extra_metadata` +JSON column (no DB migration), and is exposed as editable fields in the admin UI. + +### Single source of truth (v7 transition) + +The entity getters for these fields (`getGrantTypes()`, `getResponseTypes()`, +`getTokenEndpointAuthMethod()`) return the **raw registered value** — an empty array +/ `null` when the client has nothing registered — rather than synthesizing the OIDC +DCR spec default. This keeps the stored value the single source of truth, so: + +- the admin UI shows exactly what is registered (a pre-DCR client shows these fields + as unset, not as phantom defaults), and saving such a client does not silently + impose constraints; +- the registration response still echoes the spec defaults, because for dynamic + registrations those defaults are persisted at registration time (in + `ClientEntityFactory`), not invented at read time. + +A future major version may move the spec defaults into the getters themselves (so an +unset value resolves to the spec default everywhere). v7 deliberately does not, to +give deployments a transition window in which to set explicit values on clients that +predate these properties. + +## 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. + **Done** — rejected in `ClientMetadataValidator` with `invalid_client_metadata` + (conformance `oidcc-userinfo-rs256` now fails at the registration step, recorded + accordingly in `dynamic-warnings.json`; the plan stays green). +3. **Validate + store (+ enforce/echo)** the remaining benign behavioral fields + (`default_max_age`, `require_auth_time`, `default_acr_values`, + `initiate_login_uri`, `software_*`). **Done** — all validated, persisted, echoed + and admin-editable; the three behavioral defaults are enforced presence-based in + the authorization flow (`MaxAgeRule` for default_max_age + require_auth_time, + `AcrValuesRule` for default_acr_values). `software_statement` remains out of scope + (would require signed-statement trust). Conformance plan stays green. + +## 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/public/assets/js/src/client-form.js b/public/assets/js/src/client-form.js index 34c42979..045e6b25 100644 --- a/public/assets/js/src/client-form.js +++ b/public/assets/js/src/client-form.js @@ -19,4 +19,80 @@ radioOptionConfidential.addEventListener("change", toggleAllowedOrigins); toggleAllowedOrigins(); + + // Live-enforce the OIDC DCR response_type <-> grant_type correspondence: selecting a response type + // auto-selects the grant types it requires. The map is provided by the server (single source of truth); the + // server also normalizes on save, so this is a UX aid, not the authority. Fields remain editable. + function readCorrespondenceMap() { + const el = document.getElementById("oidc-response-type-grant-type-map"); + if (!el) { + return {}; + } + try { + return JSON.parse(el.textContent) || {}; + } catch (e) { + return {}; + } + } + + const responseTypesSelect = document.getElementById("frm-response_types"); + const grantTypesSelect = document.getElementById("frm-grant_types"); + const correspondenceMap = readCorrespondenceMap(); + + function syncRequiredGrantTypes() { + if (!responseTypesSelect || !grantTypesSelect) { + return; + } + const required = new Set(); + Array.from(responseTypesSelect.selectedOptions).forEach(function (option) { + (correspondenceMap[option.value] || []).forEach(function (grantType) { + required.add(grantType); + }); + }); + Array.from(grantTypesSelect.options).forEach(function (option) { + if (required.has(option.value)) { + option.selected = true; + } + }); + } + + if (responseTypesSelect && grantTypesSelect) { + responseTypesSelect.addEventListener("change", syncRequiredGrantTypes); + syncRequiredGrantTypes(); + } + + // Keep the confidential/public type in lockstep with the metadata, using the same precedence as the server + // (and DCR): token_endpoint_auth_method decides when selected (`none` => public, any real method => + // confidential); otherwise application_type `native` => public; otherwise the explicit choice stands. + const tokenEndpointAuthMethodSelect = document.getElementById("frm-token_endpoint_auth_method"); + const applicationTypeSelect = document.getElementById("frm-application_type"); + + function syncClientType() { + const method = tokenEndpointAuthMethodSelect ? tokenEndpointAuthMethodSelect.value : ""; + if (method !== "") { + if (method === "none") { + radioOptionPublic.checked = true; + } else { + radioOptionConfidential.checked = true; + } + toggleAllowedOrigins(); + return; + } + // No auth method selected: a native application type indicates a public client. + if (applicationTypeSelect && applicationTypeSelect.value === "native") { + radioOptionPublic.checked = true; + toggleAllowedOrigins(); + } + // Otherwise leave the explicit confidential/public choice as-is. + } + + if (tokenEndpointAuthMethodSelect) { + tokenEndpointAuthMethodSelect.addEventListener("change", syncClientType); + } + if (applicationTypeSelect) { + applicationTypeSelect.addEventListener("change", syncClientType); + } + if (tokenEndpointAuthMethodSelect || applicationTypeSelect) { + syncClientType(); + } })(); 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..09510adc 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -304,7 +304,7 @@ public function edit(Request $request): Response } /** - * TODO v7 mivanci Move to ClientEntityFactory::fromRegistrationData on dynamic client registration implementation. + * TODO v8 mivanci Move to ClientEntityFactory::fromRegistrationData on dynamic client registration implementation. * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ protected function buildClientEntityFromFormData( @@ -370,6 +370,55 @@ 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; + + /** @var mixed $defaultMaxAge */ + $defaultMaxAge = $data[ClaimsEnum::DefaultMaxAge->value] ?? null; + if (is_int($defaultMaxAge)) { + $extraMetadata[ClaimsEnum::DefaultMaxAge->value] = $defaultMaxAge; + } + $extraMetadata[ClaimsEnum::RequireAuthTime->value] = (bool)($data[ClaimsEnum::RequireAuthTime->value] ?? false); + /** @var mixed $defaultAcrValues */ + $defaultAcrValues = $data[ClaimsEnum::DefaultAcrValues->value] ?? null; + $extraMetadata[ClaimsEnum::DefaultAcrValues->value] = is_array($defaultAcrValues) ? $defaultAcrValues : []; + /** @var mixed $initiateLoginUri */ + $initiateLoginUri = $data[ClaimsEnum::InitiateLoginUri->value] ?? null; + $extraMetadata[ClaimsEnum::InitiateLoginUri->value] = is_string($initiateLoginUri) ? $initiateLoginUri : null; + /** @var mixed $softwareId */ + $softwareId = $data[ClaimsEnum::SoftwareId->value] ?? null; + $extraMetadata[ClaimsEnum::SoftwareId->value] = is_string($softwareId) ? $softwareId : null; + /** @var mixed $softwareVersion */ + $softwareVersion = $data[ClaimsEnum::SoftwareVersion->value] ?? null; + $extraMetadata[ClaimsEnum::SoftwareVersion->value] = is_string($softwareVersion) ? $softwareVersion : null; + + foreach ( + [ + ClaimsEnum::LogoUri->value, + ClaimsEnum::ClientUri->value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ClaimsEnum::ApplicationType->value, + ] as $stringClaim + ) { + /** @var mixed $stringClaimValue */ + $stringClaimValue = $data[$stringClaim] ?? null; + $extraMetadata[$stringClaim] = is_string($stringClaimValue) ? $stringClaimValue : null; + } + /** @var mixed $contacts */ + $contacts = $data[ClaimsEnum::Contacts->value] ?? null; + $extraMetadata[ClaimsEnum::Contacts->value] = is_array($contacts) ? $contacts : []; + // 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..72b5d9e3 --- /dev/null +++ b/src/Controllers/RegistrationController.php @@ -0,0 +1,390 @@ +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->issueRegistrationAccessToken($client); + + $this->clientRepository->add($client); + + return $this->jsonResponse( + $this->buildClientInformationResponse($client, $registrationAccessToken), + 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); + + // RFC 7592 Section 3 requires the Client Information Response to include a registration_access_token. Only + // the hash is stored, so we cannot return the original plaintext; instead we rotate the token on read, + // persist the new hash and return the new plaintext (RFC 7592 Section 2.1 permits the returned token to + // differ from the one issued previously; the client must then use the new token). + $registrationAccessToken = $this->issueRegistrationAccessToken($client); + $this->clientRepository->update($client); + + return $this->jsonResponse( + $this->buildClientInformationResponse($client, $registrationAccessToken), + 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, + ); + + // Rotate the Registration Access Token: RFC 7592 Section 3 requires it in the response, and only its hash is + // stored. The factory carries over the previous hash from the existing client, which we overwrite here. + $registrationAccessToken = $this->issueRegistrationAccessToken($updatedClient); + $this->clientRepository->update($updatedClient); + + return $this->jsonResponse( + $this->buildClientInformationResponse($updatedClient, $registrationAccessToken), + 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 + { + // RFC 7591 Section 3.1 / RFC 7592 Section 2.2: registration (create) and update requests carry a JSON + // document and MUST use Content-Type: application/json. Reject other media types rather than parsing them. + // Parameters such as "; charset=utf-8" are allowed; the comparison is on the media type only. + $contentType = strtolower(trim(explode(';', (string)$request->headers->get('Content-Type', ''))[0])); + if ($contentType !== 'application/json') { + throw OidcServerException::invalidRequest( + 'Content-Type', + 'Registration requests must use Content-Type: application/json.', + ); + } + + $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, + string $registrationAccessToken, + ): array { + $response = [ + ClaimsEnum::ClientId->value => $client->getIdentifier(), + ClaimsEnum::ClientIdIssuedAt->value => $client->getCreatedAt()?->getTimestamp(), + // RFC 7592 Section 3: both registration_access_token and registration_client_uri are REQUIRED in the + // Client Information Response (the response shape used by create, read and update). + ClaimsEnum::RegistrationAccessToken->value => $registrationAccessToken, + 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(); + + if (($defaultMaxAge = $client->getDefaultMaxAge()) !== null) { + $response[ClaimsEnum::DefaultMaxAge->value] = $defaultMaxAge; + } + if ($client->getRequireAuthTime()) { + $response[ClaimsEnum::RequireAuthTime->value] = true; + } + if (($defaultAcrValues = $client->getDefaultAcrValues()) !== []) { + $response[ClaimsEnum::DefaultAcrValues->value] = $defaultAcrValues; + } + + // 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; + } + + /** + * Mint a fresh Registration Access Token, store only its hash on the client, and return the plaintext (returned + * once in the Client Information Response). Used at registration and rotated on each read/update. + */ + protected function issueRegistrationAccessToken(ClientEntityInterface $client): string + { + $registrationAccessToken = $this->helpers->random()->getIdentifier(); + $client->setRegistrationAccessTokenHash($this->hashToken($registrationAccessToken)); + + return $registrationAccessToken; + } + + 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..23a5c09b 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -55,6 +55,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 +125,7 @@ class ClientEntity implements ClientEntityInterface private ?DateTimeImmutable $expiresAt; private bool $isGeneric; private ?array $extraMetadata; + private ?string $registrationAccessToken; /** * @param string[] $redirectUri @@ -154,6 +160,7 @@ public function __construct( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ) { $this->identifier = $identifier; $this->secret = $secret; @@ -179,6 +186,7 @@ public function __construct( $this->expiresAt = $expiresAt; $this->isGeneric = $isGeneric; $this->extraMetadata = $extraMetadata; + $this->registrationAccessToken = $registrationAccessToken; } /** @@ -220,6 +228,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 +265,23 @@ 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(), + ClaimsEnum::DefaultMaxAge->value => $this->getDefaultMaxAge(), + ClaimsEnum::RequireAuthTime->value => $this->getRequireAuthTime(), + ClaimsEnum::DefaultAcrValues->value => $this->getDefaultAcrValues(), + ClaimsEnum::InitiateLoginUri->value => $this->getInitiateLoginUri(), + ClaimsEnum::SoftwareId->value => $this->getSoftwareId(), + ClaimsEnum::SoftwareVersion->value => $this->getSoftwareVersion(), + ClaimsEnum::LogoUri->value => $this->getLogoUri(), + ClaimsEnum::ClientUri->value => $this->getClientUri(), + ClaimsEnum::PolicyUri->value => $this->getPolicyUri(), + ClaimsEnum::TosUri->value => $this->getTosUri(), + ClaimsEnum::ApplicationType->value => $this->getApplicationType(), + ClaimsEnum::Contacts->value => $this->getContacts(), self::KEY_AUTH_PROC_FILTERS => $this->getAuthProcFilters(), + self::KEY_REGISTRATION_ACCESS_TOKEN => $this->registrationAccessToken, ]; } @@ -401,6 +426,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 +534,209 @@ public function getRequestUris(): array return $stringUris; } + + /** + * The OAuth 2.0 grant types the client is registered to use, or an empty array when none are registered. + * + * v7 transition: this returns the raw registered value (empty when unset) rather than synthesizing the + * OpenID Connect Dynamic Client Registration 1.0 default (["authorization_code"]). This keeps the stored + * value the single source of truth, so the admin UI and the registration echo reflect exactly what is + * registered, and clients created before this property existed are not retroactively constrained. The DCR + * default is still applied at registration time for dynamic clients (see ClientEntityFactory). A future + * major version may switch this getter to return the spec default when unset. + * + * @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 []; + } + + return array_values(array_filter($grantTypes, 'is_string')); + } + + /** + * The OAuth 2.0 response types the client is registered to use, or an empty array when none are registered. + * + * v7 transition: returns the raw registered value (empty when unset) rather than synthesizing the OpenID + * Connect Dynamic Client Registration 1.0 default (["code"]). See getGrantTypes() for the rationale. + * + * @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 []; + } + + return array_values(array_filter($responseTypes, 'is_string')); + } + + /** + * The client authentication method the client is registered to use at the token endpoint, or null when none + * is registered. + * + * v7 transition: returns the raw registered value (null when unset) rather than synthesizing the OpenID + * Connect Dynamic Client Registration 1.0 default ('client_secret_basic' / 'none'). See getGrantTypes() for + * the rationale. + */ + 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 null; + } + + /** + * Default Maximum Authentication Age (seconds) applied when the authorization request omits max_age, or null + * when not registered. + */ + public function getDefaultMaxAge(): ?int + { + /** @var mixed $value */ + $value = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::DefaultMaxAge->value] ?? null) : null; + + if (is_int($value) || is_string($value)) { + $filtered = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + return $filtered !== false ? $filtered : null; + } + + return null; + } + + /** + * Whether the auth_time claim is required in the ID Token issued to this client. + */ + public function getRequireAuthTime(): bool + { + /** @var mixed $value */ + $value = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::RequireAuthTime->value] ?? null) : null; + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Default ACR values requested when the authorization request omits acr_values. + * + * @return string[] + */ + public function getDefaultAcrValues(): array + { + /** @var mixed $values */ + $values = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::DefaultAcrValues->value] ?? null) : null; + + if (!is_array($values)) { + return []; + } + + return array_values(array_filter($values, 'is_string')); + } + + /** + * URI a third party can use to initiate login for this client (informational; the OP does not act on it). + */ + public function getInitiateLoginUri(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::InitiateLoginUri->value); + } + + /** + * RFC 7591 software_id (informational). + */ + public function getSoftwareId(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::SoftwareId->value); + } + + /** + * RFC 7591 software_version (informational). + */ + public function getSoftwareVersion(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::SoftwareVersion->value); + } + + /** + * logo_uri (informational; subject to impersonation protection on the DCR path). + */ + public function getLogoUri(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::LogoUri->value); + } + + /** + * client_uri (informational). + */ + public function getClientUri(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::ClientUri->value); + } + + /** + * policy_uri (informational; subject to impersonation protection on the DCR path). + */ + public function getPolicyUri(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::PolicyUri->value); + } + + /** + * tos_uri (informational; subject to impersonation protection on the DCR path). + */ + public function getTosUri(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::TosUri->value); + } + + /** + * application_type (web or native), or null when not registered. + */ + public function getApplicationType(): ?string + { + return $this->getStringExtraMetadata(ClaimsEnum::ApplicationType->value); + } + + /** + * contacts (e.g. administrator e-mail addresses). + * + * @return string[] + */ + public function getContacts(): array + { + /** @var mixed $contacts */ + $contacts = is_array($this->extraMetadata) ? + ($this->extraMetadata[ClaimsEnum::Contacts->value] ?? null) : null; + + if (!is_array($contacts)) { + return []; + } + + return array_values(array_filter($contacts, 'is_string')); + } + + private function getStringExtraMetadata(string $key): ?string + { + /** @var mixed $value */ + $value = is_array($this->extraMetadata) ? ($this->extraMetadata[$key] ?? null) : null; + + return (is_string($value) && $value !== '') ? $value : null; + } } diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index 6d66c544..4410c468 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,48 @@ public function getRequireSignedRequestObject(): bool; */ public function getRequestUris(): array; + /** + * @return string[] + */ + public function getGrantTypes(): array; + + /** + * @return string[] + */ + public function getResponseTypes(): array; + + public function getTokenEndpointAuthMethod(): ?string; + + public function getDefaultMaxAge(): ?int; + + public function getRequireAuthTime(): bool; + + /** + * @return string[] + */ + public function getDefaultAcrValues(): array; + + public function getInitiateLoginUri(): ?string; + + public function getSoftwareId(): ?string; + + public function getSoftwareVersion(): ?string; + + public function getLogoUri(): ?string; + + public function getClientUri(): ?string; + + public function getPolicyUri(): ?string; + + public function getTosUri(): ?string; + + public function getApplicationType(): ?string; + + /** + * @return string[] + */ + public function getContacts(): array; + /** * @return array */ diff --git a/src/Factories/Entities/ClientEntityFactory.php b/src/Factories/Entities/ClientEntityFactory.php index 79224cd4..1394db0a 100644 --- a/src/Factories/Entities/ClientEntityFactory.php +++ b/src/Factories/Entities/ClientEntityFactory.php @@ -12,6 +12,7 @@ use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Utils\ResponseTypeGrantTypeCorrespondence; use SimpleSAML\OpenID\Codebooks\ApplicationTypesEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; @@ -21,6 +22,28 @@ 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, + ClaimsEnum::InitiateLoginUri->value, + ClaimsEnum::SoftwareId->value, + ClaimsEnum::SoftwareVersion->value, + ]; + public function __construct( private readonly SspBridge $sspBridge, private readonly Helpers $helpers, @@ -61,6 +84,7 @@ public function fromData( ?DateTimeImmutable $expiresAt = null, bool $isGeneric = false, ?array $extraMetadata = null, + ?string $registrationAccessToken = null, ): ClientEntityInterface { return new ClientEntity( $id, @@ -87,6 +111,7 @@ public function fromData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -133,12 +158,22 @@ public function fromRegistrationData( unset($metadata[$adminOnlyMetadataKey]); } + // RFC 7592 client update is a full REPLACE, not a merge: on a DCR update, client-settable metadata the + // request omits must be reset to its OP default (or removed), while server-managed and admin-only + // properties are still carried over from the existing client. We model that with a separate "metadata + // fallback" client that is null on a DCR update, so the per-field `?? $metadataFallbackClient?->...` + // expressions below fall back to the default rather than the previously-registered value. Manual and + // OpenID Federation registrations keep their existing merge behaviour (the entity statement / admin form + // carries the full intended state anyway). + $isDcrUpdate = $existingClient !== null && $registrationType === RegistrationTypeEnum::Dynamic; + $metadataFallbackClient = $isDcrUpdate ? null : $existingClient; + $id = $clientIdentifier ?? $existingClient?->getIdentifier() ?? $this->sspBridge->utils()->random()->generateID(); $secret = $existingClient?->getSecret() ?? $this->sspBridge->utils()->random()->generateID(); - $name = (string)($metadata[ClaimsEnum::ClientName->value] ?? $existingClient?->getName() ?? $id); + $name = (string)($metadata[ClaimsEnum::ClientName->value] ?? $metadataFallbackClient?->getName() ?? $id); $description = $existingClient?->getDescription() ?? ''; @@ -148,9 +183,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] ?? $metadataFallbackClient?->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, @@ -159,9 +206,16 @@ public function fromRegistrationData( // Let's ensure there is at least 'openid' scope present. $scopes = empty($scopes) ? [ScopesEnum::OpenId->value] : $scopes; - $isEnabled = $existingClient?->isEnabled() ?? true; + // For a new Dynamic (DCR) client, the initial enabled state is governed by configuration: deployments can + // choose to create dynamically registered clients disabled, so an administrator reviews and enables them + // before use ("register, then approve"). On update the existing state is preserved (review only gates the + // initial registration). OpenID Federation automatic registrations are always created enabled. + $isEnabled = $existingClient?->isEnabled() + ?? ($registrationType === RegistrationTypeEnum::Dynamic + ? $this->moduleConfig->getDcrRegisteredClientsEnabled() + : true); - $isConfidential = $existingClient?->isConfidential() ?? $this->determineIsConfidential( + $isConfidential = $metadataFallbackClient?->isConfidential() ?? $this->determineIsConfidential( $metadata, ); @@ -170,21 +224,21 @@ public function fromRegistrationData( $postLogoutRedirectUris = isset($metadata[ClaimsEnum::PostLogoutRedirectUris->value]) && is_array($metadata[ClaimsEnum::PostLogoutRedirectUris->value]) ? $this->helpers->arr()->ensureStringValues($metadata[ClaimsEnum::PostLogoutRedirectUris->value]) : - $existingClient?->getPostLogoutRedirectUri() ?? []; + $metadataFallbackClient?->getPostLogoutRedirectUri() ?? []; $backChannelLogoutUri = isset($metadata[ClaimsEnum::BackChannelLogoutUri->value]) && is_string($metadata[ClaimsEnum::BackChannelLogoutUri->value]) ? $metadata[ClaimsEnum::BackChannelLogoutUri->value] : - $existingClient?->getBackChannelLogoutUri(); + $metadataFallbackClient?->getBackChannelLogoutUri(); $entityIdentifier = $clientIdentifier ?? $existingClient?->getEntityIdentifier(); $clientRegistrationTypes = isset($metadata[ClaimsEnum::ClientRegistrationTypes->value]) && is_array($metadata[ClaimsEnum::ClientRegistrationTypes->value]) ? $this->helpers->arr()->ensureStringValues($metadata[ClaimsEnum::ClientRegistrationTypes->value]) : - $existingClient?->getClientRegistrationTypes(); + $metadataFallbackClient?->getClientRegistrationTypes(); - $federationJwks = $federationJwks ?? $existingClient?->getFederationJwks(); + $federationJwks = $federationJwks ?? $metadataFallbackClient?->getFederationJwks(); /** @var ?array[] $jwks */ $jwks = isset($metadata[ClaimsEnum::Jwks->value]) && @@ -192,17 +246,17 @@ public function fromRegistrationData( array_key_exists(ClaimsEnum::Keys->value, $metadata[ClaimsEnum::Jwks->value]) && (!empty($metadata[ClaimsEnum::Jwks->value][ClaimsEnum::Keys->value])) ? $metadata[ClaimsEnum::Jwks->value] : - $existingClient?->getJwks(); + $metadataFallbackClient?->getJwks(); $jwksUri = isset($metadata[ClaimsEnum::JwksUri->value]) && is_string($metadata[ClaimsEnum::JwksUri->value]) ? $metadata[ClaimsEnum::JwksUri->value] : - $existingClient?->getJwksUri(); + $metadataFallbackClient?->getJwksUri(); $signedJwksUri = isset($metadata[ClaimsEnum::SignedJwksUri->value]) && is_string($metadata[ClaimsEnum::SignedJwksUri->value]) ? $metadata[ClaimsEnum::SignedJwksUri->value] : - $existingClient?->getSignedJwksUri(); + $metadataFallbackClient?->getSignedJwksUri(); // $registrationType = $registrationType; @@ -214,14 +268,31 @@ public function fromRegistrationData( $isGeneric = $existingClient?->isGeneric() ?? false; - $extraMetadata = $existingClient?->getExtraMetadata() ?? []; + // 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(); + + // On a DCR update this starts empty (replace semantics); on create/manual/federation it carries the existing + // extra metadata. Admin-only extra metadata (e.g. authproc) is never client-settable and is re-injected from + // the real existing client below so a DCR update cannot drop it. + $extraMetadata = $metadataFallbackClient?->getExtraMetadata() ?? []; + if ($isDcrUpdate) { + // $isDcrUpdate implies $existingClient is non-null (see its definition above). + $existingExtraMetadata = $existingClient->getExtraMetadata(); + foreach (ClientEntity::ADMIN_ONLY_METADATA_KEYS as $adminOnlyMetadataKey) { + if (array_key_exists($adminOnlyMetadataKey, $existingExtraMetadata)) { + /** @psalm-suppress MixedAssignment */ + $extraMetadata[$adminOnlyMetadataKey] = $existingExtraMetadata[$adminOnlyMetadataKey]; + } + } + } // Handle any other supported client metadata as extra metadata. // id_token_signed_response_alg $idTokenSignedResponseAlg = isset($metadata[ClaimsEnum::IdTokenSignedResponseAlg->value]) && is_string($metadata[ClaimsEnum::IdTokenSignedResponseAlg->value]) ? $metadata[ClaimsEnum::IdTokenSignedResponseAlg->value] : - $existingClient?->getIdTokenSignedResponseAlg(); + $metadataFallbackClient?->getIdTokenSignedResponseAlg(); // Make sure the requested id_token_signed_response_alg is one of the OP // can actually sign ID Tokens with, i.e. one for which a protocol @@ -243,6 +314,125 @@ 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]; + } + + // Normalize grant_types to satisfy the OIDC DCR response_type <-> grant_type correspondence: every grant + // type required by a registered response_type MUST be present in grant_types. We augment rather than reject, + // so a client that (legally) omits grant_types while declaring a non-code response_type still ends up with a + // consistent, usable registration (echoed back per RFC 7591 Section 3.2.1). Only applied when grant_types is + // already present (Dynamic clients always are, after the default above); federation/manual registrations + // without a grant_types value are left untouched (presence-based, like the per-client enforcement). + if ( + array_key_exists(ClaimsEnum::GrantTypes->value, $extraMetadata) && + is_array($extraMetadata[ClaimsEnum::GrantTypes->value]) && + array_key_exists(ClaimsEnum::ResponseTypes->value, $extraMetadata) && + is_array($extraMetadata[ClaimsEnum::ResponseTypes->value]) + ) { + $extraMetadata[ClaimsEnum::GrantTypes->value] = + ResponseTypeGrantTypeCorrespondence::mergeRequiredGrantTypes( + $this->helpers->arr()->ensureStringValues($extraMetadata[ClaimsEnum::GrantTypes->value]), + $this->helpers->arr()->ensureStringValues($extraMetadata[ClaimsEnum::ResponseTypes->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; + } + + // Keep the client type (confidential/public) in lockstep with the effective token_endpoint_auth_method, + // which is the DCR signal for it: `none` => public, any real authentication method => confidential. This is + // re-derived from the final resolved value (provided, carried over from an existing client, or defaulted + // above), so it stays correct on RFC 7592 updates too - not just first registration. When no auth method is + // resolved (e.g. a federation/manual registration that did not set one), the value determined earlier from + // the rest of the metadata (or carried over from the existing client) stands. + $effectiveTokenEndpointAuthMethod = $extraMetadata[ClaimsEnum::TokenEndpointAuthMethod->value] ?? null; + if (is_string($effectiveTokenEndpointAuthMethod) && $effectiveTokenEndpointAuthMethod !== '') { + $isConfidential = $effectiveTokenEndpointAuthMethod !== TokenEndpointAuthMethodsEnum::None->value; + } + + // Behavioral "default when omitted" metadata, persisted (and enforced in the authorization flow / ID Token). + // Values are already format-validated at the registration boundary (ClientMetadataValidator) and the admin + // form; here we only persist when present so they can be echoed and applied. Preserved on update when omitted. + if (array_key_exists(ClaimsEnum::DefaultMaxAge->value, $metadata)) { + /** @var mixed $defaultMaxAge */ + $defaultMaxAge = $metadata[ClaimsEnum::DefaultMaxAge->value]; + if (is_int($defaultMaxAge) || (is_string($defaultMaxAge) && ctype_digit($defaultMaxAge))) { + $extraMetadata[ClaimsEnum::DefaultMaxAge->value] = (int)$defaultMaxAge; + } + } + + if (array_key_exists(ClaimsEnum::RequireAuthTime->value, $metadata)) { + $extraMetadata[ClaimsEnum::RequireAuthTime->value] = (bool)$metadata[ClaimsEnum::RequireAuthTime->value]; + } + + if ( + isset($metadata[ClaimsEnum::DefaultAcrValues->value]) && + is_array($metadata[ClaimsEnum::DefaultAcrValues->value]) + ) { + $extraMetadata[ClaimsEnum::DefaultAcrValues->value] = $this->helpers->arr()->ensureStringValues( + $metadata[ClaimsEnum::DefaultAcrValues->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 +459,7 @@ public function fromRegistrationData( $expiresAt, $isGeneric, $extraMetadata, + $registrationAccessToken, ); } @@ -404,6 +595,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 +624,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..45381f21 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -23,8 +23,11 @@ use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Utils\ResponseTypeGrantTypeCorrespondence; +use SimpleSAML\OpenID\Codebooks\ApplicationTypesEnum; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; +use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; use Traversable; /** @@ -310,6 +313,22 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co /** @psalm-suppress RedundantCast */ $values = (array)parent::getValues(self::TYPE_ARRAY); + // Derive the client type (confidential/public) using the same precedence as DCR registration + // (see ClientEntityFactory::determineIsConfidential() + the token_endpoint_auth_method lockstep): + // 1. token_endpoint_auth_method, if selected: `none` => public, any real method => confidential; + // 2. else application_type `native` => public (a native app is a strong public-client indication); + // 3. else the explicit confidential/public choice stands. + // The server is the authority here; client-form.js mirrors this live in the UI. + /** @var mixed $selectedAuthMethod */ + $selectedAuthMethod = $values[ClaimsEnum::TokenEndpointAuthMethod->value] ?? null; + /** @var mixed $selectedApplicationType */ + $selectedApplicationType = $values[ClaimsEnum::ApplicationType->value] ?? null; + if (is_string($selectedAuthMethod) && trim($selectedAuthMethod) !== '') { + $values['is_confidential'] = trim($selectedAuthMethod) !== TokenEndpointAuthMethodsEnum::None->value; + } elseif ($selectedApplicationType === ApplicationTypesEnum::Native->value) { + $values['is_confidential'] = false; + } + // Sanitize redirect_uri and allowed_origin $values['redirect_uri'] = $this->helpers->str()->convertTextToArray((string)$values['redirect_uri']); if (! $values['is_confidential'] && isset($values['allowed_origin'])) { @@ -385,6 +404,81 @@ 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())), + ); + + // Enforce the OIDC DCR response_type <-> grant_type correspondence server-side (the JS does the same live): + // every grant type required by a selected response_type must be present in grant_types. We keep only + // supported grant types so the multi-select can render the result. + /** @var string[] $selectedGrantTypes */ + $selectedGrantTypes = $values[ClaimsEnum::GrantTypes->value]; + /** @var string[] $selectedResponseTypes */ + $selectedResponseTypes = $values[ClaimsEnum::ResponseTypes->value]; + + $normalizedGrantTypes = ResponseTypeGrantTypeCorrespondence::mergeRequiredGrantTypes( + $selectedGrantTypes, + $selectedResponseTypes, + ); + $values[ClaimsEnum::GrantTypes->value] = array_values( + array_intersect($normalizedGrantTypes, array_keys($this->getSupportedGrantTypes())), + ); + + /** @var mixed $tokenEndpointAuthMethod */ + $tokenEndpointAuthMethod = $values[ClaimsEnum::TokenEndpointAuthMethod->value] ?? ''; + $tokenEndpointAuthMethod = is_string($tokenEndpointAuthMethod) ? trim($tokenEndpointAuthMethod) : ''; + $values[ClaimsEnum::TokenEndpointAuthMethod->value] = $tokenEndpointAuthMethod === '' ? + null : $tokenEndpointAuthMethod; + + /** @var mixed $defaultMaxAgeRaw */ + $defaultMaxAgeRaw = $values[ClaimsEnum::DefaultMaxAge->value] ?? ''; + $defaultMaxAge = (is_string($defaultMaxAgeRaw) || is_int($defaultMaxAgeRaw)) ? + trim((string)$defaultMaxAgeRaw) : ''; + $values[ClaimsEnum::DefaultMaxAge->value] = ctype_digit($defaultMaxAge) ? (int)$defaultMaxAge : null; + + $values[ClaimsEnum::RequireAuthTime->value] = (bool)($values[ClaimsEnum::RequireAuthTime->value] ?? false); + + /** @var mixed $defaultAcrValues */ + $defaultAcrValues = $values[ClaimsEnum::DefaultAcrValues->value] ?? null; + $defaultAcrValues = is_array($defaultAcrValues) ? $defaultAcrValues : []; + $values[ClaimsEnum::DefaultAcrValues->value] = array_values( + array_intersect($defaultAcrValues, array_keys($this->getSupportedAcrValues())), + ); + + foreach ( + [ + ClaimsEnum::InitiateLoginUri->value, + ClaimsEnum::SoftwareId->value, + ClaimsEnum::SoftwareVersion->value, + ClaimsEnum::LogoUri->value, + ClaimsEnum::ClientUri->value, + ClaimsEnum::PolicyUri->value, + ClaimsEnum::TosUri->value, + ClaimsEnum::ApplicationType->value, + ] as $stringClaim + ) { + /** @var mixed $claimValue */ + $claimValue = $values[$stringClaim] ?? ''; + $stringValue = is_string($claimValue) ? trim($claimValue) : ''; + $values[$stringClaim] = $stringValue === '' ? null : $stringValue; + } + + /** @var mixed $contacts */ + $contacts = $values[ClaimsEnum::Contacts->value] ?? ''; + $values[ClaimsEnum::Contacts->value] = $this->helpers->str()->convertTextToArray( + is_string($contacts) ? $contacts : '', + ); + $authProcFilters = trim((string)($values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? '')); try { /** @psalm-suppress MixedAssignment */ @@ -476,6 +570,55 @@ 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 $defaultMaxAge */ + $defaultMaxAge = $values[ClaimsEnum::DefaultMaxAge->value] ?? null; + $values[ClaimsEnum::DefaultMaxAge->value] = is_int($defaultMaxAge) ? (string)$defaultMaxAge : ''; + + $values[ClaimsEnum::RequireAuthTime->value] = (bool)($values[ClaimsEnum::RequireAuthTime->value] ?? false); + + /** @var mixed $defaultAcrValues */ + $defaultAcrValues = $values[ClaimsEnum::DefaultAcrValues->value] ?? null; + $defaultAcrValues = is_array($defaultAcrValues) ? $defaultAcrValues : []; + // The field is a multi-select bound to the OP's supported ACRs; keep only currently-supported values so the + // control can render them (values no longer supported are dropped rather than shown as invalid options). + $values[ClaimsEnum::DefaultAcrValues->value] = array_values( + array_intersect($defaultAcrValues, array_keys($this->getSupportedAcrValues())), + ); + + /** @var mixed $contacts */ + $contacts = $values[ClaimsEnum::Contacts->value] ?? null; + $contacts = is_array($contacts) ? $contacts : []; + $contactStrings = []; + /** @var mixed $contact */ + foreach ($contacts as $contact) { + if (is_string($contact)) { + $contactStrings[] = $contact; + } + } + $values[ClaimsEnum::Contacts->value] = implode("\n", $contactStrings); + /** @var mixed $authProcFilters */ $authProcFilters = $values[ClientEntity::KEY_AUTH_PROC_FILTERS] ?? null; $values[ClientEntity::KEY_AUTH_PROC_FILTERS] = (is_array($authProcFilters) && $authProcFilters !== []) ? @@ -588,6 +731,68 @@ 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->addText(ClaimsEnum::DefaultMaxAge->value, Translate::noop('Default Max Age (seconds)')) + ->setHtmlAttribute('class', 'full-width') + ->setHtmlType('number'); + + $this->addCheckbox(ClaimsEnum::RequireAuthTime->value, Translate::noop('Require auth_time in ID Token')); + + // Bound to the OP's supported ACRs (acr_values_supported). When the OP advertises no ACRs, this has no + // items and the field is hidden in the template (a per-client default ACR cannot do anything in that case). + $this->addMultiSelect( + ClaimsEnum::DefaultAcrValues->value, + Translate::noop('Default ACR Values'), + $this->getSupportedAcrValues(), + 3, + )->setHtmlAttribute('class', 'full-width'); + + $this->addText(ClaimsEnum::InitiateLoginUri->value, Translate::noop('Initiate Login URI')) + ->setHtmlAttribute('class', 'full-width'); + + $this->addText(ClaimsEnum::SoftwareId->value, Translate::noop('Software ID')) + ->setHtmlAttribute('class', 'full-width'); + + $this->addText(ClaimsEnum::SoftwareVersion->value, Translate::noop('Software Version')) + ->setHtmlAttribute('class', 'full-width'); + + $this->addText(ClaimsEnum::LogoUri->value, Translate::noop('Logo URI')) + ->setHtmlAttribute('class', 'full-width'); + $this->addText(ClaimsEnum::ClientUri->value, Translate::noop('Client URI')) + ->setHtmlAttribute('class', 'full-width'); + $this->addText(ClaimsEnum::PolicyUri->value, Translate::noop('Policy URI')) + ->setHtmlAttribute('class', 'full-width'); + $this->addText(ClaimsEnum::TosUri->value, Translate::noop('Terms of Service URI')) + ->setHtmlAttribute('class', 'full-width'); + + $this->addSelect(ClaimsEnum::ApplicationType->value, Translate::noop('Application Type')) + ->setHtmlAttribute('class', 'full-width') + ->setItems($this->getSupportedApplicationTypes(), false) + ->setPrompt(Translate::noop('-')); + + $this->addTextArea(ClaimsEnum::Contacts->value, Translate::noop('Contacts (one per line)'), null, 3) + ->setHtmlAttribute('class', 'full-width'); + $this->addTextArea( ClientEntity::KEY_AUTH_PROC_FILTERS, Translate::noop('Authentication Processing Filters'), @@ -640,6 +845,97 @@ 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 = $this->moduleConfig->getSupportedGrantTypes(); + + 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 = $this->moduleConfig->getSupportedResponseTypes(); + + 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 = $this->moduleConfig->getSupportedTokenEndpointAuthMethods(); + + return array_combine($supported, $supported); + } + + /** + * The OP's supported ACR values (value => label), as configured via OPTION_AUTH_ACR_VALUES_SUPPORTED and + * advertised in discovery as acr_values_supported. Empty when the OP advertises no ACRs. + * + * @return array + */ + protected function getSupportedAcrValues(): array + { + /** @var list $supported */ + $supported = array_values(array_filter($this->moduleConfig->getAcrValuesSupported(), 'is_string')); + + return array_combine($supported, $supported); + } + + /** + * Whether the OP has any supported ACR values configured. Used by the template to hide the per-client + * default_acr_values field when there is nothing to select. + */ + public function hasConfiguredAcrValues(): bool + { + return $this->getSupportedAcrValues() !== []; + } + + /** + * JSON map of response_type => required grant_types, restricted to the response types this OP offers. Consumed + * by the admin-form JavaScript to live-select the corresponding grant types, sharing the single source of truth + * with the server-side normalization (ResponseTypeGrantTypeCorrespondence). + */ + public function getResponseTypeGrantTypeMapJson(): string + { + $map = array_intersect_key( + ResponseTypeGrantTypeCorrespondence::map(), + $this->getSupportedResponseTypes(), + ); + + return (string)json_encode($map, JSON_UNESCAPED_SLASHES); + } + + /** + * Application types the client may register (value => label). + * + * @return array + */ + protected function getSupportedApplicationTypes(): array + { + $supported = [ + ApplicationTypesEnum::Web->value, + ApplicationTypesEnum::Native->value, + ]; + + return array_combine($supported, $supported); + } + /** * @throws \Exception */ diff --git a/src/Forms/Controls/CsrfProtection.php b/src/Forms/Controls/CsrfProtection.php index b659de67..2ab6a5f2 100644 --- a/src/Forms/Controls/CsrfProtection.php +++ b/src/Forms/Controls/CsrfProtection.php @@ -22,6 +22,9 @@ use SimpleSAML\Session; use Stringable; +/** + * @psalm-suppress DeprecatedClass + */ class CsrfProtection extends BaseCsrfProtection { final public const array PROTECTION = [ 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..251030b5 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -22,12 +22,16 @@ 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; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum; use SimpleSAML\OpenID\Codebooks\ScopesEnum; +use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\OpenID\Serializers\JwsSerializerBag; use SimpleSAML\OpenID\Serializers\JwsSerializerEnum; @@ -101,6 +105,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 +131,13 @@ 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_DCR_REGISTERED_CLIENTS_ENABLED = 'dcr_registered_clients_enabled'; 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'; @@ -480,6 +491,57 @@ public function getSupportedResponseModes(): array ]; } + /** + * Response types a client may be registered to use. + * + * Shared by OP discovery metadata, the dynamic client registration validator, + * and the client admin form so that the advertised, accepted, and admin-selectable + * sets cannot drift apart. + * + * @return string[] + */ + public function getSupportedResponseTypes(): array + { + return [ + ResponseTypesEnum::Code->value, + ResponseTypesEnum::IdToken->value, + ResponseTypesEnum::IdTokenToken->value, + ]; + } + + /** + * Grant types a client may be registered to use. + * + * Note: the discovery `grant_types_supported` may advertise additional grant types + * that are not registered per client (e.g. the VCI pre-authorized_code grant); + * that extension is applied by OpMetadataService on top of these values. + * + * @return string[] + */ + public function getSupportedGrantTypes(): array + { + return [ + GrantTypesEnum::AuthorizationCode->value, + GrantTypesEnum::Implicit->value, + GrantTypesEnum::RefreshToken->value, + ]; + } + + /** + * Token endpoint authentication methods a client may be registered to use. + * + * @return string[] + */ + public function getSupportedTokenEndpointAuthMethods(): array + { + return [ + TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, + TokenEndpointAuthMethodsEnum::ClientSecretPost->value, + TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value, + TokenEndpointAuthMethodsEnum::None->value, + ]; + } + /** * @throws ConfigurationError * @return non-empty-array @@ -662,7 +724,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 +746,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 +1063,111 @@ 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); + } + + /** + * Whether a client registered through Dynamic Client Registration (RFC 7591 / OIDC DCR) is created enabled and + * therefore immediately usable. When `true` (default) a dynamically registered client can be used right away. + * Set to `false` to create such clients disabled, so an administrator must review and enable them in the admin + * UI before they can complete authorization/token flows ("register, then approve"); the client can still read + * and manage its own registration (RFC 7592) while disabled. This applies only to Dynamic registrations; OpenID + * Federation automatic registrations (vouched for by their trust chain) are always created enabled. + */ + public function getDcrRegisteredClientsEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_DCR_REGISTERED_CLIENTS_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..acc95cc3 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,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; +use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; @@ -525,6 +527,23 @@ 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 a non-empty grant_types list, it + // must include 'authorization_code' to exchange a code here. getGrantTypes() returns the raw registered + // value (an empty array when nothing is registered - it does not synthesize the OIDC DCR spec default), so + // an empty list means "not configured" and is not enforced, preserving behavior for manually-managed and + // pre-DCR clients. 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. + $registeredGrantTypes = $client->getGrantTypes(); + if ( + $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..dbbd8c75 --- /dev/null +++ b/src/Server/Registration/ClientMetadataValidator.php @@ -0,0 +1,601 @@ +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, + ]; + + // Front-channel logout metadata (not modelled in ClaimsEnum; this OP only supports back-channel logout). + private const string CLAIM_FRONTCHANNEL_LOGOUT_URI = 'frontchannel_logout_uri'; + private const string CLAIM_FRONTCHANNEL_LOGOUT_SESSION_REQUIRED = 'frontchannel_logout_session_required'; + + /** + * Metadata for features this OP does not support. When a client requests any of these, registration is + * rejected with invalid_client_metadata rather than silently ignored (which would leave the client behaving + * differently than it asked). Map of metadata field => human description for the error hint. + */ + private const array UNSUPPORTED_FEATURE_CLAIMS = [ + ClaimsEnum::SectorIdentifierUri->value => + 'sector_identifier_uri (pairwise subject identifiers are not supported)', + ClaimsEnum::UserinfoSignedResponseAlg->value => + 'userinfo_signed_response_alg (signed UserInfo responses are not supported)', + ClaimsEnum::UserinfoEncryptedResponseAlg->value => + 'userinfo_encrypted_response_alg (encrypted UserInfo responses are not supported)', + ClaimsEnum::UserinfoEncryptedResponseEnc->value => + 'userinfo_encrypted_response_enc (encrypted UserInfo responses are not supported)', + ClaimsEnum::IdTokenEncryptedResponseAlg->value => + 'id_token_encrypted_response_alg (encrypted ID Tokens are not supported)', + ClaimsEnum::IdTokenEncryptedResponseEnc->value => + 'id_token_encrypted_response_enc (encrypted ID Tokens are not supported)', + ClaimsEnum::RequestObjectEncryptionAlg->value => + 'request_object_encryption_alg (encrypted Request Objects are not supported)', + ClaimsEnum::RequestObjectEncryptionEnc->value => + 'request_object_encryption_enc (encrypted Request Objects are not supported)', + self::CLAIM_FRONTCHANNEL_LOGOUT_URI => 'frontchannel_logout_uri (front-channel logout is not supported)', + ]; + + 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); + $this->validateRedirectUrisForApplicationType($metadata, $redirectUris); + $this->validateRegisterableProtocolValues($metadata); + $this->validateSubjectType($metadata); + $this->rejectUnsupportedFeatures($metadata); + $this->validateAdditionalMetadata($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.'); + } + } + + /** + * Reject registration of grant_types / response_types / token_endpoint_auth_method values that this OP does not + * support (the same sets it advertises in discovery via ModuleConfig). Without this, a client could + * register values that can never be honored and would fail at authentication/token time. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateRegisterableProtocolValues(array $metadata): void + { + $this->rejectUnsupportedArrayValues( + $metadata, + ClaimsEnum::GrantTypes->value, + $this->moduleConfig->getSupportedGrantTypes(), + ); + $this->rejectUnsupportedArrayValues( + $metadata, + ClaimsEnum::ResponseTypes->value, + $this->moduleConfig->getSupportedResponseTypes(), + ); + + if (array_key_exists(ClaimsEnum::TokenEndpointAuthMethod->value, $metadata)) { + /** @var mixed $authMethod */ + $authMethod = $metadata[ClaimsEnum::TokenEndpointAuthMethod->value]; + if ( + !is_string($authMethod) || + !in_array($authMethod, $this->moduleConfig->getSupportedTokenEndpointAuthMethods(), true) + ) { + throw OidcServerException::invalidClientMetadata( + 'Unsupported token_endpoint_auth_method. Supported: ' . + implode(', ', $this->moduleConfig->getSupportedTokenEndpointAuthMethods()) . '.', + ); + } + } + } + + /** + * Reject the registration when a list-valued metadata field contains a value outside the supported set. When + * present, the field must be an array of strings, each of which must be supported. + * + * @param string[] $supported + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function rejectUnsupportedArrayValues(array $metadata, string $claim, array $supported): void + { + if (!array_key_exists($claim, $metadata)) { + return; + } + + /** @var mixed $values */ + $values = $metadata[$claim]; + if (!is_array($values)) { + throw OidcServerException::invalidClientMetadata(sprintf('%s must be an array.', $claim)); + } + + /** @var mixed $value */ + foreach ($values as $value) { + if (!is_string($value) || !in_array($value, $supported, true)) { + throw OidcServerException::invalidClientMetadata(sprintf( + 'Unsupported %s value: %s. Supported: %s.', + $claim, + is_string($value) ? '"' . $value . '"' : var_export($value, true), + implode(', ', $supported), + )); + } + } + } + + /** + * Verify that every registered redirect_uri conforms to the constraints implied by application_type, as + * required by OpenID Connect Dynamic Client Registration 1.0 (Section 2, application_type): + * + * - native clients: only custom URI schemes, or loopback URLs (localhost / 127.0.0.1 / [::1]), are allowed; + * - web clients using the implicit grant: redirect_uris must use the https scheme and must not use localhost. + * + * application_type defaults to "web" when omitted. The https/localhost rule is, per spec, scoped to web clients + * that use the implicit grant; code-only web clients are not constrained here. + * + * @param string[] $redirectUris already-validated redirect URIs (absolute, no fragment) + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateRedirectUrisForApplicationType(array $metadata, array $redirectUris): void + { + /** @var mixed $applicationType */ + $applicationType = $metadata[ClaimsEnum::ApplicationType->value] ?? ApplicationTypesEnum::Web->value; + $applicationType = is_string($applicationType) ? $applicationType : ApplicationTypesEnum::Web->value; + + if ($applicationType === ApplicationTypesEnum::Native->value) { + foreach ($redirectUris as $redirectUri) { + $scheme = strtolower((string)parse_url($redirectUri, PHP_URL_SCHEME)); + $isHttpScheme = in_array($scheme, ['http', 'https'], true); + // Only custom schemes, or loopback http(s) URLs, are allowed for native clients. + if ($isHttpScheme && !$this->isLoopbackHost($this->extractHost($redirectUri))) { + throw OidcServerException::invalidRedirectUri(sprintf( + 'For a native client, each redirect_uri must use a custom scheme or a loopback address ' + . '(localhost, 127.0.0.1 or [::1]); "%s" is not allowed.', + $redirectUri, + )); + } + } + + return; + } + + // Web client. The https/localhost rule applies only when the client uses the implicit grant. + if (!$this->clientUsesImplicitGrant($metadata)) { + return; + } + + foreach ($redirectUris as $redirectUri) { + $scheme = strtolower((string)parse_url($redirectUri, PHP_URL_SCHEME)); + if ($scheme !== 'https' || $this->extractHost($redirectUri) === 'localhost') { + throw OidcServerException::invalidRedirectUri(sprintf( + 'For a web client using the implicit grant, each redirect_uri must use the https scheme and ' + . 'must not use localhost as the host; "%s" is not allowed.', + $redirectUri, + )); + } + } + } + + /** + * Whether the host is a loopback address per OIDC DCR (localhost, 127.0.0.1 or the IPv6 literal [::1]). + */ + private function isLoopbackHost(?string $host): bool + { + return in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true); + } + + /** + * Whether the registration declares use of the implicit grant, via grant_types (`implicit`) or via a + * response_type that requires it (`id_token`, `id_token token`, and the hybrid combinations). Reuses the shared + * response_type <-> grant_type correspondence so the notion of "uses implicit" stays in one place. + */ + private function clientUsesImplicitGrant(array $metadata): bool + { + /** @var mixed $rawGrantTypes */ + $rawGrantTypes = $metadata[ClaimsEnum::GrantTypes->value] ?? null; + $grantTypes = is_array($rawGrantTypes) ? + array_values(array_filter($rawGrantTypes, 'is_string')) : + []; + if (in_array(GrantTypesEnum::Implicit->value, $grantTypes, true)) { + return true; + } + + /** @var mixed $rawResponseTypes */ + $rawResponseTypes = $metadata[ClaimsEnum::ResponseTypes->value] ?? null; + $responseTypes = is_array($rawResponseTypes) ? + array_values(array_filter($rawResponseTypes, 'is_string')) : + []; + + return in_array( + GrantTypesEnum::Implicit->value, + ResponseTypeGrantTypeCorrespondence::requiredGrantTypes($responseTypes), + true, + ); + } + + /** + * subject_type, when present, must be 'public': this OP only issues public subject identifiers (no pairwise). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateSubjectType(array $metadata): void + { + if (!array_key_exists(ClaimsEnum::SubjectType->value, $metadata)) { + return; + } + + /** @var mixed $subjectType */ + $subjectType = $metadata[ClaimsEnum::SubjectType->value]; + // An empty value is treated as "not specified"; any other non-"public" value is rejected. + if ($subjectType === '' || $subjectType === null) { + return; + } + + if ($subjectType !== 'public') { + throw OidcServerException::invalidClientMetadata( + 'Unsupported subject_type; only "public" is supported.', + ); + } + } + + /** + * Validate additional supported metadata: the behavioral "default when omitted" fields (default_max_age, + * require_auth_time, default_acr_values) and the informational fields (initiate_login_uri, software_id, + * software_version). + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function validateAdditionalMetadata(array $metadata): void + { + if (array_key_exists(ClaimsEnum::DefaultMaxAge->value, $metadata)) { + /** @var mixed $defaultMaxAge */ + $defaultMaxAge = $metadata[ClaimsEnum::DefaultMaxAge->value]; + if ( + !(is_int($defaultMaxAge) || is_string($defaultMaxAge)) || + filter_var($defaultMaxAge, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) === false + ) { + throw OidcServerException::invalidClientMetadata('default_max_age must be a non-negative integer.'); + } + } + + if (array_key_exists(ClaimsEnum::RequireAuthTime->value, $metadata)) { + /** @var mixed $requireAuthTime */ + $requireAuthTime = $metadata[ClaimsEnum::RequireAuthTime->value]; + if (!is_bool($requireAuthTime)) { + throw OidcServerException::invalidClientMetadata('require_auth_time must be a boolean.'); + } + } + + if (array_key_exists(ClaimsEnum::DefaultAcrValues->value, $metadata)) { + /** @var mixed $defaultAcrValues */ + $defaultAcrValues = $metadata[ClaimsEnum::DefaultAcrValues->value]; + if (!is_array($defaultAcrValues)) { + throw OidcServerException::invalidClientMetadata('default_acr_values must be an array.'); + } + $supportedAcrValues = $this->moduleConfig->getAcrValuesSupported(); + /** @var mixed $acr */ + foreach ($defaultAcrValues as $acr) { + if (!is_string($acr) || $acr === '') { + throw OidcServerException::invalidClientMetadata( + 'default_acr_values must be an array of non-empty strings.', + ); + } + // Reject ACRs the OP does not support (advertised in discovery as acr_values_supported); requesting + // an unsupported ACR could never be satisfied at the authorization endpoint. + if (!in_array($acr, $supportedAcrValues, true)) { + throw OidcServerException::invalidClientMetadata( + sprintf('default_acr_values contains an unsupported ACR value: "%s".', $acr), + ); + } + } + } + + // initiate_login_uri must be a valid https URI when present. + if (array_key_exists(ClaimsEnum::InitiateLoginUri->value, $metadata)) { + /** @var mixed $initiateLoginUri */ + $initiateLoginUri = $metadata[ClaimsEnum::InitiateLoginUri->value]; + $scheme = is_string($initiateLoginUri) ? parse_url($initiateLoginUri, PHP_URL_SCHEME) : null; + if ( + !is_string($initiateLoginUri) || + !is_string($scheme) || + strtolower($scheme) !== 'https' || + $this->extractHost($initiateLoginUri) === null + ) { + throw OidcServerException::invalidClientMetadata('initiate_login_uri must be a valid https URI.'); + } + } + + // software_id / software_version, when present, must be non-empty strings. + foreach ([ClaimsEnum::SoftwareId->value, ClaimsEnum::SoftwareVersion->value] as $claim) { + if (!array_key_exists($claim, $metadata)) { + continue; + } + /** @var mixed $value */ + $value = $metadata[$claim]; + if (!is_string($value) || $value === '') { + throw OidcServerException::invalidClientMetadata(sprintf('%s must be a non-empty string.', $claim)); + } + } + } + + /** + * Reject metadata requesting features this OP does not support (see UNSUPPORTED_FEATURE_CLAIMS and front-channel + * logout), rather than silently ignoring it. This keeps the registration response an honest contract: the OP + * either honors a value or rejects it, it does not accept-and-diverge. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + private function rejectUnsupportedFeatures(array $metadata): void + { + foreach (self::UNSUPPORTED_FEATURE_CLAIMS as $claim => $description) { + /** @var mixed $value */ + $value = $metadata[$claim] ?? null; + if (is_string($value) && $value !== '') { + throw OidcServerException::invalidClientMetadata(sprintf('Unsupported metadata: %s.', $description)); + } + } + + // front-channel logout session flag is a boolean modifier of the (unsupported) front-channel logout feature. + /** @var mixed $frontchannelSessionRequired */ + $frontchannelSessionRequired = $metadata[self::CLAIM_FRONTCHANNEL_LOGOUT_SESSION_REQUIRED] ?? null; + if ($frontchannelSessionRequired === true || $frontchannelSessionRequired === 'true') { + throw OidcServerException::invalidClientMetadata( + 'Unsupported metadata: frontchannel_logout_session_required (front-channel logout is not supported).', + ); + } + } + + /** + * 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 at all. OpenID Connect Core 3.1.2.1 requires redirect_uris to + * contain no fragment component, which includes an empty fragment: a trailing '#' (e.g. ".../cb#") is a + * fragment component even though parse_url() reports it as an empty string. Any literal '#' delimiter therefore + * counts; a percent-encoded '%23' in the path/query does not (it is not a fragment delimiter). + */ + private function hasFragment(string $uri): bool + { + return str_contains($uri, '#'); + } + + /** + * 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/AcrValuesRule.php b/src/Server/RequestRules/Rules/AcrValuesRule.php index 85ed2413..8cdef34f 100644 --- a/src/Server/RequestRules/Rules/AcrValuesRule.php +++ b/src/Server/RequestRules/Rules/AcrValuesRule.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode; @@ -74,6 +75,15 @@ public function checkRule( $acrValues['values'] = array_merge($acrValues['values'], explode(' ', $acrValuesParam)); } + // Fall back to the client's registered default_acr_values when the request specified no acr (via the + // claims parameter or acr_values). OIDC DCR 1.0: default_acr_values are the Default requested ACRs. + if ($acrValues['values'] === []) { + $client = $currentResultBag->get(ClientRule::class)?->getValue(); + if ($client instanceof ClientEntityInterface) { + $acrValues['values'] = $client->getDefaultAcrValues(); + } + } + return new Result($this->getKey(), empty($acrValues['values']) ? null : $acrValues); } } diff --git a/src/Server/RequestRules/Rules/MaxAgeRule.php b/src/Server/RequestRules/Rules/MaxAgeRule.php index ded4849f..d3ab6095 100644 --- a/src/Server/RequestRules/Rules/MaxAgeRule.php +++ b/src/Server/RequestRules/Rules/MaxAgeRule.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -64,35 +65,48 @@ public function checkRule( $authSimple = $this->authSimpleFactory->build($client); - if (!array_key_exists(ParamsEnum::MaxAge->value, $requestParams) || !$authSimple->isAuthenticated()) { - return null; + // Determine the effective max_age: the request parameter takes precedence over the client's registered + // default_max_age (OIDC DCR 1.0). When neither is present, max_age is not in effect. + $effectiveMaxAge = null; + if (array_key_exists(ParamsEnum::MaxAge->value, $requestParams)) { + $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); + $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); + + if ( + false === filter_var( + $requestParams[ParamsEnum::MaxAge->value], + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 0]], + ) + ) { + throw OidcServerException::invalidRequest( + ParamsEnum::MaxAge->value, + 'max_age must be a valid integer', + null, + $redirectUri, + $state, + $responseMode, + ); + } + + $effectiveMaxAge = (int) $requestParams[ParamsEnum::MaxAge->value]; + } elseif ($client instanceof ClientEntityInterface) { + $effectiveMaxAge = $client->getDefaultMaxAge(); } - $redirectUri = $currentResultBag->getOrFail(ClientRedirectUriRule::class)->getValue(); - $state = $currentResultBag->getOrFail(StateRule::class)->getValue(); - - if ( - false === filter_var( - $requestParams[ParamsEnum::MaxAge->value], - FILTER_VALIDATE_INT, - ['options' => ['min_range' => 0]], - ) - ) { - throw OidcServerException::invalidRequest( - ParamsEnum::MaxAge->value, - 'max_age must be a valid integer', - null, - $redirectUri, - $state, - $responseMode, - ); + // require_auth_time forces the auth_time claim into the ID Token even when no max_age is in effect. + $requireAuthTime = $client instanceof ClientEntityInterface && $client->getRequireAuthTime(); + + // Nothing to enforce or compute when neither an effective max_age nor require_auth_time applies, or when the + // user is not (yet) authenticated (the normal authentication flow handles login in that case). + if (($effectiveMaxAge === null && !$requireAuthTime) || !$authSimple->isAuthenticated()) { + return null; } - $maxAge = (int) $requestParams[ParamsEnum::MaxAge->value]; - $lastAuth = (int) $authSimple->getAuthData('AuthnInstant'); - $isExpired = $lastAuth + $maxAge < time(); + $lastAuth = (int) $authSimple->getAuthData('AuthnInstant'); - if ($isExpired) { + // Enforce re-authentication when the session is older than the effective max_age. + if ($effectiveMaxAge !== null && ($lastAuth + $effectiveMaxAge < time())) { unset($requestParams['prompt']); $loginParams = []; $loginParams['ReturnTo'] = $this->sspBridge->utils()->http()->addURLParameters( @@ -103,6 +117,8 @@ public function checkRule( $this->authenticationService->authenticateForClient($client, $loginParams); } + // The result value becomes the ID Token auth_time (set by the grant), satisfying require_auth_time and/or + // recording the authentication instant used for the max_age check. return new Result($this->getKey(), $lastAuth); } } diff --git a/src/Server/RequestRules/Rules/ResponseTypeRule.php b/src/Server/RequestRules/Rules/ResponseTypeRule.php index cdf53487..f9edb712 100644 --- a/src/Server/RequestRules/Rules/ResponseTypeRule.php +++ b/src/Server/RequestRules/Rules/ResponseTypeRule.php @@ -5,6 +5,7 @@ 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; @@ -48,15 +49,36 @@ 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 a non-empty response_types list, the + // requested response_type must be one of them. We enforce only when the value was explicitly registered + // (present and non-empty in the client's metadata); clients that do not have it configured - or have it as + // an empty list - are not constrained, preserving behavior for manually-managed and pre-DCR clients. + // Dynamically registered clients always have it (the OIDC DCR default is applied at registration). + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + // getResponseTypes() returns the raw registered value (empty array when nothing is registered - it does not + // synthesize the OIDC DCR spec default), so an empty list means "not configured" and is not enforced. + $registeredResponseTypes = ($client instanceof ClientEntityInterface) ? $client->getResponseTypes() : []; + + if ( + $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..56953560 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -10,7 +10,6 @@ use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; -use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; /** * OpenID Provider Metadata Service - provides information about OIDC authentication server. @@ -62,16 +61,17 @@ 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::ResponseTypesSupported->value] = $this->moduleConfig->getSupportedResponseTypes(); $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; $this->metadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] = $protocolSignatureAlgorithmNames; $this->metadata[ClaimsEnum::CodeChallengeMethodsSupported->value] = ['plain', 'S256']; - $this->metadata[ClaimsEnum::TokenEndpointAuthMethodsSupported->value] = [ - TokenEndpointAuthMethodsEnum::ClientSecretPost->value, - TokenEndpointAuthMethodsEnum::ClientSecretBasic->value, - TokenEndpointAuthMethodsEnum::PrivateKeyJwt->value, - ]; + $this->metadata[ClaimsEnum::TokenEndpointAuthMethodsSupported->value] = + $this->moduleConfig->getSupportedTokenEndpointAuthMethods(); $this->metadata[ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value] = $supportedSignatureAlgorithmNames; $this->metadata[ClaimsEnum::RequestParameterSupported->value] = true; @@ -89,11 +89,10 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] = $this->moduleConfig->getRequirePushedAuthorizationRequests(); - $grantTypesSupported = [ - GrantTypesEnum::AuthorizationCode->value, - GrantTypesEnum::RefreshToken->value, - ]; + $grantTypesSupported = $this->moduleConfig->getSupportedGrantTypes(); if ($this->moduleConfig->getVciEnabled()) { + // The VCI pre-authorized_code grant is an OP capability advertised in discovery, but it is not a + // per-client registerable grant type. $grantTypesSupported[] = GrantTypesEnum::PreAuthorizedCode->value; } $this->metadata[ClaimsEnum::GrantTypesSupported->value] = $grantTypesSupported; diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php index 481994a4..26373fe5 100644 --- a/src/Utils/AuthenticatedOAuth2ClientResolver.php +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -42,11 +42,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 +62,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 { + // getTokenEndpointAuthMethod() returns the raw registered value, or null when nothing is registered (it does + // not synthesize the OIDC DCR spec default), so null means "not configured" and is not enforced. + $registeredMethod = $resolved->getClient()->getTokenEndpointAuthMethod(); + + if ($registeredMethod === null) { + 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/src/Utils/ResponseTypeGrantTypeCorrespondence.php b/src/Utils/ResponseTypeGrantTypeCorrespondence.php new file mode 100644 index 00000000..a5a02277 --- /dev/null +++ b/src/Utils/ResponseTypeGrantTypeCorrespondence.php @@ -0,0 +1,74 @@ + required grant_types). The OP currently advertises only + * `code`, `id_token` and `id_token token`, but the hybrid rows are included for spec-completeness so the + * mapping stays correct if more response types are offered later. + * + * @return array + */ + public static function map(): array + { + return [ + ResponseTypesEnum::Code->value => [GrantTypesEnum::AuthorizationCode->value], + ResponseTypesEnum::IdToken->value => [GrantTypesEnum::Implicit->value], + ResponseTypesEnum::IdTokenToken->value => [GrantTypesEnum::Implicit->value], + 'code id_token' => [GrantTypesEnum::AuthorizationCode->value, GrantTypesEnum::Implicit->value], + 'code token' => [GrantTypesEnum::AuthorizationCode->value, GrantTypesEnum::Implicit->value], + 'code id_token token' => [GrantTypesEnum::AuthorizationCode->value, GrantTypesEnum::Implicit->value], + ]; + } + + /** + * The unique set of grant types required by the given response types, in stable order. + * + * @param string[] $responseTypes + * @return string[] + */ + public static function requiredGrantTypes(array $responseTypes): array + { + $map = self::map(); + $required = []; + + foreach ($responseTypes as $responseType) { + foreach ($map[$responseType] ?? [] as $grantType) { + $required[$grantType] = $grantType; + } + } + + return array_values($required); + } + + /** + * Merge the grant types required by the given response types into the given grant types, preserving the + * existing order and appending any missing required ones. + * + * @param string[] $grantTypes + * @param string[] $responseTypes + * @return string[] + */ + public static function mergeRequiredGrantTypes(array $grantTypes, array $responseTypes): array + { + return array_values(array_unique(array_merge( + array_values($grantTypes), + self::requiredGrantTypes($responseTypes), + ))); + } +} diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig index 99a50724..3d55e6c0 100644 --- a/templates/clients/includes/form.twig +++ b/templates/clients/includes/form.twig @@ -61,8 +61,27 @@ {% trans %}Choose if client is confidential or public. Confidential clients are capable of maintaining the confidentiality of their credentials (e.g., client implemented on a secure server with restricted access to the client credentials), or capable of secure client authentication using other means. Public clients are incapable of maintaining the confidentiality of their credentials (e.g., clients executing on the device used by the resource owner, such as an installed native application or a web browser-based application), and incapable of secure client authentication via any other means.{% endtrans %} + {% trans %}This type is derived from the metadata where possible: if a Token Endpoint Authentication Method is selected below, it determines this type ("none" means public, any other method means confidential); otherwise, an Application Type of "native" means public. When neither is set, your explicit choice above is kept. To make a native client confidential, give it a real authentication method.{% endtrans %} + + {{ 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 %} + + + {{ form.application_type.control | raw }} + + {% trans %}Kind of application. For dynamically registered clients this constrains the allowed Redirect URIs: native clients may only use custom URI schemes or loopback addresses (localhost, 127.0.0.1, [::1]); web clients using the implicit grant must use https and must not use localhost.{% endtrans %} + + {% if form.application_type.hasErrors %} + {{ form.application_type.getError }} + {% endif %} + {{ form.redirect_uri.control | raw }} @@ -168,6 +187,107 @@ {{ 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. Selecting a response type automatically selects the grant types it requires.{% endtrans %} + + {% if form.response_types.hasErrors %} + {{ form.response_types.getError }} + {% endif %} + + {# Single source of truth for the response_type -> required grant_types correspondence, consumed by client-form.js. #} + + + + {{ form.default_max_age.control | raw }} + + {% trans %}Default Maximum Authentication Age, in seconds, applied when the authorization request omits max_age. Leave empty for no default.{% endtrans %} + + {% if form.default_max_age.hasErrors %} + {{ form.default_max_age.getError }} + {% endif %} + + + {{ form.require_auth_time.control | raw }} + + {% trans %}When enabled, the auth_time claim is always included in ID Tokens issued to this Client.{% endtrans %} + + + {% if form.hasConfiguredAcrValues() %} + + {{ form.default_acr_values.control | raw }} + + {% trans %}Default requested Authentication Context Class Reference values, applied when the authorization request omits acr_values. Selected from the ACRs supported by this OP.{% endtrans %} + + {% if form.default_acr_values.hasErrors %} + {{ form.default_acr_values.getError }} + {% endif %} + {% endif %} + + + {{ form.initiate_login_uri.control | raw }} + + {% trans %}URI using the https scheme that a third party can use to initiate a login by the RP. Informational.{% endtrans %} + + {% if form.initiate_login_uri.hasErrors %} + {{ form.initiate_login_uri.getError }} + {% endif %} + + + {{ form.software_id.control | raw }} + {% if form.software_id.hasErrors %} + {{ form.software_id.getError }} + {% endif %} + + + {{ form.software_version.control | raw }} + {% if form.software_version.hasErrors %} + {{ form.software_version.getError }} + {% endif %} + + + {{ form.logo_uri.control | raw }} + {% if form.logo_uri.hasErrors %} + {{ form.logo_uri.getError }} + {% endif %} + + + {{ form.client_uri.control | raw }} + {% if form.client_uri.hasErrors %} + {{ form.client_uri.getError }} + {% endif %} + + + {{ form.policy_uri.control | raw }} + {% if form.policy_uri.hasErrors %} + {{ form.policy_uri.getError }} + {% endif %} + + + {{ form.tos_uri.control | raw }} + {% if form.tos_uri.hasErrors %} + {{ form.tos_uri.getError }} + {% endif %} + + + {{ form.contacts.control | raw }} + + {% trans %}Contacts for this Client (e.g. administrator e-mail addresses). One per line.{% endtrans %} + + {% if form.contacts.hasErrors %} + {{ form.contacts.getError }} + {% endif %} +