Skip to content
Merged
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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/api/drizzle/0008_oidc_nonce_binding.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "pending_auth" ADD COLUMN "nonce" text;--> statement-breakpoint
ALTER TABLE "auth_codes" ADD COLUMN "nonce" text;
7 changes: 7 additions & 0 deletions packages/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
50 changes: 49 additions & 1 deletion packages/api/src/controllers/token.test.ts
Original file line number Diff line number Diff line change
@@ -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"];
Expand Down Expand Up @@ -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);
});
1 change: 1 addition & 0 deletions packages/api/src/controllers/user/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/controllers/user/authorizeFinalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 54 additions & 27 deletions packages/api/src/controllers/user/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -203,20 +233,18 @@ export const postToken = withRateLimit("token")(
const data = (sess?.data || {}) as Record<string, unknown>;
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,
Expand Down Expand Up @@ -461,20 +489,19 @@ export const postToken = withRateLimit("token")(
const data = (row?.data || {}) as Record<string, unknown>;
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(
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/models/authCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/models/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export async function createPendingAuth(
clientId: string;
redirectUri: string;
state?: string;
nonce?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
zkPubKid?: string;
Expand All @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions packages/api/src/models/noncePersistence.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined;

const context = {
db: {
insert: () => ({
values: (values: Record<string, unknown>) => {
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<string, unknown> | undefined;

const context = {
db: {
insert: () => ({
values: (values: Record<string, unknown>) => {
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");
});
Loading
Loading