Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 8 additions & 3 deletions packages/api/src/controllers/user/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
)
Expand Down
61 changes: 61 additions & 0 deletions packages/api/src/models/authCodes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 8 additions & 3 deletions packages/api/src/models/authCodes.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<boolean> {
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;
}
8 changes: 4 additions & 4 deletions packages/brochureware/public/whitepaper.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A technical analysis of zero‑knowledge key delivery for OIDC

_2026-02-15_
_2026-02-16_

# DarkAuth v1 Security Whitepaper

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ const AuthenticationPage = () => {
<ul className="list-disc space-y-2 pl-5 text-base text-muted-foreground">
<li>Used by browser or SPA clients that cannot safely store secrets.</li>
<li>Calls APIs with `Authorization: Bearer &lt;token&gt;`.</li>
<li>
Uses authorization code + PKCE where codes are short-lived, single-use, and
atomically consumed at `/api/token`.
</li>
<li>Authorization is permission-driven, such as `darkauth.users:read`.</li>
<li>Best for user-delegated access where the user identity matters.</li>
</ul>
Expand Down
230 changes: 230 additions & 0 deletions packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Loading
Loading