diff --git a/.cspell/names.txt b/.cspell/names.txt index 8396b05472f..850b60a990b 100644 --- a/.cspell/names.txt +++ b/.cspell/names.txt @@ -28,4 +28,4 @@ guilhermejansen iamjasonkendrick ejirocodes 0-Sandy -olliethedev +qamarq diff --git a/biome.json b/biome.json index 458cde5b82f..3e3960a4ccf 100644 --- a/biome.json +++ b/biome.json @@ -146,6 +146,7 @@ "!**/.output", "!**/.tmp", "!**/tmp-docs-fetch", + "!**/.claude/worktrees/**", "!**/btst/**", "!scripts/**" ] diff --git a/docs/content/blogs/1-5.mdx b/docs/content/blogs/1-5.mdx index 7cb9ccdf564..b29ae70ae6e 100644 --- a/docs/content/blogs/1-5.mdx +++ b/docs/content/blogs/1-5.mdx @@ -694,7 +694,6 @@ All previously deprecated APIs have been removed. This includes deprecated adapt | Removed | Replacement | | --- | --- | -| `createAdapter` | `createAdapterFactory` | | `Adapter` | `DBAdapter` | | `TransactionAdapter` | `DBTransactionAdapter` | | `Store` (client) | `ClientStore` | diff --git a/docs/content/docs/integrations/solid-start.mdx b/docs/content/docs/integrations/solid-start.mdx index d833a33cebd..45963639d85 100644 --- a/docs/content/docs/integrations/solid-start.mdx +++ b/docs/content/docs/integrations/solid-start.mdx @@ -7,9 +7,9 @@ Before you start, make sure you have a Better Auth instance configured. If you h ### Mount the handler -We need to mount the handler to SolidStart server. Put the following code in your `*auth.ts` file inside `/routes/api/auth` folder. +We need to mount the handler to SolidStart server. Put the following code in your `[...auth].ts` file inside `/routes/api/auth` folder. -```ts title="*auth.ts" +```ts title="[...auth].ts" import { auth } from "~/lib/auth"; import { toSolidStartHandler } from "better-auth/solid-start"; diff --git a/docs/content/docs/plugins/dodopayments.mdx b/docs/content/docs/plugins/dodopayments.mdx index d1acf94770a..126917454fc 100644 --- a/docs/content/docs/plugins/dodopayments.mdx +++ b/docs/content/docs/plugins/dodopayments.mdx @@ -99,6 +99,7 @@ export const auth = betterAuth({ Create or update `src/lib/auth-client.ts`: ```typescript +import { createAuthClient } from "better-auth/react"; import { dodopaymentsClient } from "@dodopayments/better-auth"; export const authClient = createAuthClient({ @@ -114,27 +115,21 @@ export const authClient = createAuthClient({ ### Creating a Checkout Session ```typescript -const { data: checkout, error } = await authClient.dodopayments.checkout({ +const { data: checkoutSession, error } = + await authClient.dodopayments.checkoutSession({ slug: "premium-plan", - customer: { - email: "customer@example.com", - name: "John Doe", - }, - billing: { - city: "San Francisco", - country: "US", - state: "CA", - street: "123 Market St", - zipcode: "94103", - }, - referenceId: "order_123", }); -if (checkout) { - window.location.href = checkout.url; +if (checkoutSession) { + window.location.href = checkoutSession.url; } ``` + + `authClient.dodopayments.checkout()` is deprecated. Use + `authClient.dodopayments.checkoutSession()` for new integrations. + + ### Accessing the Customer Portal ```typescript diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index b81fdf76fe8..ca152c0e346 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -1368,6 +1368,56 @@ oauthProvider({ ``` +### Pairwise Subject Identifiers + +By default, the `sub` (subject) claim in tokens uses the user's internal ID, which is the same across all clients. This is the **public** subject type per [OIDC Core Section 8](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes). + +You can enable **pairwise** subject identifiers so each client receives a unique, unlinkable `sub` for the same user. This prevents relying parties from correlating users across services. + +```ts title="auth.ts" +oauthProvider({ + pairwiseSecret: "your-256-bit-secret", // [!code highlight] +}) +``` + +When `pairwiseSecret` is configured, the server advertises both `"public"` and `"pairwise"` in the discovery endpoint's `subject_types_supported`. Clients opt in by setting `subject_type: "pairwise"` at registration. + +#### Per-Client Configuration + +```ts title="register-client.ts" +const response = await auth.api.createOAuthClient({ + headers, + body: { + client_name: 'Privacy-Sensitive App', + redirect_uris: ['https://app.example.com/callback'], + token_endpoint_auth_method: 'client_secret_post', + subject_type: 'pairwise', // Enable pairwise sub for this client + } +}); +``` + +#### How It Works + +Pairwise identifiers are computed using HMAC-SHA256 over the **sector identifier** (the host of the client's first redirect URI) and the user ID, keyed with `pairwiseSecret`. This means: + +- Two clients with different redirect URI hosts always receive different `sub` values for the same user +- Two clients sharing the same redirect URI host receive the **same** pairwise `sub` (per OIDC Core Section 8.1) +- The same client always receives the same `sub` for the same user (deterministic) + +Pairwise `sub` appears in: +- `id_token` +- `/oauth2/userinfo` response +- Token introspection (`/oauth2/introspect`) + +JWT access tokens always use the real user ID as `sub`, since resource servers may need to look up users directly. + + +**Limitations:** +- `sector_identifier_uri` is not yet supported. All `redirect_uris` for a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration. +- `pairwiseSecret` must be at least 32 characters long. +- Rotating `pairwiseSecret` will change all pairwise `sub` values, breaking existing RP sessions. Treat this secret as permanent once set. + + ### MCP You can easily make your APIs [MCP-compatible](https://modelcontextprotocol.io/specification/draft/basic/authorization) simply by adding a resource server which directs users to this OAuth 2.1 authorization server. @@ -1577,6 +1627,12 @@ Table Name: `oauthClient` description: "Field that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.", isOptional: true, }, + { + name: "subjectType", + type: "string", + description: "Subject identifier type for this client. Set to \"pairwise\" to receive unique, unlinkable sub claims per user. Requires pairwiseSecret to be configured on the server.", + isOptional: true, + }, { name: "scopes", type: "string[]", diff --git a/docs/content/docs/reference/security.mdx b/docs/content/docs/reference/security.mdx index f0901d63527..58444f979a7 100644 --- a/docs/content/docs/reference/security.mdx +++ b/docs/content/docs/reference/security.mdx @@ -200,7 +200,7 @@ Trusted origins prevent CSRF attacks and block open redirects. You can set a lis ### Basic Usage -The most basic usage is to specify exact origins: +The most basic usage is to specify exact origins, below is an example of a trusted origins configuration: ```typescript { @@ -211,6 +211,9 @@ The most basic usage is to specify exact origins: ] } ``` + + Do not leave the localhost origin in a trusted origins list of a production auth instance. + ### Wildcard Origins diff --git a/knip.jsonc b/knip.jsonc index 5a65fb8facf..1316a86c75c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -86,6 +86,7 @@ "better-auth", "c12", "chalk", + "get-tsconfig", "open", "prettier", "prompts", diff --git a/landing/app/blog/[[...slug]]/page.tsx b/landing/app/blog/[[...slug]]/page.tsx index e3d99cdb849..24f3f76ee07 100644 --- a/landing/app/blog/[[...slug]]/page.tsx +++ b/landing/app/blog/[[...slug]]/page.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { BlogLeftPanel } from "@/components/blog/blog-left-panel"; import { Callout } from "@/components/ui/callout"; +import { createMetadata } from "@/lib/metadata"; import { blogs } from "@/lib/source"; import { cn } from "@/lib/utils"; @@ -270,10 +271,10 @@ export async function generateMetadata({ }) { const { slug } = await params; if (!slug) { - return { + return createMetadata({ title: "Blog - Better Auth", description: "Latest updates, articles, and insights about Better Auth", - }; + }); } const page = blogs.getPage(slug); if (!page || page.data.draft) return notFound(); @@ -296,7 +297,7 @@ export async function generateMetadata({ const ogImage = image || ogUrl; - return { + return createMetadata({ title, description, openGraph: { @@ -311,7 +312,7 @@ export async function generateMetadata({ description, images: [ogImage], }, - }; + }); } export function generateStaticParams() { diff --git a/landing/app/blog/layout.tsx b/landing/app/blog/layout.tsx index a4aea65ce8a..229959e9ce9 100644 --- a/landing/app/blog/layout.tsx +++ b/landing/app/blog/layout.tsx @@ -1,21 +1,34 @@ import { RootProvider } from "fumadocs-ui/provider/next"; import type { Metadata } from "next"; +import { createMetadata } from "@/lib/metadata"; -export const metadata: Metadata = { - title: "Blog - Better Auth", - description: "Latest updates, articles, and insights about Better Auth", +const description = "Latest updates, articles, and insights about Better Auth"; + +export const metadata: Metadata = createMetadata({ + title: "Blog", + description, openGraph: { + url: "/blog", title: "Blog - Better Auth", - description: "Latest updates, articles, and insights about Better Auth", + description, images: ["/api/og-release?heading=Better%20Auth%20Blog"], }, twitter: { - card: "summary_large_image", - title: "Blog - Better Auth", - description: "Latest updates, articles, and insights about Better Auth", images: ["/api/og-release?heading=Better%20Auth%20Blog"], + title: "Blog - Better Auth", + description, + }, + alternates: { + types: { + "application/rss+xml": [ + { + title: "Better Auth Blog", + url: "https://better-auth.com/blog/rss.xml", + }, + ], + }, }, -}; +}); export default function BlogLayout({ children, diff --git a/landing/app/careers/page.tsx b/landing/app/careers/page.tsx index 3f8eabcc669..c08e17ba977 100644 --- a/landing/app/careers/page.tsx +++ b/landing/app/careers/page.tsx @@ -1,10 +1,11 @@ import type { Metadata } from "next"; +import { createMetadata } from "@/lib/metadata"; import { CareersPageClient } from "./careers-client"; -export const metadata: Metadata = { +export const metadata: Metadata = createMetadata({ title: "Careers", description: "Join the Better Auth team — open positions and how to apply.", -}; +}); export default function CareersPage() { return ; diff --git a/landing/app/changelog/page.tsx b/landing/app/changelog/page.tsx index 57090d91ef9..b9c9ebfcf61 100644 --- a/landing/app/changelog/page.tsx +++ b/landing/app/changelog/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { HalftoneBackground } from "@/components/landing/halftone-bg"; +import { createMetadata } from "@/lib/metadata"; import { ChangelogContent } from "./changelog-content"; export const dynamic = "force-static"; @@ -148,7 +149,7 @@ export default async function ChangelogPage() { ); } -export const metadata = { - title: "Changelog - Better Auth", +export const metadata = createMetadata({ + title: "Changelog", description: "Latest changes, fixes, and updates to Better Auth", -}; +}); diff --git a/landing/app/community/page.tsx b/landing/app/community/page.tsx index 87381810beb..0812e0dc7cd 100644 --- a/landing/app/community/page.tsx +++ b/landing/app/community/page.tsx @@ -1,12 +1,13 @@ import type { Metadata } from "next"; import { getCommunityStats } from "@/lib/community-stats"; +import { createMetadata } from "@/lib/metadata"; import { CommunityPageClient } from "./community-client"; -export const metadata: Metadata = { +export const metadata: Metadata = createMetadata({ title: "Community", description: "Join the Better Auth community — contributors, Discord members, and ecosystem stats.", -}; +}); export const revalidate = 21600; // Revalidate every 6 hours diff --git a/landing/app/docs/[[...slug]]/page.tsx b/landing/app/docs/[[...slug]]/page.tsx index c8a48c0fe63..902f7853bb1 100644 --- a/landing/app/docs/[[...slug]]/page.tsx +++ b/landing/app/docs/[[...slug]]/page.tsx @@ -24,6 +24,7 @@ import { GenerateSecret, } from "@/components/docs/mdx-components"; import { Callout } from "@/components/ui/callout"; +import { createMetadata } from "@/lib/metadata"; import { getSource } from "@/lib/source"; import { cn } from "@/lib/utils"; import { LLMCopyButton, ViewOptions } from "./page.client"; @@ -164,7 +165,7 @@ export async function generateMetadata({ const ogUrl = `/api/og?${ogSearchParams.toString()}`; - return { + return createMetadata({ title: page.data.title, description: page.data.description, openGraph: { @@ -186,5 +187,5 @@ export async function generateMetadata({ description: page.data.description, images: [ogUrl], }, - }; + }); } diff --git a/landing/app/enterprise/enterprise-client.tsx b/landing/app/enterprise/enterprise-client.tsx index d4455726579..e9f2ec3c837 100644 --- a/landing/app/enterprise/enterprise-client.tsx +++ b/landing/app/enterprise/enterprise-client.tsx @@ -100,7 +100,7 @@ export function EnterprisePageClient() { const url = process.env.NODE_ENV === "development" ? "http://localhost:3001/api/enterprise/contact" - : "/api/enterprise/contact"; + : "https://dash.better-auth.com/api/enterprise/contact"; const response = await fetch(url, { method: "POST", body: JSON.stringify({ diff --git a/landing/app/enterprise/page.tsx b/landing/app/enterprise/page.tsx index dacddd4c055..d5e56aa0a66 100644 --- a/landing/app/enterprise/page.tsx +++ b/landing/app/enterprise/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; +import { createMetadata } from "@/lib/metadata"; import { EnterprisePageClient } from "./enterprise-client"; -export const metadata: Metadata = { +export const metadata: Metadata = createMetadata({ title: "Enterprise", description: "Better Auth for enterprise — SSO, SAML, audit logs, and dedicated support.", -}; +}); export default function EnterprisePage() { return ; diff --git a/landing/app/layout.tsx b/landing/app/layout.tsx index 8d78b488b94..25b3466f9ab 100644 --- a/landing/app/layout.tsx +++ b/landing/app/layout.tsx @@ -7,6 +7,7 @@ import Script from "next/script"; import type { ReactNode } from "react"; import { StaggeredNavFiles } from "@/components/landing/staggered-nav-files"; import { Providers } from "@/components/providers"; +import { createMetadata } from "@/lib/metadata"; const fontSans = Geist({ subsets: ["latin"], @@ -18,43 +19,13 @@ const fontMono = Geist_Mono({ variable: "--font-mono", }); -export const metadata: Metadata = { - metadataBase: new URL( - process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` - : process.env.NODE_ENV === "production" - ? "https://better-auth.com" - : (process.env.NEXT_PUBLIC_URL ?? "http://localhost:3000"), - ), +export const metadata: Metadata = createMetadata({ title: { template: "%s | Better Auth", default: "Better Auth", }, description: "The Most Comprehensive Authentication Framework", - icons: { - icon: [ - { url: "/favicon/favicon.ico", sizes: "any" }, - { - url: "/favicon/favicon-32x32.png", - sizes: "32x32", - type: "image/png", - }, - { - url: "/favicon/favicon-16x16.png", - sizes: "16x16", - type: "image/png", - }, - ], - apple: "/favicon/apple-touch-icon.png", - }, - openGraph: { - images: ["/og.png"], - }, - twitter: { - card: "summary_large_image", - images: ["/og.png"], - }, -}; +}); export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/landing/app/products/[tab]/page.tsx b/landing/app/products/[tab]/page.tsx index fbede66619f..8c75ed74819 100644 --- a/landing/app/products/[tab]/page.tsx +++ b/landing/app/products/[tab]/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { createMetadata } from "@/lib/metadata"; import { FrameworkContent } from "./_components/framework-content"; import { InfrastructureContent } from "./_components/infrastructure-content"; @@ -30,7 +31,7 @@ export async function generateMetadata({ const { tab } = await params; const meta = tabs[tab as Tab]; if (!meta) return {}; - return { title: meta.title, description: meta.description }; + return createMetadata({ title: meta.title, description: meta.description }); } export default async function TabPage({ diff --git a/landing/components/community-plugins-table.tsx b/landing/components/community-plugins-table.tsx index ca83e65ab29..4437bbb9929 100644 --- a/landing/components/community-plugins-table.tsx +++ b/landing/components/community-plugins-table.tsx @@ -301,6 +301,17 @@ export const communityPlugins: CommunityPlugin[] = [ avatar: "https://github.com/0-Sandy.png", }, }, + { + name: "better-auth-usos", + url: "https://github.com/qamarq/better-auth-usos", + description: + "USOS plugin for Better Auth - allows students to authenticate using their university credentials via the USOS API. Using oauth 1a.", + author: { + name: "qamarq", + github: "qamarq", + avatar: "https://github.com/qamarq.png", + }, + }, ]; export function CommunityPluginsTable() { const [sorting, setSorting] = useState([]); diff --git a/landing/components/docs/docs-sidebar.tsx b/landing/components/docs/docs-sidebar.tsx index 835f035f9de..785fa7cbd85 100644 --- a/landing/components/docs/docs-sidebar.tsx +++ b/landing/components/docs/docs-sidebar.tsx @@ -115,7 +115,7 @@ export function DocsSidebar() { }} > - {section.title} + {section.title} ( }); }; } - -export const baseUrl = - process.env.NODE_ENV === "development" || - (!process.env.VERCEL_PROJECT_PRODUCTION_URL && !process.env.VERCEL_URL) - ? new URL("http://localhost:3000") - : new URL( - `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL || process.env.VERCEL_URL}`, - ); diff --git a/packages/api-key/package.json b/packages/api-key/package.json index 9d38b7f9229..88d0a640177 100644 --- a/packages/api-key/package.json +++ b/packages/api-key/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/api-key", - "version": "1.5.4", + "version": "1.5.5", "description": "API Key plugin for Better Auth.", "type": "module", "license": "MIT", diff --git a/packages/api-key/tsdown.config.ts b/packages/api-key/tsdown.config.ts index f2df8a0328a..833f634376b 100644 --- a/packages/api-key/tsdown.config.ts +++ b/packages/api-key/tsdown.config.ts @@ -4,7 +4,9 @@ export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/index.ts", "./src/client.ts", "./src/types.ts"], - external: ["better-auth", "better-call", "@better-fetch/fetch"], + deps: { + neverBundle: ["better-auth", "better-call", "@better-fetch/fetch"], + }, sourcemap: true, treeshake: true, }); diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 7d3003b6776..c5b4b7645b0 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -1,6 +1,6 @@ { "name": "better-auth", - "version": "1.5.4", + "version": "1.5.5", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/better-auth/src/adapters/index.ts b/packages/better-auth/src/adapters/index.ts index cd852588a3c..2d4e8e90d84 100644 --- a/packages/better-auth/src/adapters/index.ts +++ b/packages/better-auth/src/adapters/index.ts @@ -35,3 +35,23 @@ export { initGetFieldAttributes, initGetIdField, }; + +/** + * @deprecated Use `createAdapterFactory` instead. + */ +export const createAdapter = createAdapterFactory; + +/** + * @deprecated Use `AdapterFactoryOptions` instead. + */ +export type CreateAdapterOptions = AdapterFactoryOptions; + +/** + * @deprecated Use `AdapterFactoryConfig` instead. + */ +export type AdapterConfig = AdapterFactoryConfig; + +/** + * @deprecated Use `AdapterFactoryCustomizeAdapterCreator` instead. + */ +export type CreateCustomAdapter = AdapterFactoryCustomizeAdapterCreator; diff --git a/packages/better-auth/src/api/routes/password.test.ts b/packages/better-auth/src/api/routes/password.test.ts index ebb5c30056d..ed3b110dce8 100644 --- a/packages/better-auth/src/api/routes/password.test.ts +++ b/packages/better-auth/src/api/routes/password.test.ts @@ -37,6 +37,26 @@ describe("forget password", async () => { expect(token.length).toBeGreaterThan(10); }); + it("should reject untrusted redirectTo", async () => { + const { client, testUser } = await getTestInstance({ + emailAndPassword: { + enabled: true, + async sendResetPassword() {}, + }, + trustedOrigins: ["http://localhost:3000"], + advanced: { + disableOriginCheck: false, + }, + }); + const res = await client.requestPasswordReset({ + email: testUser.email, + redirectTo: "http://malicious.com", + }); + + expect(res.error?.status).toBe(403); + expect(res.error?.message).toBe("Invalid redirectURL"); + }); + it("should fail on invalid password", async () => { const res = await client.resetPassword( { diff --git a/packages/better-auth/src/api/routes/password.ts b/packages/better-auth/src/api/routes/password.ts index d6e247a7dfa..5a2b15a3420 100644 --- a/packages/better-auth/src/api/routes/password.ts +++ b/packages/better-auth/src/api/routes/password.ts @@ -84,6 +84,7 @@ export const requestPasswordReset = createAuthEndpoint( }, }, }, + use: [originCheck((ctx) => ctx.body.redirectTo)], }, async (ctx) => { if (!ctx.context.options.emailAndPassword?.sendResetPassword) { diff --git a/packages/better-auth/src/api/routes/sign-up.test.ts b/packages/better-auth/src/api/routes/sign-up.test.ts index 09d856bbad8..44f0a545ae8 100644 --- a/packages/better-auth/src/api/routes/sign-up.test.ts +++ b/packages/better-auth/src/api/routes/sign-up.test.ts @@ -265,13 +265,14 @@ describe("sign-up user enumeration protection", async () => { ); }); - it("should call onExistingUserSignUp when autoSignIn is false", async () => { + it("should call onExistingUserSignUp when autoSignIn is false and requireEmailVerification is true", async () => { const onExistingUserSignUp = vi.fn(); const { auth } = await getTestInstance( { emailAndPassword: { enabled: true, autoSignIn: false, + requireEmailVerification: true, onExistingUserSignUp, }, }, @@ -292,6 +293,33 @@ describe("sign-up user enumeration protection", async () => { expect(onExistingUserSignUp).toHaveBeenCalledTimes(1); }); + it("should not call onExistingUserSignUp when autoSignIn is false without requireEmailVerification", async () => { + const onExistingUserSignUp = vi.fn(); + const { auth } = await getTestInstance( + { + emailAndPassword: { + enabled: true, + autoSignIn: false, + onExistingUserSignUp, + }, + }, + { + disableTestUser: true, + }, + ); + + const body = { + email: "callback-autosignin-no-verify@test.com", + password: "password123", + name: "Callback AutoSignIn No Verify", + }; + + await auth.api.signUpEmail({ body }); + await expect(auth.api.signUpEmail({ body })).rejects.toThrow(); + + expect(onExistingUserSignUp).not.toHaveBeenCalled(); + }); + it("should not call onExistingUserSignUp when enumeration protection is inactive", async () => { const onExistingUserSignUp = vi.fn(); const { auth } = await getTestInstance( @@ -344,7 +372,7 @@ describe("sign-up user enumeration protection", async () => { expect(onExistingUserSignUp).not.toHaveBeenCalled(); }); - it("should return success for existing email when autoSignIn is disabled", async () => { + it("should throw for existing email when autoSignIn is disabled without requireEmailVerification", async () => { const { auth } = await getTestInstance( { emailAndPassword: { @@ -365,10 +393,32 @@ describe("sign-up user enumeration protection", async () => { await auth.api.signUpEmail({ body }); - const duplicatedSignUp = await auth.api.signUpEmail({ body }); + await expect(auth.api.signUpEmail({ body })).rejects.toThrow(); + }); - expect(duplicatedSignUp.token).toBeNull(); - expect(duplicatedSignUp.user.email).toBe(body.email); + it("should return token: null for new sign-up when autoSignIn is disabled", async () => { + const { auth } = await getTestInstance( + { + emailAndPassword: { + enabled: true, + autoSignIn: false, + }, + }, + { + disableTestUser: true, + }, + ); + + const res = await auth.api.signUpEmail({ + body: { + email: "new-auto-signin@test.com", + password: "password123", + name: "New User", + }, + }); + + expect(res.token).toBeNull(); + expect(res.user.email).toBe("new-auto-signin@test.com"); }); }); diff --git a/packages/better-auth/src/api/routes/sign-up.ts b/packages/better-auth/src/api/routes/sign-up.ts index 15f340edd95..5bf210714d4 100644 --- a/packages/better-auth/src/api/routes/sign-up.ts +++ b/packages/better-auth/src/api/routes/sign-up.ts @@ -232,8 +232,10 @@ export const signUpEmail = () => ); } const shouldReturnGenericDuplicateResponse = - ctx.context.options.emailAndPassword.autoSignIn === false || ctx.context.options.emailAndPassword.requireEmailVerification; + const shouldSkipAutoSignIn = + ctx.context.options.emailAndPassword.autoSignIn === false || + shouldReturnGenericDuplicateResponse; const additionalUserFields = parseUserInput( ctx.context.options, rest, @@ -391,7 +393,7 @@ export const signUpEmail = () => } } - if (shouldReturnGenericDuplicateResponse) { + if (shouldSkipAutoSignIn) { return ctx.json({ token: null, user: parseUserOutput(ctx.context.options, createdUser) as User< diff --git a/packages/better-auth/src/client/index.ts b/packages/better-auth/src/client/index.ts index c5c72f7aed3..13b34c31a82 100644 --- a/packages/better-auth/src/client/index.ts +++ b/packages/better-auth/src/client/index.ts @@ -41,4 +41,5 @@ export type * from "../plugins/access"; export type * from "../plugins/organization"; export type * from "../types/helper"; export type { UnionToIntersection } from "../types/helper"; +export type * from "./path-to-object"; //#endregion diff --git a/packages/better-auth/src/client/session-refresh.test.ts b/packages/better-auth/src/client/session-refresh.test.ts index 8a4a44f56ef..8c78d0d880a 100644 --- a/packages/better-auth/src/client/session-refresh.test.ts +++ b/packages/better-auth/src/client/session-refresh.test.ts @@ -622,6 +622,109 @@ describe("session-refresh", () => { vi.useRealTimers(); }); + /** + * https://github.com/better-auth/better-auth/issues/8325 + */ + it("should preserve custom session fields on poll refresh", async () => { + vi.useFakeTimers(); + + const sessionAtom: SessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + const customSessionData = { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + activationInfo: { isActivated: true, activatedAt: "2024-01-01" }, + role: "admin", + }; + + const mockFetch = vi.fn(async () => ({ + data: customSessionData, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchInterval: 5, + }, + }, + }); + + manager.init(); + + await vi.advanceTimersByTimeAsync(5000); + + const updatedSession = sessionAtom.get(); + expect(updatedSession.data).toEqual(customSessionData); + expect((updatedSession.data as any)?.activationInfo).toEqual({ + isActivated: true, + activatedAt: "2024-01-01", + }); + expect((updatedSession.data as any)?.role).toBe("admin"); + + manager.cleanup(); + vi.useRealTimers(); + }); + + it("should preserve custom session fields on visibilitychange refresh", async () => { + vi.useFakeTimers(); + + const sessionAtom: SessionAtom = atom({ + data: { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + }, + error: null, + isPending: false, + }); + const sessionSignal = atom(false); + + const customSessionData = { + user: { id: "1", email: "test@test.com" }, + session: { id: "session-1" }, + customField: "custom value", + }; + + const mockFetch = vi.fn(async () => ({ + data: customSessionData, + error: null, + })); + + const manager = createSessionRefreshManager({ + sessionAtom, + sessionSignal, + $fetch: mockFetch as any, + options: { + sessionOptions: { + refetchOnWindowFocus: true, + }, + }, + }); + + manager.init(); + + manager.triggerRefetch({ event: "visibilitychange" }); + await vi.runAllTimersAsync(); + + const updatedSession = sessionAtom.get(); + expect(updatedSession.data).toEqual(customSessionData); + expect((updatedSession.data as any)?.customField).toBe("custom value"); + + manager.cleanup(); + vi.useRealTimers(); + }); + it("should broadcast session update when broadcastSessionUpdate is called with signout", () => { const channel = getGlobalBroadcastChannel(); const postSpy = vi.spyOn(channel, "post"); diff --git a/packages/better-auth/src/client/session-refresh.ts b/packages/better-auth/src/client/session-refresh.ts index 8383d8220e4..a4c117c6ff4 100644 --- a/packages/better-auth/src/client/session-refresh.ts +++ b/packages/better-auth/src/client/session-refresh.ts @@ -15,10 +15,12 @@ const now = () => Math.floor(Date.now() / 1000); const FOCUS_REFETCH_RATE_LIMIT_SECONDS = 5; export interface SessionRefreshOptions { - sessionAtom: AuthQueryAtom<{ - user: User; - session: Session; - }>; + sessionAtom: AuthQueryAtom< + { + user: User; + session: Session; + } & Record + >; sessionSignal: WritableAtom; $fetch: BetterFetch; options?: BetterAuthClientOptions | undefined; @@ -34,11 +36,19 @@ interface SessionRefreshState { unsubscribeOnline?: (() => void) | undefined; } -interface SessionResponse { - session: Session | null; - user: User | null; - needsRefresh?: boolean; -} +export type SessionResponse = ( + | { + session: null; + user: null; + needsRefresh?: boolean; + } + | { + session: Session; + user: User; + needsRefresh?: boolean; + } +) & + Record; export function createSessionRefreshManager(opts: SessionRefreshOptions) { const { sessionAtom, sessionSignal, $fetch, options = {} } = opts; @@ -93,10 +103,7 @@ export function createSessionRefreshManager(opts: SessionRefreshOptions) { } catch {} } - const sessionData = - data?.session && data?.user - ? { session: data.session, user: data.user } - : null; + const sessionData = data?.session && data?.user ? data : null; sessionAtom.set({ ...currentSession, diff --git a/packages/better-auth/src/db/get-migration-schema.test.ts b/packages/better-auth/src/db/get-migration-schema.test.ts index 7a6eae6d513..fc60bd26d07 100644 --- a/packages/better-auth/src/db/get-migration-schema.test.ts +++ b/packages/better-auth/src/db/get-migration-schema.test.ts @@ -1,3 +1,4 @@ +import { DatabaseSync } from "node:sqlite"; import type { BetterAuthOptions } from "@better-auth/core"; import { CamelCasePlugin, Kysely, PostgresDialect } from "kysely"; import { Pool } from "pg"; @@ -402,7 +403,6 @@ describe.runIf(isPostgresAvailable)("PostgreSQL Column Additions", () => { }, ]; const { toBeAdded, toBeCreated } = await getMigrations(config); - console.log(toBeAdded); expect(toBeCreated.length).toBe(0); expect(toBeAdded.length).toBe(2); expect(toBeAdded).toEqual( @@ -422,4 +422,82 @@ describe.runIf(isPostgresAvailable)("PostgreSQL Column Additions", () => { ]), ); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/8536 + */ + it("should generate valid PostgreSQL CREATE INDEX syntax for indexed columns added to existing tables", async () => { + const config: BetterAuthOptions = { + database: schemaPool, + emailAndPassword: { + enabled: true, + }, + }; + + const initial = await getMigrations(config); + await initial.runMigrations(); + + config.plugins = [ + { + id: "test-index", + schema: { + user: { + fields: { + externalId: { + type: "string", + index: true, + required: false, + }, + }, + }, + }, + }, + ]; + + const { compileMigrations } = await getMigrations(config); + const sql = (await compileMigrations()).toLowerCase(); + + expect(sql).toContain("create index"); + expect(sql).not.toContain("add index"); + }); +}); + +/** + * @see https://github.com/better-auth/better-auth/issues/8536 + */ +describe("index generation for columns added to existing tables", () => { + it("should use CREATE INDEX when adding indexed columns to existing SQLite tables", async () => { + const config: BetterAuthOptions = { + database: new DatabaseSync(":memory:"), + emailAndPassword: { + enabled: true, + }, + }; + + const initial = await getMigrations(config); + await initial.runMigrations(); + + config.plugins = [ + { + id: "test-index", + schema: { + user: { + fields: { + externalId: { + type: "string", + index: true, + required: false, + }, + }, + }, + }, + }, + ]; + + const { compileMigrations } = await getMigrations(config); + const sql = (await compileMigrations()).toLowerCase(); + + expect(sql).toContain("create index"); + expect(sql).not.toContain("add index"); + }); }); diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts index de416da459f..bc4edd3874f 100644 --- a/packages/better-auth/src/db/get-migration.ts +++ b/packages/better-auth/src/db/get-migration.ts @@ -9,7 +9,6 @@ import { createLogger } from "@better-auth/core/env"; import type { KyselyDatabaseType } from "@better-auth/kysely-adapter"; import { createKyselyAdapter } from "@better-auth/kysely-adapter"; import type { - AlterTableBuilder, AlterTableColumnAlteringBuilder, ColumnDataType, CreateIndexBuilder, @@ -279,7 +278,6 @@ export async function getMigrations(config: BetterAuthOptions) { const migrations: ( | AlterTableColumnAlteringBuilder - | ReturnType | CreateTableBuilder | CreateIndexBuilder )[] = []; @@ -431,10 +429,12 @@ export async function getMigrations(config: BetterAuthOptions) { const builder = db.schema.alterTable(table.table); if (field.index) { - const index = db.schema - .alterTable(table.table) - .addIndex(`${table.table}_${fieldName}_idx`); - migrations.push(index); + const indexName = `${table.table}_${fieldName}_${field.unique ? "uidx" : "idx"}`; + const indexBuilder = db.schema + .createIndex(indexName) + .on(table.table) + .columns([fieldName]); + migrations.push(field.unique ? indexBuilder.unique() : indexBuilder); } const built = builder.addColumn(fieldName, type, (col) => { diff --git a/packages/better-auth/src/plugins/oidc-provider/authorize.ts b/packages/better-auth/src/plugins/oidc-provider/authorize.ts index e3f14af4681..b9c6c8f76d3 100644 --- a/packages/better-auth/src/plugins/oidc-provider/authorize.ts +++ b/packages/better-auth/src/plugins/oidc-provider/authorize.ts @@ -1,7 +1,9 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { APIError } from "@better-auth/core/error"; +import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata"; import { getSessionFromCtx } from "../../api"; import { generateRandomString } from "../../crypto"; +import { InvalidClient, InvalidRequest } from "./error"; import { getClient } from "./index"; import type { AuthorizationQuery, OIDCOptions } from "./types"; import { parsePrompt } from "./utils/prompt"; @@ -28,7 +30,7 @@ export async function authorize( options: OIDCOptions, ) { const handleRedirect = (url: string) => { - const fromFetch = ctx.request?.headers.get("sec-fetch-mode") === "cors"; + const fromFetch = isBrowserFetchRequest(ctx.request?.headers); if (fromFetch) { return ctx.json({ redirect: true, @@ -57,15 +59,38 @@ export async function authorize( error: "invalid_request", }); } + const query = ctx.query as AuthorizationQuery; const session = await getSessionFromCtx(ctx); if (!session) { // Handle prompt=none per OIDC spec - must return error instead of redirecting - const query = ctx.query as AuthorizationQuery; const promptSet = parsePrompt(query.prompt ?? ""); - if (promptSet.has("none") && query.redirect_uri) { + if (promptSet.has("none")) { + if (!query.redirect_uri) { + throw new InvalidRequest( + "redirect_uri is required when prompt=none and must be usable to return errors without displaying UI", + ); + } + if (!query.client_id) { + throw new InvalidClient("client_id is required"); + } + const client = await getClient( + query.client_id, + options.trustedClients || [], + ); + if (!client) { + throw new InvalidClient("client_id is required"); + } + const validRedirectURI = client.redirectUrls.find( + (url) => url === query.redirect_uri, + ); + if (!validRedirectURI) { + throw new InvalidRequest( + "redirect_uri is invalid or not registered for this client", + ); + } return handleRedirect( formatErrorURL( - query.redirect_uri, + validRedirectURI, "login_required", "Authentication required but prompt is none", ), @@ -90,7 +115,6 @@ export async function authorize( return handleRedirect(`${options.loginPage}?${queryFromURL}`); } - const query = ctx.query as AuthorizationQuery; if (!query.client_id) { const errorURL = getErrorURL( ctx, diff --git a/packages/better-auth/src/plugins/oidc-provider/error.ts b/packages/better-auth/src/plugins/oidc-provider/error.ts index 696a803d9de..5d122e39c60 100644 --- a/packages/better-auth/src/plugins/oidc-provider/error.ts +++ b/packages/better-auth/src/plugins/oidc-provider/error.ts @@ -5,9 +5,20 @@ class OIDCProviderError extends APIError {} export class InvalidRequest extends OIDCProviderError { constructor(error_description: string, error_detail?: string) { super("BAD_REQUEST", { - message: "invalid_request", + message: error_description, + error: "invalid_request", error_description, error_detail, }); } } + +export class InvalidClient extends OIDCProviderError { + constructor(error_description: string) { + super("BAD_REQUEST", { + message: error_description, + error: "invalid_client", + error_description, + }); + } +} diff --git a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts index 6ee3e60926a..ffa12693395 100644 --- a/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts +++ b/packages/better-auth/src/plugins/oidc-provider/oidc.test.ts @@ -460,24 +460,22 @@ describe("oidc", async () => { it("should return login_required error when prompt=none and user not authenticated", async ({ expect, }) => { - // Create an unauthenticated client - const unauthClient = createAuthClient({ - plugins: [oidcClient()], - baseURL: "http://localhost:3000", - fetchOptions: { - customFetchImpl, - }, + // Create an OAuth client + const testClient = await serverClient.oauth2.register({ + client_name: "test-login-required-prompt-none", + redirect_uris: [ + "http://localhost:3000/api/auth/oauth2/callback/login-required", + ], }); + const clientId = testClient.data?.client_id ?? ""; + const redirectUri = testClient.data?.redirect_uris?.[0] ?? ""; // Try to authorize with prompt=none const authUrl = new URL( "http://localhost:3000/api/auth/oauth2/authorize", ); - authUrl.searchParams.set("client_id", application.clientId); - authUrl.searchParams.set( - "redirect_uri", - application.redirectUrls[0] || "", - ); + authUrl.searchParams.set("client_id", clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", "openid profile email"); authUrl.searchParams.set("state", "test-state"); @@ -485,13 +483,11 @@ describe("oidc", async () => { authUrl.searchParams.set("code_challenge", "test-challenge"); authUrl.searchParams.set("code_challenge_method", "S256"); - let redirectURI = ""; - await unauthClient.$fetch(authUrl.toString(), { + const response = await customFetchImpl(authUrl.toString(), { method: "GET", - onError(context) { - redirectURI = context.response.headers.get("Location") || ""; - }, + redirect: "manual", }); + const redirectURI = response.headers.get("Location") || ""; expect(redirectURI).toContain("error=login_required"); expect(redirectURI).toContain("error_description=Authentication"); @@ -499,6 +495,57 @@ describe("oidc", async () => { expect(redirectURI).toContain("none"); }); + it("should not redirect to invalid redirect_uri when prompt=none", async ({ + expect, + }) => { + const attackerRedirect = "https://malicious.com/callback"; + const authUrl = new URL( + "http://localhost:3000/api/auth/oauth2/authorize", + ); + authUrl.searchParams.set("client_id", application.clientId); + authUrl.searchParams.set("redirect_uri", attackerRedirect); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid"); + authUrl.searchParams.set("state", "x"); + authUrl.searchParams.set("prompt", "none"); + + const response = await customFetchImpl(authUrl.toString(), { + method: "GET", + redirect: "manual", + }); + + const location = response.headers.get("Location") || ""; + expect(location === null || location === "").not.toContain( + "malicious.com", + ); + expect([400, 302]).toContain(response.status); + }); + + it("should return 400 invalid_request when prompt=none without redirect_uri", async ({ + expect, + }) => { + const authUrl = new URL( + "http://localhost:3000/api/auth/oauth2/authorize", + ); + authUrl.searchParams.set("client_id", application.clientId); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid"); + authUrl.searchParams.set("state", "x"); + authUrl.searchParams.set("prompt", "none"); + // No redirect_uri - must not fall through to login page + + const response = await customFetchImpl(authUrl.toString(), { + method: "GET", + redirect: "manual", + }); + + expect(response.status).toBe(400); + const location = response.headers.get("Location") || ""; + expect(location).not.toContain("/login"); + const body = await response.json().catch(() => ({})); + expect(body.error ?? body.code).toBe("invalid_request"); + }); + it("should return consent_required error when prompt=none and consent needed", async ({ expect, }) => { diff --git a/packages/btst/adapter-drizzle/package.json b/packages/btst/adapter-drizzle/package.json index 3f584fb64b6..fef7e92d662 100644 --- a/packages/btst/adapter-drizzle/package.json +++ b/packages/btst/adapter-drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-drizzle", - "version": "2.1.0", + "version": "2.1.1", "description": "Drizzle adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-kysely/package.json b/packages/btst/adapter-kysely/package.json index 15307f57aef..b81ec4cbb3e 100644 --- a/packages/btst/adapter-kysely/package.json +++ b/packages/btst/adapter-kysely/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-kysely", - "version": "2.1.0", + "version": "2.1.1", "description": "Kysely adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-kysely/src/bun-sqlite-dialect.ts b/packages/btst/adapter-kysely/src/bun-sqlite-dialect.ts index 55b60139b31..b340267ae34 100644 --- a/packages/btst/adapter-kysely/src/bun-sqlite-dialect.ts +++ b/packages/btst/adapter-kysely/src/bun-sqlite-dialect.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/kysely-adapter/src/bun-sqlite-dialect.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/adapter-kysely/src/d1-sqlite-dialect.ts b/packages/btst/adapter-kysely/src/d1-sqlite-dialect.ts index 98b3ecc110d..12048dfde64 100644 --- a/packages/btst/adapter-kysely/src/d1-sqlite-dialect.ts +++ b/packages/btst/adapter-kysely/src/d1-sqlite-dialect.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/kysely-adapter/src/d1-sqlite-dialect.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/adapter-kysely/src/dialect.ts b/packages/btst/adapter-kysely/src/dialect.ts index aa596e2091e..14482473c3a 100644 --- a/packages/btst/adapter-kysely/src/dialect.ts +++ b/packages/btst/adapter-kysely/src/dialect.ts @@ -1,13 +1,13 @@ /** * ⚠️ AUTO-GENERATED WITH PATCHES - DO NOT MODIFY - * + * * This file is automatically copied from better-auth with patches applied. * Source: packages/kysely-adapter/src/dialect.ts - * + * * Patches applied: * - @better-auth/core/utils imports replaced with local ../utils/string * (avoids dependency issues with published @better-auth/core package) - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/adapter-kysely/src/kysely-adapter.ts b/packages/btst/adapter-kysely/src/kysely-adapter.ts index 87643dea2aa..5b8c1f126a9 100644 --- a/packages/btst/adapter-kysely/src/kysely-adapter.ts +++ b/packages/btst/adapter-kysely/src/kysely-adapter.ts @@ -1,17 +1,18 @@ /** * ⚠️ AUTO-GENERATED WITH PATCHES - DO NOT MODIFY - * + * * This file is automatically copied from better-auth with patches applied. * Source: packages/kysely-adapter/src/kysely-adapter.ts - * + * * Patches applied: * - @better-auth/core/utils imports replaced with local ../utils/string * (avoids dependency issues with published @better-auth/core package) - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ +import type { BetterAuthOptions } from "better-auth/types"; import type { AdapterFactoryCustomizeAdapterCreator, AdapterFactoryOptions, @@ -21,7 +22,7 @@ import type { Where, } from "better-auth/adapters"; import { createAdapterFactory } from "better-auth/adapters"; -import type { BetterAuthOptions } from "better-auth/types"; +import { capitalizeFirstLetter } from "./utils/string"; import type { InsertQueryBuilder, Kysely, @@ -30,7 +31,6 @@ import type { } from "kysely"; import { sql } from "kysely"; import type { KyselyDatabaseType } from "./types"; -import { capitalizeFirstLetter } from "./utils/string"; interface KyselyAdapterConfig { /** diff --git a/packages/btst/adapter-kysely/src/node-sqlite-dialect.ts b/packages/btst/adapter-kysely/src/node-sqlite-dialect.ts index 616276fe157..fb26ed32117 100644 --- a/packages/btst/adapter-kysely/src/node-sqlite-dialect.ts +++ b/packages/btst/adapter-kysely/src/node-sqlite-dialect.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/kysely-adapter/src/node-sqlite-dialect.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/adapter-kysely/src/types.ts b/packages/btst/adapter-kysely/src/types.ts index c6cda89d25e..65eb73a65eb 100644 --- a/packages/btst/adapter-kysely/src/types.ts +++ b/packages/btst/adapter-kysely/src/types.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/kysely-adapter/src/types.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/adapter-memory/package.json b/packages/btst/adapter-memory/package.json index 38a8dc6a379..32cf2f5a9c5 100644 --- a/packages/btst/adapter-memory/package.json +++ b/packages/btst/adapter-memory/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-memory", - "version": "2.1.0", + "version": "2.1.1", "description": "In-memory adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-mongodb/package.json b/packages/btst/adapter-mongodb/package.json index 733224672ca..923615bd6f2 100644 --- a/packages/btst/adapter-mongodb/package.json +++ b/packages/btst/adapter-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-mongodb", - "version": "2.1.0", + "version": "2.1.1", "description": "MongoDB adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-prisma/package.json b/packages/btst/adapter-prisma/package.json index 8bc6bb4d94f..1340f32f862 100644 --- a/packages/btst/adapter-prisma/package.json +++ b/packages/btst/adapter-prisma/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-prisma", - "version": "2.1.0", + "version": "2.1.1", "description": "Prisma adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/cli/package.json b/packages/btst/cli/package.json index 278504c5064..a7307b6a2a2 100644 --- a/packages/btst/cli/package.json +++ b/packages/btst/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/cli", - "version": "2.1.0", + "version": "2.1.1", "description": "CLI for btst schema generation and migration", "type": "module", "license": "MIT", diff --git a/packages/btst/cli/src/generators/drizzle.ts b/packages/btst/cli/src/generators/drizzle.ts index 579823f3ee3..72106524702 100644 --- a/packages/btst/cli/src/generators/drizzle.ts +++ b/packages/btst/cli/src/generators/drizzle.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/cli/src/generators/drizzle.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/cli/src/generators/kysely.ts b/packages/btst/cli/src/generators/kysely.ts index b81e5140ab5..e3d9aadc955 100644 --- a/packages/btst/cli/src/generators/kysely.ts +++ b/packages/btst/cli/src/generators/kysely.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/cli/src/generators/kysely.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/cli/src/generators/prisma.ts b/packages/btst/cli/src/generators/prisma.ts index 13ac34a3100..008e6efe52b 100644 --- a/packages/btst/cli/src/generators/prisma.ts +++ b/packages/btst/cli/src/generators/prisma.ts @@ -1,13 +1,13 @@ /** * ⚠️ AUTO-GENERATED WITH PATCHES - DO NOT MODIFY - * + * * This file is automatically copied from better-auth with patches applied. * Source: packages/cli/src/generators/prisma.ts - * + * * Patches applied: * - @better-auth/core/utils imports replaced with local ../utils/string * (avoids dependency issues with published @better-auth/core package) - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ @@ -15,12 +15,12 @@ import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { capitalizeFirstLetter } from "../utils/string"; import { produceSchema } from "@mrleebo/prisma-ast"; import { initGetFieldName, initGetModelName } from "better-auth/adapters"; import type { DBFieldType } from "better-auth/db"; import { getAuthTables } from "better-auth/db"; import { getPrismaVersion } from "../utils/get-package-info"; -import { capitalizeFirstLetter } from "../utils/string"; import type { SchemaGenerator } from "./types"; export const generatePrismaSchema: SchemaGenerator = async ({ diff --git a/packages/btst/cli/src/generators/types.ts b/packages/btst/cli/src/generators/types.ts index 31cc0b988cb..fabab54da24 100644 --- a/packages/btst/cli/src/generators/types.ts +++ b/packages/btst/cli/src/generators/types.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/cli/src/generators/types.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/cli/src/utils/get-package-info.ts b/packages/btst/cli/src/utils/get-package-info.ts index 40d98418d0c..9ff6f9e8ca2 100644 --- a/packages/btst/cli/src/utils/get-package-info.ts +++ b/packages/btst/cli/src/utils/get-package-info.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/cli/src/utils/get-package-info.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/cli/src/utils/helper.ts b/packages/btst/cli/src/utils/helper.ts index 41c9e3092e9..db5b2100779 100644 --- a/packages/btst/cli/src/utils/helper.ts +++ b/packages/btst/cli/src/utils/helper.ts @@ -1,9 +1,9 @@ /** * ⚠️ AUTO-GENERATED - DO NOT MODIFY - * + * * This file is automatically copied from better-auth. * Source: packages/cli/src/utils/helper.ts - * + * * To update: run `pnpm sync-upstream` * Any manual changes will be overwritten. */ diff --git a/packages/btst/db/package.json b/packages/btst/db/package.json index cc576621f59..70309762dfb 100644 --- a/packages/btst/db/package.json +++ b/packages/btst/db/package.json @@ -1,6 +1,6 @@ { "name": "@btst/db", - "version": "2.1.0", + "version": "2.1.1", "description": "Core database utilities and schema definition for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/plugins/package.json b/packages/btst/plugins/package.json index ddcd3e903ae..145c060e136 100644 --- a/packages/btst/plugins/package.json +++ b/packages/btst/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@btst/plugins", - "version": "2.1.0", + "version": "2.1.1", "description": "Plugin utilities and common plugins for btst", "type": "module", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index d3b6d183307..893adf82e72 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "auth", - "version": "1.5.4", + "version": "1.5.5", "description": "The CLI for Better Auth", "type": "module", "license": "MIT", @@ -69,6 +69,7 @@ "commander": "^12.1.0", "dotenv": "^17.3.1", "drizzle-orm": "^0.41.0", + "get-tsconfig": "^4.13.6", "open": "^10.2.0", "pg": "^8.19.0", "prettier": "^3.8.1", diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts index 91f8a3142db..0fc96a6e6d6 100644 --- a/packages/cli/src/utils/get-config.ts +++ b/packages/cli/src/utils/get-config.ts @@ -7,10 +7,11 @@ import babelPresetTypeScript from "@babel/preset-typescript"; import type { BetterAuthOptions } from "@better-auth/core"; import { BetterAuthError } from "@better-auth/core/error"; import { loadConfig } from "c12"; +import type { TsConfigResult } from "get-tsconfig"; +import { getTsconfig, parseTsconfig } from "get-tsconfig"; import type { JitiOptions } from "jiti"; import { addCloudflareModules } from "./add-cloudflare-modules"; import { addSvelteKitEnvModules } from "./add-svelte-kit-env-modules"; -import { getTsconfigInfo } from "./get-tsconfig-info"; let possiblePaths = [ "auth.ts", @@ -42,93 +43,104 @@ possiblePaths = [ ...possiblePaths.map((it) => `app/${it}`), ]; -function resolveReferencePath(configDir: string, refPath: string): string { - const resolvedPath = path.resolve(configDir, refPath); - - // If it ends with .json, treat as direct file reference - if (refPath.endsWith(".json")) { - return resolvedPath; +function mergeAliases( + target: Record, + source: Record, +): void { + for (const [alias, aliasPath] of Object.entries(source)) { + if (!(alias in target)) { + target[alias] = aliasPath; + } } +} - // If the exact path exists and is a file, use it - if (fs.existsSync(resolvedPath)) { - try { - const stats = fs.statSync(resolvedPath); - if (stats.isFile()) { - return resolvedPath; - } - } catch { - // Fall through to directory handling +function extractAliases(tsconfig: TsConfigResult): Record { + const { paths = {}, baseUrl } = tsconfig.config.compilerOptions ?? {}; + const result: Record = {}; + const configDir = path.dirname(tsconfig.path); + const resolvedBaseUrl = baseUrl + ? path.resolve(configDir, baseUrl) + : configDir; + + for (const [alias, aliasPaths = []] of Object.entries(paths)) { + for (const aliasedPath of aliasPaths) { + const finalAlias = alias.slice(-1) === "*" ? alias.slice(0, -1) : alias; + const finalAliasedPath = + aliasedPath.slice(-1) === "*" ? aliasedPath.slice(0, -1) : aliasedPath; + + result[finalAlias || ""] = path.join(resolvedBaseUrl, finalAliasedPath); } } + return result; +} - // Otherwise, assume directory reference - return path.resolve(configDir, refPath, "tsconfig.json"); +/** + * Reads raw tsconfig JSON to get `references` (which get-tsconfig strips out). + */ +function readRawTsconfigReferences( + tsconfigPath: string, +): Array<{ path: string }> | undefined { + try { + const text = fs.readFileSync(tsconfigPath, "utf-8"); + const stripped = text + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => + g ? "" : m, + ) + .replace(/,(?=\s*[}\]])/g, ""); + const raw = JSON.parse(stripped); + return raw.references; + } catch { + return undefined; + } } -function getPathAliasesRecursive( +/** + * Collect path aliases from tsconfig references recursively. + */ +function collectReferencesAliases( tsconfigPath: string, visited = new Set(), ): Record { - if (visited.has(tsconfigPath)) { - return {}; - } - visited.add(tsconfigPath); - - if (!fs.existsSync(tsconfigPath)) { - console.warn(`Referenced tsconfig not found: ${tsconfigPath}`); - return {}; - } - - try { - const tsConfig = getTsconfigInfo(undefined, tsconfigPath); - const { paths = {}, baseUrl = "." } = tsConfig.compilerOptions || {}; - const result: Record = {}; + const result: Record = {}; + const refs = readRawTsconfigReferences(tsconfigPath); + if (!refs) return result; - const configDir = path.dirname(tsconfigPath); - const obj = Object.entries(paths) as [string, string[]][]; - for (const [alias, aliasPaths] of obj) { - for (const aliasedPath of aliasPaths) { - const resolvedBaseUrl = path.resolve(configDir, baseUrl); - const finalAlias = alias.slice(-1) === "*" ? alias.slice(0, -1) : alias; - const finalAliasedPath = - aliasedPath.slice(-1) === "*" - ? aliasedPath.slice(0, -1) - : aliasedPath; + const configDir = path.dirname(tsconfigPath); + for (const ref of refs) { + const resolvedRef = path.resolve(configDir, ref.path); + const refTsconfigPath = resolvedRef.endsWith(".json") + ? resolvedRef + : path.join(resolvedRef, "tsconfig.json"); - result[finalAlias || ""] = path.join(resolvedBaseUrl, finalAliasedPath); - } - } + if (visited.has(refTsconfigPath)) continue; + visited.add(refTsconfigPath); - if (tsConfig.references) { - for (const ref of tsConfig.references) { - const refPath = resolveReferencePath(configDir, ref.path); - const refAliases = getPathAliasesRecursive(refPath, visited); - for (const [alias, aliasPath] of Object.entries(refAliases)) { - if (!(alias in result)) { - result[alias] = aliasPath; - } - } - } + try { + const refConfig = parseTsconfig(refTsconfigPath); + mergeAliases( + result, + extractAliases({ path: refTsconfigPath, config: refConfig }), + ); + } catch { + continue; } - return result; - } catch (error) { - console.warn(`Error parsing tsconfig at ${tsconfigPath}: ${error}`); - return {}; + mergeAliases(result, collectReferencesAliases(refTsconfigPath, visited)); } + return result; } function getPathAliases(cwd: string): Record | null { - let tsConfigPath = path.join(cwd, "tsconfig.json"); - if (!fs.existsSync(tsConfigPath)) { - tsConfigPath = path.join(cwd, "jsconfig.json"); - } - if (!fs.existsSync(tsConfigPath)) { + const configName = fs.existsSync(path.join(cwd, "tsconfig.json")) + ? "tsconfig.json" + : "jsconfig.json"; + const tsconfig = getTsconfig(cwd, configName); + if (!tsconfig) { return null; } try { - const result = getPathAliasesRecursive(tsConfigPath); + const result = extractAliases(tsconfig); + mergeAliases(result, collectReferencesAliases(tsconfig.path)); addSvelteKitEnvModules(result); addCloudflareModules(result); return result; diff --git a/packages/cli/src/utils/get-tsconfig-info.ts b/packages/cli/src/utils/get-tsconfig-info.ts deleted file mode 100644 index c0e35c7ea46..00000000000 --- a/packages/cli/src/utils/get-tsconfig-info.ts +++ /dev/null @@ -1,27 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function stripJsonComments(jsonString: string): string { - return jsonString - .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => - g ? "" : m, - ) - .replace(/,(?=\s*[}\]])/g, ""); -} - -export function getTsconfigInfo(cwd?: string, flatPath?: string) { - let tsConfigPath: string; - if (flatPath) { - tsConfigPath = flatPath; - } else { - tsConfigPath = cwd - ? path.join(cwd, "tsconfig.json") - : path.join("tsconfig.json"); - } - try { - const text = fs.readFileSync(tsConfigPath, "utf-8"); - return JSON.parse(stripJsonComments(text)); - } catch (error) { - throw error; - } -} diff --git a/packages/cli/test/get-config.test.ts b/packages/cli/test/get-config.test.ts index cdea0d5de31..b61657e523c 100644 --- a/packages/cli/test/get-config.test.ts +++ b/packages/cli/test/get-config.test.ts @@ -897,4 +897,217 @@ describe("getConfig", async () => { expect(config).not.toBe(null); expect(config?.emailAndPassword?.enabled).toBe(true); }); -}, 30000); + + /** + * @see https://github.com/better-auth/better-auth/issues/6373 + */ + it("should resolve path aliases from extended tsconfig", async () => { + const authPath = path.join(tmpDir, "src", "auth"); + const dbPath = path.join(tmpDir, "src", "db"); + await fs.mkdir(authPath, { recursive: true }); + await fs.mkdir(dbPath, { recursive: true }); + + // Create base tsconfig with path aliases + await fs.writeFile( + path.join(tmpDir, "tsconfig.base.json"), + `{ + "compilerOptions": { + "paths": { + "@src/*": ["./src/*"] + } + } + }`, + ); + + // Create tsconfig.json that extends base + await fs.writeFile( + path.join(tmpDir, "tsconfig.json"), + `{ + "extends": "./tsconfig.base.json" + }`, + ); + + // Create dummy db.ts + await fs.writeFile( + path.join(dbPath, "db.ts"), + `class PrismaClient { + constructor() {} + } + export const db = new PrismaClient()`, + ); + + // Create auth.ts using the alias from extended config + await fs.writeFile( + path.join(authPath, "auth.ts"), + `import {betterAuth} from "better-auth"; + import {prismaAdapter} from "better-auth/adapters/prisma"; + import {db} from "@src/db/db"; + + export const auth = betterAuth({ + database: prismaAdapter(db, { + provider: 'sqlite' + }), + emailAndPassword: { + enabled: true, + } + })`, + ); + + const config = await getConfig({ + cwd: tmpDir, + configPath: "src/auth/auth.ts", + }); + + expect(config).not.toBe(null); + expect(config).toMatchObject({ + emailAndPassword: { enabled: true }, + }); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/6373 + */ + it("should resolve path aliases from chained extends", async () => { + const authPath = path.join(tmpDir, "src", "auth"); + const dbPath = path.join(tmpDir, "src", "db"); + await fs.mkdir(authPath, { recursive: true }); + await fs.mkdir(dbPath, { recursive: true }); + + // Create grandparent tsconfig with path aliases + await fs.writeFile( + path.join(tmpDir, "tsconfig.root.json"), + `{ + "compilerOptions": { + "paths": { + "@server/*": ["./src/*"] + } + } + }`, + ); + + // Create parent tsconfig that extends grandparent + await fs.writeFile( + path.join(tmpDir, "tsconfig.base.json"), + `{ + "extends": "./tsconfig.root.json" + }`, + ); + + // Create tsconfig.json that extends parent + await fs.writeFile( + path.join(tmpDir, "tsconfig.json"), + `{ + "extends": "./tsconfig.base.json" + }`, + ); + + // Create dummy db.ts + await fs.writeFile( + path.join(dbPath, "db.ts"), + `class PrismaClient { + constructor() {} + } + export const db = new PrismaClient()`, + ); + + // Create auth.ts using the alias from grandparent + await fs.writeFile( + path.join(authPath, "auth.ts"), + `import {betterAuth} from "better-auth"; + import {prismaAdapter} from "better-auth/adapters/prisma"; + import {db} from "@server/db/db"; + + export const auth = betterAuth({ + database: prismaAdapter(db, { + provider: 'sqlite' + }), + emailAndPassword: { + enabled: true, + } + })`, + ); + + const config = await getConfig({ + cwd: tmpDir, + configPath: "src/auth/auth.ts", + }); + + expect(config).not.toBe(null); + expect(config).toMatchObject({ + emailAndPassword: { enabled: true }, + }); + }); + + /** + * @see https://github.com/better-auth/better-auth/issues/6373 + */ + it("should let child paths override parent paths from extends", async () => { + const authPath = path.join(tmpDir, "server", "auth"); + const dbPath = path.join(tmpDir, "server", "db"); + const oldDbPath = path.join(tmpDir, "old", "db"); + await fs.mkdir(authPath, { recursive: true }); + await fs.mkdir(dbPath, { recursive: true }); + await fs.mkdir(oldDbPath, { recursive: true }); + + // Create parent tsconfig with path aliases pointing to old location + await fs.writeFile( + path.join(tmpDir, "tsconfig.base.json"), + `{ + "compilerOptions": { + "paths": { + "@server/*": ["./old/*"] + } + } + }`, + ); + + // Create child tsconfig that overrides the alias + await fs.writeFile( + path.join(tmpDir, "tsconfig.json"), + `{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "paths": { + "@server/*": ["./server/*"] + } + } + }`, + ); + + // Create dummy db.ts in the correct (overridden) location + await fs.writeFile( + path.join(dbPath, "db.ts"), + `class PrismaClient { + constructor() {} + } + export const db = new PrismaClient()`, + ); + + // Create auth.ts + await fs.writeFile( + path.join(authPath, "auth.ts"), + `import {betterAuth} from "better-auth"; + import {prismaAdapter} from "better-auth/adapters/prisma"; + import {db} from "@server/db/db"; + + export const auth = betterAuth({ + database: prismaAdapter(db, { + provider: 'sqlite' + }), + emailAndPassword: { + enabled: true, + } + })`, + ); + + const config = await getConfig({ + cwd: tmpDir, + configPath: "server/auth/auth.ts", + }); + + expect(config).not.toBe(null); + expect(config).toMatchObject({ + emailAndPassword: { enabled: true }, + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index dcc86340185..e5beedd47c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/core", - "version": "1.5.4", + "version": "1.5.5", "description": "The most comprehensive authentication framework for TypeScript.", "type": "module", "license": "MIT", diff --git a/packages/core/src/utils/fetch-metadata.test.ts b/packages/core/src/utils/fetch-metadata.test.ts new file mode 100644 index 00000000000..c4295bd5fc8 --- /dev/null +++ b/packages/core/src/utils/fetch-metadata.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isBrowserFetchRequest } from "./fetch-metadata"; + +describe("isBrowserFetchRequest", () => { + it("returns true for browser fetch requests", () => { + expect( + isBrowserFetchRequest( + new Headers({ + "Sec-Fetch-Mode": "cors", + }), + ), + ).toBe(true); + }); + + it("returns false for navigation requests", () => { + expect( + isBrowserFetchRequest( + new Headers({ + "Sec-Fetch-Mode": "navigate", + }), + ), + ).toBe(false); + }); + + it("returns false without fetch metadata", () => { + expect(isBrowserFetchRequest()).toBe(false); + }); +}); diff --git a/packages/core/src/utils/fetch-metadata.ts b/packages/core/src/utils/fetch-metadata.ts new file mode 100644 index 00000000000..6c1cfccf946 --- /dev/null +++ b/packages/core/src/utils/fetch-metadata.ts @@ -0,0 +1,3 @@ +export function isBrowserFetchRequest(headers?: Headers | null): boolean { + return headers?.get("sec-fetch-mode") === "cors"; +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 97821f1e5a3..928b78eccf6 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -23,7 +23,9 @@ export default defineConfig({ "!./src/utils/*.test.ts", "./src/error/index.ts", ], - external: ["@better-auth/core/async_hooks"], + deps: { + neverBundle: ["@better-auth/core/async_hooks"], + }, env: { BETTER_AUTH_VERSION: packageJson.version, BETTER_AUTH_TELEMETRY_ENDPOINT: diff --git a/packages/drizzle-adapter/package.json b/packages/drizzle-adapter/package.json index 64729558bab..5fa77c14dff 100644 --- a/packages/drizzle-adapter/package.json +++ b/packages/drizzle-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/drizzle-adapter", - "version": "1.5.4", + "version": "1.5.5", "description": "Drizzle adapter for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/electron/package.json b/packages/electron/package.json index b63480f1cb5..9ebe65da10f 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/electron", - "version": "1.5.4", + "version": "1.5.5", "description": "Better Auth integration for Electron applications.", "type": "module", "license": "MIT", diff --git a/packages/electron/tsdown.config.ts b/packages/electron/tsdown.config.ts index 42dc3fd1dbe..607eb3e1bbd 100644 --- a/packages/electron/tsdown.config.ts +++ b/packages/electron/tsdown.config.ts @@ -10,23 +10,32 @@ export default defineConfig([ "./src/proxy.ts", "./src/storage.ts", ], - external: ["better-auth", "better-call", "@better-fetch/fetch", "electron"], + deps: { + neverBundle: [ + "better-auth", + "better-call", + "@better-fetch/fetch", + "electron", + ], + }, treeshake: true, }, { dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/preload.ts"], - external: (id, _, isResolved) => { - if (isResolved) return false; - return ( - !id.startsWith(".") && - !id.startsWith("better-call") && - !id.startsWith("@better-auth/core") - ); + deps: { + neverBundle: (id, _, isResolved) => { + if (isResolved) return false; + return ( + !id.startsWith(".") && + !id.startsWith("better-call") && + !id.startsWith("@better-auth/core") + ); + }, + alwaysBundle: [/^@better-auth\/core/, /^better-call/], + onlyAllowBundle: ["better-call", "@standard-schema/spec"], }, - noExternal: [/^@better-auth\/core/, /^better-call/], - inlineOnly: ["better-call", "@standard-schema/spec"], treeshake: true, }, ]); diff --git a/packages/expo/package.json b/packages/expo/package.json index c3b543fc74b..c954d76616b 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/expo", - "version": "1.5.4", + "version": "1.5.5", "description": "Better Auth integration for Expo and React Native applications.", "type": "module", "license": "MIT", diff --git a/packages/expo/tsdown.config.ts b/packages/expo/tsdown.config.ts index a44883fa000..9c9d726884e 100644 --- a/packages/expo/tsdown.config.ts +++ b/packages/expo/tsdown.config.ts @@ -4,15 +4,17 @@ export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/index.ts", "./src/client.ts", "./src/plugins/index.ts"], - external: [ - "better-auth", - "better-call", - "@better-fetch/fetch", - "react-native", - "expo-web-browser", - "expo-linking", - "expo-constants", - ], + deps: { + neverBundle: [ + "better-auth", + "better-call", + "@better-fetch/fetch", + "react-native", + "expo-web-browser", + "expo-linking", + "expo-constants", + ], + }, platform: "neutral", sourcemap: true, treeshake: true, diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 9513d1fe203..834a0069a7d 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/i18n", - "version": "1.5.4", + "version": "1.5.5", "description": "i18n plugin for Better Auth - translate error messages", "type": "module", "license": "MIT", diff --git a/packages/i18n/tsdown.config.ts b/packages/i18n/tsdown.config.ts index e254acf9196..cc70b8cdddd 100644 --- a/packages/i18n/tsdown.config.ts +++ b/packages/i18n/tsdown.config.ts @@ -4,7 +4,9 @@ export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/index.ts", "./src/client.ts"], - external: ["@better-auth/core", "better-auth"], + deps: { + neverBundle: ["@better-auth/core", "better-auth"], + }, sourcemap: true, treeshake: true, }); diff --git a/packages/kysely-adapter/package.json b/packages/kysely-adapter/package.json index ca5ba229c7f..6fee5395b32 100644 --- a/packages/kysely-adapter/package.json +++ b/packages/kysely-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/kysely-adapter", - "version": "1.5.4", + "version": "1.5.5", "description": "Kysely adapter for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/memory-adapter/package.json b/packages/memory-adapter/package.json index e7b662998ae..bfa5ae3f187 100644 --- a/packages/memory-adapter/package.json +++ b/packages/memory-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/memory-adapter", - "version": "1.5.4", + "version": "1.5.5", "description": "Memory adapter for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/mongo-adapter/package.json b/packages/mongo-adapter/package.json index 749f53e6d4e..9eabdd2c4cd 100644 --- a/packages/mongo-adapter/package.json +++ b/packages/mongo-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/mongo-adapter", - "version": "1.5.4", + "version": "1.5.5", "description": "Mongo adapter for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index bec977286ec..fb0f1a5fd18 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/oauth-provider", - "version": "1.5.4", + "version": "1.5.5", "description": "An oauth provider plugin for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index 33370609f92..561555b7218 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -1,4 +1,5 @@ import type { GenericEndpointContext } from "@better-auth/core"; +import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata"; import { getSessionFromCtx } from "better-auth/api"; import { generateRandomString, makeSignature } from "better-auth/crypto"; import type { Verification } from "better-auth/db"; @@ -40,8 +41,9 @@ export function formatErrorURL( } export const handleRedirect = (ctx: GenericEndpointContext, uri: string) => { + const fromFetch = isBrowserFetchRequest(ctx.request?.headers); const acceptJson = ctx.headers?.get("accept")?.includes("application/json"); - if (acceptJson) { + if (fromFetch || acceptJson) { return { redirect: true, url: uri.toString(), diff --git a/packages/oauth-provider/src/introspect.ts b/packages/oauth-provider/src/introspect.ts index 491904ff566..5683d33a40e 100644 --- a/packages/oauth-provider/src/introspect.ts +++ b/packages/oauth-provider/src/introspect.ts @@ -18,6 +18,7 @@ import { getJwtPlugin, getStoredToken, parseClientMetadata, + resolveSubjectIdentifier, validateClientCredentials, } from "./utils"; @@ -371,6 +372,27 @@ export async function validateAccessToken( }); } +/** + * Resolves pairwise sub on an introspection payload. + * Applied at the presentation layer so internal validation functions + * keep real user.id (needed for user lookup in /userinfo). + */ +async function resolveIntrospectionSub( + opts: OAuthOptions, + payload: JWTPayload, + client: SchemaClient, +): Promise { + if (payload.active && payload.sub) { + const resolvedSub = await resolveSubjectIdentifier( + payload.sub as string, + client, + opts, + ); + return { ...payload, sub: resolvedSub }; + } + return payload; +} + export async function introspectEndpoint( ctx: GenericEndpointContext, opts: OAuthOptions, @@ -429,7 +451,7 @@ export async function introspectEndpoint( token, client.clientId, ); - return payload; + return resolveIntrospectionSub(opts, payload, client); } catch (error) { if (error instanceof APIError) { if (token_type_hint === "access_token") { @@ -452,7 +474,7 @@ export async function introspectEndpoint( refreshToken.token, client.clientId, ); - return payload; + return resolveIntrospectionSub(opts, payload, client); } catch (error) { if (error instanceof APIError) { if (token_type_hint === "refresh_token") { diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts index 50d7ab48862..213dec43852 100644 --- a/packages/oauth-provider/src/metadata.ts +++ b/packages/oauth-provider/src/metadata.ts @@ -89,7 +89,9 @@ export function oidcServerMetadata( claims_supported: opts?.advertisedMetadata?.claims_supported ?? opts?.claims ?? [], userinfo_endpoint: `${baseURL}/oauth2/userinfo`, - subject_types_supported: ["public"], + subject_types_supported: opts.pairwiseSecret + ? ["public", "pairwise"] + : ["public"], id_token_signing_alg_values_supported: jwtPluginOptions?.jwks?.keyPairConfig ?.alg ? [jwtPluginOptions?.jwks?.keyPairConfig?.alg] diff --git a/packages/oauth-provider/src/oauth.test.ts b/packages/oauth-provider/src/oauth.test.ts index 70763547fbe..3f82848439d 100644 --- a/packages/oauth-provider/src/oauth.test.ts +++ b/packages/oauth-provider/src/oauth.test.ts @@ -285,6 +285,78 @@ describe("oauth", async () => { expect(callbackUrl).toContain("/success"); }); + it("should return a JSON redirect after email sign-in in a fetch-based oauth flow", async ({ + onTestFinished, + }) => { + if (!oauthClient?.client_id || !oauthClient?.client_secret) { + throw Error("beforeAll not run properly"); + } + + const { customFetchImpl: customFetchImplRP } = await createTestInstance(); + + const client = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: rpBaseUrl, + fetchOptions: { + customFetchImpl: customFetchImplRP, + }, + }); + const headers = new Headers(); + const data = await client.signIn.oauth2( + { + providerId, + callbackURL: "/success", + }, + { + throw: true, + onSuccess: cookieSetter(headers), + }, + ); + + let loginRedirectUri = ""; + await authClient.$fetch(data.url, { + method: "GET", + onError(ctx) { + loginRedirectUri = ctx.response.headers.get("Location") || ""; + }, + }); + expect(loginRedirectUri).toContain("/login"); + + vi.stubGlobal("window", { + location: { + href: "", + search: new URL(loginRedirectUri, authServerBaseUrl).search, + }, + }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + let signInLocationHeader = ""; + const signInResponse = await authClient.signIn.email( + { + email: testUser.email, + password: testUser.password, + }, + { + headers: { + "Sec-Fetch-Mode": "cors", + }, + throw: true, + onResponse(ctx) { + signInLocationHeader = ctx.response.headers.get("Location") || ""; + }, + }, + ); + + expect(signInLocationHeader).toBe(""); + expect(signInResponse.redirect).toBe(true); + expect(signInResponse.url).toContain( + `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`, + ); + expect(signInResponse.url).not.toContain(`${authServerBaseUrl}/login`); + }); + it("should sign in using generic oauth discovery", async ({ onTestFinished, }) => { diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index 92cb0f2bf5c..005b2f9ff61 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -118,6 +118,13 @@ export const oauthProvider = >(options: O) => { clientRegistrationAllowedScopes, }; + // Validate pairwiseSecret minimum length + if (opts.pairwiseSecret && opts.pairwiseSecret.length < 32) { + throw new BetterAuthError( + "pairwiseSecret must be at least 32 characters long for adequate HMAC-SHA256 security", + ); + } + // TODO: device_code grant also allows for refresh tokens if ( opts.grantTypes && @@ -1131,6 +1138,7 @@ export const oauthProvider = >(options: O) => { .default(["code"]) .optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), + subject_type: z.enum(["public", "pairwise"]).optional(), }), metadata: { openapi: { diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts index 1229c201fdc..8cbcfb63323 100644 --- a/packages/oauth-provider/src/oauthClient/index.ts +++ b/packages/oauth-provider/src/oauthClient/index.ts @@ -57,6 +57,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => skip_consent: z.boolean().optional(), enable_end_session: z.boolean().optional(), require_pkce: z.boolean().optional(), + subject_type: z.enum(["public", "pairwise"]).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }), metadata: { diff --git a/packages/oauth-provider/src/pairwise.test.ts b/packages/oauth-provider/src/pairwise.test.ts new file mode 100644 index 00000000000..fedae757248 --- /dev/null +++ b/packages/oauth-provider/src/pairwise.test.ts @@ -0,0 +1,569 @@ +import { createAuthClient } from "better-auth/client"; +import { generateRandomString } from "better-auth/crypto"; +import { + createAuthorizationCodeRequest, + createAuthorizationURL, +} from "better-auth/oauth2"; +import { jwt } from "better-auth/plugins/jwt"; +import { getTestInstance } from "better-auth/test"; +import { APIError } from "better-call"; +import { decodeJwt } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; +import { oauthProviderClient } from "./client"; +import { oauthProvider } from "./oauth"; +import type { OAuthClient } from "./types/oauth"; + +describe("pairwise subject identifiers", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const rpBaseUrl2 = "http://localhost:6000"; + const validAudience = "https://myapi.example.com"; + + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-pairwise-secret-key-32chars!!", + validAudiences: [validAudience], + allowDynamicClientRegistration: true, + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const client = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + headers, + }, + }); + + let pairwiseClientA: OAuthClient | null; + let pairwiseClientB: OAuthClient | null; + let publicClient: OAuthClient | null; + let sameHostClientA: OAuthClient | null; + + const redirectUriA = `${rpBaseUrl}/api/auth/oauth2/callback/test-a`; + const redirectUriB = `${rpBaseUrl2}/api/auth/oauth2/callback/test-b`; + const redirectUriSameHost = `${rpBaseUrl}/api/auth/oauth2/callback/test-same`; + const redirectUriPublic = `${rpBaseUrl}/api/auth/oauth2/callback/test-public`; + + beforeAll(async () => { + pairwiseClientA = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriA], + scope: "openid profile email offline_access", + skip_consent: true, + subject_type: "pairwise", + }, + }); + expect(pairwiseClientA?.client_id).toBeDefined(); + + pairwiseClientB = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriB], + scope: "openid profile email offline_access", + skip_consent: true, + subject_type: "pairwise", + }, + }); + expect(pairwiseClientB?.client_id).toBeDefined(); + + publicClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriPublic], + scope: "openid profile email offline_access", + skip_consent: true, + }, + }); + expect(publicClient?.client_id).toBeDefined(); + + sameHostClientA = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUriSameHost], + scope: "openid profile email offline_access", + skip_consent: true, + subject_type: "pairwise", + }, + }); + expect(sameHostClientA?.client_id).toBeDefined(); + }); + + async function getTokensForClient( + oauthClient: OAuthClient, + redirectUri: string, + overrides?: { + resource?: string; + }, + ) { + const codeVerifier = generateRandomString(32); + const url = await createAuthorizationURL({ + id: "test", + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret!, + redirectURI: redirectUri, + }, + redirectURI: "", + authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`, + state: "test-state", + scopes: ["openid", "profile", "email", "offline_access"], + codeVerifier, + }); + + let callbackRedirectUrl = ""; + await client.$fetch(url.toString(), { + headers, + onError(context) { + callbackRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + const callbackUrl = new URL(callbackRedirectUrl); + const code = callbackUrl.searchParams.get("code")!; + + const { body, headers: reqHeaders } = createAuthorizationCodeRequest({ + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret!, + redirectURI: redirectUri, + }, + resource: overrides?.resource, + }); + + const tokens = await client.$fetch<{ + access_token?: string; + id_token?: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; + }>("/oauth2/token", { + method: "POST", + body, + headers: reqHeaders, + }); + + return tokens; + } + + it("should produce different sub across pairwise clients (cross-RP unlinkability)", async () => { + const tokensA = await getTokensForClient(pairwiseClientA!, redirectUriA); + const tokensB = await getTokensForClient(pairwiseClientB!, redirectUriB); + + const idTokenA = decodeJwt(tokensA.data!.id_token!); + const idTokenB = decodeJwt(tokensB.data!.id_token!); + + expect(idTokenA.sub).toBeDefined(); + expect(idTokenB.sub).toBeDefined(); + // Different sectors → different pairwise sub + expect(idTokenA.sub).not.toBe(idTokenB.sub); + }); + + it("should produce same sub for same pairwise client (determinism)", async () => { + const tokens1 = await getTokensForClient(pairwiseClientA!, redirectUriA); + const tokens2 = await getTokensForClient(pairwiseClientA!, redirectUriA); + + const idToken1 = decodeJwt(tokens1.data!.id_token!); + const idToken2 = decodeJwt(tokens2.data!.id_token!); + + expect(idToken1.sub).toBe(idToken2.sub); + }); + + it("should return user.id as sub for public client (fallback)", async () => { + const publicTokens = await getTokensForClient( + publicClient!, + redirectUriPublic, + ); + const pairwiseTokens = await getTokensForClient( + pairwiseClientA!, + redirectUriA, + ); + + const publicIdToken = decodeJwt(publicTokens.data!.id_token!); + const pairwiseIdToken = decodeJwt(pairwiseTokens.data!.id_token!); + + expect(publicIdToken.sub).toBeDefined(); + // Public sub differs from pairwise sub for same user + expect(publicIdToken.sub).not.toBe(pairwiseIdToken.sub); + }); + + it("should produce same pairwise sub for clients on same host (sector isolation)", async () => { + const tokensA = await getTokensForClient(pairwiseClientA!, redirectUriA); + const tokensSameHost = await getTokensForClient( + sameHostClientA!, + redirectUriSameHost, + ); + + const idTokenA = decodeJwt(tokensA.data!.id_token!); + const idTokenSameHost = decodeJwt(tokensSameHost.data!.id_token!); + + // Same host (localhost) → same sector → same pairwise sub + expect(idTokenA.sub).toBe(idTokenSameHost.sub); + }); + + it("should have consistent sub between id_token and userinfo", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA); + const idToken = decodeJwt(tokens.data!.id_token!); + + const userinfo = await client.$fetch<{ sub?: string }>("/oauth2/userinfo", { + method: "GET", + headers: { + authorization: `Bearer ${tokens.data!.access_token}`, + }, + }); + + expect(userinfo.data?.sub).toBe(idToken.sub); + }); + + it("should return pairwise sub in opaque access token introspection", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA); + + const introspection = await client.oauth2.introspect( + { + client_id: pairwiseClientA!.client_id, + client_secret: pairwiseClientA!.client_secret, + token: tokens.data!.access_token!, + token_type_hint: "access_token", + }, + { + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + }, + ); + + const idToken = decodeJwt(tokens.data!.id_token!); + expect(introspection.data?.active).toBe(true); + expect(introspection.data?.sub).toBe(idToken.sub); + }); + + it("should preserve pairwise sub after token refresh", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA); + const originalIdToken = decodeJwt(tokens.data!.id_token!); + + const refreshBody = new URLSearchParams({ + grant_type: "refresh_token", + client_id: pairwiseClientA!.client_id, + client_secret: pairwiseClientA!.client_secret!, + refresh_token: tokens.data!.refresh_token!, + }); + + const refreshResponse = await client.$fetch<{ + access_token?: string; + id_token?: string; + refresh_token?: string; + }>("/oauth2/token", { + method: "POST", + body: refreshBody, + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }); + + expect(refreshResponse.data?.id_token).toBeDefined(); + const refreshedIdToken = decodeJwt(refreshResponse.data!.id_token!); + expect(refreshedIdToken.sub).toBe(originalIdToken.sub); + }); + + it("should keep user.id in JWT access token sub (not pairwise)", async () => { + const tokens = await getTokensForClient(pairwiseClientA!, redirectUriA, { + resource: validAudience, + }); + + const accessToken = decodeJwt(tokens.data!.access_token!); + const idToken = decodeJwt(tokens.data!.id_token!); + + // JWT access token uses real user.id for user lookup + expect(accessToken.sub).toBeDefined(); + expect(accessToken.sub).not.toBe(idToken.sub); + }); +}); + +describe("pairwise DCR validation", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/test`; + + it("should reject pairwise subject_type when pairwiseSecret not configured", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + await expect( + auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + subject_type: "pairwise", + }, + }), + ).rejects.toThrow(APIError); + }); + + it("should accept pairwise subject_type when pairwiseSecret is configured", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + subject_type: "pairwise", + skip_consent: true, + }, + }); + + expect(response?.client_id).toBeDefined(); + expect(response?.subject_type).toBe("pairwise"); + }); + + it("should default to public when no subject_type specified", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + skip_consent: true, + }, + }); + + expect(response?.client_id).toBeDefined(); + expect(response?.subject_type).toBeUndefined(); + }); + + it("should reject pairwise client with redirect_uris on different hosts", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + await expect( + auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [ + "https://app-a.example.com/callback", + "https://app-b.example.com/callback", + ], + subject_type: "pairwise", + }, + }), + ).rejects.toThrow(APIError); + }); + + it("should accept pairwise client with redirect_uris on the same host", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [ + "https://app.example.com/callback-a", + "https://app.example.com/callback-b", + ], + subject_type: "pairwise", + skip_consent: true, + }, + }); + + expect(response?.client_id).toBeDefined(); + expect(response?.subject_type).toBe("pairwise"); + }); + + it("should round-trip subject_type through DCR", async () => { + const { signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-secret-for-dcr-test-32chars!", + allowDynamicClientRegistration: true, + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const dcrClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + headers, + }, + }); + + const response = await dcrClient.$fetch("/oauth2/register", { + method: "POST", + body: { + redirect_uris: [redirectUri], + subject_type: "pairwise", + token_endpoint_auth_method: "none", + }, + }); + + expect(response.data?.subject_type).toBe("pairwise"); + }); +}); + +describe("pairwise configuration validation", () => { + it("should reject pairwiseSecret shorter than 32 characters", () => { + expect(() => + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "too-short", + }), + ).toThrow("pairwiseSecret must be at least 32 characters"); + }); + + it("should accept pairwiseSecret of 32+ characters", () => { + expect(() => + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "a-valid-secret-that-is-32-chars!", + }), + ).not.toThrow(); + }); +}); + +describe("pairwise metadata", async () => { + const authServerBaseUrl = "http://localhost:3000"; + + it("should include pairwise in subject_types_supported when secret configured", async () => { + const { auth } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + pairwiseSecret: "test-pairwise-metadata-secret!!!", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const metadata = await auth.api.getOpenIdConfig(); + expect(metadata.subject_types_supported).toEqual(["public", "pairwise"]); + }); + + it("should only include public when no pairwise secret", async () => { + const { auth } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const metadata = await auth.api.getOpenIdConfig(); + expect(metadata.subject_types_supported).toEqual(["public"]); + }); +}); diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index 94ae1113de1..05a830c4a22 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -110,6 +110,45 @@ export async function checkOAuthClient( }); } + // Validate subject_type + if (client.subject_type !== undefined) { + if ( + client.subject_type !== "public" && + client.subject_type !== "pairwise" + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `subject_type must be "public" or "pairwise"`, + }); + } + if (client.subject_type === "pairwise" && !opts.pairwiseSecret) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "pairwise subject_type requires server pairwiseSecret configuration", + }); + } + // Per OIDC Core §8.1, when multiple redirect_uris have different hosts, + // a sector_identifier_uri is required (not yet supported). Reject registration + // until sector_identifier_uri support is added. + if ( + client.subject_type === "pairwise" && + client.redirect_uris && + client.redirect_uris.length > 1 + ) { + const hosts = new Set( + client.redirect_uris.map((uri: string) => new URL(uri).host), + ); + if (hosts.size > 1) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host.", + }); + } + } + } + // Check requested application scopes const requestedScopes = (client?.scope as string | undefined) ?.split(" ") @@ -263,6 +302,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient { skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, + subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, // All other metadata @@ -317,6 +357,7 @@ export function oauthToSchema(input: OAuthClient): SchemaClient { skipConsent, enableEndSession, requirePKCE, + subjectType, referenceId, metadata, }; @@ -364,6 +405,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient { skipConsent, enableEndSession, requirePKCE, + subjectType, referenceId, metadata, // in JSON format } = input; @@ -417,6 +459,7 @@ export function schemaToOAuth(input: SchemaClient): OAuthClient { skip_consent: skipConsent ?? undefined, enable_end_session: enableEndSession ?? undefined, require_pkce: requirePKCE ?? undefined, + subject_type: subjectType ?? undefined, reference_id: referenceId ?? undefined, }; } diff --git a/packages/oauth-provider/src/schema.ts b/packages/oauth-provider/src/schema.ts index ddf1b063cc7..5d116af4930 100644 --- a/packages/oauth-provider/src/schema.ts +++ b/packages/oauth-provider/src/schema.ts @@ -27,6 +27,10 @@ export const schema = { type: "boolean", required: false, }, + subjectType: { + type: "string", + required: false, + }, scopes: { type: "string[]", required: false, diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index f632f11eee6..7d3d493edcf 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -22,6 +22,7 @@ import { getStoredToken, isPKCERequired, parseClientMetadata, + resolveSubjectIdentifier, storeToken, validateClientCredentials, } from "./utils"; @@ -132,6 +133,7 @@ async function createIdToken( const iat = Math.floor(Date.now() / 1000); const exp = iat + (opts.idTokenExpiresIn ?? 36000); const userClaims = userNormalClaims(user, scopes); + const resolvedSub = await resolveSubjectIdentifier(user.id, client, opts); const authTimeSec = authTime != null ? Math.floor(authTime.getTime() / 1000) : undefined; // TODO: this should be validated against the login process @@ -157,7 +159,7 @@ async function createIdToken( auth_time: authTimeSec, acr, iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL, - sub: user.id, + sub: resolvedSub, aud: client.clientId, nonce, iat, diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index facdfc4a7af..528cd4b6694 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -664,6 +664,14 @@ export interface OAuthOptions< */ userinfo?: { window: number; max: number } | false; }; + /** + * Secret used to compute pairwise subject identifiers (HMAC-SHA256). + * When set, clients with `subject_type: "pairwise"` receive unique, + * unlinkable `sub` values per sector identifier. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + */ + pairwiseSecret?: string; } export interface OAuthAuthorizationQuery { @@ -907,6 +915,8 @@ export interface SchemaClient< skipConsent?: boolean; /** Used to enable client to logout via the `/oauth2/end-session` endpoint */ enableEndSession?: boolean; + /** Subject identifier type: "public" (default) or "pairwise" */ + subjectType?: "public" | "pairwise"; /** Reference to the owner of this client. Eg. Organization, Team, Profile */ referenceId?: string; /** diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 760d25d77d8..7de56189dbb 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -216,9 +216,9 @@ export interface OIDCMetadata extends AuthServerMetadata { * pairwise: the subject identifier is unique to the client * public: the subject identifier is unique to the server * - * only `public` is supported. + * @see https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes */ - subject_types_supported: "public"[]; + subject_types_supported: ("public" | "pairwise")[]; /** * Supported ID token signing algorithms. * @@ -307,6 +307,12 @@ export interface OAuthClient { * requesting offline_access scope, regardless of this setting. */ require_pkce?: boolean; + /** + * Subject identifier type for this client. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + */ + subject_type?: "public" | "pairwise"; //---- All other metadata ----// reference_id?: string; [key: string]: unknown; diff --git a/packages/oauth-provider/src/userinfo.ts b/packages/oauth-provider/src/userinfo.ts index 109651f4082..f204b96261e 100644 --- a/packages/oauth-provider/src/userinfo.ts +++ b/packages/oauth-provider/src/userinfo.ts @@ -3,6 +3,7 @@ import { APIError } from "better-auth/api"; import type { User } from "better-auth/types"; import { validateAccessToken } from "./introspect"; import type { OAuthOptions, Scope } from "./types"; +import { getClient, resolveSubjectIdentifier } from "./utils"; /** * Provides shared /userinfo and id_token claims functionality @@ -80,6 +81,21 @@ export async function userInfoEndpoint( } const baseUserClaims = userNormalClaims(user, scopes ?? []); + + // Resolve pairwise sub if server has pairwise enabled and client is configured for it + if (opts.pairwiseSecret) { + const clientId = (jwt.client_id ?? jwt.azp) as string | undefined; + if (clientId) { + const client = await getClient(ctx, opts, clientId); + if (client) { + baseUserClaims.sub = await resolveSubjectIdentifier( + user.id, + client, + opts, + ); + } + } + } const additionalInfoUserClaims = opts.customUserInfoClaims && scopes?.length ? await opts.customUserInfoClaims({ user, scopes, jwt }) diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts index 4739a598d37..0dc5a61e0a5 100644 --- a/packages/oauth-provider/src/utils/index.ts +++ b/packages/oauth-provider/src/utils/index.ts @@ -4,6 +4,7 @@ import { base64, base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; import { constantTimeEqual, + makeSignature, symmetricDecrypt, symmetricEncrypt, } from "better-auth/crypto"; @@ -418,6 +419,54 @@ export function parsePrompt(prompt: string) { return new Set(set); } +/** + * Extracts the sector identifier (hostname) from a client's first redirect URI. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + * @internal + */ +export function getSectorIdentifier(client: SchemaClient): string { + const uri = client.redirectUris?.[0]; + if (!uri) { + throw new BetterAuthError( + "Client has no redirect URIs for sector identifier", + ); + } + return new URL(uri).host; +} + +/** + * Computes a pairwise subject identifier using HMAC-SHA256. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg + * @internal + */ +export async function computePairwiseSub( + userId: string, + client: SchemaClient, + secret: string, +): Promise { + const sectorId = getSectorIdentifier(client); + return makeSignature(`${sectorId}.${userId}`, secret); +} + +/** + * Returns the appropriate subject identifier for a user+client pair. + * Uses pairwise when the client opts in and the server has a secret configured. + * + * @internal + */ +export async function resolveSubjectIdentifier( + userId: string, + client: SchemaClient, + opts: OAuthOptions, +): Promise { + if (client.subjectType === "pairwise" && opts.pairwiseSecret) { + return computePairwiseSub(userId, client, opts.pairwiseSecret); + } + return userId; +} + /** * Deletes a prompt value * diff --git a/packages/oauth-provider/tsdown.config.ts b/packages/oauth-provider/tsdown.config.ts index 1e3b6255df5..a658e1f1ee7 100644 --- a/packages/oauth-provider/tsdown.config.ts +++ b/packages/oauth-provider/tsdown.config.ts @@ -4,13 +4,15 @@ export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/index.ts", "./src/client.ts", "./src/client-resource.ts"], - external: [ - "@better-auth/core", - "@better-auth/utils", - "@better-fetch/fetch", - "better-auth", - "better-call", - ], + deps: { + neverBundle: [ + "@better-auth/core", + "@better-auth/utils", + "@better-fetch/fetch", + "better-auth", + "better-call", + ], + }, sourcemap: true, treeshake: true, clean: true, diff --git a/packages/passkey/package.json b/packages/passkey/package.json index bb1fcf7de40..cc7ef46b665 100644 --- a/packages/passkey/package.json +++ b/packages/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/passkey", - "version": "1.5.4", + "version": "1.5.5", "description": "Passkey plugin for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/passkey/tsdown.config.ts b/packages/passkey/tsdown.config.ts index 97122ea4b96..4e3ef1b566e 100644 --- a/packages/passkey/tsdown.config.ts +++ b/packages/passkey/tsdown.config.ts @@ -4,14 +4,16 @@ export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/index.ts", "./src/client.ts"], - external: [ - "nanostores", - "@better-auth/utils", - "better-call", - "@better-fetch/fetch", - "@better-auth/core", - "better-auth", - ], + deps: { + neverBundle: [ + "nanostores", + "@better-auth/utils", + "better-call", + "@better-fetch/fetch", + "@better-auth/core", + "better-auth", + ], + }, sourcemap: true, treeshake: true, }); diff --git a/packages/prisma-adapter/package.json b/packages/prisma-adapter/package.json index 8676e35671d..e2377f46dbc 100644 --- a/packages/prisma-adapter/package.json +++ b/packages/prisma-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/prisma-adapter", - "version": "1.5.4", + "version": "1.5.5", "description": "Prisma adapter for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/prisma-adapter/src/prisma-adapter.test.ts b/packages/prisma-adapter/src/prisma-adapter.test.ts index 9240dc3d0f5..75fa99ff801 100644 --- a/packages/prisma-adapter/src/prisma-adapter.test.ts +++ b/packages/prisma-adapter/src/prisma-adapter.test.ts @@ -1,14 +1,113 @@ +import type { BetterAuthOptions } from "@better-auth/core"; import { describe, expect, it, vi } from "vitest"; import { prismaAdapter } from "./prisma-adapter"; describe("prisma-adapter", () => { + const createTestAdapter = (prisma: Record) => + prismaAdapter(prisma as never, { + provider: "sqlite", + })({} as BetterAuthOptions); + it("should create prisma adapter", () => { const prisma = { $transaction: vi.fn(), - } as any; - const adapter = prismaAdapter(prisma, { + }; + const adapter = prismaAdapter(prisma as never, { provider: "sqlite", }); expect(adapter).toBeDefined(); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/8365 + */ + it("should fall back to updateMany for non-unique verification identifiers", async () => { + const update = vi.fn(); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const findFirst = vi.fn().mockResolvedValue({ + id: "verification-id", + identifier: "magic-link-token", + value: "updated-value", + }); + const adapter = createTestAdapter({ + $transaction: vi.fn(), + verification: { + findFirst, + update, + updateMany, + }, + }); + + const result = await adapter.update({ + model: "verification", + where: [{ field: "identifier", value: "magic-link-token" }], + update: { value: "updated-value" }, + }); + + expect(update).not.toHaveBeenCalled(); + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + identifier: "magic-link-token", + }, + data: expect.objectContaining({ + value: "updated-value", + updatedAt: expect.any(Date), + }), + }), + ); + expect(findFirst).toHaveBeenCalledWith({ + where: { + identifier: "magic-link-token", + }, + }); + expect(result).toEqual({ + id: "verification-id", + identifier: "magic-link-token", + value: "updated-value", + }); + }); + + it("should keep using update for unique non-id fields", async () => { + const update = vi.fn().mockResolvedValue({ + id: "session-id", + token: "session-token", + userId: "user-id", + }); + const updateMany = vi.fn(); + const findFirst = vi.fn(); + const adapter = createTestAdapter({ + $transaction: vi.fn(), + session: { + findFirst, + update, + updateMany, + }, + }); + + const result = await adapter.update({ + model: "session", + where: [{ field: "token", value: "session-token" }], + update: { userId: "user-id" }, + }); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + token: "session-token", + }, + data: expect.objectContaining({ + userId: "user-id", + updatedAt: expect.any(Date), + }), + }), + ); + expect(updateMany).not.toHaveBeenCalled(); + expect(findFirst).not.toHaveBeenCalled(); + expect(result).toEqual({ + id: "session-id", + token: "session-token", + userId: "user-id", + }); + }); }); diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index 2eb3673a50e..cbb0fdababe 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -183,6 +183,35 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { return operator; } } + const hasRootUniqueWhereCondition = ( + model: string, + where?: Where[] | undefined, + ) => { + if (!where?.length) { + return false; + } + + return where.some((condition) => { + if (condition.connector === "OR") { + return false; + } + + if (condition.operator && condition.operator !== "eq") { + return false; + } + + if (condition.field === "id") { + return true; + } + + return ( + getFieldAttributes({ + model, + field: condition.field, + })?.unique === true + ); + }); + }; const convertWhereClause = ({ action, model, @@ -463,6 +492,28 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, ); } + const hasRootUniqueCondition = hasRootUniqueWhereCondition( + model, + where, + ); + if (!hasRootUniqueCondition) { + const whereClause = convertWhereClause({ + model, + where, + action: "updateMany", + }); + const result = await db[model]!.updateMany({ + where: whereClause, + data: update, + }); + if (!result?.count) { + return null; + } + + return await db[model]!.findFirst({ + where: whereClause, + }); + } const whereClause = convertWhereClause({ model, where, diff --git a/packages/redis-storage/package.json b/packages/redis-storage/package.json index a10f82e69ba..fee44c36d48 100644 --- a/packages/redis-storage/package.json +++ b/packages/redis-storage/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/redis-storage", - "version": "1.5.4", + "version": "1.5.5", "description": "Redis storage for Better Auth secondary storage", "type": "module", "license": "MIT", diff --git a/packages/scim/package.json b/packages/scim/package.json index 3360db12b9d..f69952004d7 100644 --- a/packages/scim/package.json +++ b/packages/scim/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/scim", - "version": "1.5.4", + "version": "1.5.5", "description": "SCIM plugin for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/sso/package.json b/packages/sso/package.json index 80a06758138..b99bebc5b76 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/sso", - "version": "1.5.4", + "version": "1.5.5", "description": "SSO plugin for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 4275b1f0f82..fbc44eec4a1 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/stripe", - "version": "1.5.4", + "version": "1.5.5", "description": "Stripe plugin for Better Auth", "type": "module", "license": "MIT", diff --git a/packages/stripe/tsdown.config.ts b/packages/stripe/tsdown.config.ts index 87ee90a250b..6db760ce45f 100644 --- a/packages/stripe/tsdown.config.ts +++ b/packages/stripe/tsdown.config.ts @@ -4,6 +4,13 @@ export default defineConfig({ dts: { build: true, incremental: true }, format: ["esm"], entry: ["./src/index.ts", "./src/client.ts"], - external: ["better-auth", "better-call", "@better-fetch/fetch", "stripe"], + deps: { + neverBundle: [ + "better-auth", + "better-call", + "@better-fetch/fetch", + "stripe", + ], + }, sourcemap: true, }); diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index d7a18d2c72b..bc2c2aab16d 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/telemetry", - "version": "1.5.4", + "version": "1.5.5", "description": "Telemetry package for Better Auth", "type": "module", "license": "MIT", @@ -36,6 +36,7 @@ ".": { "dev-source": "./src/index.ts", "types": "./dist/index.d.mts", + "node": "./dist/node.mjs", "default": "./dist/index.mjs" } }, diff --git a/packages/telemetry/src/detectors/detect-system-info.ts b/packages/telemetry/src/detectors/detect-system-info.ts index 248d10e1e9d..7379c8eb2bb 100644 --- a/packages/telemetry/src/detectors/detect-system-info.ts +++ b/packages/telemetry/src/detectors/detect-system-info.ts @@ -1,5 +1,4 @@ import { env } from "@better-auth/core/env"; -import { importRuntime } from "../utils/import-util"; function getVendor() { const hasAny = (...keys: string[]) => @@ -73,131 +72,30 @@ function getVendor() { return null; } +// In the default (non-node) build, system info detection is not available. +// The node build (src/node.ts) provides its own inline implementation +// using static top-level imports of node:os and node:fs. export async function detectSystemInfo() { - try { - //check if it's cloudflare - if (getVendor() === "cloudflare") return "cloudflare"; - const os = await importRuntime("os"); - const cpus = os.cpus(); - return { - deploymentVendor: getVendor(), - systemPlatform: os.platform(), - systemRelease: os.release(), - systemArchitecture: os.arch(), - cpuCount: cpus.length, - cpuModel: cpus.length ? cpus[0]!.model : null, - cpuSpeed: cpus.length ? cpus[0]!.speed : null, - memory: os.totalmem(), - isWSL: await isWsl(), - isDocker: await isDocker(), - isTTY: - typeof process !== "undefined" && (process as any).stdout - ? (process as any).stdout.isTTY - : null, - }; - } catch { - return { - systemPlatform: null, - systemRelease: null, - systemArchitecture: null, - cpuCount: null, - cpuModel: null, - cpuSpeed: null, - memory: null, - isWSL: null, - isDocker: null, - isTTY: null, - }; - } -} - -let isDockerCached: boolean | undefined; - -async function hasDockerEnv() { - if (getVendor() === "cloudflare") return false; - - try { - const fs = await importRuntime("fs"); - fs.statSync("/.dockerenv"); - return true; - } catch { - return false; - } -} - -async function hasDockerCGroup() { - if (getVendor() === "cloudflare") return false; - try { - const fs = await importRuntime("fs"); - return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker"); - } catch { - return false; - } -} - -async function isDocker() { - if (getVendor() === "cloudflare") return false; - - if (isDockerCached === undefined) { - isDockerCached = (await hasDockerEnv()) || (await hasDockerCGroup()); - } - - return isDockerCached; -} - -async function isWsl() { - try { - if (getVendor() === "cloudflare") return false; - if (typeof process === "undefined" || process?.platform !== "linux") { - return false; - } - const fs = await importRuntime("fs"); - const os = await importRuntime("os"); - if (os.release().toLowerCase().includes("microsoft")) { - if (await isInsideContainer()) { - return false; - } - - return true; - } - - return fs - .readFileSync("/proc/version", "utf8") - .toLowerCase() - .includes("microsoft") - ? !(await isInsideContainer()) - : false; - } catch { - return false; - } -} - -let isInsideContainerCached: boolean | undefined; - -const hasContainerEnv = async () => { - if (getVendor() === "cloudflare") return false; - try { - const fs = await importRuntime("fs"); - fs.statSync("/run/.containerenv"); - return true; - } catch { - return false; - } -}; - -async function isInsideContainer() { - if (isInsideContainerCached === undefined) { - isInsideContainerCached = (await hasContainerEnv()) || (await isDocker()); - } - - return isInsideContainerCached; + return { + deploymentVendor: getVendor(), + systemPlatform: null, + systemRelease: null, + systemArchitecture: null, + cpuCount: null, + cpuModel: null, + cpuSpeed: null, + memory: null, + isWSL: null, + isDocker: null, + isTTY: null, + }; } export function isCI() { return ( env.CI !== "false" && ("BUILD_ID" in env || // Jenkins, Cloudbees - "BUILD_NUMBER" in env || // Jenkins, TeamCity (fixed typo: extra space removed) + "BUILD_NUMBER" in env || // Jenkins, TeamCity "CI" in env || // Travis CI, CircleCI, Cirrus CI, Gitlab CI, Appveyor, CodeShip, dsari, Cloudflare "CI_APP_ID" in env || // Appflow "CI_BUILD_ID" in env || // Appflow diff --git a/packages/telemetry/src/node.ts b/packages/telemetry/src/node.ts new file mode 100644 index 00000000000..40e7b36f657 --- /dev/null +++ b/packages/telemetry/src/node.ts @@ -0,0 +1,381 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { BetterAuthOptions } from "@better-auth/core"; +import { ENV, getBooleanEnvVar, isTest, logger } from "@better-auth/core/env"; +import { betterFetch } from "@better-fetch/fetch"; +import type { PackageJson } from "type-fest"; +import { getTelemetryAuthConfig } from "./detectors/detect-auth-config"; +import { detectPackageManager } from "./detectors/detect-project-info"; +import { detectEnvironment, detectRuntime } from "./detectors/detect-runtime"; +import type { TelemetryContext, TelemetryEvent } from "./types"; +import { hashToBase64 } from "./utils/hash"; +import { generateId } from "./utils/id"; +export { getTelemetryAuthConfig }; +export type { TelemetryEvent } from "./types"; + +// --- Node-specific: package.json reading --- + +let packageJSONCache: PackageJson | undefined; + +async function readRootPackageJson(): Promise { + if (packageJSONCache) return packageJSONCache; + try { + const cwd = process.cwd(); + if (!cwd) return undefined; + const raw = await fsPromises.readFile( + path.join(cwd, "package.json"), + "utf-8", + ); + packageJSONCache = JSON.parse(raw); + return packageJSONCache as PackageJson; + } catch {} + return undefined; +} + +async function getPackageVersion(pkg: string): Promise { + if (packageJSONCache) { + return (packageJSONCache.dependencies?.[pkg] || + packageJSONCache.devDependencies?.[pkg] || + packageJSONCache.peerDependencies?.[pkg]) as string | undefined; + } + + try { + const cwd = process.cwd(); + if (!cwd) throw new Error("no-cwd"); + const pkgJsonPath = path.join(cwd, "node_modules", pkg, "package.json"); + const raw = await fsPromises.readFile(pkgJsonPath, "utf-8"); + const json = JSON.parse(raw); + const resolved = + (json.version as string) || + (await getVersionFromLocalPackageJson(pkg)) || + undefined; + return resolved; + } catch {} + + return getVersionFromLocalPackageJson(pkg); +} + +async function getVersionFromLocalPackageJson( + pkg: string, +): Promise { + const json = await readRootPackageJson(); + if (!json) return undefined; + const allDeps = { + ...json.dependencies, + ...json.devDependencies, + ...json.peerDependencies, + } as Record; + return allDeps[pkg]; +} + +async function getNameFromLocalPackageJson(): Promise { + const json = await readRootPackageJson(); + return json?.name as string | undefined; +} + +// --- Node-specific: system info --- + +async function detectSystemInfo() { + try { + const cpus = os.cpus(); + return { + deploymentVendor: getVendor(), + systemPlatform: os.platform(), + systemRelease: os.release(), + systemArchitecture: os.arch(), + cpuCount: cpus.length, + cpuModel: cpus.length ? cpus[0]!.model : null, + cpuSpeed: cpus.length ? cpus[0]!.speed : null, + memory: os.totalmem(), + isWSL: await isWsl(), + isDocker: await isDocker(), + isTTY: (process as any).stdout ? (process as any).stdout.isTTY : null, + }; + } catch { + return { + systemPlatform: null, + systemRelease: null, + systemArchitecture: null, + cpuCount: null, + cpuModel: null, + cpuSpeed: null, + memory: null, + isWSL: null, + isDocker: null, + isTTY: null, + }; + } +} + +function getVendor() { + const env = process.env as Record; + const hasAny = (...keys: string[]) => keys.some((k) => Boolean(env[k])); + + if ( + hasAny("CF_PAGES", "CF_PAGES_URL", "CF_ACCOUNT_ID") || + (typeof navigator !== "undefined" && + navigator.userAgent === "Cloudflare-Workers") + ) { + return "cloudflare"; + } + + if (hasAny("VERCEL", "VERCEL_URL", "VERCEL_ENV")) return "vercel"; + if (hasAny("NETLIFY", "NETLIFY_URL")) return "netlify"; + if ( + hasAny( + "RENDER", + "RENDER_URL", + "RENDER_INTERNAL_HOSTNAME", + "RENDER_SERVICE_ID", + ) + ) + return "render"; + if ( + hasAny("AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV", "LAMBDA_TASK_ROOT") + ) + return "aws"; + if ( + hasAny( + "GOOGLE_CLOUD_FUNCTION_NAME", + "GOOGLE_CLOUD_PROJECT", + "GCP_PROJECT", + "K_SERVICE", + ) + ) + return "gcp"; + if ( + hasAny( + "AZURE_FUNCTION_NAME", + "FUNCTIONS_WORKER_RUNTIME", + "WEBSITE_INSTANCE_ID", + "WEBSITE_SITE_NAME", + ) + ) + return "azure"; + if (hasAny("DENO_DEPLOYMENT_ID", "DENO_REGION")) return "deno-deploy"; + if (hasAny("FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID")) return "fly-io"; + if (hasAny("RAILWAY_STATIC_URL", "RAILWAY_ENVIRONMENT_NAME")) + return "railway"; + if (hasAny("DYNO", "HEROKU_APP_NAME")) return "heroku"; + if (hasAny("DO_DEPLOYMENT_ID", "DO_APP_NAME", "DIGITALOCEAN")) + return "digitalocean"; + if (hasAny("KOYEB", "KOYEB_DEPLOYMENT_ID", "KOYEB_APP_NAME")) return "koyeb"; + return null; +} + +let isDockerCached: boolean | undefined; + +async function hasDockerEnv() { + try { + fs.statSync("/.dockerenv"); + return true; + } catch { + return false; + } +} + +async function hasDockerCGroup() { + try { + return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker"); + } catch { + return false; + } +} + +async function isDocker() { + if (isDockerCached === undefined) { + isDockerCached = (await hasDockerEnv()) || (await hasDockerCGroup()); + } + return isDockerCached; +} + +let isInsideContainerCached: boolean | undefined; + +const hasContainerEnv = async () => { + try { + fs.statSync("/run/.containerenv"); + return true; + } catch { + return false; + } +}; + +async function isInsideContainer() { + if (isInsideContainerCached === undefined) { + isInsideContainerCached = (await hasContainerEnv()) || (await isDocker()); + } + return isInsideContainerCached; +} + +async function isWsl() { + try { + if (process.platform !== "linux") { + return false; + } + if (os.release().toLowerCase().includes("microsoft")) { + if (await isInsideContainer()) { + return false; + } + return true; + } + + return fs + .readFileSync("/proc/version", "utf8") + .toLowerCase() + .includes("microsoft") + ? !(await isInsideContainer()) + : false; + } catch { + return false; + } +} + +// --- Node-specific: project ID --- + +let projectIdCached: string | null = null; + +async function getProjectId(baseUrl: string | undefined): Promise { + if (projectIdCached) return projectIdCached; + + const projectName = await getNameFromLocalPackageJson(); + if (projectName) { + projectIdCached = await hashToBase64( + baseUrl ? baseUrl + projectName : projectName, + ); + return projectIdCached; + } + + if (baseUrl) { + projectIdCached = await hashToBase64(baseUrl); + return projectIdCached; + } + + projectIdCached = generateId(32); + return projectIdCached; +} + +// --- detectDatabase/detectFramework override using local package.json reading --- + +async function detectDatabaseNode() { + const DATABASES: Record = { + pg: "postgresql", + mysql: "mysql", + mariadb: "mariadb", + sqlite3: "sqlite", + "better-sqlite3": "sqlite", + "@prisma/client": "prisma", + mongoose: "mongodb", + mongodb: "mongodb", + "drizzle-orm": "drizzle", + }; + for (const [pkg, name] of Object.entries(DATABASES)) { + const version = await getPackageVersion(pkg); + if (version) return { name, version }; + } + return undefined; +} + +async function detectFrameworkNode() { + const FRAMEWORKS: Record = { + next: "next", + nuxt: "nuxt", + "react-router": "react-router", + astro: "astro", + "@sveltejs/kit": "sveltekit", + "solid-start": "solid-start", + "tanstack-start": "tanstack-start", + hono: "hono", + express: "express", + elysia: "elysia", + expo: "expo", + }; + for (const [pkg, name] of Object.entries(FRAMEWORKS)) { + const version = await getPackageVersion(pkg); + if (version) return { name, version }; + } + return undefined; +} + +// --- Main telemetry export (node version) --- + +const noop: (event: TelemetryEvent) => Promise = async function noop() {}; + +export async function createTelemetry( + options: BetterAuthOptions, + context?: TelemetryContext | undefined, +) { + const debugEnabled = + options.telemetry?.debug || + getBooleanEnvVar("BETTER_AUTH_TELEMETRY_DEBUG", false); + + const telemetryEndpoint = ENV.BETTER_AUTH_TELEMETRY_ENDPOINT; + if (!telemetryEndpoint && !context?.customTrack) { + return { + publish: noop, + }; + } + const track = async (event: TelemetryEvent) => { + if (context?.customTrack) { + await context.customTrack(event).catch(logger.error); + } else if (telemetryEndpoint) { + if (debugEnabled) { + logger.info("telemetry event", JSON.stringify(event, null, 2)); + } else { + await betterFetch(telemetryEndpoint, { + method: "POST", + body: event, + }).catch(logger.error); + } + } + }; + + const isEnabled = async () => { + const telemetryEnabled = + options.telemetry?.enabled !== undefined + ? options.telemetry.enabled + : false; + const envEnabled = getBooleanEnvVar("BETTER_AUTH_TELEMETRY", false); + return ( + (envEnabled || telemetryEnabled) && (context?.skipTestCheck || !isTest()) + ); + }; + + const enabled = await isEnabled(); + let anonymousId: string | undefined; + + if (enabled) { + anonymousId = await getProjectId( + typeof options.baseURL === "string" ? options.baseURL : undefined, + ); + + const payload = { + config: await getTelemetryAuthConfig(options, context), + runtime: detectRuntime(), + database: await detectDatabaseNode(), + framework: await detectFrameworkNode(), + environment: detectEnvironment(), + systemInfo: await detectSystemInfo(), + packageManager: detectPackageManager(), + }; + + void track({ type: "init", payload, anonymousId }); + } + + return { + publish: async (event: TelemetryEvent) => { + if (!enabled) return; + if (!anonymousId) { + anonymousId = await getProjectId( + typeof options.baseURL === "string" ? options.baseURL : undefined, + ); + } + await track({ + type: event.type, + payload: event.payload, + anonymousId, + }); + }, + }; +} diff --git a/packages/telemetry/src/utils/import-util.ts b/packages/telemetry/src/utils/import-util.ts deleted file mode 100644 index 2fe75f55073..00000000000 --- a/packages/telemetry/src/utils/import-util.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const importRuntime = (m: string): Promise => { - return (Function("mm", "return import(mm)") as any)(m); -}; diff --git a/packages/telemetry/src/utils/package-json.ts b/packages/telemetry/src/utils/package-json.ts index 51791be8449..594b9451815 100644 --- a/packages/telemetry/src/utils/package-json.ts +++ b/packages/telemetry/src/utils/package-json.ts @@ -1,75 +1,15 @@ -import type { PackageJson } from "type-fest"; +// In the default (non-node) build, filesystem access is not available. +// The node build (src/node.ts) provides its own inline implementation +// using static top-level imports of node:fs/promises and node:path. -let packageJSONCache: PackageJson | undefined; - -async function readRootPackageJson() { - if (packageJSONCache) return packageJSONCache; - try { - const cwd = - typeof process !== "undefined" && typeof process.cwd === "function" - ? process.cwd() - : ""; - if (!cwd) return undefined; - // Lazily import Node built-ins only when available (Node/Bun/Deno) and - // avoid static analyzer/bundler resolution by obfuscating module names - const importRuntime = (m: string) => - (Function("mm", "return import(mm)") as any)(m); - const [{ default: fs }, { default: path }] = await Promise.all([ - importRuntime("fs/promises"), - importRuntime("path"), - ]); - const raw = await fs.readFile(path.join(cwd, "package.json"), "utf-8"); - packageJSONCache = JSON.parse(raw); - return packageJSONCache as PackageJson; - } catch {} +export async function getPackageVersion( + _pkg: string, +): Promise { return undefined; } -export async function getPackageVersion(pkg: string) { - if (packageJSONCache) { - return (packageJSONCache.dependencies?.[pkg] || - packageJSONCache.devDependencies?.[pkg] || - packageJSONCache.peerDependencies?.[pkg]) as string | undefined; - } - - try { - const cwd = - typeof process !== "undefined" && typeof process.cwd === "function" - ? process.cwd() - : ""; - if (!cwd) throw new Error("no-cwd"); - const importRuntime = (m: string) => - (Function("mm", "return import(mm)") as any)(m); - const [{ default: fs }, { default: path }] = await Promise.all([ - importRuntime("fs/promises"), - importRuntime("path"), - ]); - const pkgJsonPath = path.join(cwd, "node_modules", pkg, "package.json"); - const raw = await fs.readFile(pkgJsonPath, "utf-8"); - const json = JSON.parse(raw); - const resolved = - (json.version as string) || - (await getVersionFromLocalPackageJson(pkg)) || - undefined; - return resolved; - } catch {} - - const fromRoot = await getVersionFromLocalPackageJson(pkg); - return fromRoot; -} - -async function getVersionFromLocalPackageJson(pkg: string) { - const json = await readRootPackageJson(); - if (!json) return undefined; - const allDeps = { - ...json.dependencies, - ...json.devDependencies, - ...json.peerDependencies, - } as Record; - return allDeps[pkg]; -} - -export async function getNameFromLocalPackageJson() { - const json = await readRootPackageJson(); - return json?.name as string | undefined; +export async function getNameFromLocalPackageJson(): Promise< + string | undefined +> { + return undefined; } diff --git a/packages/telemetry/tsdown.config.ts b/packages/telemetry/tsdown.config.ts index d100d00ed5a..ef780f9c8a8 100644 --- a/packages/telemetry/tsdown.config.ts +++ b/packages/telemetry/tsdown.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from "tsdown"; -export default defineConfig({ - dts: { build: true, incremental: true }, - format: ["esm"], - entry: ["./src/index.ts"], - sourcemap: true, -}); +export default defineConfig([ + { + dts: { build: true, incremental: true }, + format: ["esm"], + entry: ["./src/index.ts"], + sourcemap: true, + }, + { + dts: { build: true, incremental: true }, + format: ["esm"], + entry: ["./src/node.ts"], + sourcemap: true, + }, +]); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f4ac00b8cec..83998899223 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@better-auth/test-utils", - "version": "1.5.4", + "version": "1.5.5", "description": "Testing utilities for Better Auth adapter development", "type": "module", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a53f28c7453..ea348f4d2ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1435,6 +1435,9 @@ importers: drizzle-orm: specifier: ^0.41.0 version: 0.41.0(@cloudflare/workers-types@4.20260226.1)(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.0(encoding@0.1.13))(@opentelemetry/api@1.9.0)(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(bun-types@1.3.9)(gel@2.2.0)(kysely@0.28.11)(mysql2@3.18.2(@types/node@25.3.3))(pg@8.19.0)(postgres@3.4.8)(prisma@7.4.1(@types/react@19.2.14)(better-sqlite3@12.6.2)(magicast@0.5.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) + get-tsconfig: + specifier: ^4.13.6 + version: 4.13.6 open: specifier: ^10.2.0 version: 10.2.0 @@ -15586,12 +15589,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup-plugin-dts@6.3.0: - resolution: {integrity: sha512-d0UrqxYd8KyZ6i3M2Nx7WOMy708qsV/7fTHMHxCMCBOAe3V/U7OMPu5GkX8hC+cmkHhzGnfeYongl1IgiooddA==} + rollup-plugin-dts@6.4.0: + resolution: {integrity: sha512-2i00A5UoPCoDecLEs13Eu105QegSGfrbp1sDeUj/54LKGmv6XFHDxWKC6Wsb4BobGUWYVCWWjmjAc8bXXbXH/Q==} engines: {node: '>=16'} peerDependencies: rollup: ^3.29.4 || ^4 - typescript: ^4.5 || ^5.0 + typescript: ^4.5 || ^5.0 || ^6.0 rollup-plugin-visualizer@6.0.5: resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} @@ -25614,14 +25617,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@20.19.37)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.2)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -25632,14 +25635,14 @@ snapshots: msw: 2.12.10(@types/node@25.3.2)(typescript@5.9.3) vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@25.3.3)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -34918,8 +34921,11 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.5 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.5 - rollup-plugin-dts@6.3.0(rollup@4.59.0)(typescript@5.9.3): + rollup-plugin-dts@6.4.0(rollup@4.59.0)(typescript@5.9.3): dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + convert-source-map: 2.0.0 magic-string: 0.30.21 rollup: 4.59.0 typescript: 5.9.3 @@ -36263,7 +36269,7 @@ snapshots: pkg-types: 2.3.0 pretty-bytes: 7.1.0 rollup: 4.59.0 - rollup-plugin-dts: 6.3.0(rollup@4.59.0)(typescript@5.9.3) + rollup-plugin-dts: 6.4.0(rollup@4.59.0)(typescript@5.9.3) scule: 1.3.0 tinyglobby: 0.2.15 untyped: 2.0.0 @@ -36864,7 +36870,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -36944,7 +36950,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@25.3.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18