test(playwright): add nightly SAML session renewal coverage (#27619)

* test(playwright): add nightly SAML session renewal spec

Covers OM's JWT refresh behavior for SAML sessions end-to-end against
the local Keycloak fixture: silent refresh after expiry, concurrent
401s queuing behind a single refresh call, and forced re-login when
the server-side SAML HttpSession is gone.

Reuses the snapshot/restore mechanism and keycloak-azure-saml provider
helper introduced in #27164; shortens samlConfiguration.security.token
Validity to 10s so the suite observes multiple expiry cycles in <60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update openmetadata-ui/src/main/resources/ui/playwright/utils/sessionRenewal.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* test(playwright): drop expiry wait from refresh-on-reload SSO specs

The reactive 401 refresh path races with the AuthProvider useEffect that
wires tokenService.renewToken from authenticatorRef — if the 401 from
/users/loggedInUser lands before that effect commits the populated ref,
refreshToken() returns null and the user is logged out instead of refreshed.

With tokenValidity=10s (< EXPIRY_THRESHOLD_MILLES=60s), the UI's proactive
timer in startTokenExpiryTimer fires immediately on every mount, so
/auth/refresh is exercised on each reload regardless of expiry state.
Assertions on token rotation and session continuity still cover "silent
refresh works end-to-end".

The SAML-session-gone case still waits for expiry — it needs to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(playwright): trigger refresh via SPA nav in SSO renewal specs

page.reload() remounts React and re-races the axios interceptor setup
in AuthProvider — the useEffect that wires authenticatorRef.renewIdToken
onto TokenService has a ref-typed dependency that doesn't reliably
re-run, so the first 401 after reload sometimes finds renewToken=null
and the interceptor silently logs the user out instead of refreshing.

Click the Explore sidebar link instead. The click triggers authenticated
API calls while staying inside the already-mounted React tree, so the
interceptor always reaches the wired TokenService. Spec now passes
10/10 locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Siddhant <siddhant@MacBook-Pro-621.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sid 2026-05-04 11:48:45 +05:30 committed by GitHub
parent d095413ed1
commit ca2d0122db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 297 additions and 5 deletions

View file

@ -113,10 +113,7 @@ jobs:
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
run: npx playwright test --project=sso-auth --workers=1
- name: Upload HTML report
if: always()

View file

@ -94,7 +94,7 @@ export default defineConfig({
},
{
name: 'sso-auth',
testMatch: '**/SSOLogin.spec.ts',
testMatch: ['**/SSOLogin.spec.ts', '**/SSORenewal.spec.ts'],
use: { ...devices['Desktop Chrome'] },
fullyParallel: false,
workers: 1,

View file

@ -0,0 +1,207 @@
/*
* 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, Response, test } from '@playwright/test';
import { SSO_ENV } from '../../constant/ssoAuth';
import { performAdminLogin } from '../../utils/admin';
import { getAuthContext } from '../../utils/common';
import {
AUTH_REFRESH_PATH,
clearSamlSessionCookie,
decodeJwtExp,
SHORT_ACCESS_TTL_SECONDS,
waitForAccessTokenExpiry,
withShortSamlTokenValidity,
} from '../../utils/sessionRenewal';
import { getProviderHelper, ProviderHelper } from '../../utils/sso-providers';
import {
applyProviderConfig,
fetchSecurityConfig,
restoreSecurityConfig,
SecurityConfigSnapshot,
} 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] ?? '';
// Limited to the local Keycloak SAML fixture because the TTL override mutates
// shared security config and is too aggressive to point at the Okta tenant.
const SUPPORTED_PROVIDER = 'keycloak-azure-saml';
test.describe.configure({ mode: 'serial' });
test.describe('SSO Session Renewal', { tag: ['@sso', '@Platform'] }, () => {
test.slow();
// eslint-disable-next-line playwright/no-skipped-test -- TTL override is unsafe against any provider other than the local Keycloak fixture
test.skip(
providerType !== SUPPORTED_PROVIDER || !username || !password,
`${SSO_ENV.PROVIDER_TYPE}=${SUPPORTED_PROVIDER} + ${SSO_ENV.USERNAME} + ${SSO_ENV.PASSWORD} must be set`
);
let helper: ProviderHelper;
let adminJwt: string | undefined;
let originalSecurityConfig: SecurityConfigSnapshot | undefined;
let userContext: BrowserContext | undefined;
let userPage: Page | undefined;
const loginViaSaml = async (page: Page): Promise<void> => {
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 helper.performProviderLogin(page, { username, password });
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 });
}
};
test.beforeAll(
'Swap server to SAML with short JWT TTL and establish user session',
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 = withShortSamlTokenValidity(
await helper.buildConfigPayload()
);
await applyProviderConfig(
apiContext,
originalSecurityConfig,
providerConfig
);
} finally {
await afterAction();
}
userContext = await browser.newContext();
userPage = await userContext.newPage();
await loginViaSaml(userPage);
}
);
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 silently refresh the access token after expiry', async () => {
const page = userPage!;
await expect(page.getByTestId('dropdown-profile')).toBeVisible();
const initialAccessToken = await getToken(page);
const initialExp = decodeJwtExp(initialAccessToken);
await waitForAccessTokenExpiry(SHORT_ACCESS_TTL_SECONDS);
const refreshResponsePromise = page.waitForResponse(
(r) => r.url().includes(AUTH_REFRESH_PATH) && r.status() === 200,
{ timeout: 15_000 }
);
await page.getByTestId('app-bar-item-explore').click();
const refreshResponse = await refreshResponsePromise;
await expect(page.getByTestId('dropdown-profile')).toBeVisible();
const newAccessToken = await getToken(page);
expect(refreshResponse.status()).toBe(200);
expect(newAccessToken).not.toBe(initialAccessToken);
expect(decodeJwtExp(newAccessToken)).toBeGreaterThan(initialExp);
expect(page.url()).not.toContain('/signin');
});
test('should queue concurrent 401s behind a single refresh call', async () => {
const page = userPage!;
await expect(page.getByTestId('dropdown-profile')).toBeVisible();
await waitForAccessTokenExpiry(SHORT_ACCESS_TTL_SECONDS);
const refreshCalls: string[] = [];
const trackRefresh = (response: Response): void => {
if (response.url().includes(AUTH_REFRESH_PATH)) {
refreshCalls.push(response.url());
}
};
page.on('response', trackRefresh);
try {
const refreshResponsePromise = page.waitForResponse(
(r) => r.url().includes(AUTH_REFRESH_PATH) && r.status() === 200,
{ timeout: 15_000 }
);
await page.getByTestId('app-bar-item-explore').click();
await refreshResponsePromise;
await expect(page.getByTestId('dropdown-profile')).toBeVisible();
} finally {
page.off('response', trackRefresh);
}
expect(refreshCalls).toHaveLength(1);
expect(page.url()).not.toContain('/signin');
});
test('should force re-login when the SAML session is gone', async () => {
const page = userPage!;
await clearSamlSessionCookie(userContext!);
await waitForAccessTokenExpiry(SHORT_ACCESS_TTL_SECONDS);
await page.reload();
await page.waitForURL('**/signin', { timeout: 30_000 });
await expect(page.getByText(/session has timed out/i)).toBeVisible();
await expect(page.locator('button.signin-button')).toBeVisible();
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 } from '@playwright/test';
import { ProviderConfigOverride } from './ssoAuth';
// Short enough to keep test runtime low, long enough to ride out SAML callback
// latency plus the small clock skew between the server JWT `exp` and the
// browser's wall clock.
export const SHORT_ACCESS_TTL_SECONDS = 10;
// Server-side SAML HttpSession cookie. SamlAuthServletHandler.handleRefresh
// uses this cookie to resolve the existing server-side session; clearing it
// prevents the session from being found and forces refresh to return 401
// "No active session".
export const SESSION_COOKIE_NAME = 'JSESSIONID';
// The /auth/refresh endpoint is auth-provider-agnostic on the server —
// AuthServeletHandlerRegistry dispatches to SamlAuthServletHandler for SAML,
// BasicAuthServletHandler for basic, etc. The UI always calls this path.
export const AUTH_REFRESH_PATH = '/api/v1/auth/refresh';
export const withShortSamlTokenValidity = (
base: ProviderConfigOverride,
tokenValiditySeconds: number = SHORT_ACCESS_TTL_SECONDS
): ProviderConfigOverride => {
const samlConfig =
(base.authenticationConfiguration.samlConfiguration as
| Record<string, unknown>
| undefined) ?? {};
const security =
(samlConfig.security as Record<string, unknown> | undefined) ?? {};
return {
...base,
authenticationConfiguration: {
...base.authenticationConfiguration,
samlConfiguration: {
...samlConfig,
security: {
...security,
tokenValidity: tokenValiditySeconds,
},
},
},
};
};
export const decodeJwtExp = (jwt: string): number => {
const payload = jwt.split('.')[1];
if (!payload) {
throw new Error('Malformed JWT: missing payload segment');
}
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(
normalized.length + ((4 - (normalized.length % 4)) % 4),
'='
);
const decoded = Buffer.from(padded, 'base64').toString('utf8');
return (JSON.parse(decoded) as { exp: number }).exp;
};
export const waitForAccessTokenExpiry = async (
ttlSeconds: number = SHORT_ACCESS_TTL_SECONDS,
bufferSeconds: number = 2
): Promise<void> => {
await new Promise((resolve) =>
setTimeout(resolve, (ttlSeconds + bufferSeconds) * 1000)
);
};
export const clearSamlSessionCookie = async (
context: BrowserContext
): Promise<void> => {
await context.clearCookies({ name: SESSION_COOKIE_NAME });
};