diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java index d1c45518b5af..a92e18146396 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java @@ -4,6 +4,7 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.security.JwtFilter.EMAIL_CLAIM_KEY; import static org.openmetadata.service.security.JwtFilter.USERNAME_CLAIM_KEY; +import static org.openmetadata.service.security.JwtFilter.normalizeIssuer; import static org.openmetadata.service.security.SecurityUtil.findEmailFromClaims; import static org.openmetadata.service.security.SecurityUtil.findTeamsFromClaims; import static org.openmetadata.service.security.SecurityUtil.findUserNameFromClaims; @@ -1189,8 +1190,34 @@ private void executeAuthorizationCodeTokenRequest( // Populate credentials populateCredentialsFromTokenResponse(tokenSuccessResponse, credentials); - // Check expiry, azure on first go itself is returning a expried token sometimes - Date expirationTime = credentials.getIdToken().getJWTClaimsSet().getExpirationTime(); + JWTClaimsSet claimsSet = credentials.getIdToken().getJWTClaimsSet(); + + OIDCProviderMetadata providerMetadata = client.getConfiguration().getProviderMetadata(); + if (providerMetadata != null && providerMetadata.getIssuer() != null) { + String expectedIssuer = providerMetadata.getIssuer().getValue(); + String actualIssuer = claimsSet.getIssuer(); + if (expectedIssuer != null + && !normalizeIssuer(expectedIssuer).equals(normalizeIssuer(actualIssuer))) { + LOG.warn( + "ID token issuer mismatch. Expected: '{}', actual: '{}'", expectedIssuer, actualIssuer); + throw new TechnicalException("ID token issuer mismatch."); + } + } + + String expectedClientId = client.getConfiguration().getClientId(); + if (expectedClientId != null) { + List audience = claimsSet.getAudience(); + if (audience == null || !audience.contains(expectedClientId)) { + LOG.warn( + "ID token audience mismatch. Expected client ID: '{}', actual: '{}'", + expectedClientId, + audience); + throw new TechnicalException("ID token audience mismatch."); + } + } + + // Azure may return an expired token on first attempt + Date expirationTime = claimsSet.getExpirationTime(); if (expirationTime != null && expirationTime.before(Calendar.getInstance(TimeZone.getTimeZone("UTC")).getTime())) { renewOidcCredentials(session, credentials); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java index f003fa71a8f4..3b79a53b6ddd 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java @@ -31,6 +31,7 @@ import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import io.micrometer.core.instrument.Timer; @@ -47,6 +48,7 @@ import java.util.Calendar; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TimeZone; @@ -61,11 +63,14 @@ import org.openmetadata.schema.auth.LogoutRequest; import org.openmetadata.schema.auth.ServiceTokenType; import org.openmetadata.schema.services.connections.metadata.AuthProvider; +import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.monitoring.RequestLatencyContext; import org.openmetadata.service.security.auth.BotTokenCache; import org.openmetadata.service.security.auth.CatalogSecurityContext; import org.openmetadata.service.security.auth.UserTokenCache; +import org.openmetadata.service.security.jwt.JWTTokenGenerator; import org.openmetadata.service.security.saml.JwtTokenCacheManager; +import org.openmetadata.service.util.ValidationHttpUtil; @Slf4j @Provider @@ -87,6 +92,9 @@ public class JwtFilter implements ContainerRequestFilter { private AuthProvider providerType; private boolean useRolesFromProvider = false; private AuthenticationConfiguration.TokenValidationAlgorithm tokenValidationAlgorithm; + private String expectedClientId; + private String expectedIssuer; + private String internalJwtIssuer; public static final List EXCLUDED_ENDPOINTS = List.of( @@ -134,6 +142,70 @@ public JwtFilter( this.enforcePrincipalDomain = authorizerConfiguration.getEnforcePrincipalDomain(); this.useRolesFromProvider = authorizerConfiguration.getUseRolesFromProvider(); this.tokenValidationAlgorithm = authenticationConfiguration.getTokenValidationAlgorithm(); + this.expectedClientId = authenticationConfiguration.getClientId(); + this.expectedIssuer = resolveOidcIssuer(authenticationConfiguration.getAuthority()); + this.internalJwtIssuer = JWTTokenGenerator.getInstance().getIssuer(); + } + + private static String resolveOidcIssuer(String authority) { + if (nullOrEmpty(authority)) { + return null; + } + try { + String discoveryUrl = authority; + if (!discoveryUrl.endsWith("/")) { + discoveryUrl += "/"; + } + discoveryUrl += ".well-known/openid-configuration"; + ValidationHttpUtil.HttpResponseData response = ValidationHttpUtil.safeGet(discoveryUrl); + if (response.getStatusCode() == 200) { + JsonNode discoveryDoc = JsonUtils.readTree(response.getBody()); + String issuer = discoveryDoc.path("issuer").asText(null); + if (!nullOrEmpty(issuer)) { + LOG.info("Resolved OIDC issuer from discovery document: {}", issuer); + return issuer; + } + } + LOG.warn( + "Failed to resolve issuer from OIDC discovery document at {}. " + + "Falling back to authority value: {}", + discoveryUrl, + authority); + } catch (Exception e) { + LOG.warn( + "Error fetching OIDC discovery document for authority: {}. " + + "Falling back to authority value.", + authority, + e); + } + return authority; + } + + @VisibleForTesting + static String normalizeIssuer(String issuer) { + if (issuer == null) { + return null; + } + String normalized = issuer.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + try { + URI uri = URI.create(normalized); + if (uri.getScheme() != null && uri.getHost() != null) { + return new URI( + uri.getScheme().toLowerCase(Locale.ROOT), + null, + uri.getHost().toLowerCase(Locale.ROOT), + uri.getPort(), + uri.getRawPath(), + uri.getRawQuery(), + uri.getRawFragment()) + .toString(); + } + } catch (Exception ignored) { + } + return normalized; } @VisibleForTesting @@ -142,11 +214,45 @@ public JwtFilter( List jwtPrincipalClaims, String principalDomain, boolean enforcePrincipalDomain) { + this( + jwkProvider, jwtPrincipalClaims, principalDomain, enforcePrincipalDomain, null, null, null); + } + + @VisibleForTesting + JwtFilter( + JwkProvider jwkProvider, + List jwtPrincipalClaims, + String principalDomain, + boolean enforcePrincipalDomain, + String expectedClientId, + String expectedIssuer) { + this( + jwkProvider, + jwtPrincipalClaims, + principalDomain, + enforcePrincipalDomain, + expectedClientId, + expectedIssuer, + null); + } + + @VisibleForTesting + JwtFilter( + JwkProvider jwkProvider, + List jwtPrincipalClaims, + String principalDomain, + boolean enforcePrincipalDomain, + String expectedClientId, + String expectedIssuer, + String internalJwtIssuer) { this.jwkProvider = jwkProvider; this.jwtPrincipalClaims = jwtPrincipalClaims; this.principalDomain = principalDomain; this.enforcePrincipalDomain = enforcePrincipalDomain; this.tokenValidationAlgorithm = AuthenticationConfiguration.TokenValidationAlgorithm.RS_256; + this.expectedClientId = expectedClientId; + this.expectedIssuer = expectedIssuer; + this.internalJwtIssuer = internalJwtIssuer; } @SneakyThrows @@ -273,6 +379,33 @@ public Map validateJwtAndGetClaims(String token) { "Invalid token. Token verification failed. Public key mismatch.", runtimeException); } + boolean isInternalToken = + internalJwtIssuer != null && internalJwtIssuer.equals(jwt.getIssuer()); + + if (!isInternalToken) { + if (!nullOrEmpty(expectedIssuer)) { + String tokenIssuer = jwt.getIssuer(); + if (tokenIssuer == null + || !normalizeIssuer(tokenIssuer).equals(normalizeIssuer(expectedIssuer))) { + LOG.warn( + "Token issuer mismatch. Expected: '{}', actual: '{}'", expectedIssuer, tokenIssuer); + throw AuthenticationException.getInvalidTokenException("Invalid token. Issuer mismatch."); + } + } + + if (!nullOrEmpty(expectedClientId)) { + List audiences = jwt.getAudience(); + if (audiences == null || !audiences.contains(expectedClientId)) { + LOG.warn( + "Token audience mismatch. Expected client ID: '{}', actual: '{}'", + expectedClientId, + audiences); + throw AuthenticationException.getInvalidTokenException( + "Invalid token. Audience mismatch."); + } + } + } + Map claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); claims.putAll(jwt.getClaims()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java index 10e622b8d6df..afbe05eab79e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java @@ -61,7 +61,7 @@ public class JWTTokenGenerator { private static final JWTTokenGenerator INSTANCE = new JWTTokenGenerator(); private RSAPrivateKey privateKey; @Getter private RSAPublicKey publicKey; - private String issuer; + @Getter private String issuer; private String kid; private AuthenticationConfiguration.TokenValidationAlgorithm tokenValidationAlgorithm; diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/JwtFilterTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/JwtFilterTest.java index 25412609252c..b8515e47bbe4 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/JwtFilterTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/JwtFilterTest.java @@ -15,6 +15,7 @@ import static java.lang.String.format; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -27,6 +28,7 @@ import com.auth0.jwk.JwkProvider; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.Claim; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.SecurityContext; @@ -253,6 +255,414 @@ void testPersonalAccessTokenClaimIsValidated() { assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("personal access token")); } + @Test + void testAudienceValidationSuccess() { + JwtFilter filter = + new JwtFilter(jwkProvider, List.of("sub"), "openmetadata.org", false, "my-client-id", null); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("my-client-id") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + filter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testAudienceValidationWithMultipleAudiences() { + JwtFilter filter = + new JwtFilter(jwkProvider, List.of("sub"), "openmetadata.org", false, "my-client-id", null); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("other-client", "my-client-id") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + filter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testAudienceValidationRejectsWrongAudience() { + JwtFilter filter = + new JwtFilter(jwkProvider, List.of("sub"), "openmetadata.org", false, "my-client-id", null); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("different-client-id") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("audience mismatch")); + } + + @Test + void testAudienceValidationRejectsMissingAudience() { + JwtFilter filter = + new JwtFilter(jwkProvider, List.of("sub"), "openmetadata.org", false, "my-client-id", null); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("audience mismatch")); + } + + @Test + void testAudienceValidationRejectsMultipleNonMatchingAudiences() { + JwtFilter filter = + new JwtFilter(jwkProvider, List.of("sub"), "openmetadata.org", false, "my-client-id", null); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("client-a", "client-b", "client-c") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("audience mismatch")); + } + + @Test + void testIssuerValidationSuccess() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + null, + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("https://auth.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + filter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testIssuerValidationRejectsWrongIssuer() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + null, + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("https://evil.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("issuer mismatch")); + } + + @Test + void testIssuerValidationRejectsMissingIssuer() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + null, + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("issuer mismatch")); + } + + @Test + void testCombinedAudienceAndIssuerValidation() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + "my-client-id", + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("my-client-id") + .withIssuer("https://auth.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + filter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testCombinedValidationRejectsWrongIssuerWithCorrectAudience() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + "my-client-id", + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("my-client-id") + .withIssuer("https://evil.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("issuer mismatch")); + } + + @Test + void testCombinedValidationRejectsWrongAudienceWithCorrectIssuer() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + "my-client-id", + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("wrong-client-id") + .withIssuer("https://auth.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("audience mismatch")); + } + + @Test + void testValidationSkippedWhenNotConfigured() { + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withAudience("any-client-id") + .withIssuer("https://any-issuer.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + jwtFilter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testIssuerValidationWithTrailingSlash() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + null, + "https://auth.example.com/"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("https://auth.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + filter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testIssuerValidationHostCaseInsensitive() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + null, + "https://Auth.Example.COM"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("https://auth.example.com") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + filter.filter(context); + + ArgumentCaptor securityContextArgument = + ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + } + + @Test + void testNormalizeIssuerTrailingSlash() { + assertEquals( + "https://auth.example.com", JwtFilter.normalizeIssuer("https://auth.example.com/")); + assertEquals("https://auth.example.com", JwtFilter.normalizeIssuer("https://auth.example.com")); + assertEquals( + "https://auth.example.com/v2.0", + JwtFilter.normalizeIssuer("https://auth.example.com/v2.0/")); + assertNull(JwtFilter.normalizeIssuer(null)); + } + + @Test + void testNormalizeIssuerHostCaseOnlyPathPreserved() { + assertEquals("https://auth.example.com", JwtFilter.normalizeIssuer("https://Auth.Example.COM")); + assertEquals( + "https://auth.example.com/realms/MyRealm", + JwtFilter.normalizeIssuer("https://Auth.Example.COM/realms/MyRealm")); + assertEquals( + "https://idp.example.com/tenant-id/v2.0", + JwtFilter.normalizeIssuer("https://IDP.Example.COM/tenant-id/v2.0/")); + } + + @Test + void testInternalBotTokenBypassesIssuerValidation() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + "my-client-id", + "https://auth.example.com", + "open-metadata.org"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("open-metadata.org") + .withClaim("sub", "ingestion-bot") + .withClaim("isBot", true) + .sign(algorithm); + + Map claims = filter.validateJwtAndGetClaims(jwt); + assertEquals("ingestion-bot", claims.get("sub").asString()); + } + + @Test + void testInternalTokenTypeBypassesIssuerAndAudienceValidation() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + "my-client-id", + "https://auth.example.com", + "open-metadata.org"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("open-metadata.org") + .withAudience("not-my-client") + .withClaim("sub", "sam") + .withClaim("tokenType", "PERSONAL_ACCESS") + .sign(algorithm); + + Map claims = filter.validateJwtAndGetClaims(jwt); + assertEquals("sam", claims.get("sub").asString()); + } + + @Test + void testExternalTokenWithoutInternalClaimsStillValidated() { + JwtFilter filter = + new JwtFilter( + jwkProvider, + List.of("sub"), + "openmetadata.org", + false, + "my-client-id", + "https://auth.example.com"); + + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withIssuer("https://evil.example.com") + .withAudience("my-client-id") + .withClaim("sub", "sam") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + Exception exception = assertThrows(AuthenticationException.class, () -> filter.filter(context)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("issuer mismatch")); + } + /** * Creates the ContainerRequestsContext that is passed to the filter. This object can be quite complex, but the * JwtFilter cares only about the Authorization header and request URI.