fix(oauth): restore Twitch and Paybin ID token handling (#11920)

This commit is contained in:
Taesu
2026-10-19 20:24:43 +00:00
committed by GitHub
parent f9ac706602
commit 492134aeba
7 changed files with 60 additions and 543 deletions
+5
View File
@@ -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.
@@ -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 `<issuer>/.well-known/jwks.json`. Custom `getUserInfo` overrides replace these checks.
@@ -56,7 +56,3 @@ description: Twitch provider setup and usage.
</Callout>
</Step>
</Steps>
### 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.
+25 -237
View File
@@ -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<typeof fetch>();
const mockedWarn = vi.fn<typeof logger.warn>();
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, unknown> | 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);
},
);
});
+2 -45
View File
@@ -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<PaybinProfile>(
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: {
+26 -210
View File
@@ -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<typeof fetch>();
const mockedWarn = vi.fn<typeof logger.warn>();
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, unknown> | 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,
});
});
});
+2 -43
View File
@@ -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<TwitchProfile>(
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: {