diff --git a/bootstrap/sql/migrations/native/2.0.2/mysql/postDataMigrationSQLScript.sql b/bootstrap/sql/migrations/native/2.0.2/mysql/postDataMigrationSQLScript.sql
index e69de29bb2d1..1cf81aa36840 100644
--- a/bootstrap/sql/migrations/native/2.0.2/mysql/postDataMigrationSQLScript.sql
+++ b/bootstrap/sql/migrations/native/2.0.2/mysql/postDataMigrationSQLScript.sql
@@ -0,0 +1,21 @@
+-- OpenMetadata signs its own JWT after both OIDC and SAML logins. A non-positive validity mints
+-- tokens whose expiry equals their issue time, so every request 401s and the client refreshes
+-- forever. Repair values accepted by older SSO forms that had no minimum.
+-- Idempotent: only rewrites values still at or below zero.
+UPDATE openmetadata_settings
+SET json = JSON_SET(json, '$.oidcConfiguration.tokenValidity', 3600)
+WHERE configType = 'authenticationConfiguration'
+ AND JSON_TYPE(JSON_EXTRACT(json, '$.oidcConfiguration.tokenValidity')) IN ('INTEGER', 'DOUBLE')
+ AND CAST(
+ JSON_UNQUOTE(JSON_EXTRACT(json, '$.oidcConfiguration.tokenValidity')) AS DECIMAL(65, 10)
+ ) <= 0;
+
+UPDATE openmetadata_settings
+SET json = JSON_SET(json, '$.samlConfiguration.security.tokenValidity', 3600)
+WHERE configType = 'authenticationConfiguration'
+ AND JSON_TYPE(JSON_EXTRACT(json, '$.samlConfiguration.security.tokenValidity'))
+ IN ('INTEGER', 'DOUBLE')
+ AND CAST(
+ JSON_UNQUOTE(JSON_EXTRACT(json, '$.samlConfiguration.security.tokenValidity'))
+ AS DECIMAL(65, 10)
+ ) <= 0;
diff --git a/bootstrap/sql/migrations/native/2.0.2/postgres/postDataMigrationSQLScript.sql b/bootstrap/sql/migrations/native/2.0.2/postgres/postDataMigrationSQLScript.sql
index e69de29bb2d1..5936bc107f53 100644
--- a/bootstrap/sql/migrations/native/2.0.2/postgres/postDataMigrationSQLScript.sql
+++ b/bootstrap/sql/migrations/native/2.0.2/postgres/postDataMigrationSQLScript.sql
@@ -0,0 +1,23 @@
+-- OpenMetadata signs its own JWT after both OIDC and SAML logins. A non-positive validity mints
+-- tokens whose expiry equals their issue time, so every request 401s and the client refreshes
+-- forever. Repair values accepted by older SSO forms that had no minimum.
+-- Idempotent: only rewrites values still at or below zero.
+UPDATE openmetadata_settings
+SET json = jsonb_set(
+ json,
+ '{oidcConfiguration,tokenValidity}',
+ to_jsonb(3600),
+ false)
+WHERE configtype = 'authenticationConfiguration'
+ AND jsonb_typeof(json #> '{oidcConfiguration,tokenValidity}') = 'number'
+ AND (json #>> '{oidcConfiguration,tokenValidity}')::numeric <= 0;
+
+UPDATE openmetadata_settings
+SET json = jsonb_set(
+ json,
+ '{samlConfiguration,security,tokenValidity}',
+ to_jsonb(3600),
+ false)
+WHERE configtype = 'authenticationConfiguration'
+ AND jsonb_typeof(json #> '{samlConfiguration,security,tokenValidity}') = 'number'
+ AND (json #>> '{samlConfiguration,security,tokenValidity}')::numeric <= 0;
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java
index f15353719a10..f29aae331c06 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SystemRepository.java
@@ -42,6 +42,7 @@
import org.jdbi.v3.sqlobject.transaction.Transaction;
import org.openmetadata.api.configuration.UiThemePreference;
import org.openmetadata.catalog.security.client.SamlSSOClientConfig;
+import org.openmetadata.catalog.type.SamlSecurityConfig;
import org.openmetadata.schema.api.configuration.LogStorageConfiguration;
import org.openmetadata.schema.api.configuration.OpenMetadataBaseUrlConfiguration;
import org.openmetadata.schema.api.search.SearchSettings;
@@ -114,6 +115,7 @@
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.JwtFilter;
import org.openmetadata.service.security.SecurityUtil;
+import org.openmetadata.service.security.TokenValidityResolver;
import org.openmetadata.service.security.auth.LoginAttemptCache;
import org.openmetadata.service.security.auth.validator.Auth0Validator;
import org.openmetadata.service.security.auth.validator.AzureAuthValidator;
@@ -353,6 +355,8 @@ public Response createOrUpdate(Settings setting) {
try {
updateSetting(setting);
+ } catch (BadRequestException ex) {
+ throw ex;
} catch (Exception ex) {
LOG.error(FAILED_TO_UPDATE_SETTINGS, ex.getMessage());
return Response.status(500, INTERNAL_SERVER_ERROR_WITH_REASON + ex.getMessage()).build();
@@ -369,6 +373,8 @@ public Response createOrUpdate(Settings setting) {
public Response createNewSetting(Settings setting) {
try {
updateSetting(setting);
+ } catch (BadRequestException ex) {
+ throw ex;
} catch (Exception ex) {
LOG.error(FAILED_TO_UPDATE_SETTINGS, ex.getMessage());
return Response.status(500, INTERNAL_SERVER_ERROR_WITH_REASON + ex.getMessage()).build();
@@ -484,6 +490,8 @@ public void updateSetting(Settings setting) {
String updatedJson = prepareSettingForUpdate(setting);
dao.insertSettings(setting.getConfigType().toString(), updatedJson);
settingUpdated(setting.getConfigType());
+ } catch (BadRequestException ex) {
+ throw ex;
} catch (Exception ex) {
LOG.error("Failing in Updating Setting.", ex);
throw new CustomExceptionMessage(
@@ -504,7 +512,7 @@ private void updateSettingIfCurrent(Settings setting, String expectedJson) {
"Setting changed while the JSON Patch was being applied");
}
settingUpdated(setting.getConfigType());
- } catch (PreconditionFailedException ex) {
+ } catch (BadRequestException | PreconditionFailedException ex) {
throw ex;
} catch (Exception ex) {
LOG.error("Failing in Updating Setting.", ex);
@@ -551,6 +559,7 @@ private String prepareSettingForUpdate(Settings setting) {
} else if (setting.getConfigType() == SettingsType.AUTHENTICATION_CONFIGURATION) {
AuthenticationConfiguration authConfig =
JsonUtils.convertValue(setting.getConfigValue(), AuthenticationConfiguration.class);
+ rejectInvalidTokenValidity(authConfig);
setting.setConfigValue(authConfig);
} else if (setting.getConfigType() == SettingsType.AUTHORIZER_CONFIGURATION) {
AuthorizerConfiguration authorizerConfig =
@@ -561,6 +570,24 @@ private String prepareSettingForUpdate(Settings setting) {
return JsonUtils.pojoToJson(setting.getConfigValue());
}
+ /**
+ * OpenMetadata signs its own JWT after both OIDC and SAML logins, so a non-positive validity on
+ * either path mints tokens that expire the instant they are issued and locks every user out.
+ */
+ private void rejectInvalidTokenValidity(AuthenticationConfiguration authConfig) {
+ OidcClientConfig oidcConfig = authConfig.getOidcConfiguration();
+ if (oidcConfig != null
+ && TokenValidityResolver.isConfiguredInvalid(oidcConfig.getTokenValidity())) {
+ throw new BadRequestException(TokenValidityResolver.VALIDATION_MESSAGE);
+ }
+ SamlSSOClientConfig samlConfig = authConfig.getSamlConfiguration();
+ SamlSecurityConfig samlSecurity = samlConfig == null ? null : samlConfig.getSecurity();
+ if (samlSecurity != null
+ && TokenValidityResolver.isConfiguredInvalid(samlSecurity.getTokenValidity())) {
+ throw new BadRequestException(TokenValidityResolver.VALIDATION_MESSAGE);
+ }
+ }
+
private void settingUpdated(SettingsType settingsType) {
SettingsCache.invalidateSettings(settingsType.value());
postUpdate(settingsType);
@@ -1701,13 +1728,18 @@ private FieldError validateAuthenticationConfigurationBaseFields(
private FieldError validateOidcConfiguration(
AuthenticationConfiguration authConfig, AuthorizerConfiguration authzConfig) {
try {
+ OidcClientConfig oidcConfig = authConfig.getOidcConfiguration();
+ FieldError tokenValidityError = validateOidcTokenValidity(oidcConfig);
+ if (tokenValidityError != null) {
+ return tokenValidityError;
+ }
+
String clientType = String.valueOf(authConfig.getClientType()).toLowerCase();
if ("confidential".equals(clientType)) {
- if (authConfig.getOidcConfiguration() == null) {
+ if (oidcConfig == null) {
return ValidationErrorBuilder.createFieldError(
FieldPaths.OIDC_CLIENT_ID, "OIDC configuration is required");
}
- OidcClientConfig oidcConfig = authConfig.getOidcConfiguration();
if (nullOrEmpty(oidcConfig.getId())) {
return ValidationErrorBuilder.createFieldError(
@@ -1821,6 +1853,27 @@ private FieldError validateOidcConfiguration(
}
}
+ @VisibleForTesting
+ static FieldError validateOidcTokenValidity(OidcClientConfig oidcConfig) {
+ if (oidcConfig != null
+ && TokenValidityResolver.isConfiguredInvalid(oidcConfig.getTokenValidity())) {
+ return ValidationErrorBuilder.createFieldError(
+ FieldPaths.OIDC_TOKEN_VALIDITY, TokenValidityResolver.VALIDATION_MESSAGE);
+ }
+ return null;
+ }
+
+ @VisibleForTesting
+ static FieldError validateSamlTokenValidity(SamlSSOClientConfig samlConfig) {
+ SamlSecurityConfig samlSecurity = samlConfig == null ? null : samlConfig.getSecurity();
+ if (samlSecurity != null
+ && TokenValidityResolver.isConfiguredInvalid(samlSecurity.getTokenValidity())) {
+ return ValidationErrorBuilder.createFieldError(
+ FieldPaths.SAML_SECURITY_TOKEN_VALIDITY, TokenValidityResolver.VALIDATION_MESSAGE);
+ }
+ return null;
+ }
+
/**
* Re-derives publicKeyUrls from the OIDC discovery document for confidential clients, where the
* field is not user-editable. Runs on every save and validate so that changing discoveryUri does
@@ -2242,6 +2295,10 @@ private FieldError mapLdapExceptionToFieldError(Exception e) {
private FieldError validateSamlConfiguration(
SamlSSOClientConfig samlConfig, OpenMetadataApplicationConfig applicationConfig) {
try {
+ FieldError tokenValidityError = validateSamlTokenValidity(samlConfig);
+ if (tokenValidityError != null) {
+ return tokenValidityError;
+ }
// Use enhanced SAML validator - this performs comprehensive validation
// without affecting production settings
SamlValidator samlValidator = new SamlValidator();
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 e26369d5a8e6..ebc8c1d232ee 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
@@ -257,7 +257,14 @@ private void initializeFields() {
validatePrincipalClaimsMapping(claimsMapping);
this.teamClaimMapping = authenticationConfiguration.getJwtTeamClaimMapping();
this.principalDomain = authorizerConfiguration.getPrincipalDomain();
- this.tokenValidity = authenticationConfiguration.getOidcConfiguration().getTokenValidity();
+ Integer configuredTokenValidity =
+ authenticationConfiguration.getOidcConfiguration().getTokenValidity();
+ if (!TokenValidityResolver.isValid(configuredTokenValidity)) {
+ LOG.warn(
+ "OIDC token validity must be positive; using the {} second default",
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS);
+ }
+ this.tokenValidity = TokenValidityResolver.resolveOrDefault(configuredTokenValidity);
this.maxAge = authenticationConfiguration.getOidcConfiguration().getMaxAge();
this.promptType = authenticationConfiguration.getOidcConfiguration().getPrompt();
this.clientAuthentication = getClientAuthentication(client.getConfiguration());
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/TokenValidityResolver.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/TokenValidityResolver.java
new file mode 100644
index 000000000000..f7feb4563f29
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/TokenValidityResolver.java
@@ -0,0 +1,45 @@
+/*
+ * 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;
+
+/**
+ * Resolves the lifetime of the JWT that OpenMetadata issues for itself after an SSO login. Both the
+ * OIDC and SAML configurations persist this as a user-editable value, and a non-positive one mints
+ * tokens whose expiry equals their issue time, so every request 401s and the client refreshes
+ * forever. Mirrors {@link org.openmetadata.service.security.session.SessionTimeoutResolver}, which
+ * guards the sibling sessionExpiry setting the same way.
+ */
+public final class TokenValidityResolver {
+ public static final int DEFAULT_TOKEN_VALIDITY_SECONDS = 3600;
+ public static final int MIN_TOKEN_VALIDITY_SECONDS = 1;
+ public static final String VALIDATION_MESSAGE = "Token validity must be at least 1 second";
+
+ private TokenValidityResolver() {}
+
+ public static boolean isValid(Integer validitySeconds) {
+ return validitySeconds != null && validitySeconds >= MIN_TOKEN_VALIDITY_SECONDS;
+ }
+
+ /**
+ * Whether a persisted value should be rejected on write. An absent value is accepted because the
+ * schema default applies and {@link #resolveOrDefault} covers it at runtime; only an explicitly
+ * configured non-positive value is a configuration error.
+ */
+ public static boolean isConfiguredInvalid(Integer validitySeconds) {
+ return validitySeconds != null && validitySeconds < MIN_TOKEN_VALIDITY_SECONDS;
+ }
+
+ public static int resolveOrDefault(Integer validitySeconds) {
+ return isValid(validitySeconds) ? validitySeconds : DEFAULT_TOKEN_VALIDITY_SECONDS;
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlSettingsHolder.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlSettingsHolder.java
index b5bd10b4b705..d96bbc0186b3 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlSettingsHolder.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlSettingsHolder.java
@@ -31,6 +31,7 @@
import org.openmetadata.schema.api.security.AuthenticationConfiguration;
import org.openmetadata.schema.api.security.AuthorizerConfiguration;
import org.openmetadata.service.OpenMetadataApplicationConfig;
+import org.openmetadata.service.security.TokenValidityResolver;
import org.openmetadata.service.security.auth.SecurityConfigurationManager;
@Slf4j
@@ -161,7 +162,7 @@ public long getTokenValidity() {
if (authConfig == null) {
LOG.error("AuthenticationConfiguration is null in getTokenValidity()");
- return 3600; // Default fallback
+ return TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS;
}
SamlSSOClientConfig samlConfig = authConfig.getSamlConfiguration();
@@ -169,7 +170,7 @@ public long getTokenValidity() {
if (samlConfig == null) {
LOG.error("SamlConfiguration is null in getTokenValidity()");
- return 3600; // Default fallback
+ return TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS;
}
SamlSecurityConfig securityConfig = samlConfig.getSecurity();
@@ -178,16 +179,20 @@ public long getTokenValidity() {
if (securityConfig == null) {
LOG.error(
"SAML SecurityConfig is null in getTokenValidity() - this should not happen if config is in DB");
- return 3600; // Default fallback
+ return TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS;
}
- long tokenValidity = securityConfig.getTokenValidity();
- LOG.debug("Retrieved token validity: {}", tokenValidity);
- return tokenValidity;
+ Integer configuredTokenValidity = securityConfig.getTokenValidity();
+ if (!TokenValidityResolver.isValid(configuredTokenValidity)) {
+ LOG.warn(
+ "SAML token validity must be positive; using the {} second default",
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS);
+ }
+ return TokenValidityResolver.resolveOrDefault(configuredTokenValidity);
} catch (Exception e) {
LOG.error("Error retrieving token validity dynamically", e);
- return 3600; // Default fallback
+ return TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS;
}
}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java
index ce4e9926bce1..e6949cbd731a 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/ValidationErrorBuilder.java
@@ -34,6 +34,8 @@ public static class FieldPaths {
public static final String OIDC_SERVER_URL =
"authenticationConfiguration.oidcConfiguration.serverUrl";
public static final String OIDC_SCOPE = "authenticationConfiguration.oidcConfiguration.scope";
+ public static final String OIDC_TOKEN_VALIDITY =
+ "authenticationConfiguration.oidcConfiguration.tokenValidity";
public static final String OIDC_CALLBACK_URL =
"authenticationConfiguration.oidcConfiguration.callbackUrl";
public static final String OIDC_TENANT = "authenticationConfiguration.oidcConfiguration.tenant";
@@ -90,6 +92,8 @@ public static class FieldPaths {
"authenticationConfiguration.samlConfiguration.sp.spPrivateKey";
public static final String SAML_SP_CALLBACK =
"authenticationConfiguration.samlConfiguration.sp.callback";
+ public static final String SAML_SECURITY_TOKEN_VALIDITY =
+ "authenticationConfiguration.samlConfiguration.security.tokenValidity";
public static final String SAML_SECURITY_AUTHN_SIGNED =
"authenticationConfiguration.samlConfiguration.security.wantAuthnRequestsSigned";
public static final String SAML_SECURITY_ASSERTIONS_SIGNED =
diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryPatchSettingTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryPatchSettingTest.java
index 314f72504058..eb347215eb75 100644
--- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryPatchSettingTest.java
+++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryPatchSettingTest.java
@@ -20,8 +20,10 @@
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
+import org.openmetadata.schema.api.security.AuthenticationConfiguration;
import org.openmetadata.schema.configuration.GlossaryTermRelationSettings;
import org.openmetadata.schema.email.SmtpSettings;
+import org.openmetadata.schema.security.client.OidcClientConfig;
import org.openmetadata.schema.settings.Settings;
import org.openmetadata.schema.settings.SettingsType;
import org.openmetadata.schema.utils.JsonUtils;
@@ -34,6 +36,7 @@
import org.openmetadata.service.migration.MigrationValidationClient;
import org.openmetadata.service.resources.settings.SettingsCache;
import org.openmetadata.service.secrets.masker.PasswordEntityMasker;
+import org.openmetadata.service.security.TokenValidityResolver;
class SystemRepositoryPatchSettingTest {
private static final String SETTING_NAME = SettingsType.GLOSSARY_TERM_RELATION_SETTINGS.value();
@@ -301,6 +304,24 @@ void putEmailSettingPreservesOmittedPassword() {
assertEquals(PasswordEntityMasker.PASSWORD_MASK, responseConfig.getPassword());
}
+ @Test
+ void putAuthenticationSettingRejectsInvalidOidcTokenValidityAsBadRequest() {
+ Settings update =
+ new Settings()
+ .withConfigType(SettingsType.AUTHENTICATION_CONFIGURATION)
+ .withConfigValue(
+ new AuthenticationConfiguration()
+ .withOidcConfiguration(new OidcClientConfig().withTokenValidity(0)));
+
+ BadRequestException failure =
+ assertThrows(BadRequestException.class, () -> systemRepository.createOrUpdate(update));
+
+ assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), failure.getResponse().getStatus());
+ assertEquals(TokenValidityResolver.VALIDATION_MESSAGE, failure.getMessage());
+ verify(systemDAO, never()).insertSettings(anyString(), anyString());
+ settingsCacheMock.verifyNoInteractions();
+ }
+
private JsonPatch appendRelationTypePatch() {
return Json.createPatchBuilder()
.test("/relationTypes", Json.createArrayBuilder().build())
diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryTokenValidityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryTokenValidityTest.java
new file mode 100644
index 000000000000..ac6b8807dd53
--- /dev/null
+++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryTokenValidityTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.jdbi3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+import org.openmetadata.catalog.security.client.SamlSSOClientConfig;
+import org.openmetadata.catalog.type.SamlSecurityConfig;
+import org.openmetadata.schema.security.client.OidcClientConfig;
+import org.openmetadata.schema.system.FieldError;
+import org.openmetadata.service.security.TokenValidityResolver;
+import org.openmetadata.service.util.ValidationErrorBuilder.FieldPaths;
+
+class SystemRepositoryTokenValidityTest {
+
+ @Test
+ void reportsZeroOidcValidityAsAFieldValidationError() {
+ FieldError error =
+ SystemRepository.validateOidcTokenValidity(new OidcClientConfig().withTokenValidity(0));
+
+ assertEquals(FieldPaths.OIDC_TOKEN_VALIDITY, error.getField());
+ assertEquals(TokenValidityResolver.VALIDATION_MESSAGE, error.getError());
+ }
+
+ @Test
+ void reportsZeroSamlValidityAsAFieldValidationError() {
+ FieldError error = SystemRepository.validateSamlTokenValidity(samlConfigWithValidity(0));
+
+ assertEquals(FieldPaths.SAML_SECURITY_TOKEN_VALIDITY, error.getField());
+ assertEquals(TokenValidityResolver.VALIDATION_MESSAGE, error.getError());
+ }
+
+ @Test
+ void acceptsPositiveValidityAndAbsentOptionalConfiguration() {
+ assertNull(
+ SystemRepository.validateOidcTokenValidity(new OidcClientConfig().withTokenValidity(3600)));
+ assertNull(SystemRepository.validateOidcTokenValidity(null));
+ assertNull(SystemRepository.validateSamlTokenValidity(samlConfigWithValidity(3600)));
+ assertNull(SystemRepository.validateSamlTokenValidity(new SamlSSOClientConfig()));
+ assertNull(SystemRepository.validateSamlTokenValidity(null));
+ }
+
+ /**
+ * A configuration that simply omits the field must stay saveable: the schema default applies and
+ * the runtime resolver covers it. Rejecting an absent value would break every existing config.
+ */
+ @Test
+ void acceptsAnOmittedValidityForBothProviders() {
+ assertNull(SystemRepository.validateOidcTokenValidity(new OidcClientConfig()));
+ assertNull(SystemRepository.validateSamlTokenValidity(samlConfigWithValidity(null)));
+ }
+
+ private SamlSSOClientConfig samlConfigWithValidity(Integer tokenValidity) {
+ return new SamlSSOClientConfig()
+ .withSecurity(new SamlSecurityConfig().withTokenValidity(tokenValidity));
+ }
+}
diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/TokenValiditySqlMigrationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/TokenValiditySqlMigrationTest.java
new file mode 100644
index 000000000000..190363a51c85
--- /dev/null
+++ b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/TokenValiditySqlMigrationTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.migration.utils.v202;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Static guard over the 2.0.2 data repair. It does not execute SQL — replay behaviour against real
+ * MySQL and PostgreSQL is covered manually — but it does fail if either provider path is dropped,
+ * which is the regression that would silently leave upgraded tenants locked out.
+ */
+class TokenValiditySqlMigrationTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"mysql", "postgres"})
+ void repairsNonPositiveTokenValidityForBothProviders(String dialect) throws IOException {
+ String sql = readPostDataMigration(dialect);
+
+ assertTrue(sql.contains("openmetadata_settings"));
+ assertTrue(sql.contains("authenticationconfiguration"));
+ assertTrue(sql.contains("3600"));
+ assertTrue(sql.contains("oidcconfiguration"), "missing the OIDC repair");
+ assertTrue(sql.contains("samlconfiguration"), "missing the SAML repair");
+ }
+
+ /** The guard is what makes a replay a no-op, so every repair statement must carry it. */
+ @ParameterizedTest
+ @ValueSource(strings = {"mysql", "postgres"})
+ void everyRepairIsGuardedSoReplayIsANoOp(String dialect) throws IOException {
+ String sql = readPostDataMigration(dialect);
+
+ assertEquals(2, countOccurrences(sql, "update openmetadata_settings"));
+ assertEquals(2, countOccurrences(sql, "<= 0"));
+ }
+
+ private String readPostDataMigration(String dialect) throws IOException {
+ return Files.readString(
+ migrationRoot().resolve(dialect).resolve("postDataMigrationSQLScript.sql"))
+ .toLowerCase(Locale.ROOT);
+ }
+
+ private int countOccurrences(String sql, String token) {
+ int count = 0;
+ int index = sql.indexOf(token);
+ while (index >= 0) {
+ count++;
+ index = sql.indexOf(token, index + token.length());
+ }
+ return count;
+ }
+
+ private static Path migrationRoot() {
+ Path current = Path.of("").toAbsolutePath();
+ while (current != null && !Files.exists(current.resolve("bootstrap/sql/schema/mysql.sql"))) {
+ current = current.getParent();
+ }
+ if (current == null) {
+ throw new IllegalStateException("Unable to locate the OpenMetadata repository root");
+ }
+ return current.resolve("bootstrap/sql/migrations/native/2.0.2");
+ }
+}
diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/TokenValidityResolverTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/TokenValidityResolverTest.java
new file mode 100644
index 000000000000..a7701c6f4b08
--- /dev/null
+++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/TokenValidityResolverTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+import org.openmetadata.catalog.type.SamlSecurityConfig;
+import org.openmetadata.schema.security.client.OidcClientConfig;
+
+class TokenValidityResolverTest {
+ private static final Validator VALIDATOR =
+ Validation.buildDefaultValidatorFactory().getValidator();
+
+ @Test
+ void schemaAndRuntimeDefaultsStayAlignedForBothProviders() {
+ assertEquals(
+ new OidcClientConfig().getTokenValidity(),
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS);
+ assertEquals(
+ new SamlSecurityConfig().getTokenValidity(),
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS);
+ }
+
+ @Test
+ void invalidValuesResolveToTheDefault() {
+ assertEquals(
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS,
+ TokenValidityResolver.resolveOrDefault(null));
+ assertEquals(
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS,
+ TokenValidityResolver.resolveOrDefault(0));
+ assertEquals(
+ TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS,
+ TokenValidityResolver.resolveOrDefault(-1));
+ }
+
+ @Test
+ void positiveValuesArePreserved() {
+ assertTrue(TokenValidityResolver.isValid(1));
+ assertEquals(900, TokenValidityResolver.resolveOrDefault(900));
+ }
+
+ @Test
+ void zeroAndNegativeValuesAreRejected() {
+ assertFalse(TokenValidityResolver.isValid(null));
+ assertFalse(TokenValidityResolver.isValid(0));
+ assertFalse(TokenValidityResolver.isValid(-1));
+ }
+
+ /**
+ * An omitted value must remain saveable — the schema default covers it and the runtime falls back
+ * — while an explicitly configured non-positive value is a configuration error.
+ */
+ @Test
+ void onlyExplicitNonPositiveValuesAreRejectedOnWrite() {
+ assertFalse(TokenValidityResolver.isConfiguredInvalid(null));
+ assertFalse(TokenValidityResolver.isConfiguredInvalid(1));
+ assertFalse(TokenValidityResolver.isConfiguredInvalid(3600));
+ assertTrue(TokenValidityResolver.isConfiguredInvalid(0));
+ assertTrue(TokenValidityResolver.isConfiguredInvalid(-1));
+ }
+
+ @Test
+ void generatedSchemaConstraintRejectsZeroForBothProviders() {
+ assertTrue(hasTokenValidityViolation(new OidcClientConfig().withTokenValidity(0)));
+ assertTrue(hasTokenValidityViolation(new SamlSecurityConfig().withTokenValidity(0)));
+ }
+
+ private boolean hasTokenValidityViolation(Object config) {
+ return VALIDATOR.validate(config).stream()
+ .anyMatch(violation -> "tokenValidity".equals(violation.getPropertyPath().toString()));
+ }
+}
diff --git a/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json b/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json
index 54acb5ba3495..e7c908e6c2b2 100644
--- a/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json
+++ b/openmetadata-spec/src/main/resources/json/schema/security/client/oidcClientConfig.json
@@ -63,9 +63,10 @@
]
},
"tokenValidity": {
- "description": "Validity for the JWT Token created from SAML Response",
+ "description": "Lifetime in seconds of the OpenMetadata JWT issued after OIDC authentication.",
"type": "integer",
- "default": "3600"
+ "default": 3600,
+ "minimum": 1
},
"customParams": {
"description": "Custom Params.",
@@ -95,7 +96,8 @@
"sessionExpiry": {
"description": "Validity for the Session in case of confidential clients",
"type": "integer",
- "default": "604800"
+ "default": 604800,
+ "minimum": 3600
}
},
"required": ["id", "secret", "discoveryUri", "tenant"],
diff --git a/openmetadata-spec/src/main/resources/json/schema/security/client/samlSSOClientConfig.json b/openmetadata-spec/src/main/resources/json/schema/security/client/samlSSOClientConfig.json
index 70185a3ffc56..d673f0e15e02 100644
--- a/openmetadata-spec/src/main/resources/json/schema/security/client/samlSSOClientConfig.json
+++ b/openmetadata-spec/src/main/resources/json/schema/security/client/samlSSOClientConfig.json
@@ -85,9 +85,10 @@
"default": false
},
"tokenValidity": {
- "description": "Validity for the JWT Token created from SAML Response",
+ "description": "Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication.",
"type": "integer",
- "default": "3600"
+ "default": 3600,
+ "minimum": 1
},
"sendEncryptedNameId": {
"description": "Encrypt Name Id while sending requests from SP.",
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/auth0SSOClientConfig.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/auth0SSOClientConfig.md
index bb2c254bf48c..ceb6b9b407c0 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/auth0SSOClientConfig.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/auth0SSOClientConfig.md
@@ -216,12 +216,14 @@ $$section
$$
$$section
-### OIDC Token Validity $(id="tokenValidity")
+### OpenMetadata Access Token Validity $(id="tokenValidity")
-- **Definition:** How long (in seconds) the issued tokens remain valid.
-- **Default:** 0 (use provider default)
+- **Definition:** How long (in seconds) the OpenMetadata access JWT remains valid.
+- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 3600 (1 hour)
-- **Why it matters:** Controls token lifetime and security vs usability balance.
+- **Why it matters:** Controls the lifetime of the token used for OpenMetadata API requests.
+- **Note:** This value is not inherited from the Auth0 token lifetime.
$$
$$section
@@ -328,4 +330,4 @@ $$section
- **Example:** true
- **Why it matters:** Ensures encrypted communication for security.
- **Note:** Should be enabled in production environments
-$$
\ No newline at end of file
+$$
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/aws-cognito.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/aws-cognito.md
index 330ffed53efa..e844ad320a1b 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/aws-cognito.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/aws-cognito.md
@@ -145,13 +145,14 @@ $$section
$$
$$section
-## OIDC Token Validity $(id="tokenValidity")
+## OpenMetadata Access Token Validity $(id="tokenValidity")
-- **Definition:** How long (in seconds) the issued tokens remain valid.
-- **Default:** 0 (use provider default)
+- **Definition:** How long (in seconds) the OpenMetadata access JWT remains valid.
+- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 3600 (1 hour)
-- **Why it matters:** Controls token lifetime and security vs usability balance.
-- **Note:** Use 0 to inherit Cognito's default token lifetime settings
+- **Why it matters:** Controls the lifetime of the token used for OpenMetadata API requests.
+- **Note:** This value is not inherited from the Cognito token lifetime.
$$
$$section
@@ -251,4 +252,4 @@ $$section
- **Example:** RS256
- **Why it matters:** Must match the algorithm used by AWS Cognito to sign tokens.
- **Note:** AWS Cognito uses RS256
-$$
\ No newline at end of file
+$$
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/awsCognitoSSOClientConfig.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/awsCognitoSSOClientConfig.md
index e3f8939ee05d..296fa969c965 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/awsCognitoSSOClientConfig.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/awsCognitoSSOClientConfig.md
@@ -135,13 +135,14 @@ $$section
$$
$$section
-## OIDC Token Validity $(id="tokenValidity")
+## OpenMetadata Access Token Validity $(id="tokenValidity")
-- **Definition:** How long (in seconds) the issued tokens remain valid.
-- **Default:** 0 (use provider default)
+- **Definition:** How long (in seconds) the OpenMetadata access JWT remains valid.
+- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 3600 (1 hour)
-- **Why it matters:** Controls token lifetime and security vs usability balance.
-- **Note:** Use 0 to inherit Cognito's default token lifetime settings
+- **Why it matters:** Controls the lifetime of the token used for OpenMetadata API requests.
+- **Note:** This value is not inherited from the Cognito token lifetime.
$$
$$section
@@ -308,4 +309,4 @@ $$section
- **Example:** true
- **Why it matters:** Ensures encrypted communication for security.
- **Note:** Should be enabled in production environments
-$$
\ No newline at end of file
+$$
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/azureSSOClientConfig.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/azureSSOClientConfig.md
index 7471720983ac..14412cb90c9e 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/azureSSOClientConfig.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/azureSSOClientConfig.md
@@ -216,12 +216,14 @@ $$section
$$
$$section
-### OIDC Token Validity $(id="tokenValidity")
+### OpenMetadata Access Token Validity $(id="tokenValidity")
-- **Definition:** How long (in seconds) the issued tokens remain valid.
-- **Default:** 0 (use provider default)
+- **Definition:** How long (in seconds) the OpenMetadata access JWT remains valid.
+- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 3600 (1 hour)
-- **Why it matters:** Controls token lifetime and security vs usability balance.
+- **Why it matters:** Controls the lifetime of the token used for OpenMetadata API requests.
+- **Note:** This value is not inherited from the Azure token lifetime.
$$
$$section
@@ -337,4 +339,4 @@ $$section
- **Example:** true
- **Why it matters:** Ensures encrypted communication for security.
- **Note:** Should be enabled in production environments
-$$
\ No newline at end of file
+$$
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/googleSSOClientConfig.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/googleSSOClientConfig.md
index 95b17d0793d7..dc4baa6743a5 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/googleSSOClientConfig.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/googleSSOClientConfig.md
@@ -233,12 +233,14 @@ $$section
$$
$$section
-### OIDC Token Validity $(id="tokenValidity")
+### OpenMetadata Access Token Validity $(id="tokenValidity")
-- **Definition:** How long (in seconds) the issued tokens remain valid.
-- **Default:** 0 (use provider default)
+- **Definition:** How long (in seconds) the OpenMetadata access JWT remains valid.
+- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 3600 (1 hour)
-- **Why it matters:** Controls token lifetime and security vs usability balance.
+- **Why it matters:** Controls the lifetime of the token used for OpenMetadata API requests.
+- **Note:** This value is not inherited from the Google token lifetime.
$$
$$section
@@ -374,4 +376,4 @@ Ensure the following Google APIs are enabled in your Google Cloud Console:
### Service Account (Optional)
For advanced integrations, you may need to create a service account in Google Cloud Console with appropriate permissions for accessing Google Workspace data.
-$$
\ No newline at end of file
+$$
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/oktaSSOClientConfig.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/oktaSSOClientConfig.md
index a89ffa4cc76a..b1413f6339fc 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/oktaSSOClientConfig.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/oktaSSOClientConfig.md
@@ -213,13 +213,14 @@ $$section
$$
$$section
-### OIDC Token Validity $(id="tokenValidity")
+### OpenMetadata Access Token Validity $(id="tokenValidity")
-- **Definition:** How long (in seconds) the issued tokens remain valid.
-- **Default:** 0 (use provider default)
+- **Definition:** How long (in seconds) the OpenMetadata access JWT remains valid.
+- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 3600 (1 hour)
-- **Why it matters:** Controls token lifetime and security vs usability balance.
-- **Note:** Use 0 to inherit Okta's default token lifetime
+- **Why it matters:** Controls the lifetime of the token used for OpenMetadata API requests.
+- **Note:** This value is not inherited from the Okta token lifetime.
$$
$$section
@@ -326,4 +327,4 @@ $$section
- **Example:** true
- **Why it matters:** Ensures encrypted communication for security.
- **Note:** Should be enabled in production environments
-$$
\ No newline at end of file
+$$
diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/samlSSOClientConfig.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/samlSSOClientConfig.md
index 6528e21fee08..7add0568d1e7 100644
--- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/samlSSOClientConfig.md
+++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/SSO/samlSSOClientConfig.md
@@ -145,6 +145,7 @@ $$section
- **Definition:** Validity period (in seconds) for JWT tokens created from SAML response.
- **Default:** 3600 (1 hour)
+- **Minimum:** 1 second
- **Example:** 7200 (2 hours)
- **Why it matters:** Controls how long users stay logged in after SAML authentication.
- **Note:** This controls the OpenMetadata JWT token lifetime, not the SAML assertion lifetime
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.test.tsx
index 125955ebed3f..a3dd3ac0ad72 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.test.tsx
@@ -601,6 +601,52 @@ describe('Test axios response interceptor', () => {
expect(mockAxios).toHaveBeenCalledWith(mockError.config);
expect(result).toEqual({ data: 'ok' });
});
+
+ // A non-positive configured token validity mints a JWT whose exp equals its iat, so
+ // /auth/refresh answers 200 with a token that is already dead. Retrying it 401s and
+ // drives another refresh: the UI spun forever with no error. Fail the cycle instead.
+ it('should not retry when the refreshed token is already expired', async () => {
+ const mockUse = jest.spyOn(axiosClient.interceptors.response, 'use');
+ const mockAxios = jest.fn().mockResolvedValue({ data: 'retried' });
+
+ jest.spyOn(axiosClient, 'request').mockImplementation(mockAxios);
+ mockRefreshToken.mockReset();
+ mockRefreshToken.mockResolvedValue('deadOnArrivalToken');
+ mockExtractDetailsFromToken.mockReturnValue({
+ exp: 1789075036,
+ isExpired: true,
+ timeoutExpiry: 0,
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ const [, errorHandler] = mockUse.mock.calls[0];
+ const mockError = {
+ response: {
+ status: 401,
+ data: { message: 'Expired token!' },
+ },
+ config: {
+ url: '/tables/name/foo',
+ headers: {},
+ baseURL: '',
+ },
+ };
+
+ await expect(errorHandler?.(mockError)).rejects.toBe(mockError);
+
+ // One refresh attempt, no retry with the dead token, and no second cycle.
+ expect(mockRefreshToken).toHaveBeenCalledTimes(1);
+ expect(mockAxios).not.toHaveBeenCalled();
+
+ mockExtractDetailsFromToken.mockReturnValue({
+ exp: 0,
+ isExpired: true,
+ timeoutExpiry: 0,
+ });
+ });
});
// Regression tests for the visibility handler. Before this branch every
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
index aa247a0b0790..26f7cb7a5383 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
@@ -253,6 +253,13 @@ let pendingRequests: {
// them — the bug that hung the UI on a spinner.
let isRefreshDriverActive = false;
+// A refresh can return HTTP 200 carrying a token that is ALREADY expired — a non-positive
+// configured token lifetime mints `exp == iat`. Retrying that token 401s, which drives
+// another refresh, forever, with the user staring at a spinner and nothing in the logs.
+// Bound the consecutive cycles so the failure surfaces as a logout instead.
+const MAX_CONSECUTIVE_REFRESH_CYCLES = 3;
+let consecutiveRefreshCycles = 0;
+
type AuthContextType = {
onLoginHandler: () => void;
onLogoutHandler: () => void;
@@ -704,13 +711,37 @@ export const AuthProvider = ({
if (hasNewToken) {
queued.forEach(
({ resolve: onResolve, reject: onReject, config: queuedConfig }) =>
- axiosClient.request(queuedConfig).then(onResolve).catch(onReject)
+ axiosClient
+ .request(queuedConfig)
+ .then((response) => {
+ // The retry succeeded, so this cycle genuinely recovered the session and
+ // the loop budget starts fresh. A retry that 401s again leaves the budget
+ // spent, which is what eventually breaks a non-recovering loop.
+ consecutiveRefreshCycles = 0;
+ onResolve(response);
+ })
+ .catch(onReject)
);
} else {
queued.forEach(({ reject: onReject }) => onReject(rejectionError));
}
};
+ // A token that decodes to an expiry already in the past can never satisfy the retry, so
+ // retrying it only re-enters the refresh cycle. Requires a real `exp` claim: a token we
+ // cannot decode reports the same `isExpired` and is left to the cycle cap instead, so an
+ // opaque-token provider keeps working.
+ const isTokenAlreadyExpired = (token: unknown) => {
+ const { exp, isExpired } = extractDetailsFromToken(token as string);
+
+ return Boolean(exp) && Boolean(isExpired);
+ };
+
+ const abandonRefresh = (error: unknown) => {
+ drainPendingRequests(false, error);
+ resetUserDetails(true);
+ };
+
// Drives exactly one token refresh for a batch of 401s in THIS tab. Extracted
// from the response interceptor's Promise executor so the refresh-settled
// handlers no longer nest past the depth limit. `resolve` / `reject` belong to
@@ -729,22 +760,27 @@ export const AuthProvider = ({
if (isRefreshDriverActive) {
return;
}
+ if (consecutiveRefreshCycles >= MAX_CONSECUTIVE_REFRESH_CYCLES) {
+ abandonRefresh(error);
+
+ return;
+ }
isRefreshDriverActive = true;
+ consecutiveRefreshCycles += 1;
tokenService.current
.refreshToken()
.then(async (token: unknown) => {
- if (token) {
- await reinit();
- drainPendingRequests(true, error);
- } else {
- drainPendingRequests(false, error);
- resetUserDetails(true);
+ if (!token || isTokenAlreadyExpired(token)) {
+ abandonRefresh(error);
+
+ return;
}
+ await reinit();
+ drainPendingRequests(true, error);
})
.catch(() => {
- drainPendingRequests(false, error);
- resetUserDetails(true);
+ abandonRefresh(error);
});
};
@@ -788,7 +824,13 @@ export const AuthProvider = ({
// Axios response interceptor for statusCode 401,403
responseInterceptor = axiosClient.interceptors.response.use(
- (response) => response,
+ (response) => {
+ // Any non-401 response proves the current token works, so a later unrelated
+ // expiry still gets the full refresh budget.
+ consecutiveRefreshCycles = 0;
+
+ return response;
+ },
(error) => {
if (error.response) {
const { status } = error.response;
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx
index b7b38c253c32..1d3b4fd17d93 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/SettingsSso/SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx
@@ -417,12 +417,18 @@ export const SSOGroupedFieldTemplate: FunctionComponent<
{!isEmpty(advancedProperties) && (
-
+ {/* Advanced fields must stay mounted: transformErrors discards any error whose
+ field is absent from the DOM, so destroying the collapsed panel silently
+ swallowed both client and server validation errors for fields like
+ tokenValidity instead of surfacing them. */}
+