feat(opaque): Silent migration on legacy login
This commit is contained in:
@@ -1,14 +1,37 @@
|
||||
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
// Mock the OPAQUE WASM client so `login()`'s silent-migration hook can
|
||||
// exercise the wire path (params + register handshake) without touching
|
||||
// real WASM in jsdom.
|
||||
vi.mock('@serenity-kit/opaque', () => ({
|
||||
ready: Promise.resolve(),
|
||||
client: {
|
||||
startRegistration: vi.fn(() => ({
|
||||
clientRegistrationState: 'STATE-R',
|
||||
registrationRequest: 'REQ-R'
|
||||
})),
|
||||
finishRegistration: vi.fn(() => ({
|
||||
registrationRecord: 'RECORD-R',
|
||||
exportKey: 'EK',
|
||||
serverStaticPublicKey: 'SPK'
|
||||
}))
|
||||
}
|
||||
}));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import * as auth from './auth';
|
||||
import { __resetOpaqueParamsCache } from './opaque';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
// Several auth probes use the raw global fetch (NOT apiFetch) on purpose.
|
||||
const okRes = { ok: true, status: 200, json: async () => ({}) };
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// login() dynamically imports the OPAQUE client and calls
|
||||
// syncOpaqueEnvelope, which caches /params results in a
|
||||
// module-level singleton. Reset it so each test's mock
|
||||
// responses drive a fresh /params fetch.
|
||||
__resetOpaqueParamsCache();
|
||||
f.mockResolvedValue(okRes);
|
||||
j.mockResolvedValue({});
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes));
|
||||
@@ -42,3 +65,83 @@ it('tryRefresh returns false when the refresh fails', async () => {
|
||||
);
|
||||
await expect(auth.tryRefresh()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
// ── Phase 2: silent OPAQUE migration on legacy login ─────────────────
|
||||
//
|
||||
// `login()` MUST trigger `syncOpaqueEnvelope(password)` after a
|
||||
// successful POST /api/auth/login response, so users pick up an OPAQUE
|
||||
// envelope automatically over time. Verified here by observing the
|
||||
// wire calls made after the login POST — a successful login without
|
||||
// a follow-up `/api/auth/opaque/*` fetch is a regression that would
|
||||
// silently break Phase 3's cutover assumption ("most active users
|
||||
// have an envelope on file by the time the SPA flips to OPAQUE").
|
||||
it('login triggers OPAQUE silent-migration on success', async () => {
|
||||
// Login POST → 200 AuthResponse
|
||||
// Then syncOpaqueEnvelope runs: GET /params → POST register/start → POST register/finish
|
||||
f.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({
|
||||
user: { id: 'u1', email: 'a@x.test' },
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({
|
||||
enabled: true,
|
||||
ciphersuiteVersion: 1,
|
||||
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ registrationResponse: 'RESP-R' })
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
||||
|
||||
const authResponse = await auth.login('alice@example.com', 'correct horse battery staple');
|
||||
expect(authResponse.access_token).toBe('at');
|
||||
|
||||
// 4 apiFetch calls: login + params + register/start + register/finish
|
||||
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
||||
expect(urls).toContain('/api/auth/login');
|
||||
expect(urls).toContain('/api/auth/opaque/params');
|
||||
expect(urls).toContain('/api/auth/opaque/register/start');
|
||||
expect(urls).toContain('/api/auth/opaque/register/finish');
|
||||
// Login POST must come FIRST — silent migration only runs on a
|
||||
// session that's already been established.
|
||||
expect(urls[0]).toBe('/api/auth/login');
|
||||
});
|
||||
|
||||
it('login returns AuthResponse even when silent-migration fails (non-fatal)', async () => {
|
||||
// Login POST succeeds; the follow-up /params returns 500. Silent
|
||||
// migration swallows the error (console.warn) — the login itself
|
||||
// still returns success to the caller, so the user reaches their
|
||||
// session and the envelope gets retried on next login.
|
||||
f.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({
|
||||
user: { id: 'u1', email: 'a@x.test' },
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600
|
||||
})
|
||||
}).mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) });
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const authResponse = await auth.login('alice@example.com', 'pw');
|
||||
expect(authResponse.access_token).toBe('at');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -77,7 +77,31 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
||||
const { errorType, message } = await parseErrorBody(res);
|
||||
throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message);
|
||||
}
|
||||
return (await res.json()) as AuthResponse;
|
||||
const auth = (await res.json()) as AuthResponse;
|
||||
|
||||
// ── OPAQUE silent migration (Phase 2) ──────────────────────────────
|
||||
// After a successful legacy password login, transparently mint an
|
||||
// OPAQUE envelope for the same passphrase. Users pick up the new
|
||||
// auth path over time, one login at a time, with zero UX change —
|
||||
// by the time Phase 3 flips the SPA to OPAQUE-first and Phase 4
|
||||
// refuses legacy for migrated users, most active accounts already
|
||||
// have an envelope on file.
|
||||
//
|
||||
// The freshly-issued session cookie is already active in the
|
||||
// browser at this point (Set-Cookie from the POST response), so
|
||||
// the session-authenticated register endpoints are reachable
|
||||
// straight away. `syncOpaqueEnvelope` no-ops when OPAQUE is
|
||||
// disabled server-side (mode=off) and swallows any error — a
|
||||
// failure here just leaves the envelope stale, and the NEXT
|
||||
// legacy login retries the same hook.
|
||||
//
|
||||
// Dynamic import keeps the ~200 KiB `@serenity-kit/opaque` WASM
|
||||
// bundle out of pre-login-page bundles — it loads only for users
|
||||
// who actually reach a successful login.
|
||||
const { syncOpaqueEnvelope } = await import('$lib/api/endpoints/opaque');
|
||||
await syncOpaqueEnvelope(password);
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
export interface OidcProviders {
|
||||
|
||||
Reference in New Issue
Block a user