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
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -568,8 +568,8 @@ class Boot extends MdcLoggable {
// DirectLogin (POST /my/logins/direct) and aliveCheck (GET /alive) are now served
// by their native http4s counterparts wired into Http4sApp.baseServices
// (DirectLoginRoutes / AliveCheckRoutes). The Lift dispatches were retired in the
// http4s migration; any prop gates (e.g. `allow_direct_login`) live with those
// routes. The OBP-as-relying-party OpenID Connect callback was removed: OBP is a
// http4s migration; any prop gates live with those routes (`allow_direct_login` is
// checked in DirectLoginRoutes.issueToken, which the v6.0.0 route also uses). The OBP-as-relying-party OpenID Connect callback was removed: OBP is a
// pure OAuth2 resource server (Bearer-JWT validation), login is done by the client/BFF.

//////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down
44 changes: 28 additions & 16 deletions obp-api/src/main/scala/code/api/DirectLoginRoutes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -78,27 +78,39 @@ object DirectLoginRoutes {
}.toMap
}

private val directLoginAllowed: Boolean =
code.api.util.APIUtil.getPropsAsBoolValue("allow_direct_login", true)
/**
* This checks DirectLogin credentials and issues a token. It is the one implementation behind both the
* unversioned POST /my/logins/direct and the versioned POST /obp/v6.0.0/my/logins/direct, so the two cannot drift.
*
* When allow_direct_login is false it refuses before reading any credentials, so the endpoint neither checks
* passwords nor stores tokens on an instance where DirectLogin is turned off. The prop is read on each request.
*/
def issueToken(cc: CallContext): Future[code.api.v6_0_0.TokenJSON] = {
val directLoginAllowed = code.api.util.APIUtil.getPropsAsBoolValue("allow_direct_login", true)
for {
_ <- code.util.Helper.booleanToFuture(code.api.util.ErrorMessages.DirectLoginIsDisabled, failCode = 403, cc = Some(cc)) {
directLoginAllowed
}
// If the parser found nothing usable, fall back to a single "error" key so that
// validatorFutureWithParams returns the MissingDirectLoginHeader message.
parsed = parseDirectLoginParams(cc)
params = if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader) else parsed
triple <- DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params)
(httpCode, message, dlParams) = triple
tokenTriple = DirectLogin.createTokenCommonPart(httpCode, message, dlParams)
_ <- Future(DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3))
} yield {
if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2)
else code.api.util.APIUtil.unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1)
}
}

val routes: HttpRoutes[IO] = if (!directLoginAllowed) HttpRoutes.empty[IO] else HttpRoutes.of[IO] {
val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> Root / "my" / "logins" / "direct" =>
Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc =>
val reqWithCC = req.withAttribute(Http4sRequestAttributes.callContextKey, cc)
EndpointHelpers.executeFutureCreated(reqWithCC) {
val parsed = parseDirectLoginParams(cc)
val params =
if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader)
else parsed
for {
triple <- DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params)
(httpCode, message, dlParams) = triple
tokenTriple = DirectLogin.createTokenCommonPart(httpCode, message, dlParams)
_ <- Future(DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3))
} yield {
if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2)
else code.api.util.APIUtil.unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1)
}
issueToken(cc)
}
}
}
Expand Down
11 changes: 10 additions & 1 deletion obp-api/src/main/scala/code/api/OAuth2.scala
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,20 @@ object OAuth2Login extends MdcLoggable {
*/
def getOrCreateConsumer(jwtToken: String, userId: Box[String], description: Option[String]): Box[Consumer] = {
val aud = Some(JwtUtil.getAudience(jwtToken).mkString(","))
val azp = getClaim(name = "azp", jwtToken = jwtToken)
// azp identifies the OAuth client. Some IdPs put the client only in client_id (RFC 9068),
// e.g. on client_credentials tokens, so fall back to that.
val azp = getClaim(name = "azp", jwtToken = jwtToken).filter(_.nonEmpty)
.orElse(getClaim(name = "client_id", jwtToken = jwtToken).filter(_.nonEmpty))
val iss = getClaim(name = "iss", jwtToken = jwtToken)
val sub = getClaim(name = "sub", jwtToken = jwtToken)
val email = getClaim(name = "email", jwtToken = jwtToken)
val name = getClaim(name = "name", jwtToken = jwtToken).orElse(description)
// Without a client identifier no lookup can ever match, so every request would auto-create
// a new Consumer. Refuse instead.
if (azp.isEmpty) {
logger.warn(s"getOrCreateConsumer says: token from iss=${iss.getOrElse("None")} sub=${sub.getOrElse("None")} has neither azp nor client_id claim; not creating a Consumer")
return Failure(s"$Oauth2TokenHaveNoConsumer The token has neither an azp nor a client_id claim.")
}
Consumers.consumers.vend.getOrCreateConsumer(
consumerId = None,
key = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2994,7 +2994,7 @@ object SwaggerDefinitionsJSON {

lazy val consumerJsonV600: ConsumerJsonV600 = ConsumerJsonV600(
consumer_id = consumerIdExample.value,
consumer_key = consumerKeyExample.value,
consumer_key = code.api.util.APIUtil.RedactedConsumerKey,
app_name = appNameExample.value,
app_type = appTypeExample.value,
description = descriptionExample.value,
Expand Down Expand Up @@ -6126,7 +6126,7 @@ object SwaggerDefinitionsJSON {
)

lazy val consumersJsonV510 = ConsumersJsonV510(
List(consumerJsonV510)
List(consumerJsonV510.copy(consumer_key = code.api.util.APIUtil.RedactedConsumerKey))
)

lazy val agentIdJson = AgentCashWithdrawalJson(
Expand Down
30 changes: 30 additions & 0 deletions obp-api/src/main/scala/code/api/cache/Redis.scala
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,36 @@ object Redis extends MdcLoggable {
(result.get(0).longValue(), result.get(1).longValue())
}

/**
* HSET field to value in KEYS[1] only when the field is absent or holds a smaller number, so
* writers that arrive out of order (several nodes, async writes) never move a value backwards.
* Also SET KEYS[2] to the same value if it does not exist yet: it records when the hash started
* filling, which is when its values became meaningful (the hash is empty after a Redis restart).
*/
private val hashSetIfGreaterScript =
"""local current = redis.call('HGET', KEYS[1], ARGV[1])
|if (not current) or tonumber(current) < tonumber(ARGV[2]) then
| redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|end
|redis.call('SET', KEYS[2], ARGV[2], 'NX')
|return 1""".stripMargin

/** See hashSetIfGreaterScript. One round trip, atomic server-side. */
def hashSetIfGreater(hashKey: String, sinceKey: String, field: String, value: Long): Unit = withJedis { jedis =>
jedis.eval(
hashSetIfGreaterScript,
java.util.Arrays.asList(hashKey, sinceKey),
java.util.Arrays.asList(field, value.toString)
)
()
}

/** HGETALL; an absent key is an empty map. */
def hashGetAll(hashKey: String): Map[String, String] = withJedis { jedis =>
import scala.jdk.CollectionConverters._
jedis.hgetAll(hashKey).asScala.toMap
}

/**
* Delete all Redis keys matching a pattern using KEYS command
* @param pattern Redis key pattern (e.g., "rl_active_CONSUMER123_*")
Expand Down
8 changes: 8 additions & 0 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2584,6 +2584,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
case _ =>
// There are no enough information for logging
}
callContext.foreach(cc => WriteMetricUtil.recordConsumerLastCall(cc.consumerId, cc.startTime))
WriteMetricUtil.writeEndpointMetric(responseBody, callContext.map(_.copy(endTime = Some(endTime))))
result
}
Expand Down Expand Up @@ -3883,6 +3884,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
else value
}

// A consumer key is a credential. Response formats that carry a consumer_key field from before
// this rule keep the field and return this value; newer versions leave the field out.
// Create responses still return the real key: that is how the creator learns it.
val RedactedConsumerKey = "**redacted**"

def redactConsumerKey(consumerKey: String): String = RedactedConsumerKey

// Convention-based public app URL props.
// Every key registered here appears in getConfigPropsPairs and so in the App Directory.
// The set is fixed in code: an operator-added public_*_url prop is NOT picked up
Expand Down
4 changes: 3 additions & 1 deletion obp-api/src/main/scala/code/api/util/ApiRole.scala
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,9 @@ object ApiRole extends MdcLoggable{
case class CanDeleteUserAuthContext(requiresBankId: Boolean = false) extends ApiRole
lazy val canDeleteUserAuthContext = CanDeleteUserAuthContext()

case class CanCreateUserAuthContextUpdate(requiresBankId: Boolean = false) extends ApiRole
// Starting a User Auth Context Update (which emails a challenge to a Customer of the bank). Held either by the
// App, as a Scope, or by the User, as an Entitlement, always at one bank.
case class CanCreateUserAuthContextUpdate(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateUserAuthContextUpdate = CanCreateUserAuthContextUpdate()

case class CanGetTaxResidence(requiresBankId: Boolean = true) extends ApiRole
Expand Down
6 changes: 2 additions & 4 deletions obp-api/src/main/scala/code/api/util/ConsentUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,8 @@ object Consent extends MdcLoggable {
if(requestConsumerId == "NONE" || consumerValidationMethodForConsent.isEmpty) {
logger.warn(s"consumer_validation_method_for_consent is empty while request consumer_id=NONE - consent_id=${consent.jti}, aud=${consent.aud}")
}
// Get consumer keys for debugging
val consentConsumerKey = Consumers.consumers.vend.getConsumerByConsumerId(consentConsumerId).map(_.key.get).getOrElse("Unknown")
val requestConsumerKey = callContext.consumer.map(_.key.get).getOrElse("None")
val detailedErrorMsg = s"${ErrorMessages.ConsentNotFound} Consumer mismatch: consent has consumer_id='$consentConsumerId' (consumer_key='$consentConsumerKey'), but current request has consumer_id='$requestConsumerId' (consumer_key='$requestConsumerKey')"
// Consumers are identified by consumer_id: the key is a credential and never goes into a log
val detailedErrorMsg = s"${ErrorMessages.ConsentNotFound} Consumer mismatch: consent has consumer_id='$consentConsumerId', but current request has consumer_id='$requestConsumerId'"
logger.debug(s"ConsentNotFound: TPP/Consumer mismatch. Consent holder consumer_id=$consentConsumerId, Request consumer_id=$requestConsumerId, consent_id=${consent.jti}")
logger.debug(s"ConsentNotFound: $detailedErrorMsg")
ErrorUtil.apiFailureToBox(ErrorMessages.ConsentNotFound, 401)(Some(callContext))
Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ object ErrorMessages {
val InvalidDAuthHeaderToken = "OBP-20072: DAuth Header value should be one single string."
val DAuthIsDisabled = "OBP-20407: DAuth is disabled. Set allow_dauth=true to enable."
val OpenIDConnectIsDisabled = "OBP-20408: OpenID Connect is disabled. Set allow_openid_connect=true to enable."
val DirectLoginIsDisabled = "OBP-20409: DirectLogin is disabled. Set allow_direct_login=true to enable."

val InvalidProviderUrl = "OBP-20079: Cannot match the local identity provider."

Expand Down
Loading
Loading