import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * The redirect flow only exists in the Stalwart target, a build-time switch, * so every test imports the modules fresh with the env stubbed. */ async function loadModules(target: 'stalwart' | 'static' = 'stalwart') { vi.resetModules(); vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', '1'); vi.stubEnv('NEXT_PUBLIC_LITE_TARGET', target); vi.stubEnv('NEXT_PUBLIC_BASE_PATH', ''); const oauth = await import('@/lib/auth/lite-oauth'); const tokens = await import('@/lib/auth/lite-tokens'); return { oauth, tokens }; } const w = window as unknown as Record; const SERVER = 'https://mail.example.com'; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } /** What Stalwart answers for a domain it authenticates itself, over TLS. */ function ownDocument(base = SERVER) { return { issuer: base, authorization_endpoint: `${base}/login`, token_endpoint: `${base}/auth/token`, scopes_supported: ['openid', 'offline_access', 'urn:ietf:params:oauth:scope:mail'], }; } /** What it relays for a domain whose directory is an external OpenID provider. */ const PROVIDER_DOCUMENT = { issuer: 'https://id.example.org', authorization_endpoint: 'https://id.example.org/authorize', token_endpoint: 'https://id.example.org/api/oidc/token', end_session_endpoint: 'https://id.example.org/api/oidc/end-session', scopes_supported: ['openid', 'profile', 'email', 'groups'], }; function stubFetch(routes: Record Response | Promise>) { const calls: string[] = []; const mock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); calls.push(url); const route = routes[url]; if (!route) return new Response('not found', { status: 404 }); return route(); }); vi.stubGlobal('fetch', mock); return calls; } describe('Lite on Stalwart: OAuth discovery', () => { beforeEach(() => { delete w.__BULWARK_LITE_MOUNT__; sessionStorage.clear(); }); afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.resetModules(); delete w.__BULWARK_LITE_MOUNT__; }); it('registers one locale-free redirect URI below the mount', async () => { const { oauth } = await loadModules(); expect(oauth.getLiteOAuthRedirectUri()).toBe(`${window.location.origin}/oauth/callback`); w.__BULWARK_LITE_MOUNT__ = '/webmail'; expect(oauth.getLiteOAuthRedirectUri()).toBe(`${window.location.origin}/webmail/oauth/callback`); }); it('recognises an account Stalwart signs in itself', async () => { const { oauth } = await loadModules(); const calls = stubFetch({ [`${SERVER}/api/discover/alice%40example.com`]: () => jsonResponse(ownDocument()) }); const found = await oauth.liteDiscoverOAuth(`${SERVER}/`, ' alice@example.com '); expect(found).toEqual({ external: false, // Stalwart 1.0 gives a third-party client only what its data scopes name. scope: 'openid offline_access urn:ietf:params:oauth:scope:mail', metadata: { issuer: SERVER, authorization_endpoint: `${SERVER}/login`, token_endpoint: `${SERVER}/auth/token`, revocation_endpoint: undefined, end_session_endpoint: undefined, }, }); expect(calls).toEqual([`${SERVER}/api/discover/alice%40example.com`]); }); it('resolves the relative endpoints Stalwart sends over plain HTTP', async () => { const { oauth } = await loadModules(); stubFetch({ 'http://127.0.0.1:8080/api/discover/alice%40example.com': () => jsonResponse({ issuer: '', authorization_endpoint: '/login', token_endpoint: '/auth/token', scopes_supported: ['openid'] }), }); const found = await oauth.liteDiscoverOAuth('http://127.0.0.1:8080', 'alice@example.com'); expect(found?.external).toBe(false); expect(found?.metadata.authorization_endpoint).toBe('http://127.0.0.1:8080/login'); expect(found?.metadata.token_endpoint).toBe('http://127.0.0.1:8080/auth/token'); }); it('sends an account of an external directory to its provider', async () => { const { oauth } = await loadModules(); const calls = stubFetch({ [`${SERVER}/api/discover/bob%40corp.example`]: () => jsonResponse(PROVIDER_DOCUMENT), // The provider is not the mail server: the last resort asks the server about itself. [`${SERVER}/.well-known/oauth-authorization-server`]: () => jsonResponse(ownDocument()), }); const found = await oauth.liteDiscoverOAuth(SERVER, 'bob@corp.example'); expect(found?.external).toBe(true); expect(found?.scope).toBe('openid email profile'); expect(found?.metadata).toEqual({ issuer: 'https://id.example.org', authorization_endpoint: 'https://id.example.org/authorize', token_endpoint: 'https://id.example.org/api/oidc/token', revocation_endpoint: undefined, end_session_endpoint: 'https://id.example.org/api/oidc/end-session', }); expect(calls).toContain(`${SERVER}/.well-known/oauth-authorization-server`); }); it('keeps the password form behind a proxy whose host is not the configured hostname', async () => { const { oauth } = await loadModules(); // The page reaches Stalwart as admin.example.com; Stalwart calls itself mail.example.com. const proxied = 'https://admin.example.com'; stubFetch({ [`${proxied}/api/discover/alice%40example.com`]: () => jsonResponse(ownDocument(SERVER)), [`${proxied}/.well-known/oauth-authorization-server`]: () => jsonResponse(ownDocument(SERVER)), }); const found = await oauth.liteDiscoverOAuth(proxied, 'alice@example.com'); expect(found?.external).toBe(false); // Its own endpoints are used through the URL the browser reaches it under. expect(found?.metadata.authorization_endpoint).toBe(`${proxied}/login`); expect(found?.metadata.token_endpoint).toBe(`${proxied}/auth/token`); }); it('asks once per account and never remembers a failure', async () => { const { oauth } = await loadModules(); let healthy = false; const calls = stubFetch({ [`${SERVER}/api/discover/alice%40example.com`]: () => (healthy ? jsonResponse(ownDocument()) : new Response('busy', { status: 429 })), }); await expect(oauth.liteDiscoverOAuth(SERVER, 'alice@example.com')).resolves.toBeNull(); healthy = true; await expect(oauth.liteDiscoverOAuth(SERVER, 'alice@example.com')).resolves.not.toBeNull(); await expect(oauth.liteDiscoverOAuth(SERVER, 'Alice@Example.com')).resolves.not.toBeNull(); expect(calls).toHaveLength(2); }); it('answers null for unusable documents, missing endpoints and unreachable servers', async () => { const { oauth } = await loadModules(); stubFetch({ [`${SERVER}/api/discover/a%40x.example`]: () => jsonResponse({ authorization_endpoint: 'https://id.example.org/authorize' }), [`${SERVER}/api/discover/b%40x.example`]: () => new Response('', { status: 200 }), [`${SERVER}/api/discover/c%40x.example`]: () => jsonResponse({ authorization_endpoint: 'javascript:alert(1)', token_endpoint: 'https://id.example.org/token' }), [`${SERVER}/api/discover/d%40x.example`]: () => Promise.reject(new TypeError('network')), }); for (const account of ['a@x.example', 'b@x.example', 'c@x.example', 'd@x.example', 'e@x.example']) { await expect(oauth.liteDiscoverOAuth(SERVER, account)).resolves.toBeNull(); } await expect(oauth.liteDiscoverOAuth(SERVER, ' ')).resolves.toBeNull(); await expect(oauth.liteDiscoverOAuth('/relative', 'a@x.example')).resolves.toBeNull(); }); it('requests no scope from a provider without OpenID, and the default when none are listed', async () => { const { oauth } = await loadModules(); stubFetch({ [`${SERVER}/api/discover/a%40x.example`]: () => jsonResponse({ ...ownDocument(), scopes_supported: ['mail'] }), [`${SERVER}/api/discover/b%40x.example`]: () => jsonResponse({ ...ownDocument(), scopes_supported: undefined }), }); expect((await oauth.liteDiscoverOAuth(SERVER, 'a@x.example'))?.scope).toBe(''); expect((await oauth.liteDiscoverOAuth(SERVER, 'b@x.example'))?.scope).toBe('openid email profile'); }); it('is switched off in the static-host build', async () => { const { oauth } = await loadModules('static'); const calls = stubFetch({}); expect(oauth.LITE_OAUTH_AVAILABLE).toBe(false); await expect(oauth.liteDiscoverOAuth(SERVER, 'alice@example.com')).resolves.toBeNull(); expect(calls).toEqual([]); }); }); describe('Lite on Stalwart: pending OAuth flow and code exchange', () => { beforeEach(() => { localStorage.clear(); sessionStorage.clear(); }); afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.resetModules(); }); it('parks the flow for the callback and rejects a damaged one', async () => { const { oauth } = await loadModules(); expect(oauth.readLiteOAuthFlow()).toBeNull(); const flow = { tokenEndpoint: 'https://id.example.org/token', clientId: 'app', redirectUri: 'https://mail.example.com/webmail/oauth/callback', persistent: true }; oauth.saveLiteOAuthFlow(flow); expect(oauth.readLiteOAuthFlow()).toEqual(flow); oauth.clearLiteOAuthFlow(); expect(oauth.readLiteOAuthFlow()).toBeNull(); sessionStorage.setItem('oauth_lite_flow', JSON.stringify({ ...flow, tokenEndpoint: '' })); expect(oauth.readLiteOAuthFlow()).toBeNull(); sessionStorage.setItem('oauth_lite_flow', '{not json'); expect(oauth.readLiteOAuthFlow()).toBeNull(); // Anything but `true` means the token stays with the tab. sessionStorage.setItem('oauth_lite_flow', JSON.stringify({ ...flow, persistent: 'yes' })); expect(oauth.readLiteOAuthFlow()?.persistent).toBe(false); }); it('redeems the code at the discovered endpoint as a public client', async () => { const { tokens } = await loadModules(); const calls: { url: string; init?: RequestInit }[] = []; vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), init }); return jsonResponse({ access_token: 'AT', refresh_token: 'RT', expires_in: 900 }); })); const result = await tokens.liteExchangeAuthorizationCode({ tokenEndpoint: 'https://id.example.org/token', code: 'the-code', codeVerifier: 'the-verifier', redirectUri: 'https://mail.example.com/webmail/oauth/callback', clientId: 'app', }); expect(result).toEqual({ accessToken: 'AT', expiresIn: 900, refreshToken: 'RT' }); expect(calls).toHaveLength(1); expect(calls[0].url).toBe('https://id.example.org/token'); expect(Object.fromEntries(new URLSearchParams(String(calls[0].init?.body)))).toEqual({ grant_type: 'authorization_code', code: 'the-code', client_id: 'app', redirect_uri: 'https://mail.example.com/webmail/oauth/callback', code_verifier: 'the-verifier', }); // A public client: PKCE, no secret, no credentials header. expect(new Headers(calls[0].init?.headers).has('Authorization')).toBe(false); }); it('reports a rejected code', async () => { const { tokens } = await loadModules(); vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'invalid_grant' }, 400))); await expect(tokens.liteExchangeAuthorizationCode({ tokenEndpoint: 'https://id.example.org/token', code: 'c', codeVerifier: 'v', redirectUri: 'r', clientId: 'app', })).rejects.toMatchObject({ code: 'token_exchange_failed', status: 400 }); }); it('renews a provider-issued token at that provider, and a Stalwart one at the server', async () => { const { tokens } = await loadModules(); const urls: string[] = []; vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { urls.push(String(input)); return jsonResponse({ access_token: 'AT2', refresh_token: 'RT2', expires_in: 60 }); })); tokens.saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'bob@corp.example', refreshToken: 'RT', clientId: 'app', tokenEndpoint: 'https://id.example.org/token' }, true); tokens.saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'alice@example.com', refreshToken: 'RT', clientId: 'app' }, true); await tokens.liteRefreshTokens(0); await tokens.liteRefreshTokens(1); expect(urls).toEqual(['https://id.example.org/token', `${SERVER}/auth/token`]); // The rotation keeps pointing at the provider. expect(tokens.readLiteRefreshToken(0)).toMatchObject({ refreshToken: 'RT2', tokenEndpoint: 'https://id.example.org/token' }); }); it('names a token once the session told us the account, where it already lives', async () => { const { tokens } = await loadModules(); tokens.saveLiteRefreshToken(0, { serverUrl: SERVER, username: '', refreshToken: 'RT', clientId: 'app' }, false); tokens.nameLiteRefreshToken(0, 'bob@corp.example'); expect(tokens.readLiteRefreshToken(0)?.username).toBe('bob@corp.example'); expect(sessionStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull(); expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull(); // Nothing stored: nothing to name. tokens.nameLiteRefreshToken(3, 'nobody'); expect(tokens.readLiteRefreshToken(3)).toBeNull(); }); });