From 83e4afd7fead9cc7579a0f8a7f2c5b4a12c7b7e4 Mon Sep 17 00:00:00 2001 From: ruddro-roy Date: Mon, 13 Jul 2026 00:53:52 +0600 Subject: [PATCH 1/2] Bump thunderbird-accounts so the login theme posts rememberMe and the realm import ships 90-day remember-me session lifespans (thunderbird/thunderbird-accounts#1091). --- thunderbird-accounts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thunderbird-accounts b/thunderbird-accounts index 14328f3..e839a75 160000 --- a/thunderbird-accounts +++ b/thunderbird-accounts @@ -1 +1 @@ -Subproject commit 14328f34a6cdfa96c2cd6cc8dff3b5cb7898da33 +Subproject commit e839a753ab0843edf56fe1b3932a9a523f4dbc8d From 8324638d12e6d51b5817d0df57c03e8bcddd391e Mon Sep 17 00:00:00 2001 From: ruddro-roy Date: Mon, 13 Jul 2026 00:53:52 +0600 Subject: [PATCH 2/2] Reconcile remember-me lifetimes and strengthen browser restart coverage. (#62) Keycloak imports realm JSON only into an empty database, so existing development volumes can retain zero remember-me lifespans. Reconcile rememberMe and both 90-day lifespans idempotently with the values shipped by Thunderbird Accounts. The restart specs require the exact rememberMe form field, assert that KEYCLOAK_IDENTITY retains the configured 90-day lifetime, close the original browser context, and verify remembered and plain logins after rehydration. Closing the original context makes the simulation match a real quit and prevents concurrent SharedWorker and OPFS state. --- tests/e2e/remember-me-session.spec.js | 179 ++++++++++++++++++++ tests/fixtures/configure-keycloak.mjs | 26 ++- tests/unit/infra/configure-keycloak.test.ts | 47 +++++ 3 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/remember-me-session.spec.js create mode 100644 tests/unit/infra/configure-keycloak.test.ts diff --git a/tests/e2e/remember-me-session.spec.js b/tests/e2e/remember-me-session.spec.js new file mode 100644 index 0000000..fc43afd --- /dev/null +++ b/tests/e2e/remember-me-session.spec.js @@ -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(); + } + }); +}); diff --git a/tests/fixtures/configure-keycloak.mjs b/tests/fixtures/configure-keycloak.mjs index c094ff2..295e206 100644 --- a/tests/fixtures/configure-keycloak.mjs +++ b/tests/fixtures/configure-keycloak.mjs @@ -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), }); } diff --git a/tests/unit/infra/configure-keycloak.test.ts b/tests/unit/infra/configure-keycloak.test.ts new file mode 100644 index 0000000..41ce835 --- /dev/null +++ b/tests/unit/infra/configure-keycloak.test.ts @@ -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'); + }); +});