mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user