Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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<String> EXCLUDED_ENDPOINTS =
List.of(
Expand Down Expand Up @@ -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";
Comment on lines +155 to +159
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveOidcIssuer performs an HTTP call to the IdP discovery endpoint during JwtFilter construction. If the IdP is slow/unreachable, this can delay startup (15s default timeout) and adds an external dependency to initialize the auth filter. Consider resolving/caching the issuer during configuration validation/save, or making discovery resolution explicitly optional/non-blocking with a shorter timeout.

Copilot uses AI. Check for mistakes.
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
Expand All @@ -142,11 +214,45 @@ public JwtFilter(
List<String> jwtPrincipalClaims,
String principalDomain,
boolean enforcePrincipalDomain) {
this(
jwkProvider, jwtPrincipalClaims, principalDomain, enforcePrincipalDomain, null, null, null);
}

@VisibleForTesting
JwtFilter(
JwkProvider jwkProvider,
List<String> jwtPrincipalClaims,
String principalDomain,
boolean enforcePrincipalDomain,
String expectedClientId,
String expectedIssuer) {
this(
jwkProvider,
jwtPrincipalClaims,
principalDomain,
enforcePrincipalDomain,
expectedClientId,
expectedIssuer,
null);
}

@VisibleForTesting
JwtFilter(
JwkProvider jwkProvider,
List<String> 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
Expand Down Expand Up @@ -273,6 +379,33 @@ public Map<String, Claim> validateJwtAndGetClaims(String token) {
"Invalid token. Token verification failed. Public key mismatch.", runtimeException);
}

boolean isInternalToken =
internalJwtIssuer != null && internalJwtIssuer.equals(jwt.getIssuer());
Comment on lines +382 to +383
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isInternalToken is determined solely by comparing the token iss claim to the configured internal JWT issuer. If an operator configures the internal jwtissuer to match the external OIDC issuer (or they coincide), then external OIDC tokens would be treated as “internal” and bypass iss/aud validation, reintroducing the cross-client token reuse risk this PR is addressing. Consider identifying internal tokens using a stronger signal tied to the signing key (e.g., token kid matching the locally configured JWKS key id) and/or the presence of OpenMetadata-only claims (like tokenType), rather than issuer string equality alone.

Suggested change
boolean isInternalToken =
internalJwtIssuer != null && internalJwtIssuer.equals(jwt.getIssuer());
Claim tokenTypeClaim = jwt.getClaim(TOKEN_TYPE);
boolean hasInternalTokenTypeClaim = tokenTypeClaim != null && !tokenTypeClaim.isNull();
boolean isInternalToken =
internalJwtIssuer != null
&& internalJwtIssuer.equals(jwt.getIssuer())
&& hasInternalTokenTypeClaim;

Copilot uses AI. Check for mistakes.

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<String> 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<String, Claim> claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
claims.putAll(jwt.getClaims());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading