From dfd8dd287a4cfeb1d3541bb4e004e8f33edce15f Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:04:24 +0000 Subject: [PATCH] fix(oauth): verify Twitch and Paybin ID tokens (#11884) --- .changeset/wet-paths-judge.md | 5 + docs/content/docs/authentication/paybin.mdx | 4 + docs/content/docs/authentication/twitch.mdx | 4 + .../core/src/oauth2/reject-redirects.test.ts | 91 ++++--- packages/core/src/oauth2/reject-redirects.ts | 23 +- .../src/oauth2/validate-authorization-code.ts | 41 +-- .../core/src/oauth2/validate-token.test.ts | 111 +++++++- packages/core/src/oauth2/verify.ts | 56 ++-- .../core/src/social-providers/paybin.test.ts | 246 ++++++++++++++++++ packages/core/src/social-providers/paybin.ts | 47 +++- .../core/src/social-providers/twitch.test.ts | 219 ++++++++++++++++ packages/core/src/social-providers/twitch.ts | 45 +++- 12 files changed, 787 insertions(+), 105 deletions(-) create mode 100644 .changeset/wet-paths-judge.md create mode 100644 packages/core/src/social-providers/paybin.test.ts create mode 100644 packages/core/src/social-providers/twitch.test.ts diff --git a/.changeset/wet-paths-judge.md b/.changeset/wet-paths-judge.md new file mode 100644 index 0000000000..3b7646788e --- /dev/null +++ b/.changeset/wet-paths-judge.md @@ -0,0 +1,5 @@ +--- +"@better-auth/core": patch +--- + +Twitch and Paybin sign-in now verify ID token signatures and identity claims before mapping profiles, with 30 seconds of clock tolerance. Custom Paybin issuers must expose RS256 signing keys at `/.well-known/jwks.json`. diff --git a/docs/content/docs/authentication/paybin.mdx b/docs/content/docs/authentication/paybin.mdx index 8cc4675e3b..8498dbc3c0 100644 --- a/docs/content/docs/authentication/paybin.mdx +++ b/docs/content/docs/authentication/paybin.mdx @@ -89,3 +89,7 @@ Paybin returns user information in the ID token following OpenID Connect standar * `email` from `email` claim * `image` from `picture` claim * `emailVerified` from `email_verified` claim + +### ID Token Validation + +The built-in provider checks the ID token's signature, issuer, audience, and expiration using signing keys from `/.well-known/jwks.json`. Custom `getUserInfo` overrides replace these checks. diff --git a/docs/content/docs/authentication/twitch.mdx b/docs/content/docs/authentication/twitch.mdx index 5e6e920fe0..f66d31c9cc 100644 --- a/docs/content/docs/authentication/twitch.mdx +++ b/docs/content/docs/authentication/twitch.mdx @@ -56,3 +56,7 @@ description: Twitch provider setup and usage. + +### ID Token Validation + +The built-in provider checks the ID token's signature, issuer, audience, and expiration before mapping the profile. Custom `getUserInfo` overrides replace these checks. diff --git a/packages/core/src/oauth2/reject-redirects.test.ts b/packages/core/src/oauth2/reject-redirects.test.ts index a47bee30f4..444062bf2d 100644 --- a/packages/core/src/oauth2/reject-redirects.test.ts +++ b/packages/core/src/oauth2/reject-redirects.test.ts @@ -1,39 +1,66 @@ -import { describe, expect, it } from "vitest"; -import { BetterAuthError } from "../error"; -import { assertResponseNotRedirect } from "./reject-redirects"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchRefusingRedirects } from "./reject-redirects"; -const fakeResponse = (init: { type?: string; status: number }): Response => - ({ type: "basic", ...init }) as unknown as Response; +/** + * @see https://fetch.spec.whatwg.org/#concept-filtered-response-opaque-redirect + */ +describe("fetchRefusingRedirects", () => { + const endpoint = "https://idp.example/token"; + const mockedFetch = vi.fn(); + beforeEach(() => { + mockedFetch.mockReset(); + vi.stubGlobal("fetch", mockedFetch); + }); + afterEach(() => vi.unstubAllGlobals()); -describe("assertResponseNotRedirect", () => { - it("throws on a 3xx status (undici-style manual redirect)", () => { - for (const status of [301, 302, 303, 307, 308]) { - expect(() => - assertResponseNotRedirect( - "https://idp.example/token", - fakeResponse({ status }), - ), - ).toThrow(BetterAuthError); - } + it.each([301, 302, 303, 307, 308])( + "refuses HTTP %s without following it", + async (status) => { + mockedFetch.mockResolvedValueOnce( + new Response("", { + status, + headers: { location: "http://internal.example" }, + }), + ); + + await expect(fetchRefusingRedirects(endpoint)).rejects.toThrow( + /refuse redirects/, + ); + expect(mockedFetch.mock.calls[0]?.[1]?.redirect).toBe("manual"); + }, + ); + + it("refuses an opaque redirect response", async () => { + const response = new Response("", { status: 302 }); + Object.defineProperties(response, { + type: { value: "opaqueredirect" }, + status: { value: 0 }, + }); + mockedFetch.mockResolvedValueOnce(response); + + await expect(fetchRefusingRedirects(endpoint)).rejects.toThrow( + /refuse redirects/, + ); }); - it("throws on an opaque redirect (spec-runtime manual redirect)", () => { - expect(() => - assertResponseNotRedirect( - "https://idp.example/token", - fakeResponse({ type: "opaqueredirect", status: 0 }), - ), - ).toThrow(BetterAuthError); - }); + it.each([200, 304, 400, 401])( + "preserves HTTP %s responses", + async (status) => { + mockedFetch.mockResolvedValueOnce(new Response(null, { status })); - it("does not throw on a non-redirect response", () => { - for (const status of [200, 304, 400, 401]) { - expect(() => - assertResponseNotRedirect( - "https://idp.example/token", - fakeResponse({ status }), - ), - ).not.toThrow(); - } + await expect(fetchRefusingRedirects(endpoint)).resolves.toBeDefined(); + }, + ); + + it("preserves redirect refusal when an error callback throws", async () => { + mockedFetch.mockResolvedValueOnce(new Response("", { status: 302 })); + + await expect( + fetchRefusingRedirects(endpoint, { + onError: () => { + throw new Error("custom callback"); + }, + }), + ).rejects.toThrow(/refuse redirects/); }); }); diff --git a/packages/core/src/oauth2/reject-redirects.ts b/packages/core/src/oauth2/reject-redirects.ts index 7e0dc84fa5..9f798e9f5f 100644 --- a/packages/core/src/oauth2/reject-redirects.ts +++ b/packages/core/src/oauth2/reject-redirects.ts @@ -25,27 +25,6 @@ function redirectRefused(endpoint: string): BetterAuthError { ); } -/** - * Fetch option that refuses HTTP redirects portably. - * - * Cloudflare Workers (workerd) rejects `redirect: "error"`, so manual mode is - * used and the resolved response is checked with {@link assertResponseNotRedirect} - * (or, for betterFetch, with {@link fetchRefusingRedirects}). - */ -export const noFollowRedirect = { redirect: "manual" } as const; - -/** - * Throw when a native-`fetch` response (e.g. jose's JWKS loader) resolved to a - * redirect, so an attacker-influenced endpoint cannot bounce a server-side - * request to an internal address. - */ -export function assertResponseNotRedirect( - endpoint: string, - response: Response, -): void { - if (isRedirectResponse(response)) throw redirectRefused(endpoint); -} - /** * betterFetch that refuses HTTP redirects on a server-side OAuth fetch. * @@ -61,7 +40,7 @@ export async function fetchRefusingRedirects( const onError = options?.onError; const result = await betterFetch(url, { ...options, - ...noFollowRedirect, + redirect: "manual", async onError(context) { if (isRedirectResponse(context.response)) redirected = true; await onError?.(context); diff --git a/packages/core/src/oauth2/validate-authorization-code.ts b/packages/core/src/oauth2/validate-authorization-code.ts index da7ac06a05..7e4efa307b 100644 --- a/packages/core/src/oauth2/validate-authorization-code.ts +++ b/packages/core/src/oauth2/validate-authorization-code.ts @@ -1,17 +1,14 @@ -import { createRemoteJWKSet, customFetch, jwtVerify } from "jose"; +import type { JWTPayload } from "jose"; import type { AwaitableFunction } from "../types"; import type { ProviderOptions } from "./index"; import { getOAuth2Tokens } from "./index"; -import { - assertResponseNotRedirect, - fetchRefusingRedirects, - noFollowRedirect, -} from "./reject-redirects"; +import { fetchRefusingRedirects } from "./reject-redirects"; import type { TokenEndpointAuth, TokenEndpointSecretAuthentication, } from "./token-endpoint-auth"; import { applyTokenEndpointAuth } from "./token-endpoint-auth"; +import { verifyJwsToken } from "./verify"; interface AuthorizationCodeRequestInput { code: string; @@ -161,24 +158,34 @@ export async function validateAuthorizationCode({ return tokens; } -export async function validateToken( +export async function validateToken( token: string, jwksEndpoint: string, options?: { audience?: string | string[]; issuer?: string | string[]; + /** + * Allowed JWS algorithms. Omit to use JOSE's default algorithm policy. + */ + algorithms?: string[]; + /** + * Claims that must be present. Claim values require separate validation. + */ + requiredClaims?: string[]; + /** + * Allowed clock skew in seconds. Defaults to zero. + */ + clockTolerance?: number; }, ) { - const jwks = createRemoteJWKSet(new URL(jwksEndpoint), { - [customFetch]: async (url, init) => { - const response = await fetch(url, { ...init, ...noFollowRedirect }); - assertResponseNotRedirect(String(url), response); - return response; + return verifyJwsToken(token, { + jwksFetch: new URL(jwksEndpoint).href, + verifyOptions: { + audience: options?.audience, + issuer: options?.issuer, + algorithms: options?.algorithms, + requiredClaims: options?.requiredClaims, + clockTolerance: options?.clockTolerance, }, }); - const verified = await jwtVerify(token, jwks, { - audience: options?.audience, - issuer: options?.issuer, - }); - return verified; } diff --git a/packages/core/src/oauth2/validate-token.test.ts b/packages/core/src/oauth2/validate-token.test.ts index 888d6efe0a..b031eb9156 100644 --- a/packages/core/src/oauth2/validate-token.test.ts +++ b/packages/core/src/oauth2/validate-token.test.ts @@ -1,19 +1,22 @@ import type { JWK } from "jose"; -import { exportJWK, generateKeyPair, SignJWT } from "jose"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { CompactEncrypt, exportJWK, generateKeyPair, SignJWT } from "jose"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { validateToken } from "./validate-authorization-code"; +import { jwksCache, verifyJwsAccessToken } from "./verify"; describe("validateToken", () => { - const originalFetch = globalThis.fetch; - const mockedFetch = vi.fn() as unknown as typeof fetch & - ReturnType; + const jwksEndpoint = "https://example.com/.well-known/jwks"; + const mockedFetch = vi.fn(); - beforeAll(() => { - globalThis.fetch = mockedFetch; + beforeEach(() => { + mockedFetch.mockReset(); + jwksCache.clear(); + vi.stubGlobal("fetch", mockedFetch); }); - afterAll(() => { - globalThis.fetch = originalFetch; + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); }); async function createTestJWKS(alg: string, crv?: string) { @@ -252,4 +255,94 @@ describe("validateToken", () => { expect(result.payload.aud).toBe("test-client"); expect(result.payload.iss).toBe("https://example.com"); }); + + /** + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + */ + describe("verification policy", () => { + it("rejects encrypted tokens before requesting signing keys", async () => { + const token = await new CompactEncrypt(new TextEncoder().encode("{}")) + .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) + .encrypt(new Uint8Array(32).fill(1)); + mockJWKSResponse(); + + await expect(validateToken(token, jwksEndpoint)).rejects.toMatchObject({ + code: "ERR_JWS_INVALID", + }); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("allows clock skew only when requested", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const { publicJWK, privateKey, kid } = await createTestJWKS("RS256"); + const token = await createSignedToken(privateKey, "RS256", kid, { + nbf: Math.floor(Date.now() / 1000) + 5, + }); + mockJWKSResponse(publicJWK); + + await expect( + validateToken(token, jwksEndpoint, { clockTolerance: 30 }), + ).resolves.toMatchObject({ payload: { sub: "user-123" } }); + await expect(validateToken(token, jwksEndpoint)).rejects.toMatchObject({ + code: "ERR_JWT_CLAIM_VALIDATION_FAILED", + claim: "nbf", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects a valid signature using an algorithm outside the allowlist", async () => { + const { publicJWK, privateKey, kid } = await createTestJWKS("ES256"); + const token = await createSignedToken(privateKey, "ES256", kid); + mockJWKSResponse(publicJWK); + + await expect( + validateToken(token, jwksEndpoint, { algorithms: ["RS256"] }), + ).rejects.toMatchObject({ code: "ERR_JOSE_ALG_NOT_ALLOWED" }); + }); + + it("rejects a signed token missing a required claim", async () => { + const { publicJWK, privateKey, kid } = await createTestJWKS("RS256"); + const token = new SignJWT({ sub: "user-123" }).setProtectedHeader({ + alg: "RS256", + kid, + }); + mockJWKSResponse(publicJWK); + + await expect( + validateToken(await token.sign(privateKey), jwksEndpoint, { + requiredClaims: ["exp"], + }), + ).rejects.toMatchObject({ + code: "ERR_JWT_CLAIM_VALIDATION_FAILED", + claim: "exp", + }); + }); + }); + + /** + * @see https://openid.net/specs/openid-connect-core-1_0.html#RotateSigKeys + */ + describe("signing key cache", () => { + it("shares cached keys with access token verification", async () => { + const { publicJWK, privateKey, kid } = await createTestJWKS("RS256"); + const token = await createSignedToken(privateKey, "RS256", kid); + mockedFetch.mockImplementation(async () => + Response.json({ keys: [publicJWK] }), + ); + + await validateToken(token, jwksEndpoint); + await verifyJwsAccessToken(token, { + jwksFetch: jwksEndpoint, + verifyOptions: { + issuer: "https://example.com", + audience: "test-client", + }, + }); + + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/core/src/oauth2/verify.ts b/packages/core/src/oauth2/verify.ts index 203f788a76..76898accfe 100644 --- a/packages/core/src/oauth2/verify.ts +++ b/packages/core/src/oauth2/verify.ts @@ -3,7 +3,6 @@ import type { JSONWebKeySet, JWTPayload, JWTVerifyOptions, - JWTVerifyResult, ProtectedHeaderParameters, } from "jose"; import { @@ -324,6 +323,40 @@ export function requestToResourceInput(request: Request): ResourceRequestInput { */ const defaultDpopReplayStore = createInMemoryDpopReplayStore(); +/** + * @internal + */ +export async function verifyJwsToken( + token: string, + opts: JwksFetchOptions & { + verifyOptions: JWTVerifyOptions; + }, +) { + if (token.split(".").length !== 3) { + throw new joseErrors.JWSInvalid("Invalid Compact JWS"); + } + const resolved = await getJwksForVerification(token, opts); + let jwks = resolved.jwks; + // Retry with a newer cached key set, then await a fetch already in progress. + // Allow at most two retries. + for (let refreshes = 0; ; refreshes++) { + try { + return await jwtVerify( + token, + createLocalJWKSet(jwks), + opts.verifyOptions, + ); + } catch (error) { + const refreshed = + refreshes < 2 && shouldRefetchCachedJwksWithoutKid(error, resolved) + ? await resolved.refresh?.(jwks) + : undefined; + if (!refreshed) throw error; + jwks = refreshed; + } + } +} + /** * Performs local verification of an access token for your APIs. * @@ -338,26 +371,7 @@ export async function verifyJwsAccessToken( }, ) { try { - const resolved = await getJwksForVerification(token, opts); - let jwks = resolved.jwks; - let jwt: JWTVerifyResult | undefined; - // Two refreshes at most: a newer cached set, then a fetch still in flight. - for (let refreshes = 0; !jwt; refreshes++) { - try { - jwt = await jwtVerify( - token, - createLocalJWKSet(jwks), - opts.verifyOptions, - ); - } catch (error) { - const refreshed = - refreshes < 2 && shouldRefetchCachedJwksWithoutKid(error, resolved) - ? await resolved.refresh?.(jwks) - : undefined; - if (!refreshed) throw error; - jwks = refreshed; - } - } + const jwt = await verifyJwsToken(token, opts); // Return the JWT payload in introspection format // https://datatracker.ietf.org/doc/html/rfc7662#section-2.2 if (jwt.payload.azp) { diff --git a/packages/core/src/social-providers/paybin.test.ts b/packages/core/src/social-providers/paybin.test.ts new file mode 100644 index 0000000000..bfb36caf9d --- /dev/null +++ b/packages/core/src/social-providers/paybin.test.ts @@ -0,0 +1,246 @@ +import { CompactSign, exportJWK, generateKeyPair, UnsecuredJWT } from "jose"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { logger } from "../env"; +import { jwksCache } from "../oauth2/verify"; +import { paybin } from "./paybin"; + +const options = { clientId: "paybin-client", clientSecret: "client-secret" }; +const issuer = "https://idp.paybin.io"; +const jwksEndpoint = "https://idp.paybin.io/.well-known/jwks.json"; +const now = new Date("2026-01-01T00:00:00Z"); +const issuedAt = Math.floor(now.getTime() / 1000); +const profile = { + iss: issuer, + sub: "provider-user", + aud: options.clientId, + iat: issuedAt, + exp: issuedAt + 3600, + email: "user@example.com", + email_verified: true, + preferred_username: "Provider User", + picture: "https://example.com/avatar.png", +}; +const { publicKey, privateKey } = await generateKeyPair("RS256", { + extractable: true, +}); +const { privateKey: otherPrivateKey } = await generateKeyPair("RS256"); +const publicJWK = { + ...(await exportJWK(publicKey)), + alg: "RS256", + kid: "provider-key", + use: "sig", +}; +const mockedFetch = vi.fn(); +const mockedWarn = vi.fn(); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + jwksCache.clear(); + mockedWarn.mockReset(); + vi.spyOn(logger, "warn").mockImplementation(mockedWarn); + mockedFetch.mockReset(); + mockedFetch.mockImplementation(async () => + Response.json({ keys: [publicJWK] }), + ); + vi.stubGlobal("fetch", mockedFetch); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +function signToken( + overrides: Record | string = {}, + key = privateKey, + includeKid = true, +) { + const payload = + typeof overrides === "string" + ? overrides + : JSON.stringify({ ...profile, ...overrides }); + return new CompactSign(new TextEncoder().encode(payload)) + .setProtectedHeader({ + alg: "RS256", + ...(includeKid ? { kid: "provider-key" } : {}), + }) + .sign(key); +} + +/** + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + * @see https://developers.paybin.io/knowledge-center/oidc#token-management + */ +describe("paybin.getUserInfo", () => { + it.each([true, false])( + "maps signed tokens with kid=%s", + async (includeKid) => { + const result = await paybin(options).getUserInfo({ + idToken: await signToken({}, privateKey, includeKid), + }); + + expect(result?.data).toEqual(profile); + expect(result?.user).toEqual({ + name: profile.preferred_username, + email: profile.email, + image: profile.picture, + emailVerified: true, + }); + expect( + mockedFetch.mock.calls.map(([input]) => new Request(input).url), + ).toContain(jwksEndpoint); + }, + ); + + it.each([ + ["wrong issuer", { iss: "https://other.example.com" }], + ["wrong audience", { aud: "other-client" }], + [ + "untrusted additional audience", + { aud: [options.clientId, "other-client"], azp: options.clientId }, + ], + ["expired", { exp: 0 }], + ["missing expiration", { exp: undefined }], + ["missing issued-at", { iat: undefined }], + ["missing subject", { sub: undefined }], + ["numeric subject", { sub: 123 }], + ["null subject", { sub: null }], + ["empty subject", { sub: "" }], + ] as const)("rejects %s before profile mapping", async (_, overrides) => { + const mapProfileToUser = vi.fn(() => ({})); + const result = await paybin({ ...options, mapProfileToUser }).getUserInfo({ + idToken: await signToken(overrides), + }); + + expect(result).toBeNull(); + expect(mapProfileToUser).not.toHaveBeenCalled(); + }); + + it.each(["unsigned", "wrong signing key"])( + "rejects tokens with %s", + async (kind) => { + const token = + kind === "unsigned" + ? new UnsecuredJWT(profile).encode() + : await signToken({}, otherPrivateKey); + + expect(await paybin(options).getUserInfo({ idToken: token })).toBeNull(); + }, + ); + + it.each([ + ["exp", -29], + ["nbf", 29], + ] as const)("accepts %s with %ss of clock skew", async (claim, offset) => { + const result = await paybin(options).getUserInfo({ + idToken: await signToken({ [claim]: issuedAt + offset }), + }); + expect(result?.data.sub).toBe(profile.sub); + }); + + it.each([ + ["exp", -31], + ["nbf", 31], + ] as const)("rejects %s with %ss of clock skew", async (claim, offset) => { + expect( + await paybin(options).getUserInfo({ + idToken: await signToken({ [claim]: issuedAt + offset }), + }), + ).toBeNull(); + }); + + it.each(["iat", "exp"] as const)( + "rejects a non-finite %s claim", + async (claim) => { + const payload = JSON.stringify({ + ...profile, + [claim]: "overflow", + }).replace('"overflow"', "1e400"); + const token = await signToken(payload); + + expect(await paybin(options).getUserInfo({ idToken: token })).toBeNull(); + }, + ); + + it("logs a safe claim error without exposing identity data", async () => { + const token = await signToken({ iss: "https://other.example.com" }); + + expect(await paybin(options).getUserInfo({ idToken: token })).toBeNull(); + const output = JSON.stringify(mockedWarn.mock.calls); + expect(output).toContain("ERR_JWT_CLAIM_VALIDATION_FAILED"); + expect(output).not.toContain(token); + expect(output).not.toContain(profile.email); + }); + + it.each([ + ["unavailable", "JWKS_FETCH_FAILED"], + ["invalid", "ERR_JWKS_INVALID"], + ])("logs a safe reason for %s signing keys", async (kind, code) => { + mockedFetch.mockImplementation(async () => + kind === "unavailable" + ? new Response("Unavailable", { status: 503 }) + : Response.json({ keys: {} }), + ); + const token = await signToken(); + + expect(await paybin(options).getUserInfo({ idToken: token })).toBeNull(); + const output = JSON.stringify(mockedWarn.mock.calls); + expect(output).toContain(code); + expect(output).not.toContain(token); + expect(output).not.toContain(profile.email); + }); + + it("rejects invalid tokens even when a custom logger throws", async () => { + mockedWarn.mockImplementationOnce(() => { + throw new Error("logger failed"); + }); + + await expect( + paybin(options).getUserInfo({ + idToken: await signToken({ iss: "https://other.example.com" }), + }), + ).resolves.toBeNull(); + }); + + it("preserves the custom getUserInfo override", async () => { + const custom = { + data: profile, + user: { email: profile.email, emailVerified: true }, + }; + const getUserInfo = vi.fn(async () => custom); + const tokens = { idToken: "custom-token" }; + + expect(await paybin({ ...options, getUserInfo }).getUserInfo(tokens)).toBe( + custom, + ); + expect(getUserInfo).toHaveBeenCalledWith(tokens); + }); + + it.each([ + { + issuer: "https://paybin.example.com", + jwksEndpoint: "https://paybin.example.com/.well-known/jwks.json", + }, + { + issuer: "https://paybin.example.com/", + jwksEndpoint: "https://paybin.example.com/.well-known/jwks.json", + }, + { + issuer: "https://paybin.example.com/tenant", + jwksEndpoint: "https://paybin.example.com/tenant/.well-known/jwks.json", + }, + ])( + "uses the signing keys for issuer $issuer", + async ({ issuer, jwksEndpoint }) => { + const result = await paybin({ ...options, issuer }).getUserInfo({ + idToken: await signToken({ iss: issuer }), + }); + + expect(result?.data.sub).toBe(profile.sub); + expect( + mockedFetch.mock.calls.map(([input]) => new Request(input).url), + ).toContain(jwksEndpoint); + }, + ); +}); diff --git a/packages/core/src/social-providers/paybin.ts b/packages/core/src/social-providers/paybin.ts index 61a286ab4c..321aa4b0a9 100644 --- a/packages/core/src/social-providers/paybin.ts +++ b/packages/core/src/social-providers/paybin.ts @@ -1,4 +1,4 @@ -import { decodeJwt } from "jose"; +import { errors as joseErrors } from "jose"; import { logger } from "../env"; import { BetterAuthError } from "../error"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; @@ -6,7 +6,9 @@ import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, + validateToken, } from "../oauth2"; +import { ID_TOKEN_CLOCK_TOLERANCE_SECONDS } from "../oauth2/verify-id-token"; export interface PaybinProfile { sub: string; @@ -102,7 +104,48 @@ export const paybin = (options: PaybinOptions) => { if (!token.idToken) { return null; } - const user = decodeJwt(token.idToken) as PaybinProfile; + let user: PaybinProfile; + try { + const issuerBaseURL = issuer.endsWith("/") ? issuer : `${issuer}/`; + const jwksEndpoint = new URL(".well-known/jwks.json", issuerBaseURL); + const { payload } = await validateToken( + token.idToken, + jwksEndpoint.href, + { + issuer, + audience: options.clientId, + algorithms: ["RS256"], + requiredClaims: ["iss", "sub", "aud", "exp", "iat"], + clockTolerance: ID_TOKEN_CLOCK_TOLERANCE_SECONDS, + }, + ); + if ( + typeof payload.sub !== "string" || + !payload.sub || + !Number.isFinite(payload.iat) || + !Number.isFinite(payload.exp) || + (Array.isArray(payload.aud) && + payload.aud.some((audience) => audience !== options.clientId)) + ) { + throw new joseErrors.JWTClaimValidationFailed( + "Invalid ID token identity claims", + payload, + ); + } + user = payload; + } catch (error) { + try { + logger.warn("Paybin ID token verification failed", { + code: + error instanceof joseErrors.JOSEError + ? error.code + : "JWKS_FETCH_FAILED", + }); + } catch { + // Preserve the rejected result if a custom logger throws. + } + return null; + } const userMap = await options.mapProfileToUser?.(user); return { user: { diff --git a/packages/core/src/social-providers/twitch.test.ts b/packages/core/src/social-providers/twitch.test.ts new file mode 100644 index 0000000000..1b1959dca0 --- /dev/null +++ b/packages/core/src/social-providers/twitch.test.ts @@ -0,0 +1,219 @@ +import { CompactSign, exportJWK, generateKeyPair, UnsecuredJWT } from "jose"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { logger } from "../env"; +import { jwksCache } from "../oauth2/verify"; +import { twitch } from "./twitch"; + +const options = { clientId: "twitch-client", clientSecret: "client-secret" }; +const issuer = "https://id.twitch.tv/oauth2"; +const jwksEndpoint = "https://id.twitch.tv/oauth2/keys"; +const now = new Date("2026-01-01T00:00:00Z"); +const issuedAt = Math.floor(now.getTime() / 1000); +const profile = { + iss: issuer, + sub: "provider-user", + aud: options.clientId, + iat: issuedAt, + exp: issuedAt + 3600, + email: "user@example.com", + email_verified: true, + preferred_username: "Provider User", + picture: "https://example.com/avatar.png", +}; +const { publicKey, privateKey } = await generateKeyPair("RS256", { + extractable: true, +}); +const { privateKey: otherPrivateKey } = await generateKeyPair("RS256"); +const publicJWK = { + ...(await exportJWK(publicKey)), + alg: "RS256", + kid: "provider-key", + use: "sig", +}; +const mockedFetch = vi.fn(); +const mockedWarn = vi.fn(); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + jwksCache.clear(); + mockedWarn.mockReset(); + vi.spyOn(logger, "warn").mockImplementation(mockedWarn); + mockedFetch.mockReset(); + mockedFetch.mockImplementation(async () => + Response.json({ keys: [publicJWK] }), + ); + vi.stubGlobal("fetch", mockedFetch); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +function signToken( + overrides: Record | string = {}, + key = privateKey, + includeKid = true, +) { + const payload = + typeof overrides === "string" + ? overrides + : JSON.stringify({ ...profile, ...overrides }); + return new CompactSign(new TextEncoder().encode(payload)) + .setProtectedHeader({ + alg: "RS256", + ...(includeKid ? { kid: "provider-key" } : {}), + }) + .sign(key); +} + +/** + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + * @see https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#validating-an-id-token + */ +describe("twitch.getUserInfo", () => { + it.each([true, false])( + "maps signed tokens with kid=%s", + async (includeKid) => { + const result = await twitch(options).getUserInfo({ + idToken: await signToken({}, privateKey, includeKid), + }); + + expect(result?.data).toEqual(profile); + expect(result?.user).toEqual({ + name: profile.preferred_username, + email: profile.email, + image: profile.picture, + emailVerified: true, + }); + expect( + mockedFetch.mock.calls.map(([input]) => new Request(input).url), + ).toContain(jwksEndpoint); + }, + ); + + it.each([ + ["wrong issuer", { iss: "https://other.example.com" }], + ["wrong audience", { aud: "other-client" }], + [ + "untrusted additional audience", + { aud: [options.clientId, "other-client"], azp: options.clientId }, + ], + ["expired", { exp: 0 }], + ["missing expiration", { exp: undefined }], + ["missing issued-at", { iat: undefined }], + ["missing subject", { sub: undefined }], + ["numeric subject", { sub: 123 }], + ["null subject", { sub: null }], + ["empty subject", { sub: "" }], + ] as const)("rejects %s before profile mapping", async (_, overrides) => { + const mapProfileToUser = vi.fn(() => ({})); + const result = await twitch({ ...options, mapProfileToUser }).getUserInfo({ + idToken: await signToken(overrides), + }); + + expect(result).toBeNull(); + expect(mapProfileToUser).not.toHaveBeenCalled(); + }); + + it.each(["unsigned", "wrong signing key"])( + "rejects tokens with %s", + async (kind) => { + const token = + kind === "unsigned" + ? new UnsecuredJWT(profile).encode() + : await signToken({}, otherPrivateKey); + + expect(await twitch(options).getUserInfo({ idToken: token })).toBeNull(); + }, + ); + + it.each([ + ["exp", -29], + ["nbf", 29], + ] as const)("accepts %s with %ss of clock skew", async (claim, offset) => { + const result = await twitch(options).getUserInfo({ + idToken: await signToken({ [claim]: issuedAt + offset }), + }); + expect(result?.data.sub).toBe(profile.sub); + }); + + it.each([ + ["exp", -31], + ["nbf", 31], + ] as const)("rejects %s with %ss of clock skew", async (claim, offset) => { + expect( + await twitch(options).getUserInfo({ + idToken: await signToken({ [claim]: issuedAt + offset }), + }), + ).toBeNull(); + }); + + it.each(["iat", "exp"] as const)( + "rejects a non-finite %s claim", + async (claim) => { + const payload = JSON.stringify({ + ...profile, + [claim]: "overflow", + }).replace('"overflow"', "1e400"); + const token = await signToken(payload); + + expect(await twitch(options).getUserInfo({ idToken: token })).toBeNull(); + }, + ); + + it("logs a safe claim error without exposing identity data", async () => { + const token = await signToken({ iss: "https://other.example.com" }); + + expect(await twitch(options).getUserInfo({ idToken: token })).toBeNull(); + const output = JSON.stringify(mockedWarn.mock.calls); + expect(output).toContain("ERR_JWT_CLAIM_VALIDATION_FAILED"); + expect(output).not.toContain(token); + expect(output).not.toContain(profile.email); + }); + + it.each([ + ["unavailable", "JWKS_FETCH_FAILED"], + ["invalid", "ERR_JWKS_INVALID"], + ])("logs a safe reason for %s signing keys", async (kind, code) => { + mockedFetch.mockImplementation(async () => + kind === "unavailable" + ? new Response("Unavailable", { status: 503 }) + : Response.json({ keys: {} }), + ); + const token = await signToken(); + + expect(await twitch(options).getUserInfo({ idToken: token })).toBeNull(); + const output = JSON.stringify(mockedWarn.mock.calls); + expect(output).toContain(code); + expect(output).not.toContain(token); + expect(output).not.toContain(profile.email); + }); + + it("rejects invalid tokens even when a custom logger throws", async () => { + mockedWarn.mockImplementationOnce(() => { + throw new Error("logger failed"); + }); + + await expect( + twitch(options).getUserInfo({ + idToken: await signToken({ iss: "https://other.example.com" }), + }), + ).resolves.toBeNull(); + }); + + it("preserves the custom getUserInfo override", async () => { + const custom = { + data: profile, + user: { email: profile.email, emailVerified: true }, + }; + const getUserInfo = vi.fn(async () => custom); + const tokens = { idToken: "custom-token" }; + + expect(await twitch({ ...options, getUserInfo }).getUserInfo(tokens)).toBe( + custom, + ); + expect(getUserInfo).toHaveBeenCalledWith(tokens); + }); +}); diff --git a/packages/core/src/social-providers/twitch.ts b/packages/core/src/social-providers/twitch.ts index a5685de98a..bf9eb2d7ab 100644 --- a/packages/core/src/social-providers/twitch.ts +++ b/packages/core/src/social-providers/twitch.ts @@ -1,11 +1,13 @@ -import { decodeJwt } from "jose"; +import { errors as joseErrors } from "jose"; import { logger } from "../env"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, + validateToken, } from "../oauth2"; +import { ID_TOKEN_CLOCK_TOLERANCE_SECONDS } from "../oauth2/verify-id-token"; /** * @see https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#requesting-claims @@ -95,7 +97,46 @@ export const twitch = (options: TwitchOptions) => { logger.error("No idToken found in token"); return null; } - const profile = decodeJwt(idToken) as TwitchProfile; + let profile: TwitchProfile; + try { + const { payload } = await validateToken( + idToken, + "https://id.twitch.tv/oauth2/keys", + { + issuer: "https://id.twitch.tv/oauth2", + audience: options.clientId, + algorithms: ["RS256"], + requiredClaims: ["iss", "sub", "aud", "exp", "iat"], + clockTolerance: ID_TOKEN_CLOCK_TOLERANCE_SECONDS, + }, + ); + if ( + typeof payload.sub !== "string" || + !payload.sub || + !Number.isFinite(payload.iat) || + !Number.isFinite(payload.exp) || + (Array.isArray(payload.aud) && + payload.aud.some((audience) => audience !== options.clientId)) + ) { + throw new joseErrors.JWTClaimValidationFailed( + "Invalid ID token identity claims", + payload, + ); + } + profile = payload; + } catch (error) { + try { + logger.warn("Twitch ID token verification failed", { + code: + error instanceof joseErrors.JOSEError + ? error.code + : "JWKS_FETCH_FAILED", + }); + } catch { + // Preserve the rejected result if a custom logger throws. + } + return null; + } const userMap = await options.mapProfileToUser?.(profile); return { user: {