diff --git a/packages/next-auth/src/core/lib/oauth/checks.ts b/packages/next-auth/src/core/lib/oauth/checks.ts index 19196b5b59..abbcdd3e56 100644 --- a/packages/next-auth/src/core/lib/oauth/checks.ts +++ b/packages/next-auth/src/core/lib/oauth/checks.ts @@ -29,7 +29,7 @@ export async function signCookie( value: await jwt.encode({ ...options.jwt, maxAge, - token: { value }, + token: { value, provider: options.provider.id }, salt: name, }), options: { ...cookies[type].options, expires }, @@ -87,6 +87,11 @@ export const pkce = { if (!value?.value) throw new TypeError("PKCE code_verifier value could not be parsed.") + if (value.provider !== options.provider?.id) + throw new TypeError( + `${name} cookie was created for a different provider than the one handling the callback.` + ) + resCookies.push({ name, value: "", @@ -138,6 +143,11 @@ export const state = { if (!value?.value) throw new TypeError("State value could not be parsed.") + if (value.provider !== options.provider?.id) + throw new TypeError( + `${name} cookie was created for a different provider than the one handling the callback.` + ) + resCookies.push({ name, value: "", @@ -188,6 +198,11 @@ export const nonce = { if (!value?.value) throw new TypeError("Nonce value could not be parsed.") + if (value.provider !== options.provider?.id) + throw new TypeError( + `${name} cookie was created for a different provider than the one handling the callback.` + ) + resCookies.push({ name, value: "", diff --git a/packages/next-auth/src/jwt/index.ts b/packages/next-auth/src/jwt/index.ts index f858ed567b..6bccc55cba 100644 --- a/packages/next-auth/src/jwt/index.ts +++ b/packages/next-auth/src/jwt/index.ts @@ -100,7 +100,13 @@ export async function getToken( if (!token && authorizationHeader?.split(" ")[0] === "Bearer") { const urlEncodedToken = authorizationHeader.split(" ")[1] - token = decodeURIComponent(urlEncodedToken) + try { + token = decodeURIComponent(urlEncodedToken) + } catch { + // Malformed percent-encoding makes the Bearer token invalid + // @ts-expect-error + return null + } } // @ts-expect-error diff --git a/packages/next-auth/tests/checks.provider.test.ts b/packages/next-auth/tests/checks.provider.test.ts new file mode 100644 index 0000000000..4e7285408f --- /dev/null +++ b/packages/next-auth/tests/checks.provider.test.ts @@ -0,0 +1,86 @@ +import { mockLogger } from "./lib" +import * as checks from "../src/core/lib/oauth/checks" +import type { Cookie } from "../src/core/lib/cookie" +import type { AuthorizationParameters } from "openid-client" + +// The edge-runtime test environment cannot decrypt jose JWEs, so the sealed +// payload is carried through a transparent codec to exercise the real provider +// binding logic in `create`/`use`. +jest.mock("../src/jwt", () => ({ + encode: jest.fn(async ({ token }: any) => JSON.stringify(token)), + decode: jest.fn(async ({ token }: any) => JSON.parse(token)), +})) + +type Check = "pkce" | "state" | "nonce" + +function optionsFor(id: string, providerChecks: Check[]) { + return { + logger: mockLogger(), + provider: { id, checks: providerChecks }, + jwt: { secret: "secret" }, + cookies: { + pkceCodeVerifier: { name: "next-auth.pkce.code_verifier", options: {} }, + state: { name: "next-auth.state", options: {} }, + nonce: { name: "next-auth.nonce", options: {} }, + }, + } as any +} + +const cases = [ + { + check: "state" as const, + cookie: "state" as const, + resultKey: "state" as const, + }, + { + check: "nonce" as const, + cookie: "nonce" as const, + resultKey: "nonce" as const, + }, + { + check: "pkce" as const, + cookie: "pkceCodeVerifier" as const, + resultKey: "code_verifier" as const, + }, +] + +describe("OAuth check cookies are bound to their provider", () => { + for (const { check, cookie, resultKey } of cases) { + describe(check, () => { + it("accepts a cookie on the same provider's callback", async () => { + const options = optionsFor("github", [check]) + const created: Cookie[] = [] + const params: AuthorizationParameters = {} + await checks[check].create(options, created, params) + + const reqCookies = { [options.cookies[cookie].name]: created[0].value } + const result: any = {} + await checks[check].use(reqCookies, [], options, result) + + expect(result[resultKey]).toBeDefined() + }) + + it("rejects a cookie minted for a different provider", async () => { + const github = optionsFor("github", [check]) + const created: Cookie[] = [] + const params: AuthorizationParameters = {} + await checks[check].create(github, created, params) + + const google = optionsFor("google", [check]) + const reqCookies = { [google.cookies[cookie].name]: created[0].value } + await expect( + checks[check].use(reqCookies, [], google, {} as any) + ).rejects.toThrow(/created for a different provider/) + }) + + it("rejects a legacy cookie with no provider field", async () => { + const options = optionsFor("github", [check]) + const legacy = JSON.stringify({ value: "legacy" }) + const reqCookies = { [options.cookies[cookie].name]: legacy } + await expect( + checks[check].use(reqCookies, [], options, {} as any) + ).rejects.toThrow(/created for a different provider/) + }) + }) + } +}) diff --git a/packages/next-auth/tests/jwt.test.ts b/packages/next-auth/tests/jwt.test.ts new file mode 100644 index 0000000000..df6f371e55 --- /dev/null +++ b/packages/next-auth/tests/jwt.test.ts @@ -0,0 +1,31 @@ +import { encode, getToken } from "../src/jwt" + +describe("getToken", () => { + const secret = "secret" + + it("returns null for a malformed percent-encoded Bearer token", async () => { + for (const value of ["%", "%A", "%ZZ"]) { + const req = { + headers: new Headers({ authorization: `Bearer ${value}` }), + } as any + await expect(getToken({ req, secret })).resolves.toBeNull() + } + }) + + it("returns null for a malformed Bearer token when raw is set", async () => { + const req = { + headers: new Headers({ authorization: "Bearer %" }), + } as any + await expect(getToken({ req, secret, raw: true })).resolves.toBeNull() + }) + + it("still reads a valid Bearer token", async () => { + const jwt = await encode({ token: { foo: "bar" }, secret }) + const req = { + headers: new Headers({ + authorization: `Bearer ${encodeURIComponent(jwt)}`, + }), + } as any + await expect(getToken({ req, secret, raw: true })).resolves.toBe(jwt) + }) +})