Skip to content
Open
9 changes: 9 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<cats.effect.version>3.5.7</cats.effect.version>
<circe.version>0.14.9</circe.version>
<java.jwt.version>4.4.0</java.jwt.version>
<nimbus.jose.jwt.version>9.40</nimbus.jose.jwt.version>
<logback.version>1.2.13</logback.version>
<scalatest.version>3.2.19</scalatest.version>
<doobie.version>1.0.0-RC4</doobie.version>
Expand Down Expand Up @@ -108,6 +109,14 @@
<artifactId>java-jwt</artifactId>
<version>${java.jwt.version}</version>
</dependency>
<!-- FAPI: JWS/JWE verification for signed request objects and private_key_jwt
client assertions, and remote JWKS resolution for client public keys.
auth0 java-jwt (above) remains the RS256 issuer/signer for OBP-OIDC's own tokens. -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus.jose.jwt.version}</version>
</dependency>

<!-- Database access -->
<dependency>
Expand Down
134 changes: 134 additions & 0 deletions src/main/scala/com/tesobe/oidc/auth/ClientAssertionService.scala
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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, _))
}
12 changes: 9 additions & 3 deletions src/main/scala/com/tesobe/oidc/auth/CodeService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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("-", ""))
Expand All @@ -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))
Expand Down
57 changes: 37 additions & 20 deletions src/main/scala/com/tesobe/oidc/auth/HybridAuthService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -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] = {
Expand Down Expand Up @@ -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
Expand All @@ -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] = {
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -1876,7 +1884,8 @@ object DatabaseUserInstances {
Option[String],
Option[String],
Option[String],
Option[Boolean]
Option[Boolean],
Option[String]
)
]
.map {
Expand All @@ -1899,7 +1908,8 @@ object DatabaseUserInstances {
company,
key_c,
consumerid,
isactive
isactive,
jwksuri
) =>
AdminDatabaseClient(
name = name,
Expand All @@ -1920,7 +1930,8 @@ object DatabaseUserInstances {
company = company,
key_c = key_c,
consumerid = consumerid,
isactive = isactive
isactive = isactive,
jwksuri = jwksuri
)
}

Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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
)
}
}
Loading