fix(oauth): verify Twitch and Paybin ID tokens (#11884)

This commit is contained in:
Taesu
2026-10-18 23:04:24 +00:00
committed by GitHub
parent 9c4e3d75c2
commit dfd8dd287a
12 changed files with 787 additions and 105 deletions
+5
View File
@@ -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 `<issuer>/.well-known/jwks.json`.
@@ -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 `<issuer>/.well-known/jwks.json`. Custom `getUserInfo` overrides replace these checks.
@@ -56,3 +56,7 @@ 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.
@@ -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<typeof fetch>();
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/);
});
});
+1 -22
View File
@@ -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<T>(
const onError = options?.onError;
const result = await betterFetch<T>(url, {
...options,
...noFollowRedirect,
redirect: "manual",
async onError(context) {
if (isRedirectResponse(context.response)) redirected = true;
await onError?.(context);
@@ -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<PayloadType = JWTPayload>(
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<PayloadType>(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;
}
+102 -9
View File
@@ -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<typeof vi.fn>;
const jwksEndpoint = "https://example.com/.well-known/jwks";
const mockedFetch = vi.fn<typeof fetch>();
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);
});
});
});
+35 -21
View File
@@ -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<PayloadType = JWTPayload>(
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<PayloadType>(
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<JWTPayload> | 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<JWTPayload>(
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) {
@@ -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<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
*/
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);
},
);
});
+45 -2
View File
@@ -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<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 userMap = await options.mapProfileToUser?.(user);
return {
user: {
@@ -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<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
*/
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);
});
});
+43 -2
View File
@@ -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<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 userMap = await options.mapProfileToUser?.(profile);
return {
user: {