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. */} +