Skip to content

Commit e85e806

Browse files
Glitchedclaude
andcommitted
Add nonce validation for OpenID Connect flows
Implements proper nonce validation to prevent replay attacks when using OpenID Connect (scope includes 'openid'). Also adds audience validation for additional security. - Automatically generates nonce for OIDC flows - Validates nonce in ID tokens during token exchange - Validates audience (aud) claim matches client_id - Adds optional saveNonce/nonce methods to provider interface - Uses inline JWT decoder for better compatibility - Includes comprehensive test coverage Note: startAuthorization() now returns an optional nonce field when scope includes 'openid'. This is backward compatible for JavaScript users but may require TypeScript users to update explicit type annotations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fd335d2 commit e85e806

File tree

3 files changed

+336
-17
lines changed

3 files changed

+336
-17
lines changed

src/client/auth.test.ts

Lines changed: 248 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ describe("OAuth Authorization", () => {
2020
beforeEach(() => {
2121
mockFetch.mockReset();
2222
});
23+
24+
afterEach(() => {
25+
jest.restoreAllMocks();
26+
});
2327

2428
describe("extractResourceMetadataUrl", () => {
2529
it("returns resource metadata url when present", async () => {
@@ -773,20 +777,6 @@ describe("OAuth Authorization", () => {
773777
expect(authorizationUrl.searchParams.has("nonce")).toBe(false);
774778
});
775779

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-
790780
it("uses metadata authorization_endpoint when provided", async () => {
791781
const { authorizationUrl } = await startAuthorization(
792782
"https://auth.example.com",
@@ -975,6 +965,250 @@ describe("OAuth Authorization", () => {
975965
})
976966
).rejects.toThrow("Token exchange failed");
977967
});
968+
969+
it("validates nonce in ID token when present", async () => {
970+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
971+
const tokensWithIdToken = {
972+
...validTokens,
973+
id_token: idToken,
974+
};
975+
976+
mockFetch.mockResolvedValueOnce({
977+
ok: true,
978+
status: 200,
979+
json: async () => tokensWithIdToken,
980+
});
981+
982+
const tokens = await exchangeAuthorization("https://auth.example.com", {
983+
clientInformation: validClientInfo,
984+
authorizationCode: "code123",
985+
codeVerifier: "verifier123",
986+
redirectUri: "http://localhost:3000/callback",
987+
nonce: "test-nonce-123",
988+
});
989+
990+
expect(tokens).toEqual(tokensWithIdToken);
991+
});
992+
993+
it("throws error when nonce in ID token doesn't match", async () => {
994+
// ID token with different nonce
995+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6ImRpZmZlcmVudC1ub25jZSIsImF1ZCI6ImNsaWVudDEyMyIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
996+
const tokensWithIdToken = {
997+
...validTokens,
998+
id_token: idToken,
999+
};
1000+
1001+
mockFetch.mockResolvedValueOnce({
1002+
ok: true,
1003+
status: 200,
1004+
json: async () => tokensWithIdToken,
1005+
});
1006+
1007+
await expect(
1008+
exchangeAuthorization("https://auth.example.com", {
1009+
clientInformation: validClientInfo,
1010+
authorizationCode: "code123",
1011+
codeVerifier: "verifier123",
1012+
redirectUri: "http://localhost:3000/callback",
1013+
nonce: "test-nonce-123",
1014+
})
1015+
).rejects.toThrow("ID token nonce mismatch - possible replay attack");
1016+
});
1017+
1018+
it("throws error when nonce is expected but missing in ID token", async () => {
1019+
// ID token without nonce claim
1020+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1021+
const tokensWithIdToken = {
1022+
...validTokens,
1023+
id_token: idToken,
1024+
};
1025+
1026+
mockFetch.mockResolvedValueOnce({
1027+
ok: true,
1028+
status: 200,
1029+
json: async () => tokensWithIdToken,
1030+
});
1031+
1032+
await expect(
1033+
exchangeAuthorization("https://auth.example.com", {
1034+
clientInformation: validClientInfo,
1035+
authorizationCode: "code123",
1036+
codeVerifier: "verifier123",
1037+
redirectUri: "http://localhost:3000/callback",
1038+
nonce: "test-nonce-123",
1039+
})
1040+
).rejects.toThrow("ID token nonce mismatch - possible replay attack");
1041+
});
1042+
1043+
it("skips nonce validation when no nonce was provided", async () => {
1044+
// ID token with nonce claim
1045+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1046+
const tokensWithIdToken = {
1047+
...validTokens,
1048+
id_token: idToken,
1049+
};
1050+
1051+
mockFetch.mockResolvedValueOnce({
1052+
ok: true,
1053+
status: 200,
1054+
json: async () => tokensWithIdToken,
1055+
});
1056+
1057+
// No nonce parameter provided
1058+
const tokens = await exchangeAuthorization("https://auth.example.com", {
1059+
clientInformation: validClientInfo,
1060+
authorizationCode: "code123",
1061+
codeVerifier: "verifier123",
1062+
redirectUri: "http://localhost:3000/callback",
1063+
});
1064+
1065+
expect(tokens).toEqual(tokensWithIdToken);
1066+
});
1067+
1068+
it("validates audience in ID token", async () => {
1069+
// ID token with correct audience
1070+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1071+
const tokensWithIdToken = {
1072+
...validTokens,
1073+
id_token: idToken,
1074+
};
1075+
1076+
mockFetch.mockResolvedValueOnce({
1077+
ok: true,
1078+
status: 200,
1079+
json: async () => tokensWithIdToken,
1080+
});
1081+
1082+
const tokens = await exchangeAuthorization("https://auth.example.com", {
1083+
clientInformation: validClientInfo,
1084+
authorizationCode: "code123",
1085+
codeVerifier: "verifier123",
1086+
redirectUri: "http://localhost:3000/callback",
1087+
});
1088+
1089+
expect(tokens).toEqual(tokensWithIdToken);
1090+
});
1091+
1092+
it("validates audience when ID token has array audience", async () => {
1093+
// ID token with array audience containing our client_id
1094+
// Payload: {"aud":["client123","other-client"],"sub":"1234567890"}
1095+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiY2xpZW50MTIzIiwib3RoZXItY2xpZW50Il0sInN1YiI6IjEyMzQ1Njc4OTAifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1096+
const tokensWithIdToken = {
1097+
...validTokens,
1098+
id_token: idToken,
1099+
};
1100+
1101+
mockFetch.mockResolvedValueOnce({
1102+
ok: true,
1103+
status: 200,
1104+
json: async () => tokensWithIdToken,
1105+
});
1106+
1107+
const tokens = await exchangeAuthorization("https://auth.example.com", {
1108+
clientInformation: validClientInfo,
1109+
authorizationCode: "code123",
1110+
codeVerifier: "verifier123",
1111+
redirectUri: "http://localhost:3000/callback",
1112+
});
1113+
1114+
expect(tokens).toEqual(tokensWithIdToken);
1115+
});
1116+
1117+
it("throws error when audience in ID token doesn't match", async () => {
1118+
// ID token with wrong audience
1119+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ3cm9uZy1jbGllbnQiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1120+
const tokensWithIdToken = {
1121+
...validTokens,
1122+
id_token: idToken,
1123+
};
1124+
1125+
mockFetch.mockResolvedValueOnce({
1126+
ok: true,
1127+
status: 200,
1128+
json: async () => tokensWithIdToken,
1129+
});
1130+
1131+
await expect(
1132+
exchangeAuthorization("https://auth.example.com", {
1133+
clientInformation: validClientInfo,
1134+
authorizationCode: "code123",
1135+
codeVerifier: "verifier123",
1136+
redirectUri: "http://localhost:3000/callback",
1137+
})
1138+
).rejects.toThrow("ID token audience mismatch");
1139+
});
1140+
1141+
it("throws error when ID token is malformed (not 3 parts)", async () => {
1142+
// Malformed ID token with only 2 parts
1143+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIn0";
1144+
const tokensWithIdToken = {
1145+
...validTokens,
1146+
id_token: idToken,
1147+
};
1148+
1149+
mockFetch.mockResolvedValueOnce({
1150+
ok: true,
1151+
status: 200,
1152+
json: async () => tokensWithIdToken,
1153+
});
1154+
1155+
await expect(
1156+
exchangeAuthorization("https://auth.example.com", {
1157+
clientInformation: validClientInfo,
1158+
authorizationCode: "code123",
1159+
codeVerifier: "verifier123",
1160+
redirectUri: "http://localhost:3000/callback",
1161+
})
1162+
).rejects.toThrow("Invalid JWT format");
1163+
});
1164+
1165+
it("throws error when ID token has invalid base64 in payload", async () => {
1166+
// ID token with invalid base64 characters in payload
1167+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.!!!invalid-base64!!!.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1168+
const tokensWithIdToken = {
1169+
...validTokens,
1170+
id_token: idToken,
1171+
};
1172+
1173+
mockFetch.mockResolvedValueOnce({
1174+
ok: true,
1175+
status: 200,
1176+
json: async () => tokensWithIdToken,
1177+
});
1178+
1179+
await expect(
1180+
exchangeAuthorization("https://auth.example.com", {
1181+
clientInformation: validClientInfo,
1182+
authorizationCode: "code123",
1183+
codeVerifier: "verifier123",
1184+
redirectUri: "http://localhost:3000/callback",
1185+
})
1186+
).rejects.toThrow();
1187+
});
1188+
1189+
it("throws error when ID token payload is not valid JSON", async () => {
1190+
// ID token with invalid JSON in payload (base64 of "not json")
1191+
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGpzb24.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
1192+
const tokensWithIdToken = {
1193+
...validTokens,
1194+
id_token: idToken,
1195+
};
1196+
1197+
mockFetch.mockResolvedValueOnce({
1198+
ok: true,
1199+
status: 200,
1200+
json: async () => tokensWithIdToken,
1201+
});
1202+
1203+
await expect(
1204+
exchangeAuthorization("https://auth.example.com", {
1205+
clientInformation: validClientInfo,
1206+
authorizationCode: "code123",
1207+
codeVerifier: "verifier123",
1208+
redirectUri: "http://localhost:3000/callback",
1209+
})
1210+
).rejects.toThrow();
1211+
});
9781212
});
9791213

9801214
describe("refreshAuthorization", () => {

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