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