mirror of
https://github.com/open-metadata/OpenMetadata
synced 2026-05-24 09:39:11 +00:00
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:
parent
d095413ed1
commit
ca2d0122db
4 changed files with 297 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
};
|
||||
Loading…
Reference in a new issue