diff --git a/package.json b/package.json index 17b2723..7d2b959 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "demodb:wipe": "npm run demodb:wipe -w @DarkAuth/demo-app", "demodb:push": "npm run demodb:push -w @DarkAuth/demo-app", "demo:dev": "concurrently \"npm run dev -w @DarkAuth/demo-app\" \"npm run server -w @DarkAuth/demo-app\"", - "clean": "rm -rf packages/api/data packages/demo-app/data ./config.yaml ../config.yaml ../../config.yaml ../../../config.yaml && echo \"Clean complete: install state removed.\"" + "clean": "rm -rf packages/api/data packages/demo-app/data ./config.yaml ../config.yaml ../../config.yaml ../../../config.yaml packages/api/config.yaml && echo \"Clean complete: install state removed.\"" }, "engines": { "node": ">=20.0.0" diff --git a/packages/api/drizzle/0008_oidc_nonce_binding.sql b/packages/api/drizzle/0008_oidc_nonce_binding.sql new file mode 100644 index 0000000..222032d --- /dev/null +++ b/packages/api/drizzle/0008_oidc_nonce_binding.sql @@ -0,0 +1,2 @@ +ALTER TABLE "pending_auth" ADD COLUMN "nonce" text;--> statement-breakpoint +ALTER TABLE "auth_codes" ADD COLUMN "nonce" text; diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 025c241..e06ebbf 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1758799980000, "tag": "0007_user_opaque_record_history", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1771192080000, + "tag": "0008_oidc_nonce_binding", + "breakpoints": true } ] } diff --git a/packages/api/src/controllers/token.test.ts b/packages/api/src/controllers/token.test.ts index 9933e64..caa6926 100644 --- a/packages/api/src/controllers/token.test.ts +++ b/packages/api/src/controllers/token.test.ts @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { InvalidRequestError, UnauthorizedClientError } from "../errors.js"; -import { assertClientSecretMatches, resolveGrantedScopes } from "./user/token.js"; +import { + assertClientSecretMatches, + buildUserIdTokenClaims, + resolveGrantedScopes, + TokenRequestSchema, +} from "./user/token.js"; test("resolveGrantedScopes returns allowed scopes when no scope is requested", () => { const allowed = ["darkauth.users:read", "darkauth.groups:read"]; @@ -45,3 +50,46 @@ test("assertClientSecretMatches throws unauthorized when decrypt fails", async ( error.message === "Client secret verification failed" ); }); + +test("TokenRequestSchema strips nonce on authorization_code grants", () => { + const parsed = TokenRequestSchema.safeParse({ + grant_type: "authorization_code", + code: "code-value", + redirect_uri: "https://client.example/callback", + nonce: "nonce-value", + }); + assert.equal(parsed.success, true); + if (!parsed.success) return; + assert.equal("nonce" in parsed.data, false); +}); + +test("buildUserIdTokenClaims includes nonce when provided", () => { + const claims = buildUserIdTokenClaims({ + issuer: "https://issuer.example", + subject: "user-sub", + audience: "client-id", + expiresAtSeconds: 200, + issuedAtSeconds: 100, + email: "user@example.com", + name: "Test User", + permissions: ["darkauth.users:read"], + groups: ["users"], + amr: ["pwd", "otp"], + nonce: "nonce-value", + }); + + assert.equal(claims.nonce, "nonce-value"); + assert.equal(claims.aud, "client-id"); +}); + +test("buildUserIdTokenClaims omits nonce when not provided", () => { + const claims = buildUserIdTokenClaims({ + issuer: "https://issuer.example", + subject: "user-sub", + audience: "client-id", + expiresAtSeconds: 200, + issuedAtSeconds: 100, + }); + + assert.equal(claims.nonce, undefined); +}); diff --git a/packages/api/src/controllers/user/authorize.ts b/packages/api/src/controllers/user/authorize.ts index eed65ae..2f25c91 100644 --- a/packages/api/src/controllers/user/authorize.ts +++ b/packages/api/src/controllers/user/authorize.ts @@ -116,6 +116,7 @@ export const getAuthorize = withRateLimit("opaque")(async function getAuthorize( clientId: authRequest.client_id, redirectUri: authRequest.redirect_uri, state: authRequest.state, + nonce: authRequest.nonce, codeChallenge: authRequest.code_challenge, codeChallengeMethod: authRequest.code_challenge_method, zkPubKid, diff --git a/packages/api/src/controllers/user/authorizeFinalize.ts b/packages/api/src/controllers/user/authorizeFinalize.ts index f64d7dc..f88ccfa 100644 --- a/packages/api/src/controllers/user/authorizeFinalize.ts +++ b/packages/api/src/controllers/user/authorizeFinalize.ts @@ -112,6 +112,7 @@ export const postAuthorizeFinalize = withRateLimit("opaque")( clientId: pendingRequest.clientId, userSub: sessionData.sub, redirectUri: pendingRequest.redirectUri, + nonce: pendingRequest.nonce, codeChallenge: pendingRequest.codeChallenge, codeChallengeMethod: pendingRequest.codeChallengeMethod || undefined, expiresAt: codeExpiresAt, diff --git a/packages/api/src/controllers/user/token.ts b/packages/api/src/controllers/user/token.ts index 220dd6f..5e64a92 100644 --- a/packages/api/src/controllers/user/token.ts +++ b/packages/api/src/controllers/user/token.ts @@ -66,6 +66,36 @@ export function resolveGrantedScopes(allowedScopes: string[], requestedScope?: s return requestedScopes.length > 0 ? requestedScopes : allowedScopes; } +export function buildUserIdTokenClaims(data: { + issuer: string; + subject: string; + audience: string; + expiresAtSeconds: number; + issuedAtSeconds: number; + email?: string | null; + name?: string | null; + permissions?: string[]; + groups?: string[]; + amr?: string[]; + nonce?: string; +}): IdTokenClaims { + return { + iss: data.issuer, + sub: data.subject, + aud: data.audience, + exp: data.expiresAtSeconds, + iat: data.issuedAtSeconds, + email: data.email || undefined, + email_verified: !!data.email, + name: data.name || undefined, + permissions: data.permissions && data.permissions.length > 0 ? data.permissions : undefined, + groups: data.groups && data.groups.length > 0 ? data.groups : undefined, + nonce: data.nonce, + acr: data.amr ? "mfa" : undefined, + amr: data.amr, + }; +} + export const TokenRequestSchema = z.union([ z.object({ grant_type: z.literal("authorization_code"), @@ -203,20 +233,18 @@ export const postToken = withRateLimit("token")( const data = (sess?.data || {}) as Record; if (data && data.otpVerified === true) amr = ["pwd", "otp"]; const issuer = await resolveIssuer(context); - const idTokenClaims: IdTokenClaims = { - iss: issuer, - sub: user.sub, - aud: providedClientId, - exp: now + idTokenTtl, - iat: now, - email: user.email || undefined, - email_verified: !!user.email, - name: user.name || undefined, - permissions: uniquePermissions.length > 0 ? uniquePermissions : undefined, - groups: groupsList.length > 0 ? groupsList : undefined, - acr: amr ? "mfa" : undefined, + const idTokenClaims = buildUserIdTokenClaims({ + issuer, + subject: user.sub, + audience: providedClientId, + expiresAtSeconds: now + idTokenTtl, + issuedAtSeconds: now, + email: user.email, + name: user.name, + permissions: uniquePermissions, + groups: groupsList, amr, - }; + }); const idToken = await signJWT( context, idTokenClaims as import("jose").JWTPayload, @@ -461,20 +489,19 @@ export const postToken = withRateLimit("token")( const data = (row?.data || {}) as Record; if (data && data.otpVerified === true) amr = ["pwd", "otp"]; const issuer = await resolveIssuer(context); - const idTokenClaims: IdTokenClaims = { - iss: issuer, - sub: user.sub, - aud: authenticatedClientId, - exp: now + idTokenTtl, - iat: now, - email: user.email || undefined, - email_verified: !!user.email, - name: user.name || undefined, - permissions: uniquePermissions.length > 0 ? uniquePermissions : undefined, - groups: groups.length > 0 ? groups : undefined, - acr: amr ? "mfa" : undefined, - amr: amr, - }; + const idTokenClaims = buildUserIdTokenClaims({ + issuer, + subject: user.sub, + audience: authenticatedClientId, + expiresAtSeconds: now + idTokenTtl, + issuedAtSeconds: now, + email: user.email, + name: user.name, + permissions: uniquePermissions, + groups, + amr, + nonce: authCode.nonce || undefined, + }); // Generate and sign ID token const idToken = await signJWT( diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 5b4f997..3bd9440 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -132,6 +132,7 @@ export const authCodes = pgTable("auth_codes", { .notNull() .references(() => users.sub, { onDelete: "cascade" }), redirectUri: text("redirect_uri").notNull(), + nonce: text("nonce"), codeChallenge: text("code_challenge"), codeChallengeMethod: text("code_challenge_method"), expiresAt: timestamp("expires_at").notNull(), @@ -174,6 +175,7 @@ export const pendingAuth = pgTable("pending_auth", { .references(() => clients.clientId, { onDelete: "cascade" }), redirectUri: text("redirect_uri").notNull(), state: text("state"), + nonce: text("nonce"), codeChallenge: text("code_challenge"), codeChallengeMethod: text("code_challenge_method"), zkPubKid: text("zk_pub_kid"), diff --git a/packages/api/src/models/authCodes.ts b/packages/api/src/models/authCodes.ts index 5678a0c..0a74837 100644 --- a/packages/api/src/models/authCodes.ts +++ b/packages/api/src/models/authCodes.ts @@ -17,6 +17,7 @@ export async function createAuthCode( clientId: string; userSub: string; redirectUri: string; + nonce?: string | null; codeChallenge?: string | null; codeChallengeMethod?: string | null; expiresAt: Date; @@ -30,6 +31,7 @@ export async function createAuthCode( clientId: data.clientId, userSub: data.userSub, redirectUri: data.redirectUri, + nonce: data.nonce ?? null, codeChallenge: data.codeChallenge ?? null, codeChallengeMethod: data.codeChallengeMethod ?? null, expiresAt: data.expiresAt, diff --git a/packages/api/src/models/authorize.ts b/packages/api/src/models/authorize.ts index 99d32a6..9e3f396 100644 --- a/packages/api/src/models/authorize.ts +++ b/packages/api/src/models/authorize.ts @@ -8,6 +8,7 @@ export async function createPendingAuth( clientId: string; redirectUri: string; state?: string; + nonce?: string; codeChallenge?: string; codeChallengeMethod?: string; zkPubKid?: string; @@ -21,6 +22,7 @@ export async function createPendingAuth( clientId: data.clientId, redirectUri: data.redirectUri, state: data.state, + nonce: data.nonce, codeChallenge: data.codeChallenge, codeChallengeMethod: data.codeChallengeMethod, zkPubKid: data.zkPubKid, diff --git a/packages/api/src/models/noncePersistence.test.ts b/packages/api/src/models/noncePersistence.test.ts new file mode 100644 index 0000000..a155e58 --- /dev/null +++ b/packages/api/src/models/noncePersistence.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Context } from "../types.js"; +import { createAuthCode } from "./authCodes.js"; +import { createPendingAuth } from "./authorize.js"; + +test("createPendingAuth stores nonce in pending auth record", async () => { + let insertedValues: Record | undefined; + + const context = { + db: { + insert: () => ({ + values: (values: Record) => { + insertedValues = values; + return Promise.resolve(); + }, + }), + }, + } as unknown as Context; + + const result = await createPendingAuth(context, { + requestId: "request-id", + clientId: "client-id", + redirectUri: "https://client.example/callback", + state: "state-value", + nonce: "nonce-value", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + origin: "https://issuer.example", + expiresAt: new Date("2026-02-15T00:00:00.000Z"), + }); + + assert.equal(result.requestId, "request-id"); + assert.equal(insertedValues?.nonce, "nonce-value"); +}); + +test("createAuthCode stores nonce in auth code record", async () => { + let insertedValues: Record | undefined; + + const context = { + db: { + insert: () => ({ + values: (values: Record) => { + insertedValues = values; + return Promise.resolve(); + }, + }), + }, + } as unknown as Context; + + await createAuthCode(context, { + code: "code-value", + clientId: "client-id", + userSub: "user-sub", + redirectUri: "https://client.example/callback", + nonce: "nonce-value", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + expiresAt: new Date("2026-02-15T00:00:00.000Z"), + }); + + assert.equal(insertedValues?.nonce, "nonce-value"); +}); 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 new file mode 100644 index 0000000..b92236f --- /dev/null +++ b/packages/test-suite/tests/api/oidc-nonce-code-flow.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from '@playwright/test' +import { createTestServers, destroyTestServers, type TestServers } from '../../setup/server.js' +import { installDarkAuth } from '../../setup/install.js' +import { FIXED_TEST_ADMIN } from '../../fixtures/testData.js' +import { createUserViaAdmin, getAdminBearerToken } from '../../setup/helpers/auth.js' +import { OpaqueClient } from '@DarkAuth/api/src/lib/opaque/opaque-ts-wrapper.ts' +import { toBase64Url, fromBase64Url, sha256Base64Url } from '@DarkAuth/api/src/utils/crypto.ts' + +async function opaqueLoginFinish(userUrl: string, email: string, password: string) { + const client = new OpaqueClient() + await client.initialize() + const start = await client.startLogin(password, email) + const resStart = await fetch(`${userUrl}/api/user/opaque/login/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: userUrl }, + body: JSON.stringify({ email, request: toBase64Url(Buffer.from(start.request)) }) + }) + expect(resStart.ok).toBeTruthy() + const startJson = await resStart.json() as { message: string; sessionId: string } + const finish = await client.finishLogin( + fromBase64Url(startJson.message), + start.state, + new Uint8Array(), + 'DarkAuth', + email + ) + const resFinish = await fetch(`${userUrl}/api/user/opaque/login/finish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: userUrl }, + body: JSON.stringify({ finish: toBase64Url(Buffer.from(finish.finish)), sessionId: startJson.sessionId }) + }) + expect(resFinish.ok).toBeTruthy() + return await resFinish.json() as { accessToken: string; refreshToken: string } +} + +function decodeJwtPayload(token: string): Record { + const parts = token.split('.') + if (parts.length !== 3 || !parts[1]) throw new Error('Invalid JWT format') + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record +} + +async function createZkPub(): Promise { + const pair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ) + const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey) + return Buffer.from(JSON.stringify(publicJwk)).toString('base64url') +} + +test.describe('API - OIDC nonce auth code flow', () => { + test.describe.configure({ mode: 'serial' }) + + let servers: TestServers + + test.beforeAll(async () => { + servers = await createTestServers({ testName: 'api-oidc-nonce-auth-code-flow' }) + await installDarkAuth({ + adminUrl: servers.adminUrl, + adminEmail: FIXED_TEST_ADMIN.email, + adminName: FIXED_TEST_ADMIN.name, + adminPassword: FIXED_TEST_ADMIN.password, + installToken: 'test-install-token' + }) + }) + + test.afterAll(async () => { + if (servers) await destroyTestServers(servers) + }) + + test('stores nonce from authorize request and emits it in id_token', async () => { + const user = { + email: `nonce-flow-${Date.now()}@example.com`, + name: 'Nonce Flow 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; state?: string; redirect_uri: string } + expect(finalizeJson.code).toBeTruthy() + expect(finalizeJson.state).toBe(state) + expect(finalizeJson.redirect_uri).toBe(redirectUri) + + const tokenRes = await fetch(`${servers.userUrl}/api/user/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: servers.userUrl + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: finalizeJson.code, + redirect_uri: redirectUri, + client_id: 'demo-public-client', + code_verifier: codeVerifier, + nonce: 'attacker-controlled-nonce' + }) + }) + + if (!tokenRes.ok) { + const errorBody = await tokenRes.text() + throw new Error(`token failed: ${tokenRes.status} ${errorBody}`) + } + const tokenJson = await tokenRes.json() as { id_token: string } + const claims = decodeJwtPayload(tokenJson.id_token) + + expect(claims.nonce).toBe(nonce) + }) +})