From 6d60fc1b30c28f6feb02fa530e2432f9f34d2d20 Mon Sep 17 00:00:00 2001 From: SightStudio Date: Mon, 26 May 2025 16:17:57 +0900 Subject: [PATCH] feature. Support client_secret_basic in token exchange --- src/client/auth.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 43 +++++++++++++++++++------------- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 1b9fb071..2935b368 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -808,4 +808,58 @@ describe("OAuth Authorization", () => { ); }); }); + + describe("exchangeAuthorization with client_secret_basic", () => { + const validTokens = { + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }; + + const validClientInfo = { + client_id: "client123", + client_secret: "secret123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const metadataWithBasicOnly = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/auth", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }; + + it("sends credentials in Authorization header when client_secret_basic is supported", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: metadataWithBasicOnly, + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check Authorization header + const authHeader = request.headers["Authorization"]; + const expected = "Basic " + Buffer.from("client123:secret123").toString("base64"); + expect(authHeader).toBe(expected); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBeNull(); // should not be in body + expect(body.get("client_secret")).toBeNull(); // should not be in body + }); + }); + }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 7a91eb25..dd50dfab 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -389,40 +389,49 @@ export async function exchangeAuthorization( ): Promise { const grantType = "authorization_code"; - let tokenUrl: URL; - if (metadata) { - tokenUrl = new URL(metadata.token_endpoint); + const tokenUrl = metadata?.token_endpoint + ? new URL(metadata.token_endpoint) + : new URL("/token", authorizationServerUrl); - if ( - metadata.grant_types_supported && + if ( + metadata?.grant_types_supported && !metadata.grant_types_supported.includes(grantType) - ) { - throw new Error( + ) { + throw new Error( `Incompatible auth server: does not support grant type ${grantType}`, - ); - } - } else { - tokenUrl = new URL("/token", authorizationServerUrl); + ); } + const headers: HeadersInit = { + "Content-Type": "application/x-www-form-urlencoded", + }; + // Exchange code for tokens const params = new URLSearchParams({ grant_type: grantType, - client_id: clientInformation.client_id, code: authorizationCode, code_verifier: codeVerifier, redirect_uri: String(redirectUri), }); - if (clientInformation.client_secret) { - params.set("client_secret", clientInformation.client_secret); + const { client_id, client_secret } = clientInformation; + const supportedMethods = + metadata?.token_endpoint_auth_methods_supported ?? []; + + const useBasicAuth = !!client_secret && supportedMethods.includes("client_secret_basic"); + + if (client_secret && useBasicAuth) { + headers["Authorization"] = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`; + } else { + params.set("client_id", client_id); + if (client_secret) { + params.set("client_secret", client_secret); + } } const response = await fetch(tokenUrl, { method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, + headers, body: params, });