feat(ciba): add buildAccessTokenClaims hook for custom token claims

Allow consumers to inject custom claims into CIBA access tokens via a
new buildAccessTokenClaims option. The hook receives the full CibaRequest
before the row is deleted, fixing the timing issue where custom claim
builders could not access request data at token issuance time.

Reorders the grant handler: build claims → delete row → issue tokens.
This commit is contained in:
Gustavo Valverde
2026-03-21 11:18:00 +00:00
parent c0d5aaf107
commit 5bf68bf861
4 changed files with 178 additions and 3 deletions
+153
View File
@@ -638,6 +638,159 @@ describe("ciba", async () => {
});
});
describe("buildAccessTokenClaims hook", () => {
it("should call the hook with cibaRequest data", async () => {
const hookCalls: Array<{ scope: string; userId: string }> = [];
const {
auth: hookAuth,
signInWithTestUser: hookSignIn,
customFetchImpl: hookFetch,
} = await getTestInstance({
baseURL,
plugins: [
jwt({ jwt: { issuer: baseURL } }),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
ciba({
sendNotification: async () => {},
buildAccessTokenClaims: (cibaRequest) => {
hookCalls.push({
scope: cibaRequest.scope,
userId: cibaRequest.userId,
});
return { aap: { level: "high" } };
},
}),
],
});
const { headers: hookHeaders } = await hookSignIn();
const hookClient = await hookAuth.api.adminCreateOAuthClient({
headers: hookHeaders,
body: {
redirect_uris: ["http://localhost:5000/callback"],
skip_consent: true,
},
});
const bcRes = await hookAuth.api.bcAuthorize({
body: {
client_id: hookClient!.client_id,
client_secret: hookClient!.client_secret!,
scope: "openid profile",
login_hint: "test@test.com",
},
});
await hookAuth.api.cibaAuthorize({
headers: hookHeaders,
body: { auth_req_id: bcRes.auth_req_id as string },
});
const tokenRes = await hookFetch(`${baseURL}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:openid:params:grant-type:ciba",
client_id: hookClient!.client_id,
client_secret: hookClient!.client_secret!,
auth_req_id: bcRes.auth_req_id as string,
}),
});
expect(tokenRes.status).toBe(200);
const tokenBody = await tokenRes.json();
expect(tokenBody.access_token).toBeDefined();
// Verify the hook was called with the correct CIBA request data
expect(hookCalls).toHaveLength(1);
expect(hookCalls[0]!.scope).toBe("openid profile");
expect(hookCalls[0]!.userId).toBeDefined();
});
it("should still prevent replay after hook runs", async () => {
const {
auth: hookAuth,
signInWithTestUser: hookSignIn,
customFetchImpl: hookFetch,
} = await getTestInstance({
baseURL,
plugins: [
jwt({ jwt: { issuer: baseURL } }),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
ciba({
sendNotification: async () => {},
buildAccessTokenClaims: () => ({ custom: true }),
}),
],
});
const { headers: hookHeaders } = await hookSignIn();
const hookClient = await hookAuth.api.adminCreateOAuthClient({
headers: hookHeaders,
body: {
redirect_uris: ["http://localhost:5000/callback"],
skip_consent: true,
},
});
const bcRes = await hookAuth.api.bcAuthorize({
body: {
client_id: hookClient!.client_id,
client_secret: hookClient!.client_secret!,
scope: "openid",
login_hint: "test@test.com",
},
});
await hookAuth.api.cibaAuthorize({
headers: hookHeaders,
body: { auth_req_id: bcRes.auth_req_id as string },
});
// First poll: success
await hookFetch(`${baseURL}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:openid:params:grant-type:ciba",
client_id: hookClient!.client_id,
client_secret: hookClient!.client_secret!,
auth_req_id: bcRes.auth_req_id as string,
}),
});
// Second poll: should fail (replay)
const replayRes = await hookFetch(`${baseURL}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:openid:params:grant-type:ciba",
client_id: hookClient!.client_id,
client_secret: hookClient!.client_secret!,
auth_req_id: bcRes.auth_req_id as string,
}),
});
expect(replayRes.status).toBe(400);
const body = await replayRes.json();
expect(body.error).toBe("invalid_grant");
});
});
describe("discovery metadata", () => {
it("should include CIBA fields in openid-configuration", async () => {
const config = await auth.api.getOpenIdConfig();
+11 -3
View File
@@ -136,9 +136,6 @@ export function createCibaGrantHandler(_options: CibaOptions) {
ctx.body.resource = cibaRequest.resource;
}
// Delete before issuing to prevent replay
await deleteCibaRequest(ctx, authReqId);
// Build extra claims: act (agent identity) + authorization_details (RAR)
const extra: {
accessTokenClaims: Record<string, unknown>;
@@ -166,6 +163,17 @@ export function createCibaGrantHandler(_options: CibaOptions) {
}
}
if (_options.buildAccessTokenClaims) {
const custom = await _options.buildAccessTokenClaims(
cibaRequest,
ctx,
);
Object.assign(extra.accessTokenClaims, custom);
}
// Delete after building claims but before issuing to prevent replay
await deleteCibaRequest(ctx, authReqId);
return createUserTokens(
ctx,
opts,
+1
View File
@@ -11,6 +11,7 @@ import { createCibaVerify } from "./verify";
export { deliverPing } from "./push";
export type { CibaOptions, SendNotificationData } from "./types";
export type { CibaRequest } from "./utils";
const CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba";
+13
View File
@@ -1,4 +1,5 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type { CibaRequest } from "./utils";
/**
* Data passed to the {@link CibaOptions.sendNotification} callback.
@@ -83,4 +84,16 @@ export interface CibaOptions {
* @default 3
*/
pushRetryAttempts?: number;
/**
* Build additional access token claims from the CIBA request.
*
* Called before the request row is deleted, so the full request
* data is still available for lookup if needed.
* Returned claims are merged into the access token.
*/
buildAccessTokenClaims?: (
cibaRequest: CibaRequest,
ctx: GenericEndpointContext,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
}