@better-auth/core
Advanced tools
@@ -5,3 +5,3 @@ //#region src/context/global.ts | ||
| const __context = {}; | ||
| const __betterAuthVersion = "1.6.20"; | ||
| const __betterAuthVersion = "1.6.21"; | ||
| /** | ||
@@ -8,0 +8,0 @@ * We store context instance in the globalThis. |
@@ -462,2 +462,3 @@ import { getCurrentAdapter, runWithTransaction } from "../../context/transaction.mjs"; | ||
| }); | ||
| if (where.length === 0) return null; | ||
| debugLog({ method: "update" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`, `${formatMethod("update")} ${formatAction("Unsafe Input")}:`, { | ||
@@ -464,0 +465,0 @@ model, |
@@ -399,4 +399,10 @@ import { BetterAuthDBSchema, DBFieldAttribute } from "../type.mjs"; | ||
| /** | ||
| * ⚠︎ Update may not return the updated data | ||
| * if multiple where clauses are provided | ||
| * Update a single row matching the where clause. | ||
| * | ||
| * Returns the updated row, or `null` when no row matched. Empty `where` | ||
| * clauses return `null`; use `updateMany` for intentional bulk updates. | ||
| * | ||
| * This is not the race-safe primitive for guarded state transitions. Use | ||
| * `incrementOne` when the predicate is both selector and guard, and use | ||
| * `consumeOne` for single-use destructive reads. | ||
| */ | ||
@@ -403,0 +409,0 @@ update: <T>(data: { |
@@ -11,3 +11,4 @@ //#region src/db/get-tables.ts | ||
| }, | ||
| modelName: value.modelName || key | ||
| modelName: value.modelName || key, | ||
| disableMigrations: value.disableMigration ?? acc[key]?.disableMigrations | ||
| }; | ||
@@ -14,0 +15,0 @@ return acc; |
@@ -5,3 +5,3 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs"; | ||
| const INSTRUMENTATION_SCOPE = "better-auth"; | ||
| const INSTRUMENTATION_VERSION = "1.6.20"; | ||
| const INSTRUMENTATION_VERSION = "1.6.21"; | ||
| /** | ||
@@ -8,0 +8,0 @@ * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth |
| import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs"; | ||
| import { JWTPayload } from "jose"; | ||
| //#region src/social-providers/google.d.ts | ||
@@ -44,7 +46,26 @@ interface GoogleProfile { | ||
| * 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. | ||
| * Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is | ||
| * rejected when the claim is missing or does not satisfy this restriction. | ||
| */ | ||
| hd?: string | undefined; | ||
| } | ||
| interface VerifyGoogleIdTokenOptions { | ||
| token: string; | ||
| audience: string | string[]; | ||
| nonce?: string | undefined; | ||
| } | ||
| /** | ||
| * Verifies a Google ID token against Google's issuer, audience, signature, | ||
| * expiry, and maximum token age. | ||
| */ | ||
| declare const verifyGoogleIdToken: ({ | ||
| token, | ||
| audience, | ||
| nonce | ||
| }: VerifyGoogleIdTokenOptions) => Promise<JWTPayload | null>; | ||
| /** | ||
| * Checks whether Google's verified `hd` claim satisfies the configured hosted | ||
| * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain. | ||
| */ | ||
| declare const isGoogleHostedDomainAllowed: (configuredHostedDomain: string | undefined, tokenHostedDomain: unknown) => boolean; | ||
| declare const google: (options: GoogleOptions) => { | ||
@@ -103,2 +124,2 @@ id: "google"; | ||
| //#endregion | ||
| export { GoogleOptions, GoogleProfile, getGooglePublicKey, google }; | ||
| export { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken }; |
@@ -10,2 +10,33 @@ import { APIError, BetterAuthError } from "../error/index.mjs"; | ||
| //#region src/social-providers/google.ts | ||
| const GOOGLE_ID_TOKEN_MAX_AGE = "1h"; | ||
| /** | ||
| * Verifies a Google ID token against Google's issuer, audience, signature, | ||
| * expiry, and maximum token age. | ||
| */ | ||
| const verifyGoogleIdToken = async ({ token, audience, nonce }) => { | ||
| try { | ||
| const { kid, alg: jwtAlg } = decodeProtectedHeader(token); | ||
| if (!kid || !jwtAlg) return null; | ||
| const { payload: jwtClaims } = await jwtVerify(token, await getGooglePublicKey(kid), { | ||
| algorithms: [jwtAlg], | ||
| issuer: ["https://accounts.google.com", "accounts.google.com"], | ||
| audience, | ||
| maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE | ||
| }); | ||
| if (nonce && jwtClaims.nonce !== nonce) return null; | ||
| return jwtClaims; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
| /** | ||
| * Checks whether Google's verified `hd` claim satisfies the configured hosted | ||
| * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain. | ||
| */ | ||
| const isGoogleHostedDomainAllowed = (configuredHostedDomain, tokenHostedDomain) => { | ||
| if (!configuredHostedDomain) return true; | ||
| if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) return false; | ||
| if (configuredHostedDomain === "*") return true; | ||
| return tokenHostedDomain === configuredHostedDomain; | ||
| }; | ||
| const google = (options) => { | ||
@@ -67,17 +98,9 @@ return { | ||
| if (options.verifyIdToken) return options.verifyIdToken(token, nonce); | ||
| try { | ||
| const { kid, alg: jwtAlg } = decodeProtectedHeader(token); | ||
| if (!kid || !jwtAlg) return false; | ||
| const { payload: jwtClaims } = await jwtVerify(token, await getGooglePublicKey(kid), { | ||
| algorithms: [jwtAlg], | ||
| issuer: ["https://accounts.google.com", "accounts.google.com"], | ||
| audience: options.clientId, | ||
| maxTokenAge: "1h" | ||
| }); | ||
| if (nonce && jwtClaims.nonce !== nonce) return false; | ||
| if (options.hd && jwtClaims.hd !== options.hd) return false; | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| const jwtClaims = await verifyGoogleIdToken({ | ||
| token, | ||
| audience: options.clientId, | ||
| nonce | ||
| }); | ||
| if (!jwtClaims) return false; | ||
| return isGoogleHostedDomainAllowed(options.hd, jwtClaims.hd); | ||
| }, | ||
@@ -88,4 +111,4 @@ async getUserInfo(token) { | ||
| 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}".`); | ||
| if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) { | ||
| logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${options.hd}".`); | ||
| return null; | ||
@@ -117,2 +140,2 @@ } | ||
| //#endregion | ||
| export { getGooglePublicKey, google }; | ||
| export { getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken }; |
@@ -9,3 +9,3 @@ import { AppleNonConformUser, AppleOptions, AppleProfile, apple, getApplePublicKey } from "./apple.mjs"; | ||
| import { MicrosoftEntraIDProfile, MicrosoftOptions, getMicrosoftPublicKey, microsoft } from "./microsoft-entra-id.mjs"; | ||
| import { GoogleOptions, GoogleProfile, getGooglePublicKey, google } from "./google.mjs"; | ||
| import { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "./google.mjs"; | ||
| import { HuggingFaceOptions, HuggingFaceProfile, huggingface } from "./huggingface.mjs"; | ||
@@ -1835,2 +1835,2 @@ import { SlackOptions, SlackProfile, slack } from "./slack.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, 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 }; | ||
| 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, VerifyGoogleIdTokenOptions, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, isGoogleHostedDomainAllowed, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, verifyGoogleIdToken, vk, wechat, zoom }; |
@@ -10,3 +10,3 @@ import { apple, getApplePublicKey } from "./apple.mjs"; | ||
| import { gitlab } from "./gitlab.mjs"; | ||
| import { getGooglePublicKey, google } from "./google.mjs"; | ||
| import { getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "./google.mjs"; | ||
| import { huggingface } from "./huggingface.mjs"; | ||
@@ -79,2 +79,2 @@ import { kakao } from "./kakao.mjs"; | ||
| //#endregion | ||
| 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 }; | ||
| export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, isGoogleHostedDomainAllowed, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, verifyGoogleIdToken, vk, wechat, zoom }; |
| import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs"; | ||
| //#region src/social-providers/paypal.d.ts | ||
| interface PayPalProfile { | ||
| sub?: string | undefined; | ||
| user_id: string; | ||
@@ -5,0 +6,0 @@ name: string; |
@@ -6,3 +6,3 @@ import { APIError, BetterAuthError } from "../error/index.mjs"; | ||
| import { betterFetch } from "@better-fetch/fetch"; | ||
| import { decodeProtectedHeader, importJWK, jwtVerify } from "jose"; | ||
| import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; | ||
| //#region src/social-providers/paypal.ts | ||
@@ -147,2 +147,16 @@ /** | ||
| const userInfo = response.data; | ||
| if (token.idToken) { | ||
| let idTokenSubject; | ||
| try { | ||
| idTokenSubject = decodeJwt(token.idToken).sub; | ||
| } catch (error) { | ||
| logger.error("Failed to decode PayPal ID token:", error); | ||
| return null; | ||
| } | ||
| const userInfoSubject = userInfo.sub ?? userInfo.user_id; | ||
| if (!idTokenSubject || userInfoSubject !== idTokenSubject) { | ||
| logger.error("PayPal user info subject does not match ID token subject"); | ||
| return null; | ||
| } | ||
| } | ||
| const userMap = await options.mapProfileToUser?.(userInfo); | ||
@@ -149,0 +163,0 @@ return { |
@@ -179,3 +179,3 @@ import { DBFieldAttribute, ModelNames, SecondaryStorage } from "../db/type.mjs"; | ||
| * @default | ||
| * @link https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/utils/get-request-ip.ts#L8 | ||
| * @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts | ||
| */ | ||
@@ -197,2 +197,17 @@ ipAddressHeaders?: string[]; | ||
| ipv6Subnet?: number; | ||
| /** | ||
| * Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP | ||
| * chain is walked right to left, trusted hops are skipped, and the | ||
| * first untrusted address is the client IP. Unset trusts only | ||
| * single-value IP headers. Use the actual address or subnet of your | ||
| * proxies, not a broad private range that also covers clients. | ||
| * | ||
| * This only interprets the forwarded header chain and cannot verify | ||
| * the direct sender. It is safe only when your origin is reachable | ||
| * through these proxies and clients cannot set forwarded headers | ||
| * directly. | ||
| * | ||
| * @example ["192.0.2.10", "10.0.0.0/24"] | ||
| */ | ||
| trustedProxies?: string[]; | ||
| } | undefined; | ||
@@ -199,0 +214,0 @@ /** |
+23
-1
@@ -0,1 +1,2 @@ | ||
| import { BetterAuthOptions } from "../types/init-options.mjs"; | ||
| //#region src/utils/ip.d.ts | ||
@@ -46,2 +47,23 @@ /** | ||
| /** | ||
| * Trusted-proxy entries that are not a valid IP address or CIDR range. | ||
| */ | ||
| declare function findInvalidTrustedProxies(entries: string[]): string[]; | ||
| /** | ||
| * Resolves the client IP from a forwarded header. The leftmost token is spoofable, | ||
| * so with `trustedProxies` the chain is stripped from the right to the first | ||
| * untrusted hop. Otherwise only a single-value header is trusted. Returns `null` | ||
| * when no trustworthy client IP can be resolved. | ||
| */ | ||
| declare function getIPFromHeader(value: string, options?: { | ||
| ipv6Subnet?: number; | ||
| trustedProxies?: string[]; | ||
| }): string | null; | ||
| /** | ||
| * Resolves the client IP for a request from the configured IP headers. | ||
| * Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default | ||
| * `x-forwarded-for`), and falls back to localhost in development and test. | ||
| * Returns `null` when tracking is disabled or no trustworthy IP can be resolved. | ||
| */ | ||
| declare function getIp(req: Request | Headers, options: BetterAuthOptions): string | null; | ||
| /** | ||
| * Creates a rate limit key from IP and path | ||
@@ -56,2 +78,2 @@ * Uses a separator to prevent collision attacks | ||
| //#endregion | ||
| export { createRateLimitKey, isValidIP, normalizeIP }; | ||
| export { createRateLimitKey, findInvalidTrustedProxies, getIPFromHeader, getIp, isValidIP, normalizeIP }; |
+115
-1
@@ -0,1 +1,2 @@ | ||
| import { isDevelopment, isTest } from "../env/env-impl.mjs"; | ||
| import * as z from "zod"; | ||
@@ -105,2 +106,115 @@ //#region src/utils/ip.ts | ||
| /** | ||
| * Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP. | ||
| */ | ||
| function ipToBytes(ip) { | ||
| if (z.ipv4().safeParse(ip).success) return Uint8Array.from(ip.split(".").map((octet) => Number(octet))); | ||
| if (!isIPv6(ip)) return null; | ||
| const mapped = extractIPv4FromMapped(ip); | ||
| if (mapped) return Uint8Array.from(mapped.split(".").map((octet) => Number(octet))); | ||
| const groups = expandIPv6(ip); | ||
| const bytes = new Uint8Array(16); | ||
| for (let i = 0; i < 8; i++) { | ||
| const group = Number.parseInt(groups[i] ?? "0", 16); | ||
| bytes[i * 2] = group >> 8 & 255; | ||
| bytes[i * 2 + 1] = group & 255; | ||
| } | ||
| return bytes; | ||
| } | ||
| const CIDR_PREFIX_PATTERN = /^\d+$/; | ||
| /** | ||
| * Parses an IP or `IP/prefix` string into network bytes and a prefix length. | ||
| * The prefix must be digits only and within the address family. `null` if the | ||
| * value is not a valid IP or CIDR range, which keeps a malformed entry from | ||
| * silently behaving like a non-match. | ||
| */ | ||
| function parseCIDR(value) { | ||
| const slash = value.lastIndexOf("/"); | ||
| const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash)); | ||
| if (!bytes) return null; | ||
| const maxBits = bytes.length * 8; | ||
| if (slash === -1) return { | ||
| bytes, | ||
| prefix: maxBits | ||
| }; | ||
| const prefixPart = value.slice(slash + 1); | ||
| if (!CIDR_PREFIX_PATTERN.test(prefixPart)) return null; | ||
| const prefix = Number(prefixPart); | ||
| return prefix <= maxBits ? { | ||
| bytes, | ||
| prefix | ||
| } : null; | ||
| } | ||
| /** | ||
| * Whether `ipBytes` falls inside an already-parsed CIDR network. | ||
| */ | ||
| function matchesCIDR(ipBytes, net) { | ||
| if (ipBytes.length !== net.bytes.length) return false; | ||
| let bitsRemaining = net.prefix; | ||
| for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) { | ||
| const take = bitsRemaining >= 8 ? 8 : bitsRemaining; | ||
| const mask = take === 8 ? 255 : 255 << 8 - take & 255; | ||
| if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) return false; | ||
| bitsRemaining -= 8; | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * Trusted-proxy entries that are not a valid IP address or CIDR range. | ||
| */ | ||
| function findInvalidTrustedProxies(entries) { | ||
| return entries.filter((entry) => parseCIDR(entry) === null); | ||
| } | ||
| /** | ||
| * Resolves the client IP from a forwarded header. The leftmost token is spoofable, | ||
| * so with `trustedProxies` the chain is stripped from the right to the first | ||
| * untrusted hop. Otherwise only a single-value header is trusted. Returns `null` | ||
| * when no trustworthy client IP can be resolved. | ||
| */ | ||
| function getIPFromHeader(value, options = {}) { | ||
| const forwardedIps = value.split(",").map((ip) => ip.trim()).filter(Boolean); | ||
| if (forwardedIps.length === 0) return null; | ||
| const trustedProxies = (options.trustedProxies ?? []).map(parseCIDR).filter((proxy) => { | ||
| return proxy !== null; | ||
| }); | ||
| if (trustedProxies.length > 0) { | ||
| for (let i = forwardedIps.length - 1; i >= 0; i--) { | ||
| const ip = forwardedIps[i]; | ||
| const ipBytes = ip ? ipToBytes(ip) : null; | ||
| if (!ip || !ipBytes) return null; | ||
| if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) continue; | ||
| return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet }); | ||
| } | ||
| return null; | ||
| } | ||
| if (forwardedIps.length !== 1) return null; | ||
| const selectedIp = forwardedIps[0]; | ||
| if (!selectedIp || !isValidIP(selectedIp)) return null; | ||
| return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet }); | ||
| } | ||
| const LOCALHOST_IP = "127.0.0.1"; | ||
| const DEFAULT_IP_HEADERS = ["x-forwarded-for"]; | ||
| /** | ||
| * Resolves the client IP for a request from the configured IP headers. | ||
| * Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default | ||
| * `x-forwarded-for`), and falls back to localhost in development and test. | ||
| * Returns `null` when tracking is disabled or no trustworthy IP can be resolved. | ||
| */ | ||
| function getIp(req, options) { | ||
| if (options.advanced?.ipAddress?.disableIpTracking) return null; | ||
| const headers = "headers" in req ? req.headers : req; | ||
| const ipHeaders = options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS; | ||
| for (const key of ipHeaders) { | ||
| const value = "get" in headers ? headers.get(key) : headers[key]; | ||
| if (typeof value === "string") { | ||
| const ip = getIPFromHeader(value, { | ||
| ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet, | ||
| trustedProxies: options.advanced?.ipAddress?.trustedProxies | ||
| }); | ||
| if (ip) return ip; | ||
| } | ||
| } | ||
| if (isTest() || isDevelopment()) return LOCALHOST_IP; | ||
| return null; | ||
| } | ||
| /** | ||
| * Creates a rate limit key from IP and path | ||
@@ -117,2 +231,2 @@ * Uses a separator to prevent collision attacks | ||
| //#endregion | ||
| export { createRateLimitKey, isValidIP, normalizeIP }; | ||
| export { createRateLimitKey, findInvalidTrustedProxies, getIPFromHeader, getIp, isValidIP, normalizeIP }; |
+3
-3
| { | ||
| "name": "@better-auth/core", | ||
| "version": "1.6.20", | ||
| "version": "1.6.21", | ||
| "description": "The most comprehensive authentication framework for TypeScript.", | ||
@@ -160,3 +160,3 @@ "type": "module", | ||
| "@opentelemetry/sdk-trace-node": "^1.30.0", | ||
| "better-call": "1.3.6", | ||
| "better-call": "1.3.7", | ||
| "@cloudflare/workers-types": "^4.20250121.0", | ||
@@ -172,3 +172,3 @@ "jose": "^6.1.3", | ||
| "@opentelemetry/api": "^1.9.0", | ||
| "better-call": "1.3.6", | ||
| "better-call": "1.3.7", | ||
| "@cloudflare/workers-types": ">=4", | ||
@@ -175,0 +175,0 @@ "jose": "^6.1.0", |
@@ -970,2 +970,7 @@ import { | ||
| }); | ||
| // `update` targets a single row. Empty predicates have no | ||
| // target, so fail closed and leave bulk writes to `updateMany`. | ||
| if (where.length === 0) { | ||
| return null; | ||
| } | ||
| debugLog( | ||
@@ -972,0 +977,0 @@ { method: "update" }, |
@@ -437,4 +437,10 @@ import type { BetterAuthOptions } from "../../types"; | ||
| /** | ||
| * ⚠︎ Update may not return the updated data | ||
| * if multiple where clauses are provided | ||
| * Update a single row matching the where clause. | ||
| * | ||
| * Returns the updated row, or `null` when no row matched. Empty `where` | ||
| * clauses return `null`; use `updateMany` for intentional bulk updates. | ||
| * | ||
| * This is not the race-safe primitive for guarded state transitions. Use | ||
| * `incrementOne` when the predicate is both selector and guard, and use | ||
| * `consumeOne` for single-use destructive reads. | ||
| */ | ||
@@ -441,0 +447,0 @@ update: <T>(data: { |
@@ -18,2 +18,4 @@ import type { BetterAuthOptions } from "../types"; | ||
| modelName: value.modelName || key, | ||
| disableMigrations: | ||
| value.disableMigration ?? acc[key]?.disableMigrations, | ||
| }; | ||
@@ -25,3 +27,7 @@ } | ||
| string, | ||
| { fields: Record<string, DBFieldAttribute>; modelName: string } | ||
| { | ||
| fields: Record<string, DBFieldAttribute>; | ||
| modelName: string; | ||
| disableMigrations?: boolean | undefined; | ||
| } | ||
| >, | ||
@@ -28,0 +34,0 @@ ); |
| import { betterFetch } from "@better-fetch/fetch"; | ||
| import type { JWTPayload } from "jose"; | ||
| import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; | ||
@@ -55,4 +56,4 @@ import { logger } from "../env"; | ||
| * 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. | ||
| * Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is | ||
| * rejected when the claim is missing or does not satisfy this restriction. | ||
| */ | ||
@@ -62,2 +63,57 @@ hd?: string | undefined; | ||
| const GOOGLE_ID_TOKEN_MAX_AGE = "1h"; | ||
| export interface VerifyGoogleIdTokenOptions { | ||
| token: string; | ||
| audience: string | string[]; | ||
| nonce?: string | undefined; | ||
| } | ||
| /** | ||
| * Verifies a Google ID token against Google's issuer, audience, signature, | ||
| * expiry, and maximum token age. | ||
| */ | ||
| export const verifyGoogleIdToken = async ({ | ||
| token, | ||
| audience, | ||
| nonce, | ||
| }: VerifyGoogleIdTokenOptions): Promise<JWTPayload | null> => { | ||
| try { | ||
| const { kid, alg: jwtAlg } = decodeProtectedHeader(token); | ||
| if (!kid || !jwtAlg) return null; | ||
| const publicKey = await getGooglePublicKey(kid); | ||
| const { payload: jwtClaims } = await jwtVerify(token, publicKey, { | ||
| algorithms: [jwtAlg], | ||
| issuer: ["https://accounts.google.com", "accounts.google.com"], | ||
| audience, | ||
| maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE, | ||
| }); | ||
| if (nonce && jwtClaims.nonce !== nonce) { | ||
| return null; | ||
| } | ||
| return jwtClaims; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
| /** | ||
| * Checks whether Google's verified `hd` claim satisfies the configured hosted | ||
| * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain. | ||
| */ | ||
| export const isGoogleHostedDomainAllowed = ( | ||
| configuredHostedDomain: string | undefined, | ||
| tokenHostedDomain: unknown, | ||
| ) => { | ||
| if (!configuredHostedDomain) return true; | ||
| if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) { | ||
| return false; | ||
| } | ||
| if (configuredHostedDomain === "*") return true; | ||
| return tokenHostedDomain === configuredHostedDomain; | ||
| }; | ||
| export const google = (options: GoogleOptions) => { | ||
@@ -138,34 +194,12 @@ return { | ||
| // Verify JWT integrity | ||
| // See https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token | ||
| try { | ||
| const { kid, alg: jwtAlg } = decodeProtectedHeader(token); | ||
| if (!kid || !jwtAlg) return false; | ||
| const publicKey = await getGooglePublicKey(kid); | ||
| const { payload: jwtClaims } = await jwtVerify(token, publicKey, { | ||
| algorithms: [jwtAlg], | ||
| issuer: ["https://accounts.google.com", "accounts.google.com"], | ||
| audience: options.clientId, | ||
| maxTokenAge: "1h", | ||
| }); | ||
| if (nonce && jwtClaims.nonce !== nonce) { | ||
| return false; | ||
| } | ||
| // 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; | ||
| } catch { | ||
| const jwtClaims = await verifyGoogleIdToken({ | ||
| token, | ||
| audience: options.clientId, | ||
| nonce, | ||
| }); | ||
| if (!jwtClaims) { | ||
| return false; | ||
| } | ||
| return isGoogleHostedDomainAllowed(options.hd, jwtClaims.hd); | ||
| }, | ||
@@ -180,11 +214,10 @@ async getUserInfo(token) { | ||
| 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) { | ||
| // Enforce the configured hosted domain on the callback profile path. | ||
| // The authorization-time `hd` value is only a UI hint; the verified | ||
| // token/profile claim is the authoritative Workspace signal. | ||
| if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) { | ||
| logger.error( | ||
| `Google sign-in rejected: id token hosted domain (hd) "${ | ||
| user.hd ?? "<missing>" | ||
| }" does not match the configured "hd" option "${options.hd}".`, | ||
| }" does not satisfy the configured "hd" option "${options.hd}".`, | ||
| ); | ||
@@ -191,0 +224,0 @@ return null; |
| import { base64 } from "@better-auth/utils/base64"; | ||
| import { betterFetch } from "@better-fetch/fetch"; | ||
| import { decodeProtectedHeader, importJWK, jwtVerify } from "jose"; | ||
| import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; | ||
| import { logger } from "../env"; | ||
@@ -19,2 +19,3 @@ import { APIError, BetterAuthError } from "../error"; | ||
| export interface PayPalProfile { | ||
| sub?: string | undefined; | ||
| user_id: string; | ||
@@ -302,2 +303,22 @@ name: string; | ||
| const userInfo = response.data; | ||
| if (token.idToken) { | ||
| let idTokenSubject: string | undefined; | ||
| try { | ||
| idTokenSubject = decodeJwt(token.idToken).sub; | ||
| } catch (error) { | ||
| logger.error("Failed to decode PayPal ID token:", error); | ||
| return null; | ||
| } | ||
| // OIDC binds UserInfo to the ID Token with `sub`. Keep `user_id` | ||
| // as the account id below for existing PayPal account mappings. | ||
| const userInfoSubject = userInfo.sub ?? userInfo.user_id; | ||
| if (!idTokenSubject || userInfoSubject !== idTokenSubject) { | ||
| logger.error( | ||
| "PayPal user info subject does not match ID token subject", | ||
| ); | ||
| return null; | ||
| } | ||
| } | ||
| const userMap = await options.mapProfileToUser?.(userInfo); | ||
@@ -304,0 +325,0 @@ |
@@ -226,3 +226,3 @@ import type { Database as BunDatabase } from "bun:sqlite"; | ||
| * @default | ||
| * @link https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/utils/get-request-ip.ts#L8 | ||
| * @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts | ||
| */ | ||
@@ -244,2 +244,17 @@ ipAddressHeaders?: string[]; | ||
| ipv6Subnet?: number; | ||
| /** | ||
| * Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP | ||
| * chain is walked right to left, trusted hops are skipped, and the | ||
| * first untrusted address is the client IP. Unset trusts only | ||
| * single-value IP headers. Use the actual address or subnet of your | ||
| * proxies, not a broad private range that also covers clients. | ||
| * | ||
| * This only interprets the forwarded header chain and cannot verify | ||
| * the direct sender. It is safe only when your origin is reachable | ||
| * through these proxies and clients cannot set forwarded headers | ||
| * directly. | ||
| * | ||
| * @example ["192.0.2.10", "10.0.0.0/24"] | ||
| */ | ||
| trustedProxies?: string[]; | ||
| } | ||
@@ -246,0 +261,0 @@ | undefined; |
+185
-0
| import * as z from "zod"; | ||
| import { isDevelopment, isTest } from "../env"; | ||
| import type { BetterAuthOptions } from "../types"; | ||
@@ -199,2 +201,185 @@ /** | ||
| /** | ||
| * Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP. | ||
| */ | ||
| function ipToBytes(ip: string): Uint8Array | null { | ||
| if (z.ipv4().safeParse(ip).success) { | ||
| return Uint8Array.from(ip.split(".").map((octet) => Number(octet))); | ||
| } | ||
| if (!isIPv6(ip)) { | ||
| return null; | ||
| } | ||
| const mapped = extractIPv4FromMapped(ip); | ||
| if (mapped) { | ||
| return Uint8Array.from(mapped.split(".").map((octet) => Number(octet))); | ||
| } | ||
| const groups = expandIPv6(ip); | ||
| const bytes = new Uint8Array(16); | ||
| for (let i = 0; i < 8; i++) { | ||
| const group = Number.parseInt(groups[i] ?? "0", 16); | ||
| bytes[i * 2] = (group >> 8) & 0xff; | ||
| bytes[i * 2 + 1] = group & 0xff; | ||
| } | ||
| return bytes; | ||
| } | ||
| // A CIDR prefix length must be decimal digits only, so values like "8x" or | ||
| // "1e3" that `Number()` would otherwise coerce are rejected. | ||
| const CIDR_PREFIX_PATTERN = /^\d+$/; | ||
| /** | ||
| * Parses an IP or `IP/prefix` string into network bytes and a prefix length. | ||
| * The prefix must be digits only and within the address family. `null` if the | ||
| * value is not a valid IP or CIDR range, which keeps a malformed entry from | ||
| * silently behaving like a non-match. | ||
| */ | ||
| function parseCIDR( | ||
| value: string, | ||
| ): { bytes: Uint8Array; prefix: number } | null { | ||
| const slash = value.lastIndexOf("/"); | ||
| const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash)); | ||
| if (!bytes) { | ||
| return null; | ||
| } | ||
| const maxBits = bytes.length * 8; | ||
| if (slash === -1) { | ||
| return { bytes, prefix: maxBits }; | ||
| } | ||
| const prefixPart = value.slice(slash + 1); | ||
| if (!CIDR_PREFIX_PATTERN.test(prefixPart)) { | ||
| return null; | ||
| } | ||
| const prefix = Number(prefixPart); | ||
| return prefix <= maxBits ? { bytes, prefix } : null; | ||
| } | ||
| /** | ||
| * Whether `ipBytes` falls inside an already-parsed CIDR network. | ||
| */ | ||
| function matchesCIDR( | ||
| ipBytes: Uint8Array, | ||
| net: { bytes: Uint8Array; prefix: number }, | ||
| ): boolean { | ||
| if (ipBytes.length !== net.bytes.length) { | ||
| return false; | ||
| } | ||
| let bitsRemaining = net.prefix; | ||
| for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) { | ||
| const take = bitsRemaining >= 8 ? 8 : bitsRemaining; | ||
| const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff; | ||
| if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) { | ||
| return false; | ||
| } | ||
| bitsRemaining -= 8; | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * Trusted-proxy entries that are not a valid IP address or CIDR range. | ||
| */ | ||
| export function findInvalidTrustedProxies(entries: string[]): string[] { | ||
| return entries.filter((entry) => parseCIDR(entry) === null); | ||
| } | ||
| /** | ||
| * Resolves the client IP from a forwarded header. The leftmost token is spoofable, | ||
| * so with `trustedProxies` the chain is stripped from the right to the first | ||
| * untrusted hop. Otherwise only a single-value header is trusted. Returns `null` | ||
| * when no trustworthy client IP can be resolved. | ||
| */ | ||
| export function getIPFromHeader( | ||
| value: string, | ||
| options: { | ||
| ipv6Subnet?: number; | ||
| trustedProxies?: string[]; | ||
| } = {}, | ||
| ): string | null { | ||
| const forwardedIps = value | ||
| .split(",") | ||
| .map((ip) => ip.trim()) | ||
| .filter(Boolean); | ||
| if (forwardedIps.length === 0) { | ||
| return null; | ||
| } | ||
| // Parse trusted proxies once, dropping malformed entries so a config typo | ||
| // cannot leave the chain enabled-but-empty and return a real proxy hop as | ||
| // the client. With no valid proxy the chain mode does not engage. | ||
| const trustedProxies = (options.trustedProxies ?? []) | ||
| .map(parseCIDR) | ||
| .filter((proxy): proxy is { bytes: Uint8Array; prefix: number } => { | ||
| return proxy !== null; | ||
| }); | ||
| if (trustedProxies.length > 0) { | ||
| for (let i = forwardedIps.length - 1; i >= 0; i--) { | ||
| const ip = forwardedIps[i]; | ||
| const ipBytes = ip ? ipToBytes(ip) : null; | ||
| // A malformed hop breaks the chain: fail closed. | ||
| if (!ip || !ipBytes) { | ||
| return null; | ||
| } | ||
| if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) { | ||
| continue; | ||
| } | ||
| return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet }); | ||
| } | ||
| return null; | ||
| } | ||
| // Without valid trusted proxies a multi-hop chain is unresolvable. | ||
| if (forwardedIps.length !== 1) { | ||
| return null; | ||
| } | ||
| const selectedIp = forwardedIps[0]; | ||
| if (!selectedIp || !isValidIP(selectedIp)) { | ||
| return null; | ||
| } | ||
| return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet }); | ||
| } | ||
| const LOCALHOST_IP = "127.0.0.1"; | ||
| const DEFAULT_IP_HEADERS = ["x-forwarded-for"]; | ||
| /** | ||
| * Resolves the client IP for a request from the configured IP headers. | ||
| * Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default | ||
| * `x-forwarded-for`), and falls back to localhost in development and test. | ||
| * Returns `null` when tracking is disabled or no trustworthy IP can be resolved. | ||
| */ | ||
| export function getIp( | ||
| req: Request | Headers, | ||
| options: BetterAuthOptions, | ||
| ): string | null { | ||
| if (options.advanced?.ipAddress?.disableIpTracking) { | ||
| return null; | ||
| } | ||
| const headers = "headers" in req ? req.headers : req; | ||
| const ipHeaders = | ||
| options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS; | ||
| for (const key of ipHeaders) { | ||
| const value = "get" in headers ? headers.get(key) : headers[key]; | ||
| if (typeof value === "string") { | ||
| const ip = getIPFromHeader(value, { | ||
| ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet, | ||
| trustedProxies: options.advanced?.ipAddress?.trustedProxies, | ||
| }); | ||
| if (ip) { | ||
| return ip; | ||
| } | ||
| } | ||
| } | ||
| if (isTest() || isDevelopment()) { | ||
| return LOCALHOST_IP; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Creates a rate limit key from IP and path | ||
@@ -201,0 +386,0 @@ * Uses a separator to prevent collision attacks |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1030087
1.69%22303
1.84%