94e5b9e355
KSF values are stored per user, if admin change value, client will detect it and regenerate the envelop This pervent users being stuck
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
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 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(),
|
|
client: {
|
|
startRegistration: vi.fn(() => ({
|
|
clientRegistrationState: 'STATE-R',
|
|
registrationRequest: 'REQ-R'
|
|
})),
|
|
finishRegistration: vi.fn(() => ({
|
|
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'
|
|
}))
|
|
}
|
|
}));
|
|
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));
|
|
});
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
it('exercises the auth endpoints (success paths)', async () => {
|
|
await auth.fetchMe().catch(() => {});
|
|
await auth.tryRefresh().catch(() => {});
|
|
await auth.login('u', 'p').catch(() => {});
|
|
await auth.getOidcProviders().catch(() => {});
|
|
await auth.getAuthStatus().catch(() => {});
|
|
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
|
await auth.exchangeOidcCode('code').catch(() => {});
|
|
await auth.register('e@x.test', 'p', 'u').catch(() => {});
|
|
await auth.sendMagicLink('e@x.test').catch(() => {});
|
|
await auth.logout().catch(() => {});
|
|
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
expect(fc + f.mock.calls.length).toBeGreaterThan(3);
|
|
});
|
|
it('fetchMe returns null when the probe is not ok', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
|
|
);
|
|
await expect(auth.fetchMe()).resolves.toBeNull();
|
|
});
|
|
it('tryRefresh returns false when the refresh fails', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) })
|
|
);
|
|
await expect(auth.tryRefresh()).resolves.toBe(false);
|
|
});
|
|
|
|
// ── Phase 2 + 3: OPAQUE lookup, silent migration, and login flip ─────
|
|
//
|
|
// `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 (no rotation when KSF matches)', 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, ksf: <matches /params> }
|
|
// 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, AND
|
|
// Phase C's rotation MUST NOT fire because the envelope's KSF matches
|
|
// what /params publishes — nothing to rotate to.
|
|
const paramsKsf = { memoryKib: 8, iterations: 1, parallelism: 1 };
|
|
f.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({
|
|
enabled: true,
|
|
ciphersuiteVersion: 1,
|
|
ksf: paramsKsf
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({ hasOpaque: true, ksf: paramsKsf })
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({
|
|
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');
|
|
// Phase C: no rotation → no register/* calls.
|
|
expect(urls).not.toContain('/api/auth/opaque/register/start');
|
|
expect(urls).not.toContain('/api/auth/opaque/register/finish');
|
|
});
|
|
|
|
// ── Phase C: silent KSF rotation on OPAQUE login ─────────────────────
|
|
//
|
|
// `login()` MUST fire `syncOpaqueEnvelope(password)` after a successful
|
|
// OPAQUE login when the envelope's stored KSF differs from the server's
|
|
// currently-published KSF (or when the envelope predates per-envelope
|
|
// KSF storage, signalled by `lookup.ksf === null / absent`). Regression
|
|
// this guards: silently ignoring the drift would freeze users on
|
|
// whatever KSF they registered under years ago, making the operator's
|
|
// tuning-defaults knob effectively write-only for existing accounts.
|
|
|
|
it('OPAQUE login triggers silent KSF rotation when envelope KSF drifted from /params', async () => {
|
|
// Wire order:
|
|
// 1. GET /api/auth/opaque/params (via checkOpaqueAvailable — server publishes NEW KSF)
|
|
// 2. POST /api/auth/opaque/login/lookup → { hasOpaque: true, ksf: <OLD, drifted from /params> }
|
|
// 3. POST /api/auth/opaque/login/ke1 → { exchangeId, loginResponse } (uses OLD envelope KSF)
|
|
// 4. POST /api/auth/opaque/login/ke3 → AuthResponse
|
|
// 5. POST /api/auth/opaque/register/start ← rotation fires
|
|
// 6. POST /api/auth/opaque/register/finish
|
|
// After (6), the envelope is re-minted under the CURRENT /params
|
|
// KSF, so the user's next login uses the new values.
|
|
const newParamsKsf = { memoryKib: 8, iterations: 1, parallelism: 1 };
|
|
const oldEnvelopeKsf = { memoryKib: 32, iterations: 3, parallelism: 4 };
|
|
f.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({ enabled: true, ciphersuiteVersion: 1, ksf: newParamsKsf })
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({ hasOpaque: true, ksf: oldEnvelopeKsf })
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({ exchangeId: 'ex-1', loginResponse: 'LR' })
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
access_token: 'at-opaque',
|
|
refresh_token: 'rt-opaque',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
})
|
|
})
|
|
.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', 'pw');
|
|
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',
|
|
'/api/auth/opaque/register/start',
|
|
'/api/auth/opaque/register/finish'
|
|
]);
|
|
});
|
|
|
|
it('OPAQUE login triggers silent rotation when envelope predates per-envelope KSF (ksf null)', async () => {
|
|
// `lookup.ksf` is null/absent → envelope predates migration
|
|
// 20261005000000 → rotation fires so the envelope gets stored under
|
|
// the new per-envelope schema on the next go-round. Same wire
|
|
// sequence as the drift case above.
|
|
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',
|
|
// No `ksf` field at all — pre-migration envelope.
|
|
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 () => ({
|
|
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 () => ({ registrationResponse: 'RESP-R' })
|
|
})
|
|
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) });
|
|
|
|
await auth.login('alice@example.com', 'pw');
|
|
|
|
const urls = f.mock.calls.map((c: unknown[]) => c[0] as string);
|
|
expect(urls).toContain('/api/auth/opaque/register/start');
|
|
expect(urls).toContain('/api/auth/opaque/register/finish');
|
|
});
|
|
|
|
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({
|
|
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');
|
|
|
|
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/login',
|
|
'/api/auth/opaque/register/start',
|
|
'/api/auth/opaque/register/finish'
|
|
]);
|
|
});
|
|
|
|
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',
|
|
json: async () => ({
|
|
user: { id: 'u1', email: 'a@x.test' },
|
|
access_token: 'at',
|
|
refresh_token: 'rt',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600
|
|
})
|
|
});
|
|
|
|
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');
|
|
expect(authResponse.access_token).toBe('at');
|
|
consoleSpy.mockRestore();
|
|
});
|