feat(DPoP): bing ceremony on login

This commit is contained in:
Edouard Vanbelle
2026-08-08 14:15:26 +02:00
parent fed265c70f
commit 8b79e26329
10 changed files with 394 additions and 11 deletions
+64 -2
View File
@@ -63,7 +63,65 @@ export async function tryRefresh(): Promise<boolean> {
}
}
/**
* Compute a DPoP JWK thumbprint for the current browser keypair, if
* WebCrypto + IndexedDB are available. Returned to callers as an
* optional string; a `null` return means the browser can't support
* DPoP, and login proceeds unbound (see `docs/plan/dpop.md` — fail-
* open per-session, immutable so bound sessions can't be downgraded).
*
* Any failure is swallowed to `null` — a DPoP hiccup MUST NOT block
* authentication. The unbound session is still functional, just not
* DPoP-protected against info-stealer replay.
*/
async function tryBindingThumbprint(): Promise<string | null> {
try {
const { ensureKeypair, computeJkt } = await import('$lib/auth/dpop');
const kp = await ensureKeypair();
return await computeJkt(kp.publicKey);
} catch (err) {
console.debug('dpop: keypair unavailable, logging in unbound', err);
return null;
}
}
/**
* Post-redirect DPoP bind — for OIDC callback and magic-link
* redemption pages, where the session was created before the SPA had
* a chance to send its thumbprint in the login body. Call once,
* fire-and-forget style: any failure (409 already bound, 400
* malformed, network error) is swallowed to `false` — the session
* either was already bound (no harm) or can't be bound now (fail-
* open per plan).
*
* Returns `true` when the bind succeeded, `false` otherwise. Callers
* typically ignore the return value; they might log it in dev.
*/
export async function bindDpopIfPossible(): Promise<boolean> {
const jkt = await tryBindingThumbprint();
if (!jkt) return false;
try {
const res = await apiFetch('/api/auth/dpop/bind', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ dpop_jkt: jkt })
});
return res.ok;
} catch (err) {
console.debug('dpop: bind endpoint call failed', err);
return false;
}
}
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
// Compute DPoP JKT ONCE per login attempt so both branches
// (OPAQUE and legacy) send the same value. Null = browser can't
// support DPoP (missing SubtleCrypto / IndexedDB / secure context)
// or a transient failure; the server accepts absent → session
// created unbound.
const dpopJkt = await tryBindingThumbprint();
// ── 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 →
@@ -98,7 +156,7 @@ export async function login(emailOrUsername: string, password: string): Promise<
// per-envelope KSF storage (`ksf === null`), which preserves
// the pre-migration behaviour.
const ksf = lookup.ksf ?? (await opaqueKsfForClient());
const auth = await opaqueLogin(emailOrUsername, password, ksf);
const auth = await opaqueLogin(emailOrUsername, password, ksf, dpopJkt);
// Phase C: silent KSF rotation. If this envelope's KSF drifted
// from what the server currently publishes (operator retuned
@@ -131,7 +189,11 @@ export async function login(emailOrUsername: string, password: string): Promise<
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username: emailOrUsername, password })
body: JSON.stringify({
username: emailOrUsername,
password,
...(dpopJkt ? { dpop_jkt: dpopJkt } : {})
})
});
if (!res.ok) {
// Surface the backend `error_type` so the login page can offer
@@ -167,7 +167,7 @@ describe('opaqueLogin', () => {
expires_in: 3600
})
);
const auth = await opaqueLogin('alice@example.com', 'pw', KSF);
const auth = await opaqueLogin('alice@example.com', 'pw', KSF, null);
expect(auth.access_token).toBe('at');
const [ke1Url, ke1Init] = f.mock.calls[0];
@@ -192,7 +192,7 @@ describe('opaqueLogin', () => {
// requires both paths look identical to the caller.
fin.mockReturnValueOnce(undefined);
f.mockResolvedValueOnce(okJson({ exchangeId: 'XID', loginResponse: 'RESP-L' }));
await expect(opaqueLogin('a@x.test', 'wrong', KSF)).rejects.toMatchObject({
await expect(opaqueLogin('a@x.test', 'wrong', KSF, null)).rejects.toMatchObject({
status: 401,
errorType: 'InvalidCredentials'
});
@@ -201,7 +201,7 @@ describe('opaqueLogin', () => {
it('bubbles up the server error_type on KE1 failure', async () => {
f.mockResolvedValueOnce(errJson(429, { error_type: 'RateLimited', message: 'slow down' }));
const err = await opaqueLogin('a@x.test', 'pw', KSF).then(
const err = await opaqueLogin('a@x.test', 'pw', KSF, null).then(
() => null,
(e) => e
);
+13 -2
View File
@@ -349,7 +349,14 @@ export async function opaqueRegister(
export async function opaqueLogin(
userIdentifier: string,
password: string,
ksf: OpaqueKsfConfig
ksf: OpaqueKsfConfig,
/**
* DPoP JWK thumbprint (RFC 7638) to bind the resulting session to
* this browser's keypair. `null` → session created unbound (fail-
* open per `docs/plan/dpop.md`; the caller in `endpoints/auth.ts`
* already tried to compute the thumbprint and swallowed failures).
*/
dpopJkt: string | null
): Promise<AuthResponse> {
const client = await opaqueWasm();
@@ -402,7 +409,11 @@ export async function opaqueLogin(
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ exchangeId, finishLoginRequest })
body: JSON.stringify({
exchangeId,
finishLoginRequest,
...(dpopJkt ? { dpopJkt } : {})
})
});
if (!ke3Res.ok) {
const { errorType, message } = await parseErrorBody(ke3Res);