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/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/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/auth/HybridAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala
index c132079..8c6d022 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, 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
+ 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
+ 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
+ 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
@@ -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
+ 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.createdat}, ${adminClient.updatedat}, ${adminClient.jwksuri},
+ ${adminClient.clientcertificate}
)
""".update
@@ -1152,7 +1153,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, client_certificate
FROM v_oidc_clients
ORDER BY client_name ASC
""".query[DatabaseClient]
@@ -1213,7 +1214,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 +1460,9 @@ 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,
+ client_certificate: Option[String] = None
) {
def toOidcClient: OidcClient = OidcClient(
client_id = client_id,
@@ -1471,7 +1474,9 @@ 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),
+ client_certificate = client_certificate.filter(_.trim.nonEmpty)
)
private def parseSimpleString(str: String): List[String] = {
@@ -1508,7 +1513,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 +1531,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] = {
@@ -1557,12 +1564,13 @@ 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
), // 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 +1884,8 @@ object DatabaseUserInstances {
Option[String],
Option[String],
Option[String],
- Option[Boolean]
+ Option[Boolean],
+ Option[String]
)
]
.map {
@@ -1899,7 +1908,8 @@ object DatabaseUserInstances {
company,
key_c,
consumerid,
- isactive
+ isactive,
+ jwksuri
) =>
AdminDatabaseClient(
name = name,
@@ -1920,7 +1930,8 @@ object DatabaseUserInstances {
company = company,
key_c = key_c,
consumerid = consumerid,
- isactive = isactive
+ isactive = isactive,
+ jwksuri = jwksuri
)
}
@@ -1937,7 +1948,9 @@ 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
+ Option[String] // client_certificate
)
]
.map {
@@ -1951,7 +1964,9 @@ object DatabaseUserInstances {
response_types,
scopes,
token_endpoint_auth_method,
- created_at
+ created_at,
+ jwks_uri,
+ client_certificate
) =>
DatabaseClient(
client_id = client_id,
@@ -1963,7 +1978,9 @@ 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,
+ client_certificate = client_certificate
)
}
}
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/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/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/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/auth/RequestObjectService.scala b/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala
new file mode 100644
index 0000000..dc3671e
--- /dev/null
+++ b/src/main/scala/com/tesobe/oidc/auth/RequestObjectService.scala
@@ -0,0 +1,191 @@
+/*
+ * 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.jwk.JWKSet
+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 = JwsClientVerifier.verify(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")))
+ }
+ }
+
+}
+
+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/config/Config.scala b/src/main/scala/com/tesobe/oidc/config/Config.scala
index d021086..62a2309 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(
@@ -106,7 +107,20 @@ 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",
+ // 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 */
@@ -187,6 +201,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,
@@ -214,7 +230,10 @@ 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"),
+ signingAlgorithm = sys.env.getOrElse("OIDC_SIGNING_ALGORITHM", "RS256")
)
}
}
diff --git a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala
index 24dba20..4dabad0 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, RequestObjectService}
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,9 @@ 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],
+ requestObjectService: RequestObjectService[IO]
) {
private val logger = LoggerFactory.getLogger(getClass)
@@ -59,6 +63,24 @@ 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/
+ // 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) +&
@@ -68,7 +90,9 @@ class AuthEndpoint(
NonceQueryParamMatcher(nonce) +&
ConsentRequestIdQueryParamMatcher(consentRequestId) +&
BankIdQueryParamMatcher(bankId) +&
- ConsentIdQueryParamMatcher(consentId) =>
+ ConsentIdQueryParamMatcher(consentId) +&
+ CodeChallengeQueryParamMatcher(codeChallenge) +&
+ CodeChallengeMethodQueryParamMatcher(codeChallengeMethod) =>
handleAuthorizationRequest(
responseType,
clientId,
@@ -78,7 +102,9 @@ class AuthEndpoint(
nonce,
consentRequestId,
bankId,
- consentId
+ consentId,
+ codeChallenge,
+ codeChallengeMethod
)
case req @ POST -> Root / "obp-oidc" / "auth" =>
@@ -115,6 +141,17 @@ 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")
+ // 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
@@ -128,6 +165,66 @@ 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
+ // 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,
@@ -137,7 +234,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 +270,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 +326,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 +434,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 +459,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 +483,9 @@ class AuthEndpoint(
nonce,
Some("Incorrect username/password"),
responseType,
- consentId
+ consentId,
+ codeChallenge,
+ codeChallengeMethod
)
}
} yield response
@@ -593,12 +707,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 +735,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 +755,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 +884,8 @@ class AuthEndpoint(
$stateParam
$nonceParam
$consentIdParam
+ $codeChallengeParam
+ $codeChallengeMethodParam
@@ -969,7 +1095,9 @@ 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],
+ requestObjectService: RequestObjectService[IO]
): AuthEndpoint =
new AuthEndpoint(
authService,
@@ -978,6 +1106,8 @@ object AuthEndpoint {
rateLimitService,
config,
jwtService,
- consentChallengesRef
+ consentChallengesRef,
+ parService,
+ requestObjectService
)
}
diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala
index eeca337..5b411b8 100644
--- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala
+++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala
@@ -45,14 +45,16 @@ 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"),
+ 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"),
+ 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"),
- claims_supported = List("sub", "name", "email", "email_verified", "consent_id"),
+ 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"),
revocation_endpoint_auth_methods_supported =
@@ -70,14 +72,16 @@ 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"),
+ 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"),
+ 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"),
- claims_supported = List("sub", "name", "email", "email_verified", "consent_id"),
+ 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"),
revocation_endpoint_auth_methods_supported =
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/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/endpoints/TokenEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala
index 3734caf..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}
+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
@@ -39,10 +39,13 @@ class TokenEndpoint(
codeService: CodeService[IO],
jwtService: JwtService[IO],
config: OidcConfig,
- statsService: StatsService[IO]
+ statsService: StatsService[IO],
+ clientAssertionService: ClientAssertionService[IO],
+ mtlsService: MtlsService[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" =>
@@ -104,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
@@ -128,6 +148,15 @@ 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)
+ // 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)
+ // 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")}")
@@ -158,47 +187,72 @@ 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 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 =>
+ 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 (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 {
+ 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
- )
- 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
- )
+ }
}
case _ =>
println(
@@ -238,37 +292,58 @@ 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 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 {
+ (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(
@@ -293,10 +368,20 @@ 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,
+ cnfThumbprint: Option[String] = None
): IO[Response[IO]] = {
logger.info(s"Validating authorization code for client: $clientId")
@@ -315,6 +400,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")}"
@@ -355,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"
@@ -460,6 +565,7 @@ class TokenEndpoint(
OidcError("invalid_grant", Some("User not found")).asJson
)
}
+ } // end pkceResult match
case Left(error) =>
logger.trace(
@@ -624,41 +730,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(
@@ -669,6 +741,50 @@ 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, a
+ // verified private_key_jwt client_assertion, or tls_client_auth.
+ private def issueClientCredentialsToken(
+ clientId: 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, cnfThumbprint)
+
+ // 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 {
@@ -677,13 +793,17 @@ object TokenEndpoint {
codeService: CodeService[IO],
jwtService: JwtService[IO],
config: OidcConfig,
- statsService: StatsService[IO]
+ statsService: StatsService[IO],
+ clientAssertionService: ClientAssertionService[IO],
+ mtlsService: MtlsService[IO]
): TokenEndpoint =
new TokenEndpoint(
authService,
codeService,
jwtService,
config,
- statsService
+ statsService,
+ 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 2c9b732..3ba0cff 100644
--- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala
+++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala
@@ -38,7 +38,17 @@ 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"),
+ // PAR (RFC 9126)
+ pushed_authorization_request_endpoint: Option[String] = None,
+ 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"),
+ // mTLS / tls_client_auth (RFC 8705): whether access tokens are certificate-bound (cnf claim).
+ tls_client_certificate_bound_access_tokens: Boolean = false
)
object OidcConfiguration {
@@ -127,7 +137,13 @@ 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,
+ // 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 {
@@ -161,9 +177,38 @@ 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
)
+// 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
@@ -312,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 {
@@ -331,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")
@@ -354,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 {
diff --git a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala
index e68f6ea..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, 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}
@@ -273,6 +273,11 @@ object OidcServer extends IOApp {
// Initialize services
codeService <- CodeService(config)
+ parService <- ParService(config)
+ 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
@@ -305,14 +310,19 @@ object OidcServer extends IOApp {
rateLimitService,
config,
jwtService,
- consentChallengesRef
+ consentChallengesRef,
+ parService,
+ requestObjectService
)
+ parEndpoint = ParEndpoint(authService, parService, config)
tokenEndpoint = TokenEndpoint(
authService,
codeService,
jwtService,
config,
- statsService
+ statsService,
+ clientAssertionService,
+ mtlsService
)
userInfoEndpoint = UserInfoEndpoint(authService, jwtService)
revocationEndpoint = RevocationEndpoint(
@@ -800,6 +810,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/main/scala/com/tesobe/oidc/tokens/JwtService.scala b/src/main/scala/com/tesobe/oidc/tokens/JwtService.scala
index cd9ca12..9ea75ee 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
@@ -91,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(
@@ -142,7 +146,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 +209,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")
@@ -212,7 +222,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
@@ -260,8 +271,16 @@ 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))
- signedToken = tokenWithConsent.sign(algorithm)
+ tokenWithConsent = consentId.fold(token)(cid =>
+ token.withClaim("consent_id", cid).withClaim("openbanking_intent_id", cid)
+ )
+ // 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"
@@ -275,7 +294,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
@@ -304,7 +324,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"
@@ -351,7 +374,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(
@@ -494,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/OidcProviderIntegrationTest.scala b/src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala
index 0bf794d..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, 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._
@@ -54,6 +54,11 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers {
for {
authService <- IO(MockAuthService())
codeService <- CodeService(testConfig)
+ parService <- ParService(testConfig)
+ 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()
@@ -69,23 +74,29 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers {
rateLimitService,
testConfig,
jwtService,
- consentChallengesRef
+ consentChallengesRef,
+ parService,
+ requestObjectService
)
tokenEndpoint = TokenEndpoint(
authService,
codeService,
jwtService,
testConfig,
- statsService
+ statsService,
+ clientAssertionService,
+ mtlsService
)
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 +391,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()
+ }
}
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
+ }
+}
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
+ }
+}
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
+ }
+}
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)
+ }
+}
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..7728388
--- /dev/null
+++ b/src/test/scala/com/tesobe/oidc/tokens/JwtServiceTest.scala
@@ -0,0 +1,170 @@
+/*
+ * 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()
+ }
+
+ // 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()
+ }
+}