🚀. Socket Launch Week Day 3:Socket Firewall Now Blocks Malicious VS Code and Open VSX Extensions.Learn more
Sign In

@better-auth/core

Package Overview
Dependencies
Maintainers
2
Versions
147
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@better-auth/core - npm Package Compare versions

Comparing version
1.6.15
to
1.6.16
+1
-1
dist/context/global.mjs

@@ -5,3 +5,3 @@ //#region src/context/global.ts

const __context = {};
const __betterAuthVersion = "1.6.15";
const __betterAuthVersion = "1.6.16";
/**

@@ -8,0 +8,0 @@ * We store context instance in the globalThis.

@@ -30,3 +30,3 @@ //#region src/env/env-impl.ts

}
const nodeENV = typeof process !== "undefined" && process.env && process.env.NODE_ENV || "";
const nodeENV = env.NODE_ENV ?? "";
/** Detect if `NODE_ENV` environment variable is `production` */

@@ -33,0 +33,0 @@ const isProduction = nodeENV === "production";

@@ -5,3 +5,3 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";

const INSTRUMENTATION_SCOPE = "better-auth";
const INSTRUMENTATION_VERSION = "1.6.15";
const INSTRUMENTATION_VERSION = "1.6.16";
/**

@@ -8,0 +8,0 @@ * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth

@@ -17,2 +17,16 @@ import { JSONWebKeySet, JWTPayload, JWTVerifyOptions } from "jose";

force?: boolean;
/**
* Accept introspection responses that omit the `aud` claim even when a
* required `audience` is configured in `verifyOptions`.
*
* By default verification fails closed: if you configure an `audience` and
* the introspection response has no `aud` (or a mismatching one), the token
* is rejected. Some authorization servers legitimately omit `aud` from
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
* this if you trust the issuer to bind the token to this resource through
* another mechanism, as it skips the audience check in that case.
*
* @default false
*/
allowMissingAudience?: boolean;
}

@@ -19,0 +33,0 @@ /**

@@ -14,5 +14,10 @@ import { logger } from "../env/logger.mjs";

}
/** Last fetched jwks used locally in getJwks @internal */
let jwks;
const jwksCache = /* @__PURE__ */ new Map();
/**
* How long a cached JWKS is trusted before it is refetched
*
* @internal
*/
const JWKS_CACHE_TTL_MS = 300 * 1e3;
/**
* Performs local verification of an access token for your APIs.

@@ -41,4 +46,9 @@ *

if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
const kid = jwtHeaders.kid;
const cacheKey = opts.jwksFetch;
const cached = jwksCache.get(cacheKey);
const isFresh = cached ? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS : false;
const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
if (!cached || !isFresh || !hasKid) {
const jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);

@@ -48,4 +58,9 @@ return res.data;

if (!jwks) throw new Error("No jwks found");
jwksCache.set(cacheKey, {
jwks,
fetchedAt: Date.now()
});
return jwks;
}
return jwks;
return cached.jwks;
}

@@ -91,4 +106,5 @@ /**

const unsecuredJwt = new UnsecuredJWT(introspect).encode();
const { audience: _audience, ...verifyOptions } = opts.verifyOptions;
payload = (introspect.aud ? UnsecuredJWT.decode(unsecuredJwt, opts.verifyOptions) : UnsecuredJWT.decode(unsecuredJwt, verifyOptions)).payload;
const { audience: _audience, ...verifyOptionsNoAudience } = opts.verifyOptions;
const skipAudience = !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
payload = UnsecuredJWT.decode(unsecuredJwt, skipAudience ? verifyOptionsNoAudience : opts.verifyOptions).payload;
} catch (error) {

@@ -95,0 +111,0 @@ throw new Error(error);

@@ -10,2 +10,30 @@ import { BetterAuthError } from "../error/index.mjs";

//#region src/social-providers/facebook.ts
/**
* Validate an opaque Facebook access token against the configured app.
*
* Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
* token minted for any Facebook app returns that app's profile. Without this
* check, a token issued to an unrelated app could be presented to this
* app's direct sign-in path and accepted as proof of identity. We call the
* `debug_token` endpoint and require the token to be valid, bound to one of the
* configured client ids, and tied to a user.
*
* @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
*
* @returns the inspected token's `user_id` when the token is valid and bound to
* the configured app, otherwise `null`.
*/
async function verifyFacebookAccessToken(accessToken, options) {
const primaryClientId = getPrimaryClientId(options.clientId);
if (!primaryClientId || !options.clientSecret) return null;
const clientIds = Array.isArray(options.clientId) ? options.clientId : [options.clientId];
const { data, error } = await betterFetch("https://graph.facebook.com/debug_token", { query: {
input_token: accessToken,
access_token: `${primaryClientId}|${options.clientSecret}`
} });
if (error || !data?.data) return null;
const { is_valid, app_id, user_id } = data.data;
if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) return null;
return user_id;
}
const facebook = (options) => {

@@ -56,3 +84,3 @@ return {

}
return true;
return await verifyFacebookAccessToken(token, options) !== null;
},

@@ -98,2 +126,6 @@ refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => {

}
const accessToken = token.accessToken;
if (!accessToken) return null;
const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
if (!tokenUserId) return null;
const { data: profile, error } = await betterFetch("https://graph.facebook.com/me?fields=" + [

@@ -107,5 +139,6 @@ "id",

type: "Bearer",
token: token.accessToken
token: accessToken
} });
if (error) return null;
if (profile.id !== tokenUserId) return null;
const userMap = await options.mapProfileToUser?.(profile);

@@ -112,0 +145,0 @@ return {

@@ -40,3 +40,8 @@ import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";

/**
* The hosted domain of the user
* The hosted domain (Google Workspace) the user must belong to.
*
* This is sent to Google as the `hd` authorization hint and, when set, is
* also enforced against the `hd` claim of the returned id token/profile.
* Sign-in is rejected when the claim is missing or does not match, so this
* can be used to restrict sign-in to a Workspace domain.
*/

@@ -43,0 +48,0 @@ hd?: string | undefined;

@@ -76,2 +76,3 @@ import { APIError, BetterAuthError } from "../error/index.mjs";

if (nonce && jwtClaims.nonce !== nonce) return false;
if (options.hd && jwtClaims.hd !== options.hd) return false;
return true;

@@ -86,2 +87,6 @@ } catch {

const user = decodeJwt(token.idToken);
if (options.hd && user.hd !== options.hd) {
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not match the configured "hd" option "${options.hd}".`);
return null;
}
const userMap = await options.mapProfileToUser?.(user);

@@ -88,0 +93,0 @@ return {

@@ -31,3 +31,3 @@ import { AppleNonConformUser, AppleOptions, AppleProfile, apple, getApplePublicKey } from "./apple.mjs";

import { PaybinOptions, PaybinProfile, paybin } from "./paybin.mjs";
import { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal } from "./paypal.mjs";
import { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal } from "./paypal.mjs";
import { PolarOptions, PolarProfile, polar } from "./polar.mjs";

@@ -1835,2 +1835,2 @@ import { RailwayOptions, RailwayProfile, railway } from "./railway.mjs";

//#endregion
export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };

@@ -21,3 +21,3 @@ import { apple, getApplePublicKey } from "./apple.mjs";

import { paybin } from "./paybin.mjs";
import { paypal } from "./paypal.mjs";
import { getPayPalPublicKey, paypal } from "./paypal.mjs";
import { polar } from "./polar.mjs";

@@ -79,2 +79,2 @@ import { railway } from "./railway.mjs";

//#endregion
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };

@@ -128,3 +128,4 @@ import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";

};
declare const getPayPalPublicKey: (kid: string, jwksUri: string) => Promise<Uint8Array<ArrayBufferLike> | CryptoKey>;
//#endregion
export { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal };
export { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal };

@@ -1,2 +0,2 @@

import { BetterAuthError } from "../error/index.mjs";
import { APIError, BetterAuthError } from "../error/index.mjs";
import { logger } from "../env/logger.mjs";

@@ -6,4 +6,12 @@ import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";

import { betterFetch } from "@better-fetch/fetch";
import { decodeJwt } from "jose";
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
//#region src/social-providers/paypal.ts
/**
* ID token signing algorithms advertised by PayPal's OpenID configuration.
* Anything outside this allowlist is rejected so each token is only ever
* verified with the algorithm it was issued for.
*
* @see https://www.paypal.com/.well-known/openid-configuration
*/
const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"];
const paypal = (options) => {

@@ -14,2 +22,9 @@ const isSandbox = (options.environment || "sandbox") === "sandbox";

const userInfoEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo" : "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
/**
* Issuer and JWKS endpoints used to cryptographically verify ID tokens.
*
* @see https://www.paypal.com/.well-known/openid-configuration
*/
const issuer = isSandbox ? "https://www.sandbox.paypal.com" : "https://www.paypal.com";
const jwksEndpoint = isSandbox ? "https://api.sandbox.paypal.com/v1/oauth2/certs" : "https://api.paypal.com/v1/oauth2/certs";
return {

@@ -99,3 +114,15 @@ id: "paypal",

try {
return !!decodeJwt(token).sub;
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
if (!jwtAlg) return false;
if (!PAYPAL_ID_TOKEN_ALGORITHMS.includes(jwtAlg)) return false;
const key = jwtAlg === "HS256" ? new TextEncoder().encode(options.clientSecret) : kid ? await getPayPalPublicKey(kid, jwksEndpoint) : void 0;
if (!key) return false;
const { payload: jwtClaims } = await jwtVerify(token, key, {
algorithms: [jwtAlg],
issuer,
audience: options.clientId,
maxTokenAge: "1h"
});
if (nonce && jwtClaims.nonce !== nonce) return false;
return true;
} catch (error) {

@@ -142,3 +169,10 @@ logger.error("Failed to verify PayPal ID token:", error);

};
const getPayPalPublicKey = async (kid, jwksUri) => {
const { data } = await betterFetch(jwksUri);
if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" });
const jwk = data.keys.find((key) => key.kid === kid);
if (!jwk) throw new Error(`JWK with kid ${kid} not found`);
return await importJWK(jwk, jwk.alg);
};
//#endregion
export { paypal };
export { getPayPalPublicKey, paypal };

@@ -64,2 +64,3 @@ import { getOAuth2Tokens } from "../oauth2/utils.mjs";

const userMap = await options.mapProfileToUser?.(profile);
const email = userMap?.email || `${profile.id}@reddit.com`;
return {

@@ -69,6 +70,6 @@ user: {

name: profile.name,
email: profile.oauth_client_id,
emailVerified: profile.has_verified_email,
image: profile.icon_img?.split("?")[0],
...userMap
...userMap,
email,
emailVerified: userMap?.emailVerified ?? false
},

@@ -75,0 +76,0 @@ data: profile

{
"name": "@better-auth/core",
"version": "1.6.15",
"version": "1.6.16",
"description": "The most comprehensive authentication framework for TypeScript.",

@@ -156,7 +156,7 @@ "type": "module",

"@better-auth/utils": "0.4.1",
"@better-fetch/fetch": "1.1.21",
"@better-fetch/fetch": "1.2.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-base": "^1.30.0",
"@opentelemetry/sdk-trace-node": "^1.30.0",
"better-call": "1.3.5",
"better-call": "1.3.6",
"@cloudflare/workers-types": "^4.20250121.0",

@@ -170,5 +170,5 @@ "jose": "^6.1.3",

"@better-auth/utils": "0.4.1",
"@better-fetch/fetch": "1.1.21",
"@better-fetch/fetch": "1.2.2",
"@opentelemetry/api": "^1.9.0",
"better-call": "1.3.5",
"better-call": "1.3.6",
"@cloudflare/workers-types": ">=4",

@@ -175,0 +175,0 @@ "jose": "^6.1.0",

@@ -49,4 +49,3 @@ /// <reference types="node" />

export const nodeENV =
(typeof process !== "undefined" && process.env && process.env.NODE_ENV) || "";
export const nodeENV = env.NODE_ENV ?? "";

@@ -53,0 +52,0 @@ /** Detect if `NODE_ENV` environment variable is `production` */

@@ -28,5 +28,19 @@ import { betterFetch } from "@better-fetch/fetch";

/** Last fetched jwks used locally in getJwks @internal */
let jwks: JSONWebKeySet | undefined;
interface JwksCacheEntry {
jwks: JSONWebKeySet;
fetchedAt: number;
}
const jwksCache = new Map<
string | (() => Promise<JSONWebKeySet | undefined>),
JwksCacheEntry
>();
/**
* How long a cached JWKS is trusted before it is refetched
*
* @internal
*/
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
export interface VerifyAccessTokenRemote {

@@ -45,2 +59,16 @@ /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */

force?: boolean;
/**
* Accept introspection responses that omit the `aud` claim even when a
* required `audience` is configured in `verifyOptions`.
*
* By default verification fails closed: if you configure an `audience` and
* the introspection response has no `aud` (or a mismatching one), the token
* is rejected. Some authorization servers legitimately omit `aud` from
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
* this if you trust the issuer to bind the token to this resource through
* another mechanism, as it skips the audience check in that case.
*
* @default false
*/
allowMissingAudience?: boolean;
}

@@ -101,6 +129,17 @@

}
const kid = jwtHeaders.kid;
// Fetch jwks if not set or has a different kid than the one stored
if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
jwks =
const cacheKey = opts.jwksFetch;
const cached = jwksCache.get(cacheKey);
const isFresh = cached
? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS
: false;
const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
// Refetch when this source has no cached set, the cached set has expired, or
// it does not contain the token's kid (e.g. a newly rotated-in key). The
// cache is scoped to `cacheKey`, so a token is only ever matched against the
// key set published by its own source.
if (!cached || !isFresh || !hasKid) {
const jwks =
typeof opts.jwksFetch === "string"

@@ -120,5 +159,7 @@ ? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {

if (!jwks) throw new Error("No jwks found");
jwksCache.set(cacheKey, { jwks, fetchedAt: Date.now() });
return jwks;
}
return jwks;
return cached.jwks;
}

@@ -208,9 +249,19 @@

});
// Verifies payload using verify options (token valid through introspect)
// Verifies payload using verify options (token valid through introspect).
// Audience is enforced by default: when `verifyOptions.audience` is set
// but the introspection response omits `aud` (or it mismatches),
// `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
// token issued for a different resource/client on the same issuer would
// also pass. Only drop the audience check when the caller has explicitly
// opted in via `remoteVerify.allowMissingAudience`.
try {
const unsecuredJwt = new UnsecuredJWT(introspect).encode();
const { audience: _audience, ...verifyOptions } = opts.verifyOptions;
const verify = introspect.aud
? UnsecuredJWT.decode(unsecuredJwt, opts.verifyOptions)
: UnsecuredJWT.decode(unsecuredJwt, verifyOptions);
const { audience: _audience, ...verifyOptionsNoAudience } =
opts.verifyOptions;
const skipAudience =
!introspect.aud && opts.remoteVerify.allowMissingAudience === true;
const verify = UnsecuredJWT.decode(
unsecuredJwt,
skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
);
payload = verify.payload;

@@ -217,0 +268,0 @@ } catch (error) {

@@ -27,2 +27,54 @@ import { betterFetch } from "@better-fetch/fetch";

interface FacebookDebugTokenData {
app_id?: string;
is_valid?: boolean;
user_id?: string;
}
/**
* Validate an opaque Facebook access token against the configured app.
*
* Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
* token minted for any Facebook app returns that app's profile. Without this
* check, a token issued to an unrelated app could be presented to this
* app's direct sign-in path and accepted as proof of identity. We call the
* `debug_token` endpoint and require the token to be valid, bound to one of the
* configured client ids, and tied to a user.
*
* @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
*
* @returns the inspected token's `user_id` when the token is valid and bound to
* the configured app, otherwise `null`.
*/
async function verifyFacebookAccessToken(
accessToken: string,
options: FacebookOptions,
): Promise<string | null> {
const primaryClientId = getPrimaryClientId(options.clientId);
if (!primaryClientId || !options.clientSecret) {
return null;
}
const clientIds = Array.isArray(options.clientId)
? options.clientId
: [options.clientId];
const appAccessToken = `${primaryClientId}|${options.clientSecret}`;
const { data, error } = await betterFetch<{ data?: FacebookDebugTokenData }>(
"https://graph.facebook.com/debug_token",
{
query: {
input_token: accessToken,
access_token: appAccessToken,
},
},
);
if (error || !data?.data) {
return null;
}
const { is_valid, app_id, user_id } = data.data;
if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) {
return null;
}
return user_id;
}
export interface FacebookOptions extends ProviderOptions<FacebookProfile> {

@@ -121,3 +173,6 @@ clientId: string | string[];

/* access_token */
return true;
// An opaque access token carries no app binding of its own, so it
// must be validated against the configured app before it can be
// trusted as proof of identity.
return (await verifyFacebookAccessToken(token, options)) !== null;
},

@@ -183,2 +238,16 @@ refreshAccessToken: options.refreshAccessToken

// The profile is fetched with `accessToken`, which is the credential
// that actually proves identity here — and a separate request field
// from the `idToken`/token validated by `verifyIdToken`. Since an
// opaque token is not app-bound at `/me`, validate this exact token
// against the configured app before trusting the profile it returns.
const accessToken = token.accessToken;
if (!accessToken) {
return null;
}
const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
if (!tokenUserId) {
return null;
}
const fields = [

@@ -196,3 +265,3 @@ "id",

type: "Bearer",
token: token.accessToken,
token: accessToken,
},

@@ -204,2 +273,6 @@ },

}
// Bind the validated token to the profile it returned.
if (profile.id !== tokenUserId) {
return null;
}
const userMap = await options.mapProfileToUser?.(profile);

@@ -206,0 +279,0 @@ return {

@@ -51,3 +51,8 @@ import { betterFetch } from "@better-fetch/fetch";

/**
* The hosted domain of the user
* The hosted domain (Google Workspace) the user must belong to.
*
* This is sent to Google as the `hd` authorization hint and, when set, is
* also enforced against the `hd` claim of the returned id token/profile.
* Sign-in is rejected when the claim is missing or does not match, so this
* can be used to restrict sign-in to a Workspace domain.
*/

@@ -151,2 +156,11 @@ hd?: string | undefined;

// Google's `hd` authorization parameter is only a UI hint and can
// be removed or changed by the user. When a hosted domain is
// configured, the `hd` claim in the verified id token is the
// authoritative value and must match, otherwise accounts outside
// the workspace domain would be accepted.
if (options.hd && jwtClaims.hd !== options.hd) {
return false;
}
return true;

@@ -165,2 +179,14 @@ } catch {

const user = decodeJwt(token.idToken) as GoogleProfile;
// Enforce the configured hosted domain on the callback profile path
// as well. The `hd` claim must be present and match, since the
// authorization-time `hd` hint does not restrict which account signs
// in.
if (options.hd && user.hd !== options.hd) {
logger.error(
`Google sign-in rejected: id token hosted domain (hd) "${
user.hd ?? "<missing>"
}" does not match the configured "hd" option "${options.hd}".`,
);
return null;
}
const userMap = await options.mapProfileToUser?.(user);

@@ -167,0 +193,0 @@ return {

import { base64 } from "@better-auth/utils/base64";
import { betterFetch } from "@better-fetch/fetch";
import { decodeJwt } from "jose";
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import { logger } from "../env";
import { BetterAuthError } from "../error";
import { APIError, BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { createAuthorizationURL } from "../oauth2";
/**
* ID token signing algorithms advertised by PayPal's OpenID configuration.
* Anything outside this allowlist is rejected so each token is only ever
* verified with the algorithm it was issued for.
*
* @see https://www.paypal.com/.well-known/openid-configuration
*/
const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"] as const;
export interface PayPalProfile {

@@ -78,2 +87,15 @@ user_id: string;

/**
* Issuer and JWKS endpoints used to cryptographically verify ID tokens.
*
* @see https://www.paypal.com/.well-known/openid-configuration
*/
const issuer = isSandbox
? "https://www.sandbox.paypal.com"
: "https://www.paypal.com";
const jwksEndpoint = isSandbox
? "https://api.sandbox.paypal.com/v1/oauth2/certs"
: "https://api.paypal.com/v1/oauth2/certs";
return {

@@ -205,5 +227,44 @@ id: "paypal",

}
// Cryptographically verify the ID token. Decoding alone is not enough:
// the signature, issuer, audience and expiration must all be checked
// before the token's claims can be relied on as proof of identity.
// See https://www.paypal.com/.well-known/openid-configuration
try {
const payload = decodeJwt(token);
return !!payload.sub;
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
if (!jwtAlg) return false;
if (
!PAYPAL_ID_TOKEN_ALGORITHMS.includes(
jwtAlg as (typeof PAYPAL_ID_TOKEN_ALGORITHMS)[number],
)
) {
return false;
}
// PayPal can sign ID tokens either asymmetrically (RS256, verified
// against the published JWKS) or symmetrically (HS256, verified with
// the client secret). Selecting the key by algorithm keeps the two
// paths separate so each algorithm is only verified with its
// corresponding key type.
const key =
jwtAlg === "HS256"
? new TextEncoder().encode(options.clientSecret)
: kid
? await getPayPalPublicKey(kid, jwksEndpoint)
: undefined;
if (!key) return false;
const { payload: jwtClaims } = await jwtVerify(token, key, {
algorithms: [jwtAlg],
issuer,
audience: options.clientId,
maxTokenAge: "1h",
});
if (nonce && jwtClaims.nonce !== nonce) {
return false;
}
return true;
} catch (error) {

@@ -266,1 +327,27 @@ logger.error("Failed to verify PayPal ID token:", error);

};
export const getPayPalPublicKey = async (kid: string, jwksUri: string) => {
const { data } = await betterFetch<{
keys: Array<{
kid: string;
alg: string;
kty: string;
use: string;
n: string;
e: string;
}>;
}>(jwksUri);
if (!data?.keys) {
throw new APIError("BAD_REQUEST", {
message: "Keys not found",
});
}
const jwk = data.keys.find((key) => key.kid === kid);
if (!jwk) {
throw new Error(`JWK with kid ${kid} not found`);
}
return await importJWK(jwk, jwk.alg);
};

@@ -107,3 +107,3 @@ import { base64 } from "@better-auth/utils/base64";

const userMap = await options.mapProfileToUser?.(profile);
const email = userMap?.email || `${profile.id}@reddit.com`;
return {

@@ -113,6 +113,6 @@ user: {

name: profile.name,
email: profile.oauth_client_id,
emailVerified: profile.has_verified_email,
image: profile.icon_img?.split("?")[0]!,
...userMap,
email,
emailVerified: userMap?.emailVerified ?? false,
},

@@ -119,0 +119,0 @@ data: profile,