Skip to content

Commit da27836

Browse files
committed
Add nonce support for OpenID Connect flows
- Add optional nonce parameter to client startAuthorization() - Auto-generate nonce when scope includes 'openid' - Pass nonce through server authorization handler - Update AuthorizationParams type to include nonce - Add comprehensive tests for nonce handling This enables proper OpenID Connect security by preventing replay attacks on ID tokens.
1 parent a9c907d commit da27836

File tree

6 files changed

+133
-5
lines changed

6 files changed

+133
-5
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client/auth.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,65 @@ describe("OAuth Authorization", () => {
728728
expect(authorizationUrl.searchParams.get("prompt")).toBe("consent");
729729
});
730730

731+
it("generates nonce automatically for OpenID Connect flows", async () => {
732+
const { authorizationUrl, nonce } = await startAuthorization(
733+
"https://auth.example.com",
734+
{
735+
clientInformation: validClientInfo,
736+
redirectUrl: "http://localhost:3000/callback",
737+
scope: "openid profile email",
738+
}
739+
);
740+
741+
expect(nonce).toBeDefined();
742+
expect(authorizationUrl.searchParams.get("nonce")).toBe(nonce);
743+
expect(nonce).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
744+
});
745+
746+
it("uses provided nonce for OpenID Connect flows", async () => {
747+
const providedNonce = "test-nonce-123";
748+
const { authorizationUrl, nonce } = await startAuthorization(
749+
"https://auth.example.com",
750+
{
751+
clientInformation: validClientInfo,
752+
redirectUrl: "http://localhost:3000/callback",
753+
scope: "openid profile",
754+
nonce: providedNonce,
755+
}
756+
);
757+
758+
expect(nonce).toBe(providedNonce);
759+
expect(authorizationUrl.searchParams.get("nonce")).toBe(providedNonce);
760+
});
761+
762+
it("does not include nonce for non-OpenID Connect flows", async () => {
763+
const { authorizationUrl, nonce } = await startAuthorization(
764+
"https://auth.example.com",
765+
{
766+
clientInformation: validClientInfo,
767+
redirectUrl: "http://localhost:3000/callback",
768+
scope: "read write",
769+
}
770+
);
771+
772+
expect(nonce).toBeUndefined();
773+
expect(authorizationUrl.searchParams.has("nonce")).toBe(false);
774+
});
775+
776+
it("generates nonce when openid scope is included with other scopes", async () => {
777+
const { authorizationUrl, nonce } = await startAuthorization(
778+
"https://auth.example.com",
779+
{
780+
clientInformation: validClientInfo,
781+
redirectUrl: "http://localhost:3000/callback",
782+
scope: "read openid write profile",
783+
}
784+
);
785+
786+
expect(nonce).toBeDefined();
787+
expect(authorizationUrl.searchParams.get("nonce")).toBe(nonce);
788+
});
789+
731790
it("uses metadata authorization_endpoint when provided", async () => {
732791
const { authorizationUrl } = await startAuthorization(
733792
"https://auth.example.com",

src/client/auth.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ export async function discoverOAuthMetadata(
548548

549549
/**
550550
* Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL.
551+
* For OpenID Connect flows (when scope includes 'openid'), automatically generates a nonce if not provided.
551552
*/
552553
export async function startAuthorization(
553554
authorizationServerUrl: string | URL,
@@ -557,16 +558,18 @@ export async function startAuthorization(
557558
redirectUrl,
558559
scope,
559560
state,
561+
nonce,
560562
resource,
561563
}: {
562564
metadata?: OAuthMetadata;
563565
clientInformation: OAuthClientInformation;
564566
redirectUrl: string | URL;
565567
scope?: string;
566568
state?: string;
569+
nonce?: string;
567570
resource?: URL;
568571
},
569-
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
572+
): Promise<{ authorizationUrl: URL; codeVerifier: string; nonce?: string }> {
570573
const responseType = "code";
571574
const codeChallengeMethod = "S256";
572575

@@ -625,7 +628,13 @@ export async function startAuthorization(
625628
authorizationUrl.searchParams.set("resource", resource.href);
626629
}
627630

628-
return { authorizationUrl, codeVerifier };
631+
let generatedNonce: string | undefined;
632+
if (scope?.includes("openid")) {
633+
generatedNonce = nonce ?? crypto.randomUUID();
634+
authorizationUrl.searchParams.set("nonce", generatedNonce);
635+
}
636+
637+
return { authorizationUrl, codeVerifier, nonce: generatedNonce };
629638
}
630639

631640
/**

src/server/auth/handlers/authorize.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,63 @@ describe('Authorization Handler', () => {
302302
expect.any(Object)
303303
);
304304
});
305+
306+
it('propagates nonce parameter for OpenID Connect flows', async () => {
307+
const mockProviderWithNonce = jest.spyOn(mockProvider, 'authorize');
308+
mockProviderWithNonce.mockClear();
309+
310+
const response = await supertest(app)
311+
.get('/authorize')
312+
.query({
313+
client_id: 'valid-client',
314+
redirect_uri: 'https://example.com/callback',
315+
response_type: 'code',
316+
code_challenge: 'challenge123',
317+
code_challenge_method: 'S256',
318+
scope: 'profile email',
319+
nonce: 'test-nonce-123'
320+
});
321+
322+
expect(response.status).toBe(302);
323+
expect(mockProviderWithNonce).toHaveBeenCalledWith(
324+
validClient,
325+
expect.objectContaining({
326+
nonce: 'test-nonce-123',
327+
redirectUri: 'https://example.com/callback',
328+
codeChallenge: 'challenge123',
329+
scopes: ['profile', 'email']
330+
}),
331+
expect.any(Object)
332+
);
333+
});
334+
335+
it('handles authorization without nonce parameter', async () => {
336+
const mockProviderWithoutNonce = jest.spyOn(mockProvider, 'authorize');
337+
mockProviderWithoutNonce.mockClear();
338+
339+
const response = await supertest(app)
340+
.get('/authorize')
341+
.query({
342+
client_id: 'valid-client',
343+
redirect_uri: 'https://example.com/callback',
344+
response_type: 'code',
345+
code_challenge: 'challenge123',
346+
code_challenge_method: 'S256',
347+
scope: 'profile email'
348+
});
349+
350+
expect(response.status).toBe(302);
351+
expect(mockProviderWithoutNonce).toHaveBeenCalledWith(
352+
validClient,
353+
expect.objectContaining({
354+
nonce: undefined,
355+
redirectUri: 'https://example.com/callback',
356+
codeChallenge: 'challenge123',
357+
scopes: ['profile', 'email']
358+
}),
359+
expect.any(Object)
360+
);
361+
});
305362
});
306363

307364
describe('Successful authorization', () => {

src/server/auth/handlers/authorize.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({
3535
code_challenge_method: z.literal("S256"),
3636
scope: z.string().optional(),
3737
state: z.string().optional(),
38+
nonce: z.string().optional(),
3839
resource: z.string().url().optional(),
3940
});
4041

@@ -115,7 +116,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
115116
throw new InvalidRequestError(parseResult.error.message);
116117
}
117118

118-
const { scope, code_challenge, resource } = parseResult.data;
119+
const { scope, code_challenge, nonce, resource } = parseResult.data;
119120
state = parseResult.data.state;
120121

121122
// Validate scopes
@@ -138,6 +139,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
138139
scopes: requestedScopes,
139140
redirectUri: redirect_uri,
140141
codeChallenge: code_challenge,
142+
nonce,
141143
resource: resource ? new URL(resource) : undefined,
142144
}, res);
143145
} catch (error) {

src/server/auth/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type AuthorizationParams = {
88
scopes?: string[];
99
codeChallenge: string;
1010
redirectUri: string;
11+
nonce?: string;
1112
resource?: URL;
1213
};
1314

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy