Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions tests/e2e/remember-me-session.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { test, expect } from '@playwright/test';

import {
TEST_OIDC_EMAIL,
TEST_OIDC_PASSWORD,
localStackEnabled,
skipLocalStackMessage,
} from './helpers/stack-env.js';
import { REMEMBER_ME_SESSION_LIFETIME_SECONDS } from '../fixtures/configure-keycloak.mjs';

/**
* "Keep me signed in" across browser restarts (#62).
*
* A restart is simulated by rehydrating a fresh browser context from
* a storageState stripped of session cookies: persistent cookies
* (Keycloak's remember-me identity cookie) and localStorage survive a
* real quit + relaunch, session cookies do not. With the remember-me
* lifespans configured by tests/fixtures/configure-keycloak.mjs, a
* login with the checkbox ticked must silently restore into the
* shell, and one without it must land back on the LoginGate.
*
* Both specs bypass the shared auth.setup storageState and perform a
* full Keycloak form login in a pristine context, so they neither
* depend on nor disturb the SSO session the rest of the suite reuses.
*/

test.skip(!localStackEnabled, skipLocalStackMessage);

const APP_ORIGIN = new URL(
process.env.PLAYWRIGHT_BASE_URL
?? `https://localhost:${process.env.PLAYWRIGHT_PORT ?? 3000}`,
).origin;
const REMEMBER_ME_COOKIE_NAME = 'KEYCLOAK_IDENTITY';
const COOKIE_EXPIRY_TOLERANCE_SECONDS = 5 * 60;

function dismissWelcomeModal(pageOrContext) {
return pageOrContext.addInitScript(() => {
try {
window.localStorage.setItem('stormbox.welcomeModalDismissed.v1', '1');
} catch {
// A new context first runs init scripts in an opaque blank page.
}
});
}

async function signInThroughKeycloak(page, { rememberMe }) {
await dismissWelcomeModal(page);
await page.goto('/');
const signInBtn = page.locator('.login-card__signin');
await expect(signInBtn).toBeEnabled({ timeout: 30_000 });
await signInBtn.click();

await page.waitForURL(/\/realms\/tbpro/, { timeout: 15_000 });
const username = page.locator('input#username, input[name="username"]').first();
await expect(username).toBeVisible({ timeout: 10_000 });
await username.fill(TEST_OIDC_EMAIL);
await page.locator('input#password, input[name="password"]').first().fill(TEST_OIDC_PASSWORD);

if (rememberMe) {
// Keycloak only starts a remember-me session for a form field
// literally named rememberMe. Assert that contract so a theme
// regression cannot silently turn this coverage into a skip.
const rememberMeInput = page.locator('input[name="rememberMe"]');
await expect(
rememberMeInput,
'tbpro theme must post the rememberMe field required by Keycloak',
).toHaveCount(1);
const themedLabel = page.locator(
'label:has([data-testid="remember-me-input"])',
);
// tbpro renders the input screen-reader-only inside a styled
// label that toggles on click; standard Keycloak themes render a
// plain visible checkbox. Click the label when present.
if ((await themedLabel.count()) > 0) {
await themedLabel.click();
} else {
await rememberMeInput.check();
}
await expect(rememberMeInput).toBeChecked();
}

await page.getByRole('button', { name: /^sign in$/i }).click();
await page.waitForURL((url) => url.origin === APP_ORIGIN, { timeout: 20_000 });
await expect(page.locator('.shell')).toBeVisible({ timeout: 30_000 });
}

/**
* What a browser keeps after quit + relaunch. Playwright marks
* session cookies with expires === -1; anything with a real expiry
* is persistent.
*/
function stripSessionCookies(storageState) {
return {
...storageState,
cookies: storageState.cookies.filter((cookie) => cookie.expires !== -1),
};
}

function expectRememberMeCookieLifetime(storageState) {
const identityCookie = storageState.cookies.find(
(cookie) => cookie.name === REMEMBER_ME_COOKIE_NAME,
);
expect(
identityCookie,
`expected persistent ${REMEMBER_ME_COOKIE_NAME} after a remember-me login`,
).toBeDefined();

const remainingLifetime = identityCookie.expires - (Date.now() / 1000);
expect(
remainingLifetime,
'expected the Keycloak remember-me identity cookie to retain the configured 90-day lifetime',
).toBeGreaterThanOrEqual(
REMEMBER_ME_SESSION_LIFETIME_SECONDS - COOKIE_EXPIRY_TOLERANCE_SECONDS,
);
expect(remainingLifetime).toBeLessThanOrEqual(
REMEMBER_ME_SESSION_LIFETIME_SECONDS + COOKIE_EXPIRY_TOLERANCE_SECONDS,
);
}

async function openBrowserContext(
browser,
storageState = { cookies: [], origins: [] },
) {
const context = await browser.newContext({
ignoreHTTPSErrors: true,
storageState,
});
await dismissWelcomeModal(context);
const page = await context.newPage();
return { context, page };
}

async function captureRestartState(browser, { rememberMe }) {
const { context, page } = await openBrowserContext(browser);
try {
await signInThroughKeycloak(page, { rememberMe });
return stripSessionCookies(await context.storageState());
} finally {
// A browser restart closes the original shared worker and OPFS connection.
await context.close();
}
}

test.describe('Keep me signed in across browser restarts', () => {
test('remember-me session silently restores after a simulated restart', async ({ browser }) => {
const restartState = await captureRestartState(browser, { rememberMe: true });
// A plain Keycloak session can also leave short-lived persistent
// cookies. Pin the remember-me identity cookie itself to the realm
// policy so the standard 10-hour fallback cannot satisfy this test.
expectRememberMeCookieLifetime(restartState);

const { context, page: restartedPage } = await openBrowserContext(browser, restartState);
try {
await restartedPage.goto(`${APP_ORIGIN}/`);
// oidc-spa restores the session silently against the proxied
// realm; the shell must appear without the Keycloak login form.
await expect(restartedPage.locator('.shell')).toBeVisible({ timeout: 30_000 });
expect(new URL(restartedPage.url()).origin).toBe(APP_ORIGIN);
} finally {
await context.close();
}
});

test('session without remember-me requires signing in again after a restart', async ({ browser }) => {
const restartState = await captureRestartState(browser, { rememberMe: false });

const { context, page: restartedPage } = await openBrowserContext(browser, restartState);
try {
await restartedPage.goto(`${APP_ORIGIN}/`);
// With the session-scoped SSO cookie gone, restoration fails and
// the LoginGate settles with an enabled Sign In button.
await expect(restartedPage.getByRole('heading', { name: 'Thundermail' })).toBeVisible({ timeout: 30_000 });
await expect(restartedPage.locator('.login-card__signin')).toBeEnabled({ timeout: 30_000 });
await expect(restartedPage.locator('.shell')).toHaveCount(0);
} finally {
await context.close();
}
});
});
26 changes: 23 additions & 3 deletions tests/fixtures/configure-keycloak.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,38 @@ async function adminToken() {
return (await res.json()).access_token;
}

// Lifetime of a "Keep me signed in" (remember-me) SSO session, in
// seconds. Keycloak only honours the login-form checkbox when the
// realm's remember-me idle/max lifespans are set; when they are 0 it
// falls back to the standard SSO timeouts (30-minute idle, 10-hour
// max), which also caps refresh tokens, so the checkbox has no
// visible effect and every return visit lands back on the login form
// (#62). Hosted realms need these same two values for webmail
// sessions to survive browser restarts.
export const REMEMBER_ME_SESSION_LIFETIME_SECONDS = 90 * 24 * 60 * 60;

export function withRememberMeSessionLifetimes(realm) {
return {
...realm,
rememberMe: true,
ssoSessionIdleTimeoutRememberMe: REMEMBER_ME_SESSION_LIFETIME_SECONDS,
ssoSessionMaxLifespanRememberMe: REMEMBER_ME_SESSION_LIFETIME_SECONDS,
};
}

async function configureRealm(token) {
const realm = await request(`${KEYCLOAK_BASE}/admin/realms/${REALM}`, {
headers: { Authorization: `Bearer ${token}` },
});
realm.attributes = {
...(realm.attributes ?? {}),
const updated = withRememberMeSessionLifetimes(realm);
updated.attributes = {
...(updated.attributes ?? {}),
frontendUrl: PUBLIC_ORIGIN,
};
await request(`${KEYCLOAK_BASE}/admin/realms/${REALM}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(realm),
body: JSON.stringify(updated),
});
}

Expand Down
47 changes: 47 additions & 0 deletions tests/unit/infra/configure-keycloak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';

import {
REMEMBER_ME_SESSION_LIFETIME_SECONDS,
withRememberMeSessionLifetimes,
} from '../../fixtures/configure-keycloak.mjs';

describe('withRememberMeSessionLifetimes', () => {
it('spans 90 days so remember-me sessions outlive browser restarts', () => {
expect(REMEMBER_ME_SESSION_LIFETIME_SECONDS).toBe(90 * 24 * 60 * 60);
});

it('enables remember-me and sets both remember-me SSO lifespans', () => {
const realm = withRememberMeSessionLifetimes({ realm: 'tbpro' });
expect(realm.rememberMe).toBe(true);
expect(realm.ssoSessionIdleTimeoutRememberMe).toBe(REMEMBER_ME_SESSION_LIFETIME_SECONDS);
expect(realm.ssoSessionMaxLifespanRememberMe).toBe(REMEMBER_ME_SESSION_LIFETIME_SECONDS);
});

it('overrides unset (0) lifespans without touching unrelated realm fields', () => {
const input = {
realm: 'tbpro',
rememberMe: false,
ssoSessionIdleTimeoutRememberMe: 0,
ssoSessionMaxLifespanRememberMe: 0,
ssoSessionIdleTimeout: 1800,
ssoSessionMaxLifespan: 36000,
attributes: { frontendUrl: 'https://localhost:3000' },
};
const realm = withRememberMeSessionLifetimes(input);
expect(realm.ssoSessionIdleTimeoutRememberMe).toBe(REMEMBER_ME_SESSION_LIFETIME_SECONDS);
expect(realm.ssoSessionMaxLifespanRememberMe).toBe(REMEMBER_ME_SESSION_LIFETIME_SECONDS);
// Standard (non-remember-me) session policy stays whatever the
// realm already had; only the checkbox-opt-in path is extended.
expect(realm.ssoSessionIdleTimeout).toBe(1800);
expect(realm.ssoSessionMaxLifespan).toBe(36000);
expect(realm.attributes).toEqual({ frontendUrl: 'https://localhost:3000' });
});

it('returns a copy instead of mutating the fetched realm representation', () => {
const input = { realm: 'tbpro', rememberMe: false };
const realm = withRememberMeSessionLifetimes(input);
expect(realm).not.toBe(input);
expect(input.rememberMe).toBe(false);
expect(input).not.toHaveProperty('ssoSessionIdleTimeoutRememberMe');
});
});
2 changes: 1 addition & 1 deletion thunderbird-accounts