diff --git a/.github/workflows/playwright-sso-login-nightly.yml b/.github/workflows/playwright-sso-login-nightly.yml new file mode 100644 index 000000000000..6d513c97ad10 --- /dev/null +++ b/.github/workflows/playwright-sso-login-nightly.yml @@ -0,0 +1,145 @@ +# 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 Nightly + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + sso_provider: + description: 'SSO provider (or "all")' + required: true + default: okta + type: choice + options: + - okta + - keycloak-azure-saml + - all + +permissions: + contents: read + +concurrency: + group: sso-login-nightly-${{ github.event.inputs.sso_provider || 'scheduled' }} + cancel-in-progress: true + +jobs: + # To onboard a new provider: + # 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 + environment: test + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + provider: + ${{ (github.event_name == 'schedule' || github.event.inputs.sso_provider == 'all') + && 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 + 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.57.0 install chromium --with-deps + + - name: Start Keycloak SAML IdP + 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; do sleep 2; done' + + - name: Run SSO Login Spec + working-directory: openmetadata-ui/src/main/resources/ui + env: + 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: | + 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-${{ matrix.provider.name }} + 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: | + 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..93ad052364a0 --- /dev/null +++ b/docker/local-sso/keycloak-saml/README.md @@ -0,0 +1,22 @@ +# 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 one realm for an OpenMetadata server running at `http://localhost:8585`: + +- `om-azure-saml` + - User: `azure.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 +``` 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/openmetadata-ui/src/main/resources/ui/playwright.config.ts b/openmetadata-ui/src/main/resources/ui/playwright.config.ts index 2f2f0f32bf07..d3d4481562af 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright.config.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright.config.ts @@ -89,8 +89,16 @@ export default defineConfig({ '**/DataAssetRulesDisabled.spec.ts', '**/SystemCertificationTags.spec.ts', '**/SearchRBAC.spec.ts', + '**/SSOLogin.spec.ts', ], }, + { + name: 'sso-auth', + testMatch: '**/SSOLogin.spec.ts', + use: { ...devices['Desktop Chrome'] }, + fullyParallel: false, + workers: 1, + }, { name: 'entity-data-teardown', testMatch: '**/entity-data.teardown.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..791179de6f08 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/ssoAuth.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +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', + PASSWORD: 'SSO_PASSWORD', + OKTA_CLIENT_ID: 'OKTA_CLIENT_ID', + OKTA_DOMAIN: 'OKTA_DOMAIN', + OKTA_PRINCIPAL_DOMAIN: 'OKTA_PRINCIPAL_DOMAIN', + KEYCLOAK_SAML_BASE_URL: 'KEYCLOAK_SAML_BASE_URL', + KEYCLOAK_SAML_AZURE_REALM: 'KEYCLOAK_SAML_AZURE_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 new file mode 100644 index 000000000000..a2dc727ccd76 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Auth/SSOLogin.spec.ts @@ -0,0 +1,196 @@ +/* + * 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 { BrowserContext, expect, Page, test } from '@playwright/test'; +import { SSO_ENV } from '../../constant/ssoAuth'; +import { performAdminLogin } from '../../utils/admin'; +import { getAuthContext, redirectToHomePage } from '../../utils/common'; +import { getProviderHelper, ProviderHelper } from '../../utils/sso-providers'; +import { + applyProviderConfig, + fetchSecurityConfig, + restoreSecurityConfig, + SecurityConfigSnapshot, + 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'] }, () => { + 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, + `${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; + let originalSecurityConfig: SecurityConfigSnapshot | undefined; + let userContext: BrowserContext | undefined; + let userPage: Page | undefined; + + test.beforeAll( + 'Swap OpenMetadata server to target SSO provider', + async ({ browser }) => { + helper = getProviderHelper(providerType); + const { apiContext, afterAction, page } = await performAdminLogin( + browser + ); + + try { + adminJwt = await getToken(page); + + 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); + const providerConfig = await helper.buildConfigPayload(); + + await applyProviderConfig( + apiContext, + originalSecurityConfig, + providerConfig + ); + } 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; + } + + const adminContext = await getAuthContext(adminJwt); + + try { + await restoreSecurityConfig(adminContext, originalSecurityConfig); + } 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('button.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 () => { + const page = userPage!; + + await test.step('Click SSO button and redirect to IdP', async () => { + await page.goto('/signin'); + + const signInButton = page.locator('button.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 redirectToHomePage(page); + }); + + await test.step('Verify JWT against loggedInUser API', async () => { + 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(); + + const confirmLogoutButton = page.getByTestId('confirm-logout'); + + await expect(confirmLogoutButton).toBeVisible(); + await expect(confirmLogoutButton).toBeEnabled(); + await confirmLogoutButton.click(); + + await page.waitForURL('**/signin', { timeout: 30_000 }); + + await expect(page.locator('button.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('button.signin-button')).toBeVisible(); + await expect(page.getByTestId('dropdown-profile')).toHaveCount(0); + }); +}); 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..909720185332 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/index.ts @@ -0,0 +1,44 @@ +/* + * 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 { ProviderConfigOverride, ProviderCredentials } from '../ssoAuth'; +import { keycloakAzureSamlProviderHelper } from './keycloak-saml'; +import { oktaProviderHelper } from './okta'; + +export type ProviderConfigPayload = + | ProviderConfigOverride + | Promise; + +export interface ProviderHelper { + expectedButtonText: string; + loginUrlPattern: RegExp; + buildConfigPayload: () => ProviderConfigPayload; + performProviderLogin: ( + page: Page, + credentials: ProviderCredentials + ) => Promise; +} + +export const getProviderHelper = (providerType: string): ProviderHelper => { + switch (providerType) { + case 'okta': + return oktaProviderHelper; + case 'keycloak-azure-saml': + return keycloakAzureSamlProviderHelper; + default: + throw new Error( + `No SSO provider helper registered for "${providerType}". ` + + `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 new file mode 100644 index 000000000000..a775332c61e3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/keycloak-saml.ts @@ -0,0 +1,137 @@ +/* + * 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'; +import { fetchIdpX509Certificate } from './saml-metadata'; + +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', + 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 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 buildConfigPayload = async ({ + realm, + providerName, +}: KeycloakSamlProfile): Promise => { + assertSupportedBaseUrl(); + + const realmBaseUrl = `${KEYCLOAK_SAML.baseUrl}/realms/${realm}`; + const idpX509Certificate = await fetchIdpX509Certificate( + `${realmBaseUrl}/protocol/saml/descriptor`, + `Keycloak realm "${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 renders a fixed "SAML SSO" label for every SAML provider — providerName +// is dropped for the SAML branch of getAuthConfig. +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', + } +); 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..36d211381f36 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/sso-providers/okta.ts @@ -0,0 +1,84 @@ +/* + * 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 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: 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 => { + const authority = `https://${OKTA_TENANT.domain}/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: OKTA_TENANT.clientId, + callbackUrl: `${OM_BASE_URL}/callback`, + jwtPrincipalClaims: ['email', 'preferred_username', 'sub'], + enableSelfSignup: true, + }, + authorizerConfiguration: { + principalDomain: OKTA_TENANT.principalDomain, + }, + }; +}; + +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/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); +}; 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..05fac578686a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ssoAuth.ts @@ -0,0 +1,134 @@ +/* + * 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 { getApiContext } from './common'; + +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, + 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: merged, + }); + + expect(response.status()).toBe(200); +}; + +export const restoreSecurityConfig = async ( + apiContext: APIRequestContext, + snapshot: SecurityConfigSnapshot +): Promise => { + const response = await apiContext.put(SECURITY_CONFIG_ENDPOINT, { + data: snapshot, + }); + + expect(response.status()).toBe(200); +}; + +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 expected = expectedEmail.toLowerCase(); + + expect(user.email?.toLowerCase()).toBe(expected); + } finally { + await afterAction(); + } +};