Skip to content

feat: FAPI 1.0 Advanced — PKCE, PAR, JAR, private_key_jwt, mTLS, PS256 - #15

Open
hongwei1 wants to merge 9 commits into
OpenBankProject:mainfrom
hongwei1:feature/fapi-advanced
Open

feat: FAPI 1.0 Advanced — PKCE, PAR, JAR, private_key_jwt, mTLS, PS256#15
hongwei1 wants to merge 9 commits into
OpenBankProject:mainfrom
hongwei1:feature/fapi-advanced

Conversation

@hongwei1

Copy link
Copy Markdown
Contributor

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

Commit Spec Notes
PKCE RFC 7636 (S256) Verified only when the authorization request carried a code_challenge; clients that never send one are unaffected
Pushed Authorization Requests RFC 9126 New /par endpoint, request_uri lifetime OIDC_PAR_EXPIRATION (default 90s)
Signed request objects (JAR) RFC 9101 RequestObjectService, with a JWKS client for client key resolution
private_key_jwt client assertions RFC 7523 ClientAssertionService
tls_client_auth + sender-constrained tokens RFC 8705 Off by default, see the security note below
PS256 signing FAPI Advanced strict profile OIDC_SIGNING_ALGORITHM, default stays RS256
openbanking_intent_id claim UK Open Banking Issued in tokens
Client jwks_uri in the consumer views Read/write
DCR registers jwks_uri and client_certificate Registration endpoint

Backwards compatibility

All three new configuration knobs default to today's behaviour:

  • OIDC_MTLS_ENABLED=false
  • OIDC_SIGNING_ALGORITHM=RS256 — switching to PS256 breaks every client currently
    validating tokens against this server's JWKS, so it is an explicit opt-in rather than a
    silent change
  • OIDC_PAR_EXPIRATION=90

Security note on tls_client_auth

OBP-OIDC never terminates TLS itself, so this trusts a client certificate forwarded by a
reverse proxy in OIDC_MTLS_CLIENT_CERT_HEADER (default X-SSL-Client-Cert). It is off by
default 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.scala
beside the setting.

Tests

mvn clean test: 10 suites, 112 tests, 0 failures — verified on JDK 17 (what the
Dockerfile builds with) and on JDK 25. Five new suites came with the features:
PkceTest, RequestObjectServiceTest, ClientAssertionServiceTest, MtlsServiceTest,
JwtServiceTest, plus additions to OidcProviderIntegrationTest.

The branch sits directly on top of current main with no rebase needed.

hongwei1 added 9 commits July 18, 2026 16:14
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant