mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
fix(oauth): verify Twitch and Paybin ID tokens (#11884)
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user