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
39 changes: 38 additions & 1 deletion packages/api/src/controllers/token.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { InvalidRequestError, UnauthorizedClientError } from "../errors.js";
import { InvalidGrantError, InvalidRequestError, UnauthorizedClientError } from "../errors.js";
import {
assertClientSecretMatches,
assertRefreshTokenClientBinding,
buildUserIdTokenClaims,
resolveGrantedScopes,
resolveSessionClientId,
TokenRequestSchema,
} from "./user/token.js";

Expand Down Expand Up @@ -93,3 +95,38 @@ test("buildUserIdTokenClaims omits nonce when not provided", () => {

assert.equal(claims.nonce, undefined);
});

test("resolveSessionClientId returns client id for valid session data", () => {
assert.equal(resolveSessionClientId({ clientId: "demo-client" }), "demo-client");
});

test("resolveSessionClientId returns null when client id is missing", () => {
assert.equal(resolveSessionClientId({}), null);
});

test("resolveSessionClientId returns null for invalid shapes", () => {
assert.equal(resolveSessionClientId(null), null);
assert.equal(resolveSessionClientId("bad"), null);
assert.equal(resolveSessionClientId({ clientId: 42 }), null);
});

test("assertRefreshTokenClientBinding accepts matching client ids", () => {
assert.doesNotThrow(() => assertRefreshTokenClientBinding("demo-client", "demo-client"));
});

test("assertRefreshTokenClientBinding rejects mismatched client ids", () => {
assert.throws(
() => assertRefreshTokenClientBinding("demo-client", "other-client"),
(error: unknown) =>
error instanceof InvalidGrantError &&
error.message === "Refresh token was not issued to this client"
);
});

test("assertRefreshTokenClientBinding rejects missing binding", () => {
assert.throws(
() => assertRefreshTokenClientBinding(null, "demo-client"),
(error: unknown) =>
error instanceof InvalidGrantError && error.message === "Invalid or expired refresh token"
);
});
1 change: 1 addition & 0 deletions packages/api/src/controllers/user/opaqueLoginFinish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const postOpaqueLoginFinish = withRateLimit("opaque", (body) => {
sub: user.sub,
email: user.email || undefined,
name: user.name || undefined,
clientId: "demo-public-client",
otpRequired: otpRequired,
otpVerified: false,
});
Expand Down
46 changes: 31 additions & 15 deletions packages/api/src/controllers/user/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@ import { InvalidGrantError, InvalidRequestError, UnauthorizedClientError } from
import { genericErrors } from "../../http/openapi-helpers.js";
import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.js";
import { signJWT } from "../../services/jwks.js";
import {
createSession,
getActorFromRefreshToken,
refreshSessionWithToken,
} from "../../services/sessions.js";
import { createSession, refreshSessionWithToken } from "../../services/sessions.js";
import { getSetting } from "../../services/settings.js";
import type {
Context,
ControllerSchema,
IdTokenClaims,
SessionData,
TokenRequest,
TokenResponse,
} from "../../types.js";
Expand Down Expand Up @@ -96,6 +93,23 @@ export function buildUserIdTokenClaims(data: {
};
}

export function resolveSessionClientId(sessionData: unknown): string | null {
if (!sessionData || typeof sessionData !== "object") return null;
const maybeClientId = (sessionData as { clientId?: unknown }).clientId;
if (typeof maybeClientId !== "string" || maybeClientId.length === 0) return null;
return maybeClientId;
}

export function assertRefreshTokenClientBinding(
issuedClientId: string | null,
authenticatedClientId: string | undefined
): void {
if (!issuedClientId) throw new InvalidGrantError("Invalid or expired refresh token");
if (issuedClientId !== authenticatedClientId) {
throw new InvalidGrantError("Refresh token was not issued to this client");
}
}

export const TokenRequestSchema = z.union([
z.object({
grant_type: z.literal("authorization_code"),
Expand Down Expand Up @@ -187,11 +201,17 @@ export const postToken = withRateLimit("token")(
clientAuthOk = true;
}
if (!clientAuthOk) throw new UnauthorizedClientError("Client authentication failed");
const actor = await getActorFromRefreshToken(context, tokenRequest.refresh_token);
if (!actor || !actor.userSub)
const { sessions } = await import("../../db/schema.js");
const { eq } = await import("drizzle-orm");
const sessionRow = await context.db.query.sessions.findFirst({
where: eq(sessions.refreshToken, tokenRequest.refresh_token),
});
if (!sessionRow || !sessionRow.userSub)
throw new InvalidGrantError("Invalid or expired refresh token");
const issuedClientId = resolveSessionClientId(sessionRow.data);
assertRefreshTokenClientBinding(issuedClientId, providedClientId);
const { getUserBySub } = await import("../../models/users.js");
const user = await getUserBySub(context, actor.userSub);
const user = await getUserBySub(context, sessionRow.userSub);
if (!user) throw new InvalidGrantError("User not found");
if (!providedClientId) throw new UnauthorizedClientError("Client authentication failed");
const client = await (await import("../../models/clients.js")).getClient(
Expand Down Expand Up @@ -225,12 +245,7 @@ export const postToken = withRateLimit("token")(
? client.idTokenLifetimeSeconds
: defaultIdTtl;
let amr: string[] | undefined = ["pwd"];
const { sessions } = await import("../../db/schema.js");
const { eq } = await import("drizzle-orm");
const sess = await context.db.query.sessions.findFirst({
where: eq(sessions.refreshToken, tokenRequest.refresh_token),
});
const data = (sess?.data || {}) as Record<string, unknown>;
const data = (sessionRow.data || {}) as Record<string, unknown>;
if (data && data.otpVerified === true) amr = ["pwd", "otp"];
const issuer = await resolveIssuer(context);
const idTokenClaims = buildUserIdTokenClaims({
Expand Down Expand Up @@ -527,7 +542,8 @@ export const postToken = withRateLimit("token")(
sub: user.sub,
email: user.email || undefined,
name: user.name || undefined,
};
clientId: authenticatedClientId,
} satisfies SessionData;
const s = await createSession(context, "user", sessionData);
tokenResponse.refresh_token = s.refreshToken;

Expand Down
1 change: 1 addition & 0 deletions packages/api/src/models/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function userOpaqueRegisterFinish(
sub,
email: data.email,
name: data.name,
clientId: "demo-public-client",
});
return { sub, accessToken: sessionInfo.sessionId, refreshToken: sessionInfo.refreshToken };
}
1 change: 1 addition & 0 deletions packages/api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export interface SessionData {
sub?: string;
email?: string;
name?: string;
clientId?: string;
adminId?: string;
adminRole?: "read" | "write";
pendingAuthId?: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/brochureware/public/whitepaper.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Server stores
- Pending authorization requests; authorization codes with `has_zk`, `zk_pub_kid`, and `drk_hash`.
- JWKS (public JWKs; private JWKs encrypted at rest when KEK is available).
- Clients, settings (including runtime flags for OIDC/PKCE, id token TTLs, etc.).
- Sessions and refresh tokens.
- Sessions and refresh tokens, including issuing `client_id` binding for refresh token ownership.
- Audit logs (admin actions).

Server never stores
Expand All @@ -159,6 +159,7 @@ Server never stores

Sessions and claims
- SPA model: `/session` for minimal info; `/refresh-token` rotates session tokens.
- OIDC refresh grant enforces client binding: cross-client refresh token reuse is rejected.
- ID tokens may include `permissions` and `groups` claims when configured.

## 6) Endpoints (as implemented)
Expand Down
3 changes: 2 additions & 1 deletion specs/0_SECURITY_WHITEPAPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Server stores
- Pending authorization requests; authorization codes with `has_zk`, `zk_pub_kid`, and `drk_hash`.
- JWKS (public JWKs; private JWKs encrypted at rest when KEK is available).
- Clients, settings (including runtime flags for OIDC/PKCE, id token TTLs, etc.).
- Sessions and refresh tokens.
- Sessions and refresh tokens, including issuing `client_id` binding for refresh token ownership.
- Audit logs (admin actions).

Server never stores
Expand All @@ -173,6 +173,7 @@ Server never stores

Sessions and claims
- SPA model: `/session` for minimal info; `/refresh-token` rotates session tokens.
- OIDC refresh grant enforces client binding: cross-client refresh token reuse is rejected.
- ID tokens may include `permissions` and `groups` claims when configured.

## 6) Endpoints (as implemented)
Expand Down
3 changes: 2 additions & 1 deletion specs/2_CORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ Same as above **+** `&zk_pub=<base64url(JWK)>` (ephemeral ECDH public key).

- Validates code (unexpired, not consumed), PKCE (S256), client, `redirect_uri`.
- Mints `id_token` (and `access_token` if enabled by settings).
- Returns a `refresh_token` bound to the session.
- Returns a `refresh_token` bound to the session and issuing `client_id`.
- 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.
Expand Down
Loading