Skip to content

Commit 2449771

Browse files
committed
feature. Support client_secret_basic in token exchange
1 parent d5c996b commit 2449771

File tree

2 files changed

+96
-35
lines changed

2 files changed

+96
-35
lines changed

src/client/auth.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,4 +808,58 @@ describe("OAuth Authorization", () => {
808808
);
809809
});
810810
});
811+
812+
describe("exchangeAuthorization with client_secret_basic", () => {
813+
const validTokens = {
814+
access_token: "access123",
815+
token_type: "Bearer",
816+
expires_in: 3600,
817+
refresh_token: "refresh123",
818+
};
819+
820+
const validClientInfo = {
821+
client_id: "client123",
822+
client_secret: "secret123",
823+
redirect_uris: ["http://localhost:3000/callback"],
824+
client_name: "Test Client",
825+
};
826+
827+
const metadataWithBasicOnly = {
828+
issuer: "https://auth.example.com",
829+
authorization_endpoint: "https://auth.example.com/auth",
830+
token_endpoint: "https://auth.example.com/token",
831+
response_types_supported: ["code"],
832+
code_challenge_methods_supported: ["S256"],
833+
token_endpoint_auth_methods_supported: ["client_secret_basic"],
834+
};
835+
836+
it("sends credentials in Authorization header when client_secret_basic is supported", async () => {
837+
mockFetch.mockResolvedValueOnce({
838+
ok: true,
839+
status: 200,
840+
json: async () => validTokens,
841+
});
842+
843+
const tokens = await exchangeAuthorization("https://auth.example.com", {
844+
metadata: metadataWithBasicOnly,
845+
clientInformation: validClientInfo,
846+
authorizationCode: "code123",
847+
codeVerifier: "verifier123",
848+
redirectUri: "http://localhost:3000/callback",
849+
});
850+
851+
expect(tokens).toEqual(validTokens);
852+
const request = mockFetch.mock.calls[0][1];
853+
854+
// Check Authorization header
855+
const authHeader = request.headers["Authorization"];
856+
const expected = "Basic " + Buffer.from("client123:secret123").toString("base64");
857+
expect(authHeader).toBe(expected);
858+
859+
const body = request.body as URLSearchParams;
860+
expect(body.get("client_id")).toBeNull(); // should not be in body
861+
expect(body.get("client_secret")).toBeNull(); // should not be in body
862+
});
863+
});
864+
811865
});

src/client/auth.ts

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -368,61 +368,67 @@ export async function startAuthorization(
368368
return { authorizationUrl, codeVerifier };
369369
}
370370

371-
/**
372-
* Exchanges an authorization code for an access token with the given server.
373-
*/
374371
export async function exchangeAuthorization(
375-
authorizationServerUrl: string | URL,
376-
{
377-
metadata,
378-
clientInformation,
379-
authorizationCode,
380-
codeVerifier,
381-
redirectUri,
382-
}: {
383-
metadata?: OAuthMetadata;
384-
clientInformation: OAuthClientInformation;
385-
authorizationCode: string;
386-
codeVerifier: string;
387-
redirectUri: string | URL;
388-
},
372+
authorizationServerUrl: string | URL,
373+
{
374+
metadata,
375+
clientInformation,
376+
authorizationCode,
377+
codeVerifier,
378+
redirectUri,
379+
}: {
380+
metadata?: OAuthMetadata;
381+
clientInformation: OAuthClientInformation;
382+
authorizationCode: string;
383+
codeVerifier: string;
384+
redirectUri: string | URL;
385+
},
389386
): Promise<OAuthTokens> {
390387
const grantType = "authorization_code";
391388

392-
let tokenUrl: URL;
393-
if (metadata) {
394-
tokenUrl = new URL(metadata.token_endpoint);
389+
const tokenUrl = metadata?.token_endpoint
390+
? new URL(metadata.token_endpoint)
391+
: new URL("/token", authorizationServerUrl);
395392

396-
if (
397-
metadata.grant_types_supported &&
393+
if (
394+
metadata?.grant_types_supported &&
398395
!metadata.grant_types_supported.includes(grantType)
399-
) {
400-
throw new Error(
396+
) {
397+
throw new Error(
401398
`Incompatible auth server: does not support grant type ${grantType}`,
402-
);
403-
}
404-
} else {
405-
tokenUrl = new URL("/token", authorizationServerUrl);
399+
);
406400
}
407401

408-
// Exchange code for tokens
402+
const headers: HeadersInit = {
403+
"Content-Type": "application/x-www-form-urlencoded",
404+
};
405+
409406
const params = new URLSearchParams({
410407
grant_type: grantType,
411-
client_id: clientInformation.client_id,
412408
code: authorizationCode,
413409
code_verifier: codeVerifier,
414410
redirect_uri: String(redirectUri),
415411
});
416412

417-
if (clientInformation.client_secret) {
418-
params.set("client_secret", clientInformation.client_secret);
413+
const { client_id, client_secret } = clientInformation;
414+
const supportedMethods =
415+
metadata?.token_endpoint_auth_methods_supported ?? [];
416+
417+
const useBasicAuth = !!client_secret && supportedMethods.includes("client_secret_basic");
418+
419+
if (client_secret && useBasicAuth) {
420+
headers["Authorization"] = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
421+
// Do not put credentials in body
422+
} else {
423+
params.set("client_id", client_id);
424+
if (client_secret) {
425+
params.set("client_secret", client_secret);
426+
}
419427
}
420428

421429
const response = await fetch(tokenUrl, {
422430
method: "POST",
423-
headers: {
424-
"Content-Type": "application/x-www-form-urlencoded",
425-
},
431+
headers,
426432
body: params,
427433
});
428434

@@ -433,6 +439,7 @@ export async function exchangeAuthorization(
433439
return OAuthTokensSchema.parse(await response.json());
434440
}
435441

442+
436443
/**
437444
* Exchange a refresh token for an updated access token.
438445
*/

0 commit comments

Comments
 (0)