From 5bf68bf8615b0fea0c5e6009fdf6cf5ef9f433db Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sat, 21 Mar 2026 10:54:50 +0000 Subject: [PATCH] feat(ciba): add buildAccessTokenClaims hook for custom token claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ciba/src/ciba.test.ts | 153 +++++++++++++++++++++++++++++ packages/ciba/src/grant-handler.ts | 14 ++- packages/ciba/src/index.ts | 1 + packages/ciba/src/types.ts | 13 +++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/packages/ciba/src/ciba.test.ts b/packages/ciba/src/ciba.test.ts index 7b5233e51d..a30edc52dd 100644 --- a/packages/ciba/src/ciba.test.ts +++ b/packages/ciba/src/ciba.test.ts @@ -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(); diff --git a/packages/ciba/src/grant-handler.ts b/packages/ciba/src/grant-handler.ts index c61a78a4a8..54cb81e1e5 100644 --- a/packages/ciba/src/grant-handler.ts +++ b/packages/ciba/src/grant-handler.ts @@ -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; @@ -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, diff --git a/packages/ciba/src/index.ts b/packages/ciba/src/index.ts index 37a82b341a..60b6e2e811 100644 --- a/packages/ciba/src/index.ts +++ b/packages/ciba/src/index.ts @@ -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"; diff --git a/packages/ciba/src/types.ts b/packages/ciba/src/types.ts index 63a6f6456c..ead761d5f3 100644 --- a/packages/ciba/src/types.ts +++ b/packages/ciba/src/types.ts @@ -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; }