OBP-API/obp-api/src/main/scala/code/api/OAuth2.scala
2026-01-06 15:48:02 +01:00

732 lines
40 KiB
Scala

/**
Open Bank Project - API
Copyright (C) 2011-2019, TESOBE GmbH.
This program 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.
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH.
Osloer Strasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
package code.api
import code.api.util.ErrorMessages._
import code.api.util._
import code.consumer.Consumers
import code.consumer.Consumers.consumers
import code.loginattempts.LoginAttempt
import code.model.{AppType, Consumer}
import code.scope.Scope
import code.users.Users
import code.util.Helper.MdcLoggable
import code.util.HydraUtil
import code.util.HydraUtil._
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.User
import net.liftweb.common.Box.tryo
import net.liftweb.common._
import net.liftweb.http.rest.RestHelper
import net.liftweb.util.Helpers
import org.apache.commons.lang3.StringUtils
import sh.ory.hydra.model.OAuth2TokenIntrospection
import java.net.URI
import scala.concurrent.Future
import scala.collection.JavaConverters._
/**
* This object provides the API calls necessary to third party applications
* so they could authenticate their users.
*/
object OAuth2Login extends RestHelper with MdcLoggable {
private def getValueOfOAuh2HeaderField(sc: CallContext) = {
val valueOfAuthReqHeaderField = sc.authReqHeaderField.getOrElse("")
.replaceAll("Authorization:", "")
.replaceAll("Bearer", "")
.trim()
valueOfAuthReqHeaderField
}
/*
Method for Old Style Endpoints
*/
def getUser(cc: CallContext): (Box[User], Option[CallContext]) = {
APIUtil.getPropsAsBoolValue("allow_oauth2_login", true) match {
case true =>
val value = getValueOfOAuh2HeaderField(cc)
if (Google.isIssuer(value)) {
Google.applyIdTokenRules(value, cc)
} else if (Yahoo.isIssuer(value)) {
Yahoo.applyIdTokenRules(value, cc)
} else if (Azure.isIssuer(value)) {
Azure.applyIdTokenRules(value, cc)
} else if (Keycloak.isIssuer(value)) {
Keycloak.applyRules(value, cc)
} else if (UnknownProvider.isIssuer(value)) {
UnknownProvider.applyRules(value, cc)
} else if (HydraUtil.integrateWithHydra) {
Hydra.applyRules(value, cc)
} else {
(Failure(Oauth2IsNotRecognized), Some(cc))
}
case false =>
(Failure(Oauth2IsNotAllowed), Some(cc))
}
}
/*
Method for New Style Endpoints
*/
def getUserFuture(cc: CallContext): Future[(Box[User], Option[CallContext])] = {
APIUtil.getPropsAsBoolValue("allow_oauth2_login", true) match {
case true =>
val value = getValueOfOAuh2HeaderField(cc)
if (Google.isIssuer(value)) {
Google.applyIdTokenRulesFuture(value, cc)
} else if (Yahoo.isIssuer(value)) {
Yahoo.applyIdTokenRulesFuture(value, cc)
} else if (Azure.isIssuer(value)) {
Azure.applyIdTokenRulesFuture(value, cc)
} else if (OBPOIDC.isIssuer(value)) {
logger.debug("getUserFuture says: I will call OBPOIDC.applyIdTokenRulesFuture")
OBPOIDC.applyIdTokenRulesFuture(value, cc)
} else if (Keycloak.isIssuer(value)) {
Keycloak.applyRulesFuture(value, cc)
} else if (UnknownProvider.isIssuer(value)) {
UnknownProvider.applyRulesFuture(value, cc)
} else if (HydraUtil.integrateWithHydra) {
Hydra.applyRulesFuture(value, cc)
} else {
Future(Failure(Oauth2IsNotRecognized), Some(cc))
}
case false =>
Future((Failure(Oauth2IsNotAllowed), Some(cc)))
}
}
object Hydra extends OAuth2Util {
override def wellKnownOpenidConfiguration: URI = new URI(hydraPublicUrl)
override def urlOfJwkSets: Box[String] = checkUrlOfJwkSets(identityProvider = hydraPublicUrl)
override def applyAccessTokenRules(value: String, cc: CallContext): (Box[User], Some[CallContext]) = {
// In case of Hydra issued access tokens are not self-encoded/self-contained like JWT tokens are.
// It implies the access token can be revoked at any time.
val introspectOAuth2Token: OAuth2TokenIntrospection = hydraAdmin.introspectOAuth2Token(value, null)
val hydraClient = hydraAdmin.getOAuth2Client(introspectOAuth2Token.getClientId())
var consumer: Box[Consumer] = consumers.vend.getConsumerByConsumerKey(introspectOAuth2Token.getClientId)
logger.debug("introspectOAuth2Token.getIss: " + introspectOAuth2Token.getIss)
logger.debug("introspectOAuth2Token.getActive: " + introspectOAuth2Token.getActive)
logger.debug("introspectOAuth2Token.getClientId: " + introspectOAuth2Token.getClientId)
logger.debug("introspectOAuth2Token.getAud: " + introspectOAuth2Token.getAud)
logger.debug("introspectOAuth2Token.getUsername: " + introspectOAuth2Token.getUsername)
logger.debug("introspectOAuth2Token.getExp: " + introspectOAuth2Token.getExp)
logger.debug("introspectOAuth2Token.getNbf: " + introspectOAuth2Token.getNbf)
// The access token can be disabled at any time due to fact it is NOT self-encoded/self-contained.
if (!introspectOAuth2Token.getActive) {
return (Failure(Oauth2IJwtCannotBeVerified), Some(cc.copy(consumer = Failure(Oauth2IJwtCannotBeVerified))))
}
if (!hydraSupportedTokenEndpointAuthMethods.contains(hydraClient.getTokenEndpointAuthMethod())) {
logger.debug("hydraClient.getTokenEndpointAuthMethod(): " + hydraClient.getTokenEndpointAuthMethod().toLowerCase())
val errorMessage = Oauth2TokenEndpointAuthMethodForbidden + hydraClient.getTokenEndpointAuthMethod()
return (Failure(errorMessage), Some(cc.copy(consumer = Failure(errorMessage))))
}
// check access token binding with client certificate
{
if(consumer.isEmpty) {
return (Failure(Oauth2TokenHaveNoConsumer), Some(cc.copy(consumer = Failure(Oauth2TokenHaveNoConsumer))))
}
val clientCert: Option[String] = APIUtil.`getPSD2-CERT`(cc.requestHeaders)
clientCert.filter(StringUtils.isNotBlank).foreach {cert =>
val foundConsumer = consumer.orNull
val certInConsumer = foundConsumer.clientCertificate.get
if(StringUtils.isBlank(certInConsumer)) {
// In case that the certificate of a consumer is not populated in a database
// we use the value at PSD2-CERT header in order to populate it for the first time.
// Please note that every next call MUST match that value.
foundConsumer.clientCertificate.set(cert)
consumer = Full(foundConsumer.saveMe())
val clientId = foundConsumer.key.get
// update hydra client client_certificate
val oAuth2Client = hydraAdmin.getOAuth2Client(clientId)
val clientMeta = oAuth2Client.getMetadata.asInstanceOf[java.util.Map[String, AnyRef]]
if(clientMeta == null) {
oAuth2Client.setMetadata(Map("client_certificate" -> cert).asJava)
} else {
clientMeta.put("client_certificate", cert)
}
// hydra update client endpoint have bug, So here delete and create to do update
hydraAdmin.deleteOAuth2Client(clientId)
hydraAdmin.createOAuth2Client(oAuth2Client)
} else if(!CertificateUtil.comparePemX509Certificates(certInConsumer, cert)) {
// Cannot mat.ch the value from PSD2-CERT header and the database value Consumer.clientCertificate
logger.debug(s"Cert in Consumer with the name ***${foundConsumer.name}*** : " + certInConsumer)
logger.debug("Cert in Request: " + cert)
logger.debug(s"Token: $value")
logger.debug(s"Client ID: ${introspectOAuth2Token.getClientId}")
return (Failure(Oauth2TokenMatchCertificateFail), Some(cc.copy(consumer = Failure(Oauth2TokenMatchCertificateFail))))
} else {
// Certificate is matched. Just make some debug logging.
logger.debug("The token is linked with a proper client certificate.")
logger.debug(s"Token: $value")
logger.debug(s"Client Key: ${introspectOAuth2Token.getClientId}")
}
}
}
// In case a user is created via OpenID Connect flow implies provider = hydraPublicUrl
// In case a user is created via GUI of OBP-API implies provider = Constant.localIdentityProvider
val user = Users.users.vend.getUserByProviderAndUsername(introspectOAuth2Token.getIss, introspectOAuth2Token.getSub).or(
Users.users.vend.getUserByProviderAndUsername(Constant.localIdentityProvider, introspectOAuth2Token.getSub)
)
user match {
case Full(u) =>
LoginAttempt.userIsLocked(u.provider, u.name) match {
case true => (Failure(UsernameHasBeenLocked), Some(cc.copy(consumer = consumer)))
case false => (Full(u), Some(cc.copy(consumer = consumer)))
}
case _ => (user, Some(cc.copy(consumer = consumer)))
}
}
def applyRules(token: String, cc: CallContext): (Box[User], Some[CallContext]) = {
isIssuer(jwtToken=token, identityProvider = hydraPublicUrl) match {
case true => super.applyIdTokenRules(token, cc)
case false => applyAccessTokenRules(token, cc)
}
}
def applyRulesFuture(value: String, cc: CallContext): Future[(Box[User], Some[CallContext])] = Future {
applyRules(value, cc)
}
}
trait OAuth2Util {
def wellKnownOpenidConfiguration: URI
def urlOfJwkSets: Box[String] = Constant.oauth2JwkSetUrl
/**
* Get all JWKS URLs from configuration.
* This is a helper method for trying multiple JWKS URLs when validating tokens.
* We need more than one JWKS URL if we have multiple OIDC providers configured etc.
* @return List of all configured JWKS URLs
*/
protected def getAllJwksUrls: List[String] = {
val url: List[String] = Constant.oauth2JwkSetUrl.toList
url.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty)
}
/**
* Try to validate a JWT token with multiple JWKS URLs.
* This is a generic retry mechanism that works for both ID tokens and access tokens.
*
* @param token The JWT token to validate
* @param tokenType Description of token type for logging (e.g., "ID token", "access token")
* @param validateFunc Function that validates token against a JWKS URL
* @tparam T The type of claims returned (IDTokenClaimsSet or JWTClaimsSet)
* @return Boxed claims or failure
*/
protected def tryValidateWithAllJwksUrls[T](
token: String,
tokenType: String,
validateFunc: (String, String) => Box[T]
): Box[T] = {
logger.debug(s"tryValidateWithAllJwksUrls - attempting to validate $tokenType")
// Extract issuer for better error reporting
val actualIssuer = JwtUtil.getIssuer(token).getOrElse("NO_ISSUER_CLAIM")
logger.debug(s"tryValidateWithAllJwksUrls - JWT issuer claim: '$actualIssuer'")
// Get all JWKS URLs
val allJwksUrls = getAllJwksUrls
if (allJwksUrls.isEmpty) {
logger.debug(s"tryValidateWithAllJwksUrls - No JWKS URLs configured")
return Failure(Oauth2ThereIsNoUrlOfJwkSet)
}
logger.debug(s"tryValidateWithAllJwksUrls - Will try ${allJwksUrls.size} JWKS URL(s): $allJwksUrls")
// Try each JWKS URL until one succeeds
val results = allJwksUrls.map { url =>
logger.debug(s"tryValidateWithAllJwksUrls - Trying JWKS URL: '$url'")
val result = validateFunc(token, url)
result match {
case Full(_) =>
logger.debug(s"tryValidateWithAllJwksUrls - SUCCESS with JWKS URL: '$url'")
case Failure(msg, _, _) =>
logger.debug(s"tryValidateWithAllJwksUrls - FAILED with JWKS URL: '$url', reason: $msg")
case _ =>
logger.debug(s"tryValidateWithAllJwksUrls - FAILED with JWKS URL: '$url'")
}
result
}
// Return the first successful result, or the last failure
results.find(_.isDefined).getOrElse {
logger.debug(s"tryValidateWithAllJwksUrls - All ${allJwksUrls.size} JWKS URL(s) failed for issuer: '$actualIssuer'")
logger.debug(s"tryValidateWithAllJwksUrls - Tried URLs: $allJwksUrls")
results.lastOption.getOrElse(Failure(Oauth2ThereIsNoUrlOfJwkSet))
}
}
def checkUrlOfJwkSets(identityProvider: String) = {
val url: List[String] = Constant.oauth2JwkSetUrl.toList
val jwksUris: List[String] = url.map(_.toLowerCase()).map(_.split(",").toList).flatten
logger.debug(s"checkUrlOfJwkSets - identityProvider: '$identityProvider'")
logger.debug(s"checkUrlOfJwkSets - oauth2.jwk_set.url raw value: '${Constant.oauth2JwkSetUrl}'")
logger.debug(s"checkUrlOfJwkSets - parsed jwksUris: $jwksUris")
// Enhanced matching for both URL-based and semantic identifiers
val identityProviderLower = identityProvider.toLowerCase()
val jwksUri = jwksUris.filter(_.contains(identityProviderLower))
logger.debug(s"checkUrlOfJwkSets - identityProviderLower: '$identityProviderLower'")
logger.debug(s"checkUrlOfJwkSets - filtered jwksUri: $jwksUri")
jwksUri match {
case x :: _ =>
logger.debug(s"checkUrlOfJwkSets - SUCCESS: Found matching JWKS URI: '$x'")
Full(x)
case Nil =>
logger.debug(s"checkUrlOfJwkSets - FAILURE: Cannot match issuer '$identityProvider' with any JWKS URI")
logger.debug(s"checkUrlOfJwkSets - Expected issuer pattern: '$identityProvider' (case-insensitive contains match)")
logger.debug(s"checkUrlOfJwkSets - Available JWKS URIs: $jwksUris")
logger.debug(s"checkUrlOfJwkSets - Identity provider (lowercase): '$identityProviderLower'")
logger.debug(s"checkUrlOfJwkSets - Matching logic: Looking for JWKS URIs containing '$identityProviderLower'")
Failure(Oauth2CannotMatchIssuerAndJwksUriException)
}
}
def checkUrlOfJwkSetsWithToken(identityProvider: String, jwtToken: String) = {
val actualIssuer = JwtUtil.getIssuer(jwtToken).getOrElse("NO_ISSUER_CLAIM")
val url: List[String] = Constant.oauth2JwkSetUrl.toList
val jwksUris: List[String] = url.map(_.toLowerCase()).map(_.split(",").toList).flatten
logger.debug(s"checkUrlOfJwkSetsWithToken - Expected identity provider: '$identityProvider'")
logger.debug(s"checkUrlOfJwkSetsWithToken - Actual JWT issuer claim: '$actualIssuer'")
logger.debug(s"checkUrlOfJwkSetsWithToken - oauth2.jwk_set.url raw value: '${Constant.oauth2JwkSetUrl}'")
logger.debug(s"checkUrlOfJwkSetsWithToken - parsed jwksUris: $jwksUris")
// Enhanced matching for both URL-based and semantic identifiers
val identityProviderLower = identityProvider.toLowerCase()
val jwksUri = jwksUris.filter(_.contains(identityProviderLower))
logger.debug(s"checkUrlOfJwkSetsWithToken - identityProviderLower: '$identityProviderLower'")
logger.debug(s"checkUrlOfJwkSetsWithToken - filtered jwksUri: $jwksUri")
jwksUri match {
case x :: _ =>
logger.debug(s"checkUrlOfJwkSetsWithToken - SUCCESS: Found matching JWKS URI: '$x'")
Full(x)
case Nil =>
logger.debug(s"checkUrlOfJwkSetsWithToken - FAILURE: Cannot match issuer with any JWKS URI")
logger.debug(s"checkUrlOfJwkSetsWithToken - Expected identity provider: '$identityProvider'")
logger.debug(s"checkUrlOfJwkSetsWithToken - Actual JWT issuer claim: '$actualIssuer'")
logger.debug(s"checkUrlOfJwkSetsWithToken - Available JWKS URIs: $jwksUris")
logger.debug(s"checkUrlOfJwkSetsWithToken - Expected pattern (lowercase): '$identityProviderLower'")
logger.debug(s"checkUrlOfJwkSetsWithToken - Matching logic: Looking for JWKS URIs containing '$identityProviderLower'")
logger.debug(s"checkUrlOfJwkSetsWithToken - TROUBLESHOOTING:")
logger.debug(s"checkUrlOfJwkSetsWithToken - 1. Verify oauth2.jwk_set.url contains URL matching '$identityProvider'")
logger.debug(s"checkUrlOfJwkSetsWithToken - 2. Check if JWT issuer '$actualIssuer' should match identity provider '$identityProvider'")
logger.debug(s"checkUrlOfJwkSetsWithToken - 3. Ensure case-insensitive substring matching works: does any JWKS URI contain '$identityProviderLower'?")
Failure(Oauth2CannotMatchIssuerAndJwksUriException)
}
}
def getClaim(name: String, idToken: String): Option[String] = {
val claim = JwtUtil.getClaim(name = name, jwtToken = idToken)
claim match {
case null => None
case string => Some(string)
}
}
def isIssuer(jwtToken: String, identityProvider: String): Boolean = {
JwtUtil.getIssuer(jwtToken).map { issuer =>
// Direct match or contains match for backward compatibility
issuer == identityProvider || issuer.contains(identityProvider) ||
// For URL-based issuers, also try exact match ignoring trailing slash
(issuer.endsWith("/") && issuer.dropRight(1) == identityProvider) ||
(identityProvider.endsWith("/") && identityProvider.dropRight(1) == issuer)
}.getOrElse(false)
}
def validateIdToken(idToken: String): Box[IDTokenClaimsSet] = {
tryValidateWithAllJwksUrls(idToken, "ID token", JwtUtil.validateIdToken)
}
def validateAccessToken(accessToken: String): Box[JWTClaimsSet] = {
tryValidateWithAllJwksUrls(accessToken, "access token", JwtUtil.validateAccessToken)
}
/** New Style Endpoints
* This function creates user based on "iss" and "sub" fields
* It is mapped in next way:
* iss => ResourceUser.provider_
* sub => ResourceUser.providerId
* @param idToken Google's response example:
* {
* "access_token": "ya29.GluUBg5DflrJciFikW5hqeKEp9r1whWnU5x2JXCm9rKkRMs2WseXX8O5UugFMDsIKuKCZlE7tTm1fMII_YYpvcMX6quyR5DXNHH8Lbx5TrZN__fA92kszHJEVqPc",
* "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6Im5HS1JUb0tOblZBMjhINk1od1hCeHciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzA1NjkxLCJleHAiOjE1NDc3MDkyOTF9.iUxhF_SU2vi76zPuRqAKJvFOzpb_EeP3lc5u9FO9o5xoXzVq3QooXexTfK2f1YAcWEy9LSftA34PB0QTuCZpkQChZVM359n3a3hplf6oWWkBXZN2_IG10NwEH4g0VVBCsjWBDMp6lvepN_Zn15x8opUB7272m4-smAou_WmUPTeivXRF8yPcp4J55DigcY31YP59dMQr2X-6Rr1vCRnJ6niqqJ1UDldfsgt4L7dXmUCnkDdXHwEQAZwbKbR4dUoEha3QeylCiBErmLdpIyqfKECphC6piGXZB-rRRqLz41WNfuF-3fswQvGmIkzTJDR7lQaletMp7ivsfVw8N5jFxg",
* "expires_in": 3600,
* "token_type": "Bearer",
* "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
* "refresh_token": "1/HkTtUahtUTdG7D6urpPNz6g-_qufF-Y1YppcBf0v3Cs"
* }
* @return an existing or a new user
*/
def getOrCreateResourceUserFuture(idToken: String): Future[Box[User]] = {
val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken).getOrElse("")
val provider = resolveProvider(idToken)
Users.users.vend.getOrCreateUserByProviderIdFuture(
provider = provider,
idGivenByProvider = uniqueIdGivenByProvider,
consentId = None,
name = getClaim(name = "given_name", idToken = idToken).orElse(Some(uniqueIdGivenByProvider)),
email = getClaim(name = "email", idToken = idToken)
).map(_._1)
}
/** Old Style Endpoints
* This function creates user based on "iss" and "sub" fields
* It is mapped in next way:
* iss => ResourceUser.provider_
* sub => ResourceUser.providerId
* @param idToken Google's response example:
* {
* "access_token": "ya29.GluUBg5DflrJciFikW5hqeKEp9r1whWnU5x2JXCm9rKkRMs2WseXX8O5UugFMDsIKuKCZlE7tTm1fMII_YYpvcMX6quyR5DXNHH8Lbx5TrZN__fA92kszHJEVqPc",
* "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6Im5HS1JUb0tOblZBMjhINk1od1hCeHciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzA1NjkxLCJleHAiOjE1NDc3MDkyOTF9.iUxhF_SU2vi76zPuRqAKJvFOzpb_EeP3lc5u9FO9o5xoXzVq3QooXexTfK2f1YAcWEy9LSftA34PB0QTuCZpkQChZVM359n3a3hplf6oWWkBXZN2_IG10NwEH4g0VVBCsjWBDMp6lvepN_Zn15x8opUB7272m4-smAou_WmUPTeivXRF8yPcp4J55DigcY31YP59dMQr2X-6Rr1vCRnJ6niqqJ1UDldfsgt4L7dXmUCnkDdXHwEQAZwbKbR4dUoEha3QeylCiBErmLdpIyqfKECphC6piGXZB-rRRqLz41WNfuF-3fswQvGmIkzTJDR7lQaletMp7ivsfVw8N5jFxg",
* "expires_in": 3600,
* "token_type": "Bearer",
* "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
* "refresh_token": "1/HkTtUahtUTdG7D6urpPNz6g-_qufF-Y1YppcBf0v3Cs"
* }
* @return an existing or a new user
*/
def getOrCreateResourceUser(idToken: String): Box[User] = {
val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken).getOrElse("")
val provider = resolveProvider(idToken)
KeycloakFederatedUserReference.parse(uniqueIdGivenByProvider) match {
case Right(fedRef) => // Users log on via Keycloak, which uses User Federation to access the external OBP database.
logger.debug(s"External ID = ${fedRef.externalId}")
logger.debug(s"Storage Provider ID = ${fedRef.storageProviderId}")
Users.users.vend.getUserByUserId(fedRef.externalId.toString)
case Left(error) =>
logger.debug(s"Parse error: $error")
Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = uniqueIdGivenByProvider).or { // Find a user
Users.users.vend.createResourceUser( // Otherwise create a new one
provider = provider,
providerId = Some(uniqueIdGivenByProvider),
None,
name = getClaim(name = "given_name", idToken = idToken).orElse(Some(uniqueIdGivenByProvider)),
email = getClaim(name = "email", idToken = idToken),
userId = None,
createdByUserInvitationId = None,
company = None,
lastMarketingAgreementSignedDate = None
)
}
}
}
def resolveProvider(idToken: String) = {
HydraUtil.integrateWithHydra && isIssuer(jwtToken = idToken, identityProvider = hydraPublicUrl) match {
case true if HydraUtil.hydraUsesObpUserCredentials => // Case that source of the truth of Hydra user management is the OBP-API mapper DB
logger.debug("resolveProvider says: we are in Hydra ")
// In case that ORY Hydra login url is "hostname/user_mgt/login" we MUST override hydraPublicUrl as provider
// in order to avoid creation of a new user
Constant.localIdentityProvider
// if its OBPOIDC issuer
case false if OBPOIDC.isIssuer(idToken) =>
logger.debug("resolveProvider says: we are in OBPOIDC ")
Constant.localIdentityProvider
case _ => // All other cases implies a new user creation
logger.debug("resolveProvider says: Other cases ")
// TODO raise exception in case of else case
JwtUtil.getIssuer(idToken).getOrElse("")
}
}
/**
* This function creates a consumer based on "azp", "sub", "iss", "name" and "email" fields
* Please note that a user must be created before consumer.
* Unique criteria to decide do we create or get a consumer is pair o values: < sub : azp > i.e.
* We cannot find consumer by sub and azp => Create
* We can find consumer by sub and azp => Get
* @param idToken Google's response example:
* {
* "access_token": "ya29.GluUBg5DflrJciFikW5hqeKEp9r1whWnU5x2JXCm9rKkRMs2WseXX8O5UugFMDsIKuKCZlE7tTm1fMII_YYpvcMX6quyR5DXNHH8Lbx5TrZN__fA92kszHJEVqPc",
* "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6Im5HS1JUb0tOblZBMjhINk1od1hCeHciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzA1NjkxLCJleHAiOjE1NDc3MDkyOTF9.iUxhF_SU2vi76zPuRqAKJvFOzpb_EeP3lc5u9FO9o5xoXzVq3QooXexTfK2f1YAcWEy9LSftA34PB0QTuCZpkQChZVM359n3a3hplf6oWWkBXZN2_IG10NwEH4g0VVBCsjWBDMp6lvepN_Zn15x8opUB7272m4-smAou_WmUPTeivXRF8yPcp4J55DigcY31YP59dMQr2X-6Rr1vCRnJ6niqqJ1UDldfsgt4L7dXmUCnkDdXHwEQAZwbKbR4dUoEha3QeylCiBErmLdpIyqfKECphC6piGXZB-rRRqLz41WNfuF-3fswQvGmIkzTJDR7lQaletMp7ivsfVw8N5jFxg",
* "expires_in": 3600,
* "token_type": "Bearer",
* "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
* "refresh_token": "1/HkTtUahtUTdG7D6urpPNz6g-_qufF-Y1YppcBf0v3Cs"
* }
* @return an existing or a new consumer
*/
def getOrCreateConsumer(idToken: String, userId: Box[String], description: Option[String]): Box[Consumer] = {
val aud = Some(JwtUtil.getAudience(idToken).mkString(","))
val azp = getClaim(name = "azp", idToken = idToken)
val iss = getClaim(name = "iss", idToken = idToken)
val sub = getClaim(name = "sub", idToken = idToken)
val email = getClaim(name = "email", idToken = idToken)
val name = getClaim(name = "name", idToken = idToken).orElse(description)
val consumerId = if(APIUtil.checkIfStringIsUUID(azp.getOrElse(""))) azp else Some(s"{$azp}_${APIUtil.generateUUID()}")
Consumers.consumers.vend.getOrCreateConsumer(
consumerId = consumerId, // Use azp as consumer id if it is uuid value
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
aud = aud,
azp = azp,
iss = iss,
sub = sub,
Some(true),
name = name,
appType = Some(AppType.Confidential),
description = description,
developerEmail = email,
redirectURL = None,
createdByUserId = userId.toOption
)
}
def applyIdTokenRules(token: String, cc: CallContext): (Box[User], Some[CallContext]) = {
logger.debug("applyIdTokenRules - starting ID token validation")
// Extract issuer from token for debugging
val actualIssuer = JwtUtil.getIssuer(token).getOrElse("NO_ISSUER_CLAIM")
logger.debug(s"applyIdTokenRules - JWT issuer claim: '$actualIssuer'")
validateIdToken(token) match {
case Full(_) =>
logger.debug("applyIdTokenRules - ID token validation successful")
val user = getOrCreateResourceUser(token)
val consumer = getOrCreateConsumer(token, user.map(_.userId), Some(OpenIdConnect.openIdConnect))
LoginAttempt.userIsLocked(user.map(_.provider).getOrElse(""), user.map(_.name).getOrElse("")) match {
case true => ((Failure(UsernameHasBeenLocked), Some(cc.copy(consumer = consumer))))
case false => (user, Some(cc.copy(consumer = consumer)))
}
case ParamFailure(a, b, c, apiFailure : APIFailure) =>
logger.debug(s"applyIdTokenRules - ParamFailure during token validation: $a")
logger.debug(s"applyIdTokenRules - JWT issuer was: '$actualIssuer'")
(ParamFailure(a, b, c, apiFailure : APIFailure), Some(cc))
case Failure(msg, t, c) =>
logger.debug(s"applyIdTokenRules - Failure during token validation: $msg")
logger.debug(s"applyIdTokenRules - JWT issuer was: '$actualIssuer'")
if (msg.contains("OBP-20208")) {
logger.debug("applyIdTokenRules - OBP-20208: JWKS URI matching failed. Diagnostic info:")
logger.debug(s"applyIdTokenRules - Actual JWT issuer: '$actualIssuer'")
logger.debug(s"applyIdTokenRules - oauth2.jwk_set.url config: '${Constant.oauth2JwkSetUrl}'")
logger.debug("applyIdTokenRules - Resolution steps:")
logger.debug("1. Verify oauth2.jwk_set.url contains URLs that match the JWT issuer")
logger.debug("2. Check if JWT issuer claim matches expected identity provider")
logger.debug("3. Ensure case-insensitive substring matching works between issuer and JWKS URLs")
logger.debug("4. Consider if trailing slashes or URL formatting might be causing mismatch")
}
(Failure(msg, t, c), Some(cc))
case _ =>
logger.debug("applyIdTokenRules - Unknown failure during token validation")
logger.debug(s"applyIdTokenRules - JWT issuer was: '$actualIssuer'")
(Failure(Oauth2IJwtCannotBeVerified), Some(cc))
}
}
def applyIdTokenRulesFuture(value: String, cc: CallContext): Future[(Box[User], Some[CallContext])] = Future {
applyIdTokenRules(value, cc)
}
def applyAccessTokenRules(token: String, cc: CallContext): (Box[User], Some[CallContext]) = {
validateAccessToken(token) match {
case Full(_) =>
val user = getOrCreateResourceUser(token)
val consumer: Box[Consumer] = getOrCreateConsumer(token, user.map(_.userId), Some("OAuth 2.0"))
consumer match {
case Full(_) =>
LoginAttempt.userIsLocked(user.map(_.provider).getOrElse(""), user.map(_.name).getOrElse("")) match {
case true => ((Failure(UsernameHasBeenLocked), Some(cc.copy(consumer = consumer))))
case false => (user, Some(cc.copy(consumer = consumer)))
}
case ParamFailure(msg, exception, chain, apiFailure: APIFailure) =>
logger.debug(s"ParamFailure - message: $msg, param: $apiFailure, exception: ${exception.map(_.getMessage).openOr("none")}, chain: ${chain.map(_.msg).openOr("none")}")
(ParamFailure(msg, exception, chain, apiFailure: APIFailure), Some(cc))
case Failure(msg, exception, c) =>
logger.error(s"Failure - message: $msg, exception: ${exception.map(_.getMessage).openOr("none")}")
(Failure(msg, exception, c), Some(cc))
case _ =>
(Failure(CreateConsumerError), Some(cc))
}
case ParamFailure(a, b, c, apiFailure: APIFailure) =>
(ParamFailure(a, b, c, apiFailure: APIFailure), Some(cc))
case Failure(msg, t, c) =>
(Failure(msg, t, c), Some(cc))
case _ =>
(Failure(Oauth2IJwtCannotBeVerified), Some(cc))
}
}
def applyAccessTokenRulesFuture(value: String, cc: CallContext): Future[(Box[User], Some[CallContext])] = Future {
applyAccessTokenRules(value, cc)
}
}
object Google extends OAuth2Util {
val google = "google"
/**
* OpenID Connect Discovery.
* Google exposes OpenID Connect discovery documents ( https://YOUR_DOMAIN/.well-known/openid-configuration ).
* These can be used to automatically configure applications.
*/
override def wellKnownOpenidConfiguration: URI = new URI("https://accounts.google.com/.well-known/openid-configuration")
override def urlOfJwkSets: Box[String] = checkUrlOfJwkSets(identityProvider = google)
def isIssuer(jwt: String): Boolean = isIssuer(jwtToken=jwt, identityProvider = google)
}
object Yahoo extends OAuth2Util {
val yahoo = "yahoo"
/**
* OpenID Connect Discovery.
* Yahoo exposes OpenID Connect discovery documents ( https://YOUR_DOMAIN/.well-known/openid-configuration ).
* These can be used to automatically configure applications.
*/
override def wellKnownOpenidConfiguration: URI = new URI("https://login.yahoo.com/.well-known/openid-configuration")
override def urlOfJwkSets: Box[String] = checkUrlOfJwkSets(identityProvider = yahoo)
def isIssuer(jwt: String): Boolean = isIssuer(jwtToken=jwt, identityProvider = yahoo)
}
object Azure extends OAuth2Util {
val microsoft = "microsoft"
/**
* OpenID Connect Discovery.
* Yahoo exposes OpenID Connect discovery documents ( https://YOUR_DOMAIN/.well-known/openid-configuration ).
* These can be used to automatically configure applications.
*/
override def wellKnownOpenidConfiguration: URI = new URI("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")
override def urlOfJwkSets: Box[String] = checkUrlOfJwkSets(identityProvider = microsoft)
def isIssuer(jwt: String): Boolean = isIssuer(jwtToken=jwt, identityProvider = microsoft)
}
object UnknownProvider extends OAuth2Util {
/**
* OpenID Connect Discovery.
* Yahoo exposes OpenID Connect discovery documents ( https://YOUR_DOMAIN/.well-known/openid-configuration ).
* These can be used to automatically configure applications.
*/
override def wellKnownOpenidConfiguration: URI = new URI("")
def isIssuer(jwt: String): Boolean = {
val url: List[String] = Constant.oauth2JwkSetUrl.toList
val jwksUris: List[String] = url.map(_.toLowerCase()).map(_.split(",").toList).flatten
jwksUris.exists( url => JwtUtil.validateAccessToken(jwt, url).isDefined)
}
def applyRules(token: String, cc: CallContext): (Box[User], Some[CallContext]) = {
super.applyAccessTokenRules(token, cc)
}
def applyRulesFuture(value: String, cc: CallContext): Future[(Box[User], Some[CallContext])] = Future {
applyRules(value, cc)
}
}
object Keycloak extends OAuth2Util {
val keycloakHost = APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.host", "http://localhost:7070")
/**
* OpenID Connect Discovery.
* Yahoo exposes OpenID Connect discovery documents ( https://YOUR_DOMAIN/.well-known/openid-configuration ).
* These can be used to automatically configure applications.
*/
override def wellKnownOpenidConfiguration: URI =
new URI(
APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.well_known", "http://localhost:8000/realms/master/.well-known/openid-configuration")
)
override def urlOfJwkSets: Box[String] = checkUrlOfJwkSets(identityProvider = keycloakHost)
def isIssuer(jwt: String): Boolean = isIssuer(jwtToken=jwt, identityProvider = keycloakHost)
def applyRules(token: String, cc: CallContext): (Box[User], Some[CallContext]) = {
JwtUtil.getClaim("typ", token) match {
case "ID" => super.applyIdTokenRules(token, cc) // Authentication
case "Bearer" => // Authorization
val result = super.applyAccessTokenRules(token, cc)
result._2.flatMap(_.consumer.map(_.id.get)) match {
case Some(consumerPrimaryKey) =>
addScopesToConsumer(token, consumerPrimaryKey)
case None => // Do nothing
}
result
case "" => super.applyAccessTokenRules(token, cc)
}
}
private def addScopesToConsumer(token: String, consumerPrimaryKey: Long): Unit = {
val sourceOfTruth = APIUtil.getPropsAsBoolValue(nameOfProperty = "oauth2.keycloak.source_of_truth", defaultValue = false)
// Consumers allowed to use the source of truth feature
val resourceAccessName = APIUtil.getPropsValue(nameOfProperty = "oauth2.keycloak.resource_access_key_name_to_trust", "open-bank-project")
val consumerId = getClaim(name = "azp", idToken = token).getOrElse("")
if(sourceOfTruth) {
logger.debug("Extracting roles from Access Token")
import net.liftweb.json._
val jsonString = JwtUtil.getSignedPayloadAsJson(token)
val json = parse(jsonString.getOrElse(""))
val openBankRoles: List[String] =
// Sync Keycloak's roles
(json \ "resource_access" \ resourceAccessName \ "roles").extract[List[String]]
.filter(role => tryo(ApiRole.valueOf(role)).isDefined) // Keep only the roles OBP-API can recognise
val scopes = Scope.scope.vend.getScopesByConsumerId(consumerPrimaryKey.toString).getOrElse(Nil)
val databaseState = scopes.map(_.roleName)
// Already exist at DB
val existingRoles = openBankRoles.intersect(databaseState)
// Roles to add into DB
val rolesToAdd = openBankRoles.toSet diff databaseState.toSet
rolesToAdd.foreach(roleName => Scope.scope.vend.addScope("", consumerPrimaryKey.toString, roleName))
// Roles to delete from DB
val rolesToDelete = databaseState.toSet diff openBankRoles.toSet
rolesToDelete.foreach( roleName =>
Scope.scope.vend.deleteScope(scopes.find(s => s.roleName == roleName || s.consumerId == consumerId))
)
logger.debug(s"Consumer ID: $consumerId # Existing roles: ${existingRoles.mkString(",")} # Added roles: ${rolesToAdd.mkString(",")} # Deleted roles: ${rolesToDelete.mkString(",")}")
} else {
logger.debug(s"Adding scopes omitted due to oauth2.keycloak.source_of_truth = $sourceOfTruth # Consumer ID: $consumerId")
}
}
def applyRulesFuture(value: String, cc: CallContext): Future[(Box[User], Some[CallContext])] = Future {
applyRules(value, cc)
}
}
object OBPOIDC extends OAuth2Util {
val obpOidcHost = APIUtil.getPropsValue(nameOfProperty = "oauth2.obp_oidc.host", "http://localhost:9000")
val obpOidcIssuer = "obp-oidc"
/**
* OBP-OIDC (Open Bank Project OIDC Provider)
* OBP-OIDC exposes OpenID Connect discovery documents at /.well-known/openid-configuration
* This is the native OIDC provider for OBP ecosystem
*/
override def wellKnownOpenidConfiguration: URI =
new URI(
APIUtil.getPropsValue(nameOfProperty = "oauth2.obp_oidc.well_known", s"$obpOidcHost/obp-oidc/.well-known/openid-configuration")
)
override def urlOfJwkSets: Box[String] = checkUrlOfJwkSets(identityProvider = obpOidcIssuer)
def isIssuer(jwt: String): Boolean = isIssuer(jwtToken=jwt, identityProvider = obpOidcIssuer)
}
}