feat(opaque): Silent migration on legacy login
This commit is contained in:
@@ -1,14 +1,37 @@
|
|||||||
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
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 { apiFetch, apiJson } from '$lib/api/client';
|
||||||
import * as auth from './auth';
|
import * as auth from './auth';
|
||||||
|
import { __resetOpaqueParamsCache } from './opaque';
|
||||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
const j = apiJson 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.
|
// Several auth probes use the raw global fetch (NOT apiFetch) on purpose.
|
||||||
const okRes = { ok: true, status: 200, json: async () => ({}) };
|
const okRes = { ok: true, status: 200, json: async () => ({}) };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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);
|
f.mockResolvedValue(okRes);
|
||||||
j.mockResolvedValue({});
|
j.mockResolvedValue({});
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okRes));
|
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);
|
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);
|
const { errorType, message } = await parseErrorBody(res);
|
||||||
throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message);
|
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 {
|
export interface OidcProviders {
|
||||||
|
|||||||
Reference in New Issue
Block a user