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; }