@better-auth/core
Advanced tools
@@ -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 |
+5
-5
| { | ||
| "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` */ |
+62
-11
@@ -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, |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
976148
1.4%21113
1.47%5
-44.44%