From 492134aeba6707bdfb371cf8ef817c945d6fb44f Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:24:43 +0000 Subject: [PATCH] fix(oauth): restore Twitch and Paybin ID token handling (#11920) --- .changeset/stale-twitch-tokens.md | 5 + docs/content/docs/authentication/paybin.mdx | 4 - docs/content/docs/authentication/twitch.mdx | 4 - .../core/src/social-providers/paybin.test.ts | 262 ++---------------- packages/core/src/social-providers/paybin.ts | 47 +--- .../core/src/social-providers/twitch.test.ts | 236 ++-------------- packages/core/src/social-providers/twitch.ts | 45 +-- 7 files changed, 60 insertions(+), 543 deletions(-) create mode 100644 .changeset/stale-twitch-tokens.md diff --git a/.changeset/stale-twitch-tokens.md b/.changeset/stale-twitch-tokens.md new file mode 100644 index 0000000000..3958614e7f --- /dev/null +++ b/.changeset/stale-twitch-tokens.md @@ -0,0 +1,5 @@ +--- +"@better-auth/core": patch +--- + +Restore the Twitch and Paybin ID token handling from before 1.7.11, which made account info fail once the ID token stored at sign-in expired. diff --git a/docs/content/docs/authentication/paybin.mdx b/docs/content/docs/authentication/paybin.mdx index 8498dbc3c0..8cc4675e3b 100644 --- a/docs/content/docs/authentication/paybin.mdx +++ b/docs/content/docs/authentication/paybin.mdx @@ -89,7 +89,3 @@ 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 f66d31c9cc..5e6e920fe0 100644 --- a/docs/content/docs/authentication/twitch.mdx +++ b/docs/content/docs/authentication/twitch.mdx @@ -56,7 +56,3 @@ 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/social-providers/paybin.test.ts b/packages/core/src/social-providers/paybin.test.ts index bfb36caf9d..e884527f88 100644 --- a/packages/core/src/social-providers/paybin.test.ts +++ b/packages/core/src/social-providers/paybin.test.ts @@ -1,246 +1,34 @@ -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 { UnsecuredJWT } from "jose"; +import { describe, expect, it } from "vitest"; 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 + * @see https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse */ 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 }, + it("maps the profile from a stored ID token after it expires", async () => { + const profile = { + sub: "provider-user", + name: "Provider User", + email: "user@example.com", + email_verified: true, + picture: "https://example.com/avatar.png", + exp: Math.floor(Date.now() / 1000) - 3600, }; - const getUserInfo = vi.fn(async () => custom); - const tokens = { idToken: "custom-token" }; - expect(await paybin({ ...options, getUserInfo }).getUserInfo(tokens)).toBe( - custom, - ); - expect(getUserInfo).toHaveBeenCalledWith(tokens); + const result = await paybin({ + clientId: "paybin-client", + clientSecret: "client-secret", + }).getUserInfo({ + idToken: new UnsecuredJWT(profile).encode(), + accessToken: "refreshed-access-token", + }); + + expect(result?.user).toEqual({ + name: profile.name, + email: profile.email, + image: profile.picture, + emailVerified: true, + }); }); - - 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 321aa4b0a9..61a286ab4c 100644 --- a/packages/core/src/social-providers/paybin.ts +++ b/packages/core/src/social-providers/paybin.ts @@ -1,4 +1,4 @@ -import { errors as joseErrors } from "jose"; +import { decodeJwt } from "jose"; import { logger } from "../env"; import { BetterAuthError } from "../error"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; @@ -6,9 +6,7 @@ import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, - validateToken, } from "../oauth2"; -import { ID_TOKEN_CLOCK_TOLERANCE_SECONDS } from "../oauth2/verify-id-token"; export interface PaybinProfile { sub: string; @@ -104,48 +102,7 @@ export const paybin = (options: PaybinOptions) => { if (!token.idToken) { return null; } - 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 user = decodeJwt(token.idToken) as PaybinProfile; 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 index 1b1959dca0..9370c7da2d 100644 --- a/packages/core/src/social-providers/twitch.test.ts +++ b/packages/core/src/social-providers/twitch.test.ts @@ -1,219 +1,35 @@ -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 { UnsecuredJWT } from "jose"; +import { describe, expect, it } from "vitest"; 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 + * @see https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse + * @see https://dev.twitch.tv/docs/authentication/refresh-tokens/ */ 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 }, + it("maps the profile from a stored ID token after it expires", async () => { + const profile = { + sub: "provider-user", + preferred_username: "Provider User", + email: "user@example.com", + email_verified: true, + picture: "https://example.com/avatar.png", + exp: Math.floor(Date.now() / 1000) - 3600, }; - const getUserInfo = vi.fn(async () => custom); - const tokens = { idToken: "custom-token" }; - expect(await twitch({ ...options, getUserInfo }).getUserInfo(tokens)).toBe( - custom, - ); - expect(getUserInfo).toHaveBeenCalledWith(tokens); + const result = await twitch({ + clientId: "twitch-client", + clientSecret: "client-secret", + }).getUserInfo({ + idToken: new UnsecuredJWT(profile).encode(), + accessToken: "refreshed-access-token", + }); + + expect(result?.user).toEqual({ + name: profile.preferred_username, + email: profile.email, + image: profile.picture, + emailVerified: true, + }); }); }); diff --git a/packages/core/src/social-providers/twitch.ts b/packages/core/src/social-providers/twitch.ts index bf9eb2d7ab..a5685de98a 100644 --- a/packages/core/src/social-providers/twitch.ts +++ b/packages/core/src/social-providers/twitch.ts @@ -1,13 +1,11 @@ -import { errors as joseErrors } from "jose"; +import { decodeJwt } 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 @@ -97,46 +95,7 @@ export const twitch = (options: TwitchOptions) => { logger.error("No idToken found in token"); return null; } - 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 profile = decodeJwt(idToken) as TwitchProfile; const userMap = await options.mapProfileToUser?.(profile); return { user: {