From 515d90785f30ccb8797774414148883037de0c0a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 16:14:17 +0200 Subject: [PATCH 1/9] feat: add PKCE (RFC 7636 S256) to the authorization code flow 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). --- .../com/tesobe/oidc/auth/CodeService.scala | 12 +++- .../tesobe/oidc/endpoints/AuthEndpoint.scala | 56 +++++++++++++--- .../tesobe/oidc/endpoints/TokenEndpoint.scala | 39 ++++++++++- .../com/tesobe/oidc/models/OidcModels.scala | 10 ++- .../com/tesobe/oidc/endpoints/PkceTest.scala | 64 +++++++++++++++++++ 5 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 src/test/scala/com/tesobe/oidc/endpoints/PkceTest.scala diff --git a/src/main/scala/com/tesobe/oidc/auth/CodeService.scala b/src/main/scala/com/tesobe/oidc/auth/CodeService.scala index 6fb46c2..283efee 100644 --- a/src/main/scala/com/tesobe/oidc/auth/CodeService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/CodeService.scala @@ -38,7 +38,9 @@ trait CodeService[F[_]] { state: Option[String] = None, nonce: Option[String] = None, provider: Option[String] = None, - consentId: Option[String] = None + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None ): F[String] def validateAndConsumeCode( code: String, @@ -62,7 +64,9 @@ class InMemoryCodeService( state: Option[String] = None, nonce: Option[String] = None, provider: Option[String] = None, - consentId: Option[String] = None + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None ): IO[String] = { for { code <- IO(UUID.randomUUID().toString.replace("-", "")) @@ -81,7 +85,9 @@ class InMemoryCodeService( nonce = nonce, provider = provider, exp = exp, - consent_id = consentId + consent_id = consentId, + code_challenge = codeChallenge, + code_challenge_method = codeChallengeMethod ) _ <- codesRef.update(_ + (code -> authCode)) diff --git a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala index 24dba20..19d7ac0 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala @@ -68,7 +68,9 @@ class AuthEndpoint( NonceQueryParamMatcher(nonce) +& ConsentRequestIdQueryParamMatcher(consentRequestId) +& BankIdQueryParamMatcher(bankId) +& - ConsentIdQueryParamMatcher(consentId) => + ConsentIdQueryParamMatcher(consentId) +& + CodeChallengeQueryParamMatcher(codeChallenge) +& + CodeChallengeMethodQueryParamMatcher(codeChallengeMethod) => handleAuthorizationRequest( responseType, clientId, @@ -78,7 +80,9 @@ class AuthEndpoint( nonce, consentRequestId, bankId, - consentId + consentId, + codeChallenge, + codeChallengeMethod ) case req @ POST -> Root / "obp-oidc" / "auth" => @@ -115,6 +119,11 @@ class AuthEndpoint( extends OptionalQueryParamDecoderMatcher[String]("bank_id") object ConsentIdQueryParamMatcher extends OptionalQueryParamDecoderMatcher[String]("consent_id") + // PKCE (RFC 7636) + object CodeChallengeQueryParamMatcher + extends OptionalQueryParamDecoderMatcher[String]("code_challenge") + object CodeChallengeMethodQueryParamMatcher + extends OptionalQueryParamDecoderMatcher[String]("code_challenge_method") // Consent callback query parameter matchers object ChallengeQueryParamMatcher @@ -137,7 +146,9 @@ class AuthEndpoint( nonce: Option[String], consentRequestId: Option[String] = None, bankId: Option[String] = None, - consentId: Option[String] = None + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None ): IO[Response[IO]] = { IO( @@ -171,6 +182,16 @@ class AuthEndpoint( ) redirectWithError(redirectUri, error) } + } else if (codeChallengeMethod.exists(_ != "S256")) { + // PKCE (RFC 7636) + FAPI: only S256 is accepted; 'plain' is rejected. + IO(logger.warn(s"Unsupported code_challenge_method: ${codeChallengeMethod.getOrElse("")}")) *> { + val error = OidcError( + "invalid_request", + Some("code_challenge_method must be S256"), + state = state + ) + redirectWithError(redirectUri, error) + } } else { // Validate client and redirect URI IO( @@ -217,7 +238,7 @@ class AuthEndpoint( // Normal flow: show login form IO(logger.info(s"Client validated, showing login form...")) *> IO(println(s"Client validated, showing login form...")) *> - showLoginForm(clientId, redirectUri, scope, state, nonce, responseType = responseType, consentId = consentId) + showLoginForm(clientId, redirectUri, scope, state, nonce, responseType = responseType, consentId = consentId, codeChallenge = codeChallenge, codeChallengeMethod = codeChallengeMethod) } } } @@ -325,6 +346,8 @@ class AuthEndpoint( nonce = formData.get("nonce") responseType = formData.get("response_type").getOrElse("code") consentId = formData.get("consent_id") + codeChallenge = formData.get("code_challenge") + codeChallengeMethod = formData.get("code_challenge_method") _ <- IO( logger.info( @@ -348,7 +371,8 @@ class AuthEndpoint( ) *> generateCodeForUser( user, clientId, redirectUri, scope, state, nonce, - responseType, consentId = consentId + responseType, consentId = consentId, + codeChallenge = codeChallenge, codeChallengeMethod = codeChallengeMethod ) case Left(error) => // Authentication failed - record failed attempt for rate limiting @@ -371,7 +395,9 @@ class AuthEndpoint( nonce, Some("Incorrect username/password"), responseType, - consentId + consentId, + codeChallenge, + codeChallengeMethod ) } } yield response @@ -593,12 +619,14 @@ class AuthEndpoint( state: Option[String], nonce: Option[String], responseType: String = "code", - consentId: Option[String] = None + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None ): IO[Response[IO]] = { for { _ <- statsService.incrementLoginSuccess(user.username) code <- codeService - .generateCode(clientId, redirectUri, user.sub, scope, state, nonce, user.provider, consentId) + .generateCode(clientId, redirectUri, user.sub, scope, state, nonce, user.provider, consentId, codeChallenge, codeChallengeMethod) response <- responseType match { case "code id_token" => for { @@ -619,7 +647,9 @@ class AuthEndpoint( nonce: Option[String], errorMessage: Option[String] = None, responseType: String = "code", - consentId: Option[String] = None + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None ): IO[Response[IO]] = { IO(logger.info(s"showLoginForm called for clientId: $clientId")) *> @@ -637,6 +667,12 @@ class AuthEndpoint( consentIdParam = consentId .map(c => s"""""") .getOrElse("") + codeChallengeParam = codeChallenge + .map(c => s"""""") + .getOrElse("") + codeChallengeMethodParam = codeChallengeMethod + .map(m => s"""""") + .getOrElse("") providerOptions = providers .map { provider => @@ -760,6 +796,8 @@ class AuthEndpoint( $stateParam $nonceParam $consentIdParam + $codeChallengeParam + $codeChallengeMethodParam diff --git a/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala index 3734caf..284f1c0 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala @@ -128,6 +128,7 @@ class TokenEndpoint( val resolvedClientId = clientIdFromBasic.orElse(clientIdFromForm) val credentialSource = if (clientIdFromBasic.isDefined) "Basic auth header" else if (clientIdFromForm.isDefined) "form data" else "NONE" val refreshToken = formData.get("refresh_token") + val codeVerifier = formData.get("code_verifier") // PKCE (RFC 7636) println(s"DEBUG: Grant type extracted: ${grantType}") logger.info(s"Grant type: ${grantType.getOrElse("MISSING")}") @@ -180,7 +181,8 @@ class TokenEndpoint( processAuthorizationCodeGrant( authCode, redirectUriValue, - clientIdValue + clientIdValue, + codeVerifier ) case Left(error) => logger.warn( @@ -197,7 +199,8 @@ class TokenEndpoint( processAuthorizationCodeGrant( authCode, redirectUriValue, - clientIdValue + clientIdValue, + codeVerifier ) } case _ => @@ -293,10 +296,19 @@ class TokenEndpoint( } } + // PKCE (RFC 7636 §4.6): BASE64URL-ENCODE(SHA256(ASCII(code_verifier))), no padding. + private def computeS256Challenge(codeVerifier: String): String = { + val digest = MessageDigest + .getInstance("SHA-256") + .digest(codeVerifier.getBytes(java.nio.charset.StandardCharsets.US_ASCII)) + java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(digest) + } + private def processAuthorizationCodeGrant( code: String, redirectUri: String, - clientId: String + clientId: String, + codeVerifier: Option[String] = None ): IO[Response[IO]] = { logger.info(s"Validating authorization code for client: $clientId") @@ -315,6 +327,26 @@ class TokenEndpoint( logger.info( s"DEBUG: AuthCode details - scope: ${authCode.scope}, nonce: ${authCode.nonce}" ) + // PKCE (RFC 7636 §4.6): if the authorization request carried a code_challenge, the + // matching code_verifier is mandatory here and must hash (S256) to that challenge. + // If no challenge was captured, no verifier is required (non-PKCE clients unaffected). + val pkceResult: Either[OidcError, Unit] = authCode.code_challenge match { + case None => Right(()) + case Some(challenge) => + codeVerifier match { + case None => + Left(OidcError("invalid_grant", Some("code_verifier is required for this authorization code"))) + case Some(verifier) if computeS256Challenge(verifier) == challenge => + Right(()) + case Some(_) => + Left(OidcError("invalid_grant", Some("code_verifier does not match code_challenge"))) + } + } + pkceResult match { + case Left(err) => + logger.warn(s"PKCE verification failed for client $clientId: ${err.error_description.getOrElse("")}") + BadRequest(err.asJson) + case Right(()) => // Get user information - use provider from auth code when available (API mode) logger.info( s"Looking up user: sub=${authCode.sub}, provider=${authCode.provider.getOrElse("none")}" @@ -460,6 +492,7 @@ class TokenEndpoint( OidcError("invalid_grant", Some("User not found")).asJson ) } + } // end pkceResult match case Left(error) => logger.trace( diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index 2c9b732..e8235f3 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -38,7 +38,9 @@ case class OidcConfiguration( token_endpoint_auth_methods_supported: List[String], claims_supported: List[String], grant_types_supported: List[String], - revocation_endpoint_auth_methods_supported: List[String] + revocation_endpoint_auth_methods_supported: List[String], + // PKCE (RFC 7636 / RFC 8414): advertised code_challenge methods. FAPI requires S256. + code_challenge_methods_supported: List[String] = List("S256") ) object OidcConfiguration { @@ -161,7 +163,11 @@ case class AuthorizationCode( nonce: Option[String] = None, provider: Option[String] = None, exp: Long, // Expiration time - consent_id: Option[String] = None + consent_id: Option[String] = None, + // PKCE (RFC 7636): the S256 code_challenge captured at the authorization request, + // verified against the code_verifier at token exchange. None = client did not use PKCE. + code_challenge: Option[String] = None, + code_challenge_method: Option[String] = None ) object AuthorizationCode { diff --git a/src/test/scala/com/tesobe/oidc/endpoints/PkceTest.scala b/src/test/scala/com/tesobe/oidc/endpoints/PkceTest.scala new file mode 100644 index 0000000..d572bc1 --- /dev/null +++ b/src/test/scala/com/tesobe/oidc/endpoints/PkceTest.scala @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.endpoints + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import java.security.MessageDigest +import java.nio.charset.StandardCharsets + +/** Tests for PKCE (RFC 7636) S256 code_challenge computation, mirroring + * TokenEndpoint.computeS256Challenge (private, so the logic is duplicated + * here per this file's existing AuthInputValidationTest convention). + */ +class PkceTest extends AnyFunSuite with Matchers { + + private def computeS256Challenge(codeVerifier: String): String = { + val digest = MessageDigest + .getInstance("SHA-256") + .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)) + java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(digest) + } + + // RFC 7636 Appendix B official test vector + test("computeS256Challenge matches the RFC 7636 Appendix B test vector") { + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + val expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + computeS256Challenge(verifier) shouldBe expectedChallenge + } + + test("computeS256Challenge output has no padding and is URL-safe") { + val challenge = computeS256Challenge("some-random-verifier-value-1234567890") + challenge should not include "=" + challenge should not include "+" + challenge should not include "/" + } + + test("a different verifier produces a different challenge") { + val challengeA = computeS256Challenge("verifier-a-1234567890123456789012345") + val challengeB = computeS256Challenge("verifier-b-1234567890123456789012345") + challengeA should not equal challengeB + } + + test("the same verifier always produces the same challenge (deterministic)") { + val verifier = "repeatable-verifier-value-123456789012" + computeS256Challenge(verifier) shouldBe computeS256Challenge(verifier) + } +} From 21b76d61fe4beb352278d371651e0d5f6fefd48b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 16:25:05 +0200 Subject: [PATCH 2/9] feat: add Pushed Authorization Requests (RFC 9126) 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. --- .../com/tesobe/oidc/auth/ParService.scala | 151 ++++++++++++++ .../scala/com/tesobe/oidc/config/Config.scala | 3 + .../tesobe/oidc/endpoints/AuthEndpoint.scala | 56 +++++- .../oidc/endpoints/DiscoveryEndpoint.scala | 2 + .../tesobe/oidc/endpoints/ParEndpoint.scala | 185 ++++++++++++++++++ .../com/tesobe/oidc/models/OidcModels.scala | 30 ++- .../com/tesobe/oidc/server/OidcServer.scala | 14 +- .../oidc/OidcProviderIntegrationTest.scala | 143 +++++++++++++- 8 files changed, 574 insertions(+), 10 deletions(-) create mode 100644 src/main/scala/com/tesobe/oidc/auth/ParService.scala create mode 100644 src/main/scala/com/tesobe/oidc/endpoints/ParEndpoint.scala diff --git a/src/main/scala/com/tesobe/oidc/auth/ParService.scala b/src/main/scala/com/tesobe/oidc/auth/ParService.scala new file mode 100644 index 0000000..36bc1b7 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/auth/ParService.scala @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.{IO, Ref} +import cats.syntax.either._ +import com.tesobe.oidc.models.{OidcError, PushedAuthorizationRequest} +import com.tesobe.oidc.config.OidcConfig + +import java.time.Instant +import java.util.UUID +import org.slf4j.LoggerFactory + +/** PAR (RFC 9126): lets a client push authorization-request parameters to the + * server ahead of time and get back a `request_uri` to reference them from + * GET /auth, instead of exposing them (and, with FAPI, a signed request + * object) on the front-channel query string. + */ +trait ParService[F[_]] { + def pushAuthorizationRequest( + clientId: String, + responseType: String, + redirectUri: String, + scope: String, + state: Option[String] = None, + nonce: Option[String] = None, + consentRequestId: Option[String] = None, + bankId: Option[String] = None, + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None + ): F[PushedAuthorizationRequest] + + def consumeRequest( + requestUri: String, + clientId: String + ): F[Either[OidcError, PushedAuthorizationRequest]] +} + +class InMemoryParService( + config: OidcConfig, + requestsRef: Ref[IO, Map[String, PushedAuthorizationRequest]] +) extends ParService[IO] { + + private val logger = LoggerFactory.getLogger(getClass) + private val requestUriPrefix = "urn:ietf:params:oauth:request_uri:" + + def pushAuthorizationRequest( + clientId: String, + responseType: String, + redirectUri: String, + scope: String, + state: Option[String] = None, + nonce: Option[String] = None, + consentRequestId: Option[String] = None, + bankId: Option[String] = None, + consentId: Option[String] = None, + codeChallenge: Option[String] = None, + codeChallengeMethod: Option[String] = None + ): IO[PushedAuthorizationRequest] = { + for { + requestUri <- IO(requestUriPrefix + UUID.randomUUID().toString) + exp = Instant.now().plusSeconds(config.parExpirationSeconds).getEpochSecond + par = PushedAuthorizationRequest( + request_uri = requestUri, + client_id = clientId, + response_type = responseType, + redirect_uri = redirectUri, + scope = scope, + state = state, + nonce = nonce, + consent_request_id = consentRequestId, + bank_id = bankId, + consent_id = consentId, + code_challenge = codeChallenge, + code_challenge_method = codeChallengeMethod, + exp = exp + ) + _ <- requestsRef.update(_ + (requestUri -> par)) + _ = logger.info( + s"Pushed authorization request for clientId: $clientId, request_uri: ${requestUri.takeRight(8)}..., expires in ${config.parExpirationSeconds}s" + ) + } yield par + } + + def consumeRequest( + requestUri: String, + clientId: String + ): IO[Either[OidcError, PushedAuthorizationRequest]] = { + for { + requests <- requestsRef.get + result <- requests.get(requestUri) match { + case None => + logger.warn(s"PAR request_uri not found: ${requestUri.takeRight(8)}...") + IO.pure( + OidcError("invalid_request_uri", Some("Unknown or expired request_uri")).asLeft[PushedAuthorizationRequest] + ) + case Some(par) => + // One-time use regardless of outcome (RFC 9126 SS4). + requestsRef.update(_ - requestUri) *> { + val now = Instant.now().getEpochSecond + if (par.exp < now) { + logger.warn(s"PAR request_uri expired: ${requestUri.takeRight(8)}...") + IO.pure( + OidcError("invalid_request_uri", Some("request_uri has expired")).asLeft[PushedAuthorizationRequest] + ) + } else if (par.client_id != clientId) { + logger.warn( + s"PAR client_id mismatch (expected: ${par.client_id}, got: $clientId)" + ) + IO.pure( + OidcError("invalid_request_uri", Some("client_id does not match the pushed request")).asLeft[PushedAuthorizationRequest] + ) + } else { + IO.pure(par.asRight[OidcError]) + } + } + } + } yield result + } + + def cleanupExpiredRequests: IO[Unit] = { + val now = Instant.now().getEpochSecond + requestsRef.update(_.filter(_._2.exp > now)) + } +} + +object ParService { + def apply(config: OidcConfig): IO[ParService[IO]] = { + for { + requestsRef <- Ref.of[IO, Map[String, PushedAuthorizationRequest]](Map.empty) + } yield new InMemoryParService(config, requestsRef) + } +} diff --git a/src/main/scala/com/tesobe/oidc/config/Config.scala b/src/main/scala/com/tesobe/oidc/config/Config.scala index d021086..6d70d1a 100644 --- a/src/main/scala/com/tesobe/oidc/config/Config.scala +++ b/src/main/scala/com/tesobe/oidc/config/Config.scala @@ -91,6 +91,7 @@ case class OidcConfig( signingKeyPath: Option[String] = None, // PEM file for the RSA signing key; None = ephemeral per-startup key tokenExpirationSeconds: Long = 3600, // 1 hour codeExpirationSeconds: Long = 600, // 10 minutes + parExpirationSeconds: Long = 90, // RFC 9126 recommends a short PAR request_uri lifetime obpApiUrl: Option[String] = None, localDevelopmentMode: Boolean = false, logoUrl: Option[String] = Some( @@ -187,6 +188,8 @@ object Config { sys.env.getOrElse("OIDC_TOKEN_EXPIRATION", "3600").toLong, codeExpirationSeconds = sys.env.getOrElse("OIDC_CODE_EXPIRATION", "600").toLong, + parExpirationSeconds = + sys.env.getOrElse("OIDC_PAR_EXPIRATION", "90").toLong, obpApiUrl = sys.env.get("OBP_API_URL"), localDevelopmentMode = sys.env.getOrElse("LOCAL_DEVELOPMENT_MODE", "false").toBoolean, diff --git a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala index 19d7ac0..00f7a7d 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala @@ -20,13 +20,15 @@ package com.tesobe.oidc.endpoints import cats.effect.{IO, Ref} -import com.tesobe.oidc.auth.{AuthService, CodeService} +import com.tesobe.oidc.auth.{AuthService, CodeService, ParService} import com.tesobe.oidc.endpoints.HtmlUtils.htmlEncode import com.tesobe.oidc.models.{ConsentChallenge, OidcError, User} import com.tesobe.oidc.ratelimit.RateLimitService import com.tesobe.oidc.config.OidcConfig import com.tesobe.oidc.tokens.JwtService +import io.circe.syntax._ import org.http4s._ +import org.http4s.circe._ import org.http4s.dsl.io._ import org.http4s.headers.Location import org.slf4j.LoggerFactory @@ -42,7 +44,8 @@ class AuthEndpoint( rateLimitService: RateLimitService[IO], config: OidcConfig, jwtService: JwtService[IO], - consentChallengesRef: Ref[IO, Map[String, ConsentChallenge]] + consentChallengesRef: Ref[IO, Map[String, ConsentChallenge]], + parService: ParService[IO] ) { private val logger = LoggerFactory.getLogger(getClass) @@ -59,6 +62,15 @@ class AuthEndpoint( if config.localDevelopmentMode => showStandaloneLoginForm() + // PAR (RFC 9126): request_uri resolves to a previously pushed set of + // authorization parameters. Matched before the direct-parameter case so + // request_uri-bearing requests (which omit response_type/redirect_uri/ + // scope from the query string) are intercepted here. + case GET -> Root / "obp-oidc" / "auth" :? + RequestUriQueryParamMatcher(requestUri) +& + ClientIdQueryParamMatcher(clientId) => + handlePushedAuthorizationRequest(requestUri, clientId) + case GET -> Root / "obp-oidc" / "auth" :? ResponseTypeQueryParamMatcher(responseType) +& ClientIdQueryParamMatcher(clientId) +& @@ -124,6 +136,9 @@ class AuthEndpoint( extends OptionalQueryParamDecoderMatcher[String]("code_challenge") object CodeChallengeMethodQueryParamMatcher extends OptionalQueryParamDecoderMatcher[String]("code_challenge_method") + // PAR (RFC 9126) + object RequestUriQueryParamMatcher + extends QueryParamDecoderMatcher[String]("request_uri") // Consent callback query parameter matchers object ChallengeQueryParamMatcher @@ -137,6 +152,37 @@ class AuthEndpoint( object ProviderCallbackQueryParamMatcher extends OptionalQueryParamDecoderMatcher[String]("provider") + // PAR (RFC 9126): resolve a previously pushed request_uri into a full set + // of authorization parameters, then continue through the normal flow. + // request_uri is one-time-use and validated (existence, expiry, client_id + // match) inside parService.consumeRequest before anything else runs. + // Resolution failures return a direct JSON error rather than a redirect — + // the redirect_uri isn't trustworthy until the request_uri itself is valid. + private def handlePushedAuthorizationRequest( + requestUri: String, + clientId: String + ): IO[Response[IO]] = { + parService.consumeRequest(requestUri, clientId).flatMap { + case Left(error) => + IO(logger.warn(s"PAR resolution failed for clientId: $clientId: ${error.error_description.getOrElse(error.error)}")) *> + BadRequest(error.asJson) + case Right(par) => + handleAuthorizationRequest( + par.response_type, + par.client_id, + par.redirect_uri, + par.scope, + par.state, + par.nonce, + par.consent_request_id, + par.bank_id, + par.consent_id, + par.code_challenge, + par.code_challenge_method + ) + } + } + private def handleAuthorizationRequest( responseType: String, clientId: String, @@ -1007,7 +1053,8 @@ object AuthEndpoint { rateLimitService: RateLimitService[IO], config: OidcConfig, jwtService: JwtService[IO], - consentChallengesRef: Ref[IO, Map[String, ConsentChallenge]] + consentChallengesRef: Ref[IO, Map[String, ConsentChallenge]], + parService: ParService[IO] ): AuthEndpoint = new AuthEndpoint( authService, @@ -1016,6 +1063,7 @@ object AuthEndpoint { rateLimitService, config, jwtService, - consentChallengesRef + consentChallengesRef, + parService ) } diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala index eeca337..570d2ef 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -45,6 +45,7 @@ class DiscoveryEndpoint(config: OidcConfig) { userinfo_endpoint = s"${config.issuer}/userinfo", jwks_uri = s"${config.issuer}/jwks", revocation_endpoint = s"${config.issuer}/revoke", + pushed_authorization_request_endpoint = Some(s"${config.issuer}/par"), registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code", "code id_token"), subject_types_supported = List("public"), @@ -70,6 +71,7 @@ class DiscoveryEndpoint(config: OidcConfig) { userinfo_endpoint = s"${config.issuer}/userinfo", jwks_uri = s"${config.issuer}/jwks", revocation_endpoint = s"${config.issuer}/revoke", + pushed_authorization_request_endpoint = Some(s"${config.issuer}/par"), registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code", "code id_token"), subject_types_supported = List("public"), diff --git a/src/main/scala/com/tesobe/oidc/endpoints/ParEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/ParEndpoint.scala new file mode 100644 index 0000000..032d739 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/endpoints/ParEndpoint.scala @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.endpoints + +import cats.effect.IO +import com.tesobe.oidc.auth.{AuthService, ParService} +import com.tesobe.oidc.config.OidcConfig +import com.tesobe.oidc.models.{OidcError, ParResponse} +import io.circe.syntax._ +import org.http4s._ +import org.http4s.circe._ +import org.http4s.dsl.io._ +import org.typelevel.ci.CIString +import org.slf4j.LoggerFactory + +/** PAR (RFC 9126) pushed authorization request endpoint: POST /obp-oidc/par. + * + * Accepts the same parameters as GET /auth, plus optional client + * credentials, and returns a `request_uri` the client then passes to + * GET /auth instead of the individual parameters. + */ +class ParEndpoint( + authService: AuthService[IO], + parService: ParService[IO], + config: OidcConfig +) { + + private val logger = LoggerFactory.getLogger(getClass) + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> Root / "obp-oidc" / "par" => + req.as[UrlForm].attempt.flatMap { + case Right(form) => handleParRequest(req, form) + case Left(error) => + logger.warn(s"Failed to parse PAR form data: ${error.getMessage}") + BadRequest( + OidcError("invalid_request", Some("Failed to parse form data")).asJson + ) + } + } + + private def extractBasicAuthCredentials( + req: Request[IO] + ): Option[(String, String)] = { + req.headers + .get(CIString("Authorization")) + .flatMap { authHeader => + val authValue = authHeader.head.value + if (authValue.startsWith("Basic ")) { + val encoded = authValue.substring(6) + try { + val decoded = new String(java.util.Base64.getDecoder.decode(encoded), "UTF-8") + decoded.split(":", 2) match { + case Array(clientId, clientSecret) => Some((clientId, clientSecret)) + case _ => None + } + } catch { + case _: Exception => None + } + } else None + } + } + + private def handleParRequest( + req: Request[IO], + form: UrlForm + ): IO[Response[IO]] = { + val formData = form.values.view.mapValues(_.headOption.getOrElse("")).toMap + + val responseType = formData.get("response_type") + val redirectUri = formData.get("redirect_uri") + val scope = formData.get("scope") + val basicCredentialsOpt = extractBasicAuthCredentials(req) + val clientIdFromForm = formData.get("client_id") + val clientSecretFromForm = formData.get("client_secret") + val resolvedClientId = basicCredentialsOpt.map(_._1).orElse(clientIdFromForm) + val credentialsOpt: Option[(String, String)] = + basicCredentialsOpt.orElse { + (clientIdFromForm, clientSecretFromForm) match { + case (Some(id), Some(secret)) => Some((id, secret)) + case _ => None + } + } + val state = formData.get("state") + val nonce = formData.get("nonce") + val consentRequestId = formData.get("consent_request_id") + val bankId = formData.get("bank_id") + val consentId = formData.get("consent_id") + val codeChallenge = formData.get("code_challenge") + val codeChallengeMethod = formData.get("code_challenge_method") + + (responseType, resolvedClientId, redirectUri, scope) match { + case (Some(rt), Some(clientId), Some(ru), Some(sc)) => + if (codeChallengeMethod.exists(_ != "S256")) { + BadRequest( + OidcError("invalid_request", Some("code_challenge_method must be S256")).asJson + ) + } else { + val authenticated: IO[Either[OidcError, Unit]] = credentialsOpt match { + case Some((id, secret)) => + if (id != clientId) { + IO.pure(Left(OidcError("invalid_client", Some("Client ID mismatch")))) + } else { + authService.authenticateClient(id, secret).map(_.map(_ => ())) + } + case None => + // Public client (no secret) — same lenient acceptance as the token endpoint. + IO.pure(Right(())) + } + + authenticated.flatMap { + case Left(error) => + logger.warn(s"PAR client authentication failed: ${error.error}") + BadRequest(error.asJson) + case Right(()) => + authService.validateClient(clientId, ru).flatMap { isValid => + if (!isValid) { + logger.warn( + s"PAR client validation failed for clientId: $clientId, redirectUri: $ru" + ) + BadRequest( + OidcError("invalid_client", Some("Invalid client_id or redirect_uri")).asJson + ) + } else if (rt != "code" && rt != "code id_token") { + BadRequest( + OidcError("unsupported_response_type", Some("Supported response types: 'code', 'code id_token'")).asJson + ) + } else if (!sc.contains("openid")) { + BadRequest( + OidcError("invalid_scope", Some("'openid' scope is required")).asJson + ) + } else { + parService + .pushAuthorizationRequest( + clientId = clientId, + responseType = rt, + redirectUri = ru, + scope = sc, + state = state, + nonce = nonce, + consentRequestId = consentRequestId, + bankId = bankId, + consentId = consentId, + codeChallenge = codeChallenge, + codeChallengeMethod = codeChallengeMethod + ) + .flatMap { par => + Created(ParResponse(par.request_uri, config.parExpirationSeconds).asJson) + } + } + } + } + } + case _ => + BadRequest( + OidcError("invalid_request", Some("Missing required parameters: response_type, client_id, redirect_uri, scope")).asJson + ) + } + } +} + +object ParEndpoint { + def apply( + authService: AuthService[IO], + parService: ParService[IO], + config: OidcConfig + ): ParEndpoint = new ParEndpoint(authService, parService, config) +} diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index e8235f3..14407eb 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -40,7 +40,10 @@ case class OidcConfiguration( grant_types_supported: List[String], revocation_endpoint_auth_methods_supported: List[String], // PKCE (RFC 7636 / RFC 8414): advertised code_challenge methods. FAPI requires S256. - code_challenge_methods_supported: List[String] = List("S256") + code_challenge_methods_supported: List[String] = List("S256"), + // PAR (RFC 9126) + pushed_authorization_request_endpoint: Option[String] = None, + require_pushed_authorization_requests: Boolean = false ) object OidcConfiguration { @@ -170,6 +173,31 @@ case class AuthorizationCode( code_challenge_method: Option[String] = None ) +// PAR (RFC 9126): parameters pushed to /par ahead of the authorization request, +// resolved later at GET /auth via the request_uri it was issued. +case class PushedAuthorizationRequest( + request_uri: String, + client_id: String, + response_type: String, + redirect_uri: String, + scope: String, + state: Option[String] = None, + nonce: Option[String] = None, + consent_request_id: Option[String] = None, + bank_id: Option[String] = None, + consent_id: Option[String] = None, + code_challenge: Option[String] = None, + code_challenge_method: Option[String] = None, + exp: Long +) + +case class ParResponse(request_uri: String, expires_in: Long) + +object ParResponse { + implicit val encoder: Encoder[ParResponse] = deriveEncoder + implicit val decoder: Decoder[ParResponse] = deriveDecoder +} + object AuthorizationCode { implicit val encoder: Encoder[AuthorizationCode] = deriveEncoder implicit val decoder: Decoder[AuthorizationCode] = deriveDecoder diff --git a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala index e68f6ea..6f85d54 100644 --- a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala +++ b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala @@ -27,7 +27,7 @@ import cats.data.Kleisli import com.comcast.ip4s.{Host, Port} import cats.effect.Ref import org.typelevel.ci._ -import com.tesobe.oidc.auth.{CodeService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} +import com.tesobe.oidc.auth.{CodeService, ParService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} import com.tesobe.oidc.models.{ConsentChallenge, OidcClient} import com.tesobe.oidc.bootstrap.ClientBootstrap import com.tesobe.oidc.config.{Config, OidcConfig, VerifyCredentialsMethod, VerifyClientMethod} @@ -273,6 +273,7 @@ object OidcServer extends IOApp { // Initialize services codeService <- CodeService(config) + parService <- ParService(config) jwtService <- JwtService(config) statsService <- StatsService() statusService <- StatusService @@ -305,8 +306,10 @@ object OidcServer extends IOApp { rateLimitService, config, jwtService, - consentChallengesRef + consentChallengesRef, + parService ) + parEndpoint = ParEndpoint(authService, parService, config) tokenEndpoint = TokenEndpoint( authService, codeService, @@ -800,6 +803,13 @@ object OidcServer extends IOApp { case None => NotFound("Revocation endpoint not found") } + // PAR (RFC 9126) endpoint + case req @ POST -> Root / "obp-oidc" / "par" => + parEndpoint.routes.run(req).value.flatMap { + case Some(resp) => IO.pure(resp) + case None => NotFound("PAR endpoint not found") + } + // Dynamic Client Registration endpoint (RFC 7591) case req @ POST -> Root / "obp-oidc" / "connect" / "register" => registrationEndpoint match { diff --git a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala index 0bf794d..ce73e17 100644 --- a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala +++ b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala @@ -20,7 +20,7 @@ package com.tesobe.oidc import cats.effect.{IO, Ref} -import com.tesobe.oidc.auth.{CodeService, MockAuthService} +import com.tesobe.oidc.auth.{CodeService, ParService, MockAuthService} import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} import com.tesobe.oidc.endpoints._ import com.tesobe.oidc.models._ @@ -54,6 +54,7 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { for { authService <- IO(MockAuthService()) codeService <- CodeService(testConfig) + parService <- ParService(testConfig) jwtService <- JwtService(testConfig) statsService <- StatsService() rateLimitConfig = RateLimitConfig() @@ -69,7 +70,8 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { rateLimitService, testConfig, jwtService, - consentChallengesRef + consentChallengesRef, + parService ) tokenEndpoint = TokenEndpoint( authService, @@ -79,13 +81,15 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { statsService ) userInfoEndpoint = UserInfoEndpoint(authService, jwtService) + parEndpoint = ParEndpoint(authService, parService, testConfig) routes = Router( "/" -> discoveryEndpoint.routes, "/" -> jwksEndpoint.routes, "/" -> authEndpoint.routes, "/" -> tokenEndpoint.routes, - "/" -> userInfoEndpoint.routes + "/" -> userInfoEndpoint.routes, + "/" -> parEndpoint.routes ).orNotFound } yield routes } @@ -380,4 +384,137 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { test.unsafeRunSync() } + + "PAR Endpoint" should "issue a request_uri for a valid pushed authorization request" in { + val test = for { + app <- createTestApp + parForm = UrlForm( + "response_type" -> "code", + "client_id" -> "test-client", + "redirect_uri" -> "https://example.com/callback", + "scope" -> "openid profile email", + "state" -> "par-state-123", + "code_challenge" -> "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + "code_challenge_method" -> "S256" + ) + parRequest = Request[IO](Method.POST, uri"/obp-oidc/par").withEntity(parForm) + parResponse <- app(parRequest) + body <- parResponse.as[String] + } yield { + parResponse.status should be(Status.Created) + val par = decode[ParResponse](body) + par.isRight should be(true) + val parObj = par.getOrElse(throw new Exception("Failed to decode PAR response")) + parObj.request_uri should startWith("urn:ietf:params:oauth:request_uri:") + parObj.expires_in should be(testConfig.parExpirationSeconds) + } + + test.unsafeRunSync() + } + + it should "reject a code_challenge_method other than S256" in { + val test = for { + app <- createTestApp + parForm = UrlForm( + "response_type" -> "code", + "client_id" -> "test-client", + "redirect_uri" -> "https://example.com/callback", + "scope" -> "openid profile email", + "code_challenge" -> "somechallenge", + "code_challenge_method" -> "plain" + ) + parRequest = Request[IO](Method.POST, uri"/obp-oidc/par").withEntity(parForm) + parResponse <- app(parRequest) + } yield { + parResponse.status should be(Status.BadRequest) + } + + test.unsafeRunSync() + } + + "Authorization Endpoint" should "resolve a pushed request_uri into a login form" in { + val test = for { + app <- createTestApp + parForm = UrlForm( + "response_type" -> "code", + "client_id" -> "test-client", + "redirect_uri" -> "https://example.com/callback", + "scope" -> "openid profile email", + "state" -> "par-resolve-state" + ) + parRequest = Request[IO](Method.POST, uri"/obp-oidc/par").withEntity(parForm) + parResponse <- app(parRequest) + parBody <- parResponse.as[String] + requestUri = decode[ParResponse](parBody) + .getOrElse(throw new Exception("Failed to decode PAR response")) + .request_uri + + authRequest = Request[IO]( + Method.GET, + Uri + .unsafeFromString("/obp-oidc/auth") + .withQueryParam("client_id", "test-client") + .withQueryParam("request_uri", requestUri) + ) + authResponse <- app(authRequest) + authBody <- authResponse.as[String] + } yield { + authResponse.status should be(Status.Ok) + authBody should include("Sign In") + } + + test.unsafeRunSync() + } + + it should "reject an unknown request_uri" in { + val test = for { + app <- createTestApp + authRequest = Request[IO]( + Method.GET, + Uri + .unsafeFromString("/obp-oidc/auth") + .withQueryParam("client_id", "test-client") + .withQueryParam( + "request_uri", + "urn:ietf:params:oauth:request_uri:does-not-exist" + ) + ) + authResponse <- app(authRequest) + } yield { + authResponse.status should be(Status.BadRequest) + } + + test.unsafeRunSync() + } + + it should "reject a request_uri reused for a second request (one-time use)" in { + val test = for { + app <- createTestApp + parForm = UrlForm( + "response_type" -> "code", + "client_id" -> "test-client", + "redirect_uri" -> "https://example.com/callback", + "scope" -> "openid profile email" + ) + parRequest = Request[IO](Method.POST, uri"/obp-oidc/par").withEntity(parForm) + parResponse <- app(parRequest) + parBody <- parResponse.as[String] + requestUri = decode[ParResponse](parBody) + .getOrElse(throw new Exception("Failed to decode PAR response")) + .request_uri + + authUri = Uri + .unsafeFromString("/obp-oidc/auth") + .withQueryParam("client_id", "test-client") + .withQueryParam("request_uri", requestUri) + + firstResponse <- app(Request[IO](Method.GET, authUri)) + secondResponse <- app(Request[IO](Method.GET, authUri)) + } yield { + firstResponse.status should be(Status.Ok) + secondResponse.status should be(Status.BadRequest) + } + + test.unsafeRunSync() + } } From 0399c060a523a16ea172f7621cb790915b5e3de4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 16:52:11 +0200 Subject: [PATCH 3/9] feat: read/write client jwks_uri from the OIDC consumer views 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. --- pom.xml | 9 ++++ .../tesobe/oidc/auth/HybridAuthService.scala | 49 ++++++++++++------- .../com/tesobe/oidc/models/OidcModels.scala | 5 +- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/pom.xml b/pom.xml index 17aafa8..b8ddf3d 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ 3.5.7 0.14.9 4.4.0 + 9.40 1.2.13 3.2.19 1.0.0-RC4 @@ -108,6 +109,14 @@ java-jwt ${java.jwt.version} + + + com.nimbusds + nimbus-jose-jwt + ${nimbus.jose.jwt.version} + diff --git a/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala index c132079..61b45ab 100644 --- a/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala @@ -666,7 +666,7 @@ class HybridAuthService( logger.debug(s"Looking up client via database for client_id: $clientId") val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri FROM v_oidc_clients WHERE client_id = $clientId """.query[DatabaseClient] @@ -690,7 +690,7 @@ class HybridAuthService( def findDatabaseClientById(clientId: String): IO[Option[DatabaseClient]] = { val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri FROM v_oidc_clients WHERE client_id = $clientId """.query[DatabaseClient] @@ -865,7 +865,7 @@ class HybridAuthService( println(s" Looking in v_oidc_clients view with column 'client_name'") val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri FROM v_oidc_clients WHERE client_name = $clientName LIMIT 1 @@ -931,7 +931,7 @@ class HybridAuthService( ) val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri FROM v_oidc_clients WHERE client_name = $clientName ORDER BY created_at ASC @@ -1006,14 +1006,14 @@ class HybridAuthService( INSERT INTO v_oidc_admin_clients ( name, apptype, description, developeremail, sub, secret, azp, aud, iss, redirecturl, company, key_c, consumerid, isactive, - createdat, updatedat + createdat, updatedat, jwksuri ) VALUES ( ${adminClient.name}, ${adminClient.apptype}, ${adminClient.description}, ${adminClient.developeremail}, ${adminClient.sub}, ${adminClient.secret}, ${adminClient.azp}, ${adminClient.aud}, ${adminClient.iss}, ${adminClient.redirecturl}, ${adminClient.company}, ${adminClient.key_c}, ${adminClient.consumerid}, ${adminClient.isactive}, - ${adminClient.createdat}, ${adminClient.updatedat} + ${adminClient.createdat}, ${adminClient.updatedat}, ${adminClient.jwksuri} ) """.update @@ -1152,7 +1152,7 @@ class HybridAuthService( val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri FROM v_oidc_clients ORDER BY client_name ASC """.query[DatabaseClient] @@ -1213,7 +1213,7 @@ class HybridAuthService( val query = sql""" SELECT name, apptype, description, developeremail, sub, createdat, updatedat, secret, azp, aud, iss, redirecturl, - logourl, userauthenticationurl, clientcertificate, company, key_c, consumerid, isactive + logourl, userauthenticationurl, clientcertificate, company, key_c, consumerid, isactive, jwksuri FROM v_oidc_admin_clients WHERE key_c = $clientId """.query[AdminDatabaseClient] @@ -1459,7 +1459,8 @@ case class DatabaseClient( response_types: Option[String], // Simple string from database scopes: Option[String], // Simple string from database token_endpoint_auth_method: Option[String], - created_at: Option[String] + created_at: Option[String], + jwks_uri: Option[String] = None ) { def toOidcClient: OidcClient = OidcClient( client_id = client_id, @@ -1471,7 +1472,8 @@ case class DatabaseClient( response_types = parseSimpleString(response_types.orNull), scopes = parseSimpleString(scopes.orNull), token_endpoint_auth_method = token_endpoint_auth_method.getOrElse(""), - created_at = created_at + created_at = created_at, + jwks_uri = jwks_uri.filter(_.trim.nonEmpty) ) private def parseSimpleString(str: String): List[String] = { @@ -1508,7 +1510,8 @@ case class AdminDatabaseClient( key_c: Option[ String ], // OAuth1/OAuth2 client identifier (maps to client_id in views) - isactive: Option[Boolean] // is active + isactive: Option[Boolean], // is active + jwksuri: Option[String] = None // FAPI: client's JWKS URL ) { def toOidcClient: OidcClient = OidcClient( client_id = key_c.getOrElse(""), // Use key_c as the OAuth2 identifier @@ -1525,7 +1528,8 @@ case class AdminDatabaseClient( response_types = List("code"), scopes = List("openid", "profile", "email"), token_endpoint_auth_method = "client_secret_basic", - created_at = createdat.map(_.toString) + created_at = createdat.map(_.toString), + jwks_uri = jwksuri.filter(_.trim.nonEmpty) ) private def parseSimpleString(str: String): List[String] = { @@ -1562,7 +1566,8 @@ object AdminDatabaseClient { key_c = Some( client.client_id ), // Use client_id as the OAuth2 identifier (maps to key_c in database) - isactive = Some(true) + isactive = Some(true), + jwksuri = client.jwks_uri ) } @@ -1876,7 +1881,8 @@ object DatabaseUserInstances { Option[String], Option[String], Option[String], - Option[Boolean] + Option[Boolean], + Option[String] ) ] .map { @@ -1899,7 +1905,8 @@ object DatabaseUserInstances { company, key_c, consumerid, - isactive + isactive, + jwksuri ) => AdminDatabaseClient( name = name, @@ -1920,7 +1927,8 @@ object DatabaseUserInstances { company = company, key_c = key_c, consumerid = consumerid, - isactive = isactive + isactive = isactive, + jwksuri = jwksuri ) } @@ -1937,7 +1945,8 @@ object DatabaseUserInstances { Option[String], // response_types Option[String], // scopes Option[String], // token_endpoint_auth_method - Option[String] // created_at + Option[String], // created_at + Option[String] // jwks_uri ) ] .map { @@ -1951,7 +1960,8 @@ object DatabaseUserInstances { response_types, scopes, token_endpoint_auth_method, - created_at + created_at, + jwks_uri ) => DatabaseClient( client_id = client_id, @@ -1963,7 +1973,8 @@ object DatabaseUserInstances { response_types = response_types, scopes = scopes, token_endpoint_auth_method = token_endpoint_auth_method, - created_at = created_at + created_at = created_at, + jwks_uri = jwks_uri ) } } diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index 14407eb..d032238 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -132,7 +132,10 @@ case class OidcClient( response_types: List[String] = List("code"), scopes: List[String] = List("openid", "profile", "email"), token_endpoint_auth_method: String = "client_secret_post", - created_at: Option[String] = None + created_at: Option[String] = None, + // FAPI 1.0 Advanced: URL where this client publishes its JWKS, used to verify + // signed request objects and private_key_jwt client assertions. + jwks_uri: Option[String] = None ) object OidcClient { From 01520456e5608e8e11b0864a1d94f3e1b9ed6da4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 16:56:38 +0200 Subject: [PATCH 4/9] feat: add openbanking_intent_id claim to issued tokens (Gap 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../oidc/endpoints/DiscoveryEndpoint.scala | 4 +- .../com/tesobe/oidc/tokens/JwtService.scala | 18 ++- .../tesobe/oidc/tokens/JwtServiceTest.scala | 116 ++++++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala index 570d2ef..b468e64 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -53,7 +53,7 @@ class DiscoveryEndpoint(config: OidcConfig) { scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = List("client_secret_post", "client_secret_basic", "none"), - claims_supported = List("sub", "name", "email", "email_verified", "consent_id"), + claims_supported = List("sub", "name", "email", "email_verified", "consent_id", "openbanking_intent_id"), grant_types_supported = List("authorization_code", "refresh_token", "client_credentials"), revocation_endpoint_auth_methods_supported = @@ -79,7 +79,7 @@ class DiscoveryEndpoint(config: OidcConfig) { scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = List("client_secret_post", "client_secret_basic", "none"), - claims_supported = List("sub", "name", "email", "email_verified", "consent_id"), + claims_supported = List("sub", "name", "email", "email_verified", "consent_id", "openbanking_intent_id"), grant_types_supported = List("authorization_code", "refresh_token", "client_credentials"), revocation_endpoint_auth_methods_supported = diff --git a/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala index cd9ca12..cfc2fe0 100644 --- a/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala +++ b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala @@ -142,7 +142,11 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) _ = logger.trace(s"Added azp claim with value: $clientId") tokenWithNonce = nonce.fold(token)(n => token.withClaim("nonce", n)) - tokenWithConsent = consentId.fold(tokenWithNonce)(cid => tokenWithNonce.withClaim("consent_id", cid)) + // consent_id is OBP-OIDC's proprietary claim name; openbanking_intent_id is the + // standard UK Open Banking claim (same value) added for FAPI/Gap 8 compatibility. + tokenWithConsent = consentId.fold(tokenWithNonce)(cid => + tokenWithNonce.withClaim("consent_id", cid).withClaim("openbanking_intent_id", cid) + ) signedToken = tokenWithConsent.sign(algorithm) _ = logger.trace( @@ -201,7 +205,9 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) tokenWithState = state.fold(token)(s => token.withClaim("s_hash", computeHalfHash(s))) tokenWithNonce = nonce.fold(tokenWithState)(n => tokenWithState.withClaim("nonce", n)) - tokenWithConsent = consentId.fold(tokenWithNonce)(cid => tokenWithNonce.withClaim("consent_id", cid)) + tokenWithConsent = consentId.fold(tokenWithNonce)(cid => + tokenWithNonce.withClaim("consent_id", cid).withClaim("openbanking_intent_id", cid) + ) signedToken = tokenWithConsent.sign(algorithm) _ = logger.info(s"Hybrid ID token generated successfully with azp: $clientId, c_hash: $cHash") @@ -260,7 +266,9 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) ) // Bind the token to an OBP Consent (consent authorisation flow) — resource // servers (OBP-API) read this claim to resolve and validate the consent. - tokenWithConsent = consentId.fold(token)(cid => token.withClaim("consent_id", cid)) + tokenWithConsent = consentId.fold(token)(cid => + token.withClaim("consent_id", cid).withClaim("openbanking_intent_id", cid) + ) signedToken = tokenWithConsent.sign(algorithm) _ = logger.trace( @@ -351,7 +359,9 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) // Carry the consent binding across token rotation: the refresh grant reads this // claim back and stamps it into the next access/refresh token pair. - tokenWithConsent = consentId.fold(token)(cid => token.withClaim("consent_id", cid)) + tokenWithConsent = consentId.fold(token)(cid => + token.withClaim("consent_id", cid).withClaim("openbanking_intent_id", cid) + ) signedToken = tokenWithConsent.sign(algorithm) _ = logger.info( diff --git a/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala b/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala new file mode 100644 index 0000000..532488d --- /dev/null +++ b/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.tokens + +import cats.effect.unsafe.implicits.global +import com.auth0.jwt.JWT +import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} +import com.tesobe.oidc.models.User +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Gap 8: issued tokens must carry the standard openbanking_intent_id claim + * alongside OBP-OIDC's proprietary consent_id claim (same value), so UK + * Open Banking resource servers that only look for the standard claim name + * still find the consent binding. + */ +class JwtServiceTest extends AnyFunSuite with Matchers { + + val testConfig = OidcConfig( + issuer = "http://localhost:9000/obp-oidc", + server = ServerConfig("localhost", 9000), + database = DatabaseConfig("localhost", 5432, "test", "test", "test"), + adminDatabase = + DatabaseConfig("localhost", 5432, "test", "test_admin", "test_admin"), + keyId = "test-key-1" + ) + + val testUser = User( + sub = "alice123", + username = "alice123", + password = "secret123456", + name = Some("Alice Smith"), + email = Some("alice@example.com"), + email_verified = Some(true), + provider = Some("obp-test") + ) + + test("generateIdToken should mirror consent_id into openbanking_intent_id") { + val test = for { + jwtService <- JwtService(testConfig) + token <- jwtService.generateIdToken(testUser, "test-client", consentId = Some("consent-abc-123")) + } yield { + val decoded = JWT.decode(token) + decoded.getClaim("consent_id").asString shouldBe "consent-abc-123" + decoded.getClaim("openbanking_intent_id").asString shouldBe "consent-abc-123" + } + + test.unsafeRunSync() + } + + test("generateIdToken should omit both claims when no consent_id is given") { + val test = for { + jwtService <- JwtService(testConfig) + token <- jwtService.generateIdToken(testUser, "test-client") + } yield { + val decoded = JWT.decode(token) + decoded.getClaim("consent_id").asString shouldBe null + decoded.getClaim("openbanking_intent_id").asString shouldBe null + } + + test.unsafeRunSync() + } + + test("generateAccessToken should mirror consent_id into openbanking_intent_id") { + val test = for { + jwtService <- JwtService(testConfig) + token <- jwtService.generateAccessToken( + testUser, + "test-client", + "openid profile", + consentId = Some("consent-xyz-789") + ) + } yield { + val decoded = JWT.decode(token) + decoded.getClaim("consent_id").asString shouldBe "consent-xyz-789" + decoded.getClaim("openbanking_intent_id").asString shouldBe "consent-xyz-789" + } + + test.unsafeRunSync() + } + + test("generateRefreshToken should mirror consent_id into openbanking_intent_id") { + val test = for { + jwtService <- JwtService(testConfig) + token <- jwtService.generateRefreshToken( + testUser, + "test-client", + "openid profile", + consentId = Some("consent-refresh-456") + ) + } yield { + val decoded = JWT.decode(token) + decoded.getClaim("consent_id").asString shouldBe "consent-refresh-456" + decoded.getClaim("openbanking_intent_id").asString shouldBe "consent-refresh-456" + } + + test.unsafeRunSync() + } +} From 6b8bd3d0f91c2c5fbdfdde175931eaf4d4883e0c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 17:37:25 +0200 Subject: [PATCH 5/9] feat: verify signed request objects (JAR / RFC 9101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/tesobe/oidc/auth/JwksClient.scala | 91 +++++++ .../oidc/auth/RequestObjectService.scala | 206 +++++++++++++++ .../tesobe/oidc/endpoints/AuthEndpoint.scala | 52 +++- .../com/tesobe/oidc/server/OidcServer.scala | 7 +- .../oidc/OidcProviderIntegrationTest.scala | 7 +- .../oidc/auth/RequestObjectServiceTest.scala | 235 ++++++++++++++++++ 6 files changed, 590 insertions(+), 8 deletions(-) create mode 100644 src/main/scala/com/tesobe/oidc/auth/JwksClient.scala create mode 100644 src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala create mode 100644 src/test/scala/com/tesobe/oidc/auth/RequestObjectServiceTest.scala diff --git a/src/main/scala/com/tesobe/oidc/auth/JwksClient.scala b/src/main/scala/com/tesobe/oidc/auth/JwksClient.scala new file mode 100644 index 0000000..bff0487 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/auth/JwksClient.scala @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.{IO, Ref, Resource} +import com.nimbusds.jose.jwk.JWKSet +import org.http4s.Uri +import org.http4s.client.Client +import org.http4s.ember.client.EmberClientBuilder +import org.slf4j.LoggerFactory + +import java.time.Instant +import scala.concurrent.duration._ + +/** Fetches and caches a client's JWKS document (RFC 7517), keyed by its + * jwks_uri. Used to verify signed request objects (JAR / RFC 9101) and + * private_key_jwt client assertions against the client's own public key — + * OBP-OIDC never stores a client's private key, only its published JWKS. + */ +trait JwksClient[F[_]] { + def fetch(jwksUri: String): F[Either[String, JWKSet]] +} + +class HttpJwksClient( + httpClient: Client[IO], + cacheRef: Ref[IO, Map[String, (JWKSet, Instant)]], + ttl: FiniteDuration +) extends JwksClient[IO] { + + private val logger = LoggerFactory.getLogger(getClass) + + def fetch(jwksUri: String): IO[Either[String, JWKSet]] = { + for { + cache <- cacheRef.get + now = Instant.now() + result <- cache.get(jwksUri) match { + case Some((jwks, fetchedAt)) if fetchedAt.plusSeconds(ttl.toSeconds).isAfter(now) => + IO.pure(Right(jwks)) + case _ => + fetchAndCache(jwksUri, now) + } + } yield result + } + + private def fetchAndCache(jwksUri: String, now: Instant): IO[Either[String, JWKSet]] = { + IO.fromEither(Uri.fromString(jwksUri).left.map(e => new RuntimeException(e.message))) + .flatMap(httpClient.expect[String](_)) + .attempt + .flatMap { + case Right(body) => + IO(JWKSet.parse(body)).attempt.flatMap { + case Right(jwks) => + cacheRef.update(_ + (jwksUri -> (jwks, now))).as(Right(jwks)) + case Left(error) => + logger.warn(s"Failed to parse JWKS from $jwksUri: ${error.getMessage}") + IO.pure(Left(s"Invalid JWKS document at $jwksUri")) + } + case Left(error) => + logger.warn(s"Failed to fetch JWKS from $jwksUri: ${error.getMessage}") + IO.pure(Left(s"Could not fetch JWKS from $jwksUri")) + } + } +} + +object JwksClient { + private val defaultTtl = 10.minutes + + def create(): Resource[IO, JwksClient[IO]] = { + for { + httpClient <- EmberClientBuilder.default[IO].build + cacheRef <- Resource.eval(Ref.of[IO, Map[String, (JWKSet, Instant)]](Map.empty)) + } yield new HttpJwksClient(httpClient, cacheRef, defaultTtl) + } +} diff --git a/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala b/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala new file mode 100644 index 0000000..a737017 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.IO +import com.nimbusds.jose.crypto.{ECDSAVerifier, RSASSAVerifier} +import com.nimbusds.jose.jwk.{ECKey, JWK, JWKSet, RSAKey} +import com.nimbusds.jwt.SignedJWT +import com.tesobe.oidc.config.OidcConfig +import com.tesobe.oidc.models.OidcError +import org.slf4j.LoggerFactory + +import scala.jdk.CollectionConverters._ +import scala.util.Try + +/** Authorization parameters carried inside a verified signed request object. */ +case class RequestObjectClaims( + responseType: String, + clientId: String, + redirectUri: String, + scope: String, + state: Option[String], + nonce: Option[String], + consentRequestId: Option[String], + bankId: Option[String], + consentId: Option[String], + codeChallenge: Option[String], + codeChallengeMethod: Option[String] +) + +/** Signals a request-object validation failure through an IO chain, carrying + * the OidcError to report back to the client. Caught and unwrapped at the + * boundary in resolve() — never leaks past this file. + */ +private case class RequestObjectRejected(oidcError: OidcError) extends RuntimeException(oidcError.error) + +/** Verifies signed request objects (JAR / RFC 9101), a FAPI 1.0 Advanced + * requirement: instead of authorization parameters travelling as plain + * query params, the client signs them as a JWT with its own private key. + * OBP-OIDC verifies the signature against the client's published JWKS + * (resolved via its jwks_uri) rather than trusting the query string. + */ +trait RequestObjectService[F[_]] { + def resolve( + requestJws: String, + expectedClientId: String + ): F[Either[OidcError, RequestObjectClaims]] +} + +class DefaultRequestObjectService( + authService: AuthService[IO], + jwksClient: JwksClient[IO], + config: OidcConfig +) extends RequestObjectService[IO] { + + private val logger = LoggerFactory.getLogger(getClass) + + // FAPI 1.0 Advanced: request object lifetime must not exceed 60 minutes. + private val maxLifetimeSeconds = 60 * 60L + + private def reject[A](error: OidcError): IO[A] = IO.raiseError(RequestObjectRejected(error)) + + private def require(cond: Boolean, error: => OidcError): IO[Unit] = + if (cond) IO.unit else reject(error) + + private def requireSome[A](opt: Option[A], error: => OidcError): IO[A] = + opt.fold(reject[A](error))(IO.pure) + + def resolve( + requestJws: String, + expectedClientId: String + ): IO[Either[OidcError, RequestObjectClaims]] = { + val validated = for { + signedJwt <- Try(SignedJWT.parse(requestJws)).toOption match { + case Some(jwt) => IO.pure(jwt) + case None => reject[SignedJWT](OidcError("invalid_request_object", Some("Malformed request object"))) + } + claims = signedJwt.getJWTClaimsSet + + clientIdClaim = Option(claims.getStringClaim("client_id")) + _ <- require( + clientIdClaim.contains(expectedClientId), + OidcError("invalid_request_object", Some("client_id claim does not match the request's client_id")) + ) + + issuer = Option(claims.getIssuer) + _ <- require( + issuer.contains(expectedClientId), + OidcError("invalid_request_object", Some("iss claim must equal client_id")) + ) + + audience = Option(claims.getAudience).map(_.asScala.toList).getOrElse(Nil) + _ <- require( + audience.contains(config.issuer), + OidcError("invalid_request_object", Some("aud claim must include this server's issuer")) + ) + + now = java.time.Instant.now() + exp = Option(claims.getExpirationTime).map(_.toInstant) + _ <- exp match { + case None => + reject[Unit](OidcError("invalid_request_object", Some("exp claim is required"))) + case Some(e) if e.isBefore(now) => + reject[Unit](OidcError("invalid_request_object", Some("request object has expired"))) + case Some(e) if e.isAfter(now.plusSeconds(maxLifetimeSeconds)) => + reject[Unit](OidcError("invalid_request_object", Some("exp claim exceeds the maximum request object lifetime"))) + case Some(_) => IO.unit + } + nbf = Option(claims.getNotBeforeTime).map(_.toInstant) + _ <- nbf match { + case Some(n) if n.isAfter(now) => + reject[Unit](OidcError("invalid_request_object", Some("nbf claim is in the future"))) + case _ => IO.unit + } + + client <- authService.findClientByClientIdThatIsKey(expectedClientId) + jwksUri <- requireSome( + client.flatMap(_.jwks_uri), + OidcError("invalid_request_object", Some(s"Client $expectedClientId has no registered jwks_uri")) + ) + + jwksResult <- jwksClient.fetch(jwksUri) + jwks <- jwksResult match { + case Right(k) => IO.pure(k) + case Left(msg) => reject[JWKSet](OidcError("invalid_request_object", Some(msg))) + } + + verified = verifySignature(signedJwt, jwks) + _ <- require(verified, OidcError("invalid_request_object", Some("Signature verification failed"))) + + responseType <- requireSome( + Option(claims.getStringClaim("response_type")), + OidcError("invalid_request_object", Some("response_type claim is required")) + ) + redirectUri <- requireSome( + Option(claims.getStringClaim("redirect_uri")), + OidcError("invalid_request_object", Some("redirect_uri claim is required")) + ) + scope <- requireSome( + Option(claims.getStringClaim("scope")), + OidcError("invalid_request_object", Some("scope claim is required")) + ) + } yield RequestObjectClaims( + responseType = responseType, + clientId = expectedClientId, + redirectUri = redirectUri, + scope = scope, + state = Option(claims.getStringClaim("state")), + nonce = Option(claims.getStringClaim("nonce")), + consentRequestId = Option(claims.getStringClaim("consent_request_id")), + bankId = Option(claims.getStringClaim("bank_id")), + consentId = Option(claims.getStringClaim("consent_id")), + codeChallenge = Option(claims.getStringClaim("code_challenge")), + codeChallengeMethod = Option(claims.getStringClaim("code_challenge_method")) + ) + + validated.attempt.map { + case Right(value) => Right(value) + case Left(RequestObjectRejected(err)) => Left(err) + case Left(other) => + logger.warn(s"Unexpected error verifying request object: ${other.getMessage}") + Left(OidcError("invalid_request_object", Some("Failed to verify request object"))) + } + } + + private def verifySignature(signedJwt: SignedJWT, jwks: JWKSet): Boolean = { + val keyId = Option(signedJwt.getHeader.getKeyID) + val candidates: List[JWK] = keyId match { + case Some(kid) => Option(jwks.getKeyByKeyId(kid)).toList + case None => jwks.getKeys.asScala.toList + } + + candidates + .collectFirst { + case rsaKey: RSAKey => Try(signedJwt.verify(new RSASSAVerifier(rsaKey))).getOrElse(false) + case ecKey: ECKey => Try(signedJwt.verify(new ECDSAVerifier(ecKey))).getOrElse(false) + } + .getOrElse(false) + } +} + +object RequestObjectService { + def apply( + authService: AuthService[IO], + jwksClient: JwksClient[IO], + config: OidcConfig + ): RequestObjectService[IO] = + new DefaultRequestObjectService(authService, jwksClient, config) +} diff --git a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala index 00f7a7d..4dabad0 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala @@ -20,7 +20,7 @@ package com.tesobe.oidc.endpoints import cats.effect.{IO, Ref} -import com.tesobe.oidc.auth.{AuthService, CodeService, ParService} +import com.tesobe.oidc.auth.{AuthService, CodeService, ParService, RequestObjectService} import com.tesobe.oidc.endpoints.HtmlUtils.htmlEncode import com.tesobe.oidc.models.{ConsentChallenge, OidcError, User} import com.tesobe.oidc.ratelimit.RateLimitService @@ -45,7 +45,8 @@ class AuthEndpoint( config: OidcConfig, jwtService: JwtService[IO], consentChallengesRef: Ref[IO, Map[String, ConsentChallenge]], - parService: ParService[IO] + parService: ParService[IO], + requestObjectService: RequestObjectService[IO] ) { private val logger = LoggerFactory.getLogger(getClass) @@ -62,6 +63,15 @@ class AuthEndpoint( if config.localDevelopmentMode => showStandaloneLoginForm() + // Signed request object (JAR / RFC 9101, FAPI 1.0 Advanced): the actual + // authorization parameters are inside a JWT signed with the client's own + // key, verified against its published JWKS. Matched first so a `request` + // param always wins over any (untrusted) plain query params sent alongside it. + case GET -> Root / "obp-oidc" / "auth" :? + RequestObjectQueryParamMatcher(requestJws) +& + ClientIdQueryParamMatcher(clientId) => + handleRequestObject(requestJws, clientId) + // PAR (RFC 9126): request_uri resolves to a previously pushed set of // authorization parameters. Matched before the direct-parameter case so // request_uri-bearing requests (which omit response_type/redirect_uri/ @@ -139,6 +149,9 @@ class AuthEndpoint( // PAR (RFC 9126) object RequestUriQueryParamMatcher extends QueryParamDecoderMatcher[String]("request_uri") + // Signed request object (JAR / RFC 9101) + object RequestObjectQueryParamMatcher + extends QueryParamDecoderMatcher[String]("request") // Consent callback query parameter matchers object ChallengeQueryParamMatcher @@ -152,6 +165,35 @@ class AuthEndpoint( object ProviderCallbackQueryParamMatcher extends OptionalQueryParamDecoderMatcher[String]("provider") + // Signed request object (JAR / RFC 9101, FAPI 1.0 Advanced): verify the JWS + // against the client's JWKS and continue with the claims it carries instead + // of trusting any plain query parameters. Verification failures return a + // direct JSON error — redirect_uri isn't trustworthy until the object is verified. + private def handleRequestObject( + requestJws: String, + clientId: String + ): IO[Response[IO]] = { + requestObjectService.resolve(requestJws, clientId).flatMap { + case Left(error) => + IO(logger.warn(s"Request object verification failed for clientId: $clientId: ${error.error_description.getOrElse(error.error)}")) *> + BadRequest(error.asJson) + case Right(claims) => + handleAuthorizationRequest( + claims.responseType, + claims.clientId, + claims.redirectUri, + claims.scope, + claims.state, + claims.nonce, + claims.consentRequestId, + claims.bankId, + claims.consentId, + claims.codeChallenge, + claims.codeChallengeMethod + ) + } + } + // PAR (RFC 9126): resolve a previously pushed request_uri into a full set // of authorization parameters, then continue through the normal flow. // request_uri is one-time-use and validated (existence, expiry, client_id @@ -1054,7 +1096,8 @@ object AuthEndpoint { config: OidcConfig, jwtService: JwtService[IO], consentChallengesRef: Ref[IO, Map[String, ConsentChallenge]], - parService: ParService[IO] + parService: ParService[IO], + requestObjectService: RequestObjectService[IO] ): AuthEndpoint = new AuthEndpoint( authService, @@ -1064,6 +1107,7 @@ object AuthEndpoint { config, jwtService, consentChallengesRef, - parService + parService, + requestObjectService ) } diff --git a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala index 6f85d54..cd025a4 100644 --- a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala +++ b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala @@ -27,7 +27,7 @@ import cats.data.Kleisli import com.comcast.ip4s.{Host, Port} import cats.effect.Ref import org.typelevel.ci._ -import com.tesobe.oidc.auth.{CodeService, ParService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} +import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} import com.tesobe.oidc.models.{ConsentChallenge, OidcClient} import com.tesobe.oidc.bootstrap.ClientBootstrap import com.tesobe.oidc.config.{Config, OidcConfig, VerifyCredentialsMethod, VerifyClientMethod} @@ -274,6 +274,8 @@ object OidcServer extends IOApp { // Initialize services codeService <- CodeService(config) parService <- ParService(config) + jwksClient <- JwksClient.create().allocated.map(_._1) + requestObjectService = RequestObjectService(authService, jwksClient, config) jwtService <- JwtService(config) statsService <- StatsService() statusService <- StatusService @@ -307,7 +309,8 @@ object OidcServer extends IOApp { config, jwtService, consentChallengesRef, - parService + parService, + requestObjectService ) parEndpoint = ParEndpoint(authService, parService, config) tokenEndpoint = TokenEndpoint( diff --git a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala index ce73e17..c179ac7 100644 --- a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala +++ b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala @@ -20,7 +20,7 @@ package com.tesobe.oidc import cats.effect.{IO, Ref} -import com.tesobe.oidc.auth.{CodeService, ParService, MockAuthService} +import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, MockAuthService} import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} import com.tesobe.oidc.endpoints._ import com.tesobe.oidc.models._ @@ -55,6 +55,8 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { authService <- IO(MockAuthService()) codeService <- CodeService(testConfig) parService <- ParService(testConfig) + jwksClient <- JwksClient.create().allocated.map(_._1) + requestObjectService = RequestObjectService(authService, jwksClient, testConfig) jwtService <- JwtService(testConfig) statsService <- StatsService() rateLimitConfig = RateLimitConfig() @@ -71,7 +73,8 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { testConfig, jwtService, consentChallengesRef, - parService + parService, + requestObjectService ) tokenEndpoint = TokenEndpoint( authService, diff --git a/src/test/scala/com/tesobe/oidc/auth/RequestObjectServiceTest.scala b/src/test/scala/com/tesobe/oidc/auth/RequestObjectServiceTest.scala new file mode 100644 index 0000000..29d0645 --- /dev/null +++ b/src/test/scala/com/tesobe/oidc/auth/RequestObjectServiceTest.scala @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.{JWKSet, RSAKey} +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} +import com.tesobe.oidc.models.OidcClient +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.util.Date + +class RequestObjectServiceTest extends AnyFunSuite with Matchers { + + val testConfig = OidcConfig( + issuer = "http://localhost:9000/obp-oidc", + server = ServerConfig("localhost", 9000), + database = DatabaseConfig("localhost", 5432, "test", "test", "test"), + adminDatabase = + DatabaseConfig("localhost", 5432, "test", "test_admin", "test_admin") + ) + + private val testJwksUri = "https://client.example.com/.well-known/jwks.json" + private val testClientId = "fapi-test-client" + + private val rsaJwk: RSAKey = + new RSAKeyGenerator(2048).keyID("test-client-key-1").generate() + + private val testJwks = new JWKSet(rsaJwk) + + private def mockAuthService(jwksUri: Option[String]): AuthService[IO] = + new MockAuthService() { + override def findClientByClientIdThatIsKey( + clientId: String + ): IO[Option[OidcClient]] = + IO.pure( + Some( + OidcClient( + client_id = clientId, + client_secret = Some("test-secret"), + client_name = "FAPI Test Client", + consumer_id = "test-consumer", + redirect_uris = List("https://example.com/callback"), + jwks_uri = jwksUri + ) + ) + ) + } + + private def mockJwksClient(uri: String, jwks: JWKSet): JwksClient[IO] = + new JwksClient[IO] { + def fetch(jwksUri: String): IO[Either[String, JWKSet]] = + if (jwksUri == uri) IO.pure(Right(jwks)) + else IO.pure(Left(s"no JWKS configured for $jwksUri in this test double")) + } + + private def signedRequestObject( + claimsBuilder: JWTClaimsSet.Builder => JWTClaimsSet.Builder, + key: RSAKey = rsaJwk + ): String = { + val now = new Date() + val builder = new JWTClaimsSet.Builder() + .issuer(testClientId) + .audience(testConfig.issuer) + .claim("client_id", testClientId) + .claim("response_type", "code") + .claim("redirect_uri", "https://example.com/callback") + .claim("scope", "openid profile") + .notBeforeTime(now) + .expirationTime(new Date(now.getTime + 5 * 60 * 1000)) // 5 minutes + + val claims = claimsBuilder(builder).build() + val header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID).build() + val jwt = new SignedJWT(header, claims) + jwt.sign(new RSASSASigner(key.toPrivateKey)) + jwt.serialize() + } + + test("resolve should accept a validly signed request object") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject(identity) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isRight shouldBe true + val claims = result.getOrElse(throw new Exception("expected Right")) + claims.responseType shouldBe "code" + claims.clientId shouldBe testClientId + claims.redirectUri shouldBe "https://example.com/callback" + claims.scope shouldBe "openid profile" + } + + test("resolve should carry through optional UK consent claims") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject(_.claim("consent_id", "consent-abc").claim("state", "xyz")) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isRight shouldBe true + val claims = result.getOrElse(throw new Exception("expected Right")) + claims.consentId shouldBe Some("consent-abc") + claims.state shouldBe Some("xyz") + } + + test("resolve should reject a request object signed with an untrusted key") { + val otherKey = new RSAKeyGenerator(2048).keyID("untrusted-key").generate() + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), // only trusts rsaJwk + testConfig + ) + val jws = signedRequestObject(identity, key = otherKey) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isLeft shouldBe true + result.left.getOrElse(throw new Exception("expected Left")).error shouldBe "invalid_request_object" + } + + test("resolve should reject an expired request object") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject(_.expirationTime(new Date(System.currentTimeMillis() - 60000))) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isLeft shouldBe true + result.left.getOrElse(throw new Exception("expected Left")).error_description shouldBe Some( + "request object has expired" + ) + } + + test("resolve should reject a request object exceeding the max lifetime") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject( + _.expirationTime(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) // 2 hours + ) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isLeft shouldBe true + } + + test("resolve should reject a wrong audience") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject(_.audience("https://not-this-server.example.com")) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isLeft shouldBe true + } + + test("resolve should reject a client_id mismatch between claim and query param") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject(identity) + + val result = service.resolve(jws, "a-different-client-id").unsafeRunSync() + + result.isLeft shouldBe true + } + + test("resolve should reject when the client has no registered jwks_uri") { + val service = RequestObjectService( + mockAuthService(None), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + val jws = signedRequestObject(identity) + + val result = service.resolve(jws, testClientId).unsafeRunSync() + + result.isLeft shouldBe true + result.left.getOrElse(throw new Exception("expected Left")).error_description.getOrElse("") should include( + "no registered jwks_uri" + ) + } + + test("resolve should reject a malformed request object") { + val service = RequestObjectService( + mockAuthService(Some(testJwksUri)), + mockJwksClient(testJwksUri, testJwks), + testConfig + ) + + val result = service.resolve("not-a-jwt", testClientId).unsafeRunSync() + + result.isLeft shouldBe true + } +} From aac5f05c0650dec4f6cdb96fae3a769f698771da Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 17:45:30 +0200 Subject: [PATCH 6/9] feat: verify private_key_jwt client assertions (RFC 7523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../oidc/auth/ClientAssertionService.scala | 134 +++++++++ .../tesobe/oidc/auth/JwsClientVerifier.scala | 50 ++++ .../oidc/auth/RequestObjectService.scala | 19 +- .../oidc/endpoints/DiscoveryEndpoint.scala | 4 +- .../tesobe/oidc/endpoints/TokenEndpoint.scala | 262 ++++++++++-------- .../com/tesobe/oidc/models/OidcModels.scala | 5 +- .../com/tesobe/oidc/server/OidcServer.scala | 6 +- .../oidc/OidcProviderIntegrationTest.scala | 6 +- .../auth/ClientAssertionServiceTest.scala | 193 +++++++++++++ 9 files changed, 546 insertions(+), 133 deletions(-) create mode 100644 src/main/scala/com/tesobe/oidc/auth/ClientAssertionService.scala create mode 100644 src/main/scala/com/tesobe/oidc/auth/JwsClientVerifier.scala create mode 100644 src/test/scala/com/tesobe/oidc/auth/ClientAssertionServiceTest.scala diff --git a/src/main/scala/com/tesobe/oidc/auth/ClientAssertionService.scala b/src/main/scala/com/tesobe/oidc/auth/ClientAssertionService.scala new file mode 100644 index 0000000..c8cf982 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/auth/ClientAssertionService.scala @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.{IO, Ref} +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jwt.SignedJWT +import com.tesobe.oidc.models.OidcError +import org.slf4j.LoggerFactory + +import java.time.Instant +import scala.jdk.CollectionConverters._ +import scala.util.Try + +/** Verifies private_key_jwt client assertions (RFC 7523 / OAuth2 JWT client + * authentication), a FAPI 1.0 Advanced client authentication method: instead + * of a shared client_secret, the client signs a short-lived assertion JWT + * with its own private key, verified here against its published JWKS. On + * success returns the asserted client_id — the caller treats this exactly + * like a successful authService.authenticateClient. + */ +trait ClientAssertionService[F[_]] { + def verify(clientAssertion: String, expectedAudience: String): F[Either[OidcError, String]] +} + +private case class ClientAssertionRejected(oidcError: OidcError) extends RuntimeException(oidcError.error) + +class DefaultClientAssertionService( + authService: AuthService[IO], + jwksClient: JwksClient[IO], + usedJtiRef: Ref[IO, Map[String, Instant]] +) extends ClientAssertionService[IO] { + + private val logger = LoggerFactory.getLogger(getClass) + + // RFC 7523 recommends a short assertion lifetime; this server enforces a hard cap. + private val maxLifetimeSeconds = 5 * 60L + + private def reject[A](error: OidcError): IO[A] = IO.raiseError(ClientAssertionRejected(error)) + + private def require(cond: Boolean, error: => OidcError): IO[Unit] = + if (cond) IO.unit else reject(error) + + private def requireSome[A](opt: Option[A], error: => OidcError): IO[A] = + opt.fold(reject[A](error))(IO.pure) + + def verify(clientAssertion: String, expectedAudience: String): IO[Either[OidcError, String]] = { + val validated = for { + signedJwt <- Try(SignedJWT.parse(clientAssertion)).toOption match { + case Some(jwt) => IO.pure(jwt) + case None => reject[SignedJWT](OidcError("invalid_client", Some("Malformed client_assertion"))) + } + claims = signedJwt.getJWTClaimsSet + + issuer = Option(claims.getIssuer) + subject = Option(claims.getSubject) + _ <- require( + issuer.isDefined && issuer == subject, + OidcError("invalid_client", Some("iss and sub claims must both be present and equal to client_id")) + ) + clientId <- requireSome(issuer, OidcError("invalid_client", Some("iss claim is required"))) + + audience = Option(claims.getAudience).map(_.asScala.toList).getOrElse(Nil) + _ <- require( + audience.contains(expectedAudience), + OidcError("invalid_client", Some("aud claim must equal the token endpoint")) + ) + + now = Instant.now() + expOpt = Option(claims.getExpirationTime).map(_.toInstant) + expInstant <- requireSome(expOpt, OidcError("invalid_client", Some("exp claim is required"))) + _ <- require(!expInstant.isBefore(now), OidcError("invalid_client", Some("client_assertion has expired"))) + _ <- require( + !expInstant.isAfter(now.plusSeconds(maxLifetimeSeconds)), + OidcError("invalid_client", Some("exp claim exceeds the maximum client_assertion lifetime")) + ) + + jti <- requireSome(Option(claims.getJWTID), OidcError("invalid_client", Some("jti claim is required"))) + alreadyUsed <- usedJtiRef.get.map(_.contains(jti)) + _ <- require(!alreadyUsed, OidcError("invalid_client", Some("client_assertion has already been used"))) + + client <- authService.findClientByClientIdThatIsKey(clientId) + jwksUri <- requireSome( + client.flatMap(_.jwks_uri), + OidcError("invalid_client", Some(s"Client $clientId has no registered jwks_uri")) + ) + jwksResult <- jwksClient.fetch(jwksUri) + jwks <- jwksResult match { + case Right(k) => IO.pure(k) + case Left(msg) => reject[JWKSet](OidcError("invalid_client", Some(msg))) + } + + verified = JwsClientVerifier.verify(signedJwt, jwks) + _ <- require(verified, OidcError("invalid_client", Some("client_assertion signature verification failed"))) + + // Record the jti as used only once the assertion is fully verified, so a + // failed/forged attempt never burns a legitimate future replay slot. + _ <- usedJtiRef.update(_ + (jti -> expInstant)) + _ <- usedJtiRef.update(_.filter { case (_, exp) => exp.isAfter(now) }) + } yield clientId + + validated.attempt.map { + case Right(clientId) => Right(clientId) + case Left(ClientAssertionRejected(err)) => Left(err) + case Left(other) => + logger.warn(s"Unexpected error verifying client_assertion: ${other.getMessage}") + Left(OidcError("invalid_client", Some("Failed to verify client_assertion"))) + } + } +} + +object ClientAssertionService { + val JwtBearerAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + def create(authService: AuthService[IO], jwksClient: JwksClient[IO]): IO[ClientAssertionService[IO]] = + Ref.of[IO, Map[String, Instant]](Map.empty).map(new DefaultClientAssertionService(authService, jwksClient, _)) +} diff --git a/src/main/scala/com/tesobe/oidc/auth/JwsClientVerifier.scala b/src/main/scala/com/tesobe/oidc/auth/JwsClientVerifier.scala new file mode 100644 index 0000000..93e76ba --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/auth/JwsClientVerifier.scala @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import com.nimbusds.jose.crypto.{ECDSAVerifier, RSASSAVerifier} +import com.nimbusds.jose.jwk.{ECKey, JWK, JWKSet, RSAKey} +import com.nimbusds.jwt.SignedJWT + +import scala.jdk.CollectionConverters._ +import scala.util.Try + +/** Verifies a JWS against a client's JWKS: finds the signing key (by `kid` if + * the JWS header carries one, else tries every key in the set) and checks + * the signature with the matching verifier for that key's type. Shared by + * signed-request-object and private_key_jwt client-assertion verification — + * both need exactly this, against the same per-client JWKS source. + */ +object JwsClientVerifier { + def verify(signedJwt: SignedJWT, jwks: JWKSet): Boolean = { + val keyId = Option(signedJwt.getHeader.getKeyID) + val candidates: List[JWK] = keyId match { + case Some(kid) => Option(jwks.getKeyByKeyId(kid)).toList + case None => jwks.getKeys.asScala.toList + } + + candidates + .collectFirst { + case rsaKey: RSAKey => Try(signedJwt.verify(new RSASSAVerifier(rsaKey))).getOrElse(false) + case ecKey: ECKey => Try(signedJwt.verify(new ECDSAVerifier(ecKey))).getOrElse(false) + } + .getOrElse(false) + } +} diff --git a/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala b/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala index a737017..dc3671e 100644 --- a/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala @@ -20,8 +20,7 @@ package com.tesobe.oidc.auth import cats.effect.IO -import com.nimbusds.jose.crypto.{ECDSAVerifier, RSASSAVerifier} -import com.nimbusds.jose.jwk.{ECKey, JWK, JWKSet, RSAKey} +import com.nimbusds.jose.jwk.JWKSet import com.nimbusds.jwt.SignedJWT import com.tesobe.oidc.config.OidcConfig import com.tesobe.oidc.models.OidcError @@ -142,7 +141,7 @@ class DefaultRequestObjectService( case Left(msg) => reject[JWKSet](OidcError("invalid_request_object", Some(msg))) } - verified = verifySignature(signedJwt, jwks) + verified = JwsClientVerifier.verify(signedJwt, jwks) _ <- require(verified, OidcError("invalid_request_object", Some("Signature verification failed"))) responseType <- requireSome( @@ -180,20 +179,6 @@ class DefaultRequestObjectService( } } - private def verifySignature(signedJwt: SignedJWT, jwks: JWKSet): Boolean = { - val keyId = Option(signedJwt.getHeader.getKeyID) - val candidates: List[JWK] = keyId match { - case Some(kid) => Option(jwks.getKeyByKeyId(kid)).toList - case None => jwks.getKeys.asScala.toList - } - - candidates - .collectFirst { - case rsaKey: RSAKey => Try(signedJwt.verify(new RSASSAVerifier(rsaKey))).getOrElse(false) - case ecKey: ECKey => Try(signedJwt.verify(new ECDSAVerifier(ecKey))).getOrElse(false) - } - .getOrElse(false) - } } object RequestObjectService { diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala index b468e64..4da3dcc 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -52,7 +52,7 @@ class DiscoveryEndpoint(config: OidcConfig) { id_token_signing_alg_values_supported = List("RS256"), scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = - List("client_secret_post", "client_secret_basic", "none"), + List("client_secret_post", "client_secret_basic", "none", "private_key_jwt"), claims_supported = List("sub", "name", "email", "email_verified", "consent_id", "openbanking_intent_id"), grant_types_supported = List("authorization_code", "refresh_token", "client_credentials"), @@ -78,7 +78,7 @@ class DiscoveryEndpoint(config: OidcConfig) { id_token_signing_alg_values_supported = List("RS256"), scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = - List("client_secret_post", "client_secret_basic", "none"), + List("client_secret_post", "client_secret_basic", "none", "private_key_jwt"), claims_supported = List("sub", "name", "email", "email_verified", "consent_id", "openbanking_intent_id"), grant_types_supported = List("authorization_code", "refresh_token", "client_credentials"), diff --git a/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala index 284f1c0..407b2ed 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala @@ -21,7 +21,7 @@ package com.tesobe.oidc.endpoints import cats.effect.IO import cats.syntax.all._ -import com.tesobe.oidc.auth.{AuthService, CodeService} +import com.tesobe.oidc.auth.{AuthService, CodeService, ClientAssertionService} import com.tesobe.oidc.models.{OidcError, TokenRequest, TokenResponse} import com.tesobe.oidc.tokens.JwtService import com.tesobe.oidc.config.OidcConfig @@ -39,10 +39,12 @@ class TokenEndpoint( codeService: CodeService[IO], jwtService: JwtService[IO], config: OidcConfig, - statsService: StatsService[IO] + statsService: StatsService[IO], + clientAssertionService: ClientAssertionService[IO] ) { private val logger = LoggerFactory.getLogger(getClass) + private val tokenEndpointUrl = s"${config.issuer}/token" val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> Root / "obp-oidc" / "token" => @@ -129,6 +131,11 @@ class TokenEndpoint( val credentialSource = if (clientIdFromBasic.isDefined) "Basic auth header" else if (clientIdFromForm.isDefined) "form data" else "NONE" val refreshToken = formData.get("refresh_token") val codeVerifier = formData.get("code_verifier") // PKCE (RFC 7636) + // private_key_jwt client authentication (RFC 7523 / FAPI 1.0 Advanced) + val clientAssertionType = formData.get("client_assertion_type") + val clientAssertion = formData.get("client_assertion") + val usesClientAssertion = clientAssertion.isDefined && + clientAssertionType.contains(ClientAssertionService.JwtBearerAssertionType) println(s"DEBUG: Grant type extracted: ${grantType}") logger.info(s"Grant type: ${grantType.getOrElse("MISSING")}") @@ -159,49 +166,63 @@ class TokenEndpoint( logger.info( s"Processing authorization_code grant for client: $clientIdValue" ) - // If credentials are provided (Basic or form), validate client secret - credentialsOpt match { - case Some((id, secret)) => - if (id != clientIdValue) { - logger.warn( - "Client ID in credentials does not match resolved client_id" + // private_key_jwt (FAPI 1.0 Advanced) takes priority over Basic/secret auth when present. + if (usesClientAssertion) { + clientAssertionService.verify(clientAssertion.get, tokenEndpointUrl).flatMap { + case Right(assertedClientId) if assertedClientId == clientIdValue => + processAuthorizationCodeGrant(authCode, redirectUriValue, clientIdValue, codeVerifier) + case Right(_) => + logger.warn("client_assertion's client_id does not match resolved client_id") + BadRequest(OidcError("invalid_client", Some("client_id does not match client_assertion")).asJson) + case Left(error) => + logger.warn(s"Client assertion verification failed for authorization_code: ${error.error}") + BadRequest(error.asJson) + } + } else { + // If credentials are provided (Basic or form), validate client secret + credentialsOpt match { + case Some((id, secret)) => + if (id != clientIdValue) { + logger.warn( + "Client ID in credentials does not match resolved client_id" + ) + BadRequest( + OidcError( + "invalid_client", + Some("Client ID mismatch") + ).asJson + ) + } else { + authService.authenticateClient(id, secret).flatMap { + case Right(_) => + logger.trace( + s"About to call processAuthorizationCodeGrant (basic auth validated)" + ) + processAuthorizationCodeGrant( + authCode, + redirectUriValue, + clientIdValue, + codeVerifier + ) + case Left(error) => + logger.warn( + s"Client authentication failed for authorization_code: ${error.error}" + ) + BadRequest(error.asJson) + } + } + case None => + // Public client (no secret) or legacy behavior + logger.trace( + s"About to call processAuthorizationCodeGrant (no client secret provided)" ) - BadRequest( - OidcError( - "invalid_client", - Some("Client ID mismatch") - ).asJson + processAuthorizationCodeGrant( + authCode, + redirectUriValue, + clientIdValue, + codeVerifier ) - } else { - authService.authenticateClient(id, secret).flatMap { - case Right(_) => - logger.trace( - s"About to call processAuthorizationCodeGrant (basic auth validated)" - ) - processAuthorizationCodeGrant( - authCode, - redirectUriValue, - clientIdValue, - codeVerifier - ) - case Left(error) => - logger.warn( - s"Client authentication failed for authorization_code: ${error.error}" - ) - BadRequest(error.asJson) - } - } - case None => - // Public client (no secret) or legacy behavior - logger.trace( - s"About to call processAuthorizationCodeGrant (no client secret provided)" - ) - processAuthorizationCodeGrant( - authCode, - redirectUriValue, - clientIdValue, - codeVerifier - ) + } } case _ => println( @@ -241,37 +262,49 @@ class TokenEndpoint( println(s"DEBUG: Matched client_credentials case") logger.info(s"Processing client_credentials grant") - // Extract client credentials from Basic Auth header or form data - val credentials = extractBasicAuthCredentials(req).orElse { - (formData.get("client_id"), formData.get("client_secret")) match { - case (Some(id), Some(secret)) => Some((id, secret)) - case _ => None + val scope = formData.getOrElse("scope", "") + + if (usesClientAssertion) { + clientAssertionService.verify(clientAssertion.get, tokenEndpointUrl).flatMap { + case Right(assertedClientId) => + logger.trace("client_credentials authenticated via client_assertion") + issueClientCredentialsToken(assertedClientId, scope) + case Left(error) => + logger.warn(s"Client assertion verification failed for client_credentials: ${error.error}") + BadRequest(error.asJson) + } + } else { + // Extract client credentials from Basic Auth header or form data + val credentials = extractBasicAuthCredentials(req).orElse { + (formData.get("client_id"), formData.get("client_secret")) match { + case (Some(id), Some(secret)) => Some((id, secret)) + case _ => None + } } - } - credentials match { - case Some((clientIdValue, clientSecretValue)) => - val scope = formData.getOrElse("scope", "") - processClientCredentialsGrant( - clientIdValue, - clientSecretValue, - scope - ) - case None => - println( - s"DEBUG: Missing client credentials for client_credentials" - ) - logger.warn( - s"Missing client credentials for client_credentials grant" - ) - BadRequest( - OidcError( - "invalid_request", - Some( - "Missing client_id and client_secret for client_credentials grant" - ) - ).asJson - ) + credentials match { + case Some((clientIdValue, clientSecretValue)) => + processClientCredentialsGrant( + clientIdValue, + clientSecretValue, + scope + ) + case None => + println( + s"DEBUG: Missing client credentials for client_credentials" + ) + logger.warn( + s"Missing client credentials for client_credentials grant" + ) + BadRequest( + OidcError( + "invalid_request", + Some( + "Missing client_id and client_secret for client_credentials grant" + ) + ).asJson + ) + } } case Some(unsupportedGrant) => println( @@ -657,41 +690,7 @@ class TokenEndpoint( authService.authenticateClient(clientId, clientSecret).flatMap { case Right(client) => logger.info(s"Client authenticated: ${client.client_name}") - - for { - // Generate access token for the client (no user context) - accessToken <- jwtService - .generateClientCredentialsToken(clientId, scope) - - // Create token response (no ID token or refresh token for client credentials) - tokenResponse = TokenResponse( - access_token = accessToken, - token_type = "Bearer", - expires_in = config.tokenExpirationSeconds, - id_token = "", // Not included in client credentials response - scope = scope, - refresh_token = None // No refresh token for client credentials - ) - - _ <- IO.pure( - logger.info( - s"Client credentials grant successful for client: $clientId" - ) - ) - - // Track successful client credentials grant - _ <- statsService - .incrementAuthorizationCodeSuccess(clientId, clientId) - - response <- Ok(tokenResponse.asJson) - .map( - _.putHeaders( - Header.Raw(CIString("Cache-Control"), "no-store"), - Header.Raw(CIString("Pragma"), "no-cache") - ) - ) - - } yield response + issueClientCredentialsToken(clientId, scope) case Left(error) => logger.warn( @@ -702,6 +701,49 @@ class TokenEndpoint( .flatMap(_ => BadRequest(error.asJson)) } } + + // Issues the client_credentials access token; the caller is responsible for + // having already authenticated clientId, whether via client_secret or a + // verified private_key_jwt client_assertion. + private def issueClientCredentialsToken( + clientId: String, + scope: String + ): IO[Response[IO]] = { + for { + // Generate access token for the client (no user context) + accessToken <- jwtService + .generateClientCredentialsToken(clientId, scope) + + // Create token response (no ID token or refresh token for client credentials) + tokenResponse = TokenResponse( + access_token = accessToken, + token_type = "Bearer", + expires_in = config.tokenExpirationSeconds, + id_token = "", // Not included in client credentials response + scope = scope, + refresh_token = None // No refresh token for client credentials + ) + + _ <- IO.pure( + logger.info( + s"Client credentials grant successful for client: $clientId" + ) + ) + + // Track successful client credentials grant + _ <- statsService + .incrementAuthorizationCodeSuccess(clientId, clientId) + + response <- Ok(tokenResponse.asJson) + .map( + _.putHeaders( + Header.Raw(CIString("Cache-Control"), "no-store"), + Header.Raw(CIString("Pragma"), "no-cache") + ) + ) + + } yield response + } } object TokenEndpoint { @@ -710,13 +752,15 @@ object TokenEndpoint { codeService: CodeService[IO], jwtService: JwtService[IO], config: OidcConfig, - statsService: StatsService[IO] + statsService: StatsService[IO], + clientAssertionService: ClientAssertionService[IO] ): TokenEndpoint = new TokenEndpoint( authService, codeService, jwtService, config, - statsService + statsService, + clientAssertionService ) } diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index d032238..08b1f7c 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -43,7 +43,10 @@ case class OidcConfiguration( code_challenge_methods_supported: List[String] = List("S256"), // PAR (RFC 9126) pushed_authorization_request_endpoint: Option[String] = None, - require_pushed_authorization_requests: Boolean = false + require_pushed_authorization_requests: Boolean = false, + // private_key_jwt (RFC 7523): algorithms this server accepts on a client_assertion signature. + token_endpoint_auth_signing_alg_values_supported: List[String] = + List("RS256", "RS384", "RS512", "ES256", "ES384", "ES512") ) object OidcConfiguration { diff --git a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala index cd025a4..c5abaa3 100644 --- a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala +++ b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala @@ -27,7 +27,7 @@ import cats.data.Kleisli import com.comcast.ip4s.{Host, Port} import cats.effect.Ref import org.typelevel.ci._ -import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} +import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, ClientAssertionService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} import com.tesobe.oidc.models.{ConsentChallenge, OidcClient} import com.tesobe.oidc.bootstrap.ClientBootstrap import com.tesobe.oidc.config.{Config, OidcConfig, VerifyCredentialsMethod, VerifyClientMethod} @@ -276,6 +276,7 @@ object OidcServer extends IOApp { parService <- ParService(config) jwksClient <- JwksClient.create().allocated.map(_._1) requestObjectService = RequestObjectService(authService, jwksClient, config) + clientAssertionService <- ClientAssertionService.create(authService, jwksClient) jwtService <- JwtService(config) statsService <- StatsService() statusService <- StatusService @@ -318,7 +319,8 @@ object OidcServer extends IOApp { codeService, jwtService, config, - statsService + statsService, + clientAssertionService ) userInfoEndpoint = UserInfoEndpoint(authService, jwtService) revocationEndpoint = RevocationEndpoint( diff --git a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala index c179ac7..5c8c206 100644 --- a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala +++ b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala @@ -20,7 +20,7 @@ package com.tesobe.oidc import cats.effect.{IO, Ref} -import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, MockAuthService} +import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, ClientAssertionService, MockAuthService} import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} import com.tesobe.oidc.endpoints._ import com.tesobe.oidc.models._ @@ -57,6 +57,7 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { parService <- ParService(testConfig) jwksClient <- JwksClient.create().allocated.map(_._1) requestObjectService = RequestObjectService(authService, jwksClient, testConfig) + clientAssertionService <- ClientAssertionService.create(authService, jwksClient) jwtService <- JwtService(testConfig) statsService <- StatsService() rateLimitConfig = RateLimitConfig() @@ -81,7 +82,8 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { codeService, jwtService, testConfig, - statsService + statsService, + clientAssertionService ) userInfoEndpoint = UserInfoEndpoint(authService, jwtService) parEndpoint = ParEndpoint(authService, parService, testConfig) diff --git a/src/test/scala/com/tesobe/oidc/auth/ClientAssertionServiceTest.scala b/src/test/scala/com/tesobe/oidc/auth/ClientAssertionServiceTest.scala new file mode 100644 index 0000000..122c2a7 --- /dev/null +++ b/src/test/scala/com/tesobe/oidc/auth/ClientAssertionServiceTest.scala @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.{JWKSet, RSAKey} +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import com.tesobe.oidc.models.OidcClient +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.util.{Date, UUID} + +class ClientAssertionServiceTest extends AnyFunSuite with Matchers { + + private val testJwksUri = "https://client.example.com/.well-known/jwks.json" + private val testClientId = "fapi-test-client" + private val tokenEndpointUrl = "http://localhost:9000/obp-oidc/token" + + private val rsaJwk: RSAKey = + new RSAKeyGenerator(2048).keyID("test-client-key-1").generate() + + private val testJwks = new JWKSet(rsaJwk) + + private def mockAuthService(jwksUri: Option[String]): AuthService[IO] = + new MockAuthService() { + override def findClientByClientIdThatIsKey( + clientId: String + ): IO[Option[OidcClient]] = + IO.pure( + Some( + OidcClient( + client_id = clientId, + client_secret = Some("test-secret"), + client_name = "FAPI Test Client", + consumer_id = "test-consumer", + redirect_uris = List("https://example.com/callback"), + jwks_uri = jwksUri + ) + ) + ) + } + + private def mockJwksClient(uri: String, jwks: JWKSet): JwksClient[IO] = + new JwksClient[IO] { + def fetch(jwksUri: String): IO[Either[String, JWKSet]] = + if (jwksUri == uri) IO.pure(Right(jwks)) + else IO.pure(Left(s"no JWKS configured for $jwksUri in this test double")) + } + + private def signedAssertion( + claimsBuilder: JWTClaimsSet.Builder => JWTClaimsSet.Builder, + key: RSAKey = rsaJwk + ): String = { + val now = new Date() + val builder = new JWTClaimsSet.Builder() + .issuer(testClientId) + .subject(testClientId) + .audience(tokenEndpointUrl) + .jwtID(UUID.randomUUID().toString) + .expirationTime(new Date(now.getTime + 60 * 1000)) // 1 minute + + val claims = claimsBuilder(builder).build() + val header = new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID).build() + val jwt = new SignedJWT(header, claims) + jwt.sign(new RSASSASigner(key.toPrivateKey)) + jwt.serialize() + } + + private def newService(jwksUri: Option[String] = Some(testJwksUri)): IO[ClientAssertionService[IO]] = + ClientAssertionService.create(mockAuthService(jwksUri), mockJwksClient(testJwksUri, testJwks)) + + test("verify should accept a validly signed client_assertion") { + val result = (for { + service <- newService() + r <- service.verify(signedAssertion(identity), tokenEndpointUrl) + } yield r).unsafeRunSync() + + result shouldBe Right(testClientId) + } + + test("verify should reject a replayed jti") { + val result = (for { + service <- newService() + assertion = signedAssertion(identity) + first <- service.verify(assertion, tokenEndpointUrl) + second <- service.verify(assertion, tokenEndpointUrl) + } yield (first, second)).unsafeRunSync() + + result._1 shouldBe Right(testClientId) + result._2.isLeft shouldBe true + result._2.left.getOrElse(throw new Exception("expected Left")).error_description.getOrElse("") should include( + "already been used" + ) + } + + test("verify should reject an expired assertion") { + val result = (for { + service <- newService() + r <- service.verify( + signedAssertion(_.expirationTime(new Date(System.currentTimeMillis() - 60000))), + tokenEndpointUrl + ) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + result.left.getOrElse(throw new Exception("expected Left")).error_description shouldBe Some( + "client_assertion has expired" + ) + } + + test("verify should reject an assertion exceeding the max lifetime") { + val result = (for { + service <- newService() + r <- service.verify( + signedAssertion(_.expirationTime(new Date(System.currentTimeMillis() + 60 * 60 * 1000))), // 1 hour + tokenEndpointUrl + ) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + } + + test("verify should reject a wrong audience") { + val result = (for { + service <- newService() + r <- service.verify(signedAssertion(_.audience("https://not-the-token-endpoint.example.com")), tokenEndpointUrl) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + } + + test("verify should reject iss/sub mismatch") { + val result = (for { + service <- newService() + r <- service.verify(signedAssertion(_.subject("a-different-client")), tokenEndpointUrl) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + } + + test("verify should reject a signature from an untrusted key") { + val otherKey = new RSAKeyGenerator(2048).keyID("untrusted-key").generate() + val result = (for { + service <- newService() + r <- service.verify(signedAssertion(identity, key = otherKey), tokenEndpointUrl) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + } + + test("verify should reject when the client has no registered jwks_uri") { + val result = (for { + service <- newService(jwksUri = None) + r <- service.verify(signedAssertion(identity), tokenEndpointUrl) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + result.left.getOrElse(throw new Exception("expected Left")).error_description.getOrElse("") should include( + "no registered jwks_uri" + ) + } + + test("verify should reject a malformed assertion") { + val result = (for { + service <- newService() + r <- service.verify("not-a-jwt", tokenEndpointUrl) + } yield r).unsafeRunSync() + + result.isLeft shouldBe true + } +} From 3c36b614a2e1ba50b07d5786f5c6c5489261eb2e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 17:57:21 +0200 Subject: [PATCH 7/9] feat: add tls_client_auth and sender-constrained access tokens (RFC 8705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../tesobe/oidc/auth/HybridAuthService.scala | 25 ++- .../com/tesobe/oidc/auth/MtlsService.scala | 118 ++++++++++++ .../scala/com/tesobe/oidc/config/Config.scala | 14 +- .../oidc/endpoints/DiscoveryEndpoint.scala | 6 +- .../tesobe/oidc/endpoints/TokenEndpoint.scala | 65 +++++-- .../com/tesobe/oidc/models/OidcModels.scala | 9 +- .../com/tesobe/oidc/server/OidcServer.scala | 6 +- .../com/tesobe/oidc/tokens/JwtService.scala | 25 ++- .../oidc/OidcProviderIntegrationTest.scala | 6 +- .../tesobe/oidc/auth/MtlsServiceTest.scala | 174 ++++++++++++++++++ 10 files changed, 411 insertions(+), 37 deletions(-) create mode 100644 src/main/scala/com/tesobe/oidc/auth/MtlsService.scala create mode 100644 src/test/scala/com/tesobe/oidc/auth/MtlsServiceTest.scala diff --git a/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala index 61b45ab..7c300ff 100644 --- a/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala @@ -666,7 +666,7 @@ class HybridAuthService( logger.debug(s"Looking up client via database for client_id: $clientId") val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri, client_certificate FROM v_oidc_clients WHERE client_id = $clientId """.query[DatabaseClient] @@ -690,7 +690,7 @@ class HybridAuthService( def findDatabaseClientById(clientId: String): IO[Option[DatabaseClient]] = { val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri, client_certificate FROM v_oidc_clients WHERE client_id = $clientId """.query[DatabaseClient] @@ -865,7 +865,7 @@ class HybridAuthService( println(s" Looking in v_oidc_clients view with column 'client_name'") val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri, client_certificate FROM v_oidc_clients WHERE client_name = $clientName LIMIT 1 @@ -931,7 +931,7 @@ class HybridAuthService( ) val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri, client_certificate FROM v_oidc_clients WHERE client_name = $clientName ORDER BY created_at ASC @@ -1152,7 +1152,7 @@ class HybridAuthService( val query = sql""" SELECT client_id, client_secret, client_name, consumer_id, redirect_uris, - grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri + grant_types, response_types, scopes, token_endpoint_auth_method, created_at, jwks_uri, client_certificate FROM v_oidc_clients ORDER BY client_name ASC """.query[DatabaseClient] @@ -1460,7 +1460,8 @@ case class DatabaseClient( scopes: Option[String], // Simple string from database token_endpoint_auth_method: Option[String], created_at: Option[String], - jwks_uri: Option[String] = None + jwks_uri: Option[String] = None, + client_certificate: Option[String] = None ) { def toOidcClient: OidcClient = OidcClient( client_id = client_id, @@ -1473,7 +1474,8 @@ case class DatabaseClient( scopes = parseSimpleString(scopes.orNull), token_endpoint_auth_method = token_endpoint_auth_method.getOrElse(""), created_at = created_at, - jwks_uri = jwks_uri.filter(_.trim.nonEmpty) + jwks_uri = jwks_uri.filter(_.trim.nonEmpty), + client_certificate = client_certificate.filter(_.trim.nonEmpty) ) private def parseSimpleString(str: String): List[String] = { @@ -1946,7 +1948,8 @@ object DatabaseUserInstances { Option[String], // scopes Option[String], // token_endpoint_auth_method Option[String], // created_at - Option[String] // jwks_uri + Option[String], // jwks_uri + Option[String] // client_certificate ) ] .map { @@ -1961,7 +1964,8 @@ object DatabaseUserInstances { scopes, token_endpoint_auth_method, created_at, - jwks_uri + jwks_uri, + client_certificate ) => DatabaseClient( client_id = client_id, @@ -1974,7 +1978,8 @@ object DatabaseUserInstances { scopes = scopes, token_endpoint_auth_method = token_endpoint_auth_method, created_at = created_at, - jwks_uri = jwks_uri + jwks_uri = jwks_uri, + client_certificate = client_certificate ) } } diff --git a/src/main/scala/com/tesobe/oidc/auth/MtlsService.scala b/src/main/scala/com/tesobe/oidc/auth/MtlsService.scala new file mode 100644 index 0000000..eb042a4 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/auth/MtlsService.scala @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import cats.effect.IO +import com.tesobe.oidc.config.OidcConfig +import com.tesobe.oidc.models.OidcError +import org.http4s.Request +import org.typelevel.ci.CIString +import org.slf4j.LoggerFactory + +import java.io.ByteArrayInputStream +import java.net.URLDecoder +import java.security.MessageDigest +import java.security.cert.{CertificateFactory, X509Certificate} +import java.util.Base64 +import scala.util.Try + +/** A parsed mTLS client certificate plus its RFC 8705 x5t#S256 thumbprint + * (SHA-256 over the DER encoding, base64url, used both to match against a + * client's registered certificate and as the cnf claim on sender-constrained + * access tokens). + */ +case class MtlsCertificate(certificate: X509Certificate, thumbprint: String) + +/** FAPI 1.0 Advanced tls_client_auth + sender-constrained access tokens + * (RFC 8705). OBP-OIDC does not terminate TLS itself — the client + * certificate is read from a header set by a trusted reverse proxy that + * does terminate it. This is only as safe as that proxy: it must be + * configured to always overwrite this header from client-supplied values + * and only forward it when the TLS handshake actually presented and + * validated a client certificate. mtlsEnabled defaults to false. + */ +trait MtlsService[F[_]] { + def extractPresentedCertificate(req: Request[IO]): Option[MtlsCertificate] + + def verifyClientCertificate( + presented: MtlsCertificate, + registeredCertificatePem: String + ): Either[OidcError, Unit] +} + +class DefaultMtlsService(config: OidcConfig) extends MtlsService[IO] { + + private val logger = LoggerFactory.getLogger(getClass) + private val certificateFactory = CertificateFactory.getInstance("X.509") + + def extractPresentedCertificate(req: Request[IO]): Option[MtlsCertificate] = { + if (!config.mtlsEnabled) None + else + req.headers.get(CIString(config.mtlsClientCertHeader)).flatMap { header => + val raw = header.head.value + // Reverse proxies commonly URL-encode the PEM (e.g. nginx $ssl_client_escaped_cert). + // URL-decoding an already-raw PEM would silently corrupt it: '+' (common in + // base64 bodies) decodes to a space, and URLDecoder rarely throws on plain text, + // so a try/catch fallback can't detect this. A raw, already-unescaped PEM always + // contains literal newlines between its base64 lines; an encoded one never does + // (real newlines become %0A) — that presence is the reliable signal to use. + val decoded = + if (raw.contains('\n') || raw.contains('\r')) raw + else Try(URLDecoder.decode(raw, "UTF-8")).getOrElse(raw) + val parsed = parseCertificate(decoded) + if (parsed.isEmpty) { + logger.warn(s"Could not parse client certificate from header ${config.mtlsClientCertHeader}") + } + parsed + } + } + + private def parseCertificate(pem: String): Option[MtlsCertificate] = { + Try { + val cert = certificateFactory + .generateCertificate(new ByteArrayInputStream(pem.getBytes("UTF-8"))) + .asInstanceOf[X509Certificate] + MtlsCertificate(cert, thumbprint(cert)) + }.toOption + } + + private def thumbprint(cert: X509Certificate): String = { + val digest = MessageDigest.getInstance("SHA-256").digest(cert.getEncoded) + Base64.getUrlEncoder.withoutPadding.encodeToString(digest) + } + + def verifyClientCertificate( + presented: MtlsCertificate, + registeredCertificatePem: String + ): Either[OidcError, Unit] = { + parseCertificate(registeredCertificatePem) match { + case None => + Left(OidcError("invalid_client", Some("Client's registered certificate is not parseable"))) + case Some(registered) if registered.thumbprint == presented.thumbprint => + Right(()) + case Some(_) => + Left(OidcError("invalid_client", Some("Presented client certificate does not match the registered certificate"))) + } + } +} + +object MtlsService { + def apply(config: OidcConfig): MtlsService[IO] = new DefaultMtlsService(config) +} diff --git a/src/main/scala/com/tesobe/oidc/config/Config.scala b/src/main/scala/com/tesobe/oidc/config/Config.scala index 6d70d1a..96088dd 100644 --- a/src/main/scala/com/tesobe/oidc/config/Config.scala +++ b/src/main/scala/com/tesobe/oidc/config/Config.scala @@ -107,7 +107,15 @@ case class OidcConfig( obpApiConsumerKey: Option[String] = None, obpApiRetryMaxAttempts: Int = 60, obpApiRetryDelaySeconds: Int = 30, - dbVendor: DbVendor = DbVendor.PostgreSQL + dbVendor: DbVendor = DbVendor.PostgreSQL, + // FAPI 1.0 Advanced tls_client_auth (RFC 8705): OBP-OIDC never terminates TLS + // itself, so this trusts a client certificate forwarded by a reverse proxy in + // mtlsClientCertHeader. Off by default — only enable once the proxy is + // configured to always overwrite this header (never pass through a + // client-supplied value) and to only set it after a real TLS handshake + // presented and validated a client certificate. + mtlsEnabled: Boolean = false, + mtlsClientCertHeader: String = "X-SSL-Client-Cert" ) { /** Derived method settings from useVerifyEndpoints */ @@ -217,7 +225,9 @@ object Config { obpApiConsumerKey = sys.env.get("OBP_API_CONSUMER_KEY"), obpApiRetryMaxAttempts = sys.env.getOrElse("OBP_API_RETRY_MAX_ATTEMPTS", "60").toInt, obpApiRetryDelaySeconds = sys.env.getOrElse("OBP_API_RETRY_DELAY_SECONDS", "30").toInt, - dbVendor = dbVendor + dbVendor = dbVendor, + mtlsEnabled = sys.env.getOrElse("OIDC_MTLS_ENABLED", "false").toBoolean, + mtlsClientCertHeader = sys.env.getOrElse("OIDC_MTLS_CLIENT_CERT_HEADER", "X-SSL-Client-Cert") ) } } diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala index 4da3dcc..0907fb5 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -46,13 +46,14 @@ class DiscoveryEndpoint(config: OidcConfig) { jwks_uri = s"${config.issuer}/jwks", revocation_endpoint = s"${config.issuer}/revoke", pushed_authorization_request_endpoint = Some(s"${config.issuer}/par"), + tls_client_certificate_bound_access_tokens = config.mtlsEnabled, registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code", "code id_token"), subject_types_supported = List("public"), id_token_signing_alg_values_supported = List("RS256"), scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = - List("client_secret_post", "client_secret_basic", "none", "private_key_jwt"), + List("client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth"), claims_supported = List("sub", "name", "email", "email_verified", "consent_id", "openbanking_intent_id"), grant_types_supported = List("authorization_code", "refresh_token", "client_credentials"), @@ -72,13 +73,14 @@ class DiscoveryEndpoint(config: OidcConfig) { jwks_uri = s"${config.issuer}/jwks", revocation_endpoint = s"${config.issuer}/revoke", pushed_authorization_request_endpoint = Some(s"${config.issuer}/par"), + tls_client_certificate_bound_access_tokens = config.mtlsEnabled, registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code", "code id_token"), subject_types_supported = List("public"), id_token_signing_alg_values_supported = List("RS256"), scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = - List("client_secret_post", "client_secret_basic", "none", "private_key_jwt"), + List("client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth"), claims_supported = List("sub", "name", "email", "email_verified", "consent_id", "openbanking_intent_id"), grant_types_supported = List("authorization_code", "refresh_token", "client_credentials"), diff --git a/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala index 407b2ed..21d6582 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala @@ -21,7 +21,7 @@ package com.tesobe.oidc.endpoints import cats.effect.IO import cats.syntax.all._ -import com.tesobe.oidc.auth.{AuthService, CodeService, ClientAssertionService} +import com.tesobe.oidc.auth.{AuthService, CodeService, ClientAssertionService, MtlsService, MtlsCertificate} import com.tesobe.oidc.models.{OidcError, TokenRequest, TokenResponse} import com.tesobe.oidc.tokens.JwtService import com.tesobe.oidc.config.OidcConfig @@ -40,7 +40,8 @@ class TokenEndpoint( jwtService: JwtService[IO], config: OidcConfig, statsService: StatsService[IO], - clientAssertionService: ClientAssertionService[IO] + clientAssertionService: ClientAssertionService[IO], + mtlsService: MtlsService[IO] ) { private val logger = LoggerFactory.getLogger(getClass) @@ -106,6 +107,23 @@ class TokenEndpoint( } } + // tls_client_auth (RFC 8705 §2.1): the presented certificate's thumbprint must + // match the client's registered certificate. On success returns that thumbprint + // for use as the cnf claim on the token this authenticates (sender-constraining). + private def verifyTlsClientAuth( + clientId: String, + presented: MtlsCertificate + ): IO[Either[OidcError, String]] = { + authService.findClientByClientIdThatIsKey(clientId).map { clientOpt => + clientOpt.flatMap(_.client_certificate) match { + case None => + Left(OidcError("invalid_client", Some(s"Client $clientId has no registered certificate"))) + case Some(registeredPem) => + mtlsService.verifyClientCertificate(presented, registeredPem).map(_ => presented.thumbprint) + } + } + } + private def handleTokenRequest( req: Request[IO], form: UrlForm @@ -136,6 +154,9 @@ class TokenEndpoint( val clientAssertion = formData.get("client_assertion") val usesClientAssertion = clientAssertion.isDefined && clientAssertionType.contains(ClientAssertionService.JwtBearerAssertionType) + // tls_client_auth (RFC 8705, FAPI 1.0 Advanced): the reverse proxy-forwarded + // client certificate, when mTLS is enabled and the header is present/parseable. + val presentedCert: Option[MtlsCertificate] = mtlsService.extractPresentedCertificate(req) println(s"DEBUG: Grant type extracted: ${grantType}") logger.info(s"Grant type: ${grantType.getOrElse("MISSING")}") @@ -166,7 +187,8 @@ class TokenEndpoint( logger.info( s"Processing authorization_code grant for client: $clientIdValue" ) - // private_key_jwt (FAPI 1.0 Advanced) takes priority over Basic/secret auth when present. + // private_key_jwt and tls_client_auth (FAPI 1.0 Advanced) take priority over + // Basic/secret auth when present; private_key_jwt wins if both are somehow sent. if (usesClientAssertion) { clientAssertionService.verify(clientAssertion.get, tokenEndpointUrl).flatMap { case Right(assertedClientId) if assertedClientId == clientIdValue => @@ -178,6 +200,14 @@ class TokenEndpoint( logger.warn(s"Client assertion verification failed for authorization_code: ${error.error}") BadRequest(error.asJson) } + } else if (presentedCert.isDefined) { + verifyTlsClientAuth(clientIdValue, presentedCert.get).flatMap { + case Right(thumbprint) => + processAuthorizationCodeGrant(authCode, redirectUriValue, clientIdValue, codeVerifier, Some(thumbprint)) + case Left(error) => + logger.warn(s"tls_client_auth verification failed for authorization_code: ${error.error}") + BadRequest(error.asJson) + } } else { // If credentials are provided (Basic or form), validate client secret credentialsOpt match { @@ -273,6 +303,15 @@ class TokenEndpoint( logger.warn(s"Client assertion verification failed for client_credentials: ${error.error}") BadRequest(error.asJson) } + } else if (presentedCert.isDefined && resolvedClientId.isDefined) { + verifyTlsClientAuth(resolvedClientId.get, presentedCert.get).flatMap { + case Right(thumbprint) => + logger.trace("client_credentials authenticated via tls_client_auth") + issueClientCredentialsToken(resolvedClientId.get, scope, Some(thumbprint)) + case Left(error) => + logger.warn(s"tls_client_auth verification failed for client_credentials: ${error.error}") + BadRequest(error.asJson) + } } else { // Extract client credentials from Basic Auth header or form data val credentials = extractBasicAuthCredentials(req).orElse { @@ -341,7 +380,8 @@ class TokenEndpoint( code: String, redirectUri: String, clientId: String, - codeVerifier: Option[String] = None + codeVerifier: Option[String] = None, + cnfThumbprint: Option[String] = None ): IO[Response[IO]] = { logger.info(s"Validating authorization code for client: $clientId") @@ -420,7 +460,7 @@ class TokenEndpoint( ) ) accessToken <- jwtService - .generateAccessToken(user, clientId, authCode.scope, authCode.consent_id) + .generateAccessToken(user, clientId, authCode.scope, authCode.consent_id, cnfThumbprint) _ <- IO.pure( logger.trace( s"Access token generated successfully" @@ -703,16 +743,17 @@ class TokenEndpoint( } // Issues the client_credentials access token; the caller is responsible for - // having already authenticated clientId, whether via client_secret or a - // verified private_key_jwt client_assertion. + // having already authenticated clientId, whether via client_secret, a + // verified private_key_jwt client_assertion, or tls_client_auth. private def issueClientCredentialsToken( clientId: String, - scope: String + scope: String, + cnfThumbprint: Option[String] = None ): IO[Response[IO]] = { for { // Generate access token for the client (no user context) accessToken <- jwtService - .generateClientCredentialsToken(clientId, scope) + .generateClientCredentialsToken(clientId, scope, cnfThumbprint) // Create token response (no ID token or refresh token for client credentials) tokenResponse = TokenResponse( @@ -753,7 +794,8 @@ object TokenEndpoint { jwtService: JwtService[IO], config: OidcConfig, statsService: StatsService[IO], - clientAssertionService: ClientAssertionService[IO] + clientAssertionService: ClientAssertionService[IO], + mtlsService: MtlsService[IO] ): TokenEndpoint = new TokenEndpoint( authService, @@ -761,6 +803,7 @@ object TokenEndpoint { jwtService, config, statsService, - clientAssertionService + clientAssertionService, + mtlsService ) } diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index 08b1f7c..f9e44b0 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -46,7 +46,9 @@ case class OidcConfiguration( require_pushed_authorization_requests: Boolean = false, // private_key_jwt (RFC 7523): algorithms this server accepts on a client_assertion signature. token_endpoint_auth_signing_alg_values_supported: List[String] = - List("RS256", "RS384", "RS512", "ES256", "ES384", "ES512") + List("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"), + // mTLS / tls_client_auth (RFC 8705): whether access tokens are certificate-bound (cnf claim). + tls_client_certificate_bound_access_tokens: Boolean = false ) object OidcConfiguration { @@ -138,7 +140,10 @@ case class OidcClient( created_at: Option[String] = None, // FAPI 1.0 Advanced: URL where this client publishes its JWKS, used to verify // signed request objects and private_key_jwt client assertions. - jwks_uri: Option[String] = None + jwks_uri: Option[String] = None, + // FAPI 1.0 Advanced (tls_client_auth / RFC 8705): the client's registered mTLS + // certificate (PEM), matched against what the client presents at token request time. + client_certificate: Option[String] = None ) object OidcClient { diff --git a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala index c5abaa3..561cd10 100644 --- a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala +++ b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala @@ -27,7 +27,7 @@ import cats.data.Kleisli import com.comcast.ip4s.{Host, Port} import cats.effect.Ref import org.typelevel.ci._ -import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, ClientAssertionService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} +import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, ClientAssertionService, MtlsService, HybridAuthService, DatabaseClient, ObpApiCredentialsService, ObpApiClientService} import com.tesobe.oidc.models.{ConsentChallenge, OidcClient} import com.tesobe.oidc.bootstrap.ClientBootstrap import com.tesobe.oidc.config.{Config, OidcConfig, VerifyCredentialsMethod, VerifyClientMethod} @@ -277,6 +277,7 @@ object OidcServer extends IOApp { jwksClient <- JwksClient.create().allocated.map(_._1) requestObjectService = RequestObjectService(authService, jwksClient, config) clientAssertionService <- ClientAssertionService.create(authService, jwksClient) + mtlsService = MtlsService(config) jwtService <- JwtService(config) statsService <- StatsService() statusService <- StatusService @@ -320,7 +321,8 @@ object OidcServer extends IOApp { jwtService, config, statsService, - clientAssertionService + clientAssertionService, + mtlsService ) userInfoEndpoint = UserInfoEndpoint(authService, jwtService) revocationEndpoint = RevocationEndpoint( diff --git a/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala index cfc2fe0..4effbff 100644 --- a/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala +++ b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala @@ -62,7 +62,8 @@ trait JwtService[F[_]] { user: User, clientId: String, scope: String, - consentId: Option[String] = None + consentId: Option[String] = None, + cnfThumbprint: Option[String] = None ): F[String] def generateRefreshToken( user: User, @@ -72,7 +73,8 @@ trait JwtService[F[_]] { ): F[String] def generateClientCredentialsToken( clientId: String, - scope: String + scope: String, + cnfThumbprint: Option[String] = None ): F[String] def validateAccessToken( token: String @@ -218,7 +220,8 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) user: User, clientId: String, scope: String, - consentId: Option[String] = None + consentId: Option[String] = None, + cnfThumbprint: Option[String] = None ): IO[String] = { for { algorithm <- getAlgorithm @@ -269,7 +272,13 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) tokenWithConsent = consentId.fold(token)(cid => token.withClaim("consent_id", cid).withClaim("openbanking_intent_id", cid) ) - signedToken = tokenWithConsent.sign(algorithm) + // Sender-constrained access token (RFC 8705 §3): binds this token to the mTLS + // certificate the client authenticated with, so a stolen bearer token alone + // isn't enough to use it — the resource server must see the same certificate. + tokenWithCnf = cnfThumbprint.fold(tokenWithConsent)(thumb => + tokenWithConsent.withClaim("cnf", java.util.Collections.singletonMap("x5t#S256", thumb)) + ) + signedToken = tokenWithCnf.sign(algorithm) _ = logger.trace( s"Access token generated successfully with azp: $clientId" @@ -283,7 +292,8 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) def generateClientCredentialsToken( clientId: String, - scope: String + scope: String, + cnfThumbprint: Option[String] = None ): IO[String] = { for { algorithm <- getAlgorithm @@ -312,7 +322,10 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) .withClaim("client_id", clientId) .withClaim("grant_type", "client_credentials") - signedToken = token.sign(algorithm) + tokenWithCnf = cnfThumbprint.fold(token)(thumb => + token.withClaim("cnf", java.util.Collections.singletonMap("x5t#S256", thumb)) + ) + signedToken = tokenWithCnf.sign(algorithm) _ = logger.info( s"Client credentials token generated successfully for client: $clientId" diff --git a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala index 5c8c206..41c0261 100644 --- a/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala +++ b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala @@ -20,7 +20,7 @@ package com.tesobe.oidc import cats.effect.{IO, Ref} -import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, ClientAssertionService, MockAuthService} +import com.tesobe.oidc.auth.{CodeService, ParService, JwksClient, RequestObjectService, ClientAssertionService, MtlsService, MockAuthService} import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} import com.tesobe.oidc.endpoints._ import com.tesobe.oidc.models._ @@ -58,6 +58,7 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { jwksClient <- JwksClient.create().allocated.map(_._1) requestObjectService = RequestObjectService(authService, jwksClient, testConfig) clientAssertionService <- ClientAssertionService.create(authService, jwksClient) + mtlsService = MtlsService(testConfig) jwtService <- JwtService(testConfig) statsService <- StatsService() rateLimitConfig = RateLimitConfig() @@ -83,7 +84,8 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers { jwtService, testConfig, statsService, - clientAssertionService + clientAssertionService, + mtlsService ) userInfoEndpoint = UserInfoEndpoint(authService, jwtService) parEndpoint = ParEndpoint(authService, parService, testConfig) diff --git a/src/test/scala/com/tesobe/oidc/auth/MtlsServiceTest.scala b/src/test/scala/com/tesobe/oidc/auth/MtlsServiceTest.scala new file mode 100644 index 0000000..9eb429b --- /dev/null +++ b/src/test/scala/com/tesobe/oidc/auth/MtlsServiceTest.scala @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.auth + +import com.tesobe.oidc.config.{DatabaseConfig, OidcConfig, ServerConfig} +import org.http4s.{Header, Method, Request, Uri} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import org.typelevel.ci.CIString + +import java.net.URLEncoder + +/** Two distinct real self-signed test certificates (openssl req -x509), + * fixed as constants so tests don't depend on any certificate-generation + * API being available/stable across JDKs. + */ +class MtlsServiceTest extends AnyFunSuite with Matchers { + + private val certPem1 = + """-----BEGIN CERTIFICATE----- + |MIICrDCCAZQCCQDYp7easZ19xDANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA10 + |ZXN0LWNsaWVudC0xMB4XDTI2MDcxODE1NTM0M1oXDTM2MDcxNTE1NTM0M1owGDEW + |MBQGA1UEAwwNdGVzdC1jbGllbnQtMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC + |AQoCggEBANAW483BfrX+HOY4OYDU2qhOCgRZ7BSMyCsj1ibGGG5kf+Zqfk2kWn4J + |909G51V6+U0Z4xvVra9Cmtv4AnAlXiVALNFzLeH6T4V/NGcO5ClrBI1fA4kVewZ8 + |1D5bLPYbW07FocDNh26+BuH11I5rnwPnFupXwVumpqNcYpkrJsgtYTN9VKIvtipT + |aO56UO5Gj/il7VzNllebc/13cCcQSG/FMt6tFC83SP7sXRiCsvqg7noPg2p/shkJ + |mBdbSDeKKyFqRarJEKKnNLLhLFKQXNYR66qerD62iKFfY0nwxv3bepxtGw+i7a22 + |vedhNVs5pNeKGRJJofyHTGKgdD5r7zUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA + |m9vOWKvDFxSxOmot7GaiwPSPuzP/s17DWX3yOsRNsaKcOBTVr4avhsrIDbFh1C/y + |PNPDNAoUpdn8+ZxAv25aOll94EPHY9hMlsDHp32SV55hVil4Ep/TQN4LRIXnHGbr + |nRLSlxt9Bw8Vf4AWlkS0MDsrfb9uMqjOFulGzkZuOa4DwB2xjBp93asXWYRIgPLA + |CvDQoN4RM+cV0+jnCCztKKTMHqQHMAnHQN6ldJSUk4I7QSAfxJv1V/gZTQUoiEf6 + |ztQGJhq1zIJXdU8njIq4rUrDxBOU/+KyuqCLCGeMcOjwogjsk1NjbGuAlJi5RO0d + |pXNqTf6QbaDHJQ0Yhi4ZFQ== + |-----END CERTIFICATE-----""".stripMargin + + private val certPem2 = + """-----BEGIN CERTIFICATE----- + |MIICrDCCAZQCCQDcCxn22XqjVzANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA10 + |ZXN0LWNsaWVudC0yMB4XDTI2MDcxODE1NTM0M1oXDTM2MDcxNTE1NTM0M1owGDEW + |MBQGA1UEAwwNdGVzdC1jbGllbnQtMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC + |AQoCggEBANpgqaEICzbK790bUCOkY3Tzo6RgZEJ2XCKtjf74LjoQPRQROXZ1rHSf + |fXfs1mhtv34h1uDFHGlKJ3m/CftcOrniuUk2Y8dsy+eWLbzo9CIloGHN/25w1uW0 + |2+nhZ8lQ577+vco4UaX8g4owvH28AIC9GBZQys1UR2C3YQRs5qj1DvAmg0tqDRlK + |NmdDS+TMnMhll7djkx9E8H8Dm/ZBZx+NKjBwo0SX32FfYp5xwvA1thySoFSH+HVE + |k5aI0ekmS/SHydq9OE5xRFhPpDHrWLCFBKTFonPZs54iNtu2SvzbP0NSPD6MheEW + |2bPyphxWravvReR07F++TlbUDlo08IUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEA + |oRdjrlhCIUIE0sid+dl646RVyGWL/q93TrFeSET62jK/xHPtalnX7yYiI93N8hyM + |H2dZ0HVe8kXzUYHMpEP4J8z7pLZStlQbBsEjFIWR1W+vPZGv1KZr4d9SAAsuxIye + |h9E+hD2h++UjBeaewRzUiSG/EvFmFGFcWRi+U+xUImR6ObqbM7f7LWWrm9/90ve8 + |5tY3we+sO/zYJTtTVeJC+DNblOIon9qboK6l26dJKKAVEdrzfA3j9NajalpWoDiS + |zbcb3RBs2ZVn70q7pcTL8s11MaRAmxDDGAXe28sRppqFE5gc+ZzQYpEVENqrIZj/ + |xdOwIUR9a+oAyaTs/Nz7EQ== + |-----END CERTIFICATE-----""".stripMargin + + private val headerName = "X-SSL-Client-Cert" + + private def config(mtlsEnabled: Boolean): OidcConfig = OidcConfig( + issuer = "http://localhost:9000/obp-oidc", + server = ServerConfig("localhost", 9000), + database = DatabaseConfig("localhost", 5432, "test", "test", "test"), + adminDatabase = + DatabaseConfig("localhost", 5432, "test", "test_admin", "test_admin"), + mtlsEnabled = mtlsEnabled, + mtlsClientCertHeader = headerName + ) + + test("extractPresentedCertificate returns None when mTLS is disabled, even with a valid header") { + val service = MtlsService(config(mtlsEnabled = false)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), URLEncoder.encode(certPem1, "UTF-8"))) + + service.extractPresentedCertificate(req) shouldBe None + } + + test("extractPresentedCertificate returns None when the header is absent") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + + service.extractPresentedCertificate(req) shouldBe None + } + + test("extractPresentedCertificate parses a URL-encoded PEM header") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), URLEncoder.encode(certPem1, "UTF-8"))) + + val result = service.extractPresentedCertificate(req) + result.isDefined shouldBe true + result.get.thumbprint should not be empty + } + + test("extractPresentedCertificate parses a raw (non-encoded) PEM header") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), certPem1)) + + val result = service.extractPresentedCertificate(req) + result.isDefined shouldBe true + } + + test("extractPresentedCertificate returns None for garbage header content") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), "not-a-certificate")) + + service.extractPresentedCertificate(req) shouldBe None + } + + test("the same certificate always produces the same thumbprint (deterministic)") { + val service = MtlsService(config(mtlsEnabled = true)) + def extract(): String = { + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), certPem1)) + service.extractPresentedCertificate(req).get.thumbprint + } + extract() shouldBe extract() + } + + test("different certificates produce different thumbprints") { + val service = MtlsService(config(mtlsEnabled = true)) + def extract(pem: String): String = { + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), pem)) + service.extractPresentedCertificate(req).get.thumbprint + } + extract(certPem1) should not equal extract(certPem2) + } + + test("verifyClientCertificate accepts a matching registered certificate") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), certPem1)) + val presented = service.extractPresentedCertificate(req).get + + service.verifyClientCertificate(presented, certPem1) shouldBe Right(()) + } + + test("verifyClientCertificate rejects a non-matching registered certificate") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), certPem1)) + val presented = service.extractPresentedCertificate(req).get + + service.verifyClientCertificate(presented, certPem2).isLeft shouldBe true + } + + test("verifyClientCertificate rejects an unparseable registered certificate") { + val service = MtlsService(config(mtlsEnabled = true)) + val req = org.http4s.Request[cats.effect.IO](org.http4s.Method.GET, org.http4s.Uri.unsafeFromString("/obp-oidc/token")) + .putHeaders(Header.Raw(CIString(headerName), certPem1)) + val presented = service.extractPresentedCertificate(req).get + + service.verifyClientCertificate(presented, "not-a-pem-certificate").isLeft shouldBe true + } +} From 505b267bff3eb2eed45336dc4d5893ec6336537b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 18:02:50 +0200 Subject: [PATCH 8/9] feat: add configurable PS256 signing (default remains RS256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../scala/com/tesobe/oidc/config/Config.scala | 10 ++- .../oidc/endpoints/DiscoveryEndpoint.scala | 4 +- .../com/tesobe/oidc/tokens/JwtService.scala | 12 +-- .../tesobe/oidc/tokens/PS256Algorithm.scala | 73 +++++++++++++++++++ .../tesobe/oidc/tokens/JwtServiceTest.scala | 54 ++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 src/main/scala/com/tesobe/oidc/tokens/PS256Algorithm.scala diff --git a/src/main/scala/com/tesobe/oidc/config/Config.scala b/src/main/scala/com/tesobe/oidc/config/Config.scala index 96088dd..62a2309 100644 --- a/src/main/scala/com/tesobe/oidc/config/Config.scala +++ b/src/main/scala/com/tesobe/oidc/config/Config.scala @@ -115,7 +115,12 @@ case class OidcConfig( // client-supplied value) and to only set it after a real TLS handshake // presented and validated a client certificate. mtlsEnabled: Boolean = false, - mtlsClientCertHeader: String = "X-SSL-Client-Cert" + mtlsClientCertHeader: String = "X-SSL-Client-Cert", + // FAPI 1.0 Advanced's strict profile disallows plain RSASSA (RS256); PS256 + // (RSASSA-PSS) is required instead. Kept RS256 by default — flipping this is a + // breaking change for every existing client validating tokens against this + // server's JWKS, so it must be an explicit opt-in, not silently switched. + signingAlgorithm: String = "RS256" ) { /** Derived method settings from useVerifyEndpoints */ @@ -227,7 +232,8 @@ object Config { obpApiRetryDelaySeconds = sys.env.getOrElse("OBP_API_RETRY_DELAY_SECONDS", "30").toInt, dbVendor = dbVendor, mtlsEnabled = sys.env.getOrElse("OIDC_MTLS_ENABLED", "false").toBoolean, - mtlsClientCertHeader = sys.env.getOrElse("OIDC_MTLS_CLIENT_CERT_HEADER", "X-SSL-Client-Cert") + mtlsClientCertHeader = sys.env.getOrElse("OIDC_MTLS_CLIENT_CERT_HEADER", "X-SSL-Client-Cert"), + signingAlgorithm = sys.env.getOrElse("OIDC_SIGNING_ALGORITHM", "RS256") ) } } diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala index 0907fb5..5b411b8 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -50,7 +50,7 @@ class DiscoveryEndpoint(config: OidcConfig) { registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code", "code id_token"), subject_types_supported = List("public"), - id_token_signing_alg_values_supported = List("RS256"), + id_token_signing_alg_values_supported = List(config.signingAlgorithm), scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = List("client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth"), @@ -77,7 +77,7 @@ class DiscoveryEndpoint(config: OidcConfig) { registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code", "code id_token"), subject_types_supported = List("public"), - id_token_signing_alg_values_supported = List("RS256"), + id_token_signing_alg_values_supported = List(config.signingAlgorithm), scopes_supported = List("openid", "profile", "email"), token_endpoint_auth_methods_supported = List("client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth"), diff --git a/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala index 4effbff..9ea75ee 100644 --- a/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala +++ b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala @@ -93,10 +93,12 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) private def getAlgorithm: IO[Algorithm] = keyPairRef.get.map { keyPair => - Algorithm.RSA256( - keyPair.getPublic.asInstanceOf[RSAPublicKey], - keyPair.getPrivate.asInstanceOf[RSAPrivateKey] - ) + val publicKey = keyPair.getPublic.asInstanceOf[RSAPublicKey] + val privateKey = keyPair.getPrivate.asInstanceOf[RSAPrivateKey] + config.signingAlgorithm match { + case "PS256" => new PS256Algorithm(publicKey, privateKey) + case _ => Algorithm.RSA256(publicKey, privateKey) + } } def generateIdToken( @@ -517,7 +519,7 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair]) kty = "RSA", use = "sig", kid = config.keyId, - alg = "RS256", + alg = config.signingAlgorithm, n = modulus, e = exponent ) diff --git a/src/main/scala/com/tesobe/oidc/tokens/PS256Algorithm.scala b/src/main/scala/com/tesobe/oidc/tokens/PS256Algorithm.scala new file mode 100644 index 0000000..562dffb --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/tokens/PS256Algorithm.scala @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OBP-OIDC is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.tokens + +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.exceptions.{SignatureGenerationException, SignatureVerificationException} +import com.auth0.jwt.interfaces.DecodedJWT + +import java.security.interfaces.{RSAPrivateKey, RSAPublicKey} +import java.security.spec.{MGF1ParameterSpec, PSSParameterSpec} +import java.security.{Signature, SecureRandom} +import java.util.Base64 + +/** PS256 (RSASSA-PSS with SHA-256, MGF1-SHA256, 32-byte salt) for the auth0 + * java-jwt library, which only ships plain RSASSA (RS256/384/512) — FAPI + * 1.0 Advanced's strict profile requires PS256 instead. `Algorithm` is + * designed to be subclassed (protected constructor, abstract sign/verify); + * this uses the JDK's own "RSASSA-PSS" Signature provider (built in since + * Java 11) with PS256's exact parameters, so no extra crypto dependency is + * needed just for this. + */ +class PS256Algorithm(publicKey: RSAPublicKey, privateKey: RSAPrivateKey) + extends Algorithm("PS256", "RSASSA-PSS256") { + + private val pssParams = new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1) + + def sign(contentBytes: Array[Byte]): Array[Byte] = { + try { + val signature = Signature.getInstance("RSASSA-PSS") + signature.setParameter(pssParams) + signature.initSign(privateKey, new SecureRandom()) + signature.update(contentBytes) + signature.sign() + } catch { + case e: Exception => + throw new SignatureGenerationException(this, e) + } + } + + def verify(jwt: DecodedJWT): Unit = { + try { + val signature = Signature.getInstance("RSASSA-PSS") + signature.setParameter(pssParams) + signature.initVerify(publicKey) + val signingInput = s"${jwt.getHeader}.${jwt.getPayload}".getBytes("UTF-8") + signature.update(signingInput) + val signatureBytes = Base64.getUrlDecoder.decode(jwt.getSignature) + if (!signature.verify(signatureBytes)) { + throw new SignatureVerificationException(this) + } + } catch { + case e: SignatureVerificationException => throw e + case e: Exception => throw new SignatureVerificationException(this, e) + } + } +} diff --git a/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala b/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala index 532488d..7728388 100644 --- a/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala +++ b/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala @@ -113,4 +113,58 @@ class JwtServiceTest extends AnyFunSuite with Matchers { test.unsafeRunSync() } + + // FAPI 1.0 Advanced's strict profile requires PS256 instead of plain RS256. + test("default config signs with RS256") { + val test = for { + jwtService <- JwtService(testConfig) + token <- jwtService.generateIdToken(testUser, "test-client") + } yield { + JWT.decode(token).getAlgorithm shouldBe "RS256" + } + + test.unsafeRunSync() + } + + test("PS256 config signs with PS256 and the token verifies") { + val ps256Config = testConfig.copy(signingAlgorithm = "PS256") + val test = for { + jwtService <- JwtService(ps256Config) + idToken <- jwtService.generateIdToken(testUser, "test-client") + accessToken <- jwtService.generateAccessToken(testUser, "test-client", "openid profile") + verified <- jwtService.validateAccessToken(accessToken) + } yield { + JWT.decode(idToken).getAlgorithm shouldBe "PS256" + JWT.decode(accessToken).getAlgorithm shouldBe "PS256" + verified.isRight shouldBe true + } + + test.unsafeRunSync() + } + + test("PS256 config is reflected in the published JWKS") { + val ps256Config = testConfig.copy(signingAlgorithm = "PS256") + val test = for { + jwtService <- JwtService(ps256Config) + jwk <- jwtService.getJsonWebKey + } yield { + jwk.alg shouldBe "PS256" + } + + test.unsafeRunSync() + } + + test("a PS256 token rejects tampering (signature no longer verifies)") { + val ps256Config = testConfig.copy(signingAlgorithm = "PS256") + val test = for { + jwtService <- JwtService(ps256Config) + token <- jwtService.generateAccessToken(testUser, "test-client", "openid profile") + tampered = token.dropRight(4) + "AAAA" // corrupt the signature segment + verified <- jwtService.validateAccessToken(tampered) + } yield { + verified.isLeft shouldBe true + } + + test.unsafeRunSync() + } } From d32a19d3a7e56804cca62af734a3b3cedcf12b0d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sat, 18 Jul 2026 18:26:21 +0200 Subject: [PATCH 9/9] feat: let DCR register jwks_uri and client_certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tesobe/oidc/auth/HybridAuthService.scala | 7 ++-- .../oidc/endpoints/RegistrationEndpoint.scala | 39 ++++++++++++++++++- .../com/tesobe/oidc/models/OidcModels.scala | 17 ++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala index 7c300ff..8c6d022 100644 --- a/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala @@ -1006,14 +1006,15 @@ class HybridAuthService( INSERT INTO v_oidc_admin_clients ( name, apptype, description, developeremail, sub, secret, azp, aud, iss, redirecturl, company, key_c, consumerid, isactive, - createdat, updatedat, jwksuri + createdat, updatedat, jwksuri, clientcertificate ) VALUES ( ${adminClient.name}, ${adminClient.apptype}, ${adminClient.description}, ${adminClient.developeremail}, ${adminClient.sub}, ${adminClient.secret}, ${adminClient.azp}, ${adminClient.aud}, ${adminClient.iss}, ${adminClient.redirecturl}, ${adminClient.company}, ${adminClient.key_c}, ${adminClient.consumerid}, ${adminClient.isactive}, - ${adminClient.createdat}, ${adminClient.updatedat}, ${adminClient.jwksuri} + ${adminClient.createdat}, ${adminClient.updatedat}, ${adminClient.jwksuri}, + ${adminClient.clientcertificate} ) """.update @@ -1563,7 +1564,7 @@ object AdminDatabaseClient { redirecturl = Some(client.redirect_uris.mkString(",")), logourl = None, userauthenticationurl = None, - clientcertificate = None, + clientcertificate = client.client_certificate, company = Some("TESOBE"), key_c = Some( client.client_id diff --git a/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala index e736942..c7aa828 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala @@ -143,7 +143,9 @@ class RegistrationEndpoint( response_types = responseTypes, scopes = scopes, token_endpoint_auth_method = authMethod, - created_at = Some(java.time.Instant.now().toString) + created_at = Some(java.time.Instant.now().toString), + jwks_uri = validatedRequest.jwks_uri, + client_certificate = validatedRequest.client_certificate ) // Persist the client @@ -170,7 +172,9 @@ class RegistrationEndpoint( token_endpoint_auth_method = authMethod, logo_uri = validatedRequest.logo_uri, client_uri = validatedRequest.client_uri, - contacts = validatedRequest.contacts + contacts = validatedRequest.contacts, + jwks_uri = createdClient.jwks_uri, + client_certificate = createdClient.client_certificate ) Created(response.asJson).map(addNoCacheHeaders) @@ -255,6 +259,37 @@ class RegistrationEndpoint( ) ) } + + // private_key_jwt / tls_client_auth need something to verify future requests + // against — without it the client could never actually authenticate this way. + if (authMethod == "private_key_jwt" && request.jwks_uri.isEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some("jwks_uri is required when token_endpoint_auth_method is private_key_jwt") + ) + ) + } + if (authMethod == "tls_client_auth" && request.client_certificate.isEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some("client_certificate is required when token_endpoint_auth_method is tls_client_auth") + ) + ) + } + } + + // Validate jwks_uri if provided (must be a valid HTTPS URL, per FAPI's TLS requirement) + request.jwks_uri.foreach { jwksUri => + if (!isValidHttpUrl(jwksUri)) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"jwks_uri must be a valid HTTP/HTTPS URL: $jwksUri") + ) + ) + } } // Validate logo_uri if provided (must be valid URL) diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index f9e44b0..3ba0cff 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -357,7 +357,14 @@ case class ClientRegistrationRequest( token_endpoint_auth_method: Option[String] = None, logo_uri: Option[String] = None, client_uri: Option[String] = None, - contacts: Option[List[String]] = None + contacts: Option[List[String]] = None, + // FAPI 1.0 Advanced: jwks_uri is the RFC 7591-standard field, used to verify + // signed request objects and private_key_jwt client assertions. + jwks_uri: Option[String] = None, + // Not an RFC 7591-standard field (no field standardizes an inline mTLS cert at + // registration time); a pragmatic extension so tls_client_auth clients can + // register their certificate the same way private_key_jwt clients register jwks_uri. + client_certificate: Option[String] = None ) object ClientRegistrationRequest { @@ -376,7 +383,9 @@ object ClientRegistrationRequest { val SUPPORTED_AUTH_METHODS: Set[String] = Set( "client_secret_post", "client_secret_basic", - "none" + "none", + "private_key_jwt", + "tls_client_auth" ) val DEFAULT_GRANT_TYPES: List[String] = List("authorization_code") @@ -399,7 +408,9 @@ case class ClientRegistrationResponse( token_endpoint_auth_method: String, logo_uri: Option[String] = None, client_uri: Option[String] = None, - contacts: Option[List[String]] = None + contacts: Option[List[String]] = None, + jwks_uri: Option[String] = None, + client_certificate: Option[String] = None ) object ClientRegistrationResponse {