diff --git a/README.md b/README.md index 5de6baca6..951d7a8a5 100644 --- a/README.md +++ b/README.md @@ -666,6 +666,59 @@ allow_oauth2_login=true oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs ``` +### OAuth2 JWKS URI Configuration + +The `oauth2.jwk_set.url` property is critical for OAuth2 JWT token validation. OBP-API uses this to verify the authenticity of JWT tokens by fetching the JSON Web Key Set (JWKS) from the specified URI(s). + +#### Configuration Methods + +The `oauth2.jwk_set.url` property is resolved in the following order of priority: + +1. **Environment Variable** + + ```bash + export OBP_OAUTH2_JWK_SET_URL="https://your-oidc-server.com/jwks" + ``` + +2. **Properties Files** (located in `obp-api/src/main/resources/props/`) + - `production.default.props` (for production deployments) + - `default.props` (for development) + - `test.default.props` (for testing) + +#### Supported Formats + +- **Single URL**: `oauth2.jwk_set.url=http://localhost:9000/obp-oidc/jwks` +- **Multiple URLs**: `oauth2.jwk_set.url=http://localhost:8080/jwk.json,https://www.googleapis.com/oauth2/v3/certs` + +#### Common OAuth2 Provider Examples + +- **Google**: `https://www.googleapis.com/oauth2/v3/certs` +- **OBP-OIDC**: `http://localhost:9000/obp-oidc/jwks` +- **Keycloak**: `http://localhost:7070/realms/master/protocol/openid-connect/certs` +- **Azure AD**: `https://login.microsoftonline.com/common/discovery/v2.0/keys` + +#### Troubleshooting OBP-20208 Error + +If you encounter the error "OBP-20208: Cannot match the issuer and JWKS URI at this server instance", check the following: + +1. **Verify JWT Issuer Claim**: The JWT token's `iss` (issuer) claim must match one of the configured identity providers +2. **Check JWKS URL Configuration**: Ensure `oauth2.jwk_set.url` contains URLs that correspond to your JWT issuer +3. **Case-Insensitive Matching**: OBP-API performs case-insensitive substring matching between the issuer and JWKS URLs +4. **URL Format Consistency**: Check for trailing slashes or URL formatting differences + +**Debug Logging**: Enable debug logging to see detailed information about the matching process: + +```properties +# Add to your logging configuration +logger.code.api.OAuth2=DEBUG +``` + +The debug logs will show: + +- Expected identity provider vs actual JWT issuer claim +- Available JWKS URIs from configuration +- Matching logic results + --- ## Frozen APIs diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index dfeae730e..96bf19d47 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -231,14 +231,65 @@ object OAuth2Login extends RestHelper with MdcLoggable { 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 :: _ => Full(x) - case Nil => Failure(Oauth2CannotMatchIssuerAndJwksUriException) + 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) } } @@ -259,14 +310,33 @@ object OAuth2Login extends RestHelper with MdcLoggable { }.getOrElse(false) } def validateIdToken(idToken: String): Box[IDTokenClaimsSet] = { + logger.debug(s"validateIdToken - attempting to validate ID token") + + // Extract issuer for better error reporting + val actualIssuer = JwtUtil.getIssuer(idToken).getOrElse("NO_ISSUER_CLAIM") + logger.debug(s"validateIdToken - JWT issuer claim: '$actualIssuer'") + urlOfJwkSets match { case Full(url) => + logger.debug(s"validateIdToken - using JWKS URL: '$url'") JwtUtil.validateIdToken(idToken, url) case ParamFailure(a, b, c, apiFailure : APIFailure) => + logger.debug(s"validateIdToken - ParamFailure: $a, $b, $c, $apiFailure") + logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'") ParamFailure(a, b, c, apiFailure : APIFailure) case Failure(msg, t, c) => + logger.debug(s"validateIdToken - Failure getting JWKS URL: $msg") + logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'") + if (msg.contains("OBP-20208")) { + logger.debug("validateIdToken - OBP-20208 Error Details:") + logger.debug(s"validateIdToken - JWT issuer claim: '$actualIssuer'") + logger.debug(s"validateIdToken - oauth2.jwk_set.url value: '${Constant.oauth2JwkSetUrl}'") + logger.debug("validateIdToken - Check that the JWKS URL configuration matches the JWT issuer") + } Failure(msg, t, c) case _ => + logger.debug("validateIdToken - No JWKS URL available") + logger.debug(s"validateIdToken - JWT issuer was: '$actualIssuer'") Failure(Oauth2ThereIsNoUrlOfJwkSet) } } @@ -414,8 +484,15 @@ object OAuth2Login extends RestHelper with MdcLoggable { } 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 { @@ -423,10 +500,26 @@ object OAuth2Login extends RestHelper with MdcLoggable { 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)) } }