feat(opaque): add lookup identifier (with anti-enum)

This commit is contained in:
Edouard Vanbelle
2026-08-01 17:05:22 +02:00
parent ebe76ffac2
commit fac65a7c4d
5 changed files with 414 additions and 45 deletions
+177 -35
View File
@@ -1,8 +1,9 @@
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
// Mock the OPAQUE WASM client so `login()`'s Phase 2 silent-migration
// hook and Phase 3 lookup-then-login flip can exercise the wire path
// (params + lookup + register or ke1/ke3 handshake) without touching
// real WASM in jsdom.
vi.mock('@serenity-kit/opaque', () => ({
ready: Promise.resolve(),
@@ -15,6 +16,16 @@ vi.mock('@serenity-kit/opaque', () => ({
registrationRecord: 'RECORD-R',
exportKey: 'EK',
serverStaticPublicKey: 'SPK'
})),
startLogin: vi.fn(() => ({
clientLoginState: 'STATE-L',
startLoginRequest: 'REQ-L'
})),
finishLogin: vi.fn(() => ({
finishLoginRequest: 'REQ-F',
sessionKey: 'SK',
exportKey: 'EK',
serverStaticPublicKey: 'SPK'
}))
}
}));
@@ -66,38 +77,114 @@ it('tryRefresh returns false when the refresh fails', async () => {
await expect(auth.tryRefresh()).resolves.toBe(false);
});
// ── Phase 2: silent OPAQUE migration on legacy login ─────────────────
// ── Phase 2 + 3: OPAQUE lookup, silent migration, and login flip ─────
//
// `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
// `login()` MUST first probe the server for an OPAQUE envelope via
// `POST /api/auth/opaque/login/lookup`:
// - if the response says `hasOpaque: true` → dispatch to
// `opaqueLogin` (KE1/KE3) and skip the legacy path entirely
// (Phase 3 cutover);
// - if `false` → fall through to `POST /api/auth/login` (legacy),
// then run `syncOpaqueEnvelope(password)` to silently mint the
// envelope for the NEXT login (Phase 2 silent migration).
//
// Regression risks these tests guard:
// - The lookup POST goes missing (Phase 3 flip never fires — every
// login stays on legacy forever, defeats the OPAQUE substrate).
// - The silent-migration hook goes missing on the legacy branch
// (envelopes never get minted, so the lookup always returns
// false — same net effect as above).
// - The OPAQUE branch silently falls back to legacy on any WASM
// hiccup (would mask a wrong passphrase as a network error).
it('login flips to OPAQUE when the lookup reports hasOpaque: true', async () => {
// Order on the wire, given the current implementation:
// 1. GET /api/auth/opaque/params (via checkOpaqueAvailable → fetchOpaqueParams)
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true }
// 3. POST /api/auth/opaque/login/ke1 → { exchangeId, loginResponse }
// 4. POST /api/auth/opaque/login/ke3 → AuthResponse
// The legacy /api/auth/login POST must NOT fire on this branch.
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
enabled: true,
ciphersuiteVersion: 1,
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
})
})
.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ hasOpaque: true })
})
.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
})
.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
enabled: true,
ciphersuiteVersion: 1,
ksf: { memoryKib: 8, iterations: 1, parallelism: 1 }
user: { id: 'u1', email: 'a@x.test' },
access_token: 'at-opaque',
refresh_token: 'rt-opaque',
token_type: 'Bearer',
expires_in: 3600
})
});
const authResponse = await auth.login('alice@example.com', 'correct horse battery staple');
expect(authResponse.access_token).toBe('at-opaque');
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
expect(urls).toEqual([
'/api/auth/opaque/params',
'/api/auth/opaque/login/lookup',
'/api/auth/opaque/login/ke1',
'/api/auth/opaque/login/ke3'
]);
// Legacy MUST NOT run when we took the OPAQUE branch.
expect(urls).not.toContain('/api/auth/login');
});
it('login falls back to legacy + silent-migration when hasOpaque: false', async () => {
// Order on the wire:
// 1. GET /api/auth/opaque/params
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: false }
// 3. POST /api/auth/login → AuthResponse
// 4. POST /api/auth/opaque/register/start (params is cached — no re-fetch)
// 5. POST /api/auth/opaque/register/finish
f.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 () => ({ hasOpaque: false })
})
.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({
@@ -111,23 +198,31 @@ it('login triggers OPAQUE silent-migration on success', 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');
expect(urls).toEqual([
'/api/auth/opaque/params',
'/api/auth/opaque/login/lookup',
'/api/auth/login',
'/api/auth/opaque/register/start',
'/api/auth/opaque/register/finish'
]);
});
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.
it('login skips the OPAQUE branch entirely when the substrate is disabled', async () => {
// /params replies `enabled: false` (mode=off or misconfig). The
// lookup POST MUST NOT fire (cheap short-circuit inside
// checkOpaqueAvailable), and the legacy silent-migration hook
// MUST also short-circuit — no register/start calls either.
f.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
enabled: false,
ciphersuiteVersion: 0,
ksf: { memoryKib: 0, iterations: 0, parallelism: 0 }
})
}).mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
@@ -138,7 +233,54 @@ it('login returns AuthResponse even when silent-migration fails (non-fatal)', as
token_type: 'Bearer',
expires_in: 3600
})
}).mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) });
});
const authResponse = await auth.login('alice@example.com', 'pw');
expect(authResponse.access_token).toBe('at');
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
expect(urls).toEqual(['/api/auth/opaque/params', '/api/auth/login']);
});
it('login returns AuthResponse even when silent-migration fails (non-fatal)', async () => {
// Wire order: /params (enabled=true) → lookup (false) → legacy
// login (200) → /params is cached, skip → register/start fails
// with 500 → syncOpaqueEnvelope logs a console.warn and returns,
// login() still returns the AuthResponse to the caller.
f.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 () => ({ hasOpaque: false })
})
.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,
statusText: 'Internal Server Error',
json: async () => ({})
});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const authResponse = await auth.login('alice@example.com', 'pw');
+46 -10
View File
@@ -64,6 +64,34 @@ export async function tryRefresh(): Promise<boolean> {
}
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
// ── OPAQUE lookup (Phase 3) ────────────────────────────────────────
// Ask the server whether this identifier already has an OPAQUE
// envelope on file. If yes → use OPAQUE login (KE1/KE3). If no →
// fall back to legacy `POST /api/auth/login`, which then silently
// mints the envelope via the Phase 2 hook.
//
// Both paths return the SAME `AuthResponse` shape, so downstream
// callers don't need to know which branch fired. Dynamic import
// keeps the ~200 KiB `@serenity-kit/opaque` WASM bundle out of
// pre-login bundles — the module loads only when a login is
// actually attempted.
//
// `checkOpaqueAvailable` and `opaqueLogin` both fall back
// gracefully: any wire failure inside the OPAQUE branch (503,
// timeout, malformed response) either short-circuits to `false`
// (lookup) or throws with an `InvalidCredentials` shape (login).
// The lookup fallback lands here as `false` → legacy branch
// takes over; a mid-login OPAQUE failure surfaces as a login
// error to the user, same shape as a legacy failure — no silent
// legacy fallback there because it would mask a wrong passphrase
// as a network hiccup.
const { checkOpaqueAvailable, opaqueLogin, syncOpaqueEnvelope } =
await import('$lib/api/endpoints/opaque');
if (await checkOpaqueAvailable(emailOrUsername)) {
return await opaqueLogin(emailOrUsername, password, await opaqueKsfForClient());
}
// ── Legacy login (fallback for users without an envelope yet) ─────
const res = await apiFetch('/api/auth/login', {
method: 'POST',
credentials: 'same-origin',
@@ -81,11 +109,8 @@ export async function login(emailOrUsername: string, password: string): Promise<
// ── 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.
// OPAQUE envelope for the same passphrase. The NEXT login for this
// account will take the OPAQUE branch above.
//
// The freshly-issued session cookie is already active in the
// browser at this point (Set-Cookie from the POST response), so
@@ -94,16 +119,27 @@ export async function login(emailOrUsername: string, password: string): Promise<
// 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;
}
/**
* Fetch the server's OPAQUE KSF config to pass to `opaqueLogin`.
* Extracted so `login()` reads more linearly and the module keeps
* one `fetchOpaqueParams` call site regardless of which branch
* (lookup / login / silent-migration) reaches it first.
*/
async function opaqueKsfForClient(): Promise<{
memoryKib: number;
iterations: number;
parallelism: number;
}> {
const { fetchOpaqueParams } = await import('$lib/api/endpoints/opaque');
const params = await fetchOpaqueParams();
return params.ksf;
}
export interface OidcProviders {
enabled: boolean;
provider_name?: string;
+39
View File
@@ -112,6 +112,45 @@ export function fetchOpaqueParams(): Promise<OpaqueServerParams> {
return opaqueParamsInflight;
}
/**
* Ask the server whether `userIdentifier` resolves to a user with an
* OPAQUE envelope on file. The SPA login form calls this before
* submit to decide between OPAQUE (KE1/KE3) and legacy password
* login. The two paths converge to the same session shape, so the
* user never notices the branch.
*
* Returns `false` on any error — network hiccup, disabled substrate,
* malformed response — so callers fall back to legacy login rather
* than blocking on the OPAQUE probe. The Phase 2 silent-migration
* hook will still run after the legacy login and mint the envelope,
* so this transient "false" just delays adoption by one login cycle.
*
* The server-side shape is anti-enum: same `hasOpaque: false` for
* both "unknown user" and "user without envelope." Callers must
* never assume `hasOpaque: false` implies the user exists.
*/
export async function checkOpaqueAvailable(userIdentifier: string): Promise<boolean> {
// Cheap short-circuit: if the substrate isn't enabled server-side,
// the endpoint would return 503 anyway. `syncOpaqueEnvelope`
// primed the cache after any prior login in this session; this
// call reuses it.
const params = await fetchOpaqueParams();
if (!params.enabled) return false;
try {
const res = await apiFetch('/api/auth/opaque/login/lookup', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ userIdentifier })
});
if (!res.ok) return false;
const body = (await res.json()) as { hasOpaque?: boolean };
return body.hasOpaque === true;
} catch {
return false;
}
}
/**
* Silent OPAQUE registration after a passphrase-touching action
* (signup completion, change-password, silent migration on legacy