feat: FAPI 1.0 Advanced — PKCE, PAR, JAR, private_key_jwt, mTLS, PS256 - #15
Open
hongwei1 wants to merge 9 commits into
Open
feat: FAPI 1.0 Advanced — PKCE, PAR, JAR, private_key_jwt, mTLS, PS256#15hongwei1 wants to merge 9 commits into
hongwei1 wants to merge 9 commits into
Conversation
FAPI 1.0 Advanced requires PKCE on every authorization request. Adds code_challenge/code_challenge_method to the /auth request, threads the challenge through the login form round-trip, stores it on the issued AuthorizationCode, and verifies code_verifier against it (S256 only, plain rejected) in the token endpoint's authorization_code grant. Advertises S256 support via discovery metadata. Clients that don't send a code_challenge are unaffected (backward compatible with existing confidential-client flows).
FAPI 1.0 Advanced requires PAR so authorization parameters travel over a back-channel POST instead of the browser's front-channel query string. Adds POST /obp-oidc/par (validates client + redirect_uri, enforces PKCE S256-only same as the direct flow, returns a one-time request_uri), and a new GET /auth?request_uri=...&client_id=... case that resolves the pushed parameters before continuing through the existing authorization flow unchanged. Advertised via discovery metadata (pushed_authorization_request_endpoint). request_uri is single-use and short-lived (default 90s, configurable via OIDC_PAR_EXPIRATION) with client_id cross-checked at resolution.
Threads the jwks_uri column (added to OBP-API's Consumer/v_oidc_clients in a companion commit) through OidcClient, DatabaseClient, and AdminDatabaseClient, including the explicit Doobie Read instances for both (their fixed-arity tuple mapping doesn't pick up new case class fields automatically). Also pulls in nimbus-jose-jwt, needed next to verify signed request objects and private_key_jwt assertions against this key. No client currently sets jwks_uri, so this is inert until dynamic client registration or an admin update starts writing it.
UK Open Banking resource servers expect the standard openbanking_intent_id claim; OBP-OIDC only carried its own proprietary consent_id claim. Mirrors the same value into openbanking_intent_id wherever a consent-bound token is issued (ID token, hybrid ID token, access token, refresh token) — consent_id stays for backward compatibility, this is additive.
FAPI 1.0 Advanced requires authorization parameters to travel inside a JWT signed with the client's own key, not as plain query params. Adds a request= handler for GET /auth: parses the JWS, resolves the client's JWKS via its jwks_uri (new JwksClient, in-memory cached with a 10-minute TTL), verifies the signature (RS*/ES* via nimbus-jose-jwt), checks iss==client_id, aud==issuer, exp/nbf with a 60-minute maximum lifetime, then continues through the existing authorization flow with the JWT's claims — never the raw query string. Matched before the plain-parameter and PAR cases so a request object, when present, always wins over any untrusted query params sent alongside it.
FAPI 1.0 Advanced client authentication: a client can authenticate to the token endpoint with a client_assertion JWT signed by its own key instead of a shared client_secret. Adds client_assertion_type/ client_assertion handling to the authorization_code and client_credentials grants — verifies iss==sub==client_id, aud equals the token endpoint, exp within a 5-minute max lifetime, and jti replay protection (in-memory, cleared as entries expire), then checks the signature against the client's JWKS via the shared JwsClientVerifier (factored out of RequestObjectService, which now uses it too). Advertised via discovery (private_key_jwt in token_endpoint_auth_methods_supported). refresh_token grant is untouched — it doesn't authenticate the client today regardless of method, a pre-existing gap out of scope here.
…705) FAPI 1.0 Advanced's remaining client-authentication method: mTLS. OBP-OIDC never terminates TLS itself, so the client certificate is read from a header a trusted reverse proxy forwards after a real handshake (off by default via mtlsEnabled; header name configurable). tls_client_auth compares the presented certificate's SHA-256 thumbprint against the client's registered certificate (client_certificate, newly exposed through v_oidc_clients alongside jwks_uri — the Consumer.clientCertificate field already existed, just wasn't in the read view). On success, the issued access token carries a cnf.x5t#S256 claim binding it to that certificate (RFC 8705 §3), for authorization_code and client_credentials. Precedence when multiple credentials are sent: private_key_jwt > tls_client_auth > client_secret. Advertised via discovery (tls_client_auth in token_endpoint_auth_methods_supported, tls_client_certificate_bound_access_tokens reflecting mtlsEnabled).
FAPI 1.0 Advanced's strict profile disallows plain RSASSA (RS256) and requires PS256 (RSASSA-PSS) instead. auth0 java-jwt, the only JWT library on this classpath, never shipped PS256 support — but its Algorithm class is designed to be subclassed (protected constructor, abstract sign/verify), so PS256Algorithm implements it directly with the JDK's built-in "RSASSA-PSS" Signature provider (SHA-256/MGF1-SHA256/ 32-byte salt, matching PS256's parameters exactly) rather than pulling in another crypto dependency for one algorithm. Selected via OIDC_SIGNING_ALGORITHM (config.signingAlgorithm), default unchanged at RS256 — flipping it is a breaking change for every client already validating tokens against this server's JWKS, so it stays an explicit opt-in. JWKS alg and discovery's id_token_signing_alg_values_supported both reflect whichever algorithm is configured.
Without this, private_key_jwt and tls_client_auth were unusable in practice — no client could ever set the fields they're verified against, so those two auth methods existed in code with nothing able to trigger them. Dynamic Client Registration (RFC 7591) now accepts jwks_uri (standard field) and client_certificate (pragmatic extension, no RFC standardizes an inline mTLS cert at registration time), and rejects registering as private_key_jwt/tls_client_auth without the matching field. Also fixes a pre-existing bug where AdminDatabaseClient.fromOidcClient hardcoded clientcertificate to None regardless of what the OidcClient carried, and the client INSERT never included that column at all — client_certificate could never have been persisted through this path even before jwks_uri existed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings OBP-OIDC up to FAPI 1.0 Advanced, one RFC per commit. Every new behaviour is
opt-in or conditional — merging this changes nothing for an existing deployment until
its environment variables say otherwise.
What is added
code_challenge; clients that never send one are unaffected/parendpoint,request_urilifetimeOIDC_PAR_EXPIRATION(default 90s)RequestObjectService, with a JWKS client for client key resolutionprivate_key_jwtclient assertionsClientAssertionServicetls_client_auth+ sender-constrained tokensOIDC_SIGNING_ALGORITHM, default staysRS256openbanking_intent_idclaimjwks_uriin the consumer viewsjwks_uriandclient_certificateBackwards compatibility
All three new configuration knobs default to today's behaviour:
OIDC_MTLS_ENABLED=falseOIDC_SIGNING_ALGORITHM=RS256— switching to PS256 breaks every client currentlyvalidating tokens against this server's JWKS, so it is an explicit opt-in rather than a
silent change
OIDC_PAR_EXPIRATION=90Security note on
tls_client_authOBP-OIDC never terminates TLS itself, so this trusts a client certificate forwarded by a
reverse proxy in
OIDC_MTLS_CLIENT_CERT_HEADER(defaultX-SSL-Client-Cert). It is off bydefault and must only be enabled once the proxy is configured to always overwrite that
header — never pass a client-supplied value through — and to set it only after a real TLS
handshake presented and validated a client certificate. The same note is in
Config.scalabeside the setting.
Tests
mvn clean test: 10 suites, 112 tests, 0 failures — verified on JDK 17 (what theDockerfile builds with) and on JDK 25. Five new suites came with the features:
PkceTest,RequestObjectServiceTest,ClientAssertionServiceTest,MtlsServiceTest,JwtServiceTest, plus additions toOidcProviderIntegrationTest.The branch sits directly on top of current
mainwith no rebase needed.