From 4a3ede0e291ecdad882e82382945a261ea3a46ee Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Fri, 15 May 2026 12:18:56 +0100 Subject: [PATCH] fix(core): honor options-level client assertions --- .../src/oauth2/client-credentials-token.ts | 6 +- .../private-key-jwt-authentication.test.ts | 129 ++++++++++++++++++ .../core/src/oauth2/refresh-access-token.ts | 6 +- .../src/oauth2/validate-authorization-code.ts | 6 +- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/packages/core/src/oauth2/client-credentials-token.ts b/packages/core/src/oauth2/client-credentials-token.ts index 2101ebe455..5ed2ba67d2 100644 --- a/packages/core/src/oauth2/client-credentials-token.ts +++ b/packages/core/src/oauth2/client-credentials-token.ts @@ -22,9 +22,11 @@ export async function clientCredentialsTokenRequest({ options = typeof options === "function" ? await options() : options; let extraParams: Record | undefined; - if (clientAssertionProvider) { + const resolvedClientAssertionProvider = + clientAssertionProvider ?? options.clientAssertionProvider; + if (resolvedClientAssertionProvider) { extraParams = await resolveAssertionParams({ - clientAssertionProvider, + clientAssertionProvider: resolvedClientAssertionProvider, }); } diff --git a/packages/core/src/oauth2/private-key-jwt-authentication.test.ts b/packages/core/src/oauth2/private-key-jwt-authentication.test.ts index fed0b7965d..32e0843cdf 100644 --- a/packages/core/src/oauth2/private-key-jwt-authentication.test.ts +++ b/packages/core/src/oauth2/private-key-jwt-authentication.test.ts @@ -66,6 +66,24 @@ describe("private_key_jwt OAuth2 helpers", () => { return body; } + function expectCustomAssertionRequest(clientAssertion: string) { + const [url, init] = mockedBetterFetch.mock.calls[0] ?? []; + expect(url).toBe(tokenEndpoint); + expect(init?.method).toBe("POST"); + expect(init?.headers).not.toHaveProperty("authorization"); + + const body = init?.body as URLSearchParams; + expect(body).toBeInstanceOf(URLSearchParams); + expect(body.get("client_id")).toBe(clientId); + expect(body.get("client_secret")).toBeNull(); + expect(body.get("client_assertion_type")).toBe( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ); + expect(body.get("client_assertion")).toBe(clientAssertion); + + return body; + } + it("should sign authorization-code assertions with the request tokenEndpoint", async () => { mockedBetterFetch.mockResolvedValueOnce({ data: { @@ -94,6 +112,34 @@ describe("private_key_jwt OAuth2 helpers", () => { expect(body.get("redirect_uri")).toBe("https://rp.example.com/callback"); }); + it("should use an options-level authorization-code assertion provider", async () => { + const clientAssertionProvider = vi.fn(async () => "options-auth-assertion"); + mockedBetterFetch.mockResolvedValueOnce({ + data: { + access_token: "access-token", + token_type: "Bearer", + }, + error: null, + }); + + await validateAuthorizationCode({ + code: "auth-code", + redirectURI: "https://rp.example.com/callback", + options: { + clientId, + clientSecret: "client-secret", + clientAssertionProvider, + }, + tokenEndpoint, + authentication: "basic", + }); + + expect(clientAssertionProvider).toHaveBeenCalledOnce(); + const body = expectCustomAssertionRequest("options-auth-assertion"); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("auth-code"); + }); + it("should sign refresh-token assertions with the request tokenEndpoint", async () => { mockedBetterFetch.mockResolvedValueOnce({ data: { @@ -120,6 +166,35 @@ describe("private_key_jwt OAuth2 helpers", () => { expect(body.get("refresh_token")).toBe("old-refresh-token"); }); + it("should use an options-level refresh-token assertion provider", async () => { + const clientAssertionProvider = vi.fn( + async () => "options-refresh-assertion", + ); + mockedBetterFetch.mockResolvedValueOnce({ + data: { + access_token: "new-access-token", + token_type: "Bearer", + }, + error: null, + }); + + await refreshAccessToken({ + refreshToken: "old-refresh-token", + options: { + clientId, + clientSecret: "client-secret", + clientAssertionProvider, + }, + tokenEndpoint, + authentication: "basic", + }); + + expect(clientAssertionProvider).toHaveBeenCalledOnce(); + const body = expectCustomAssertionRequest("options-refresh-assertion"); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("old-refresh-token"); + }); + it("should sign client-credentials assertions with the request tokenEndpoint", async () => { mockedBetterFetch.mockResolvedValueOnce({ data: { @@ -146,6 +221,37 @@ describe("private_key_jwt OAuth2 helpers", () => { expect(body.get("scope")).toBe("openid profile"); }); + it("should use an options-level client-credentials assertion provider", async () => { + const clientAssertionProvider = vi.fn( + async () => "options-client-credentials-assertion", + ); + mockedBetterFetch.mockResolvedValueOnce({ + data: { + access_token: "client-credentials-access-token", + token_type: "Bearer", + }, + error: null, + }); + + await clientCredentialsToken({ + options: { + clientId, + clientSecret: "client-secret", + clientAssertionProvider, + }, + tokenEndpoint, + scope: "openid profile", + authentication: "basic", + }); + + expect(clientAssertionProvider).toHaveBeenCalledOnce(); + const body = expectCustomAssertionRequest( + "options-client-credentials-assertion", + ); + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("scope")).toBe("openid profile"); + }); + it("should send a custom client assertion provider result without a client secret", async () => { const clientAssertionProvider = vi.fn( async () => "custom-client-assertion", @@ -192,4 +298,27 @@ describe("private_key_jwt OAuth2 helpers", () => { ); expect(body.get("client_assertion")).toBe("client-assertion"); }); + + it("should prefer explicit clientAssertionProvider over options provider", async () => { + const optionsClientAssertionProvider = vi.fn( + async () => "options-client-assertion", + ); + const explicitClientAssertionProvider = vi.fn( + async () => "explicit-client-assertion", + ); + + const { body } = await authorizationCodeRequest({ + code: "auth-code", + redirectURI: "https://rp.example.com/callback", + options: { + clientId, + clientAssertionProvider: optionsClientAssertionProvider, + }, + clientAssertionProvider: explicitClientAssertionProvider, + }); + + expect(optionsClientAssertionProvider).not.toHaveBeenCalled(); + expect(explicitClientAssertionProvider).toHaveBeenCalledOnce(); + expect(body.get("client_assertion")).toBe("explicit-client-assertion"); + }); }); diff --git a/packages/core/src/oauth2/refresh-access-token.ts b/packages/core/src/oauth2/refresh-access-token.ts index a4cd8e8775..e9f5c763c5 100644 --- a/packages/core/src/oauth2/refresh-access-token.ts +++ b/packages/core/src/oauth2/refresh-access-token.ts @@ -23,9 +23,11 @@ export async function refreshAccessTokenRequest({ }) { options = typeof options === "function" ? await options() : options; - if (clientAssertionProvider) { + const resolvedClientAssertionProvider = + clientAssertionProvider ?? options.clientAssertionProvider; + if (resolvedClientAssertionProvider) { const assertionParams = await resolveAssertionParams({ - clientAssertionProvider, + clientAssertionProvider: resolvedClientAssertionProvider, }); extraParams = { ...extraParams, ...assertionParams }; } diff --git a/packages/core/src/oauth2/validate-authorization-code.ts b/packages/core/src/oauth2/validate-authorization-code.ts index 36c11cfa8e..f0ce5ef296 100644 --- a/packages/core/src/oauth2/validate-authorization-code.ts +++ b/packages/core/src/oauth2/validate-authorization-code.ts @@ -33,9 +33,11 @@ export async function authorizationCodeRequest({ }) { options = typeof options === "function" ? await options() : options; - if (clientAssertionProvider) { + const resolvedClientAssertionProvider = + clientAssertionProvider ?? options.clientAssertionProvider; + if (resolvedClientAssertionProvider) { const assertionParams = await resolveAssertionParams({ - clientAssertionProvider, + clientAssertionProvider: resolvedClientAssertionProvider, }); additionalParams = { ...additionalParams, ...assertionParams }; }