params = buildLoginParams(client.getConfiguration());
params.put(OidcConfiguration.REDIRECT_URI, client.getCallbackUrl());
@@ -473,6 +474,7 @@ public void handleLogin(HttpServletRequest req, HttpServletResponse resp) {
if (isMcpFlow) {
persistMcpPendingState(req, pendingLoginContext);
}
+ revokeTheSessionThisLoginReplaces(req, resp);
sessionService.createPendingSession(
req,
resp,
@@ -544,6 +546,17 @@ private boolean isMcpRedirectUri(String redirectUri) {
return (serverUrl + MCP_CALLBACK_PATH).equals(redirectUri);
}
+ /**
+ * The pending session's cookie replaces the browser's current one, and nothing reaches that
+ * session again. Left active, it would keep its refresh tokens until it expires and count toward
+ * maxActiveSessionsPerUser, so signing in again on one browser could evict another device.
+ */
+ private void revokeTheSessionThisLoginReplaces(HttpServletRequest req, HttpServletResponse resp) {
+ sessionService
+ .getActiveSession(req, resp)
+ .ifPresent(session -> sessionService.revokeSession(session.getId()));
+ }
+
private void persistMcpPendingState(HttpServletRequest req, PendingLoginContext context) {
McpPendingStatePersister persister = mcpPendingStatePersister;
if (persister != null) {
@@ -653,15 +666,15 @@ public void handleCallback(HttpServletRequest req, HttpServletResponse resp) {
pendingSession,
user,
refreshToken.getToken().toString(),
- providerTokensAtLogin(credentials, System.currentTimeMillis()));
+ providerTokensAtLogin(credentials, expiresAtOrNow(expirationTime)));
if (maybeActiveSession.isEmpty()) {
Entity.getTokenRepository().deleteToken(refreshToken.getToken().toString());
throw new TechnicalException("Failed to activate OIDC session");
}
- UserSession activeSession = maybeActiveSession.get();
- JWTAuthMechanism jwtAuthMechanism = generateJwtToken(user, activeSession);
- sendRedirectWithToken(resp, redirectUri, user, jwtAuthMechanism.getJWTToken());
+ // The browser presents the provider's own ID token: its claims are the ones the principal
+ // configuration names, and its expiry is the provider's.
+ sendRedirectWithToken(resp, redirectUri, user, credentials.getIdToken());
} catch (IllegalArgumentException e) {
try {
org.openmetadata.service.security.SecurityUtil.writeErrorResponse(
@@ -727,6 +740,12 @@ public void handleRefresh(
refreshLeasedSession(httpServletRequest, httpServletResponse, leasedSession);
} catch (SessionRefreshInProgressException e) {
writeRefreshContentionResponse(httpServletResponse, e);
+ } catch (ServiceUnavailableException e) {
+ // No verdict from the provider (outage, client misconfiguration): keep the session so a
+ // later refresh can still renew it.
+ sessionService.releaseRefreshLease(leasedSession);
+ LOG.warn("[Auth Refresh] {}", e.getMessage());
+ SecurityUtil.writeFailureResponse(httpServletResponse, e);
} catch (AuthenticationException e) {
// The session can never be refreshed again, so revoke it: the browser answers a 401 here by
// re-authenticating at the identity provider, where a 500 would just sign the user out.
@@ -751,99 +770,104 @@ private void refreshLeasedSession(
requireUnexpiredRefreshToken(currentRefreshToken);
// Ask the identity provider before rotating, so a rejected or failed check leaves the
// session's stored refresh token untouched.
- SessionService.ProviderTokenUpdate providerTokens =
- renewProviderTokensIfDue(session, currentRefreshToken);
+ RenewedIdToken renewed = renewProviderIdToken(session, currentRefreshToken);
RefreshRotation rotation =
new RefreshRotation(
currentRefreshToken,
rotateRefreshToken(user.getId(), currentRefreshToken),
- providerTokens);
- writeRefreshedTokenResponse(response, user, completeRefresh(session, rotation));
+ renewed.providerTokens());
+ completeRefresh(session, rotation);
+ writeRefreshedTokenResponse(response, renewed);
}
/**
- * Starts the provider renewal schedule from the login's own token response, so the first access
- * token already ends before the provider's tokens need renewing. Nothing is kept when sessions do
- * not end with the provider's ({@code oidcConfiguration.endSessionWithProvider}), or when the
- * provider issued no refresh token.
+ * The provider refresh token the session keeps: each refresh redeems it for the provider's next
+ * ID token. A provider that issued none leaves nothing to renew, so the session lasts as long as
+ * its first ID token, after which the browser signs in again at the provider.
*/
- SessionService.ProviderTokenUpdate providerTokensAtLogin(OidcCredentials credentials, long now) {
- // pac4j 6 re-parses the stored tokens on every to*Token() call; parse each once.
- var providerRefreshToken = endsSessionWithProvider ? credentials.toRefreshToken() : null;
- if (providerRefreshToken == null) {
- return SessionService.ProviderTokenUpdate.NONE;
- }
- AccessToken providerAccessToken = credentials.toAccessToken();
- long lifetimeSeconds = providerAccessToken == null ? 0 : providerAccessToken.getLifetime();
- return new SessionService.ProviderTokenUpdate.Replaced(
- providerRefreshToken.getValue(),
- ProviderTokenSchedule.renewalDueAt(now, lifetimeSeconds, tokenValidity));
+ SessionService.ProviderTokenUpdate providerTokensAtLogin(
+ OidcCredentials credentials, long idTokenExpiresAt) {
+ var providerRefreshToken = credentials.toRefreshToken();
+ return providerRefreshToken == null
+ ? SessionService.ProviderTokenUpdate.NONE
+ : new SessionService.ProviderTokenUpdate.Replaced(
+ providerRefreshToken.getValue(), idTokenExpiresAt);
}
/**
- * Renews the identity provider's tokens when they would lapse before the access token this
- * refresh issues. Following the provider's schedule keeps its session alive while the user is
- * active here (Keycloak counts refresh-token use as activity), and a rejected grant ends this
- * session too. A renewed grant never extends the session: refresh tokens can outlive the
- * provider's browser session, so only a new sign-in, which the browser attempts silently once the
- * session ends, starts a new session lifetime. Only sessions that end with the provider's
- * ({@code oidcConfiguration.endSessionWithProvider}) contact it.
- *
- * @return {@link SessionService.ProviderTokenUpdate#NONE} when the provider was not due or the
- * session holds no provider token
+ * Redeems the session's provider refresh token for the provider's next ID token, which the
+ * browser presents until it expires: the provider decides how long that is. A grant it rejects
+ * (sign-out where its refresh tokens are bound to its session, a disabled user, a revoked token)
+ * ends this session too. A successful grant never extends the session: refresh tokens can outlive
+ * the provider's browser session, so only a new sign-in, which the browser attempts silently once
+ * the session ends, starts a new session lifetime.
*/
- private SessionService.ProviderTokenUpdate renewProviderTokensIfDue(
- UserSession session, String omRefreshToken) {
- String providerRefreshToken =
- endsSessionWithProvider ? sessionService.decryptProviderRefreshToken(session) : null;
- long now = System.currentTimeMillis();
- if (nullOrEmpty(providerRefreshToken)
- || !ProviderTokenSchedule.isRenewalDue(
- session.getProviderRenewalDueAt(), now, tokenValidity)) {
- return SessionService.ProviderTokenUpdate.NONE;
+ private RenewedIdToken renewProviderIdToken(UserSession session, String omRefreshToken) {
+ String providerRefreshToken = sessionService.decryptProviderRefreshToken(session);
+ if (nullOrEmpty(providerRefreshToken)) {
+ throw endedSession(omRefreshToken, "Identity provider issued no refresh token");
}
OidcProviderTokenRefresher.Outcome outcome =
providerTokenRefresher.refresh(providerRefreshToken);
if (outcome.isRejected()) {
- deleteRefreshTokenIfPresent(omRefreshToken);
- throw new AuthenticationException("Identity provider session ended");
+ throw endedSession(omRefreshToken, "Identity provider session ended");
}
- return scheduleNextRenewal(session, outcome, now);
+ if (!outcome.isRenewed()) {
+ throw new ServiceUnavailableException(
+ "Identity provider gave no verdict on the session; try again shortly");
+ }
+ return renewedIdToken(outcome, omRefreshToken);
}
- private SessionService.ProviderTokenUpdate scheduleNextRenewal(
- UserSession session, OidcProviderTokenRefresher.Outcome outcome, long now) {
- return outcome.isRenewed()
- ? renewedProviderTokens(outcome, now)
- : retryProviderRenewal(session, now);
+ /** A provider that does not rotate its refresh token on use leaves the session's one valid. */
+ private RenewedIdToken renewedIdToken(
+ OidcProviderTokenRefresher.Outcome outcome, String omRefreshToken) {
+ long expiresAt = idTokenExpiresAt(outcome.idToken());
+ if (!outlivesBrowserRenewal(expiresAt)) {
+ throw endedSession(omRefreshToken, "Identity provider returned no usable ID token");
+ }
+ SessionService.ProviderTokenUpdate providerTokens =
+ outcome.rotatedRefreshToken() == null
+ ? new SessionService.ProviderTokenUpdate.Rescheduled(expiresAt)
+ : new SessionService.ProviderTokenUpdate.Replaced(
+ outcome.rotatedRefreshToken(), expiresAt);
+ return new RenewedIdToken(outcome.idToken(), expiresAt, providerTokens);
}
- /** A provider that does not rotate its refresh token on use leaves the session's one valid. */
- private SessionService.ProviderTokenUpdate renewedProviderTokens(
- OidcProviderTokenRefresher.Outcome outcome, long now) {
- long renewalDueAt =
- ProviderTokenSchedule.renewalDueAt(now, outcome.lifetimeSeconds(), tokenValidity);
- return outcome.rotatedRefreshToken() == null
- ? new SessionService.ProviderTokenUpdate.Rescheduled(renewalDueAt)
- : new SessionService.ProviderTokenUpdate.Replaced(
- outcome.rotatedRefreshToken(), renewalDueAt);
+ /** Whether a token expiring then lasts past the browser's renewal margin. */
+ static boolean outlivesBrowserRenewal(long expiresAt) {
+ return expiresAt > System.currentTimeMillis() + BROWSER_RENEWAL_MARGIN_MILLIS;
+ }
+
+ /** Expiry of a provider ID token in epoch millis; 0 when there is none or it cannot be read. */
+ static long idTokenExpiresAt(String idToken) {
+ try {
+ Date expirationTime =
+ nullOrEmpty(idToken)
+ ? null
+ : JWTParser.parse(idToken).getJWTClaimsSet().getExpirationTime();
+ return expirationTime == null ? 0 : expirationTime.getTime();
+ } catch (java.text.ParseException e) {
+ LOG.warn("[Auth Refresh] Unreadable ID token from the identity provider: {}", e.getMessage());
+ return 0;
+ }
+ }
+
+ private static long expiresAtOrNow(Date expirationTime) {
+ return expirationTime == null ? System.currentTimeMillis() : expirationTime.getTime();
}
- private SessionService.ProviderTokenUpdate retryProviderRenewal(UserSession session, long now) {
- LOG.warn(
- "[Auth Refresh] Identity provider gave no verdict for session {}; asking again in {}s",
- SessionService.truncateId(session.getId()),
- ProviderTokenSchedule.RETRY_AFTER_SECONDS);
- return new SessionService.ProviderTokenUpdate.Rescheduled(ProviderTokenSchedule.retryAt(now));
+ private AuthenticationException endedSession(String omRefreshToken, String reason) {
+ deleteRefreshTokenIfPresent(omRefreshToken);
+ return new AuthenticationException(reason);
}
- private void writeRefreshedTokenResponse(
- HttpServletResponse response, User user, UserSession session) throws IOException {
- JWTAuthMechanism jwtAuthMechanism = generateJwtToken(user, session);
+ private static void writeRefreshedTokenResponse(
+ HttpServletResponse response, RenewedIdToken renewed) throws IOException {
JwtResponse jwtResponse = new JwtResponse();
jwtResponse.setTokenType("Bearer");
- jwtResponse.setAccessToken(jwtAuthMechanism.getJWTToken());
- jwtResponse.setExpiryDuration(jwtAuthMechanism.getJWTTokenExpiresAt());
+ jwtResponse.setAccessToken(renewed.idToken());
+ jwtResponse.setExpiryDuration(renewed.expiresAt());
response.setStatus(HttpServletResponse.SC_OK);
writeJsonResponse(response, JsonUtils.pojoToJson(jwtResponse));
}
@@ -1246,14 +1270,9 @@ userName, new CreateUser().withName(userName).withEmail(email).withIsBot(false))
}
/**
- * Applies the identity provider's roles claim to the user before the OpenMetadata session token
- * is minted.
- *
- * Until 1.10.x the browser was handed the provider's raw id_token, so JwtFilter saw the
- * provider's roles on every request and synced them. This callback now mints an OpenMetadata
- * token carrying OpenMetadata's own roles, which makes that sync compare the database against
- * itself. This is the last point at which the provider's roles are visible, so the sync has to
- * happen here.
+ * Applies the identity provider's roles claim to the user at sign-in, so the roles are current
+ * before the browser's first request. The sync on {@code /users/loggedInUser} reads the same
+ * claim from the provider's ID token the browser then presents.
*/
private void syncRolesFromProvider(User user, Map claims) {
if (!Boolean.TRUE.equals(authorizerConfiguration.getUseRolesFromProvider())) {
@@ -1463,23 +1482,6 @@ private User getSessionUser(UserSession session) {
return null;
}
- private JWTAuthMechanism generateJwtToken(User user, UserSession session) {
- // A renewal time kept from while the setting was on must not keep cutting tokens short.
- Long providerRenewalDueAt = endsSessionWithProvider ? session.getProviderRenewalDueAt() : null;
- int validitySeconds =
- ProviderTokenSchedule.accessTokenValiditySeconds(
- providerRenewalDueAt, System.currentTimeMillis(), tokenValidity);
- return JWTTokenGenerator.getInstance()
- .generateJWTTokenForSession(
- user.getName(),
- getRoleListFromUser(user),
- !nullOrEmpty(user.getIsAdmin()) && user.getIsAdmin(),
- user.getEmail(),
- validitySeconds,
- ServiceTokenType.OM_USER,
- session.getId());
- }
-
private static void requireUnexpiredRefreshToken(String refreshToken) {
org.openmetadata.schema.auth.RefreshToken storedRefreshToken;
try {
@@ -1546,6 +1548,9 @@ private void deleteRefreshTokenIfPresent(String refreshToken) {
record PendingLoginContext(String state, String nonce, String pkceVerifier) {}
+ private record RenewedIdToken(
+ String idToken, long expiresAt, SessionService.ProviderTokenUpdate providerTokens) {}
+
private record RefreshRotation(
String previousRefreshToken,
String updatedRefreshToken,
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 ec18a6f72448..9864f185306c 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
@@ -53,18 +53,23 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.security.AuthenticationConfiguration;
import org.openmetadata.schema.api.security.AuthorizerConfiguration;
+import org.openmetadata.schema.api.security.ClientType;
import org.openmetadata.schema.auth.LogoutRequest;
import org.openmetadata.schema.auth.ServiceTokenType;
import org.openmetadata.schema.entity.teams.User;
+import org.openmetadata.schema.security.client.OidcClientConfig;
import org.openmetadata.schema.services.connections.metadata.AuthProvider;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
@@ -107,6 +112,9 @@ public class JwtFilter implements ContainerRequestFilter {
private AuthProvider providerType;
private boolean useRolesFromProvider = false;
private AuthenticationConfiguration.TokenValidationAlgorithm tokenValidationAlgorithm;
+ private OidcClientConfig serverSideOidcClient;
+ private ProviderTokenValidator providerTokenValidator =
+ ProviderTokenValidator.acceptingAnyProviderToken();
private String emailClaim;
private String displayNameClaim;
@@ -189,7 +197,14 @@ public JwtFilter(
for (String publicKeyUrlStr : authenticationConfiguration.getPublicKeyUrls()) {
publicKeyUrlsBuilder.add(URI.create(publicKeyUrlStr).toURL());
}
- this.jwkProvider = new MultiUrlJwkProvider(publicKeyUrlsBuilder.build());
+ this.jwkProvider =
+ new MultiUrlJwkProvider(publicKeyUrlsBuilder.build(), this::providerKeySetUrls);
+ this.serverSideOidcClient =
+ authenticationConfiguration.getClientType() == ClientType.CONFIDENTIAL
+ ? authenticationConfiguration.getOidcConfiguration()
+ : null;
+ this.providerTokenValidator =
+ new ProviderTokenValidator(clientIdsOf(authenticationConfiguration), this::providerIssuer);
this.principalDomain =
SecurityUtil.resolvePrincipalDomain(
@@ -260,6 +275,16 @@ private void logDeprecationWarnings(AuthenticationConfiguration config) {
this.tokenValidationAlgorithm = AuthenticationConfiguration.TokenValidationAlgorithm.RS_256;
}
+ @VisibleForTesting
+ JwtFilter(
+ JwkProvider jwkProvider,
+ List jwtPrincipalClaims,
+ String principalDomain,
+ ProviderTokenValidator providerTokenValidator) {
+ this(jwkProvider, jwtPrincipalClaims, principalDomain, false);
+ this.providerTokenValidator = providerTokenValidator;
+ }
+
@VisibleForTesting
JwtFilter(
JwkProvider jwkProvider,
@@ -542,10 +567,59 @@ private DecodedJWT decodeAndVerify(String token) {
throw AuthenticationException.getInvalidTokenException(
"Invalid token. Token verification failed. Public key mismatch.", runtimeException);
}
+ requireIssuedForThisDeployment(jwt);
return jwt;
}
+ private void requireIssuedForThisDeployment(DecodedJWT jwt) {
+ if (!isSignedWithOpenMetadataKey(jwt)) {
+ providerTokenValidator.validate(jwt);
+ }
+ }
+
+ /**
+ * Key lookup resolves OpenMetadata's own key id locally before any configured URL, so a verified
+ * token carrying it was signed with our key. Unlike {@link #isInternallyIssuedToken}, this does
+ * not require the current issuer: long-lived bot tokens minted under an earlier one are still ours.
+ */
+ private static boolean isSignedWithOpenMetadataKey(DecodedJWT jwt) {
+ String openMetadataKeyId = JWTTokenGenerator.getInstance().getKid();
+ return !nullOrEmpty(openMetadataKeyId) && openMetadataKeyId.equals(jwt.getKeyId());
+ }
+
+ /** The OIDC client IDs this deployment's sign-in flows request tokens for. */
+ static Set clientIdsOf(AuthenticationConfiguration authenticationConfiguration) {
+ OidcClientConfig oidcClient = authenticationConfiguration.getOidcConfiguration();
+ return Stream.of(
+ authenticationConfiguration.getClientId(),
+ oidcClient == null ? null : oidcClient.getId())
+ .filter(clientId -> !nullOrEmpty(clientId) && !clientId.isBlank())
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ @VisibleForTesting
+ Optional providerIssuer() {
+ return codeFlowHandler().map(AuthenticationCodeFlowHandler::getProviderIssuer);
+ }
+
+ @VisibleForTesting
+ List providerKeySetUrls() {
+ return codeFlowHandler().map(AuthenticationCodeFlowHandler::getProviderKeySetUrl).stream()
+ .toList();
+ }
+
+ /**
+ * The server-side sign-in handler, when it serves this filter's OIDC client. It registers after
+ * this filter at startup and is replaced on a configuration change, so it is looked up per use.
+ */
+ private Optional codeFlowHandler() {
+ return AuthServeletHandlerRegistry.getHandler() instanceof AuthenticationCodeFlowHandler handler
+ && handler.servesClient(serverSideOidcClient)
+ ? Optional.of(handler)
+ : Optional.empty();
+ }
+
private static Map extractClaims(DecodedJWT jwt) {
Map claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
claims.putAll(jwt.getClaims());
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/MultiUrlJwkProvider.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/MultiUrlJwkProvider.java
index d700d17673a3..2b2233c1a146 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/security/MultiUrlJwkProvider.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/MultiUrlJwkProvider.java
@@ -23,14 +23,20 @@
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.UncheckedExecutionException;
import java.net.URL;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import org.openmetadata.service.exception.UnhandledServerException;
final class MultiUrlJwkProvider implements JwkProvider {
private final List jwkProviders;
+ private final Set configuredUrls;
+ private final Supplier> providerKeySetUrls;
private final LocalJwkProvider localJwkProvider = new LocalJwkProvider();
private final LoadingCache CACHE =
CacheBuilder.newBuilder()
@@ -50,7 +56,7 @@ final class MultiUrlJwkProvider implements JwkProvider {
"JWT Token keyID doesn't match the configured keyID. This usually happens if you didn't configure "
+ "proper publicKeyUrls under authentication configuration.",
null);
- for (JwkProvider jwkProvider : jwkProviders) {
+ for (JwkProvider jwkProvider : candidateProviders()) {
try {
return jwkProvider.get(key);
} catch (JwkException e) {
@@ -62,10 +68,29 @@ final class MultiUrlJwkProvider implements JwkProvider {
});
public MultiUrlJwkProvider(List publicKeyUris) {
+ this(publicKeyUris, List::of);
+ }
+
+ /**
+ * {@code providerKeySetUrls} adds the identity provider's own key set (its discovery document's
+ * {@code jwks_uri}) to the configured URLs: provider tokens are signed with it, and a deployment
+ * configured only through the environment never had it written into {@code publicKeyUrls}.
+ */
+ MultiUrlJwkProvider(List publicKeyUris, Supplier> providerKeySetUrls) {
this.jwkProviders =
- publicKeyUris.stream()
- .map(UrlJwkProvider::new)
- .collect(java.util.stream.Collectors.toList());
+ publicKeyUris.stream().map(UrlJwkProvider::new).collect(Collectors.toList());
+ this.configuredUrls =
+ publicKeyUris.stream().map(URL::toExternalForm).collect(Collectors.toUnmodifiableSet());
+ this.providerKeySetUrls = providerKeySetUrls;
+ }
+
+ private List candidateProviders() {
+ List candidates = new ArrayList<>(jwkProviders);
+ providerKeySetUrls.get().stream()
+ .filter(url -> !configuredUrls.contains(url.toExternalForm()))
+ .map(UrlJwkProvider::new)
+ .forEach(candidates::add);
+ return candidates;
}
@Override
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcProviderTokenRefresher.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcProviderTokenRefresher.java
index db0869902247..16c223dd9726 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcProviderTokenRefresher.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcProviderTokenRefresher.java
@@ -13,6 +13,7 @@
import com.nimbusds.oauth2.sdk.token.RefreshToken;
import com.nimbusds.oauth2.sdk.token.Tokens;
import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
import java.io.IOException;
import java.util.Set;
import java.util.function.Function;
@@ -47,16 +48,15 @@ public enum Status {
/**
* @param rotatedRefreshToken the replacement refresh token when the provider rotates on use,
* otherwise {@code null}
- * @param lifetimeSeconds the renewed access token's {@code expires_in}, or 0 when the provider
- * did not say
+ * @param idToken the renewed ID token, or {@code null} when the provider returned none
*/
- public record Outcome(Status status, String rotatedRefreshToken, long lifetimeSeconds) {
- static Outcome renewed(String rotatedRefreshToken, long lifetimeSeconds) {
- return new Outcome(Status.RENEWED, rotatedRefreshToken, lifetimeSeconds);
+ public record Outcome(Status status, String rotatedRefreshToken, String idToken) {
+ static Outcome renewed(String rotatedRefreshToken, String idToken) {
+ return new Outcome(Status.RENEWED, rotatedRefreshToken, idToken);
}
static Outcome of(Status status) {
- return new Outcome(status, null, 0);
+ return new Outcome(status, null, null);
}
public boolean isRenewed() {
@@ -106,8 +106,12 @@ private static Outcome toOutcome(TokenResponse response) {
private static Outcome renewedOutcome(Tokens tokens) {
RefreshToken rotated = tokens.getRefreshToken();
- return Outcome.renewed(
- rotated == null ? null : rotated.getValue(), tokens.getAccessToken().getLifetime());
+ return Outcome.renewed(rotated == null ? null : rotated.getValue(), idTokenOf(tokens));
+ }
+
+ /** OIDC allows a refresh response without an ID token (OpenID Connect Core 12.2). */
+ private static String idTokenOf(Tokens tokens) {
+ return tokens instanceof OIDCTokens oidcTokens ? oidcTokens.getIDTokenString() : null;
}
private static Outcome refusedOutcome(ErrorObject error) {
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/ProviderTokenSchedule.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/ProviderTokenSchedule.java
deleted file mode 100644
index 4fabef0c8910..000000000000
--- a/openmetadata-service/src/main/java/org/openmetadata/service/security/ProviderTokenSchedule.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.openmetadata.service.security;
-
-import java.util.concurrent.TimeUnit;
-
-/**
- * When a confidential OIDC session has to go back to the identity provider.
- *
- * Renewal follows the provider's own token lifetime ({@code expires_in}), not OpenMetadata's
- * session expiry, and an access token OpenMetadata issues never outlives the point where the
- * provider's tokens need renewing, so an open browser always refreshes in time. A provider session
- * with a short idle timeout (Keycloak counts refresh-token use as activity) then stays alive exactly
- * as long as the user keeps OpenMetadata open, and a provider session that has ended is noticed
- * within one provider token lifetime.
- */
-final class ProviderTokenSchedule {
- /**
- * Floor for a shortened access token. The browser renews a minute before expiry, so a shorter
- * token would send it straight back.
- */
- static final int MIN_ACCESS_TOKEN_VALIDITY_SECONDS = 120;
-
- /** How soon to ask again after the provider gave no verdict (outage, client misconfiguration). */
- static final int RETRY_AFTER_SECONDS = 300;
-
- private ProviderTokenSchedule() {}
-
- /**
- * Due when the provider's tokens would lapse before an access token issued now. A session that
- * never recorded a due time (created before it was tracked) is due.
- */
- static boolean isRenewalDue(Long renewalDueAt, long now, int tokenValiditySeconds) {
- return renewalDueAt == null
- || renewalDueAt < now + TimeUnit.SECONDS.toMillis(tokenValiditySeconds);
- }
-
- /**
- * When tokens the provider has just issued need renewing. Without {@code expires_in} the
- * provider's schedule is unknown, and every refresh renews them.
- */
- static long renewalDueAt(long now, long providerTokenLifetimeSeconds, int tokenValiditySeconds) {
- long lifetimeSeconds =
- providerTokenLifetimeSeconds > 0 ? providerTokenLifetimeSeconds : tokenValiditySeconds;
- return now + TimeUnit.SECONDS.toMillis(lifetimeSeconds);
- }
-
- static long retryAt(long now) {
- return now + TimeUnit.SECONDS.toMillis(RETRY_AFTER_SECONDS);
- }
-
- /**
- * The configured validity, cut short so the browser refreshes before the provider's tokens need
- * renewing. A session with no provider schedule gets the configured validity.
- */
- static int accessTokenValiditySeconds(Long renewalDueAt, long now, int tokenValiditySeconds) {
- if (renewalDueAt == null) {
- return tokenValiditySeconds;
- }
- long secondsUntilDue = TimeUnit.MILLISECONDS.toSeconds(renewalDueAt - now);
- long flooredSeconds = Math.max(secondsUntilDue, MIN_ACCESS_TOKEN_VALIDITY_SECONDS);
- return (int) Math.min(flooredSeconds, tokenValiditySeconds);
- }
-}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/ProviderTokenValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/ProviderTokenValidator.java
new file mode 100644
index 000000000000..56fb1b68aa12
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/ProviderTokenValidator.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openmetadata.service.security;
+
+import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
+
+import com.auth0.jwt.interfaces.DecodedJWT;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * Checks that a token signed by the identity provider's keys was issued to this deployment. A
+ * valid signature does not say that on its own: Google and Microsoft Entra ID sign every
+ * application's tokens with the same keys, so another application's ID token for the same person
+ * verifies just as well. The audience must name one of our clients, and the issuer must be our
+ * provider whenever its discovery document is known to the server.
+ */
+final class ProviderTokenValidator {
+ private static final String TENANT_ID_PLACEHOLDER = "{tenantid}";
+ private static final String TENANT_ID_CLAIM = "tid";
+ private static final String GOOGLE_ISSUER = "https://accounts.google.com";
+ private static final String GOOGLE_SCHEMELESS_ISSUER = "accounts.google.com";
+
+ private final Set clientIds;
+ private final Supplier> providerIssuer;
+
+ ProviderTokenValidator(Set clientIds, Supplier> providerIssuer) {
+ this.clientIds = Set.copyOf(clientIds);
+ this.providerIssuer = providerIssuer;
+ }
+
+ /** For deployments with no OIDC client configured, where there is nothing to compare against. */
+ static ProviderTokenValidator acceptingAnyProviderToken() {
+ return new ProviderTokenValidator(Set.of(), Optional::empty);
+ }
+
+ void validate(DecodedJWT token) {
+ requireIssuedToOurClient(token);
+ providerIssuer.get().ifPresent(issuer -> requireIssuedBy(issuer, token));
+ }
+
+ private void requireIssuedToOurClient(DecodedJWT token) {
+ if (!clientIds.isEmpty() && !isIssuedToOurClient(token)) {
+ throw AuthenticationException.getInvalidTokenException(
+ String.format(
+ "Token audience %s does not include a client configured for this deployment",
+ listOrEmpty(token.getAudience())));
+ }
+ }
+
+ private boolean isIssuedToOurClient(DecodedJWT token) {
+ return listOrEmpty(token.getAudience()).stream().anyMatch(clientIds::contains);
+ }
+
+ private static void requireIssuedBy(String providerIssuer, DecodedJWT token) {
+ if (!isIssuedBy(providerIssuer, token)) {
+ throw AuthenticationException.getInvalidTokenException(
+ String.format(
+ "Token issuer '%s' is not the configured identity provider", token.getIssuer()));
+ }
+ }
+
+ private static boolean isIssuedBy(String providerIssuer, DecodedJWT token) {
+ String expectedIssuer = withTokenTenant(providerIssuer, token);
+ String issuer = token.getIssuer();
+ return expectedIssuer.equals(issuer) || isGoogleSchemelessIssuer(expectedIssuer, issuer);
+ }
+
+ /** Multi-tenant Entra ID discovery names the issuer as a template filled in by each tenant. */
+ private static String withTokenTenant(String providerIssuer, DecodedJWT token) {
+ String tenantId = token.getClaim(TENANT_ID_CLAIM).asString();
+ return tenantId == null
+ ? providerIssuer
+ : providerIssuer.replace(TENANT_ID_PLACEHOLDER, tenantId);
+ }
+
+ /** Google documents both forms as the issuer of its ID tokens. */
+ private static boolean isGoogleSchemelessIssuer(String expectedIssuer, String issuer) {
+ return GOOGLE_ISSUER.equals(expectedIssuer) && GOOGLE_SCHEMELESS_ISSUER.equals(issuer);
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/TestLoginOidcHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/TestLoginOidcHandler.java
index 654e0778b9eb..51571b18bade 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/security/TestLoginOidcHandler.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/TestLoginOidcHandler.java
@@ -19,6 +19,7 @@
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
import com.nimbusds.oauth2.sdk.TokenRequest;
import com.nimbusds.oauth2.sdk.pkce.CodeVerifier;
+import com.nimbusds.oauth2.sdk.token.RefreshToken;
import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
@@ -28,6 +29,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.configuration.SecurityConfiguration;
@@ -49,6 +51,9 @@
* steps {@link AuthenticationCodeFlowHandler} takes for a real login, built from the same
* package-private helpers so the two cannot drift. It stops where the live handler starts writing:
* it never provisions a user, issues a token, or touches the live client, session store or config.
+ * It does renew the test sign-in once with the provider's refresh token, as every live session
+ * refresh does, because a provider that cannot do that sends users back to sign in each time their
+ * ID token expires.
*
* Public clients are not handled here. Their live login redeems the code in the browser, so
* their test does too, through the {@code validate-token} endpoint.
@@ -63,6 +68,25 @@ public record Authorization(String authorizationUrl, TestLoginHandshake.Oidc han
private record ReceivedCallback(
OidcConfiguration configuration, AuthenticationSuccessResponse response) {}
+ private record RedeemedCallback(
+ ReceivedCallback received, OidcCredentials credentials, Map claims) {}
+
+ private static final String NO_REFRESH_TOKEN =
+ "The identity provider issued no refresh token, so OpenMetadata cannot renew sessions: users "
+ + "will be sent back to the provider whenever their ID token expires. Request the "
+ + "offline_access scope (Okta, Microsoft Entra ID, Auth0) or allow refresh tokens for this "
+ + "client; Google issues one only when the user is shown its consent screen.";
+ private static final String REFRESH_REJECTED =
+ "The identity provider refused the refresh token it had just issued, so OpenMetadata cannot "
+ + "renew sessions.";
+ private static final String REFRESH_UNANSWERED =
+ "The identity provider did not renew the session with its refresh token. Check that this "
+ + "client is allowed the refresh_token grant.";
+ private static final String NO_USABLE_ID_TOKEN =
+ "The identity provider renewed the session without an ID token that lasts more than a "
+ + "minute; OpenMetadata needs one to hand to the browser. Check that the openid scope is "
+ + "requested.";
+
private TestLoginOidcHandler() {}
/** Builds the authorization request for the candidate, carrying the Test Login marker as state. */
@@ -98,10 +122,61 @@ public static TestLoginResult complete(
candidate.getAuthenticationConfiguration().getOidcConfiguration();
return receive(clientConfig, handshake, callbackParameters, recorder)
.flatMap(received -> redeem(received, handshake, recorder))
- .map(claims -> TestLoginService.resolveOidcCallbackIdentity(candidate, claims, recorder))
+ .map(redeemed -> renewThenResolve(candidate, redeemed, recorder))
.orElseGet(() -> TestLoginService.failure(TestLoginProtocol.OIDC, recorder));
}
+ /** A failed renewal still resolves the identity, so the admin sees everything that went wrong. */
+ private static TestLoginResult renewThenResolve(
+ SecurityConfiguration candidate, RedeemedCallback redeemed, TestLoginStageRecorder recorder) {
+ checkSessionRenewal(redeemed.received().configuration(), redeemed.credentials(), recorder);
+ return TestLoginService.resolveOidcCallbackIdentity(candidate, redeemed.claims(), recorder);
+ }
+
+ private static void checkSessionRenewal(
+ OidcConfiguration configuration,
+ OidcCredentials credentials,
+ TestLoginStageRecorder recorder) {
+ RefreshToken refreshToken = credentials.toRefreshToken();
+ if (refreshToken == null) {
+ recorder.fail(TestLoginStage.TOKEN_REFRESHED, NO_REFRESH_TOKEN);
+ } else {
+ recordRenewal(refresherFor(configuration).refresh(refreshToken.getValue()), recorder);
+ }
+ }
+
+ private static void recordRenewal(
+ OidcProviderTokenRefresher.Outcome outcome, TestLoginStageRecorder recorder) {
+ long expiresAt = AuthenticationCodeFlowHandler.idTokenExpiresAt(outcome.idToken());
+ if (outcome.isRejected()) {
+ recorder.fail(TestLoginStage.TOKEN_REFRESHED, REFRESH_REJECTED);
+ } else if (!outcome.isRenewed()) {
+ recorder.fail(TestLoginStage.TOKEN_REFRESHED, REFRESH_UNANSWERED);
+ } else if (!AuthenticationCodeFlowHandler.outlivesBrowserRenewal(expiresAt)) {
+ recorder.fail(TestLoginStage.TOKEN_REFRESHED, NO_USABLE_ID_TOKEN);
+ } else {
+ recorder.pass(
+ TestLoginStage.TOKEN_REFRESHED,
+ String.format(
+ "The identity provider renewed the session: its new ID token is valid for %d minutes.",
+ TimeUnit.MILLISECONDS.toMinutes(expiresAt - System.currentTimeMillis())));
+ }
+ }
+
+ /**
+ * The same grant, client authentication and endpoint the live session refresh uses. Built inside
+ * the request factory so a failure becomes the refresher's no-verdict outcome, not a 500.
+ */
+ private static OidcProviderTokenRefresher refresherFor(OidcConfiguration configuration) {
+ return new OidcProviderTokenRefresher(
+ grant ->
+ AuthenticationCodeFlowHandler.createTokenRequest(
+ configuration,
+ AuthenticationCodeFlowHandler.getClientAuthentication(configuration),
+ grant),
+ request -> AuthenticationCodeFlowHandler.executeTokenHttpRequest(configuration, request));
+ }
+
private static Optional receive(
OidcClientConfig clientConfig,
TestLoginHandshake.Oidc handshake,
@@ -141,13 +216,17 @@ private static AuthenticationSuccessResponse requireSuccess(AuthenticationRespon
return (AuthenticationSuccessResponse) response;
}
- private static Optional