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() + } +}