mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
When Stalwart accepted the password and code but refused to issue tokens (in Lite: `invalid_client` for an OAuth client with a secret), login retried `password$totp` over Basic. Stalwart 0.16+ rejects that, so the browser showed its own Basic-auth dialog and the login page then reported a wrong code. The login now stops with a message pointing at the OAuth client settings. Password-only logins keep the Basic fallback.
552 lines
25 KiB
TypeScript
552 lines
25 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Bulwark Lite (static export) has no /api/* routes: sessions are kept in web
|
|
// storage and renewed against the mail server directly. These tests run the
|
|
// auth store with IS_LITE forced on and assert that nothing under /api/ on
|
|
// our own origin is ever called (config.json / policy.json are static files).
|
|
vi.mock('@/lib/lite', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@/lib/lite')>()),
|
|
IS_LITE: true,
|
|
}));
|
|
|
|
import { JMAPClient } from '@/lib/jmap/client';
|
|
import * as browserNavigation from '@/lib/browser-navigation';
|
|
import { useAuthStore } from '../auth-store';
|
|
import { useAccountStore } from '../account-store';
|
|
import {
|
|
readLiteAccessToken,
|
|
readLiteBasicSession,
|
|
readLiteRefreshToken,
|
|
saveLiteAccessToken,
|
|
saveLiteBasicSession,
|
|
saveLiteRefreshToken,
|
|
} from '@/lib/auth/lite-tokens';
|
|
import { readLiteOAuthFlow, saveLiteOAuthFlow } from '@/lib/auth/lite-oauth';
|
|
|
|
type FetchInput = Parameters<typeof fetch>[0];
|
|
type FetchInit = Parameters<typeof fetch>[1];
|
|
|
|
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' } });
|
|
}
|
|
|
|
/** Rauthy's answer to a refresh token used before its `nbf` (#552). */
|
|
function notYetValid(): Response {
|
|
return jsonResponse({ error: 'JwtToken', message: 'Token is not valid yet' }, 401);
|
|
}
|
|
|
|
/**
|
|
* A fetch that serves Stalwart's login + token endpoints, the static
|
|
* config/policy files, and fails on anything else - in particular on any
|
|
* /api/ route of our own origin. `calls` lists the mail-server requests.
|
|
*/
|
|
function stalwartFetch(options: {
|
|
loginAnswer?: unknown;
|
|
refreshAnswer?: () => Response;
|
|
loginStatus?: number;
|
|
/** Stalwart's answer to the authorization_code grant (default: tokens). */
|
|
tokenAnswer?: () => Response;
|
|
/** Advertise an RFC 7009 revocation endpoint in the metadata document. */
|
|
revocation?: boolean;
|
|
} = {}) {
|
|
const calls: string[] = [];
|
|
const revoked: string[] = [];
|
|
const mock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url === '/config.json') return jsonResponse({ jmapServerUrl: SERVER });
|
|
if (url === '/policy.json') return jsonResponse({});
|
|
if (url.startsWith('/')) throw new Error(`Lite must not call its own origin: ${url}`);
|
|
// Sign-out looks the revocation endpoint up; not counted in `calls`, as
|
|
// a sign-out in one test's cleanup may still be looking when the next
|
|
// test starts.
|
|
if (url.startsWith(`${SERVER}/.well-known/`)) {
|
|
return options.revocation
|
|
? jsonResponse({ issuer: SERVER, revocation_endpoint: `${SERVER}/auth/revoke` })
|
|
: new Response('not found', { status: 404 });
|
|
}
|
|
calls.push(`${init?.method ?? 'GET'} ${url}`);
|
|
if (url === `${SERVER}/auth/revoke`) {
|
|
revoked.push(new URLSearchParams(String(init?.body)).get('token') ?? '');
|
|
return new Response(null, { status: 200 });
|
|
}
|
|
if (url === `${SERVER}/api/auth`) {
|
|
if (options.loginStatus) return new Response('nope', { status: options.loginStatus });
|
|
return jsonResponse(options.loginAnswer ?? { type: 'authenticated', client_code: 'CODE' });
|
|
}
|
|
if (url === `${SERVER}/auth/token`) {
|
|
const params = new URLSearchParams(String(init?.body));
|
|
if (params.get('grant_type') === 'refresh_token') {
|
|
return options.refreshAnswer ? options.refreshAnswer() : jsonResponse({ access_token: 'AT-refreshed', expires_in: 3600, refresh_token: 'RT-2' });
|
|
}
|
|
if (options.tokenAnswer) return options.tokenAnswer();
|
|
return jsonResponse({ access_token: 'AT-1', expires_in: 3600, refresh_token: 'RT-1' });
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', mock);
|
|
return { mock, calls, revoked };
|
|
}
|
|
|
|
function liteStorageKeys(): string[] {
|
|
return [...Object.keys(localStorage), ...Object.keys(sessionStorage)].filter((k) => k.startsWith('bulwark-lite:'));
|
|
}
|
|
|
|
function registerAccount(authMode: 'basic' | 'oauth'): string {
|
|
return useAccountStore.getState().addAccount({
|
|
label: 'alice',
|
|
serverUrl: SERVER,
|
|
username: 'alice',
|
|
authMode,
|
|
rememberMe: true,
|
|
displayName: 'alice',
|
|
email: 'alice@example.com',
|
|
lastLoginAt: Date.now(),
|
|
isConnected: false,
|
|
hasError: false,
|
|
isDefault: true,
|
|
});
|
|
}
|
|
|
|
describe('auth-store in the static Lite build', () => {
|
|
let connectSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
sessionStorage.clear();
|
|
localStorage.clear();
|
|
window.history.pushState({}, '', '/en/login');
|
|
|
|
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
|
useAuthStore.setState({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
serverUrl: null,
|
|
username: null,
|
|
client: null,
|
|
identities: [],
|
|
primaryIdentity: null,
|
|
authMode: 'basic',
|
|
rememberMe: false,
|
|
accessToken: null,
|
|
tokenExpiresAt: null,
|
|
connectionLost: false,
|
|
activeAccountId: null,
|
|
isDemoMode: false,
|
|
});
|
|
|
|
connectSpy = vi.spyOn(JMAPClient.prototype, 'connect').mockResolvedValue(undefined);
|
|
vi.spyOn(JMAPClient.prototype, 'getIdentities').mockResolvedValue([]);
|
|
vi.spyOn(JMAPClient.prototype, 'getSessionUsername').mockReturnValue('alice@example.com');
|
|
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Signing out revokes refresh tokens. Finish that here against a fetch
|
|
// that answers nothing: left running, it reaches the real network and
|
|
// then posts a revocation into a later test's fetch mock.
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })));
|
|
await useAuthStore.getState().logoutAll();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('"remember me" logs in through Stalwart token login and keeps the tokens, never the password, in localStorage', async () => {
|
|
const { calls } = stalwartFetch();
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(calls).toEqual([`POST ${SERVER}/api/auth`, `POST ${SERVER}/auth/token`]);
|
|
const state = useAuthStore.getState();
|
|
expect(state.authMode).toBe('oauth');
|
|
expect(state.accessToken).toBe('AT-1');
|
|
expect(state.client?.getAuthHeader()).toBe('Bearer AT-1');
|
|
// The client the token was issued to rides along, so refreshes present the same one.
|
|
expect(readLiteRefreshToken(0)).toEqual({ serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1', clientId: 'bulwark-webmail' });
|
|
expect(localStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull();
|
|
expect(localStorage.getItem('bulwark-lite:access:0')).toContain('"accessToken":"AT-1"');
|
|
// No password anywhere in web storage.
|
|
const dump = JSON.stringify({ ...localStorage, ...sessionStorage });
|
|
expect(dump).not.toContain('"pw"');
|
|
expect(readLiteBasicSession(0)).toBeNull();
|
|
});
|
|
|
|
it('a TOTP login without "remember me" keeps the refresh token with the tab only', async () => {
|
|
const { mock } = stalwartFetch();
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', '123456', false);
|
|
|
|
expect(ok).toBe(true);
|
|
const loginCall = mock.mock.calls.find(([input]) => String(input) === `${SERVER}/api/auth`);
|
|
expect(JSON.parse(String(loginCall?.[1]?.body)).mfaToken).toBe('123456');
|
|
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
|
|
expect(JSON.parse(sessionStorage.getItem('bulwark-lite:refresh:0')!).refreshToken).toBe('RT-1');
|
|
});
|
|
|
|
it('a plain password login without "remember me" still uses token login and keeps the refresh token with the tab', async () => {
|
|
const { calls } = stalwartFetch();
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw');
|
|
|
|
expect(ok).toBe(true);
|
|
expect(calls).toEqual([`POST ${SERVER}/api/auth`, `POST ${SERVER}/auth/token`]);
|
|
expect(connectSpy).toHaveBeenCalledTimes(1);
|
|
expect(useAuthStore.getState().authMode).toBe('oauth');
|
|
expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer AT-1');
|
|
// Tab-scoped: sessionStorage only, so a reload keeps the session and closing the tab ends it.
|
|
expect(sessionStorage.getItem('bulwark-lite:refresh:0')).toContain('"refreshToken":"RT-1"');
|
|
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
|
|
expect(readLiteBasicSession(0)).toBeNull();
|
|
});
|
|
|
|
it('signs in with an address on an IDN domain as typed, using its ASCII form (#1100)', async () => {
|
|
const { mock } = stalwartFetch();
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice@ノード.com', 'pw');
|
|
|
|
expect(ok).toBe(true);
|
|
const loginCall = mock.mock.calls.find(([input]) => String(input) === `${SERVER}/api/auth`);
|
|
expect(JSON.parse(String(loginCall?.[1]?.body)).accountName).toBe('alice@xn--gdkj2l.com');
|
|
expect(useAuthStore.getState().username).toBe('alice@xn--gdkj2l.com');
|
|
expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer AT-1');
|
|
});
|
|
|
|
it('sends Basic credentials as UTF-8 when the server has no token login', async () => {
|
|
stalwartFetch({ loginStatus: 404 });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'jörg@ノード.com', 'pässwörd€');
|
|
|
|
expect(ok).toBe(true);
|
|
const header = useAuthStore.getState().client?.getAuthHeader() ?? '';
|
|
expect(header.startsWith('Basic ')).toBe(true);
|
|
const bytes = Uint8Array.from(atob(header.slice(6)), (c) => c.charCodeAt(0));
|
|
expect(new TextDecoder().decode(bytes)).toBe('jörg@xn--gdkj2l.com:pässwörd€');
|
|
});
|
|
|
|
it('without token login and without "remember me" the Basic session is still kept for the tab', async () => {
|
|
const { calls } = stalwartFetch({ loginStatus: 404 });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw');
|
|
|
|
expect(ok).toBe(true);
|
|
expect(calls).toEqual([`POST ${SERVER}/api/auth`]);
|
|
expect(useAuthStore.getState().authMode).toBe('basic');
|
|
expect(readLiteBasicSession(0)).toEqual({ serverUrl: SERVER, username: 'alice', password: 'pw' });
|
|
expect(localStorage.getItem('bulwark-lite:basic:0')).toBeNull();
|
|
expect(liteStorageKeys().filter((k) => k.startsWith('bulwark-lite:refresh:'))).toEqual([]);
|
|
});
|
|
|
|
it('a Basic account without "remember me" is restored from the tab session instead of being evicted', async () => {
|
|
saveLiteBasicSession(0, { serverUrl: SERVER, username: 'alice', password: 'pw' });
|
|
const id = registerAccount('basic');
|
|
useAccountStore.getState().updateAccount(id, { rememberMe: false });
|
|
stalwartFetch();
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
|
|
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([id]);
|
|
});
|
|
|
|
it('surfaces a missing MFA code as totp_required', async () => {
|
|
stalwartFetch({ loginAnswer: { type: 'mfaRequired' } });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(useAuthStore.getState().error).toBe('totp_required');
|
|
expect(connectSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Stalwart answers `invalid_client` at /auth/token when the OAuth client has
|
|
// a secret, which Lite cannot send. Retrying `password$totp` over Basic
|
|
// only popped the browser's Basic-auth dialog and then blamed the code.
|
|
it('reports a refused token exchange after a TOTP login instead of retrying over Basic', async () => {
|
|
const { calls } = stalwartFetch({ tokenAnswer: () => jsonResponse({ error: 'invalid_client' }, 400) });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', '123456', true);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(useAuthStore.getState().error).toBe('token_exchange_failed');
|
|
expect(calls).toEqual([`POST ${SERVER}/api/auth`, `POST ${SERVER}/auth/token`]);
|
|
expect(connectSpy).not.toHaveBeenCalled();
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
});
|
|
|
|
it('still falls back to Basic for a password-only login when the token exchange is refused', async () => {
|
|
stalwartFetch({ tokenAnswer: () => jsonResponse({ error: 'invalid_client' }, 400) });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(useAuthStore.getState().client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
|
|
});
|
|
|
|
it('keeps no password in web storage when token login exists but failed', async () => {
|
|
stalwartFetch({ loginStatus: 500 });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(useAuthStore.getState().authMode).toBe('basic');
|
|
expect(readLiteBasicSession(0)).toBeNull();
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
});
|
|
|
|
it('falls back to Basic auth with a tab-scoped session when the server has no token login', async () => {
|
|
const { calls } = stalwartFetch({ loginStatus: 404 });
|
|
|
|
const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(calls).toEqual([`POST ${SERVER}/api/auth`]);
|
|
expect(useAuthStore.getState().authMode).toBe('basic');
|
|
expect(connectSpy).toHaveBeenCalledTimes(1);
|
|
expect(readLiteRefreshToken(0)).toBeNull();
|
|
expect(readLiteBasicSession(0)).toEqual({ serverUrl: SERVER, username: 'alice', password: 'pw' });
|
|
expect(localStorage.getItem('bulwark-lite:basic:0')).toBeNull();
|
|
});
|
|
|
|
it('resumes a reload with the cached access token instead of renewing early (#552)', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true);
|
|
saveLiteAccessToken(0, 'AT-cached', 1800, true);
|
|
const id = registerAccount('oauth');
|
|
// A provider that gates refresh on `nbf` refuses a renewal this early.
|
|
const { calls } = stalwartFetch({ refreshAnswer: notYetValid });
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
expect(calls).toEqual([]);
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.client?.getAuthHeader()).toBe('Bearer AT-cached');
|
|
expect(useAccountStore.getState().getAccountById(id)?.isConnected).toBe(true);
|
|
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT-1');
|
|
});
|
|
|
|
it('renews on reload once the cached access token is in its last minute', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true);
|
|
saveLiteAccessToken(0, 'AT-stale', 30, true);
|
|
registerAccount('oauth');
|
|
const { calls } = stalwartFetch();
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
expect(calls).toEqual([`POST ${SERVER}/auth/token`]);
|
|
expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer AT-refreshed');
|
|
expect(readLiteAccessToken(0)?.accessToken).toBe('AT-refreshed');
|
|
});
|
|
|
|
it('restores a remembered session without a cached access token by refreshing against the mail server', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true);
|
|
registerAccount('oauth');
|
|
const { calls } = stalwartFetch();
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
expect(calls).toEqual([`POST ${SERVER}/auth/token`]);
|
|
const state = useAuthStore.getState();
|
|
expect(state.isAuthenticated).toBe(true);
|
|
expect(state.client?.getAuthHeader()).toBe('Bearer AT-refreshed');
|
|
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT-2');
|
|
});
|
|
|
|
it('restores a tab-scoped Basic session from sessionStorage', async () => {
|
|
saveLiteBasicSession(0, { serverUrl: SERVER, username: 'alice', password: 'pw' });
|
|
registerAccount('basic');
|
|
const { calls } = stalwartFetch();
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
expect(calls).toEqual([]);
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
|
expect(useAuthStore.getState().client?.getAuthHeader()).toBe(`Basic ${btoa('alice:pw')}`);
|
|
});
|
|
|
|
it('evicts an account whose refresh token the server rejected', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-dead' }, true);
|
|
const id = registerAccount('oauth');
|
|
stalwartFetch({ refreshAnswer: () => jsonResponse({ error: 'invalid_grant' }, 400) });
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
expect(useAccountStore.getState().getAccountById(id)).toBeUndefined();
|
|
expect(readLiteRefreshToken(0)).toBeNull();
|
|
});
|
|
|
|
it('keeps the account when the mail server is unreachable during restore', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'alice', refreshToken: 'RT-1' }, true);
|
|
const id = registerAccount('oauth');
|
|
vi.stubGlobal('fetch', vi.fn(async (input: FetchInput) => {
|
|
if (String(input) === '/config.json' || String(input) === '/policy.json') return jsonResponse({});
|
|
throw new TypeError('Failed to fetch');
|
|
}));
|
|
|
|
await useAuthStore.getState().checkAuth();
|
|
|
|
expect(useAccountStore.getState().getAccountById(id)?.hasError).toBe(true);
|
|
expect(readLiteRefreshToken(0)?.refreshToken).toBe('RT-1');
|
|
});
|
|
|
|
it('logout wipes the slot from web storage without touching our origin', async () => {
|
|
const { calls } = stalwartFetch();
|
|
await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
expect(readLiteRefreshToken(0)).not.toBeNull();
|
|
calls.length = 0;
|
|
|
|
await useAuthStore.getState().logout();
|
|
|
|
expect(calls).toEqual([]);
|
|
expect(readLiteRefreshToken(0)).toBeNull();
|
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
});
|
|
|
|
it('logout revokes the refresh token where the mail server advertises revocation', async () => {
|
|
const { calls, revoked } = stalwartFetch({ revocation: true });
|
|
await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
calls.length = 0;
|
|
|
|
await useAuthStore.getState().logout();
|
|
|
|
expect(calls).toEqual([`POST ${SERVER}/auth/revoke`]);
|
|
expect(revoked).toEqual(['RT-1']);
|
|
expect(readLiteRefreshToken(0)).toBeNull();
|
|
});
|
|
|
|
it('logoutAll revokes every slot\'s refresh token', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0' }, true);
|
|
saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'b', refreshToken: 'r1' }, false);
|
|
const { revoked } = stalwartFetch({ revocation: true });
|
|
|
|
await useAuthStore.getState().logoutAll();
|
|
|
|
expect(revoked.sort()).toEqual(['r0', 'r1']);
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
});
|
|
|
|
it('logoutAll clears every Lite slot', async () => {
|
|
saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0' }, true);
|
|
saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'b', refreshToken: 'r1' }, false);
|
|
saveLiteBasicSession(2, { serverUrl: SERVER, username: 'c', password: 'p' });
|
|
const { calls } = stalwartFetch();
|
|
|
|
await useAuthStore.getState().logoutAll();
|
|
|
|
expect(calls).toEqual([]);
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
});
|
|
|
|
it('refreshAccessToken renews through the mail server and signs out on a definitive rejection', async () => {
|
|
const { calls } = stalwartFetch();
|
|
await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true);
|
|
calls.length = 0;
|
|
|
|
const token = await useAuthStore.getState().refreshAccessToken();
|
|
expect(token).toBe('AT-refreshed');
|
|
expect(calls).toEqual([`POST ${SERVER}/auth/token`]);
|
|
expect(useAuthStore.getState().accessToken).toBe('AT-refreshed');
|
|
|
|
stalwartFetch({ refreshAnswer: () => jsonResponse({ error: 'invalid_grant' }, 400) });
|
|
const rejected = await useAuthStore.getState().refreshAccessToken();
|
|
expect(rejected).toBeNull();
|
|
// logout() runs asynchronously after the 401.
|
|
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
|
|
expect(readLiteRefreshToken(0)).toBeNull();
|
|
});
|
|
|
|
describe('OAuth sign-in (Lite on Stalwart)', () => {
|
|
const PROVIDER_TOKEN = 'https://id.example.org/token';
|
|
const REDIRECT = 'https://mail.example.com/webmail/oauth/callback';
|
|
|
|
beforeEach(() => {
|
|
// A bearer client learns its account from the session, which the mocked connect() never loads.
|
|
vi.spyOn(JMAPClient.prototype, 'getUsername').mockReturnValue('alice@example.com');
|
|
});
|
|
|
|
/** Serves the external provider's token endpoint; anything on our origin throws. */
|
|
function providerFetch(answer: () => Response = () => jsonResponse({ access_token: 'AT-sso', expires_in: 600, refresh_token: 'RT-sso' })) {
|
|
const calls: { url: string; params: Record<string, string> }[] = [];
|
|
vi.stubGlobal('fetch', vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url === '/config.json') return jsonResponse({ jmapServerUrl: SERVER });
|
|
if (url === '/policy.json') return jsonResponse({});
|
|
if (url.startsWith('/')) throw new Error(`Lite must not call its own origin: ${url}`);
|
|
if (url !== PROVIDER_TOKEN) throw new Error(`unexpected fetch ${url}`);
|
|
calls.push({ url, params: Object.fromEntries(new URLSearchParams(String(init?.body))) });
|
|
return answer();
|
|
}));
|
|
return calls;
|
|
}
|
|
|
|
it('redeems the code at the provider the login page discovered and remembers where to renew', async () => {
|
|
const calls = providerFetch();
|
|
saveLiteOAuthFlow({ tokenEndpoint: PROVIDER_TOKEN, clientId: 'app-client', redirectUri: REDIRECT, persistent: true });
|
|
sessionStorage.setItem('oauth_cookie_slot', '0');
|
|
|
|
const ok = await useAuthStore.getState().loginWithOAuth(SERVER, 'CODE', 'VERIFIER', REDIRECT);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(calls).toEqual([{
|
|
url: PROVIDER_TOKEN,
|
|
params: { grant_type: 'authorization_code', code: 'CODE', client_id: 'app-client', redirect_uri: REDIRECT, code_verifier: 'VERIFIER' },
|
|
}]);
|
|
const state = useAuthStore.getState();
|
|
expect(state.authMode).toBe('oauth');
|
|
expect(state.client?.getAuthHeader()).toBe('Bearer AT-sso');
|
|
// Named after the session, renewed at the provider, kept across restarts.
|
|
expect(readLiteRefreshToken(0)).toEqual({
|
|
serverUrl: SERVER, username: 'alice@example.com', refreshToken: 'RT-sso', clientId: 'app-client', tokenEndpoint: PROVIDER_TOKEN,
|
|
});
|
|
expect(localStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull();
|
|
// The reload that follows sign-in resumes with this token (#552).
|
|
expect(localStorage.getItem('bulwark-lite:access:0')).toContain('"accessToken":"AT-sso"');
|
|
// A code is good for one attempt.
|
|
expect(readLiteOAuthFlow()).toBeNull();
|
|
|
|
calls.length = 0;
|
|
await useAuthStore.getState().refreshAccessToken();
|
|
expect(calls.map((c) => [c.url, c.params.grant_type, c.params.client_id])).toEqual([[PROVIDER_TOKEN, 'refresh_token', 'app-client']]);
|
|
});
|
|
|
|
it('keeps the token with the tab when "remember me" was not ticked', async () => {
|
|
providerFetch();
|
|
saveLiteOAuthFlow({ tokenEndpoint: PROVIDER_TOKEN, clientId: 'app-client', redirectUri: REDIRECT, persistent: false });
|
|
|
|
expect(await useAuthStore.getState().loginWithOAuth(SERVER, 'CODE', 'VERIFIER', REDIRECT)).toBe(true);
|
|
|
|
expect(localStorage.getItem('bulwark-lite:refresh:0')).toBeNull();
|
|
expect(sessionStorage.getItem('bulwark-lite:refresh:0')).not.toBeNull();
|
|
expect(localStorage.getItem('bulwark-lite:access:0')).toBeNull();
|
|
expect(sessionStorage.getItem('bulwark-lite:access:0')).not.toBeNull();
|
|
});
|
|
|
|
it('refuses a callback it did not start', async () => {
|
|
const calls = providerFetch();
|
|
|
|
expect(await useAuthStore.getState().loginWithOAuth(SERVER, 'CODE', 'VERIFIER', REDIRECT)).toBe(false);
|
|
|
|
expect(calls).toEqual([]);
|
|
expect(useAuthStore.getState().error).toBe('token_exchange_failed');
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
});
|
|
|
|
it('leaves no token behind when the provider rejects the code or the session never comes up', async () => {
|
|
providerFetch(() => jsonResponse({ error: 'invalid_grant' }, 400));
|
|
saveLiteOAuthFlow({ tokenEndpoint: PROVIDER_TOKEN, clientId: 'app-client', redirectUri: REDIRECT, persistent: true });
|
|
expect(await useAuthStore.getState().loginWithOAuth(SERVER, 'CODE', 'VERIFIER', REDIRECT)).toBe(false);
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
|
|
providerFetch();
|
|
connectSpy.mockRejectedValueOnce(new Error('Failed to get session: 401'));
|
|
saveLiteOAuthFlow({ tokenEndpoint: PROVIDER_TOKEN, clientId: 'app-client', redirectUri: REDIRECT, persistent: true });
|
|
expect(await useAuthStore.getState().loginWithOAuth(SERVER, 'CODE', 'VERIFIER', REDIRECT)).toBe(false);
|
|
expect(liteStorageKeys()).toEqual([]);
|
|
});
|
|
});
|
|
});
|