/** 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 . 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) } }