Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,30 @@ display_internal_errors=false

# After setting the above and restarting the server curl -s http://localhost:8080/obp/v5.1.0/well-known
# should advertise obp-oidc

# Sender-constrained (certificate-bound) access tokens - FAPI / RFC 8705.
# A FAPI-grade authorization server (e.g. Keycloak with "certificate-bound access
# tokens" enabled per client) stamps cnf.x5t#S256 (the SHA-256 thumbprint of the
# client certificate) into the access token. This prop controls whether OBP, as the
# resource server, verifies that claim against the certificate the caller actually
# presented (as resolved by PeerTrust: the direct TLS peer, or one forwarded by a
# trusted proxy - see mtls.* props). A stolen bearer token then cannot be replayed
# from a different TLS client.
#
# Modes (rollout ladder - move down as your estate migrates):
# NONE (default) no checking; behaviour identical to before this prop existed
# MONITOR check tokens that carry cnf.x5t#S256, log mismatches, never reject
# ENFORCE reject bound tokens that do not match (or arrive without a client
# certificate); tokens WITHOUT a cnf claim still pass, so first-party
# apps on plain bearer tokens keep working while TPP clients are bound
# REQUIRED every OAuth2 token (access and id) must be bound and match - full FAPI
# posture, for instances dedicated to the TPP channel
#
# Which clients receive bound tokens is decided per client in the authorization
# server, so app-by-app migration control lives there; this prop is the server-side
# gate. DPoP (cnf.jkt) is not yet supported. An invalid value logs an error and
# behaves as NONE.
#oauth2.token_binding.mode=NONE
# -----------------------------------------------------------------------------------


Expand Down
12 changes: 12 additions & 0 deletions obp-api/src/main/scala/code/api/OAuth2.scala
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,12 @@ object OAuth2Login extends MdcLoggable {
validateIdToken(token) match {
case Full(_) =>
logger.debug("applyIdTokenRules - ID token validation successful")
// Also enforced on the ID-token login path: in REQUIRED mode an unbound ID token
// must not be a bypass around certificate-bound access tokens.
TokenBinding.verifyTokenBinding(token, cc) match {
case failure: Failure => return (failure, Some(cc))
case _ => // binding ok, or checking not enabled
}
validateAudience(token) match {
case Full(_) =>
val user = getOrCreateResourceUser(token)
Expand Down Expand Up @@ -586,6 +592,12 @@ object OAuth2Login extends MdcLoggable {

validateAccessToken(token) match {
case Full(_) =>
// FAPI / RFC 8705 sender-constrained token check (oauth2.token_binding.mode).
// Runs after signature validation so the cnf claim can be trusted.
TokenBinding.verifyTokenBinding(token, cc) match {
case failure: Failure => return (failure, Some(cc))
case _ => // binding ok, or checking not enabled
}
validateAudience(token) match {
case Full(_) =>
val user = getOrCreateResourceUser(token)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3208,6 +3208,16 @@ object SwaggerDefinitionsJSON {
lazy val metricsJsonV600 = MetricsJsonV600(
metrics = List(metricJsonV600)
)
lazy val aggregateMetricJsonV600 = AggregateMetricJsonV600(
count = 7076,
average_response_time = 65.21,
minimum_response_time = 1,
maximum_response_time = 9039,
distinct_user_count = 41,
distinct_consumer_count = 12,
consent_call_count = 1024,
distinct_consent_count = 9
)

lazy val branchJsonPut = BranchJsonPutV210("gh.29.fi", "OBP",
addressJsonV140,
Expand Down
34 changes: 27 additions & 7 deletions obp-api/src/main/scala/code/api/util/ApiSession.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,25 @@ case class CallContext(
dauthRequestPayload: Option[JSONFactoryDAuth.PayloadOfJwtJSON] = None, //Never update these values inside the case class !!!
dauthResponseHeader: Option[String] = None,
spelling: Option[String] = None,
// The AUTHENTICATED principal. Not always a person: under a consent this is the
// consent's own shadow user. Stored data (metric rows, created_by_user_id columns)
// always records this id — the human is resolved at read time via the consent table.
user: Box[User] = Empty,
// The human who CREATED the consent this request runs under. Populated only by the
// OBP-native consent path (applyConsentRulesCommon) from the consent JWT's
// createdByUserId claim, resolved against the users table. For OBP-native consents
// the creator is the granting human (they create their own consent in the Portal).
// Not set by Berlin Group / UK flows, where the consent may be created by a TPP flow
// with no human logged in — see `consenter` for those.
// Read via humanUser / effectiveHumanUserId, where it takes precedence over consenter.
onBehalfOfUser: Box[User] = Empty,
// The human (PSU) who AUTHORISED the consent this request runs under — the owner of
// record, from the consent table's userId (bound by updateConsentUser during the
// authorise ceremony). Populated by the Berlin Group and UK consent paths, whose
// consents are created by TPP flows and only gain their human at authorisation.
// The UK ownership check (checkUKConsent) compares the consent's userId against this.
// In practice onBehalfOfUser and consenter are never both set: each consent standard
// populates the one whose source is authoritative for it.
consenter: Box[User] = Empty,
consumer: Box[Consumer] = Empty,
ipAddress: String = "",
Expand Down Expand Up @@ -94,7 +111,9 @@ case class CallContext(
* `user` is not always a person: a consent resolves to a shadow user that exists only for that
* consent (Berlin Group, OBP-native, and -- since UK consents moved to the same model -- UK too).
* Anything that must name a human rather than a principal reads this instead: the CBS adapter,
* which tells the core banking system who is asking, and metric attribution.
* which tells the core banking system who is asking, and the consent ownership checks.
* Stored data (metric rows included) always carries the authenticated principal; the human is
* resolved at read time via the consent table (see effectiveHumanUserId).
*/
def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user)

Expand Down Expand Up @@ -159,12 +178,13 @@ case class CallContext(
CallContextLight(
gatewayLoginRequestPayload = this.gatewayLoginRequestPayload,
gatewayLoginResponseHeader = this.gatewayLoginResponseHeader,
// Metrics name the human, not the principal. A consent's shadow user would record a per-consent
// UUID and an empty username, which is what Berlin Group and OBP-native traffic has always
// looked like on the metrics table; the consent itself stays identifiable via
// consentReferenceId below.
userId = this.humanUser.map(_.userId).toOption,
userName = this.humanUser.map(_.name).toOption,
// Like for like with CallContext: userId/userName are the AUTHENTICATED principal
// (CallContext.user), never a resolved human. Under a consent that principal is the
// consent's own shadow user (a per-consent UUID with an empty name) — the on-behalf-of
// human is not stored here but resolved at read time via the consent table
// (consentReferenceId below -> consent.userId), see CallContext.effectiveHumanUserId.
userId = this.user.map(_.userId).toOption,
userName = this.user.map(_.name).toOption,
consumerId = this.consumer.map(_.consumerId.get).toOption,
appName = this.consumer.map(_.name.get).toOption,
developerEmail = this.consumer.map(_.developerEmail.get).toOption,
Expand Down
6 changes: 6 additions & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ object ErrorMessages {
val InvalidSignalChannelName = "OBP-10057: Invalid Signal Channel name. " +
"Signal Channel names must use only alphanumeric characters, dots, hyphens, and underscores, " +
"and be between 1 and 128 characters long."
val UserFilterParametersNotSupported = "OBP-10058: User identity filter parameters (user_id, username, email, provider_provider_id, anon) " +
"are not supported on this endpoint. It only ever returns the logged in user's own records. "



Expand Down Expand Up @@ -324,6 +326,10 @@ object ErrorMessages {
val DuplicateUsername = "OBP-20258: Duplicate Username. Cannot create Username because it already exists. "
val ExternalUserCheckFailed = "OBP-20259: Could not check username uniqueness against the external provider. The Connector or Adapter may not be running. "

val Oauth2TokenBindingCertificateMissing = "OBP-20260: The access token is certificate-bound (cnf.x5t#S256) but no client certificate was presented with the request. "
val Oauth2TokenBindingCertificateMismatch = "OBP-20261: The presented client certificate does not match the certificate binding (cnf.x5t#S256) of the access token. "
val Oauth2TokenBindingRequired = "OBP-20262: This instance requires certificate-bound access tokens (oauth2.token_binding.mode=REQUIRED) but the token carries no cnf.x5t#S256 claim. "


// X.509
val X509GeneralError = "OBP-20300: PEM Encoded Certificate issue."
Expand Down
3 changes: 3 additions & 0 deletions obp-api/src/main/scala/code/api/util/OBPParam.scala
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ case class OBPConsentReferenceId(value: String) extends OBPQueryParam
// PeerTrust.Resolution.mode on the metric row: "direct", "forwarded" or "none".
case class OBPCertificateTrust(value: String) extends OBPQueryParam
case class OBPUserId(value: String) extends OBPQueryParam
// Multiple user ids, matched with SQL IN — used by self-service endpoints that lock the
// user filter to a server-resolved set (e.g. /my/metrics: the human plus their consent-agents).
case class OBPUserIds(values: List[String]) extends OBPQueryParam
case class ProviderProviderId(value: String) extends OBPQueryParam
case class OBPStatus(value: String) extends OBPQueryParam
case class OBPBankId(value: String) extends OBPQueryParam
Expand Down
130 changes: 130 additions & 0 deletions obp-api/src/main/scala/code/api/util/TokenBinding.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package code.api.util

import java.security.MessageDigest
import java.security.cert.X509Certificate
import java.util.Base64

import code.api.util.APIUtil.`getPSD2-CERT`
import code.api.util.ErrorMessages._
import code.util.Helper.MdcLoggable
import com.nimbusds.jwt.SignedJWT
import net.liftweb.common.{Box, Failure, Full}
import net.liftweb.util.Helpers.tryo

/**
* Sender-constrained (certificate-bound) access token verification — RFC 8705, as required
* by FAPI at the resource server. A FAPI-grade authorization server (e.g. Keycloak with
* certificate-bound access tokens enabled per client) stamps the confirmation claim
* `cnf.x5t#S256` = base64url(SHA-256(DER of the client certificate)) into the access token.
* This object compares that claim against the certificate the caller actually presented,
* so a stolen bearer token cannot be replayed from a different TLS client.
*
* The caller certificate is whatever [[PeerTrust]] resolved for this request (the direct TLS
* peer, or one forwarded by a trusted proxy) — delivered via the PSD2-CERT request header, the
* same channel the PSD2 certificate checks use. The check therefore inherits the mtls.* trust
* configuration and never trusts an unverified forwarded header.
*
* Gated by the oauth2.token_binding.mode Props value:
* - NONE (default): no checking — existing deployments are untouched.
* - MONITOR: check tokens that carry cnf.x5t#S256 and log mismatches, but never reject.
* - ENFORCE: reject bound tokens that do not match (or arrive with no certificate);
* tokens without a cnf claim still pass, so a mixed estate can migrate app by app.
* - REQUIRED: every OAuth2 token must be bound and match — full FAPI posture.
*
* DPoP (cnf.jkt, the FAPI 2.0 alternative binding) is not yet supported.
*/
object TokenBinding extends MdcLoggable {

final val ModePropsName = "oauth2.token_binding.mode"

object Mode extends Enumeration {
val NONE, MONITOR, ENFORCE, REQUIRED = Value
}

/**
* The configured mode. An unrecognised value cannot be allowed to silently harden or soften
* the instance, so it logs loudly and behaves as NONE — the same behaviour as before the
* prop existed.
*/
def configuredMode: Mode.Value = {
val raw = APIUtil.getPropsValue(ModePropsName, Mode.NONE.toString).trim.toUpperCase
Mode.values.find(_.toString == raw).getOrElse {
logger.error(s"$ModePropsName has invalid value '$raw' (valid values: ${Mode.values.mkString(", ")}). " +
s"Falling back to ${Mode.NONE} — token binding is NOT being checked.")
Mode.NONE
}
}

/** base64url without padding of SHA-256 over the certificate's DER encoding (x5t#S256). */
def x5tS256(certificate: X509Certificate): String =
Base64.getUrlEncoder.withoutPadding.encodeToString(
MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded))

/**
* The cnf.x5t#S256 claim of a JWT, if present. The token's signature must already have been
* validated by the caller — this only parses claims.
*/
def cnfX5tS256(jwtToken: String): Option[String] =
tryo(SignedJWT.parse(jwtToken).getJWTClaimsSet.getJSONObjectClaim("cnf")).toOption
.flatMap(Option(_))
.flatMap(cnf => Option(cnf.get("x5t#S256")))
.map(_.toString)
.filter(_.nonEmpty)

/**
* The pure decision — mode and inputs passed explicitly so it is testable without Props or
* a server. Returns Full(()) when the request may proceed.
*/
def verify(
mode: Mode.Value,
cnfThumbprint: Option[String],
callerCertificate: Option[X509Certificate],
logContext: => String
): Box[Unit] = {
(mode, cnfThumbprint, callerCertificate) match {
case (Mode.NONE, _, _) =>
Full(())
case (Mode.REQUIRED, None, _) =>
Failure(Oauth2TokenBindingRequired)
case (_, None, _) => // MONITOR / ENFORCE: an unbound token passes untouched
Full(())
case (Mode.MONITOR, Some(_), None) =>
logger.warn(s"TOKEN BINDING MONITOR: token is certificate-bound (cnf.x5t#S256) " +
s"but no client certificate was presented. $logContext")
Full(())
case (_, Some(_), None) => // ENFORCE / REQUIRED
Failure(Oauth2TokenBindingCertificateMissing)
case (m, Some(expected), Some(certificate)) =>
val presented = x5tS256(certificate)
// constant-time comparison — thumbprints are secrets-adjacent
val matches = MessageDigest.isEqual(expected.getBytes("UTF-8"), presented.getBytes("UTF-8"))
if (matches) Full(())
else if (m == Mode.MONITOR) {
logger.warn(s"TOKEN BINDING MONITOR: thumbprint mismatch — token cnf.x5t#S256=$expected, " +
s"presented certificate=$presented. $logContext")
Full(())
} else {
Failure(Oauth2TokenBindingCertificateMismatch)
}
}
}

/**
* Props-driven entry point for the OAuth2 login path. Call after the token's signature has
* been validated.
*/
def verifyTokenBinding(jwtToken: String, cc: CallContext): Box[Unit] = {
val mode = configuredMode
if (mode == Mode.NONE) Full(())
else {
val callerCertificate: Option[X509Certificate] = `getPSD2-CERT`(cc.requestHeaders)
.flatMap(pem => tryo(BerlinGroupSigning.parseCertificate(pem)).toOption)
verify(
mode,
cnfX5tS256(jwtToken),
callerCertificate,
s"url=${cc.url} correlationId=${cc.correlationId}"
)
}
}
}
Loading
Loading