From a6150b6dc3caad1b143db4d0a92d6a0eacd8bf6f Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 16 Feb 2026 22:41:52 +0000 Subject: [PATCH 1/3] test(api,test-suite): cover auth code atomic consume and redemption races --- packages/api/src/models/authCodes.test.ts | 61 +++++ .../tests/api/oidc-nonce-code-flow.spec.ts | 230 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 packages/api/src/models/authCodes.test.ts diff --git a/packages/api/src/models/authCodes.test.ts b/packages/api/src/models/authCodes.test.ts new file mode 100644 index 0000000..40098a0 --- /dev/null +++ b/packages/api/src/models/authCodes.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "../types.js"; +import { consumeAuthCode } from "./authCodes.js"; + +test("consumeAuthCode returns true when an unconsumed code is atomically updated", async () => { + let updateCalled = false; + let setCalled = false; + let whereCalled = false; + let returningCalled = false; + + const context = { + db: { + update: () => { + updateCalled = true; + return { + set: () => { + setCalled = true; + return { + where: () => { + whereCalled = true; + return { + returning: async () => { + returningCalled = true; + return [{ code: "code-1" }]; + }, + }; + }, + }; + }, + }; + }, + }, + } as unknown as Context; + + const consumed = await consumeAuthCode(context, "code-1"); + + assert.equal(consumed, true); + assert.equal(updateCalled, true); + assert.equal(setCalled, true); + assert.equal(whereCalled, true); + assert.equal(returningCalled, true); +}); + +test("consumeAuthCode returns false when code was already consumed by another request", async () => { + const context = { + db: { + update: () => ({ + set: () => ({ + where: () => ({ + returning: async () => [], + }), + }), + }), + }, + } as unknown as Context; + + const consumed = await consumeAuthCode(context, "code-1"); + + assert.equal(consumed, false); +}); diff --git a/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts b/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts index b92236f..7ee95e5 100644 --- a/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts +++ b/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts @@ -172,4 +172,234 @@ test.describe('API - OIDC nonce auth code flow', () => { expect(claims.nonce).toBe(nonce) }) + + test('authorization code can only be redeemed once under concurrent requests', async () => { + const user = { + email: `nonce-race-${Date.now()}@example.com`, + name: 'Nonce Race User', + password: 'Passw0rd!123' + } + + await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user) + const loginResult = await opaqueLoginFinish(servers.userUrl, user.email, user.password) + const adminToken = await getAdminBearerToken(servers, { + email: FIXED_TEST_ADMIN.email, + password: FIXED_TEST_ADMIN.password + }) + const clientsRes = await fetch(`${servers.adminUrl}/admin/clients`, { + headers: { + Authorization: `Bearer ${adminToken}`, + Origin: servers.adminUrl + } + }) + expect(clientsRes.ok).toBeTruthy() + const clientsJson = await clientsRes.json() as { + clients: Array<{ clientId: string; redirectUris: string[] }> + } + const publicClient = clientsJson.clients.find((client) => client.clientId === 'demo-public-client') + expect(publicClient).toBeTruthy() + const redirectUri = publicClient?.redirectUris?.[0] + expect(redirectUri).toBeTruthy() + if (!redirectUri) throw new Error('demo-public-client is missing redirect URI') + + const nonce = `nonce-${Date.now().toString(36)}` + const state = `state-${Date.now().toString(36)}` + const codeVerifier = Buffer.from(crypto.getRandomValues(new Uint8Array(48))).toString('base64url') + const codeChallenge = sha256Base64Url(codeVerifier) + const zkPub = await createZkPub() + + const authorizeRes = await fetch(`${servers.userUrl}/api/user/authorize?${new URLSearchParams({ + client_id: 'demo-public-client', + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + zk_pub: zkPub + }).toString()}`, { + method: 'GET', + redirect: 'manual' + }) + + if (authorizeRes.status !== 302) { + const errorBody = await authorizeRes.text() + throw new Error(`authorize failed: ${authorizeRes.status} ${errorBody}`) + } + const location = authorizeRes.headers.get('location') + expect(location).toBeTruthy() + if (!location) throw new Error('Missing authorize redirect location') + const authorizeRedirectUrl = new URL(location, servers.userUrl) + const requestId = authorizeRedirectUrl.searchParams.get('request_id') + expect(requestId).toBeTruthy() + if (!requestId) throw new Error('Missing request_id') + + const finalizeRes = await fetch(`${servers.userUrl}/api/user/authorize/finalize`, { + method: 'POST', + headers: { + Authorization: `Bearer ${loginResult.accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl + }, + body: new URLSearchParams({ request_id: requestId }) + }) + + expect(finalizeRes.ok).toBeTruthy() + const finalizeJson = await finalizeRes.json() as { code: string; redirect_uri: string } + expect(finalizeJson.code).toBeTruthy() + expect(finalizeJson.redirect_uri).toBe(redirectUri) + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: finalizeJson.code, + redirect_uri: redirectUri, + client_id: 'demo-public-client', + code_verifier: codeVerifier + }) + + const [firstTokenRes, secondTokenRes] = await Promise.all([ + fetch(`${servers.userUrl}/api/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl + }, + body + }), + fetch(`${servers.userUrl}/api/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl + }, + body: new URLSearchParams(body) + }) + ]) + + const successCount = [firstTokenRes, secondTokenRes].filter((res) => res.ok).length + expect(successCount).toBe(1) + + const failedResponse = firstTokenRes.ok ? secondTokenRes : firstTokenRes + expect(failedResponse.status).toBe(400) + const failedBody = await failedResponse.json() as { error?: string } + expect(failedBody.error).toBe('invalid_grant') + }) + + test('confidential client authorization code can only be redeemed once under concurrent requests', async () => { + const user = { + email: `nonce-race-confidential-${Date.now()}@example.com`, + name: 'Nonce Race Confidential User', + password: 'Passw0rd!123' + } + + await createUserViaAdmin(servers, { email: FIXED_TEST_ADMIN.email, password: FIXED_TEST_ADMIN.password }, user) + const loginResult = await opaqueLoginFinish(servers.userUrl, user.email, user.password) + const adminToken = await getAdminBearerToken(servers, { + email: FIXED_TEST_ADMIN.email, + password: FIXED_TEST_ADMIN.password + }) + const secretRes = await fetch(`${servers.adminUrl}/admin/clients/demo-confidential-client/secret`, { + headers: { + Authorization: `Bearer ${adminToken}`, + Origin: servers.adminUrl + } + }) + expect(secretRes.ok).toBeTruthy() + const secretJson = await secretRes.json() as { clientSecret: string | null } + if (!secretJson.clientSecret) throw new Error('demo-confidential-client secret missing') + + const clientsRes = await fetch(`${servers.adminUrl}/admin/clients`, { + headers: { + Authorization: `Bearer ${adminToken}`, + Origin: servers.adminUrl + } + }) + expect(clientsRes.ok).toBeTruthy() + const clientsJson = await clientsRes.json() as { + clients: Array<{ clientId: string; redirectUris: string[] }> + } + const confidentialClient = clientsJson.clients.find((client) => client.clientId === 'demo-confidential-client') + expect(confidentialClient).toBeTruthy() + const redirectUri = confidentialClient?.redirectUris?.[0] + expect(redirectUri).toBeTruthy() + if (!redirectUri) throw new Error('demo-confidential-client is missing redirect URI') + + const state = `state-${Date.now().toString(36)}` + const nonce = `nonce-${Date.now().toString(36)}` + const authorizeRes = await fetch(`${servers.userUrl}/api/user/authorize?${new URLSearchParams({ + client_id: 'demo-confidential-client', + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile', + state, + nonce + }).toString()}`, { + method: 'GET', + redirect: 'manual' + }) + + if (authorizeRes.status !== 302) { + const errorBody = await authorizeRes.text() + throw new Error(`authorize failed: ${authorizeRes.status} ${errorBody}`) + } + const location = authorizeRes.headers.get('location') + expect(location).toBeTruthy() + if (!location) throw new Error('Missing authorize redirect location') + const authorizeRedirectUrl = new URL(location, servers.userUrl) + const requestId = authorizeRedirectUrl.searchParams.get('request_id') + expect(requestId).toBeTruthy() + if (!requestId) throw new Error('Missing request_id') + + const finalizeRes = await fetch(`${servers.userUrl}/api/user/authorize/finalize`, { + method: 'POST', + headers: { + Authorization: `Bearer ${loginResult.accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl + }, + body: new URLSearchParams({ request_id: requestId }) + }) + + expect(finalizeRes.ok).toBeTruthy() + const finalizeJson = await finalizeRes.json() as { code: string; redirect_uri: string } + expect(finalizeJson.code).toBeTruthy() + expect(finalizeJson.redirect_uri).toBe(redirectUri) + + const authorization = `Basic ${Buffer.from(`demo-confidential-client:${secretJson.clientSecret}`).toString('base64')}` + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code', + code: finalizeJson.code, + redirect_uri: redirectUri + }) + + const [firstTokenRes, secondTokenRes] = await Promise.all([ + fetch(`${servers.userUrl}/api/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl, + Authorization: authorization + }, + body: tokenBody + }), + fetch(`${servers.userUrl}/api/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl, + Authorization: authorization + }, + body: new URLSearchParams(tokenBody) + }) + ]) + + const successCount = [firstTokenRes, secondTokenRes].filter((res) => res.ok).length + expect(successCount).toBe(1) + + const failedResponse = firstTokenRes.ok ? secondTokenRes : firstTokenRes + expect(failedResponse.status).toBe(400) + const failedBody = await failedResponse.json() as { error?: string } + expect(failedBody.error).toBe('invalid_grant') + }) }) From c0fb8677dbbbe0aaff50e4a240aed9997eb525e4 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 16 Feb 2026 22:42:00 +0000 Subject: [PATCH 2/3] fix(api): enforce atomic single-use authorization code redemption --- packages/api/src/controllers/user/token.ts | 11 ++++++++--- packages/api/src/models/authCodes.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/api/src/controllers/user/token.ts b/packages/api/src/controllers/user/token.ts index a00edc9..2201200 100644 --- a/packages/api/src/controllers/user/token.ts +++ b/packages/api/src/controllers/user/token.ts @@ -475,6 +475,14 @@ export const postToken = withRateLimit("token")( const uniquePermissions = access.permissions; const groups = access.groupsList; + const codeConsumed = await (await import("../../models/authCodes.js")).consumeAuthCode( + context, + tokenRequest.code + ); + if (!codeConsumed) { + throw new InvalidGrantError("Authorization code has already been used"); + } + // Create ID token claims const now = Math.floor(Date.now() / 1000); let defaultIdTtl = 300; @@ -547,9 +555,6 @@ export const postToken = withRateLimit("token")( const s = await createSession(context, "user", sessionData); tokenResponse.refresh_token = s.refreshToken; - // Consume the authorization code (mark as used) - await (await import("../../models/authCodes.js")).consumeAuthCode(context, tokenRequest.code); - sendJson(response, 200, tokenResponse); } ) diff --git a/packages/api/src/models/authCodes.ts b/packages/api/src/models/authCodes.ts index 0a74837..1e2b2db 100644 --- a/packages/api/src/models/authCodes.ts +++ b/packages/api/src/models/authCodes.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { authCodes } from "../db/schema.js"; import type { Context } from "../types.js"; @@ -43,6 +43,11 @@ export async function createAuthCode( }); } -export async function consumeAuthCode(context: Context, code: string) { - await context.db.update(authCodes).set({ consumed: true }).where(eq(authCodes.code, code)); +export async function consumeAuthCode(context: Context, code: string): Promise { + const consumedRows = await context.db + .update(authCodes) + .set({ consumed: true }) + .where(and(eq(authCodes.code, code), eq(authCodes.consumed, false))) + .returning(); + return consumedRows.length > 0; } From 235a2607dd1d398c09fc1c49da30f5c0ff90183c Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 16 Feb 2026 22:42:04 +0000 Subject: [PATCH 3/3] docs(specs,brochureware): document atomic auth code redemption --- README.md | 2 ++ packages/brochureware/public/whitepaper.generated.md | 8 ++++---- .../pages/docs/developers/client-apis/Authentication.tsx | 4 ++++ specs/0_OIDC_ZK_EXTENSION.md | 2 +- specs/0_SECURITY_WHITEPAPER.md | 6 +++--- specs/2_CORE.md | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4129652..81e831a 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ rpId: "localhost" # Relying party identifier - `POST /api/authorize/finalize` - Complete authorization (internal) - `POST /api/token` - Token exchange endpoint +Authorization codes are short-lived and single-use. Redemption at the token endpoint is enforced atomically so concurrent redemption attempts cannot both succeed. + ### OPAQUE Authentication - `POST /api/opaque/register/start` - `POST /api/opaque/register/finish` diff --git a/packages/brochureware/public/whitepaper.generated.md b/packages/brochureware/public/whitepaper.generated.md index cd01528..6c80474 100644 --- a/packages/brochureware/public/whitepaper.generated.md +++ b/packages/brochureware/public/whitepaper.generated.md @@ -2,7 +2,7 @@ A technical analysis of zero‑knowledge key delivery for OIDC -_2026-02-15_ +_2026-02-16_ # DarkAuth v1 Security Whitepaper @@ -123,7 +123,7 @@ Implementation note Sequence notes - The server NEVER stores or returns the DRK JWE; only the hash is stored/returned. The fragment is visible only to the browser/app. -- Code TTL is ≤ 60s and codes are single‑use. +- Code TTL is ≤ 60s and codes are single‑use. Code redemption is consumed atomically at the token endpoint using a compare-and-set update, so concurrent redemption attempts cannot both succeed. Standard authorization code flow (Mermaid) @@ -186,7 +186,7 @@ Admin (port 9081) — highlights Constraints - PKCE S256 required for public clients and when configured. -- Code TTL ≤ 60s; single‑use. +- Code TTL ≤ 60s; single‑use with atomic consume at token redemption. - ZK delivery is opt‑in per client; `zk_pub` accepted only when `zk_delivery='fragment-jwe'`. - Discovery returns authoritative absolute URLs; clients SHOULD use discovery rather than hard‑coding paths. @@ -225,7 +225,7 @@ Attacks and mitigations - Insider reads: No plaintext passwords/DRK stored; KEK encrypts private JWKs and client secrets. - Redirect tampering: Clients verify `zk_drk_hash` before using `drk_jwe`. - Weak key injection: Strict zk_pub validation and rejection policy. -- Token endpoint abuse: PKCE S256 for public clients; codes are short‑lived and single‑use. +- Token endpoint abuse: PKCE S256 for public clients; codes are short‑lived and single‑use with atomic consume semantics at redemption time. Out of scope - Compromised user devices or RP apps mishandling decrypted DRK. diff --git a/packages/brochureware/src/pages/docs/developers/client-apis/Authentication.tsx b/packages/brochureware/src/pages/docs/developers/client-apis/Authentication.tsx index 558dde3..13708b8 100644 --- a/packages/brochureware/src/pages/docs/developers/client-apis/Authentication.tsx +++ b/packages/brochureware/src/pages/docs/developers/client-apis/Authentication.tsx @@ -39,6 +39,10 @@ const AuthenticationPage = () => {
  • Used by browser or SPA clients that cannot safely store secrets.
  • Calls APIs with `Authorization: Bearer <token>`.
  • +
  • + Uses authorization code + PKCE where codes are short-lived, single-use, and + atomically consumed at `/api/token`. +
  • Authorization is permission-driven, such as `darkauth.users:read`.
  • Best for user-delegated access where the user identity matters.
diff --git a/specs/0_OIDC_ZK_EXTENSION.md b/specs/0_OIDC_ZK_EXTENSION.md index f2a19cf..bdf1954 100644 --- a/specs/0_OIDC_ZK_EXTENSION.md +++ b/specs/0_OIDC_ZK_EXTENSION.md @@ -113,7 +113,7 @@ Notational Conventions - The fragment JWE is never seen by the AS; it is produced client‑side and transmitted only via URL fragment. - Binding and replay: - drk_hash binds the out‑of‑band fragment to the authorization code. Clients MUST verify the hash prior to decryption. - - Authorization codes MUST be single‑use and short‑lived (≤ 60 s). PKCE S256 MUST be enforced for public clients and SHOULD be enforced generally. +- Authorization codes MUST be single‑use and short‑lived (≤ 60 s), and code consumption at redemption MUST be atomic to prevent concurrent double redemption. PKCE S256 MUST be enforced for public clients and SHOULD be enforced generally. - Downgrade resilience: - Servers MUST only honor zk_pub for clients registered with zk_delivery = "fragment-jwe". - Servers MUST ignore or reject zk_pub for non‑ZK clients. diff --git a/specs/0_SECURITY_WHITEPAPER.md b/specs/0_SECURITY_WHITEPAPER.md index 70b978f..63a6de6 100644 --- a/specs/0_SECURITY_WHITEPAPER.md +++ b/specs/0_SECURITY_WHITEPAPER.md @@ -117,7 +117,7 @@ Implementation note Sequence notes - The server NEVER stores or returns the DRK JWE; only the hash is stored/returned. The fragment is visible only to the browser/app. -- Code TTL is ≤ 60s and codes are single‑use. +- Code TTL is ≤ 60s and codes are single‑use. Code redemption is consumed atomically at the token endpoint using a compare-and-set update, so concurrent redemption attempts cannot both succeed. Standard authorization code flow (Mermaid) @@ -200,7 +200,7 @@ Admin (port 9081) — highlights Constraints - PKCE S256 required for public clients and when configured. -- Code TTL ≤ 60s; single‑use. +- Code TTL ≤ 60s; single‑use with atomic consume at token redemption. - ZK delivery is opt‑in per client; `zk_pub` accepted only when `zk_delivery='fragment-jwe'`. - Discovery returns authoritative absolute URLs; clients SHOULD use discovery rather than hard‑coding paths. @@ -239,7 +239,7 @@ Attacks and mitigations - Insider reads: No plaintext passwords/DRK stored; KEK encrypts private JWKs and client secrets. - Redirect tampering: Clients verify `zk_drk_hash` before using `drk_jwe`. - Weak key injection: Strict zk_pub validation and rejection policy. -- Token endpoint abuse: PKCE S256 for public clients; codes are short‑lived and single‑use. +- Token endpoint abuse: PKCE S256 for public clients; codes are short‑lived and single‑use with atomic consume semantics at redemption time. Out of scope - Compromised user devices or RP apps mishandling decrypted DRK. diff --git a/specs/2_CORE.md b/specs/2_CORE.md index 8f07c5e..27518b2 100644 --- a/specs/2_CORE.md +++ b/specs/2_CORE.md @@ -228,7 +228,7 @@ Same as above **+** `&zk_pub=` (ephemeral ECDH public key). - Refresh grant enforces `client_id` binding: only the original client can rotate that refresh token. - Optionally includes user authorization data as custom claims when configured: `permissions` (array of strings) and `groups` (array of strings). These reflect the union of direct user permissions and permissions derived from groups. - **SECURITY**: If the code had `has_zk=true`, include ONLY `zk_drk_hash` for verification. Server NEVER returns `zk_drk_jwe` as it doesn't store it. -- Consume the code. +- Atomically consume the code so concurrent redemption attempts cannot both succeed. ### 5.4 App behavior (ZK vs Standard)