Skip to content

Add nonce validation #770

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

293 changes: 293 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ describe("OAuth Authorization", () => {
beforeEach(() => {
mockFetch.mockReset();
});

afterEach(() => {
jest.restoreAllMocks();
});

describe("extractResourceMetadataUrl", () => {
it("returns resource metadata url when present", async () => {
Expand Down Expand Up @@ -728,6 +732,51 @@ describe("OAuth Authorization", () => {
expect(authorizationUrl.searchParams.get("prompt")).toBe("consent");
});

it("generates nonce automatically for OpenID Connect flows", async () => {
const { authorizationUrl, nonce } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
scope: "openid profile email",
}
);

expect(nonce).toBeDefined();
expect(authorizationUrl.searchParams.get("nonce")).toBe(nonce);
expect(nonce).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});

it("uses provided nonce for OpenID Connect flows", async () => {
const providedNonce = "test-nonce-123";
const { authorizationUrl, nonce } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
scope: "openid profile",
nonce: providedNonce,
}
);

expect(nonce).toBe(providedNonce);
expect(authorizationUrl.searchParams.get("nonce")).toBe(providedNonce);
});

it("does not include nonce for non-OpenID Connect flows", async () => {
const { authorizationUrl, nonce } = await startAuthorization(
"https://auth.example.com",
{
clientInformation: validClientInfo,
redirectUrl: "http://localhost:3000/callback",
scope: "read write",
}
);

expect(nonce).toBeUndefined();
expect(authorizationUrl.searchParams.has("nonce")).toBe(false);
});

it("uses metadata authorization_endpoint when provided", async () => {
const { authorizationUrl } = await startAuthorization(
"https://auth.example.com",
Expand Down Expand Up @@ -916,6 +965,250 @@ describe("OAuth Authorization", () => {
})
).rejects.toThrow("Token exchange failed");
});

it("validates nonce in ID token when present", async () => {
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

const tokens = await exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
nonce: "test-nonce-123",
});

expect(tokens).toEqual(tokensWithIdToken);
});

it("throws error when nonce in ID token doesn't match", async () => {
// ID token with different nonce
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6ImRpZmZlcmVudC1ub25jZSIsImF1ZCI6ImNsaWVudDEyMyIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

await expect(
exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
nonce: "test-nonce-123",
})
).rejects.toThrow("ID token nonce mismatch - possible replay attack");
});

it("throws error when nonce is expected but missing in ID token", async () => {
// ID token without nonce claim
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

await expect(
exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
nonce: "test-nonce-123",
})
).rejects.toThrow("ID token nonce mismatch - possible replay attack");
});

it("skips nonce validation when no nonce was provided", async () => {
// ID token with nonce claim
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

// No nonce parameter provided
const tokens = await exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
});

expect(tokens).toEqual(tokensWithIdToken);
});

it("validates audience in ID token", async () => {
// ID token with correct audience
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

const tokens = await exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
});

expect(tokens).toEqual(tokensWithIdToken);
});

it("validates audience when ID token has array audience", async () => {
// ID token with array audience containing our client_id
// Payload: {"aud":["client123","other-client"],"sub":"1234567890"}
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiY2xpZW50MTIzIiwib3RoZXItY2xpZW50Il0sInN1YiI6IjEyMzQ1Njc4OTAifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

const tokens = await exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
});

expect(tokens).toEqual(tokensWithIdToken);
});

it("throws error when audience in ID token doesn't match", async () => {
// ID token with wrong audience
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ3cm9uZy1jbGllbnQiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

await expect(
exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
})
).rejects.toThrow("ID token audience mismatch");
});

it("throws error when ID token is malformed (not 3 parts)", async () => {
// Malformed ID token with only 2 parts
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIn0";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

await expect(
exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
})
).rejects.toThrow("Invalid JWT format");
});

it("throws error when ID token has invalid base64 in payload", async () => {
// ID token with invalid base64 characters in payload
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.!!!invalid-base64!!!.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

await expect(
exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
})
).rejects.toThrow();
});

it("throws error when ID token payload is not valid JSON", async () => {
// ID token with invalid JSON in payload (base64 of "not json")
const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGpzb24.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const tokensWithIdToken = {
...validTokens,
id_token: idToken,
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => tokensWithIdToken,
});

await expect(
exchangeAuthorization("https://auth.example.com", {
clientInformation: validClientInfo,
authorizationCode: "code123",
codeVerifier: "verifier123",
redirectUri: "http://localhost:3000/callback",
})
).rejects.toThrow();
});
});

describe("refreshAuthorization", () => {
Expand Down
Loading