From 850fc93b7bb743d438f5fd57f498b138db565c6a Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 8 Apr 2026 15:40:06 +0530 Subject: [PATCH 01/29] test(playwright): add nightly SSO login spec starting with Okta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends Playwright coverage end-to-end for SSO login flows. Today's SSO coverage (Features/SSOConfiguration.spec.ts) only asserts the config form UI. This adds a new suite that configures OpenMetadata to an external identity provider, drives a real login through the provider's hosted UI, and validates the resulting session against the OM API. Phase 1 ships Okta only (integrator-9351624.okta.com). Additional providers (Auth0, Azure, Cognito, SAML, Google) plug into the same dispatcher by adding a ProviderHelper implementation. ## What's new - playwright/e2e/Auth/SSOLogin.spec.ts — two-test suite tagged @sso 1. Asserts the SSO sign-in button renders on /signin with the correct brand label and that the basic-auth form is not shown. 2. Clicks the button, drives the provider's login widget, follows the OAuth callback, completes first-run self-signup when needed, lands on /my-data, then verifies the JWT by calling GET /api/v1/users/loggedInUser and asserting the returned email matches SSO_USERNAME. - playwright/utils/ssoAuth.ts — provider-agnostic orchestration: applyProviderConfig (PUT /api/v1/system/security/config), restoreBasicAuth, buildAuthContextFromJwt, verifyLoggedInUserMatches. Composes existing getApiContext/getAuthContext/getToken helpers — no token extraction or HTTP plumbing is reimplemented. - playwright/utils/sso-providers/{index,okta}.ts — ProviderHelper interface plus the Okta Identity Engine widget driver. Defaults the dev tenant values from the committed openmetadata.yaml snippet so the spec only needs SSO_USERNAME/SSO_PASSWORD to run locally. - playwright/constant/ssoAuth.ts — env var key constants, PROVIDER_BUTTON_TEXT map, and the BASIC_AUTH_CONFIG payload used for cleanup. - playwright.config.ts — new 'sso-auth' project matching playwright/e2e/Auth/**/*.spec.ts with its own serial workers, and '**/Auth/**' added to the chromium project's testIgnore so these tests never run in the default suite. ## How provider switching works beforeAll logs in as admin via basic auth, captures the admin JWT via getToken(page) BEFORE the swap, then PUTs the Okta config. The admin JWT survives the provider swap because OM's internal JWKS stays in publicKeyUrls and the admin user's isAdmin flag is persisted in the DB. afterAll rebuilds an API context from that JWT and restores basic auth, making the spec fully idempotent — the same OM instance can run the suite repeatedly without any manual cleanup. ## Running locally export SSO_PROVIDER_TYPE=okta export SSO_USERNAME='' export SSO_PASSWORD='' npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ --project=sso-auth --workers=1 Verified end-to-end against integrator-9351624.okta.com — both tests pass in ~12s on an already-provisioned user, ~14s on first-run self-signup. Cleanup leaves the server in basic-auth mode. ## Notes for reviewers - The existing .github/workflows/playwright-sso-tests.yml already wires up the CI matrix and secret names; this change intentionally does NOT enable the cron schedule. That lands in a follow-up once one provider is stable for a few nightly runs. - OKTA_SSO_CLIENT_ID / OKTA_SSO_DOMAIN / OKTA_SSO_PRINCIPAL_DOMAIN env vars can override the baked-in dev tenant defaults if a different Okta tenant is used in CI. --- .../main/resources/ui/playwright.config.ts | 7 + .../ui/playwright/constant/ssoAuth.ts | 52 +++++++ .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 133 ++++++++++++++++++ .../playwright/utils/sso-providers/index.ts | 38 +++++ .../ui/playwright/utils/sso-providers/okta.ts | 104 ++++++++++++++ .../resources/ui/playwright/utils/ssoAuth.ts | 76 ++++++++++ 6 files changed, 410 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index 267f2ae4e867..2629b6f45198 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -84,12 +84,19 @@ export default defineConfig({ teardown: 'SearchRBAC', testIgnore: [ '**/nightly/**', + '**/Auth/**', '**/DataAssetRulesEnabled.spec.ts', '**/DataAssetRulesDisabled.spec.ts', '**/SystemCertificationTags.spec.ts', '**/SearchRBAC.spec.ts', ], }, + { + name: 'sso-auth', + testMatch: '**/Auth/**/*.spec.ts', + use: { ...devices['Desktop Chrome'] }, + fullyParallel: false, + }, { name: 'SearchRBAC', testMatch: '**/SearchRBAC.spec.ts', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts new file mode 100644 index 000000000000..af7e15c10ad8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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. + */ +import { SSOConfig } from '../utils/sso'; + +export const SSO_ENV = { + PROVIDER_TYPE: 'SSO_PROVIDER_TYPE', + USERNAME: 'SSO_USERNAME', + PASSWORD: 'SSO_PASSWORD', + OKTA_CLIENT_ID: 'OKTA_SSO_CLIENT_ID', + OKTA_DOMAIN: 'OKTA_SSO_DOMAIN', + OKTA_PRINCIPAL_DOMAIN: 'OKTA_SSO_PRINCIPAL_DOMAIN', +} as const; + +export const PROVIDER_BUTTON_TEXT: Record = { + okta: 'Sign in with Okta', + azure: 'Sign in with Azure', + google: 'Sign in with Google', + auth0: 'Sign in with Auth0', + 'aws-cognito': 'Sign in with AWS Cognito', + saml: 'Sign in with SAML SSO', +}; + +export const BASIC_AUTH_CONFIG: SSOConfig = { + authenticationConfiguration: { + provider: 'basic', + providerName: 'basic', + authority: '', + clientId: '', + callbackUrl: '', + publicKeyUrls: ['http://localhost:8585/api/v1/system/config/jwks'], + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + enableSelfSignup: true, + }, + authorizerConfiguration: { + className: 'org.openmetadata.service.security.DefaultAuthorizer', + containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', + adminPrincipals: ['admin'], + principalDomain: 'open-metadata.org', + enforcePrincipalDomain: false, + enableSecureSocketConnection: false, + }, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts new file mode 100644 index 000000000000..91ddf84c34db --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2025 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. + */ +import { expect, test } from '@playwright/test'; +import { SSO_ENV } from '../../constant/ssoAuth'; +import { performAdminLogin } from '../../utils/admin'; +import { + getProviderHelper, + ProviderHelper, +} from '../../utils/sso-providers'; +import { + applyProviderConfig, + buildAuthContextFromJwt, + restoreBasicAuth, + verifyLoggedInUserMatches, +} from '../../utils/ssoAuth'; +import { getToken } from '../../utils/tokenStorage'; + +const providerType = process.env[SSO_ENV.PROVIDER_TYPE] ?? ''; +const username = process.env[SSO_ENV.USERNAME] ?? ''; +const password = process.env[SSO_ENV.PASSWORD] ?? ''; + +test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { + // eslint-disable-next-line playwright/no-skipped-test -- conditional skip on required env vars; the suite only runs when SSO credentials are provided by CI or the developer + test.skip( + !providerType || !username || !password, + `${SSO_ENV.PROVIDER_TYPE}, ${SSO_ENV.USERNAME}, and ${SSO_ENV.PASSWORD} env vars must be set` + ); + + test.describe.configure({ mode: 'serial' }); + + let helper: ProviderHelper; + let adminJwt: string | undefined; + + test.beforeAll( + 'Swap OpenMetadata server to target SSO provider', + async ({ browser }) => { + test.slow(); + helper = getProviderHelper(providerType); + const { apiContext, afterAction, page } = await performAdminLogin( + browser + ); + + try { + adminJwt = await getToken(page); + await applyProviderConfig(apiContext, helper.buildConfigPayload()); + } finally { + await afterAction(); + } + } + ); + + test.afterAll('Restore basic auth configuration', async () => { + if (!adminJwt) { + return; + } + + const adminContext = await buildAuthContextFromJwt(adminJwt); + + try { + await restoreBasicAuth(adminContext); + } finally { + await adminContext.dispose(); + } + }); + + test('should display SSO sign-in button on /signin', async ({ page }) => { + await page.goto('/signin'); + + await expect(page.getByTestId('login-form-container')).toBeVisible(); + + const signInButton = page.locator('.signin-button'); + + await expect(signInButton).toBeVisible(); + await expect(signInButton).toContainText(helper.expectedButtonText); + await expect(page.getByTestId('email')).toHaveCount(0); + }); + + test('should complete full SSO login and verify user session', async ({ + page, + }) => { + test.slow(); + + await test.step('Click SSO button and redirect to IdP', async () => { + await page.goto('/signin'); + + const signInButton = page.locator('.signin-button'); + + await expect(signInButton).toBeVisible(); + await signInButton.click(); + await page.waitForURL(helper.loginUrlPattern, { timeout: 45_000 }); + }); + + await test.step('Authenticate at the identity provider', async () => { + await helper.performProviderLogin(page, { username, password }); + }); + + await test.step( + 'Return to OpenMetadata and complete self-signup if needed', + async () => { + await page.waitForURL( + (url) => + url.pathname.endsWith('/signup') || + url.pathname.endsWith('/my-data'), + { timeout: 60_000 } + ); + + if (page.url().includes('/signup')) { + const createButton = page.getByRole('button', { name: /create/i }); + + await expect(createButton).toBeEnabled(); + await createButton.click(); + await page.waitForURL('**/my-data', { timeout: 60_000 }); + } + + await expect(page.getByTestId('dropdown-profile')).toBeVisible(); + } + ); + + await test.step('Verify JWT against loggedInUser API', async () => { + await verifyLoggedInUserMatches(page, username); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts new file mode 100644 index 000000000000..132af70d6e32 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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. + */ +import { Page } from '@playwright/test'; +import { SSOConfig } from '../sso'; +import { ProviderCredentials } from '../ssoAuth'; +import { oktaProviderHelper } from './okta'; + +export interface ProviderHelper { + expectedButtonText: string; + loginUrlPattern: RegExp; + buildConfigPayload: () => SSOConfig; + performProviderLogin: ( + page: Page, + credentials: ProviderCredentials + ) => Promise; +} + +export const getProviderHelper = (providerType: string): ProviderHelper => { + switch (providerType) { + case 'okta': + return oktaProviderHelper; + default: + throw new Error( + `No SSO provider helper registered for "${providerType}". ` + + `Supported providers: okta` + ); + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts new file mode 100644 index 000000000000..41d10025cd1c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2025 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. + */ +import { expect, Page } from '@playwright/test'; +import { SSO_ENV } from '../../constant/ssoAuth'; +import { SSOConfig } from '../sso'; +import { ProviderCredentials } from '../ssoAuth'; +import { ProviderHelper } from './index'; + +const OM_BASE_URL = 'http://localhost:8585'; +const DEFAULT_OKTA_DOMAIN = 'integrator-9351624.okta.com'; +const DEFAULT_OKTA_CLIENT_ID = '0oayn277hnOhUpVLd697'; +const DEFAULT_OKTA_PRINCIPAL_DOMAIN = 'getcollate.io'; + +const requireEnv = (key: string): string => { + const value = process.env[key]; + + if (!value) { + throw new Error( + `Okta SSO test requires env var ${key} to be set. ` + + `Required: ${SSO_ENV.USERNAME}, ${SSO_ENV.PASSWORD}. ` + + `Optional with defaults: ${SSO_ENV.OKTA_CLIENT_ID}, ${SSO_ENV.OKTA_DOMAIN}, ${SSO_ENV.OKTA_PRINCIPAL_DOMAIN}` + ); + } + + return value; +}; + +const buildConfigPayload = (): SSOConfig => { + const adminPrincipal = requireEnv(SSO_ENV.USERNAME); + const clientId = process.env[SSO_ENV.OKTA_CLIENT_ID] ?? DEFAULT_OKTA_CLIENT_ID; + const oktaDomain = process.env[SSO_ENV.OKTA_DOMAIN] ?? DEFAULT_OKTA_DOMAIN; + const principalDomain = + process.env[SSO_ENV.OKTA_PRINCIPAL_DOMAIN] ?? DEFAULT_OKTA_PRINCIPAL_DOMAIN; + + const authority = `https://${oktaDomain}/oauth2/default`; + + return { + authenticationConfiguration: { + clientType: 'public', + provider: 'okta', + providerName: '', + publicKeyUrls: [ + `${OM_BASE_URL}/api/v1/system/config/jwks`, + `${authority}/v1/keys`, + ], + tokenValidationAlgorithm: 'RS256', + authority, + clientId, + callbackUrl: `${OM_BASE_URL}/callback`, + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + enableSelfSignup: true, + }, + authorizerConfiguration: { + className: 'org.openmetadata.service.security.DefaultAuthorizer', + containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', + adminPrincipals: [adminPrincipal], + principalDomain, + enforcePrincipalDomain: false, + enableSecureSocketConnection: false, + }, + }; +}; + +const performProviderLogin = async ( + page: Page, + { username, password }: ProviderCredentials +): Promise => { + const identifierInput = page.locator('input[name="identifier"]'); + + await expect(identifierInput).toBeVisible(); + await identifierInput.fill(username); + + const nextButton = page.locator('input[type="submit"]'); + + await expect(nextButton).toBeEnabled(); + await nextButton.click(); + + const passwordInput = page.locator('input[type="password"]'); + + await expect(passwordInput).toBeVisible(); + await passwordInput.fill(password); + + const verifyButton = page.locator('input[type="submit"]'); + + await expect(verifyButton).toBeEnabled(); + await verifyButton.click(); +}; + +export const oktaProviderHelper: ProviderHelper = { + expectedButtonText: 'Sign in with Okta', + loginUrlPattern: /\.okta\.com/, + buildConfigPayload, + performProviderLogin, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts new file mode 100644 index 000000000000..517031d66a42 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2025 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. + */ +import { APIRequestContext, expect, Page } from '@playwright/test'; +import { BASIC_AUTH_CONFIG } from '../constant/ssoAuth'; +import { getApiContext, getAuthContext } from './common'; +import { SSOConfig } from './sso'; + +export interface ProviderCredentials { + username: string; + password: string; +} + +const SECURITY_CONFIG_ENDPOINT = '/api/v1/system/security/config'; + +export const applyProviderConfig = async ( + apiContext: APIRequestContext, + config: SSOConfig +): Promise => { + const response = await apiContext.put(SECURITY_CONFIG_ENDPOINT, { + data: config, + }); + + expect(response.status()).toBe(200); +}; + +export const restoreBasicAuth = async ( + apiContext: APIRequestContext +): Promise => { + const response = await apiContext.put(SECURITY_CONFIG_ENDPOINT, { + data: BASIC_AUTH_CONFIG, + }); + + expect(response.status()).toBe(200); +}; + +export const buildAuthContextFromJwt = async ( + jwt: string +): Promise => { + return await getAuthContext(jwt); +}; + +export const verifyLoggedInUserMatches = async ( + page: Page, + expectedEmail: string +): Promise => { + const { apiContext, afterAction } = await getApiContext(page); + + try { + const response = await apiContext.get('/api/v1/users/loggedInUser'); + + expect(response.status()).toBe(200); + + const user = await response.json(); + const normalizedExpected = expectedEmail.toLowerCase(); + const candidates = [user?.email, user?.name] + .filter(Boolean) + .map((value: string) => value.toLowerCase()); + + expect( + candidates.some((value) => value === normalizedExpected), + `Expected logged-in user to match ${expectedEmail}, got email="${user?.email}" name="${user?.name}"` + ).toBe(true); + } finally { + await afterAction(); + } +}; From 45d8c9547785405c07bbc75c0fbb7c002d026322 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 8 Apr 2026 15:44:16 +0530 Subject: [PATCH 02/29] ci: add dedicated SSO Login Nightly workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/playwright-sso-login-nightly.yml, a standalone workflow that runs the new SSOLogin spec nightly at 03:00 UTC instead of piggy-backing on playwright-sso-tests.yml. The existing playwright-sso-tests.yml is left untouched — it still covers the SSO configuration form UI via SSOConfiguration.spec.ts and its matrix/secrets wiring is unchanged. The new workflow complements it with a real end-to-end login round-trip: - Schedule: cron '0 3 * * *' - Provider matrix: okta only for Phase 1 (extended as helpers ship) - Invokes playwright/e2e/Auth/SSOLogin.spec.ts under the new sso-auth Playwright project with workers=1 - Wires provider credentials via secrets with the existing {PROVIDER}_SSO_USERNAME / {PROVIDER}_SSO_PASSWORD convention plus optional OKTA_SSO_CLIENT_ID / OKTA_SSO_DOMAIN / OKTA_SSO_PRINCIPAL_DOMAIN overrides - Uses the shared setup-openmetadata-test-environment composite action, PostgreSQL, ingestion disabled — matching the existing SSO tests workflow - Uploads the HTML report as an artifact on every run and cleans up the docker stack in a final always-run step --- .../playwright-sso-login-nightly.yml | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 .github/workflows/playwright-sso-login-nightly.yml diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml new file mode 100644 index 000000000000..7261e23733af --- /dev/null +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -0,0 +1,141 @@ +# Copyright 2025 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. + +# Nightly end-to-end SSO login tests. +# +# Purpose: +# Drive a real login round-trip through an external identity provider +# (Okta in Phase 1) and validate the resulting OpenMetadata session. +# Complements playwright-sso-tests.yml, which only exercises the SSO +# configuration form UI and does not perform an actual login. +# +# Test location: +# openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +# +# Triggers: +# - Nightly cron at 03:00 UTC +# - Manual workflow_dispatch with provider selection + +name: SSO Login Nightly + +on: + schedule: + # Every night at 03:00 UTC + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + sso_provider: + description: "SSO provider to test" + required: true + default: "okta" + type: choice + options: + - okta + +permissions: + contents: read + +concurrency: + group: sso-login-nightly-${{ github.event.inputs.sso_provider || 'scheduled' }} + cancel-in-progress: true + +jobs: + sso-login: + runs-on: ubuntu-latest + environment: test + + strategy: + fail-fast: false + matrix: + provider: + ${{ github.event_name == 'schedule' + && fromJSON('["okta"]') + || github.event.inputs.sso_provider + && fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) + || fromJSON('["okta"]') }} + + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: true + docker-images: false + + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Maven Dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup OpenMetadata Test Environment + uses: ./.github/actions/setup-openmetadata-test-environment + with: + python-version: "3.10" + # Ingestion is not required for SSO login tests. + args: "-d postgresql -i false" + ingestion_dependency: "all" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: 'openmetadata-ui/src/main/resources/ui/.nvmrc' + + - name: Install dependencies + working-directory: openmetadata-ui/src/main/resources/ui/ + run: yarn --ignore-scripts --frozen-lockfile + + - name: Install Playwright Browsers + run: npx playwright@1.51.1 install chromium --with-deps + + - name: Run SSO Login Spec + working-directory: openmetadata-ui/src/main/resources/ui + run: | + npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ + --project=sso-auth \ + --workers=1 + env: + SSO_PROVIDER_TYPE: ${{ matrix.provider }} + SSO_USERNAME: ${{ secrets[format('{0}_SSO_USERNAME', upper(matrix.provider))] }} + SSO_PASSWORD: ${{ secrets[format('{0}_SSO_PASSWORD', upper(matrix.provider))] }} + # Okta-specific overrides (all optional — defaults are baked into + # playwright/utils/sso-providers/okta.ts and point at the + # integrator-9351624.okta.com dev tenant). + OKTA_SSO_CLIENT_ID: ${{ secrets.OKTA_SSO_CLIENT_ID }} + OKTA_SSO_DOMAIN: ${{ secrets.OKTA_SSO_DOMAIN }} + OKTA_SSO_PRINCIPAL_DOMAIN: ${{ secrets.OKTA_SSO_PRINCIPAL_DOMAIN }} + PLAYWRIGHT_IS_OSS: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 30 + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: sso-login-html-report-${{ matrix.provider }} + path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report + retention-days: 5 + + - name: Clean Up + if: always() + run: | + cd ./docker/development + docker compose down --remove-orphans + sudo rm -rf ${PWD}/docker-volume From ae7f190bcf1dff08e204b22fea4b1fb092baff1a Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 8 Apr 2026 15:48:11 +0530 Subject: [PATCH 03/29] refactor(playwright): simplify ssoAuth helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verifyLoggedInUserMatches now asserts directly on the lowercased email field instead of building a candidate array and feeding it a long stringified failure message. The assertion failure already shows expected vs received, so the wrapper string was just noise. - Drop buildAuthContextFromJwt — it was a one-line wrapper around getAuthContext. The spec calls getAuthContext directly now. --- .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 4 ++-- .../resources/ui/playwright/utils/ssoAuth.ts | 18 +++--------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 91ddf84c34db..f8441148bb1c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -13,13 +13,13 @@ import { expect, test } from '@playwright/test'; import { SSO_ENV } from '../../constant/ssoAuth'; import { performAdminLogin } from '../../utils/admin'; +import { getAuthContext } from '../../utils/common'; import { getProviderHelper, ProviderHelper, } from '../../utils/sso-providers'; import { applyProviderConfig, - buildAuthContextFromJwt, restoreBasicAuth, verifyLoggedInUserMatches, } from '../../utils/ssoAuth'; @@ -64,7 +64,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { return; } - const adminContext = await buildAuthContextFromJwt(adminJwt); + const adminContext = await getAuthContext(adminJwt); try { await restoreBasicAuth(adminContext); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts index 517031d66a42..7e9af203c117 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts @@ -12,7 +12,7 @@ */ import { APIRequestContext, expect, Page } from '@playwright/test'; import { BASIC_AUTH_CONFIG } from '../constant/ssoAuth'; -import { getApiContext, getAuthContext } from './common'; +import { getApiContext } from './common'; import { SSOConfig } from './sso'; export interface ProviderCredentials { @@ -43,12 +43,6 @@ export const restoreBasicAuth = async ( expect(response.status()).toBe(200); }; -export const buildAuthContextFromJwt = async ( - jwt: string -): Promise => { - return await getAuthContext(jwt); -}; - export const verifyLoggedInUserMatches = async ( page: Page, expectedEmail: string @@ -61,15 +55,9 @@ export const verifyLoggedInUserMatches = async ( expect(response.status()).toBe(200); const user = await response.json(); - const normalizedExpected = expectedEmail.toLowerCase(); - const candidates = [user?.email, user?.name] - .filter(Boolean) - .map((value: string) => value.toLowerCase()); + const expected = expectedEmail.toLowerCase(); - expect( - candidates.some((value) => value === normalizedExpected), - `Expected logged-in user to match ${expectedEmail}, got email="${user?.email}" name="${user?.name}"` - ).toBe(true); + expect(user.email?.toLowerCase()).toBe(expected); } finally { await afterAction(); } From 3f9fbcc374f8b4c1cf6e8d436e92ca4207974eb2 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 8 Apr 2026 15:52:23 +0530 Subject: [PATCH 04/29] refactor(playwright): address SSO suite review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract OM_BASE_URL from PLAYWRIGHT_TEST_BASE_URL (with the same http://localhost:8585 default as playwright.config.ts) and export it from constant/ssoAuth.ts. okta.ts and BASIC_AUTH_CONFIG both consume it, so callbackUrl, the OM JWKS entry in publicKeyUrls, and the basic-auth restore payload all match the test target — including CI runs against non-default hosts. - Drop PROVIDER_BUTTON_TEXT. It was exported but never imported; the ProviderHelper.expectedButtonText field is the only source of truth for the SSO sign-in button label and the spec already reads from it. - Restore the OM convention adminPrincipals: ['admin'] in the Okta config (matches conf/openmetadata.yaml's AUTHORIZER_ADMIN_PRINCIPALS default). The previous code was granting admin to whichever IdP user ran the suite — verifyLoggedInUserMatches only needs an authenticated session, not admin, so the elevation was unnecessary. This also drops the now-unused requireEnv on SSO_USERNAME inside okta.ts; the spec itself still gates on the env var via test.skip. - Set workers: 1 on the sso-auth Playwright project. fullyParallel: false alone wasn't enough — the global workers: 3 on CI could still fan out across multiple Auth/**/*.spec.ts files in the future. The explicit limit enforces full isolation as more provider specs land. --- .../main/resources/ui/playwright.config.ts | 2 ++ .../ui/playwright/constant/ssoAuth.ts | 17 +++++++--------- .../ui/playwright/utils/sso-providers/okta.ts | 20 ++----------------- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index 2629b6f45198..c81ed57bd142 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -95,7 +95,9 @@ export default defineConfig({ name: 'sso-auth', testMatch: '**/Auth/**/*.spec.ts', use: { ...devices['Desktop Chrome'] }, + // Provider swaps mutate global server state — keep tests strictly serial. fullyParallel: false, + workers: 1, }, { name: 'SearchRBAC', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index af7e15c10ad8..2f525e84eadf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -12,6 +12,12 @@ */ import { SSOConfig } from '../utils/sso'; +// Mirrors playwright.config.ts `baseURL` resolution so the SSO config we +// PUT into OpenMetadata always matches the host the Playwright suite is +// driving. Defaults to the local stack. +export const OM_BASE_URL = + process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:8585'; + export const SSO_ENV = { PROVIDER_TYPE: 'SSO_PROVIDER_TYPE', USERNAME: 'SSO_USERNAME', @@ -21,15 +27,6 @@ export const SSO_ENV = { OKTA_PRINCIPAL_DOMAIN: 'OKTA_SSO_PRINCIPAL_DOMAIN', } as const; -export const PROVIDER_BUTTON_TEXT: Record = { - okta: 'Sign in with Okta', - azure: 'Sign in with Azure', - google: 'Sign in with Google', - auth0: 'Sign in with Auth0', - 'aws-cognito': 'Sign in with AWS Cognito', - saml: 'Sign in with SAML SSO', -}; - export const BASIC_AUTH_CONFIG: SSOConfig = { authenticationConfiguration: { provider: 'basic', @@ -37,7 +34,7 @@ export const BASIC_AUTH_CONFIG: SSOConfig = { authority: '', clientId: '', callbackUrl: '', - publicKeyUrls: ['http://localhost:8585/api/v1/system/config/jwks'], + publicKeyUrls: [`${OM_BASE_URL}/api/v1/system/config/jwks`], jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], enableSelfSignup: true, }, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts index 41d10025cd1c..53caa4152809 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts @@ -11,32 +11,16 @@ * limitations under the License. */ import { expect, Page } from '@playwright/test'; -import { SSO_ENV } from '../../constant/ssoAuth'; +import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; import { SSOConfig } from '../sso'; import { ProviderCredentials } from '../ssoAuth'; import { ProviderHelper } from './index'; -const OM_BASE_URL = 'http://localhost:8585'; const DEFAULT_OKTA_DOMAIN = 'integrator-9351624.okta.com'; const DEFAULT_OKTA_CLIENT_ID = '0oayn277hnOhUpVLd697'; const DEFAULT_OKTA_PRINCIPAL_DOMAIN = 'getcollate.io'; -const requireEnv = (key: string): string => { - const value = process.env[key]; - - if (!value) { - throw new Error( - `Okta SSO test requires env var ${key} to be set. ` + - `Required: ${SSO_ENV.USERNAME}, ${SSO_ENV.PASSWORD}. ` + - `Optional with defaults: ${SSO_ENV.OKTA_CLIENT_ID}, ${SSO_ENV.OKTA_DOMAIN}, ${SSO_ENV.OKTA_PRINCIPAL_DOMAIN}` - ); - } - - return value; -}; - const buildConfigPayload = (): SSOConfig => { - const adminPrincipal = requireEnv(SSO_ENV.USERNAME); const clientId = process.env[SSO_ENV.OKTA_CLIENT_ID] ?? DEFAULT_OKTA_CLIENT_ID; const oktaDomain = process.env[SSO_ENV.OKTA_DOMAIN] ?? DEFAULT_OKTA_DOMAIN; const principalDomain = @@ -63,7 +47,7 @@ const buildConfigPayload = (): SSOConfig => { authorizerConfiguration: { className: 'org.openmetadata.service.security.DefaultAuthorizer', containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', - adminPrincipals: [adminPrincipal], + adminPrincipals: ['admin'], principalDomain, enforcePrincipalDomain: false, enableSecureSocketConnection: false, From 8b3f993fef8cd2ad95e449fb759458978e72eae1 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 8 Apr 2026 15:55:34 +0530 Subject: [PATCH 05/29] ci: avoid CodeQL "Excessive Secrets Exposure" in SSO Login Nightly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dynamic secret lookup secrets[format('{0}_SSO_USERNAME', upper(matrix.provider))] with a static reference secrets.OKTA_SSO_USERNAME CodeQL flagged the dynamic indexing because GitHub Actions can only mask & scope secrets that are referenced statically. With a computed key, the runner has no way to know which single secret is needed and conservatively materializes EVERY org and repo secret into the step's environment — even though the test only reads OKTA_SSO_*. Static references let GitHub expose only the two credentials this step actually uses. Phase 1's matrix is okta-only so the change is two lines. The added inline comment documents the convention for future providers: add a sibling step gated by `if: matrix.provider == ''` with that provider's static secret references — do not bring back the secrets[format(...)] pattern. --- .github/workflows/playwright-sso-login-nightly.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 7261e23733af..f1164b4c348b 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -113,8 +113,15 @@ jobs: --workers=1 env: SSO_PROVIDER_TYPE: ${{ matrix.provider }} - SSO_USERNAME: ${{ secrets[format('{0}_SSO_USERNAME', upper(matrix.provider))] }} - SSO_PASSWORD: ${{ secrets[format('{0}_SSO_PASSWORD', upper(matrix.provider))] }} + # Reference each provider's secrets statically so GitHub Actions + # exposes only the credentials this step needs — not the entire + # org/repo secret store. A `secrets[format(...)]` style lookup + # would defeat static analysis and trigger CodeQL's + # "Excessive Secrets Exposure" rule. When a new provider lands, + # add a sibling step gated by `if: matrix.provider == ''` + # with that provider's static secret references. + SSO_USERNAME: ${{ secrets.OKTA_SSO_USERNAME }} + SSO_PASSWORD: ${{ secrets.OKTA_SSO_PASSWORD }} # Okta-specific overrides (all optional — defaults are baked into # playwright/utils/sso-providers/okta.ts and point at the # integrator-9351624.okta.com dev tenant). From 13849afd7939b2d5d26339c72430c57fdaf3921a Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 11:56:37 +0530 Subject: [PATCH 06/29] refactor(playwright): capture/restore real security config in SSO suite - Snapshot /system/security/config in beforeAll, restore exact payload in afterAll instead of PUTting a hand-rolled basic-auth baseline (preserves allowedDomains, forceSecureSessionCookie, adminPrincipals, etc.) - Strip ldap/saml subtrees from the snapshot: GET returns empty-string placeholders the PUT validator rejects - Require OKTA_SSO_{CLIENT_ID,DOMAIN,PRINCIPAL_DOMAIN} via getRequiredEnv; no more hardcoded tenant defaults - Fail fast in beforeAll if admin JWT capture returns empty string so the server is never left stuck in SSO mode - Shrink Okta provider override to just the fields Okta needs; sibling authorizer fields come from the captured snapshot Co-Authored-By: Claude Opus 4.6 (1M context) --- .../playwright-sso-login-nightly.yml | 7 +- .../ui/playwright/constant/ssoAuth.ts | 22 ----- .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 62 ++++++++------ .../playwright/utils/sso-providers/index.ts | 5 +- .../ui/playwright/utils/sso-providers/okta.ts | 32 +++---- .../resources/ui/playwright/utils/ssoAuth.ts | 84 +++++++++++++++++-- 6 files changed, 135 insertions(+), 77 deletions(-) diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index f1164b4c348b..409d16bf5290 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -122,9 +122,10 @@ jobs: # with that provider's static secret references. SSO_USERNAME: ${{ secrets.OKTA_SSO_USERNAME }} SSO_PASSWORD: ${{ secrets.OKTA_SSO_PASSWORD }} - # Okta-specific overrides (all optional — defaults are baked into - # playwright/utils/sso-providers/okta.ts and point at the - # integrator-9351624.okta.com dev tenant). + # Okta tenant configuration — all three are REQUIRED by + # playwright/utils/sso-providers/okta.ts; the helper throws if any + # are missing so the suite is explicit about which IdP tenant it + # exercises. OKTA_SSO_CLIENT_ID: ${{ secrets.OKTA_SSO_CLIENT_ID }} OKTA_SSO_DOMAIN: ${{ secrets.OKTA_SSO_DOMAIN }} OKTA_SSO_PRINCIPAL_DOMAIN: ${{ secrets.OKTA_SSO_PRINCIPAL_DOMAIN }} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index 2f525e84eadf..e10710084445 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -10,7 +10,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SSOConfig } from '../utils/sso'; // Mirrors playwright.config.ts `baseURL` resolution so the SSO config we // PUT into OpenMetadata always matches the host the Playwright suite is @@ -26,24 +25,3 @@ export const SSO_ENV = { OKTA_DOMAIN: 'OKTA_SSO_DOMAIN', OKTA_PRINCIPAL_DOMAIN: 'OKTA_SSO_PRINCIPAL_DOMAIN', } as const; - -export const BASIC_AUTH_CONFIG: SSOConfig = { - authenticationConfiguration: { - provider: 'basic', - providerName: 'basic', - authority: '', - clientId: '', - callbackUrl: '', - publicKeyUrls: [`${OM_BASE_URL}/api/v1/system/config/jwks`], - jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], - enableSelfSignup: true, - }, - authorizerConfiguration: { - className: 'org.openmetadata.service.security.DefaultAuthorizer', - containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', - adminPrincipals: ['admin'], - principalDomain: 'open-metadata.org', - enforcePrincipalDomain: false, - enableSecureSocketConnection: false, - }, -}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index f8441148bb1c..fd4572b4b6b6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -14,13 +14,12 @@ import { expect, test } from '@playwright/test'; import { SSO_ENV } from '../../constant/ssoAuth'; import { performAdminLogin } from '../../utils/admin'; import { getAuthContext } from '../../utils/common'; -import { - getProviderHelper, - ProviderHelper, -} from '../../utils/sso-providers'; +import { getProviderHelper, ProviderHelper } from '../../utils/sso-providers'; import { applyProviderConfig, - restoreBasicAuth, + fetchSecurityConfig, + restoreSecurityConfig, + SecurityConfigSnapshot, verifyLoggedInUserMatches, } from '../../utils/ssoAuth'; import { getToken } from '../../utils/tokenStorage'; @@ -40,6 +39,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { let helper: ProviderHelper; let adminJwt: string | undefined; + let originalSecurityConfig: SecurityConfigSnapshot | undefined; test.beforeAll( 'Swap OpenMetadata server to target SSO provider', @@ -52,22 +52,34 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { try { adminJwt = await getToken(page); - await applyProviderConfig(apiContext, helper.buildConfigPayload()); + + if (!adminJwt) { + throw new Error( + 'Failed to capture admin JWT before SSO swap — aborting to avoid leaving server in SSO mode' + ); + } + + originalSecurityConfig = await fetchSecurityConfig(apiContext); + await applyProviderConfig( + apiContext, + originalSecurityConfig, + helper.buildConfigPayload() + ); } finally { await afterAction(); } } ); - test.afterAll('Restore basic auth configuration', async () => { - if (!adminJwt) { + test.afterAll('Restore original security configuration', async () => { + if (!adminJwt || !originalSecurityConfig) { return; } const adminContext = await getAuthContext(adminJwt); try { - await restoreBasicAuth(adminContext); + await restoreSecurityConfig(adminContext, originalSecurityConfig); } finally { await adminContext.dispose(); } @@ -104,27 +116,23 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await helper.performProviderLogin(page, { username, password }); }); - await test.step( - 'Return to OpenMetadata and complete self-signup if needed', - async () => { - await page.waitForURL( - (url) => - url.pathname.endsWith('/signup') || - url.pathname.endsWith('/my-data'), - { timeout: 60_000 } - ); - - if (page.url().includes('/signup')) { - const createButton = page.getByRole('button', { name: /create/i }); + await test.step('Return to OpenMetadata and complete self-signup if needed', async () => { + await page.waitForURL( + (url) => + url.pathname.endsWith('/signup') || url.pathname.endsWith('/my-data'), + { timeout: 60_000 } + ); - await expect(createButton).toBeEnabled(); - await createButton.click(); - await page.waitForURL('**/my-data', { timeout: 60_000 }); - } + if (page.url().includes('/signup')) { + const createButton = page.getByRole('button', { name: /create/i }); - await expect(page.getByTestId('dropdown-profile')).toBeVisible(); + await expect(createButton).toBeEnabled(); + await createButton.click(); + await page.waitForURL('**/my-data', { timeout: 60_000 }); } - ); + + await expect(page.getByTestId('dropdown-profile')).toBeVisible(); + }); await test.step('Verify JWT against loggedInUser API', async () => { await verifyLoggedInUserMatches(page, username); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts index 132af70d6e32..5bea4d90d8f7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -11,14 +11,13 @@ * limitations under the License. */ import { Page } from '@playwright/test'; -import { SSOConfig } from '../sso'; -import { ProviderCredentials } from '../ssoAuth'; +import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import { oktaProviderHelper } from './okta'; export interface ProviderHelper { expectedButtonText: string; loginUrlPattern: RegExp; - buildConfigPayload: () => SSOConfig; + buildConfigPayload: () => ProviderConfigOverride; performProviderLogin: ( page: Page, credentials: ProviderCredentials diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts index 53caa4152809..09648bc30f6e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts @@ -12,19 +12,26 @@ */ import { expect, Page } from '@playwright/test'; import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; -import { SSOConfig } from '../sso'; -import { ProviderCredentials } from '../ssoAuth'; +import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import { ProviderHelper } from './index'; -const DEFAULT_OKTA_DOMAIN = 'integrator-9351624.okta.com'; -const DEFAULT_OKTA_CLIENT_ID = '0oayn277hnOhUpVLd697'; -const DEFAULT_OKTA_PRINCIPAL_DOMAIN = 'getcollate.io'; +const getRequiredEnv = (envKey: string): string => { + const value = process.env[envKey]?.trim(); -const buildConfigPayload = (): SSOConfig => { - const clientId = process.env[SSO_ENV.OKTA_CLIENT_ID] ?? DEFAULT_OKTA_CLIENT_ID; - const oktaDomain = process.env[SSO_ENV.OKTA_DOMAIN] ?? DEFAULT_OKTA_DOMAIN; - const principalDomain = - process.env[SSO_ENV.OKTA_PRINCIPAL_DOMAIN] ?? DEFAULT_OKTA_PRINCIPAL_DOMAIN; + if (!value) { + throw new Error( + `Missing required Okta SSO environment variable: ${envKey}. ` + + 'Set this explicitly for Playwright SSO tests.' + ); + } + + return value; +}; + +const buildConfigPayload = (): ProviderConfigOverride => { + const clientId = getRequiredEnv(SSO_ENV.OKTA_CLIENT_ID); + const oktaDomain = getRequiredEnv(SSO_ENV.OKTA_DOMAIN); + const principalDomain = getRequiredEnv(SSO_ENV.OKTA_PRINCIPAL_DOMAIN); const authority = `https://${oktaDomain}/oauth2/default`; @@ -45,12 +52,7 @@ const buildConfigPayload = (): SSOConfig => { enableSelfSignup: true, }, authorizerConfiguration: { - className: 'org.openmetadata.service.security.DefaultAuthorizer', - containerRequestFilter: 'org.openmetadata.service.security.JwtFilter', - adminPrincipals: ['admin'], principalDomain, - enforcePrincipalDomain: false, - enableSecureSocketConnection: false, }, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts index 7e9af203c117..05fac578686a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts @@ -11,33 +11,103 @@ * limitations under the License. */ import { APIRequestContext, expect, Page } from '@playwright/test'; -import { BASIC_AUTH_CONFIG } from '../constant/ssoAuth'; import { getApiContext } from './common'; -import { SSOConfig } from './sso'; export interface ProviderCredentials { username: string; password: string; } +export type SecurityConfigSnapshot = Record; + +export interface ProviderConfigOverride { + authenticationConfiguration: Record; + authorizerConfiguration: Record; +} + const SECURITY_CONFIG_ENDPOINT = '/api/v1/system/security/config'; +// Round-trippable = can be GET'd and PUT back unchanged. These two aren't: +// GET returns empty-string placeholders that the PUT validator rejects. +const NON_ROUND_TRIPPABLE_AUTH_FIELDS = [ + 'ldapConfiguration', + 'samlConfiguration', +] as const; + +export const fetchSecurityConfig = async ( + apiContext: APIRequestContext +): Promise => { + const response = await apiContext.get(SECURITY_CONFIG_ENDPOINT); + + expect(response.status()).toBe(200); + + const snapshot = (await response.json()) as SecurityConfigSnapshot; + const authConfig = snapshot.authenticationConfiguration as + | Record + | undefined; + + if (authConfig) { + for (const field of NON_ROUND_TRIPPABLE_AUTH_FIELDS) { + delete authConfig[field]; + } + } + + return snapshot; +}; + +const mergeAdminPrincipals = ( + originalSection: Record | undefined, + overrideSection: Record +): string[] => { + const originalAdmins = Array.isArray(originalSection?.adminPrincipals) + ? (originalSection?.adminPrincipals as string[]) + : []; + const overrideAdmins = Array.isArray(overrideSection.adminPrincipals) + ? (overrideSection.adminPrincipals as string[]) + : []; + + return Array.from(new Set([...originalAdmins, ...overrideAdmins])); +}; + export const applyProviderConfig = async ( apiContext: APIRequestContext, - config: SSOConfig + original: SecurityConfigSnapshot, + override: ProviderConfigOverride ): Promise => { + const originalAuth = + (original.authenticationConfiguration as Record) ?? {}; + const originalAuthorizer = + (original.authorizerConfiguration as Record) ?? {}; + + const merged: SecurityConfigSnapshot = { + ...original, + authenticationConfiguration: { + ...originalAuth, + ...override.authenticationConfiguration, + }, + authorizerConfiguration: { + ...originalAuthorizer, + ...override.authorizerConfiguration, + adminPrincipals: mergeAdminPrincipals( + originalAuthorizer, + override.authorizerConfiguration + ), + }, + }; + const response = await apiContext.put(SECURITY_CONFIG_ENDPOINT, { - data: config, + data: merged, }); expect(response.status()).toBe(200); }; -export const restoreBasicAuth = async ( - apiContext: APIRequestContext +export const restoreSecurityConfig = async ( + apiContext: APIRequestContext, + snapshot: SecurityConfigSnapshot ): Promise => { const response = await apiContext.put(SECURITY_CONFIG_ENDPOINT, { - data: BASIC_AUTH_CONFIG, + data: snapshot, }); expect(response.status()).toBe(200); From 314331278756138a31b156220a9dcda2090ba2f1 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 12:53:12 +0530 Subject: [PATCH 07/29] ci(sso-login): extract per-provider composite action Restructures the nightly workflow so provider credentials stay statically referenced for CodeQL while making it trivial to add new providers: - New composite action .github/actions/sso-login-run bundles all shared setup + test-run logic; pulls non-secret provider config from the caller's vars context dynamically (${PROVIDER_UPPER}_SSO_*) - playwright-sso-login-nightly.yml becomes a thin dispatcher with one real job per provider. Each job declares environment: test so it can resolve its password via a static secrets._SSO_PASSWORD reference (no secrets[format(...)] dynamic lookup, CodeQL clean) - Adding a provider = copy the okta job stanza, swap the secret name, add the provider to the dispatch input choices, register the helper in sso-providers/index.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/sso-login-run/action.yml | 131 +++++++++++++++++ .../playwright-sso-login-nightly.yml | 135 +++++------------- 2 files changed, 163 insertions(+), 103 deletions(-) create mode 100644 .github/actions/sso-login-run/action.yml diff --git a/.github/actions/sso-login-run/action.yml b/.github/actions/sso-login-run/action.yml new file mode 100644 index 000000000000..6b407a1e396f --- /dev/null +++ b/.github/actions/sso-login-run/action.yml @@ -0,0 +1,131 @@ +# Copyright 2025 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. + +# Composite action that runs the end-to-end SSO login Playwright spec for a +# single provider. +# +# Provider-agnostic: the caller job passes the provider name + its password +# secret. Non-secret provider configuration (username, tenant IDs, domains) +# is pulled dynamically from the calling job's `vars` context using the +# convention `${PROVIDER_UPPER}_SSO_*`, so adding a new tenant field for an +# existing provider requires no workflow change. +# +# Adding a new provider: +# 1. Add `_SSO_PASSWORD` as an environment secret and +# `_SSO_USERNAME` + whatever tenant vars the helper reads in +# the `test` environment. +# 2. Add a sibling job in playwright-sso-login-nightly.yml with +# `environment: test`, gated by `if: ...`, that calls this composite +# action with a STATIC `${{ secrets._SSO_PASSWORD }}` +# reference (keeps CodeQL happy). +# 3. Register the provider helper in +# openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts. + +name: SSO Login Run +description: Run the end-to-end SSO login Playwright spec for a single provider. + +inputs: + provider: + description: 'SSO provider identifier (lowercase, e.g. okta, azure)' + required: true + sso_password: + description: 'Password for the provider test account' + required: true + all_vars_json: + description: 'toJSON(vars) from the caller — used to pull non-secret provider config' + required: true + +runs: + using: composite + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: true + docker-images: false + + - name: Cache Maven Dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup OpenMetadata Test Environment + uses: ./.github/actions/setup-openmetadata-test-environment + with: + python-version: "3.10" + # Ingestion is not required for SSO login tests. + args: "-d postgresql -i false" + ingestion_dependency: "all" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: 'openmetadata-ui/src/main/resources/ui/.nvmrc' + + - name: Install dependencies + working-directory: openmetadata-ui/src/main/resources/ui/ + shell: bash + run: yarn --ignore-scripts --frozen-lockfile + + - name: Install Playwright Browsers + shell: bash + run: npx playwright@1.51.1 install chromium --with-deps + + - name: Export SSO provider env + shell: bash + env: + PROVIDER: ${{ inputs.provider }} + ALL_VARS: ${{ inputs.all_vars_json }} + run: | + PREFIX=$(echo "$PROVIDER" | tr '[:lower:]' '[:upper:]') + echo "SSO_PROVIDER_TYPE=$PROVIDER" >> "$GITHUB_ENV" + printf '%s\n' "$ALL_VARS" \ + | jq -r --arg p "${PREFIX}_SSO_" \ + 'to_entries[] | select(.key | startswith($p)) | "\(.key)=\(.value)"' \ + >> "$GITHUB_ENV" + USERNAME=$(printf '%s\n' "$ALL_VARS" | jq -r --arg k "${PREFIX}_SSO_USERNAME" '.[$k]') + echo "SSO_USERNAME=$USERNAME" >> "$GITHUB_ENV" + + - name: Run SSO Login Spec + working-directory: openmetadata-ui/src/main/resources/ui + shell: bash + env: + SSO_PASSWORD: ${{ inputs.sso_password }} + PLAYWRIGHT_IS_OSS: true + run: | + npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ + --project=sso-auth \ + --workers=1 + timeout-minutes: 30 + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: sso-login-html-report-${{ inputs.provider }} + path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report + retention-days: 5 + + - name: Clean Up + if: always() + shell: bash + run: | + cd ./docker/development + docker compose down --remove-orphans + sudo rm -rf ${PWD}/docker-volume diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 409d16bf5290..4a5df81866ad 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -12,33 +12,44 @@ # Nightly end-to-end SSO login tests. # # Purpose: -# Drive a real login round-trip through an external identity provider -# (Okta in Phase 1) and validate the resulting OpenMetadata session. -# Complements playwright-sso-tests.yml, which only exercises the SSO -# configuration form UI and does not perform an actual login. +# Drive real login round-trips through external identity providers and +# validate the resulting OpenMetadata sessions. Complements +# playwright-sso-tests.yml, which only exercises the SSO configuration +# form UI without performing an actual login. # -# Test location: -# openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +# Shape: +# One job per provider. Each job declares `environment: test` so it can +# resolve `${{ secrets._SSO_PASSWORD }}` from the environment's +# secrets with a STATIC reference (keeps CodeQL's "Excessive Secrets +# Exposure" rule happy). All shared setup + test-run logic lives in the +# composite action ./.github/actions/sso-login-run. # -# Triggers: -# - Nightly cron at 03:00 UTC -# - Manual workflow_dispatch with provider selection +# Adding a new provider: +# 1. Add `_SSO_PASSWORD` as an environment secret and +# `_SSO_USERNAME` + tenant vars in the `test` environment. +# 2. Add the provider to the `workflow_dispatch.inputs.sso_provider` +# options list. +# 3. Copy the `okta` job below, rename it, and point its static secret +# reference at `_SSO_PASSWORD`. +# 4. Register the provider helper in +# openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts. name: SSO Login Nightly on: schedule: - # Every night at 03:00 UTC + # Every night at 03:00 UTC — runs every registered provider. - cron: '0 3 * * *' workflow_dispatch: inputs: sso_provider: - description: "SSO provider to test" + description: 'SSO provider to test (or "all")' required: true - default: "okta" + default: okta type: choice options: - okta + - all permissions: contents: read @@ -48,102 +59,20 @@ concurrency: cancel-in-progress: true jobs: - sso-login: + okta: + if: >- + github.event_name == 'schedule' || + github.event.inputs.sso_provider == 'okta' || + github.event.inputs.sso_provider == 'all' runs-on: ubuntu-latest environment: test - - strategy: - fail-fast: false - matrix: - provider: - ${{ github.event_name == 'schedule' - && fromJSON('["okta"]') - || github.event.inputs.sso_provider - && fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) - || fromJSON('["okta"]') }} - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: true - docker-images: false - - name: Checkout uses: actions/checkout@v4 - - name: Cache Maven Dependencies - uses: actions/cache@v4 - with: - path: ~/.m2 - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- - - - name: Setup OpenMetadata Test Environment - uses: ./.github/actions/setup-openmetadata-test-environment - with: - python-version: "3.10" - # Ingestion is not required for SSO login tests. - args: "-d postgresql -i false" - ingestion_dependency: "all" - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: 'openmetadata-ui/src/main/resources/ui/.nvmrc' - - - name: Install dependencies - working-directory: openmetadata-ui/src/main/resources/ui/ - run: yarn --ignore-scripts --frozen-lockfile - - - name: Install Playwright Browsers - run: npx playwright@1.51.1 install chromium --with-deps - - name: Run SSO Login Spec - working-directory: openmetadata-ui/src/main/resources/ui - run: | - npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ - --project=sso-auth \ - --workers=1 - env: - SSO_PROVIDER_TYPE: ${{ matrix.provider }} - # Reference each provider's secrets statically so GitHub Actions - # exposes only the credentials this step needs — not the entire - # org/repo secret store. A `secrets[format(...)]` style lookup - # would defeat static analysis and trigger CodeQL's - # "Excessive Secrets Exposure" rule. When a new provider lands, - # add a sibling step gated by `if: matrix.provider == ''` - # with that provider's static secret references. - SSO_USERNAME: ${{ secrets.OKTA_SSO_USERNAME }} - SSO_PASSWORD: ${{ secrets.OKTA_SSO_PASSWORD }} - # Okta tenant configuration — all three are REQUIRED by - # playwright/utils/sso-providers/okta.ts; the helper throws if any - # are missing so the suite is explicit about which IdP tenant it - # exercises. - OKTA_SSO_CLIENT_ID: ${{ secrets.OKTA_SSO_CLIENT_ID }} - OKTA_SSO_DOMAIN: ${{ secrets.OKTA_SSO_DOMAIN }} - OKTA_SSO_PRINCIPAL_DOMAIN: ${{ secrets.OKTA_SSO_PRINCIPAL_DOMAIN }} - PLAYWRIGHT_IS_OSS: true - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - timeout-minutes: 30 - - - name: Upload HTML report - if: always() - uses: actions/upload-artifact@v4 + uses: ./.github/actions/sso-login-run with: - name: sso-login-html-report-${{ matrix.provider }} - path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report - retention-days: 5 - - - name: Clean Up - if: always() - run: | - cd ./docker/development - docker compose down --remove-orphans - sudo rm -rf ${PWD}/docker-volume + provider: okta + sso_password: ${{ secrets.OKTA_SSO_PASSWORD }} + all_vars_json: ${{ toJSON(vars) }} From 8a5706a1398f89c7fef87a19246ff78357226ca1 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 13:26:11 +0530 Subject: [PATCH 08/29] refactor(playwright): move Okta tenant config to a repo constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Okta tenant identifiers (clientId, domain, principalDomain) are non-secret OAuth public values — visible on the hosted login page during any sign-in. Keeping them in GitHub environment variables cost setup friction (5 env vars to configure locally, each a potential typo) without any security benefit. Move them back to a committed OKTA_TENANT constant in okta.ts where a reviewer can see exactly which tenant the suite is exercising. Net effect: - Local runs only need SSO_PROVIDER_TYPE, SSO_USERNAME, SSO_PASSWORD. - The test environment in GH Actions keeps OKTA_SSO_USERNAME (variable) and OKTA_SSO_PASSWORD (secret); the three tenant variables are no longer consumed. - Composite action drops the jq-based dynamic var extraction; the caller passes sso_username directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/sso-login-run/action.yml | 43 +++---------------- .../playwright-sso-login-nightly.yml | 27 +----------- .../ui/playwright/constant/ssoAuth.ts | 3 -- .../ui/playwright/utils/sso-providers/okta.ts | 33 ++++++-------- 4 files changed, 19 insertions(+), 87 deletions(-) diff --git a/.github/actions/sso-login-run/action.yml b/.github/actions/sso-login-run/action.yml index 6b407a1e396f..927569cba5a3 100644 --- a/.github/actions/sso-login-run/action.yml +++ b/.github/actions/sso-login-run/action.yml @@ -9,26 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Composite action that runs the end-to-end SSO login Playwright spec for a -# single provider. -# -# Provider-agnostic: the caller job passes the provider name + its password -# secret. Non-secret provider configuration (username, tenant IDs, domains) -# is pulled dynamically from the calling job's `vars` context using the -# convention `${PROVIDER_UPPER}_SSO_*`, so adding a new tenant field for an -# existing provider requires no workflow change. -# -# Adding a new provider: -# 1. Add `_SSO_PASSWORD` as an environment secret and -# `_SSO_USERNAME` + whatever tenant vars the helper reads in -# the `test` environment. -# 2. Add a sibling job in playwright-sso-login-nightly.yml with -# `environment: test`, gated by `if: ...`, that calls this composite -# action with a STATIC `${{ secrets._SSO_PASSWORD }}` -# reference (keeps CodeQL happy). -# 3. Register the provider helper in -# openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts. - name: SSO Login Run description: Run the end-to-end SSO login Playwright spec for a single provider. @@ -36,12 +16,12 @@ inputs: provider: description: 'SSO provider identifier (lowercase, e.g. okta, azure)' required: true + sso_username: + description: 'Username / email for the provider test account' + required: true sso_password: description: 'Password for the provider test account' required: true - all_vars_json: - description: 'toJSON(vars) from the caller — used to pull non-secret provider config' - required: true runs: using: composite @@ -87,25 +67,12 @@ runs: shell: bash run: npx playwright@1.51.1 install chromium --with-deps - - name: Export SSO provider env - shell: bash - env: - PROVIDER: ${{ inputs.provider }} - ALL_VARS: ${{ inputs.all_vars_json }} - run: | - PREFIX=$(echo "$PROVIDER" | tr '[:lower:]' '[:upper:]') - echo "SSO_PROVIDER_TYPE=$PROVIDER" >> "$GITHUB_ENV" - printf '%s\n' "$ALL_VARS" \ - | jq -r --arg p "${PREFIX}_SSO_" \ - 'to_entries[] | select(.key | startswith($p)) | "\(.key)=\(.value)"' \ - >> "$GITHUB_ENV" - USERNAME=$(printf '%s\n' "$ALL_VARS" | jq -r --arg k "${PREFIX}_SSO_USERNAME" '.[$k]') - echo "SSO_USERNAME=$USERNAME" >> "$GITHUB_ENV" - - name: Run SSO Login Spec working-directory: openmetadata-ui/src/main/resources/ui shell: bash env: + SSO_PROVIDER_TYPE: ${{ inputs.provider }} + SSO_USERNAME: ${{ inputs.sso_username }} SSO_PASSWORD: ${{ inputs.sso_password }} PLAYWRIGHT_IS_OSS: true run: | diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 4a5df81866ad..89fbfb3a68bc 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -9,31 +9,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Nightly end-to-end SSO login tests. -# -# Purpose: -# Drive real login round-trips through external identity providers and -# validate the resulting OpenMetadata sessions. Complements -# playwright-sso-tests.yml, which only exercises the SSO configuration -# form UI without performing an actual login. -# -# Shape: -# One job per provider. Each job declares `environment: test` so it can -# resolve `${{ secrets._SSO_PASSWORD }}` from the environment's -# secrets with a STATIC reference (keeps CodeQL's "Excessive Secrets -# Exposure" rule happy). All shared setup + test-run logic lives in the -# composite action ./.github/actions/sso-login-run. -# -# Adding a new provider: -# 1. Add `_SSO_PASSWORD` as an environment secret and -# `_SSO_USERNAME` + tenant vars in the `test` environment. -# 2. Add the provider to the `workflow_dispatch.inputs.sso_provider` -# options list. -# 3. Copy the `okta` job below, rename it, and point its static secret -# reference at `_SSO_PASSWORD`. -# 4. Register the provider helper in -# openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts. - name: SSO Login Nightly on: @@ -74,5 +49,5 @@ jobs: uses: ./.github/actions/sso-login-run with: provider: okta + sso_username: ${{ vars.OKTA_SSO_USERNAME }} sso_password: ${{ secrets.OKTA_SSO_PASSWORD }} - all_vars_json: ${{ toJSON(vars) }} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index e10710084445..9a98be38fbbe 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -21,7 +21,4 @@ export const SSO_ENV = { PROVIDER_TYPE: 'SSO_PROVIDER_TYPE', USERNAME: 'SSO_USERNAME', PASSWORD: 'SSO_PASSWORD', - OKTA_CLIENT_ID: 'OKTA_SSO_CLIENT_ID', - OKTA_DOMAIN: 'OKTA_SSO_DOMAIN', - OKTA_PRINCIPAL_DOMAIN: 'OKTA_SSO_PRINCIPAL_DOMAIN', } as const; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts index 09648bc30f6e..25f25d5f7c6d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts @@ -11,29 +11,22 @@ * limitations under the License. */ import { expect, Page } from '@playwright/test'; -import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; +import { OM_BASE_URL } from '../../constant/ssoAuth'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import { ProviderHelper } from './index'; -const getRequiredEnv = (envKey: string): string => { - const value = process.env[envKey]?.trim(); - - if (!value) { - throw new Error( - `Missing required Okta SSO environment variable: ${envKey}. ` + - 'Set this explicitly for Playwright SSO tests.' - ); - } - - return value; -}; +// Collate's nightly test Okta tenant. These are non-secret OAuth public +// identifiers — visible on the hosted login page during any sign-in — so +// committing them is intentional. To point the suite at a different tenant, +// update this constant in a single commit. +const OKTA_TENANT = { + clientId: '0oayn277hnOhUpVLd697', + domain: 'integrator-9351624.okta.com', + principalDomain: 'getcollate.io', +} as const; const buildConfigPayload = (): ProviderConfigOverride => { - const clientId = getRequiredEnv(SSO_ENV.OKTA_CLIENT_ID); - const oktaDomain = getRequiredEnv(SSO_ENV.OKTA_DOMAIN); - const principalDomain = getRequiredEnv(SSO_ENV.OKTA_PRINCIPAL_DOMAIN); - - const authority = `https://${oktaDomain}/oauth2/default`; + const authority = `https://${OKTA_TENANT.domain}/oauth2/default`; return { authenticationConfiguration: { @@ -46,13 +39,13 @@ const buildConfigPayload = (): ProviderConfigOverride => { ], tokenValidationAlgorithm: 'RS256', authority, - clientId, + clientId: OKTA_TENANT.clientId, callbackUrl: `${OM_BASE_URL}/callback`, jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], enableSelfSignup: true, }, authorizerConfiguration: { - principalDomain, + principalDomain: OKTA_TENANT.principalDomain, }, }; }; From 325405edeaa99e2b8a645591ecf3729c989de747 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 13:28:49 +0530 Subject: [PATCH 09/29] ci(sso-login): move timeout-minutes from composite step to job level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite actions don't support timeout-minutes on individual steps — that's a runner job field only. Move the 30-minute test timeout up to the dispatcher job and bump to 45 minutes to cover docker + maven setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/sso-login-run/action.yml | 1 - .github/workflows/playwright-sso-login-nightly.yml | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sso-login-run/action.yml b/.github/actions/sso-login-run/action.yml index 927569cba5a3..fb68ae6cfbe5 100644 --- a/.github/actions/sso-login-run/action.yml +++ b/.github/actions/sso-login-run/action.yml @@ -79,7 +79,6 @@ runs: npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ --project=sso-auth \ --workers=1 - timeout-minutes: 30 - name: Upload HTML report if: always() diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 89fbfb3a68bc..65ea238988a7 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -41,6 +41,7 @@ jobs: github.event.inputs.sso_provider == 'all' runs-on: ubuntu-latest environment: test + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@v4 From 31a3cf2ed600057ba0eba72ac0ec065df80a79c2 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 13:42:53 +0530 Subject: [PATCH 10/29] ci(sso-login): consolidate dispatcher + composite action into one file Collapse the dispatcher workflow + composite action split into a single ~115-line workflow using a strategy matrix and dynamic vars[format(...)] / secrets[format(...)] credential resolution keyed on the matrix provider name. Trade-off: - CodeQL "Excessive Secrets Exposure" (low severity) will re-flag the dynamic secret lookup. Accepted in exchange for a single source of truth and true zero-workflow-churn multi-provider support. Onboarding a new provider is now: 1. Add its name to the matrix array + dispatch options list. 2. Add _SSO_USERNAME (variable) + _SSO_PASSWORD (secret) in the test environment. 3. Register the helper in sso-providers/index.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/sso-login-run/action.yml | 97 ------------------- .../playwright-sso-login-nightly.yml | 91 ++++++++++++++--- 2 files changed, 80 insertions(+), 108 deletions(-) delete mode 100644 .github/actions/sso-login-run/action.yml diff --git a/.github/actions/sso-login-run/action.yml b/.github/actions/sso-login-run/action.yml deleted file mode 100644 index fb68ae6cfbe5..000000000000 --- a/.github/actions/sso-login-run/action.yml +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright 2025 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. - -name: SSO Login Run -description: Run the end-to-end SSO login Playwright spec for a single provider. - -inputs: - provider: - description: 'SSO provider identifier (lowercase, e.g. okta, azure)' - required: true - sso_username: - description: 'Username / email for the provider test account' - required: true - sso_password: - description: 'Password for the provider test account' - required: true - -runs: - using: composite - steps: - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: false - swap-storage: true - docker-images: false - - - name: Cache Maven Dependencies - uses: actions/cache@v4 - with: - path: ~/.m2 - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- - - - name: Setup OpenMetadata Test Environment - uses: ./.github/actions/setup-openmetadata-test-environment - with: - python-version: "3.10" - # Ingestion is not required for SSO login tests. - args: "-d postgresql -i false" - ingestion_dependency: "all" - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: 'openmetadata-ui/src/main/resources/ui/.nvmrc' - - - name: Install dependencies - working-directory: openmetadata-ui/src/main/resources/ui/ - shell: bash - run: yarn --ignore-scripts --frozen-lockfile - - - name: Install Playwright Browsers - shell: bash - run: npx playwright@1.51.1 install chromium --with-deps - - - name: Run SSO Login Spec - working-directory: openmetadata-ui/src/main/resources/ui - shell: bash - env: - SSO_PROVIDER_TYPE: ${{ inputs.provider }} - SSO_USERNAME: ${{ inputs.sso_username }} - SSO_PASSWORD: ${{ inputs.sso_password }} - PLAYWRIGHT_IS_OSS: true - run: | - npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ - --project=sso-auth \ - --workers=1 - - - name: Upload HTML report - if: always() - uses: actions/upload-artifact@v4 - with: - name: sso-login-html-report-${{ inputs.provider }} - path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report - retention-days: 5 - - - name: Clean Up - if: always() - shell: bash - run: | - cd ./docker/development - docker compose down --remove-orphans - sudo rm -rf ${PWD}/docker-volume diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 65ea238988a7..ee53fad222e7 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -13,12 +13,11 @@ name: SSO Login Nightly on: schedule: - # Every night at 03:00 UTC — runs every registered provider. - cron: '0 3 * * *' workflow_dispatch: inputs: sso_provider: - description: 'SSO provider to test (or "all")' + description: 'SSO provider (or "all")' required: true default: okta type: choice @@ -34,21 +33,91 @@ concurrency: cancel-in-progress: true jobs: - okta: - if: >- - github.event_name == 'schedule' || - github.event.inputs.sso_provider == 'okta' || - github.event.inputs.sso_provider == 'all' + # To onboard a new provider: + # 1. Add its lowercase name to the matrix array below AND to the + # dispatch `options:` list above. + # 2. Add _SSO_USERNAME (variable) and _SSO_PASSWORD + # (secret) to the `test` environment. + # 3. Register the helper in playwright/utils/sso-providers/index.ts. + sso-login: runs-on: ubuntu-latest environment: test timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + provider: + ${{ (github.event_name == 'schedule' || github.event.inputs.sso_provider == 'all') + && fromJSON('["okta"]') + || fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) }} steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + swap-storage: true + docker-images: false + - name: Checkout uses: actions/checkout@v4 + - name: Cache Maven Dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Setup OpenMetadata Test Environment + uses: ./.github/actions/setup-openmetadata-test-environment + with: + python-version: "3.10" + args: "-d postgresql -i false" + ingestion_dependency: "all" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: 'openmetadata-ui/src/main/resources/ui/.nvmrc' + + - name: Install dependencies + working-directory: openmetadata-ui/src/main/resources/ui/ + run: yarn --ignore-scripts --frozen-lockfile + + - name: Install Playwright Browsers + run: npx playwright@1.51.1 install chromium --with-deps + + - name: Compute provider prefix + run: echo "PROVIDER_PREFIX=$(echo '${{ matrix.provider }}' | tr '[:lower:]' '[:upper:]')" >> "$GITHUB_ENV" + - name: Run SSO Login Spec - uses: ./.github/actions/sso-login-run + working-directory: openmetadata-ui/src/main/resources/ui + env: + SSO_PROVIDER_TYPE: ${{ matrix.provider }} + SSO_USERNAME: ${{ vars[format('{0}_SSO_USERNAME', env.PROVIDER_PREFIX)] }} + SSO_PASSWORD: ${{ secrets[format('{0}_SSO_PASSWORD', env.PROVIDER_PREFIX)] }} + PLAYWRIGHT_IS_OSS: true + run: | + npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ + --project=sso-auth \ + --workers=1 + + - name: Upload HTML report + if: always() + uses: actions/upload-artifact@v4 with: - provider: okta - sso_username: ${{ vars.OKTA_SSO_USERNAME }} - sso_password: ${{ secrets.OKTA_SSO_PASSWORD }} + name: sso-login-html-report-${{ matrix.provider }} + path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report + retention-days: 5 + + - name: Clean Up + if: always() + run: | + cd ./docker/development + docker compose down --remove-orphans + sudo rm -rf ${PWD}/docker-volume From e0c3f4f75127f51bf33572f384805b2e76f2b6b6 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 13:45:27 +0530 Subject: [PATCH 11/29] ci(sso-login): drop provider-prefix bash step; use case-insensitive lookup GitHub secret and variable names are case-insensitive, so format('{0}_SSO_PASSWORD', matrix.provider) with the lowercase matrix value resolves correctly against the uppercase conventional names like OKTA_SSO_PASSWORD. That removes the need for a separate "Compute provider prefix" step and its cross-step env-context plumbing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/playwright-sso-login-nightly.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index ee53fad222e7..b60a4189eccb 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -92,15 +92,15 @@ jobs: - name: Install Playwright Browsers run: npx playwright@1.51.1 install chromium --with-deps - - name: Compute provider prefix - run: echo "PROVIDER_PREFIX=$(echo '${{ matrix.provider }}' | tr '[:lower:]' '[:upper:]')" >> "$GITHUB_ENV" - - name: Run SSO Login Spec working-directory: openmetadata-ui/src/main/resources/ui + # GitHub secret/variable names are case-insensitive, so passing the + # lowercase matrix.provider through format() resolves correctly + # against uppercase conventional names like OKTA_SSO_PASSWORD. env: SSO_PROVIDER_TYPE: ${{ matrix.provider }} - SSO_USERNAME: ${{ vars[format('{0}_SSO_USERNAME', env.PROVIDER_PREFIX)] }} - SSO_PASSWORD: ${{ secrets[format('{0}_SSO_PASSWORD', env.PROVIDER_PREFIX)] }} + SSO_USERNAME: ${{ vars[format('{0}_SSO_USERNAME', matrix.provider)] }} + SSO_PASSWORD: ${{ secrets[format('{0}_SSO_PASSWORD', matrix.provider)] }} PLAYWRIGHT_IS_OSS: true run: | npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ From a37aff08875eb7a7538cc973caa9d3fa8ed93675 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 13:48:12 +0530 Subject: [PATCH 12/29] ci(sso-login): drop redundant case-insensitivity comment Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/playwright-sso-login-nightly.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index b60a4189eccb..6ce826592284 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -94,9 +94,6 @@ jobs: - name: Run SSO Login Spec working-directory: openmetadata-ui/src/main/resources/ui - # GitHub secret/variable names are case-insensitive, so passing the - # lowercase matrix.provider through format() resolves correctly - # against uppercase conventional names like OKTA_SSO_PASSWORD. env: SSO_PROVIDER_TYPE: ${{ matrix.provider }} SSO_USERNAME: ${{ vars[format('{0}_SSO_USERNAME', matrix.provider)] }} From b054b6f351324e3d52ae173da4ddc30d6e1a20bc Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 13:59:59 +0530 Subject: [PATCH 13/29] ci(sso-login): pin playwright install to 1.57.0 to match package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 1.51.1 pin was stale vs. the @playwright/test version in package.json. The mismatch caused browser cache path divergence — the install step wrote browsers under 1.51.1's cache and the test run looked for them under 1.57.0's cache and failed with "browsers not installed." Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/playwright-sso-login-nightly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 6ce826592284..8a240d44103b 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -90,7 +90,7 @@ jobs: run: yarn --ignore-scripts --frozen-lockfile - name: Install Playwright Browsers - run: npx playwright@1.51.1 install chromium --with-deps + run: npx playwright@1.57.0 install chromium --with-deps - name: Run SSO Login Spec working-directory: openmetadata-ui/src/main/resources/ui From 891d97645ac9080882f9bb7c33b076e8a5d20c8b Mon Sep 17 00:00:00 2001 From: Siddhant Date: Thu, 9 Apr 2026 17:11:51 +0530 Subject: [PATCH 14/29] refactor(playwright): address SSO suite review comments [skip ci] - Drive Okta tenant (clientId, domain, principalDomain) from env vars, falling back to the existing nightly tenant values as defaults - Use redirectToHomePage as the final assertion in the SSO login step - Document why the /signup vs /my-data branch is conditional Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/ui/playwright/constant/ssoAuth.ts | 3 +++ .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 7 +++++-- .../ui/playwright/utils/sso-providers/okta.ts | 17 +++++++++-------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index 9a98be38fbbe..add89e699e69 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -21,4 +21,7 @@ export const SSO_ENV = { PROVIDER_TYPE: 'SSO_PROVIDER_TYPE', USERNAME: 'SSO_USERNAME', PASSWORD: 'SSO_PASSWORD', + OKTA_CLIENT_ID: 'OKTA_CLIENT_ID', + OKTA_DOMAIN: 'OKTA_DOMAIN', + OKTA_PRINCIPAL_DOMAIN: 'OKTA_PRINCIPAL_DOMAIN', } as const; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index fd4572b4b6b6..6151c7ec48e6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -13,7 +13,7 @@ import { expect, test } from '@playwright/test'; import { SSO_ENV } from '../../constant/ssoAuth'; import { performAdminLogin } from '../../utils/admin'; -import { getAuthContext } from '../../utils/common'; +import { getAuthContext, redirectToHomePage } from '../../utils/common'; import { getProviderHelper, ProviderHelper } from '../../utils/sso-providers'; import { applyProviderConfig, @@ -117,6 +117,9 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { }); await test.step('Return to OpenMetadata and complete self-signup if needed', async () => { + // The IdP can land the user on /signup (first-ever sign-in against this + // OM instance) or /my-data (user already exists from a prior run). Wait + // for either, and handle the one-time self-signup click when it appears. await page.waitForURL( (url) => url.pathname.endsWith('/signup') || url.pathname.endsWith('/my-data'), @@ -131,7 +134,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await page.waitForURL('**/my-data', { timeout: 60_000 }); } - await expect(page.getByTestId('dropdown-profile')).toBeVisible(); + await redirectToHomePage(page); }); await test.step('Verify JWT against loggedInUser API', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts index 25f25d5f7c6d..36d211381f36 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts @@ -11,18 +11,19 @@ * limitations under the License. */ import { expect, Page } from '@playwright/test'; -import { OM_BASE_URL } from '../../constant/ssoAuth'; +import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import { ProviderHelper } from './index'; -// Collate's nightly test Okta tenant. These are non-secret OAuth public -// identifiers — visible on the hosted login page during any sign-in — so -// committing them is intentional. To point the suite at a different tenant, -// update this constant in a single commit. +// Defaults target Collate's nightly test Okta tenant. These are non-secret +// OAuth public identifiers — visible on the hosted login page during any +// sign-in — so committing them is intentional. Override via the matching +// env vars to point the suite at a different tenant without a code change. const OKTA_TENANT = { - clientId: '0oayn277hnOhUpVLd697', - domain: 'integrator-9351624.okta.com', - principalDomain: 'getcollate.io', + clientId: process.env[SSO_ENV.OKTA_CLIENT_ID] ?? '0oayn277hnOhUpVLd697', + domain: process.env[SSO_ENV.OKTA_DOMAIN] ?? 'integrator-9351624.okta.com', + principalDomain: + process.env[SSO_ENV.OKTA_PRINCIPAL_DOMAIN] ?? 'getcollate.io', } as const; const buildConfigPayload = (): ProviderConfigOverride => { From 39fb53a69b39247feea5f00a8163f61d9db442ce Mon Sep 17 00:00:00 2001 From: Siddhant Date: Fri, 10 Apr 2026 12:23:45 +0530 Subject: [PATCH 15/29] saml --- .../playwright-sso-login-nightly.yml | 3 +- .../ui/playwright/constant/ssoAuth.ts | 3 + .../utils/sso-providers/azure-saml.ts | 128 ++++++++++++++++++ .../playwright/utils/sso-providers/index.ts | 5 +- 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 8a240d44103b..2fce513c6aa3 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -23,6 +23,7 @@ on: type: choice options: - okta + - azure-saml - all permissions: @@ -48,7 +49,7 @@ jobs: matrix: provider: ${{ (github.event_name == 'schedule' || github.event.inputs.sso_provider == 'all') - && fromJSON('["okta"]') + && fromJSON('["okta","azure-saml"]') || fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) }} steps: - name: Free Disk Space (Ubuntu) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index add89e699e69..46838f168b3e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -24,4 +24,7 @@ export const SSO_ENV = { OKTA_CLIENT_ID: 'OKTA_CLIENT_ID', OKTA_DOMAIN: 'OKTA_DOMAIN', OKTA_PRINCIPAL_DOMAIN: 'OKTA_PRINCIPAL_DOMAIN', + AZURE_SAML_TENANT_ID: 'AZURE_SAML_TENANT_ID', + AZURE_SAML_PRINCIPAL_DOMAIN: 'AZURE_SAML_PRINCIPAL_DOMAIN', + AZURE_SAML_IDP_CERTIFICATE: 'AZURE_SAML_IDP_CERTIFICATE', } as const; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts new file mode 100644 index 000000000000..74f8d4a794bd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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. + */ +import { expect, Page } from '@playwright/test'; +import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; +import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import { ProviderHelper } from './index'; + +// Defaults target Collate's nightly test Azure AD tenant. These are non-secret +// SAML metadata values — the entity ID, login URL, and signing certificate are +// published in Azure's federation metadata XML endpoint — so committing them is +// intentional. Override via the matching env vars to point the suite at a +// different tenant without a code change. +const AZURE_SAML_TENANT = { + tenantId: + process.env[SSO_ENV.AZURE_SAML_TENANT_ID] ?? + 'face9c4e-1b50-41d3-b404-d4d2432a5be3', + principalDomain: + process.env[SSO_ENV.AZURE_SAML_PRINCIPAL_DOMAIN] ?? 'getcollate.io', + idpX509Certificate: + process.env[SSO_ENV.AZURE_SAML_IDP_CERTIFICATE] ?? + [ + '-----BEGIN CERTIFICATE-----', + 'MIIC8DCCAdigAwIBAgIQRWr0YoCt5oRIk335kT5DjjANBgkqhkiG9w0BAQsFADA0', + 'MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZp', + 'Y2F0ZTAeFw0yNTA3MjQwNzM0MTZaFw0yODA3MjQwNzM0MTZaMDQxMjAwBgNVBAMT', + 'KU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjAN', + 'BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXhfRXgCUkqU8fpAoSBq+xh0f38I', + 'kV1RwipcLRIYSXyWZTXs79WiWB9s+fzinB9oPr2TX+BwWPk0Z4PraeMXng5LvPck', + 'RgpvLCgTyiy3RPDaPcUqkWIkLvO949qS+IiAuzdjVM/Tw72fi88SyRGyEa/HjDio', + 'SND6yf/mzWJ4eX59yvEEWrHW2T9IB5+Rb8VA7JzLU5/AnYsxugve7HzMfJYl3wWW', + 'B/qoBOyyrhamCCW8GFD8sfE7Um8yxGyAM5q+IXYX0Iuzxo+JnWuBPUKlTdmLbfnX', + 'odtjQn//RFnmA8b4uodwCmObzNnN2xCExj33PPIBezemWSmMq4bYOoA6tQIDAQAB', + 'MA0GCSqGSIb3DQEBCwUAA4IBAQAZz/SQLmJdHAD4tU8lsPJyWEw5CKDGvBYvCnyZ', + 'Ph5/gCKujlFgGAvmXAPtl616itGndlVnWsA6ofyrGBtwWBeMk+XJTqol9ki9ZTzo', + 'Vuj5/2Un2gREQaInKN2uDcs64E9j74dWO3t/litN4XYRUGirsKSKOubV0PVtkmiBW', + 'CHYDySsZ3z3XAyRRCoV3DCdkf3kiy4fGTM/VatirBBAZfu/MaoTtIvDEUKotfn6tO', + 'UxRdvegHWqa5Tn8QjDqcLbN8ok0AmrCP5WOTQjtt1+PO6v4mvzvkiNeypxEqOwcQ', + 'WVdgvw/IldUp0TDomvr6xZeYEPv0Xn0l4ot1lwq3/tv/Kz', + '-----END CERTIFICATE-----', + ].join('\n'), +} as const; + +const buildConfigPayload = (): ProviderConfigOverride => { + const { tenantId } = AZURE_SAML_TENANT; + + return { + authenticationConfiguration: { + provider: 'saml', + providerName: 'Azure AD', + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + enableSelfSignup: true, + samlConfiguration: { + idp: { + entityId: `https://sts.windows.net/${tenantId}/`, + ssoLoginUrl: `https://login.microsoftonline.com/${tenantId}/saml2`, + idpX509Certificate: AZURE_SAML_TENANT.idpX509Certificate, + nameId: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }, + sp: { + entityId: `${OM_BASE_URL}/api/v1/saml/metadata`, + acs: `${OM_BASE_URL}/api/v1/saml/acs`, + callback: `${OM_BASE_URL}/callback`, + }, + security: { + strictMode: false, + tokenValidity: 3600, + sendEncryptedNameId: false, + sendSignedAuthRequest: false, + wantMessagesSigned: false, + wantAssertionsSigned: false, + }, + debugMode: false, + }, + }, + authorizerConfiguration: { + principalDomain: AZURE_SAML_TENANT.principalDomain, + }, + }; +}; + +const performProviderLogin = async ( + page: Page, + { username, password }: ProviderCredentials +): Promise => { + const emailInput = page.locator('input[type="email"]'); + + await expect(emailInput).toBeVisible(); + await emailInput.fill(username); + + const nextButton = page.locator('input[type="submit"]'); + + await expect(nextButton).toBeEnabled(); + await nextButton.click(); + + const passwordInput = page.locator('input[type="password"]'); + + await expect(passwordInput).toBeVisible(); + await passwordInput.fill(password); + + const signInButton = page.locator('input[type="submit"]'); + + await expect(signInButton).toBeEnabled(); + await signInButton.click(); + + // Azure may show a "Stay signed in?" prompt — dismiss it if it appears + const staySignedInNo = page.locator('input#idBtn_Back'); + + if (await staySignedInNo.isVisible({ timeout: 5_000 }).catch(() => false)) { + await staySignedInNo.click(); + } +}; + +export const azureSamlProviderHelper: ProviderHelper = { + expectedButtonText: 'Sign in with Azure AD', + loginUrlPattern: /login\.microsoftonline\.com/, + buildConfigPayload, + performProviderLogin, +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts index 5bea4d90d8f7..da97d8d5a05e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -12,6 +12,7 @@ */ import { Page } from '@playwright/test'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import { azureSamlProviderHelper } from './azure-saml'; import { oktaProviderHelper } from './okta'; export interface ProviderHelper { @@ -28,10 +29,12 @@ export const getProviderHelper = (providerType: string): ProviderHelper => { switch (providerType) { case 'okta': return oktaProviderHelper; + case 'azure-saml': + return azureSamlProviderHelper; default: throw new Error( `No SSO provider helper registered for "${providerType}". ` + - `Supported providers: okta` + `Supported providers: okta, azure-saml` ); } }; From 633d1855f7184b905693b08b16829d3e8b3b710c Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 14 Apr 2026 13:02:06 +0530 Subject: [PATCH 16/29] test(playwright): add SAML providers to SSO login nightly Extend the nightly SSO login matrix with Azure AD SAML and a self-contained Keycloak SAML fixture (Azure-profile + Google-profile realms), so the suite exercises the full SAML flow end-to-end without relying on a hosted IdP. - docker/local-sso/keycloak-saml: Keycloak 26.3.3 compose + pre-imported realms bound to OM at localhost:8585, port-overridable via KEYCLOAK_SAML_PORT. - playwright sso-providers: azure-saml helper (hosted tenant, non-secret federation metadata committed) and keycloak-saml factory that fetches the realm's IdP X509 at runtime. - SSO assertion matches OM's actual SAML sign-in label ("Sign in with SAML SSO"), since providerName isn't propagated into the store for the SAML provider branch of getAuthConfig. - Workflow starts/stops the Keycloak stack only for keycloak-* matrix rows and injects the fixture credentials inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../playwright-sso-login-nightly.yml | 26 ++- docker/local-sso/keycloak-saml/README.md | 32 +++ .../keycloak-saml/docker-compose.yml | 23 +++ .../realms/om-azure-saml-realm.json | 109 ++++++++++ .../realms/om-google-saml-realm.json | 109 ++++++++++ .../ui/playwright/constant/ssoAuth.ts | 4 + .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 4 +- .../utils/sso-providers/azure-saml.ts | 6 +- .../playwright/utils/sso-providers/index.ts | 16 +- .../utils/sso-providers/keycloak-saml.ts | 190 ++++++++++++++++++ 10 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 docker/local-sso/keycloak-saml/README.md create mode 100644 docker/local-sso/keycloak-saml/docker-compose.yml create mode 100644 docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json create mode 100644 docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 2fce513c6aa3..b955d06a9ca2 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -24,6 +24,8 @@ on: options: - okta - azure-saml + - keycloak-azure-saml + - keycloak-google-saml - all permissions: @@ -38,7 +40,7 @@ jobs: # 1. Add its lowercase name to the matrix array below AND to the # dispatch `options:` list above. # 2. Add _SSO_USERNAME (variable) and _SSO_PASSWORD - # (secret) to the `test` environment. + # (secret) to the `test` environment, unless the provider uses a local fixture. # 3. Register the helper in playwright/utils/sso-providers/index.ts. sso-login: runs-on: ubuntu-latest @@ -49,8 +51,8 @@ jobs: matrix: provider: ${{ (github.event_name == 'schedule' || github.event.inputs.sso_provider == 'all') - && fromJSON('["okta","azure-saml"]') - || fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) }} + && fromJSON('["okta","keycloak-azure-saml","keycloak-google-saml"]') + || fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) }} steps: - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@main @@ -77,9 +79,9 @@ jobs: - name: Setup OpenMetadata Test Environment uses: ./.github/actions/setup-openmetadata-test-environment with: - python-version: "3.10" - args: "-d postgresql -i false" - ingestion_dependency: "all" + python-version: '3.10' + args: '-d postgresql -i false' + ingestion_dependency: 'all' - name: Setup Node.js uses: actions/setup-node@v4 @@ -93,12 +95,19 @@ jobs: - name: Install Playwright Browsers run: npx playwright@1.57.0 install chromium --with-deps + - name: Start Keycloak SAML IdP + if: startsWith(matrix.provider, 'keycloak-') + run: | + docker compose -f docker/local-sso/keycloak-saml/docker-compose.yml up -d + timeout 180 bash -c 'until curl -fsS http://localhost:8080/realms/om-azure-saml >/dev/null && curl -fsS http://localhost:8080/realms/om-google-saml >/dev/null; do sleep 2; done' + - name: Run SSO Login Spec working-directory: openmetadata-ui/src/main/resources/ui env: SSO_PROVIDER_TYPE: ${{ matrix.provider }} - SSO_USERNAME: ${{ vars[format('{0}_SSO_USERNAME', matrix.provider)] }} - SSO_PASSWORD: ${{ secrets[format('{0}_SSO_PASSWORD', matrix.provider)] }} + SSO_USERNAME: ${{ matrix.provider == 'keycloak-azure-saml' && 'azure.saml@openmetadata.local' || matrix.provider == 'keycloak-google-saml' && 'google.saml@openmetadata.local' || vars[format('{0}_SSO_USERNAME', matrix.provider)] }} + SSO_PASSWORD: ${{ startsWith(matrix.provider, 'keycloak-') && 'OpenMetadata@123' || secrets[format('{0}_SSO_PASSWORD', matrix.provider)] }} + KEYCLOAK_SAML_BASE_URL: http://localhost:8080 PLAYWRIGHT_IS_OSS: true run: | npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts \ @@ -116,6 +125,7 @@ jobs: - name: Clean Up if: always() run: | + docker compose -f docker/local-sso/keycloak-saml/docker-compose.yml down --remove-orphans || true cd ./docker/development docker compose down --remove-orphans sudo rm -rf ${PWD}/docker-volume diff --git a/docker/local-sso/keycloak-saml/README.md b/docker/local-sso/keycloak-saml/README.md new file mode 100644 index 000000000000..2cd9ade76b4c --- /dev/null +++ b/docker/local-sso/keycloak-saml/README.md @@ -0,0 +1,32 @@ +# Keycloak SAML Fixture + +Local SAML IdP fixture for the Playwright SSO login spec. + +```bash +docker compose -f docker/local-sso/keycloak-saml/docker-compose.yml up -d +``` + +It imports two realms for an OpenMetadata server running at `http://localhost:8585`: + +- `om-azure-saml` + - User: `azure.saml@openmetadata.local` + - Password: `OpenMetadata@123` +- `om-google-saml` + - User: `google.saml@openmetadata.local` + - Password: `OpenMetadata@123` + +Use the matching Playwright provider type: + +```bash +SSO_PROVIDER_TYPE=keycloak-azure-saml \ +SSO_USERNAME=azure.saml@openmetadata.local \ +SSO_PASSWORD=OpenMetadata@123 \ +npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts --project=sso-auth --workers=1 +``` + +```bash +SSO_PROVIDER_TYPE=keycloak-google-saml \ +SSO_USERNAME=google.saml@openmetadata.local \ +SSO_PASSWORD=OpenMetadata@123 \ +npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts --project=sso-auth --workers=1 +``` diff --git a/docker/local-sso/keycloak-saml/docker-compose.yml b/docker/local-sso/keycloak-saml/docker-compose.yml new file mode 100644 index 000000000000..8d79f73f2772 --- /dev/null +++ b/docker/local-sso/keycloak-saml/docker-compose.yml @@ -0,0 +1,23 @@ +name: openmetadata-keycloak-saml + +services: + keycloak: + image: ${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.3.3} + container_name: openmetadata-keycloak-saml + command: ['start-dev', '--import-realm'] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD:-admin123} + KC_HEALTH_ENABLED: 'true' + KC_HTTP_ENABLED: 'true' + KC_HTTP_PORT: '8080' + ports: + - '${KEYCLOAK_SAML_PORT:-8080}:8080' + volumes: + - ./realms:/opt/keycloak/data/import:ro + healthcheck: + test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/8080'] + interval: 10s + timeout: 5s + retries: 18 + start_period: 20s diff --git a/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json b/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json new file mode 100644 index 000000000000..cc44c38303bc --- /dev/null +++ b/docker/local-sso/keycloak-saml/realms/om-azure-saml-realm.json @@ -0,0 +1,109 @@ +{ + "realm": "om-azure-saml", + "enabled": true, + "displayName": "OpenMetadata Azure SAML", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "clients": [ + { + "clientId": "http://localhost:8585/api/v1/saml/metadata", + "name": "OpenMetadata", + "enabled": true, + "protocol": "saml", + "publicClient": true, + "frontchannelLogout": true, + "redirectUris": ["http://localhost:8585/*"], + "baseUrl": "http://localhost:8585", + "adminUrl": "http://localhost:8585", + "attributes": { + "saml.assertion.signature": "true", + "saml.authnstatement": "true", + "saml.client.signature": "false", + "saml.encrypt": "false", + "saml.force.name.id.format": "true", + "saml.force.post.binding": "true", + "saml.multivalued.roles": "false", + "saml.server.signature": "true", + "saml.signature.algorithm": "RSA_SHA256", + "saml_assertion_consumer_url_post": "http://localhost:8585/api/v1/saml/acs", + "saml_force_name_id_format": "true", + "saml_name_id_format": "email" + }, + "protocolMappers": [ + { + "name": "Email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.name": "email", + "attribute.nameformat": "Basic", + "friendly.name": "email", + "user.attribute": "email" + } + }, + { + "name": "Display Name", + "protocol": "saml", + "protocolMapper": "saml-user-attribute-mapper", + "consentRequired": false, + "config": { + "attribute.name": "http://schemas.microsoft.com/identity/claims/displayname", + "attribute.nameformat": "Basic", + "friendly.name": "displayname", + "user.attribute": "displayName" + } + }, + { + "name": "Given Name", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + "attribute.nameformat": "Basic", + "friendly.name": "givenname", + "user.attribute": "firstName" + } + }, + { + "name": "Surname", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", + "attribute.nameformat": "Basic", + "friendly.name": "surname", + "user.attribute": "lastName" + } + } + ] + } + ], + "users": [ + { + "username": "azure.saml@openmetadata.local", + "email": "azure.saml@openmetadata.local", + "firstName": "Azure", + "lastName": "SAML", + "enabled": true, + "emailVerified": true, + "requiredActions": [], + "attributes": { + "displayName": ["Azure SAML User"] + }, + "credentials": [ + { + "type": "password", + "value": "OpenMetadata@123", + "temporary": false + } + ] + } + ] +} diff --git a/docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json b/docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json new file mode 100644 index 000000000000..d65f4740357c --- /dev/null +++ b/docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json @@ -0,0 +1,109 @@ +{ + "realm": "om-google-saml", + "enabled": true, + "displayName": "OpenMetadata Google SAML", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "clients": [ + { + "clientId": "http://localhost:8585/api/v1/saml/metadata", + "name": "OpenMetadata", + "enabled": true, + "protocol": "saml", + "publicClient": true, + "frontchannelLogout": true, + "redirectUris": ["http://localhost:8585/*"], + "baseUrl": "http://localhost:8585", + "adminUrl": "http://localhost:8585", + "attributes": { + "saml.assertion.signature": "true", + "saml.authnstatement": "true", + "saml.client.signature": "false", + "saml.encrypt": "false", + "saml.force.name.id.format": "true", + "saml.force.post.binding": "true", + "saml.multivalued.roles": "false", + "saml.server.signature": "true", + "saml.signature.algorithm": "RSA_SHA256", + "saml_assertion_consumer_url_post": "http://localhost:8585/api/v1/saml/acs", + "saml_force_name_id_format": "true", + "saml_name_id_format": "email" + }, + "protocolMappers": [ + { + "name": "Email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.name": "email", + "attribute.nameformat": "Basic", + "friendly.name": "email", + "user.attribute": "email" + } + }, + { + "name": "Given Name", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.name": "given_name", + "attribute.nameformat": "Basic", + "friendly.name": "given_name", + "user.attribute": "firstName" + } + }, + { + "name": "Family Name", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.name": "family_name", + "attribute.nameformat": "Basic", + "friendly.name": "family_name", + "user.attribute": "lastName" + } + }, + { + "name": "Name", + "protocol": "saml", + "protocolMapper": "saml-user-attribute-mapper", + "consentRequired": false, + "config": { + "attribute.name": "name", + "attribute.nameformat": "Basic", + "friendly.name": "name", + "user.attribute": "name" + } + } + ] + } + ], + "users": [ + { + "username": "google.saml@openmetadata.local", + "email": "google.saml@openmetadata.local", + "firstName": "Google", + "lastName": "SAML", + "enabled": true, + "emailVerified": true, + "requiredActions": [], + "attributes": { + "name": ["Google SAML User"] + }, + "credentials": [ + { + "type": "password", + "value": "OpenMetadata@123", + "temporary": false + } + ] + } + ] +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index 46838f168b3e..47874fc5cb96 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -27,4 +27,8 @@ export const SSO_ENV = { AZURE_SAML_TENANT_ID: 'AZURE_SAML_TENANT_ID', AZURE_SAML_PRINCIPAL_DOMAIN: 'AZURE_SAML_PRINCIPAL_DOMAIN', AZURE_SAML_IDP_CERTIFICATE: 'AZURE_SAML_IDP_CERTIFICATE', + KEYCLOAK_SAML_BASE_URL: 'KEYCLOAK_SAML_BASE_URL', + KEYCLOAK_SAML_AZURE_REALM: 'KEYCLOAK_SAML_AZURE_REALM', + KEYCLOAK_SAML_GOOGLE_REALM: 'KEYCLOAK_SAML_GOOGLE_REALM', + KEYCLOAK_SAML_PRINCIPAL_DOMAIN: 'KEYCLOAK_SAML_PRINCIPAL_DOMAIN', } as const; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 6151c7ec48e6..e8490542db4c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -60,10 +60,12 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { } originalSecurityConfig = await fetchSecurityConfig(apiContext); + const providerConfig = await helper.buildConfigPayload(); + await applyProviderConfig( apiContext, originalSecurityConfig, - helper.buildConfigPayload() + providerConfig ); } finally { await afterAction(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts index 74f8d4a794bd..304cc58391a4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts @@ -120,8 +120,12 @@ const performProviderLogin = async ( } }; +// OM's sign-in page uses a single "SAML SSO" label for every SAML provider — +// `authenticationConfiguration.providerName` isn't propagated into the store +// for the SAML branch in getAuthConfig, so even with providerName="Azure AD" +// the button renders as "Sign in with SAML SSO". export const azureSamlProviderHelper: ProviderHelper = { - expectedButtonText: 'Sign in with Azure AD', + expectedButtonText: 'Sign in with SAML SSO', loginUrlPattern: /login\.microsoftonline\.com/, buildConfigPayload, performProviderLogin, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts index da97d8d5a05e..1a7d39cae4c7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -13,12 +13,20 @@ import { Page } from '@playwright/test'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import { azureSamlProviderHelper } from './azure-saml'; +import { + keycloakAzureSamlProviderHelper, + keycloakGoogleSamlProviderHelper, +} from './keycloak-saml'; import { oktaProviderHelper } from './okta'; +export type ProviderConfigPayload = + | ProviderConfigOverride + | Promise; + export interface ProviderHelper { expectedButtonText: string; loginUrlPattern: RegExp; - buildConfigPayload: () => ProviderConfigOverride; + buildConfigPayload: () => ProviderConfigPayload; performProviderLogin: ( page: Page, credentials: ProviderCredentials @@ -31,10 +39,14 @@ export const getProviderHelper = (providerType: string): ProviderHelper => { return oktaProviderHelper; case 'azure-saml': return azureSamlProviderHelper; + case 'keycloak-azure-saml': + return keycloakAzureSamlProviderHelper; + case 'keycloak-google-saml': + return keycloakGoogleSamlProviderHelper; default: throw new Error( `No SSO provider helper registered for "${providerType}". ` + - `Supported providers: okta, azure-saml` + `Supported providers: okta, azure-saml, keycloak-azure-saml, keycloak-google-saml` ); } }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts new file mode 100644 index 000000000000..ee18615ca799 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts @@ -0,0 +1,190 @@ +/* + * Copyright 2025 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. + */ +import { expect, Page } from '@playwright/test'; +import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; +import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import type { ProviderHelper } from './index'; + +const SUPPORTED_OM_BASE_URL = 'http://localhost:8585'; + +const KEYCLOAK_SAML = { + baseUrl: + process.env[SSO_ENV.KEYCLOAK_SAML_BASE_URL] ?? 'http://localhost:8080', + azureRealm: process.env[SSO_ENV.KEYCLOAK_SAML_AZURE_REALM] ?? 'om-azure-saml', + googleRealm: + process.env[SSO_ENV.KEYCLOAK_SAML_GOOGLE_REALM] ?? 'om-google-saml', + principalDomain: + process.env[SSO_ENV.KEYCLOAK_SAML_PRINCIPAL_DOMAIN] ?? 'openmetadata.local', +} as const; + +interface KeycloakSamlProfile { + realm: string; + providerName: string; +} + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const descriptorUrl = (realm: string): string => + `${KEYCLOAK_SAML.baseUrl}/realms/${realm}/protocol/saml/descriptor`; + +const assertSupportedBaseUrl = (): void => { + if (OM_BASE_URL !== SUPPORTED_OM_BASE_URL) { + throw new Error( + `Keycloak SAML fixture realms are imported for ${SUPPORTED_OM_BASE_URL}. ` + + `Set PLAYWRIGHT_TEST_BASE_URL=${SUPPORTED_OM_BASE_URL} or update the realm import files before running with ${OM_BASE_URL}.` + ); + } +}; + +const wrapPemCertificate = (certificate: string): string => { + const body = certificate.replace(/\s+/g, ''); + const lines = body.match(/.{1,64}/g); + + if (!lines) { + throw new Error( + 'Keycloak SAML descriptor returned an empty X509 certificate' + ); + } + + return [ + '-----BEGIN CERTIFICATE-----', + ...lines, + '-----END CERTIFICATE-----', + ].join('\n'); +}; + +const extractIdpCertificate = (descriptor: string, realm: string): string => { + const match = descriptor.match( + /<[^>]*X509Certificate[^>]*>([^<]+)<\/[^>]*X509Certificate>/ + ); + + if (!match?.[1]) { + throw new Error( + `Could not find IdP X509 certificate in Keycloak SAML descriptor for realm "${realm}"` + ); + } + + return wrapPemCertificate(match[1]); +}; + +const fetchIdpCertificate = async (realm: string): Promise => { + const url = descriptorUrl(realm); + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to fetch Keycloak SAML descriptor from ${url}: ${response.status} ${response.statusText}` + ); + } + + return extractIdpCertificate(await response.text(), realm); +}; + +const buildConfigPayload = async ({ + realm, + providerName, +}: KeycloakSamlProfile): Promise => { + assertSupportedBaseUrl(); + + const idpX509Certificate = await fetchIdpCertificate(realm); + const realmBaseUrl = `${KEYCLOAK_SAML.baseUrl}/realms/${realm}`; + + return { + authenticationConfiguration: { + provider: 'saml', + providerName, + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + enableSelfSignup: true, + samlConfiguration: { + idp: { + entityId: realmBaseUrl, + ssoLoginUrl: `${realmBaseUrl}/protocol/saml`, + idpX509Certificate, + nameId: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }, + sp: { + entityId: `${OM_BASE_URL}/api/v1/saml/metadata`, + acs: `${OM_BASE_URL}/api/v1/saml/acs`, + callback: `${OM_BASE_URL}/callback`, + }, + security: { + strictMode: false, + tokenValidity: 3600, + sendEncryptedNameId: false, + sendSignedAuthRequest: false, + wantMessagesSigned: false, + wantAssertionsSigned: true, + }, + debugMode: false, + }, + }, + authorizerConfiguration: { + principalDomain: KEYCLOAK_SAML.principalDomain, + }, + }; +}; + +const performProviderLogin = async ( + page: Page, + { username, password }: ProviderCredentials +): Promise => { + const usernameInput = page + .locator('input#username, input[name="username"]') + .first(); + + await expect(usernameInput).toBeVisible(); + await usernameInput.fill(username); + + const passwordInput = page + .locator('input#password, input[name="password"]') + .first(); + + await expect(passwordInput).toBeVisible(); + await passwordInput.fill(password); + + const loginButton = page + .locator( + 'input#kc-login, button[name="login"], input[type="submit"], button[type="submit"]' + ) + .first(); + + await expect(loginButton).toBeEnabled(); + await loginButton.click(); +}; + +// OM's sign-in page uses a single "SAML SSO" label for every SAML provider — +// `authenticationConfiguration.providerName` isn't propagated into the store +// for the SAML branch in getAuthConfig, so even with a per-realm providerName +// the button renders as "Sign in with SAML SSO". +const createKeycloakSamlProviderHelper = ( + profile: KeycloakSamlProfile +): ProviderHelper => ({ + expectedButtonText: 'Sign in with SAML SSO', + loginUrlPattern: new RegExp(`/realms/${escapeRegExp(profile.realm)}/`), + buildConfigPayload: () => buildConfigPayload(profile), + performProviderLogin, +}); + +export const keycloakAzureSamlProviderHelper = createKeycloakSamlProviderHelper( + { + realm: KEYCLOAK_SAML.azureRealm, + providerName: 'Azure AD', + } +); + +export const keycloakGoogleSamlProviderHelper = + createKeycloakSamlProviderHelper({ + realm: KEYCLOAK_SAML.googleRealm, + providerName: 'Google', + }); From b12e7af1d88a5d5f2dc5fbc318b4017163effba8 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 14 Apr 2026 13:24:55 +0530 Subject: [PATCH 17/29] refactor(playwright): fetch Azure SAML IdP cert at runtime Drop the committed Azure Federated SSO X509 certificate and the AZURE_SAML_IDP_CERTIFICATE env fallback from the azure-saml provider. The cert now comes from Azure's federation metadata XML endpoint at test start, mirroring how the Keycloak provider resolves its realm cert, so the suite stays aligned with Azure's ~3-year cert rotations automatically. - New saml-metadata.ts exporting fetchIdpX509Certificate(descriptorUrl, label), reused by azure-saml and keycloak-saml. - azure-saml.buildConfigPayload is now async and pulls the cert from https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml before building the SAML payload. - keycloak-saml drops its inline cert-fetching helpers and delegates to the shared util. - Trim narration comments across the SSO suite to keep only the non-obvious rationale. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/playwright/constant/ssoAuth.ts | 4 -- .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 3 - .../utils/sso-providers/azure-saml.ts | 45 ++++---------- .../utils/sso-providers/keycloak-saml.ts | 59 +++---------------- .../utils/sso-providers/saml-metadata.ts | 59 +++++++++++++++++++ 5 files changed, 77 insertions(+), 93 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/saml-metadata.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index 47874fc5cb96..48f813be7b5d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -11,9 +11,6 @@ * limitations under the License. */ -// Mirrors playwright.config.ts `baseURL` resolution so the SSO config we -// PUT into OpenMetadata always matches the host the Playwright suite is -// driving. Defaults to the local stack. export const OM_BASE_URL = process.env.PLAYWRIGHT_TEST_BASE_URL ?? 'http://localhost:8585'; @@ -26,7 +23,6 @@ export const SSO_ENV = { OKTA_PRINCIPAL_DOMAIN: 'OKTA_PRINCIPAL_DOMAIN', AZURE_SAML_TENANT_ID: 'AZURE_SAML_TENANT_ID', AZURE_SAML_PRINCIPAL_DOMAIN: 'AZURE_SAML_PRINCIPAL_DOMAIN', - AZURE_SAML_IDP_CERTIFICATE: 'AZURE_SAML_IDP_CERTIFICATE', KEYCLOAK_SAML_BASE_URL: 'KEYCLOAK_SAML_BASE_URL', KEYCLOAK_SAML_AZURE_REALM: 'KEYCLOAK_SAML_AZURE_REALM', KEYCLOAK_SAML_GOOGLE_REALM: 'KEYCLOAK_SAML_GOOGLE_REALM', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index e8490542db4c..5f5671103ab4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -119,9 +119,6 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { }); await test.step('Return to OpenMetadata and complete self-signup if needed', async () => { - // The IdP can land the user on /signup (first-ever sign-in against this - // OM instance) or /my-data (user already exists from a prior run). Wait - // for either, and handle the one-time self-signup click when it appears. await page.waitForURL( (url) => url.pathname.endsWith('/signup') || url.pathname.endsWith('/my-data'), diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts index 304cc58391a4..ad4e6495f6b6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts @@ -14,44 +14,26 @@ import { expect, Page } from '@playwright/test'; import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import { ProviderHelper } from './index'; +import { fetchIdpX509Certificate } from './saml-metadata'; -// Defaults target Collate's nightly test Azure AD tenant. These are non-secret -// SAML metadata values — the entity ID, login URL, and signing certificate are -// published in Azure's federation metadata XML endpoint — so committing them is -// intentional. Override via the matching env vars to point the suite at a -// different tenant without a code change. +// Tenant ID and principal domain are public Azure metadata — safe to commit. const AZURE_SAML_TENANT = { tenantId: process.env[SSO_ENV.AZURE_SAML_TENANT_ID] ?? 'face9c4e-1b50-41d3-b404-d4d2432a5be3', principalDomain: process.env[SSO_ENV.AZURE_SAML_PRINCIPAL_DOMAIN] ?? 'getcollate.io', - idpX509Certificate: - process.env[SSO_ENV.AZURE_SAML_IDP_CERTIFICATE] ?? - [ - '-----BEGIN CERTIFICATE-----', - 'MIIC8DCCAdigAwIBAgIQRWr0YoCt5oRIk335kT5DjjANBgkqhkiG9w0BAQsFADA0', - 'MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZp', - 'Y2F0ZTAeFw0yNTA3MjQwNzM0MTZaFw0yODA3MjQwNzM0MTZaMDQxMjAwBgNVBAMT', - 'KU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjAN', - 'BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwXhfRXgCUkqU8fpAoSBq+xh0f38I', - 'kV1RwipcLRIYSXyWZTXs79WiWB9s+fzinB9oPr2TX+BwWPk0Z4PraeMXng5LvPck', - 'RgpvLCgTyiy3RPDaPcUqkWIkLvO949qS+IiAuzdjVM/Tw72fi88SyRGyEa/HjDio', - 'SND6yf/mzWJ4eX59yvEEWrHW2T9IB5+Rb8VA7JzLU5/AnYsxugve7HzMfJYl3wWW', - 'B/qoBOyyrhamCCW8GFD8sfE7Um8yxGyAM5q+IXYX0Iuzxo+JnWuBPUKlTdmLbfnX', - 'odtjQn//RFnmA8b4uodwCmObzNnN2xCExj33PPIBezemWSmMq4bYOoA6tQIDAQAB', - 'MA0GCSqGSIb3DQEBCwUAA4IBAQAZz/SQLmJdHAD4tU8lsPJyWEw5CKDGvBYvCnyZ', - 'Ph5/gCKujlFgGAvmXAPtl616itGndlVnWsA6ofyrGBtwWBeMk+XJTqol9ki9ZTzo', - 'Vuj5/2Un2gREQaInKN2uDcs64E9j74dWO3t/litN4XYRUGirsKSKOubV0PVtkmiBW', - 'CHYDySsZ3z3XAyRRCoV3DCdkf3kiy4fGTM/VatirBBAZfu/MaoTtIvDEUKotfn6tO', - 'UxRdvegHWqa5Tn8QjDqcLbN8ok0AmrCP5WOTQjtt1+PO6v4mvzvkiNeypxEqOwcQ', - 'WVdgvw/IldUp0TDomvr6xZeYEPv0Xn0l4ot1lwq3/tv/Kz', - '-----END CERTIFICATE-----', - ].join('\n'), } as const; -const buildConfigPayload = (): ProviderConfigOverride => { +const federationMetadataUrl = (tenantId: string): string => + `https://login.microsoftonline.com/${tenantId}/federationmetadata/2007-06/federationmetadata.xml`; + +const buildConfigPayload = async (): Promise => { const { tenantId } = AZURE_SAML_TENANT; + const idpX509Certificate = await fetchIdpX509Certificate( + federationMetadataUrl(tenantId), + `Azure tenant "${tenantId}"` + ); return { authenticationConfiguration: { @@ -63,7 +45,7 @@ const buildConfigPayload = (): ProviderConfigOverride => { idp: { entityId: `https://sts.windows.net/${tenantId}/`, ssoLoginUrl: `https://login.microsoftonline.com/${tenantId}/saml2`, - idpX509Certificate: AZURE_SAML_TENANT.idpX509Certificate, + idpX509Certificate, nameId: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', }, sp: { @@ -112,7 +94,6 @@ const performProviderLogin = async ( await expect(signInButton).toBeEnabled(); await signInButton.click(); - // Azure may show a "Stay signed in?" prompt — dismiss it if it appears const staySignedInNo = page.locator('input#idBtn_Back'); if (await staySignedInNo.isVisible({ timeout: 5_000 }).catch(() => false)) { @@ -120,10 +101,6 @@ const performProviderLogin = async ( } }; -// OM's sign-in page uses a single "SAML SSO" label for every SAML provider — -// `authenticationConfiguration.providerName` isn't propagated into the store -// for the SAML branch in getAuthConfig, so even with providerName="Azure AD" -// the button renders as "Sign in with SAML SSO". export const azureSamlProviderHelper: ProviderHelper = { expectedButtonText: 'Sign in with SAML SSO', loginUrlPattern: /login\.microsoftonline\.com/, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts index ee18615ca799..20e12b16ef62 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts @@ -14,6 +14,7 @@ import { expect, Page } from '@playwright/test'; import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; import type { ProviderHelper } from './index'; +import { fetchIdpX509Certificate } from './saml-metadata'; const SUPPORTED_OM_BASE_URL = 'http://localhost:8585'; @@ -35,9 +36,6 @@ interface KeycloakSamlProfile { const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -const descriptorUrl = (realm: string): string => - `${KEYCLOAK_SAML.baseUrl}/realms/${realm}/protocol/saml/descriptor`; - const assertSupportedBaseUrl = (): void => { if (OM_BASE_URL !== SUPPORTED_OM_BASE_URL) { throw new Error( @@ -47,58 +45,17 @@ const assertSupportedBaseUrl = (): void => { } }; -const wrapPemCertificate = (certificate: string): string => { - const body = certificate.replace(/\s+/g, ''); - const lines = body.match(/.{1,64}/g); - - if (!lines) { - throw new Error( - 'Keycloak SAML descriptor returned an empty X509 certificate' - ); - } - - return [ - '-----BEGIN CERTIFICATE-----', - ...lines, - '-----END CERTIFICATE-----', - ].join('\n'); -}; - -const extractIdpCertificate = (descriptor: string, realm: string): string => { - const match = descriptor.match( - /<[^>]*X509Certificate[^>]*>([^<]+)<\/[^>]*X509Certificate>/ - ); - - if (!match?.[1]) { - throw new Error( - `Could not find IdP X509 certificate in Keycloak SAML descriptor for realm "${realm}"` - ); - } - - return wrapPemCertificate(match[1]); -}; - -const fetchIdpCertificate = async (realm: string): Promise => { - const url = descriptorUrl(realm); - const response = await fetch(url); - - if (!response.ok) { - throw new Error( - `Failed to fetch Keycloak SAML descriptor from ${url}: ${response.status} ${response.statusText}` - ); - } - - return extractIdpCertificate(await response.text(), realm); -}; - const buildConfigPayload = async ({ realm, providerName, }: KeycloakSamlProfile): Promise => { assertSupportedBaseUrl(); - const idpX509Certificate = await fetchIdpCertificate(realm); const realmBaseUrl = `${KEYCLOAK_SAML.baseUrl}/realms/${realm}`; + const idpX509Certificate = await fetchIdpX509Certificate( + `${realmBaseUrl}/protocol/saml/descriptor`, + `Keycloak realm "${realm}"` + ); return { authenticationConfiguration: { @@ -163,10 +120,8 @@ const performProviderLogin = async ( await loginButton.click(); }; -// OM's sign-in page uses a single "SAML SSO" label for every SAML provider — -// `authenticationConfiguration.providerName` isn't propagated into the store -// for the SAML branch in getAuthConfig, so even with a per-realm providerName -// the button renders as "Sign in with SAML SSO". +// OM renders a fixed "SAML SSO" label for every SAML provider — providerName +// is dropped for the SAML branch of getAuthConfig. const createKeycloakSamlProviderHelper = ( profile: KeycloakSamlProfile ): ProviderHelper => ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/saml-metadata.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/saml-metadata.ts new file mode 100644 index 000000000000..37fb30366078 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/saml-metadata.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2025 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. + */ + +const wrapPemCertificate = (certificate: string): string => { + const body = certificate.replace(/\s+/g, ''); + const lines = body.match(/.{1,64}/g); + + if (!lines) { + throw new Error('SAML descriptor returned an empty X509 certificate'); + } + + return [ + '-----BEGIN CERTIFICATE-----', + ...lines, + '-----END CERTIFICATE-----', + ].join('\n'); +}; + +const extractFirstX509Certificate = ( + descriptor: string, + sourceLabel: string +): string => { + const match = descriptor.match( + /<[^>]*X509Certificate[^>]*>([^<]+)<\/[^>]*X509Certificate>/ + ); + + if (!match?.[1]) { + throw new Error( + `Could not find IdP X509 certificate in SAML descriptor for ${sourceLabel}` + ); + } + + return wrapPemCertificate(match[1]); +}; + +export const fetchIdpX509Certificate = async ( + descriptorUrl: string, + sourceLabel: string +): Promise => { + const response = await fetch(descriptorUrl); + + if (!response.ok) { + throw new Error( + `Failed to fetch SAML descriptor from ${descriptorUrl}: ${response.status} ${response.statusText}` + ); + } + + return extractFirstX509Certificate(await response.text(), sourceLabel); +}; From 484105b7e7d3ad5c227bbcf979d2aeba68934400 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 14 Apr 2026 15:00:27 +0530 Subject: [PATCH 18/29] refactor(playwright): drop hosted Azure SAML provider The nightly Keycloak SAML fixture with Azure-profile attribute claims exercises the same OM SAML code path as the hosted Azure AD tenant. The hosted provider added external tenant/cert coupling without unique coverage, so this removes it. Drops the azure-saml helper, its env keys (AZURE_SAML_TENANT_ID / AZURE_SAML_PRINCIPAL_DOMAIN), the dispatcher registration, and the workflow dispatch option. Keycloak Azure/Google realms remain. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../playwright-sso-login-nightly.yml | 1 - .../ui/playwright/constant/ssoAuth.ts | 2 - .../utils/sso-providers/azure-saml.ts | 109 ------------------ .../playwright/utils/sso-providers/index.ts | 5 +- 4 files changed, 1 insertion(+), 116 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index b955d06a9ca2..b29c6a5cca8c 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -23,7 +23,6 @@ on: type: choice options: - okta - - azure-saml - keycloak-azure-saml - keycloak-google-saml - all diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index 48f813be7b5d..5064b401d168 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -21,8 +21,6 @@ export const SSO_ENV = { OKTA_CLIENT_ID: 'OKTA_CLIENT_ID', OKTA_DOMAIN: 'OKTA_DOMAIN', OKTA_PRINCIPAL_DOMAIN: 'OKTA_PRINCIPAL_DOMAIN', - AZURE_SAML_TENANT_ID: 'AZURE_SAML_TENANT_ID', - AZURE_SAML_PRINCIPAL_DOMAIN: 'AZURE_SAML_PRINCIPAL_DOMAIN', KEYCLOAK_SAML_BASE_URL: 'KEYCLOAK_SAML_BASE_URL', KEYCLOAK_SAML_AZURE_REALM: 'KEYCLOAK_SAML_AZURE_REALM', KEYCLOAK_SAML_GOOGLE_REALM: 'KEYCLOAK_SAML_GOOGLE_REALM', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts deleted file mode 100644 index ad4e6495f6b6..000000000000 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/azure-saml.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2025 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. - */ -import { expect, Page } from '@playwright/test'; -import { OM_BASE_URL, SSO_ENV } from '../../constant/ssoAuth'; -import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; -import { ProviderHelper } from './index'; -import { fetchIdpX509Certificate } from './saml-metadata'; - -// Tenant ID and principal domain are public Azure metadata — safe to commit. -const AZURE_SAML_TENANT = { - tenantId: - process.env[SSO_ENV.AZURE_SAML_TENANT_ID] ?? - 'face9c4e-1b50-41d3-b404-d4d2432a5be3', - principalDomain: - process.env[SSO_ENV.AZURE_SAML_PRINCIPAL_DOMAIN] ?? 'getcollate.io', -} as const; - -const federationMetadataUrl = (tenantId: string): string => - `https://login.microsoftonline.com/${tenantId}/federationmetadata/2007-06/federationmetadata.xml`; - -const buildConfigPayload = async (): Promise => { - const { tenantId } = AZURE_SAML_TENANT; - const idpX509Certificate = await fetchIdpX509Certificate( - federationMetadataUrl(tenantId), - `Azure tenant "${tenantId}"` - ); - - return { - authenticationConfiguration: { - provider: 'saml', - providerName: 'Azure AD', - jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], - enableSelfSignup: true, - samlConfiguration: { - idp: { - entityId: `https://sts.windows.net/${tenantId}/`, - ssoLoginUrl: `https://login.microsoftonline.com/${tenantId}/saml2`, - idpX509Certificate, - nameId: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', - }, - sp: { - entityId: `${OM_BASE_URL}/api/v1/saml/metadata`, - acs: `${OM_BASE_URL}/api/v1/saml/acs`, - callback: `${OM_BASE_URL}/callback`, - }, - security: { - strictMode: false, - tokenValidity: 3600, - sendEncryptedNameId: false, - sendSignedAuthRequest: false, - wantMessagesSigned: false, - wantAssertionsSigned: false, - }, - debugMode: false, - }, - }, - authorizerConfiguration: { - principalDomain: AZURE_SAML_TENANT.principalDomain, - }, - }; -}; - -const performProviderLogin = async ( - page: Page, - { username, password }: ProviderCredentials -): Promise => { - const emailInput = page.locator('input[type="email"]'); - - await expect(emailInput).toBeVisible(); - await emailInput.fill(username); - - const nextButton = page.locator('input[type="submit"]'); - - await expect(nextButton).toBeEnabled(); - await nextButton.click(); - - const passwordInput = page.locator('input[type="password"]'); - - await expect(passwordInput).toBeVisible(); - await passwordInput.fill(password); - - const signInButton = page.locator('input[type="submit"]'); - - await expect(signInButton).toBeEnabled(); - await signInButton.click(); - - const staySignedInNo = page.locator('input#idBtn_Back'); - - if (await staySignedInNo.isVisible({ timeout: 5_000 }).catch(() => false)) { - await staySignedInNo.click(); - } -}; - -export const azureSamlProviderHelper: ProviderHelper = { - expectedButtonText: 'Sign in with SAML SSO', - loginUrlPattern: /login\.microsoftonline\.com/, - buildConfigPayload, - performProviderLogin, -}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts index 1a7d39cae4c7..6ee0ca88ad22 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -12,7 +12,6 @@ */ import { Page } from '@playwright/test'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; -import { azureSamlProviderHelper } from './azure-saml'; import { keycloakAzureSamlProviderHelper, keycloakGoogleSamlProviderHelper, @@ -37,8 +36,6 @@ export const getProviderHelper = (providerType: string): ProviderHelper => { switch (providerType) { case 'okta': return oktaProviderHelper; - case 'azure-saml': - return azureSamlProviderHelper; case 'keycloak-azure-saml': return keycloakAzureSamlProviderHelper; case 'keycloak-google-saml': @@ -46,7 +43,7 @@ export const getProviderHelper = (providerType: string): ProviderHelper => { default: throw new Error( `No SSO provider helper registered for "${providerType}". ` + - `Supported providers: okta, azure-saml, keycloak-azure-saml, keycloak-google-saml` + `Supported providers: okta, keycloak-azure-saml, keycloak-google-saml` ); } }; From 01406f3de48172f73696f4bac9a3df1b3e1c24b5 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 14 Apr 2026 15:00:38 +0530 Subject: [PATCH 19/29] test(playwright): cover SSO session lifecycle end-to-end Extends the SSO login spec beyond "can you log in" to the full session round-trip: reload survives, same-context tabs inherit auth, sidebar logout (with modal confirm) lands on /signin, and post-logout refresh stays signed out. Adds a describe-scoped userContext/userPage created in beforeAll so tests 2-6 inherit the IdP-backed session; test 1 keeps its fresh fixture for the unauthenticated assertion. Cleanup closes the user context before restoring the server security config. Verified locally against keycloak-azure-saml and keycloak-google-saml realms: 6 passed each (was 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 5f5671103ab4..76b0358addb9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect, test } from '@playwright/test'; +import { BrowserContext, expect, Page, test } from '@playwright/test'; import { SSO_ENV } from '../../constant/ssoAuth'; import { performAdminLogin } from '../../utils/admin'; import { getAuthContext, redirectToHomePage } from '../../utils/common'; @@ -40,6 +40,8 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { let helper: ProviderHelper; let adminJwt: string | undefined; let originalSecurityConfig: SecurityConfigSnapshot | undefined; + let userContext: BrowserContext | undefined; + let userPage: Page | undefined; test.beforeAll( 'Swap OpenMetadata server to target SSO provider', @@ -70,10 +72,16 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { } finally { await afterAction(); } + + userContext = await browser.newContext(); + userPage = await userContext.newPage(); } ); test.afterAll('Restore original security configuration', async () => { + await userPage?.close(); + await userContext?.close(); + if (!adminJwt || !originalSecurityConfig) { return; } @@ -99,10 +107,9 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await expect(page.getByTestId('email')).toHaveCount(0); }); - test('should complete full SSO login and verify user session', async ({ - page, - }) => { + test('should complete full SSO login and verify user session', async () => { test.slow(); + const page = userPage!; await test.step('Click SSO button and redirect to IdP', async () => { await page.goto('/signin'); @@ -140,4 +147,44 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await verifyLoggedInUserMatches(page, username); }); }); + + test('should keep the session after a page reload', async () => { + const page = userPage!; + + await page.reload(); + await page.waitForURL('**/my-data', { timeout: 30_000 }); + await expect(page.getByTestId('dropdown-profile')).toBeVisible(); + await verifyLoggedInUserMatches(page, username); + }); + + test('should share the session with a new page in the same context', async () => { + const extraPage = await userContext!.newPage(); + + try { + await extraPage.goto('/'); + await extraPage.waitForURL('**/my-data', { timeout: 30_000 }); + await expect(extraPage.getByTestId('dropdown-profile')).toBeVisible(); + await verifyLoggedInUserMatches(extraPage, username); + } finally { + await extraPage.close(); + } + }); + + test('should sign out and return to /signin', async () => { + const page = userPage!; + + await page.getByRole('menuitem', { name: /logout/i }).click(); + await page.getByTestId('confirm-logout').click(); + await page.waitForURL('**/signin', { timeout: 30_000 }); + await expect(page.locator('.signin-button')).toBeVisible(); + }); + + test('should stay signed-out after refreshing', async () => { + const page = userPage!; + + await page.reload(); + await page.waitForURL('**/signin', { timeout: 30_000 }); + await expect(page.locator('.signin-button')).toBeVisible(); + await expect(page.getByTestId('dropdown-profile')).toHaveCount(0); + }); }); From 6824868ef546b275925b9c6520b428155fceb492 Mon Sep 17 00:00:00 2001 From: Sid <30566406+siddhant1@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:17:13 +0530 Subject: [PATCH 20/29] remove slow from individual spec --- .../src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 76b0358addb9..2f282ecaa9c5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -29,6 +29,7 @@ const username = process.env[SSO_ENV.USERNAME] ?? ''; const password = process.env[SSO_ENV.PASSWORD] ?? ''; test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { + test.slow() // eslint-disable-next-line playwright/no-skipped-test -- conditional skip on required env vars; the suite only runs when SSO credentials are provided by CI or the developer test.skip( !providerType || !username || !password, @@ -108,7 +109,6 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { }); test('should complete full SSO login and verify user session', async () => { - test.slow(); const page = userPage!; await test.step('Click SSO button and redirect to IdP', async () => { From fc1655d066269c7af4d3441303b292b4e649c78e Mon Sep 17 00:00:00 2001 From: Sid <30566406+siddhant1@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:19:20 +0530 Subject: [PATCH 21/29] remove slow from beforeAll --- .../src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 2f282ecaa9c5..a7015d3eca07 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -47,7 +47,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { test.beforeAll( 'Swap OpenMetadata server to target SSO provider', async ({ browser }) => { - test.slow(); + helper = getProviderHelper(providerType); const { apiContext, afterAction, page } = await performAdminLogin( browser From 12d913c71f8b092a78951cbab5727ccfb302d9e3 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 15 Apr 2026 11:44:57 +0530 Subject: [PATCH 22/29] style(playwright): fix SSOLogin spec prettier issues Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index a7015d3eca07..682533535e79 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -29,7 +29,7 @@ const username = process.env[SSO_ENV.USERNAME] ?? ''; const password = process.env[SSO_ENV.PASSWORD] ?? ''; test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { - test.slow() + test.slow(); // eslint-disable-next-line playwright/no-skipped-test -- conditional skip on required env vars; the suite only runs when SSO credentials are provided by CI or the developer test.skip( !providerType || !username || !password, @@ -47,7 +47,6 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { test.beforeAll( 'Swap OpenMetadata server to target SSO provider', async ({ browser }) => { - helper = getProviderHelper(providerType); const { apiContext, afterAction, page } = await performAdminLogin( browser From be3d298c12d72ca872e24323ca025d3fc31c9eb3 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 15 Apr 2026 12:54:03 +0530 Subject: [PATCH 23/29] test(playwright): tighten SSO sign-in locator and await logout response Address Copilot review comments on PR #27164: - Use button.signin-button to match the pattern in SSOAuthentication.spec.ts. - Await /api/v1/users/logout POST alongside the /signin navigation in the logout test to remove the race against the server response. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/playwright/e2e/Auth/SSOLogin.spec.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 682533535e79..56f5a78cec77 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -100,7 +100,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await expect(page.getByTestId('login-form-container')).toBeVisible(); - const signInButton = page.locator('.signin-button'); + const signInButton = page.locator('button.signin-button'); await expect(signInButton).toBeVisible(); await expect(signInButton).toContainText(helper.expectedButtonText); @@ -113,7 +113,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await test.step('Click SSO button and redirect to IdP', async () => { await page.goto('/signin'); - const signInButton = page.locator('.signin-button'); + const signInButton = page.locator('button.signin-button'); await expect(signInButton).toBeVisible(); await signInButton.click(); @@ -173,9 +173,21 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { const page = userPage!; await page.getByRole('menuitem', { name: /logout/i }).click(); + + const waitForLogoutResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/users/logout') && + response.request().method() === 'POST' + ); + const waitForSigninNavigation = page.waitForURL('**/signin', { + timeout: 30_000, + }); + await page.getByTestId('confirm-logout').click(); - await page.waitForURL('**/signin', { timeout: 30_000 }); - await expect(page.locator('.signin-button')).toBeVisible(); + + await Promise.all([waitForLogoutResponse, waitForSigninNavigation]); + + await expect(page.locator('button.signin-button')).toBeVisible(); }); test('should stay signed-out after refreshing', async () => { @@ -183,7 +195,7 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await page.reload(); await page.waitForURL('**/signin', { timeout: 30_000 }); - await expect(page.locator('.signin-button')).toBeVisible(); + await expect(page.locator('button.signin-button')).toBeVisible(); await expect(page.getByTestId('dropdown-profile')).toHaveCount(0); }); }); From eb1dc7b6458eb215ffa587c7baa96d938f1dccf5 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 15 Apr 2026 13:44:44 +0530 Subject: [PATCH 24/29] fix --- openmetadata-ui/src/main/resources/ui/playwright.config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index dd789640ceed..bc56e5193f72 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -84,18 +84,17 @@ export default defineConfig({ teardown: 'entity-data-teardown', testIgnore: [ '**/nightly/**', - '**/Auth/**', '**/DataAssetRulesEnabled.spec.ts', '**/DataAssetRulesDisabled.spec.ts', '**/SystemCertificationTags.spec.ts', '**/SearchRBAC.spec.ts', + '**/SSOLogin.spec.ts', ], }, { name: 'sso-auth', - testMatch: '**/Auth/**/*.spec.ts', + testMatch: '**/SSOLogin.spec.ts', use: { ...devices['Desktop Chrome'] }, - // Provider swaps mutate global server state — keep tests strictly serial. fullyParallel: false, workers: 1, }, From 014eeae056e4233a7913db68e9b786a7df2ab741 Mon Sep 17 00:00:00 2001 From: Sid <30566406+siddhant1@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:45:53 +0530 Subject: [PATCH 25/29] Update openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 56f5a78cec77..9b8d272e15d8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -182,8 +182,11 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { const waitForSigninNavigation = page.waitForURL('**/signin', { timeout: 30_000, }); + const confirmLogoutButton = page.getByTestId('confirm-logout'); - await page.getByTestId('confirm-logout').click(); + await expect(confirmLogoutButton).toBeVisible(); + await expect(confirmLogoutButton).toBeEnabled(); + await confirmLogoutButton.click(); await Promise.all([waitForLogoutResponse, waitForSigninNavigation]); From 7d64bfcd640c68a73c4b385c197df5beb92bb4c0 Mon Sep 17 00:00:00 2001 From: Sid <30566406+siddhant1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:08:54 +0530 Subject: [PATCH 26/29] fix --- openmetadata-ui/src/main/resources/ui/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index bc56e5193f72..d3d4481562af 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -84,6 +84,7 @@ export default defineConfig({ teardown: 'entity-data-teardown', testIgnore: [ '**/nightly/**', + '**/Auth/**', '**/DataAssetRulesEnabled.spec.ts', '**/DataAssetRulesDisabled.spec.ts', '**/SystemCertificationTags.spec.ts', From 210463940764e016db21f36a1f62f0426b4a4ee3 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 15 Apr 2026 16:44:30 +0530 Subject: [PATCH 27/29] test(playwright): resolve SSO creds via env vars, drop keycloak-google-saml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route Keycloak credentials through the same `vars[format(...)]` / `secrets[format(...)]` indirection as Okta via an `env_prefix` matrix column, removing the hardcoded fixture literals from the workflow. Password lookup falls back `vars || secrets` so fixture passwords can live as vars while real provider secrets stay in secrets. Also drop the keycloak-google-saml variant — same IdP and realm shape as the Azure variant, so it adds CI cost without meaningful coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../playwright-sso-login-nightly.yml | 31 ++--- docker/local-sso/keycloak-saml/README.md | 12 +- .../realms/om-google-saml-realm.json | 109 ------------------ .../ui/playwright/constant/ssoAuth.ts | 1 - .../playwright/utils/sso-providers/index.ts | 9 +- .../utils/sso-providers/keycloak-saml.ts | 8 -- 6 files changed, 21 insertions(+), 149 deletions(-) delete mode 100644 docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index b29c6a5cca8c..0f767236b452 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -24,7 +24,6 @@ on: options: - okta - keycloak-azure-saml - - keycloak-google-saml - all permissions: @@ -36,10 +35,14 @@ concurrency: jobs: # To onboard a new provider: - # 1. Add its lowercase name to the matrix array below AND to the - # dispatch `options:` list above. - # 2. Add _SSO_USERNAME (variable) and _SSO_PASSWORD - # (secret) to the `test` environment, unless the provider uses a local fixture. + # 1. Add a matrix entry below (`name` is the lowercase provider id used by + # the Playwright helper; `env_prefix` is the uppercase/underscore form + # used to look up credentials). Also add `name` to the dispatch + # `options:` list above. + # 2. Add _SSO_USERNAME (variable) and _SSO_PASSWORD + # (variable) to the `test` environment. Use a secret instead of a + # variable for the password if the provider uses a real (non-fixture) + # credential. # 3. Register the helper in playwright/utils/sso-providers/index.ts. sso-login: runs-on: ubuntu-latest @@ -50,8 +53,10 @@ jobs: matrix: provider: ${{ (github.event_name == 'schedule' || github.event.inputs.sso_provider == 'all') - && fromJSON('["okta","keycloak-azure-saml","keycloak-google-saml"]') - || fromJSON(format('["{0}"]', github.event.inputs.sso_provider)) }} + && fromJSON('[{"name":"okta","env_prefix":"OKTA"},{"name":"keycloak-azure-saml","env_prefix":"KEYCLOAK_AZURE_SAML"}]') + || (github.event.inputs.sso_provider == 'keycloak-azure-saml' + && fromJSON('[{"name":"keycloak-azure-saml","env_prefix":"KEYCLOAK_AZURE_SAML"}]') + || fromJSON('[{"name":"okta","env_prefix":"OKTA"}]')) }} steps: - name: Free Disk Space (Ubuntu) uses: jlumbroso/free-disk-space@main @@ -95,17 +100,17 @@ jobs: run: npx playwright@1.57.0 install chromium --with-deps - name: Start Keycloak SAML IdP - if: startsWith(matrix.provider, 'keycloak-') + if: startsWith(matrix.provider.name, 'keycloak-') run: | docker compose -f docker/local-sso/keycloak-saml/docker-compose.yml up -d - timeout 180 bash -c 'until curl -fsS http://localhost:8080/realms/om-azure-saml >/dev/null && curl -fsS http://localhost:8080/realms/om-google-saml >/dev/null; do sleep 2; done' + timeout 180 bash -c 'until curl -fsS http://localhost:8080/realms/om-azure-saml >/dev/null; do sleep 2; done' - name: Run SSO Login Spec working-directory: openmetadata-ui/src/main/resources/ui env: - SSO_PROVIDER_TYPE: ${{ matrix.provider }} - SSO_USERNAME: ${{ matrix.provider == 'keycloak-azure-saml' && 'azure.saml@openmetadata.local' || matrix.provider == 'keycloak-google-saml' && 'google.saml@openmetadata.local' || vars[format('{0}_SSO_USERNAME', matrix.provider)] }} - SSO_PASSWORD: ${{ startsWith(matrix.provider, 'keycloak-') && 'OpenMetadata@123' || secrets[format('{0}_SSO_PASSWORD', matrix.provider)] }} + SSO_PROVIDER_TYPE: ${{ matrix.provider.name }} + SSO_USERNAME: ${{ vars[format('{0}_SSO_USERNAME', matrix.provider.env_prefix)] }} + SSO_PASSWORD: ${{ vars[format('{0}_SSO_PASSWORD', matrix.provider.env_prefix)] || secrets[format('{0}_SSO_PASSWORD', matrix.provider.env_prefix)] }} KEYCLOAK_SAML_BASE_URL: http://localhost:8080 PLAYWRIGHT_IS_OSS: true run: | @@ -117,7 +122,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: sso-login-html-report-${{ matrix.provider }} + name: sso-login-html-report-${{ matrix.provider.name }} path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report retention-days: 5 diff --git a/docker/local-sso/keycloak-saml/README.md b/docker/local-sso/keycloak-saml/README.md index 2cd9ade76b4c..93ad052364a0 100644 --- a/docker/local-sso/keycloak-saml/README.md +++ b/docker/local-sso/keycloak-saml/README.md @@ -6,14 +6,11 @@ Local SAML IdP fixture for the Playwright SSO login spec. docker compose -f docker/local-sso/keycloak-saml/docker-compose.yml up -d ``` -It imports two realms for an OpenMetadata server running at `http://localhost:8585`: +It imports one realm for an OpenMetadata server running at `http://localhost:8585`: - `om-azure-saml` - User: `azure.saml@openmetadata.local` - Password: `OpenMetadata@123` -- `om-google-saml` - - User: `google.saml@openmetadata.local` - - Password: `OpenMetadata@123` Use the matching Playwright provider type: @@ -23,10 +20,3 @@ SSO_USERNAME=azure.saml@openmetadata.local \ SSO_PASSWORD=OpenMetadata@123 \ npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts --project=sso-auth --workers=1 ``` - -```bash -SSO_PROVIDER_TYPE=keycloak-google-saml \ -SSO_USERNAME=google.saml@openmetadata.local \ -SSO_PASSWORD=OpenMetadata@123 \ -npx playwright test playwright/e2e/Auth/SSOLogin.spec.ts --project=sso-auth --workers=1 -``` diff --git a/docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json b/docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json deleted file mode 100644 index d65f4740357c..000000000000 --- a/docker/local-sso/keycloak-saml/realms/om-google-saml-realm.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "realm": "om-google-saml", - "enabled": true, - "displayName": "OpenMetadata Google SAML", - "sslRequired": "none", - "registrationAllowed": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": false, - "editUsernameAllowed": false, - "clients": [ - { - "clientId": "http://localhost:8585/api/v1/saml/metadata", - "name": "OpenMetadata", - "enabled": true, - "protocol": "saml", - "publicClient": true, - "frontchannelLogout": true, - "redirectUris": ["http://localhost:8585/*"], - "baseUrl": "http://localhost:8585", - "adminUrl": "http://localhost:8585", - "attributes": { - "saml.assertion.signature": "true", - "saml.authnstatement": "true", - "saml.client.signature": "false", - "saml.encrypt": "false", - "saml.force.name.id.format": "true", - "saml.force.post.binding": "true", - "saml.multivalued.roles": "false", - "saml.server.signature": "true", - "saml.signature.algorithm": "RSA_SHA256", - "saml_assertion_consumer_url_post": "http://localhost:8585/api/v1/saml/acs", - "saml_force_name_id_format": "true", - "saml_name_id_format": "email" - }, - "protocolMappers": [ - { - "name": "Email", - "protocol": "saml", - "protocolMapper": "saml-user-property-mapper", - "consentRequired": false, - "config": { - "attribute.name": "email", - "attribute.nameformat": "Basic", - "friendly.name": "email", - "user.attribute": "email" - } - }, - { - "name": "Given Name", - "protocol": "saml", - "protocolMapper": "saml-user-property-mapper", - "consentRequired": false, - "config": { - "attribute.name": "given_name", - "attribute.nameformat": "Basic", - "friendly.name": "given_name", - "user.attribute": "firstName" - } - }, - { - "name": "Family Name", - "protocol": "saml", - "protocolMapper": "saml-user-property-mapper", - "consentRequired": false, - "config": { - "attribute.name": "family_name", - "attribute.nameformat": "Basic", - "friendly.name": "family_name", - "user.attribute": "lastName" - } - }, - { - "name": "Name", - "protocol": "saml", - "protocolMapper": "saml-user-attribute-mapper", - "consentRequired": false, - "config": { - "attribute.name": "name", - "attribute.nameformat": "Basic", - "friendly.name": "name", - "user.attribute": "name" - } - } - ] - } - ], - "users": [ - { - "username": "google.saml@openmetadata.local", - "email": "google.saml@openmetadata.local", - "firstName": "Google", - "lastName": "SAML", - "enabled": true, - "emailVerified": true, - "requiredActions": [], - "attributes": { - "name": ["Google SAML User"] - }, - "credentials": [ - { - "type": "password", - "value": "OpenMetadata@123", - "temporary": false - } - ] - } - ] -} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts index 5064b401d168..791179de6f08 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -23,6 +23,5 @@ export const SSO_ENV = { OKTA_PRINCIPAL_DOMAIN: 'OKTA_PRINCIPAL_DOMAIN', KEYCLOAK_SAML_BASE_URL: 'KEYCLOAK_SAML_BASE_URL', KEYCLOAK_SAML_AZURE_REALM: 'KEYCLOAK_SAML_AZURE_REALM', - KEYCLOAK_SAML_GOOGLE_REALM: 'KEYCLOAK_SAML_GOOGLE_REALM', KEYCLOAK_SAML_PRINCIPAL_DOMAIN: 'KEYCLOAK_SAML_PRINCIPAL_DOMAIN', } as const; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts index 6ee0ca88ad22..909720185332 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -12,10 +12,7 @@ */ import { Page } from '@playwright/test'; import { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; -import { - keycloakAzureSamlProviderHelper, - keycloakGoogleSamlProviderHelper, -} from './keycloak-saml'; +import { keycloakAzureSamlProviderHelper } from './keycloak-saml'; import { oktaProviderHelper } from './okta'; export type ProviderConfigPayload = @@ -38,12 +35,10 @@ export const getProviderHelper = (providerType: string): ProviderHelper => { return oktaProviderHelper; case 'keycloak-azure-saml': return keycloakAzureSamlProviderHelper; - case 'keycloak-google-saml': - return keycloakGoogleSamlProviderHelper; default: throw new Error( `No SSO provider helper registered for "${providerType}". ` + - `Supported providers: okta, keycloak-azure-saml, keycloak-google-saml` + `Supported providers: okta, keycloak-azure-saml` ); } }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts index 20e12b16ef62..a775332c61e3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts @@ -22,8 +22,6 @@ const KEYCLOAK_SAML = { baseUrl: process.env[SSO_ENV.KEYCLOAK_SAML_BASE_URL] ?? 'http://localhost:8080', azureRealm: process.env[SSO_ENV.KEYCLOAK_SAML_AZURE_REALM] ?? 'om-azure-saml', - googleRealm: - process.env[SSO_ENV.KEYCLOAK_SAML_GOOGLE_REALM] ?? 'om-google-saml', principalDomain: process.env[SSO_ENV.KEYCLOAK_SAML_PRINCIPAL_DOMAIN] ?? 'openmetadata.local', } as const; @@ -137,9 +135,3 @@ export const keycloakAzureSamlProviderHelper = createKeycloakSamlProviderHelper( providerName: 'Azure AD', } ); - -export const keycloakGoogleSamlProviderHelper = - createKeycloakSamlProviderHelper({ - realm: KEYCLOAK_SAML.googleRealm, - providerName: 'Google', - }); From f918b652db25fd30ad96ccbaac515b4fb55384a3 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 15 Apr 2026 16:46:59 +0530 Subject: [PATCH 28/29] test(playwright): post SSO login nightly results to Slack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-provider Slack notification step mirroring the pattern used by the postgresql/mysql nightly workflows — reuses the existing `slack-cli.config.json` and `playwright-slack-report` CLI against the `results.json` that the global JSON reporter already emits. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/playwright-sso-login-nightly.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml index 0f767236b452..6d513c97ad10 100644 --- a/.github/workflows/playwright-sso-login-nightly.yml +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -126,6 +126,16 @@ jobs: path: openmetadata-ui/src/main/resources/ui/playwright/output/playwright-report retention-days: 5 + - name: Send Slack Notification + if: always() + working-directory: openmetadata-ui/src/main/resources/ui + env: + RUN_TITLE: "SSO Login Nightly: ${{ matrix.provider.name }} (${{ github.ref_name }})" + RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + SLACK_BOT_USER_OAUTH_TOKEN: ${{ secrets.E2E_SLACK_BOT_OAUTH_TOKEN }} + run: | + npx playwright-slack-report -c playwright/slack-cli.config.json -j playwright/output/results.json > slack_report.json + - name: Clean Up if: always() run: | From c13e1dc124343b7deab58354ad3a8d20a9817869 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 15 Apr 2026 17:18:46 +0530 Subject: [PATCH 29/29] fix(playwright): drop logout response wait in SSO spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OktaAuthenticator.logout clears tokens locally with no backend call, and GenericAuthenticator (SAML) hits `GET /auth/logout` — neither triggers the `POST /api/v1/users/logout` the test was waiting on. The listener never matched, so `Promise.all` hung past the 180s test timeout even though the page had already navigated to /signin. Rely on `waitForURL('**/signin')` + the signin button assertion, which are the actual cross-provider success signals. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts index 9b8d272e15d8..a2dc727ccd76 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -174,21 +174,13 @@ test.describe('SSO Login', { tag: ['@sso', '@Platform'] }, () => { await page.getByRole('menuitem', { name: /logout/i }).click(); - const waitForLogoutResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/users/logout') && - response.request().method() === 'POST' - ); - const waitForSigninNavigation = page.waitForURL('**/signin', { - timeout: 30_000, - }); const confirmLogoutButton = page.getByTestId('confirm-logout'); await expect(confirmLogoutButton).toBeVisible(); await expect(confirmLogoutButton).toBeEnabled(); await confirmLogoutButton.click(); - await Promise.all([waitForLogoutResponse, waitForSigninNavigation]); + await page.waitForURL('**/signin', { timeout: 30_000 }); await expect(page.locator('button.signin-button')).toBeVisible(); });