From 630f3d973b2ab63fa54e75655997e9285a7c7b3d Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 10 Sep 2026 15:57:09 -0700 Subject: [PATCH 1/7] Fix OIDC zero-lifetime access tokens --- .../native/2.0.2/mysql/schemaChanges.sql | 10 +++ .../native/2.0.2/postgres/schemaChanges.sql | 12 ++++ .../service/jdbi3/SystemRepository.java | 22 +++++- .../AuthenticationCodeFlowHandler.java | 9 ++- .../service/security/OidcTokenValidity.java | 34 ++++++++++ .../service/util/ValidationErrorBuilder.java | 2 + ...SystemRepositoryOidcTokenValidityTest.java | 40 +++++++++++ .../OidcTokenValiditySqlMigrationTest.java | 50 ++++++++++++++ .../security/OidcTokenValidityTest.java | 68 +++++++++++++++++++ .../security/client/oidcClientConfig.json | 5 +- .../locales/en-US/SSO/auth0SSOClientConfig.md | 12 ++-- .../public/locales/en-US/SSO/aws-cognito.md | 13 ++-- .../en-US/SSO/awsCognitoSSOClientConfig.md | 13 ++-- .../locales/en-US/SSO/azureSSOClientConfig.md | 12 ++-- .../en-US/SSO/googleSSOClientConfig.md | 12 ++-- .../locales/en-US/SSO/oktaSSOClientConfig.md | 13 ++-- .../ui/src/constants/SSO.constant.ts | 4 +- .../authenticationConfiguration.ts | 2 +- .../configuration/securityConfiguration.ts | 2 +- .../security/client/oidcClientConfig.ts | 2 +- .../ui/src/generated/settings/settings.ts | 2 +- .../generated/system/testLoginTokenRequest.ts | 2 +- .../authenticationConfiguration.json | 5 +- 23 files changed, 299 insertions(+), 47 deletions(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/OidcTokenValiditySqlMigrationTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java diff --git a/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql index c0d6e1c14333..dbec70d61417 100644 --- a/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql @@ -156,3 +156,13 @@ UPDATE change_event_consumers SET json = JSON_SET(json, '$.startingTimestamp', CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS UNSIGNED)) WHERE extension = 'eventSubscription.Offset' AND JSON_EXTRACT(json, '$.startingTimestamp') IS NULL; + +-- OpenMetadata issues this JWT itself; a non-positive lifetime makes every login token expire +-- immediately. Repair persisted values accepted by older SSO forms before auth configuration loads. +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; diff --git a/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql index f89d710ffb6e..c4204523acec 100644 --- a/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql @@ -115,3 +115,15 @@ SET json = jsonb_set( to_jsonb((EXTRACT(EPOCH FROM now()) * 1000)::bigint)) WHERE extension = 'eventSubscription.Offset' AND json ->> 'startingTimestamp' IS NULL; + +-- OpenMetadata issues this JWT itself; a non-positive lifetime makes every login token expire +-- immediately. Repair persisted values accepted by older SSO forms before auth configuration loads. +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; 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..bb0f858d5d75 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 @@ -113,6 +113,7 @@ import org.openmetadata.service.security.AuthenticationCodeFlowHandler; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.JwtFilter; +import org.openmetadata.service.security.OidcTokenValidity; import org.openmetadata.service.security.SecurityUtil; import org.openmetadata.service.security.auth.LoginAttemptCache; import org.openmetadata.service.security.auth.validator.Auth0Validator; @@ -551,6 +552,9 @@ private String prepareSettingForUpdate(Settings setting) { } else if (setting.getConfigType() == SettingsType.AUTHENTICATION_CONFIGURATION) { AuthenticationConfiguration authConfig = JsonUtils.convertValue(setting.getConfigValue(), AuthenticationConfiguration.class); + if (authConfig.getOidcConfiguration() != null) { + OidcTokenValidity.validate(authConfig.getOidcConfiguration().getTokenValidity()); + } setting.setConfigValue(authConfig); } else if (setting.getConfigType() == SettingsType.AUTHORIZER_CONFIGURATION) { AuthorizerConfiguration authorizerConfig = @@ -1701,13 +1705,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 +1830,15 @@ private FieldError validateOidcConfiguration( } } + @VisibleForTesting + static FieldError validateOidcTokenValidity(OidcClientConfig oidcConfig) { + if (oidcConfig != null && !OidcTokenValidity.isValid(oidcConfig.getTokenValidity())) { + return ValidationErrorBuilder.createFieldError( + FieldPaths.OIDC_TOKEN_VALIDITY, OidcTokenValidity.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 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..3d0576b0a49c 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 (!OidcTokenValidity.isValid(configuredTokenValidity)) { + LOG.warn( + "OIDC token validity must be positive; using the {} second default", + OidcTokenValidity.DEFAULT_VALIDITY_SECONDS); + } + this.tokenValidity = OidcTokenValidity.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/OidcTokenValidity.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java new file mode 100644 index 000000000000..381175a4551e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java @@ -0,0 +1,34 @@ +/* + * 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; + +public final class OidcTokenValidity { + public static final int DEFAULT_VALIDITY_SECONDS = 3600; + public static final String VALIDATION_MESSAGE = "OIDC token validity must be at least 1 second"; + + private OidcTokenValidity() {} + + public static boolean isValid(Integer validitySeconds) { + return validitySeconds != null && validitySeconds > 0; + } + + public static int resolveOrDefault(Integer validitySeconds) { + return isValid(validitySeconds) ? validitySeconds : DEFAULT_VALIDITY_SECONDS; + } + + public static void validate(Integer validitySeconds) { + if (!isValid(validitySeconds)) { + throw new IllegalArgumentException(VALIDATION_MESSAGE); + } + } +} 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..7263fb9f1a37 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"; diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java new file mode 100644 index 000000000000..3ead6190c525 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java @@ -0,0 +1,40 @@ +/* + * 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.schema.security.client.OidcClientConfig; +import org.openmetadata.schema.system.FieldError; +import org.openmetadata.service.util.ValidationErrorBuilder.FieldPaths; + +class SystemRepositoryOidcTokenValidityTest { + + @Test + void reportsZeroAsAFieldValidationError() { + FieldError error = + SystemRepository.validateOidcTokenValidity(new OidcClientConfig().withTokenValidity(0)); + + assertEquals(FieldPaths.OIDC_TOKEN_VALIDITY, error.getField()); + assertEquals("OIDC token validity must be at least 1 second", error.getError()); + } + + @Test + void acceptsPositiveValidityAndAbsentOptionalOidcConfiguration() { + assertNull( + SystemRepository.validateOidcTokenValidity(new OidcClientConfig().withTokenValidity(3600))); + assertNull(SystemRepository.validateOidcTokenValidity(null)); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/OidcTokenValiditySqlMigrationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/OidcTokenValiditySqlMigrationTest.java new file mode 100644 index 000000000000..7e6c745b7beb --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/OidcTokenValiditySqlMigrationTest.java @@ -0,0 +1,50 @@ +/* + * 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.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; + +class OidcTokenValiditySqlMigrationTest { + + @ParameterizedTest + @ValueSource(strings = {"mysql", "postgres"}) + void repairsNonPositiveOidcTokenValidity(String dialect) throws IOException { + String sql = + Files.readString(migrationRoot().resolve(dialect).resolve("schemaChanges.sql")) + .toLowerCase(Locale.ROOT); + + assertTrue(sql.contains("openmetadata_settings")); + assertTrue(sql.contains("authenticationconfiguration")); + assertTrue(sql.contains("tokenvalidity")); + assertTrue(sql.contains("3600")); + assertTrue(sql.contains("<= 0")); + } + + 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/OidcTokenValidityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java new file mode 100644 index 000000000000..d72d39810f95 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java @@ -0,0 +1,68 @@ +/* + * 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.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.schema.security.client.OidcClientConfig; + +class OidcTokenValidityTest { + private static final Validator VALIDATOR = + Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void schemaAndRuntimeDefaultsStayAligned() { + assertEquals( + new OidcClientConfig().getTokenValidity(), OidcTokenValidity.DEFAULT_VALIDITY_SECONDS); + } + + @Test + void invalidValuesResolveToTheDefault() { + assertEquals( + OidcTokenValidity.DEFAULT_VALIDITY_SECONDS, OidcTokenValidity.resolveOrDefault(null)); + assertEquals(OidcTokenValidity.DEFAULT_VALIDITY_SECONDS, OidcTokenValidity.resolveOrDefault(0)); + assertEquals( + OidcTokenValidity.DEFAULT_VALIDITY_SECONDS, OidcTokenValidity.resolveOrDefault(-1)); + } + + @Test + void positiveValuesArePreserved() { + assertTrue(OidcTokenValidity.isValid(1)); + assertEquals(900, OidcTokenValidity.resolveOrDefault(900)); + assertDoesNotThrow(() -> OidcTokenValidity.validate(900)); + } + + @Test + void zeroAndNegativeValuesAreRejected() { + assertFalse(OidcTokenValidity.isValid(null)); + assertFalse(OidcTokenValidity.isValid(0)); + assertFalse(OidcTokenValidity.isValid(-1)); + assertThrows(IllegalArgumentException.class, () -> OidcTokenValidity.validate(0)); + } + + @Test + void generatedSchemaConstraintRejectsZero() { + OidcClientConfig oidcConfig = new OidcClientConfig().withTokenValidity(0); + + assertTrue( + VALIDATOR.validate(oidcConfig).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..d0c043825a17 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.", 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/src/constants/SSO.constant.ts b/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts index 1c83adbe37bf..7d19d559bf7f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/SSO.constant.ts @@ -131,7 +131,7 @@ export const COMMON_UI_FIELDS = { oidcClientAuthenticationMethod: { 'ui:title': 'OIDC Client Authentication Method', }, - oidcTokenValidity: { 'ui:title': 'OIDC Token Validity' }, + oidcTokenValidity: { 'ui:title': 'OpenMetadata Access Token Validity' }, oidcCustomParameters: { 'ui:title': 'OIDC Custom Parameters' }, oidcMaxAge: { 'ui:title': 'OIDC Max Age' }, oidcPrompt: { 'ui:title': 'OIDC Prompt' }, @@ -544,7 +544,7 @@ export const GOOGLE_OAUTH_UI_SCHEMA = { maxClockSkew: COMMON_UI_FIELDS.oidcMaxClockSkew, clientAuthenticationMethod: { 'ui:widget': 'hidden', 'ui:hideError': true }, tokenValidity: { - 'ui:title': 'OIDC Token Validity', + 'ui:title': 'OpenMetadata Access Token Validity', 'ui:placeholder': `Default: ${OIDC_SSO_DEFAULTS.tokenValidity}`, }, customParams: COMMON_UI_FIELDS.oidcCustomParameters, diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts index d3f4e0b7c4dd..34e26d748326 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts @@ -377,7 +377,7 @@ export interface OidcClientConfig { */ tenant: string; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after OIDC authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts index 1cc7eb9e9676..2068208aaca0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts @@ -393,7 +393,7 @@ export interface OidcClientConfig { */ tenant: string; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after OIDC authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts b/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts index c04eb72d88ce..3a5acef09ffd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/security/client/oidcClientConfig.ts @@ -79,7 +79,7 @@ export interface OidcClientConfig { */ tenant: string; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after OIDC authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts b/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts index 0fbc9bbef53e..f1e206172093 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts @@ -1626,7 +1626,7 @@ export interface OidcClientConfig { */ tenant: string; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after OIDC authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts b/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts index d21ae0d5e054..da52a2f19606 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts @@ -412,7 +412,7 @@ export interface OidcClientConfig { */ tenant: string; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after OIDC authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/jsons/configuration/authenticationConfiguration.json b/openmetadata-ui/src/main/resources/ui/src/jsons/configuration/authenticationConfiguration.json index 8f5f5cce83f4..fb242b6c8f8e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/jsons/configuration/authenticationConfiguration.json +++ b/openmetadata-ui/src/main/resources/ui/src/jsons/configuration/authenticationConfiguration.json @@ -714,9 +714,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.", From 4e1f6490a6b73c05aecc5be150dae4b6107c131d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 23:00:37 +0000 Subject: [PATCH 2/7] chore(playwright): auto-refresh impact-map.generated.json --- .github/playwright/impact-map.generated.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 2100976494ab..0e7bcaabc0e2 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -1604,6 +1604,7 @@ "playwright/e2e/Features/TestSuiteMultiPipeline.spec.ts", "playwright/e2e/Features/TestSuitePipelineRedeploy.spec.ts", "playwright/e2e/Features/TierDropdown.spec.ts", + "playwright/e2e/Features/TierWidget.spec.ts", "playwright/e2e/Flow/CustomizeWidgets.spec.ts", "playwright/e2e/Flow/ExploreDiscovery.spec.ts", "playwright/e2e/Flow/LineageSettings.spec.ts", @@ -2060,6 +2061,7 @@ "playwright/e2e/Features/TeamsHierarchy.spec.ts", "playwright/e2e/Features/TestSuiteMultiPipeline.spec.ts", "playwright/e2e/Features/TierDropdown.spec.ts", + "playwright/e2e/Features/TierWidget.spec.ts", "playwright/e2e/Features/UserProfileOnlineStatus.spec.ts", "playwright/e2e/Features/Workflows/NoOpWorkflowNodeConfig.spec.ts", "playwright/e2e/Features/Workflows/RecognizerFeedbackSchemaNodeLock.spec.ts", @@ -3138,6 +3140,7 @@ "playwright/e2e/Features/TestSuiteMultiPipeline.spec.ts", "playwright/e2e/Features/TestSuitePipelineRedeploy.spec.ts", "playwright/e2e/Features/TierDropdown.spec.ts", + "playwright/e2e/Features/TierWidget.spec.ts", "playwright/e2e/Features/Topic.spec.ts", "playwright/e2e/Features/UserProfileOnlineStatus.spec.ts", "playwright/e2e/Features/Workflows/NoOpWorkflowNodeConfig.spec.ts", @@ -3435,6 +3438,7 @@ "playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts", "playwright/e2e/Features/Permissions/DataProductPermissions.spec.ts", "playwright/e2e/Features/SampleDataDomainDataProduct.spec.ts", + "playwright/e2e/Features/TierWidget.spec.ts", "playwright/e2e/Flow/IngestionBot.spec.ts", "playwright/e2e/Pages/DataContractInheritance.spec.ts", "playwright/e2e/Pages/DataMarketplace.spec.ts", @@ -3597,6 +3601,7 @@ "playwright/e2e/Features/Tasks/TaskNavigation.spec.ts", "playwright/e2e/Features/TeamSubscriptions.spec.ts", "playwright/e2e/Features/TestSuiteMultiPipeline.spec.ts", + "playwright/e2e/Features/TierWidget.spec.ts", "playwright/e2e/Features/Topic.spec.ts", "playwright/e2e/Features/Workflows/NoOpWorkflowNodeConfig.spec.ts", "playwright/e2e/Features/Workflows/RecognizerFeedbackSchemaNodeLock.spec.ts", @@ -4407,7 +4412,8 @@ "openmetadata-ui/src/main/resources/ui/playwright/utils/tier.ts" ], "specs": [ - "playwright/e2e/Features/TierDropdown.spec.ts" + "playwright/e2e/Features/TierDropdown.spec.ts", + "playwright/e2e/Features/TierWidget.spec.ts" ] }, { From c546e94b762240ac8290c3c8378fa0bb4726463b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 10 Sep 2026 16:23:14 -0700 Subject: [PATCH 3/7] Return bad request for invalid OIDC token validity --- .../service/jdbi3/SystemRepository.java | 13 +++++++++--- .../service/security/OidcTokenValidity.java | 6 ------ .../SystemRepositoryPatchSettingTest.java | 21 +++++++++++++++++++ .../security/OidcTokenValidityTest.java | 4 ---- 4 files changed, 31 insertions(+), 13 deletions(-) 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 bb0f858d5d75..d5cb2970219d 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 @@ -354,6 +354,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(); @@ -370,6 +372,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(); @@ -485,6 +489,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( @@ -505,7 +511,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); @@ -552,8 +558,9 @@ private String prepareSettingForUpdate(Settings setting) { } else if (setting.getConfigType() == SettingsType.AUTHENTICATION_CONFIGURATION) { AuthenticationConfiguration authConfig = JsonUtils.convertValue(setting.getConfigValue(), AuthenticationConfiguration.class); - if (authConfig.getOidcConfiguration() != null) { - OidcTokenValidity.validate(authConfig.getOidcConfiguration().getTokenValidity()); + if (authConfig.getOidcConfiguration() != null + && !OidcTokenValidity.isValid(authConfig.getOidcConfiguration().getTokenValidity())) { + throw new BadRequestException(OidcTokenValidity.VALIDATION_MESSAGE); } setting.setConfigValue(authConfig); } else if (setting.getConfigType() == SettingsType.AUTHORIZER_CONFIGURATION) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java index 381175a4551e..7c0ed3e0c179 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java @@ -25,10 +25,4 @@ public static boolean isValid(Integer validitySeconds) { public static int resolveOrDefault(Integer validitySeconds) { return isValid(validitySeconds) ? validitySeconds : DEFAULT_VALIDITY_SECONDS; } - - public static void validate(Integer validitySeconds) { - if (!isValid(validitySeconds)) { - throw new IllegalArgumentException(VALIDATION_MESSAGE); - } - } } 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..0eebcb1c402b 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.OidcTokenValidity; 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(OidcTokenValidity.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/security/OidcTokenValidityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java index d72d39810f95..54226100ed80 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java @@ -12,10 +12,8 @@ */ package org.openmetadata.service.security; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import jakarta.validation.Validation; @@ -46,7 +44,6 @@ void invalidValuesResolveToTheDefault() { void positiveValuesArePreserved() { assertTrue(OidcTokenValidity.isValid(1)); assertEquals(900, OidcTokenValidity.resolveOrDefault(900)); - assertDoesNotThrow(() -> OidcTokenValidity.validate(900)); } @Test @@ -54,7 +51,6 @@ void zeroAndNegativeValuesAreRejected() { assertFalse(OidcTokenValidity.isValid(null)); assertFalse(OidcTokenValidity.isValid(0)); assertFalse(OidcTokenValidity.isValid(-1)); - assertThrows(IllegalArgumentException.class, () -> OidcTokenValidity.validate(0)); } @Test From 12a7843975a60d6bbdcf167aca2de73e70cc1ac0 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 10 Sep 2026 21:07:40 -0700 Subject: [PATCH 4/7] Fix RDF migration test schema setup --- .../it/tests/RdfTestDatabase.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java index 9d47d2a54253..1385d9b07680 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java @@ -73,6 +73,7 @@ Jdbi jdbi() { } private void initializeSchema() { + createFlywayTable("v004__create_db_connection_info.sql", "openmetadata_settings"); createTable("1.3.0", "change_event_consumers"); createTable("1.13.0", "rdf_index_job"); createTable("1.13.0", "rdf_index_partition"); @@ -86,11 +87,32 @@ void applyReleaseMigration() { } private void createTable(final String version, final String table) { + final String dialect = backend == Backend.MYSQL ? "mysql" : "postgres"; + final Path path = + repositoryRoot() + .resolve( + "bootstrap/sql/migrations/native/" + + version + + "/" + + dialect + + "/schemaChanges.sql"); + createTable(path, table); + } + + private void createFlywayTable(final String migration, final String table) { + final String driver = + backend == Backend.MYSQL ? "com.mysql.cj.jdbc.Driver" : "org.postgresql.Driver"; + final Path path = + repositoryRoot().resolve("bootstrap/sql/migrations/flyway/" + driver + "/" + migration); + createTable(path, table); + } + + private void createTable(final Path path, final String table) { final String create = - migrationStatements(version).stream() + migrationStatements(path).stream() .filter(statement -> statement.contains("CREATE TABLE IF NOT EXISTS " + table + " (")) .findFirst() - .orElseThrow(() -> new IllegalStateException("Missing migration for " + table)); + .orElseThrow(() -> new IllegalStateException("Missing table migration for " + table)); jdbi.useHandle(handle -> handle.execute(create)); } @@ -104,6 +126,10 @@ private List migrationStatements(final String version) { + "/" + dialect + "/schemaChanges.sql"); + return migrationStatements(path); + } + + private List migrationStatements(final Path path) { return MigrationFile.parseSQLFile( path.toFile(), backend == Backend.MYSQL ? ConnectionType.MYSQL : ConnectionType.POSTGRES); } From af4253ab6845737a0eaad762fdaafa75b9209391 Mon Sep 17 00:00:00 2001 From: mohitdeuex Date: Fri, 11 Sep 2026 19:13:36 +0530 Subject: [PATCH 5/7] fix(auth): guard the OpenMetadata JWT validity for SAML as well as OIDC PR #33172 clamps a non-positive oidcConfiguration.tokenValidity, which is what locked Remitly out after their 2.0.1 upgrade. Four gaps remained: - SAML had the identical defect. SamlSettingsHolder.getTokenValidity() fell back to 3600 on every null branch but returned a stored 0 verbatim, so a SAML tenant reproduces the same lockout. Both providers now share one TokenValidityResolver, mirroring the existing SessionTimeoutResolver, and both are validated on write. - The new minimum could never be shown. transformErrors drops any error whose field is absent from the DOM, and tokenValidity lives in the Advanced Config panel, which was destroyInactivePanel. Both the client-side minimum and the server 400 were silently swallowed while the panel was collapsed; it now forceRenders. - Nothing bounded the client loop. A refresh returning 200 with an already-expired token was retried, 401'd and refreshed again indefinitely. AuthProvider now refuses a token that decodes to a past exp and caps consecutive refresh->401 cycles. - samlSSOClientConfig.tokenValidity and oidcClientConfig.sessionExpiry still declared string defaults on integer fields, so neither default ever materialised. Moves the 2.0.2 data repair from schemaChanges.sql to postDataMigrationSQLScript.sql per bootstrap/MIGRATION_SYSTEM.md and extends it to the SAML path. That removes the reason for the RdfTestDatabase change, which is reverted. An omitted tokenValidity is now accepted on write, since the schema default and the runtime resolver both cover it; only an explicitly non-positive value is rejected. Co-Authored-By: Claude Opus 5 --- .../mysql/postDataMigrationSQLScript.sql | 21 +++++ .../native/2.0.2/mysql/schemaChanges.sql | 10 --- .../postgres/postDataMigrationSQLScript.sql | 23 +++++ .../native/2.0.2/postgres/schemaChanges.sql | 12 --- .../it/tests/RdfTestDatabase.java | 30 +------ .../service/jdbi3/SystemRepository.java | 46 ++++++++-- .../AuthenticationCodeFlowHandler.java | 6 +- .../service/security/OidcTokenValidity.java | 28 ------ .../security/TokenValidityResolver.java | 45 ++++++++++ .../security/saml/SamlSettingsHolder.java | 19 ++-- .../service/util/ValidationErrorBuilder.java | 2 + ...SystemRepositoryOidcTokenValidityTest.java | 40 --------- .../SystemRepositoryPatchSettingTest.java | 4 +- .../SystemRepositoryTokenValidityTest.java | 69 +++++++++++++++ ...ava => TokenValiditySqlMigrationTest.java} | 44 ++++++++-- .../security/OidcTokenValidityTest.java | 64 -------------- .../security/TokenValidityResolverTest.java | 88 +++++++++++++++++++ .../security/client/oidcClientConfig.json | 3 +- .../security/client/samlSSOClientConfig.json | 5 +- .../locales/en-US/SSO/samlSSOClientConfig.md | 1 + .../Auth/AuthProviders/AuthProvider.test.tsx | 46 ++++++++++ .../Auth/AuthProviders/AuthProvider.tsx | 62 ++++++++++--- .../SSOGroupedFieldTemplate.tsx | 7 +- .../authenticationConfiguration.json | 13 +-- 24 files changed, 460 insertions(+), 228 deletions(-) delete mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/security/TokenValidityResolver.java delete mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryTokenValidityTest.java rename openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/{OidcTokenValiditySqlMigrationTest.java => TokenValiditySqlMigrationTest.java} (50%) delete mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/security/TokenValidityResolverTest.java 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/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql index dbec70d61417..c0d6e1c14333 100644 --- a/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.2/mysql/schemaChanges.sql @@ -156,13 +156,3 @@ UPDATE change_event_consumers SET json = JSON_SET(json, '$.startingTimestamp', CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS UNSIGNED)) WHERE extension = 'eventSubscription.Offset' AND JSON_EXTRACT(json, '$.startingTimestamp') IS NULL; - --- OpenMetadata issues this JWT itself; a non-positive lifetime makes every login token expire --- immediately. Repair persisted values accepted by older SSO forms before auth configuration loads. -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; 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/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql index c4204523acec..f89d710ffb6e 100644 --- a/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.2/postgres/schemaChanges.sql @@ -115,15 +115,3 @@ SET json = jsonb_set( to_jsonb((EXTRACT(EPOCH FROM now()) * 1000)::bigint)) WHERE extension = 'eventSubscription.Offset' AND json ->> 'startingTimestamp' IS NULL; - --- OpenMetadata issues this JWT itself; a non-positive lifetime makes every login token expire --- immediately. Repair persisted values accepted by older SSO forms before auth configuration loads. -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; diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java index 1385d9b07680..9d47d2a54253 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfTestDatabase.java @@ -73,7 +73,6 @@ Jdbi jdbi() { } private void initializeSchema() { - createFlywayTable("v004__create_db_connection_info.sql", "openmetadata_settings"); createTable("1.3.0", "change_event_consumers"); createTable("1.13.0", "rdf_index_job"); createTable("1.13.0", "rdf_index_partition"); @@ -87,32 +86,11 @@ void applyReleaseMigration() { } private void createTable(final String version, final String table) { - final String dialect = backend == Backend.MYSQL ? "mysql" : "postgres"; - final Path path = - repositoryRoot() - .resolve( - "bootstrap/sql/migrations/native/" - + version - + "/" - + dialect - + "/schemaChanges.sql"); - createTable(path, table); - } - - private void createFlywayTable(final String migration, final String table) { - final String driver = - backend == Backend.MYSQL ? "com.mysql.cj.jdbc.Driver" : "org.postgresql.Driver"; - final Path path = - repositoryRoot().resolve("bootstrap/sql/migrations/flyway/" + driver + "/" + migration); - createTable(path, table); - } - - private void createTable(final Path path, final String table) { final String create = - migrationStatements(path).stream() + migrationStatements(version).stream() .filter(statement -> statement.contains("CREATE TABLE IF NOT EXISTS " + table + " (")) .findFirst() - .orElseThrow(() -> new IllegalStateException("Missing table migration for " + table)); + .orElseThrow(() -> new IllegalStateException("Missing migration for " + table)); jdbi.useHandle(handle -> handle.execute(create)); } @@ -126,10 +104,6 @@ private List migrationStatements(final String version) { + "/" + dialect + "/schemaChanges.sql"); - return migrationStatements(path); - } - - private List migrationStatements(final Path path) { return MigrationFile.parseSQLFile( path.toFile(), backend == Backend.MYSQL ? ConnectionType.MYSQL : ConnectionType.POSTGRES); } 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 d5cb2970219d..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; @@ -113,8 +114,8 @@ import org.openmetadata.service.security.AuthenticationCodeFlowHandler; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.JwtFilter; -import org.openmetadata.service.security.OidcTokenValidity; 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; @@ -558,10 +559,7 @@ private String prepareSettingForUpdate(Settings setting) { } else if (setting.getConfigType() == SettingsType.AUTHENTICATION_CONFIGURATION) { AuthenticationConfiguration authConfig = JsonUtils.convertValue(setting.getConfigValue(), AuthenticationConfiguration.class); - if (authConfig.getOidcConfiguration() != null - && !OidcTokenValidity.isValid(authConfig.getOidcConfiguration().getTokenValidity())) { - throw new BadRequestException(OidcTokenValidity.VALIDATION_MESSAGE); - } + rejectInvalidTokenValidity(authConfig); setting.setConfigValue(authConfig); } else if (setting.getConfigType() == SettingsType.AUTHORIZER_CONFIGURATION) { AuthorizerConfiguration authorizerConfig = @@ -572,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); @@ -1839,9 +1855,21 @@ private FieldError validateOidcConfiguration( @VisibleForTesting static FieldError validateOidcTokenValidity(OidcClientConfig oidcConfig) { - if (oidcConfig != null && !OidcTokenValidity.isValid(oidcConfig.getTokenValidity())) { + if (oidcConfig != null + && TokenValidityResolver.isConfiguredInvalid(oidcConfig.getTokenValidity())) { return ValidationErrorBuilder.createFieldError( - FieldPaths.OIDC_TOKEN_VALIDITY, OidcTokenValidity.VALIDATION_MESSAGE); + 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; } @@ -2267,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 3d0576b0a49c..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 @@ -259,12 +259,12 @@ private void initializeFields() { this.principalDomain = authorizerConfiguration.getPrincipalDomain(); Integer configuredTokenValidity = authenticationConfiguration.getOidcConfiguration().getTokenValidity(); - if (!OidcTokenValidity.isValid(configuredTokenValidity)) { + if (!TokenValidityResolver.isValid(configuredTokenValidity)) { LOG.warn( "OIDC token validity must be positive; using the {} second default", - OidcTokenValidity.DEFAULT_VALIDITY_SECONDS); + TokenValidityResolver.DEFAULT_TOKEN_VALIDITY_SECONDS); } - this.tokenValidity = OidcTokenValidity.resolveOrDefault(configuredTokenValidity); + 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/OidcTokenValidity.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java deleted file mode 100644 index 7c0ed3e0c179..000000000000 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/OidcTokenValidity.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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; - -public final class OidcTokenValidity { - public static final int DEFAULT_VALIDITY_SECONDS = 3600; - public static final String VALIDATION_MESSAGE = "OIDC token validity must be at least 1 second"; - - private OidcTokenValidity() {} - - public static boolean isValid(Integer validitySeconds) { - return validitySeconds != null && validitySeconds > 0; - } - - public static int resolveOrDefault(Integer validitySeconds) { - return isValid(validitySeconds) ? validitySeconds : DEFAULT_VALIDITY_SECONDS; - } -} 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 7263fb9f1a37..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 @@ -92,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/SystemRepositoryOidcTokenValidityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java deleted file mode 100644 index 3ead6190c525..000000000000 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/SystemRepositoryOidcTokenValidityTest.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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.schema.security.client.OidcClientConfig; -import org.openmetadata.schema.system.FieldError; -import org.openmetadata.service.util.ValidationErrorBuilder.FieldPaths; - -class SystemRepositoryOidcTokenValidityTest { - - @Test - void reportsZeroAsAFieldValidationError() { - FieldError error = - SystemRepository.validateOidcTokenValidity(new OidcClientConfig().withTokenValidity(0)); - - assertEquals(FieldPaths.OIDC_TOKEN_VALIDITY, error.getField()); - assertEquals("OIDC token validity must be at least 1 second", error.getError()); - } - - @Test - void acceptsPositiveValidityAndAbsentOptionalOidcConfiguration() { - assertNull( - SystemRepository.validateOidcTokenValidity(new OidcClientConfig().withTokenValidity(3600))); - assertNull(SystemRepository.validateOidcTokenValidity(null)); - } -} 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 0eebcb1c402b..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 @@ -36,7 +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.OidcTokenValidity; +import org.openmetadata.service.security.TokenValidityResolver; class SystemRepositoryPatchSettingTest { private static final String SETTING_NAME = SettingsType.GLOSSARY_TERM_RELATION_SETTINGS.value(); @@ -317,7 +317,7 @@ void putAuthenticationSettingRejectsInvalidOidcTokenValidityAsBadRequest() { assertThrows(BadRequestException.class, () -> systemRepository.createOrUpdate(update)); assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), failure.getResponse().getStatus()); - assertEquals(OidcTokenValidity.VALIDATION_MESSAGE, failure.getMessage()); + assertEquals(TokenValidityResolver.VALIDATION_MESSAGE, failure.getMessage()); verify(systemDAO, never()).insertSettings(anyString(), anyString()); settingsCacheMock.verifyNoInteractions(); } 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/OidcTokenValiditySqlMigrationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/TokenValiditySqlMigrationTest.java similarity index 50% rename from openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/OidcTokenValiditySqlMigrationTest.java rename to openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/TokenValiditySqlMigrationTest.java index 7e6c745b7beb..190363a51c85 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/OidcTokenValiditySqlMigrationTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v202/TokenValiditySqlMigrationTest.java @@ -12,6 +12,7 @@ */ 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; @@ -21,20 +22,49 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -class OidcTokenValiditySqlMigrationTest { +/** + * 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 repairsNonPositiveOidcTokenValidity(String dialect) throws IOException { - String sql = - Files.readString(migrationRoot().resolve(dialect).resolve("schemaChanges.sql")) - .toLowerCase(Locale.ROOT); + void repairsNonPositiveTokenValidityForBothProviders(String dialect) throws IOException { + String sql = readPostDataMigration(dialect); assertTrue(sql.contains("openmetadata_settings")); assertTrue(sql.contains("authenticationconfiguration")); - assertTrue(sql.contains("tokenvalidity")); assertTrue(sql.contains("3600")); - assertTrue(sql.contains("<= 0")); + 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() { diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java deleted file mode 100644 index 54226100ed80..000000000000 --- a/openmetadata-service/src/test/java/org/openmetadata/service/security/OidcTokenValidityTest.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.schema.security.client.OidcClientConfig; - -class OidcTokenValidityTest { - private static final Validator VALIDATOR = - Validation.buildDefaultValidatorFactory().getValidator(); - - @Test - void schemaAndRuntimeDefaultsStayAligned() { - assertEquals( - new OidcClientConfig().getTokenValidity(), OidcTokenValidity.DEFAULT_VALIDITY_SECONDS); - } - - @Test - void invalidValuesResolveToTheDefault() { - assertEquals( - OidcTokenValidity.DEFAULT_VALIDITY_SECONDS, OidcTokenValidity.resolveOrDefault(null)); - assertEquals(OidcTokenValidity.DEFAULT_VALIDITY_SECONDS, OidcTokenValidity.resolveOrDefault(0)); - assertEquals( - OidcTokenValidity.DEFAULT_VALIDITY_SECONDS, OidcTokenValidity.resolveOrDefault(-1)); - } - - @Test - void positiveValuesArePreserved() { - assertTrue(OidcTokenValidity.isValid(1)); - assertEquals(900, OidcTokenValidity.resolveOrDefault(900)); - } - - @Test - void zeroAndNegativeValuesAreRejected() { - assertFalse(OidcTokenValidity.isValid(null)); - assertFalse(OidcTokenValidity.isValid(0)); - assertFalse(OidcTokenValidity.isValid(-1)); - } - - @Test - void generatedSchemaConstraintRejectsZero() { - OidcClientConfig oidcConfig = new OidcClientConfig().withTokenValidity(0); - - assertTrue( - VALIDATOR.validate(oidcConfig).stream() - .anyMatch(violation -> "tokenValidity".equals(violation.getPropertyPath().toString()))); - } -} 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 d0c043825a17..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 @@ -96,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/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..0c6aa7121886 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,15 @@ 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. */} +
Date: Fri, 11 Sep 2026 19:22:46 +0530 Subject: [PATCH 6/7] style(ui): apply Prettier to the SSO advanced-config panel Prettier wraps the Collapse.Panel props onto separate lines once forceRender is added. Fixes the ui-checkstyle failure on the previous commit. Co-Authored-By: Claude Opus 5 --- .../SSOGroupedFieldTemplate/SSOGroupedFieldTemplate.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 0c6aa7121886..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 @@ -425,7 +425,10 @@ export const SSOGroupedFieldTemplate: FunctionComponent< 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. */} - +
Date: Fri, 11 Sep 2026 14:01:46 +0000 Subject: [PATCH 7/7] Update generated TypeScript types and dereferenced schemas --- .../src/main/resources/ui/src/generated/api/teams/createUser.ts | 2 +- .../src/main/resources/ui/src/generated/auth/ssoAuth.ts | 2 +- .../src/generated/configuration/authenticationConfiguration.ts | 2 +- .../ui/src/generated/configuration/securityConfiguration.ts | 2 +- .../src/main/resources/ui/src/generated/entity/teams/user.ts | 2 +- .../ui/src/generated/security/client/samlSSOClientConfig.ts | 2 +- .../src/main/resources/ui/src/generated/settings/settings.ts | 2 +- .../resources/ui/src/generated/system/testLoginTokenRequest.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/teams/createUser.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/teams/createUser.ts index e441f62998b8..16b0c61f0310 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/teams/createUser.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/teams/createUser.ts @@ -307,7 +307,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/auth/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/src/generated/auth/ssoAuth.ts index e91667ec7d61..1e2eb6001e5d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/auth/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/auth/ssoAuth.ts @@ -181,7 +181,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts index 34e26d748326..c07921eb18b1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/authenticationConfiguration.ts @@ -506,7 +506,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts index 2068208aaca0..986523b64144 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/configuration/securityConfiguration.ts @@ -522,7 +522,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts index ac8c982e52ac..3f4c9a26c169 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts @@ -375,7 +375,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/security/client/samlSSOClientConfig.ts b/openmetadata-ui/src/main/resources/ui/src/generated/security/client/samlSSOClientConfig.ts index 536979292194..5a5390565d4a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/security/client/samlSSOClientConfig.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/security/client/samlSSOClientConfig.ts @@ -87,7 +87,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts b/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts index f1e206172093..5b4ffa6b0c62 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/settings/settings.ts @@ -1738,7 +1738,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts b/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts index da52a2f19606..a4660f6b3ea3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/system/testLoginTokenRequest.ts @@ -541,7 +541,7 @@ export interface Security { */ strictMode?: boolean; /** - * Validity for the JWT Token created from SAML Response + * Lifetime in seconds of the OpenMetadata JWT issued after SAML authentication. */ tokenValidity?: number; /**