Skip to content

Commit b803121

Browse files
committed
Merge remote-tracking branch 'jaredhanson/jaredhanson/token-auth-ext' into ochafik/auth-merge-531-552
2 parents 590d484 + c0e9ef6 commit b803121

File tree

2 files changed

+140
-16
lines changed

2 files changed

+140
-16
lines changed

src/client/auth.test.ts

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,22 @@ describe("OAuth Authorization", () => {
439439
});
440440

441441
describe("exchangeAuthorization", () => {
442+
const mockProvider: OAuthClientProvider = {
443+
get redirectUrl() { return "http://localhost:3000/callback"; },
444+
get clientMetadata() {
445+
return {
446+
redirect_uris: ["http://localhost:3000/callback"],
447+
client_name: "Test Client",
448+
};
449+
},
450+
clientInformation: jest.fn(),
451+
tokens: jest.fn(),
452+
saveTokens: jest.fn(),
453+
redirectToAuthorization: jest.fn(),
454+
saveCodeVerifier: jest.fn(),
455+
codeVerifier: jest.fn(),
456+
};
457+
442458
const validTokens = {
443459
access_token: "access123",
444460
token_type: "Bearer",
@@ -474,12 +490,11 @@ describe("OAuth Authorization", () => {
474490
}),
475491
expect.objectContaining({
476492
method: "POST",
477-
headers: {
478-
"Content-Type": "application/x-www-form-urlencoded",
479-
},
480493
})
481494
);
482495

496+
const headers = mockFetch.mock.calls[0][1].headers as Headers;
497+
expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
483498
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
484499
expect(body.get("grant_type")).toBe("authorization_code");
485500
expect(body.get("code")).toBe("code123");
@@ -489,6 +504,50 @@ describe("OAuth Authorization", () => {
489504
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
490505
});
491506

507+
it("exchanges code for tokens with auth", async () => {
508+
mockProvider.authToTokenEndpoint = function(url: URL, headers: Headers, params: URLSearchParams) {
509+
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
510+
params.set("example_url", url.toString());
511+
params.set("example_param", "example_value");
512+
};
513+
514+
mockFetch.mockResolvedValueOnce({
515+
ok: true,
516+
status: 200,
517+
json: async () => validTokens,
518+
});
519+
520+
const tokens = await exchangeAuthorization("https://auth.example.com", {
521+
clientInformation: validClientInfo,
522+
authorizationCode: "code123",
523+
codeVerifier: "verifier123",
524+
redirectUri: "http://localhost:3000/callback",
525+
}, mockProvider);
526+
527+
expect(tokens).toEqual(validTokens);
528+
expect(mockFetch).toHaveBeenCalledWith(
529+
expect.objectContaining({
530+
href: "https://auth.example.com/token",
531+
}),
532+
expect.objectContaining({
533+
method: "POST",
534+
})
535+
);
536+
537+
const headers = mockFetch.mock.calls[0][1].headers as Headers;
538+
expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
539+
expect(headers.get("Authorization")).toBe("Basic Y2xpZW50MTIzOnNlY3JldDEyMw==");
540+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
541+
expect(body.get("grant_type")).toBe("authorization_code");
542+
expect(body.get("code")).toBe("code123");
543+
expect(body.get("code_verifier")).toBe("verifier123");
544+
expect(body.get("client_id")).toBe("client123");
545+
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
546+
expect(body.get("example_url")).toBe("https://auth.example.com/token");
547+
expect(body.get("example_param")).toBe("example_value");
548+
expect(body.get("client_secret")).toBeUndefined;
549+
});
550+
492551
it("validates token response schema", async () => {
493552
mockFetch.mockResolvedValueOnce({
494553
ok: true,
@@ -527,6 +586,22 @@ describe("OAuth Authorization", () => {
527586
});
528587

529588
describe("refreshAuthorization", () => {
589+
const mockProvider: OAuthClientProvider = {
590+
get redirectUrl() { return "http://localhost:3000/callback"; },
591+
get clientMetadata() {
592+
return {
593+
redirect_uris: ["http://localhost:3000/callback"],
594+
client_name: "Test Client",
595+
};
596+
},
597+
clientInformation: jest.fn(),
598+
tokens: jest.fn(),
599+
saveTokens: jest.fn(),
600+
redirectToAuthorization: jest.fn(),
601+
saveCodeVerifier: jest.fn(),
602+
codeVerifier: jest.fn(),
603+
};
604+
530605
const validTokens = {
531606
access_token: "newaccess123",
532607
token_type: "Bearer",
@@ -563,19 +638,58 @@ describe("OAuth Authorization", () => {
563638
}),
564639
expect.objectContaining({
565640
method: "POST",
566-
headers: {
567-
"Content-Type": "application/x-www-form-urlencoded",
568-
},
569641
})
570642
);
571643

644+
const headers = mockFetch.mock.calls[0][1].headers as Headers;
645+
expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
572646
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
573647
expect(body.get("grant_type")).toBe("refresh_token");
574648
expect(body.get("refresh_token")).toBe("refresh123");
575649
expect(body.get("client_id")).toBe("client123");
576650
expect(body.get("client_secret")).toBe("secret123");
577651
});
578652

653+
it("exchanges refresh token for new tokens with auth", async () => {
654+
mockProvider.authToTokenEndpoint = function(url: URL, headers: Headers, params: URLSearchParams) {
655+
headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret));
656+
params.set("example_url", url.toString());
657+
params.set("example_param", "example_value");
658+
};
659+
660+
mockFetch.mockResolvedValueOnce({
661+
ok: true,
662+
status: 200,
663+
json: async () => validTokensWithNewRefreshToken,
664+
});
665+
666+
const tokens = await refreshAuthorization("https://auth.example.com", {
667+
clientInformation: validClientInfo,
668+
refreshToken: "refresh123",
669+
}, mockProvider);
670+
671+
expect(tokens).toEqual(validTokensWithNewRefreshToken);
672+
expect(mockFetch).toHaveBeenCalledWith(
673+
expect.objectContaining({
674+
href: "https://auth.example.com/token",
675+
}),
676+
expect.objectContaining({
677+
method: "POST",
678+
})
679+
);
680+
681+
const headers = mockFetch.mock.calls[0][1].headers as Headers;
682+
expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded");
683+
expect(headers.get("Authorization")).toBe("Basic Y2xpZW50MTIzOnNlY3JldDEyMw==");
684+
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
685+
expect(body.get("grant_type")).toBe("refresh_token");
686+
expect(body.get("refresh_token")).toBe("refresh123");
687+
expect(body.get("client_id")).toBe("client123");
688+
expect(body.get("example_url")).toBe("https://auth.example.com/token");
689+
expect(body.get("example_param")).toBe("example_value");
690+
expect(body.get("client_secret")).toBeUndefined;
691+
});
692+
579693
it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => {
580694
mockFetch.mockResolvedValueOnce({
581695
ok: true,

src/client/auth.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export interface OAuthClientProvider {
7171
* the authorization result.
7272
*/
7373
codeVerifier(): string | Promise<string>;
74+
75+
authToTokenEndpoint?(url: URL, headers: Headers, params: URLSearchParams): void | Promise<void>;
7476
}
7577

7678
export type AuthResult = "AUTHORIZED" | "REDIRECT";
@@ -142,7 +144,7 @@ export async function auth(
142144
authorizationCode,
143145
codeVerifier,
144146
redirectUri: provider.redirectUrl,
145-
});
147+
}, provider);
146148

147149
await provider.saveTokens(tokens);
148150
return "AUTHORIZED";
@@ -158,7 +160,7 @@ export async function auth(
158160
metadata,
159161
clientInformation,
160162
refreshToken: tokens.refresh_token,
161-
});
163+
}, provider);
162164

163165
await provider.saveTokens(newTokens);
164166
return "AUTHORIZED";
@@ -386,6 +388,7 @@ export async function exchangeAuthorization(
386388
codeVerifier: string;
387389
redirectUri: string | URL;
388390
},
391+
provider?: OAuthClientProvider
389392
): Promise<OAuthTokens> {
390393
const grantType = "authorization_code";
391394

@@ -406,6 +409,9 @@ export async function exchangeAuthorization(
406409
}
407410

408411
// Exchange code for tokens
412+
const headers = new Headers({
413+
"Content-Type": "application/x-www-form-urlencoded",
414+
});
409415
const params = new URLSearchParams({
410416
grant_type: grantType,
411417
client_id: clientInformation.client_id,
@@ -414,15 +420,15 @@ export async function exchangeAuthorization(
414420
redirect_uri: String(redirectUri),
415421
});
416422

417-
if (clientInformation.client_secret) {
423+
if (provider?.authToTokenEndpoint) {
424+
provider.authToTokenEndpoint(tokenUrl, headers, params);
425+
} else if (clientInformation.client_secret) {
418426
params.set("client_secret", clientInformation.client_secret);
419427
}
420428

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

@@ -447,6 +453,7 @@ export async function refreshAuthorization(
447453
clientInformation: OAuthClientInformation;
448454
refreshToken: string;
449455
},
456+
provider?: OAuthClientProvider,
450457
): Promise<OAuthTokens> {
451458
const grantType = "refresh_token";
452459

@@ -467,21 +474,24 @@ export async function refreshAuthorization(
467474
}
468475

469476
// Exchange refresh token
477+
const headers = new Headers({
478+
"Content-Type": "application/x-www-form-urlencoded",
479+
});
470480
const params = new URLSearchParams({
471481
grant_type: grantType,
472482
client_id: clientInformation.client_id,
473483
refresh_token: refreshToken,
474484
});
475485

476-
if (clientInformation.client_secret) {
486+
if (provider?.authToTokenEndpoint) {
487+
provider.authToTokenEndpoint(tokenUrl, headers, params);
488+
} else if (clientInformation.client_secret) {
477489
params.set("client_secret", clientInformation.client_secret);
478490
}
479491

480492
const response = await fetch(tokenUrl, {
481493
method: "POST",
482-
headers: {
483-
"Content-Type": "application/x-www-form-urlencoded",
484-
},
494+
headers: headers,
485495
body: params,
486496
});
487497
if (!response.ok) {

0 commit comments

Comments
 (0)