This is page 21 of 69. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-declaration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.base.json ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /docs/components/logo-context-menu.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import type React from "react"; 4 | import { useState, useRef, useEffect } from "react"; 5 | import { Code, Image, Type } from "lucide-react"; 6 | import { toast } from "sonner"; 7 | import { useTheme } from "next-themes"; 8 | import type { StaticImageData } from "next/image"; 9 | 10 | interface LogoAssets { 11 | darkSvg: string; 12 | whiteSvg: string; 13 | darkWordmark: string; 14 | whiteWordmark: string; 15 | darkPng: StaticImageData; 16 | whitePng: StaticImageData; 17 | } 18 | 19 | interface ContextMenuProps { 20 | logo: React.ReactNode; 21 | logoAssets: LogoAssets; 22 | } 23 | 24 | export default function LogoContextMenu({ 25 | logo, 26 | logoAssets, 27 | }: ContextMenuProps) { 28 | const [showMenu, setShowMenu] = useState<boolean>(false); 29 | const menuRef = useRef<HTMLDivElement>(null); 30 | const logoRef = useRef<HTMLDivElement>(null); 31 | const { theme } = useTheme(); 32 | 33 | const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => { 34 | e.preventDefault(); 35 | e.stopPropagation(); 36 | const rect = logoRef.current?.getBoundingClientRect(); 37 | if (rect) { 38 | setShowMenu(true); 39 | } 40 | }; 41 | 42 | const copySvgToClipboard = ( 43 | e: React.MouseEvent, 44 | svgContent: string, 45 | type: string, 46 | ) => { 47 | e.preventDefault(); 48 | e.stopPropagation(); 49 | navigator.clipboard 50 | .writeText(svgContent) 51 | .then(() => { 52 | toast.success("", { 53 | description: `${type} copied to clipboard`, 54 | }); 55 | }) 56 | .catch((err) => { 57 | toast.error("", { 58 | description: `Failed to copy ${type} to clipboard`, 59 | }); 60 | }); 61 | setShowMenu(false); 62 | }; 63 | 64 | const downloadPng = ( 65 | e: React.MouseEvent, 66 | pngData: StaticImageData, 67 | fileName: string, 68 | ) => { 69 | e.preventDefault(); 70 | e.stopPropagation(); 71 | const link = document.createElement("a"); 72 | link.href = pngData.src; 73 | link.download = fileName; 74 | 75 | document.body.appendChild(link); 76 | link.click(); 77 | document.body.removeChild(link); 78 | 79 | toast.success(`Downloading the asset...`); 80 | 81 | setShowMenu(false); 82 | }; 83 | 84 | const downloadAllAssets = (e: React.MouseEvent) => { 85 | e.preventDefault(); 86 | e.stopPropagation(); 87 | const link = document.createElement("a"); 88 | link.href = "/branding/better-auth-brand-assets.zip"; 89 | link.download = "better-auth-branding-assets.zip"; 90 | 91 | document.body.appendChild(link); 92 | link.click(); 93 | document.body.removeChild(link); 94 | 95 | toast.success("Downloading all assets..."); 96 | setShowMenu(false); 97 | }; 98 | 99 | useEffect(() => { 100 | const handleClickOutside = (event: MouseEvent) => { 101 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 102 | setShowMenu(false); 103 | } 104 | }; 105 | 106 | document.addEventListener("mousedown", handleClickOutside); 107 | return () => { 108 | document.removeEventListener("mousedown", handleClickOutside); 109 | }; 110 | }, []); 111 | 112 | const getAsset = <T,>(darkAsset: T, lightAsset: T): T => { 113 | return theme === "dark" ? darkAsset : lightAsset; 114 | }; 115 | 116 | return ( 117 | <div className="relative"> 118 | <div 119 | ref={logoRef} 120 | onContextMenu={handleContextMenu} 121 | className="cursor-pointer" 122 | > 123 | {logo} 124 | </div> 125 | 126 | {showMenu && ( 127 | <div 128 | ref={menuRef} 129 | className="fixed mx-10 z-50 bg-white dark:bg-black border border-gray-200 dark:border-border p-1 rounded-sm shadow-xl w-56 overflow-hidden animate-fd-dialog-in duration-500" 130 | > 131 | <div className=""> 132 | <div className="flex p-0 gap-1 flex-col text-xs"> 133 | <button 134 | onClick={(e) => 135 | copySvgToClipboard( 136 | e, 137 | getAsset(logoAssets.darkSvg, logoAssets.whiteSvg), 138 | "Logo SVG", 139 | ) 140 | } 141 | className="flex items-center gap-3 w-full p-2 text-black dark:text-white hover:bg-gray-100 dark:hover:bg-zinc-900 rounded-md transition-colors cursor-pointer" 142 | > 143 | <div className="flex items-center"> 144 | <span className="text-gray-400 dark:text-zinc-400/30">[</span> 145 | 146 | <Code className="h-[13.8px] w-[13.8px] mx-[3px]" /> 147 | <span className="text-gray-400 dark:text-zinc-400/30">]</span> 148 | </div> 149 | <span>Copy Logo as SVG </span> 150 | </button> 151 | <hr className="border-border/[60%]" /> 152 | <button 153 | onClick={(e) => 154 | copySvgToClipboard( 155 | e, 156 | getAsset(logoAssets.darkWordmark, logoAssets.whiteWordmark), 157 | "Logo Wordmark", 158 | ) 159 | } 160 | className="flex items-center gap-3 w-full p-2 text-black dark:text-white hover:bg-gray-100 dark:hover:bg-zinc-900 rounded-md transition-colors cursor-pointer" 161 | > 162 | <div className="flex items-center"> 163 | <span className="text-gray-400 dark:text-zinc-400/30">[</span> 164 | 165 | <Type className="h-[13.8px] w-[13.8px] mx-[3px]" /> 166 | <span className="text-gray-400 dark:text-zinc-400/30">]</span> 167 | </div> 168 | <span>Copy Logo as Wordmark </span> 169 | </button> 170 | 171 | <hr className="border-border/[60%]" /> 172 | <button 173 | onClick={(e) => 174 | downloadPng( 175 | e, 176 | getAsset(logoAssets.darkPng, logoAssets.whitePng), 177 | `better-auth-logo-${theme}.png`, 178 | ) 179 | } 180 | className="flex items-center gap-3 w-full p-2 text-black dark:text-white hover:bg-gray-100 dark:hover:bg-zinc-900 rounded-md transition-colors cursor-pointer" 181 | > 182 | <div className="flex items-center"> 183 | <span className="text-gray-400 dark:text-zinc-400/30">[</span> 184 | 185 | <Image className="h-[13.8px] w-[13.8px] mx-[3px]" /> 186 | <span className="text-gray-400 dark:text-zinc-400/30">]</span> 187 | </div> 188 | <span>Download Logo PNG</span> 189 | </button> 190 | <hr className="borde-border" /> 191 | <button 192 | onClick={(e) => downloadAllAssets(e)} 193 | className="flex items-center gap-3 w-full p-2 text-black dark:text-white hover:bg-gray-100 dark:hover:bg-zinc-900 rounded-md transition-colors cursor-pointer" 194 | > 195 | <div className="flex items-center"> 196 | <span className="text-gray-400 dark:text-zinc-400/30">[</span> 197 | 198 | <svg 199 | xmlns="http://www.w3.org/2000/svg" 200 | width="1em" 201 | height="1em" 202 | viewBox="0 0 24 24" 203 | className="h-[13.8px] w-[13.8px] mx-[3px]" 204 | > 205 | <path 206 | fill="none" 207 | stroke="currentColor" 208 | strokeLinecap="round" 209 | strokeLinejoin="round" 210 | strokeWidth="2" 211 | d="M4 8v8.8c0 1.12 0 1.68.218 2.108a2 2 0 0 0 .874.874c.427.218.987.218 2.105.218h9.606c1.118 0 1.677 0 2.104-.218c.377-.192.683-.498.875-.874c.218-.428.218-.987.218-2.105V8M4 8h16M4 8l1.365-2.39c.335-.585.503-.878.738-1.092c.209-.189.456-.332.723-.42C7.13 4 7.466 4 8.143 4h7.714c.676 0 1.015 0 1.318.099c.267.087.513.23.721.42c.236.213.404.506.74 1.093L20 8m-8 3v6m0 0l3-2m-3 2l-3-2" 212 | ></path> 213 | </svg> 214 | <span className="text-gray-400 dark:text-zinc-400/30">]</span> 215 | </div> 216 | <span>Brand Assets</span> 217 | </button> 218 | </div> 219 | </div> 220 | </div> 221 | )} 222 | </div> 223 | ); 224 | } 225 | ``` -------------------------------------------------------------------------------- /docs/app/docs/[[...slug]]/page.client.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | import { useState, useTransition } from "react"; 3 | import { 4 | Check, 5 | Copy, 6 | ChevronDown, 7 | ExternalLink, 8 | MessageCircle, 9 | } from "lucide-react"; 10 | import { cn } from "@/lib/utils"; 11 | import { buttonVariants } from "@/components/ui/button"; 12 | import { 13 | Popover, 14 | PopoverContent, 15 | PopoverTrigger, 16 | } from "fumadocs-ui/components/ui/popover"; 17 | import { cva } from "class-variance-authority"; 18 | 19 | import { type MouseEventHandler, useEffect, useRef } from "react"; 20 | import { useEffectEvent } from "fumadocs-core/utils/use-effect-event"; 21 | 22 | export function useCopyButton( 23 | onCopy: () => void | Promise<void>, 24 | ): [checked: boolean, onClick: MouseEventHandler] { 25 | const [checked, setChecked] = useState(false); 26 | const timeoutRef = useRef<number | null>(null); 27 | 28 | const onClick: MouseEventHandler = useEffectEvent(() => { 29 | if (timeoutRef.current) window.clearTimeout(timeoutRef.current); 30 | const res = Promise.resolve(onCopy()); 31 | 32 | void res.then(() => { 33 | setChecked(true); 34 | timeoutRef.current = window.setTimeout(() => { 35 | setChecked(false); 36 | }, 1500); 37 | }); 38 | }); 39 | 40 | // Avoid updates after being unmounted 41 | useEffect(() => { 42 | return () => { 43 | if (timeoutRef.current) window.clearTimeout(timeoutRef.current); 44 | }; 45 | }, []); 46 | 47 | return [checked, onClick]; 48 | } 49 | 50 | const cache = new Map<string, string>(); 51 | 52 | export function LLMCopyButton() { 53 | const [isLoading, startTransition] = useTransition(); 54 | const [checked, onClick] = useCopyButton(async () => { 55 | startTransition(async () => { 56 | const url = window.location.pathname + ".mdx"; 57 | const cached = cache.get(url); 58 | 59 | if (cached) { 60 | await navigator.clipboard.writeText(cached); 61 | } else { 62 | await navigator.clipboard.write([ 63 | new ClipboardItem({ 64 | "text/plain": fetch(url).then(async (res) => { 65 | const content = await res.text(); 66 | cache.set(url, content); 67 | 68 | return content; 69 | }), 70 | }), 71 | ]); 72 | } 73 | }); 74 | }); 75 | 76 | return ( 77 | <button 78 | disabled={isLoading} 79 | className={cn( 80 | buttonVariants({ 81 | variant: "secondary", 82 | size: "sm", 83 | className: "gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground", 84 | }), 85 | )} 86 | onClick={onClick} 87 | > 88 | {checked ? <Check /> : <Copy />} 89 | Copy Markdown 90 | </button> 91 | ); 92 | } 93 | 94 | const optionVariants = cva( 95 | "text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4", 96 | ); 97 | 98 | export function ViewOptions(props: { markdownUrl: string; githubUrl: string }) { 99 | const markdownUrl = new URL(props.markdownUrl, "https://better-auth.com"); 100 | const q = `Read ${markdownUrl}, I want to ask questions about it.`; 101 | 102 | const claude = `https://claude.ai/new?${new URLSearchParams({ 103 | q, 104 | })}`; 105 | const gpt = `https://chatgpt.com/?${new URLSearchParams({ 106 | hints: "search", 107 | q, 108 | })}`; 109 | const t3 = `https://t3.chat/new?${new URLSearchParams({ 110 | q, 111 | })}`; 112 | 113 | return ( 114 | <Popover> 115 | <PopoverTrigger 116 | className={cn( 117 | buttonVariants({ 118 | variant: "secondary", 119 | size: "sm", 120 | className: "gap-2", 121 | }), 122 | )} 123 | > 124 | Open in 125 | <ChevronDown className="size-3.5 text-fd-muted-foreground" /> 126 | </PopoverTrigger> 127 | <PopoverContent className="flex flex-col overflow-auto"> 128 | {[ 129 | { 130 | title: "Open in GitHub", 131 | href: props.githubUrl, 132 | icon: ( 133 | <svg fill="currentColor" role="img" viewBox="0 0 24 24"> 134 | <title>GitHub</title> 135 | <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /> 136 | </svg> 137 | ), 138 | }, 139 | { 140 | title: "Open in ChatGPT", 141 | href: gpt, 142 | icon: ( 143 | <svg 144 | role="img" 145 | viewBox="0 0 24 24" 146 | fill="currentColor" 147 | xmlns="http://www.w3.org/2000/svg" 148 | > 149 | <title>OpenAI</title> 150 | <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" /> 151 | </svg> 152 | ), 153 | }, 154 | { 155 | title: "Open in Claude", 156 | href: claude, 157 | icon: ( 158 | <svg 159 | fill="currentColor" 160 | role="img" 161 | viewBox="0 0 24 24" 162 | xmlns="http://www.w3.org/2000/svg" 163 | > 164 | <title>Anthropic</title> 165 | <path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" /> 166 | </svg> 167 | ), 168 | }, 169 | { 170 | title: "Open in T3 Chat", 171 | href: t3, 172 | icon: <MessageCircle />, 173 | }, 174 | ].map((item) => ( 175 | <a 176 | key={item.href} 177 | href={item.href} 178 | rel="noreferrer noopener" 179 | target="_blank" 180 | className={cn(optionVariants())} 181 | > 182 | {item.icon} 183 | {item.title} 184 | <ExternalLink className="text-fd-muted-foreground size-3.5 ms-auto" /> 185 | </a> 186 | ))} 187 | </PopoverContent> 188 | </Popover> 189 | ); 190 | } 191 | ``` -------------------------------------------------------------------------------- /packages/cli/src/commands/mcp.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Command } from "commander"; 2 | import { execSync } from "child_process"; 3 | import * as os from "os"; 4 | import * as fs from "fs"; 5 | import * as path from "path"; 6 | import chalk from "chalk"; 7 | import { base64 } from "@better-auth/utils/base64"; 8 | 9 | interface MCPOptions { 10 | cursor?: boolean; 11 | claudeCode?: boolean; 12 | openCode?: boolean; 13 | manual?: boolean; 14 | } 15 | 16 | export async function mcpAction(options: MCPOptions) { 17 | const mcpUrl = "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp"; 18 | const mcpName = "Better Auth"; 19 | 20 | if (options.cursor) { 21 | await handleCursorAction(mcpUrl, mcpName); 22 | } else if (options.claudeCode) { 23 | handleClaudeCodeAction(mcpUrl); 24 | } else if (options.openCode) { 25 | handleOpenCodeAction(mcpUrl); 26 | } else if (options.manual) { 27 | handleManualAction(mcpUrl, mcpName); 28 | } else { 29 | showAllOptions(mcpUrl, mcpName); 30 | } 31 | } 32 | 33 | async function handleCursorAction(mcpUrl: string, mcpName: string) { 34 | const mcpConfig = { 35 | url: mcpUrl, 36 | }; 37 | 38 | const encodedConfig = base64.encode( 39 | new TextEncoder().encode(JSON.stringify(mcpConfig)), 40 | ); 41 | const deeplinkUrl = `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(mcpName)}&config=${encodedConfig}`; 42 | 43 | console.log(chalk.bold.blue("🚀 Adding Better Auth MCP to Cursor...")); 44 | 45 | try { 46 | const platform = os.platform(); 47 | let command: string; 48 | 49 | switch (platform) { 50 | case "darwin": 51 | command = `open "${deeplinkUrl}"`; 52 | break; 53 | case "win32": 54 | command = `start "" "${deeplinkUrl}"`; 55 | break; 56 | case "linux": 57 | command = `xdg-open "${deeplinkUrl}"`; 58 | break; 59 | default: 60 | throw new Error(`Unsupported platform: ${platform}`); 61 | } 62 | 63 | execSync(command, { stdio: "inherit" }); 64 | console.log(chalk.green("\n✓ Cursor MCP installed successfully!")); 65 | } catch (error) { 66 | console.log( 67 | chalk.yellow( 68 | "\n⚠ Could not automatically open Cursor. Please copy the deeplink URL above and open it manually.", 69 | ), 70 | ); 71 | console.log( 72 | chalk.gray( 73 | "\nYou can also manually add this configuration to your Cursor MCP settings:", 74 | ), 75 | ); 76 | console.log(chalk.gray(JSON.stringify(mcpConfig, null, 2))); 77 | } 78 | 79 | console.log(chalk.bold.white("\n✨ Next Steps:")); 80 | console.log( 81 | chalk.gray("• The MCP server will be added to your Cursor configuration"), 82 | ); 83 | console.log( 84 | chalk.gray("• You can now use Better Auth features directly in Cursor"), 85 | ); 86 | } 87 | 88 | function handleClaudeCodeAction(mcpUrl: string) { 89 | console.log(chalk.bold.blue("🤖 Adding Better Auth MCP to Claude Code...")); 90 | 91 | const command = `claude mcp add --transport http better-auth ${mcpUrl}`; 92 | 93 | try { 94 | execSync(command, { stdio: "inherit" }); 95 | console.log(chalk.green("\n✓ Claude Code MCP installed successfully!")); 96 | } catch (error) { 97 | console.log( 98 | chalk.yellow( 99 | "\n⚠ Could not automatically add to Claude Code. Please run this command manually:", 100 | ), 101 | ); 102 | console.log(chalk.cyan(command)); 103 | } 104 | 105 | console.log(chalk.bold.white("\n✨ Next Steps:")); 106 | console.log( 107 | chalk.gray( 108 | "• The MCP server will be added to your Claude Code configuration", 109 | ), 110 | ); 111 | console.log( 112 | chalk.gray( 113 | "• You can now use Better Auth features directly in Claude Code", 114 | ), 115 | ); 116 | } 117 | 118 | function handleOpenCodeAction(mcpUrl: string) { 119 | console.log(chalk.bold.blue("🔧 Adding Better Auth MCP to Open Code...")); 120 | 121 | const openCodeConfig = { 122 | $schema: "https://opencode.ai/config.json", 123 | mcp: { 124 | "Better Auth": { 125 | type: "remote", 126 | url: mcpUrl, 127 | enabled: true, 128 | }, 129 | }, 130 | }; 131 | 132 | const configPath = path.join(process.cwd(), "opencode.json"); 133 | 134 | try { 135 | let existingConfig: { 136 | mcp?: Record<string, unknown>; 137 | [key: string]: unknown; 138 | } = {}; 139 | if (fs.existsSync(configPath)) { 140 | const existingContent = fs.readFileSync(configPath, "utf8"); 141 | existingConfig = JSON.parse(existingContent); 142 | } 143 | 144 | const mergedConfig = { 145 | ...existingConfig, 146 | ...openCodeConfig, 147 | mcp: { 148 | ...existingConfig.mcp, 149 | ...openCodeConfig.mcp, 150 | }, 151 | }; 152 | 153 | fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2)); 154 | console.log( 155 | chalk.green(`\n✓ Open Code configuration written to ${configPath}`), 156 | ); 157 | console.log(chalk.green("✓ Better Auth MCP added successfully!")); 158 | } catch (error) { 159 | console.log( 160 | chalk.yellow( 161 | "\n⚠ Could not automatically write opencode.json. Please add this configuration manually:", 162 | ), 163 | ); 164 | console.log(chalk.cyan(JSON.stringify(openCodeConfig, null, 2))); 165 | } 166 | 167 | console.log(chalk.bold.white("\n✨ Next Steps:")); 168 | console.log(chalk.gray("• Restart Open Code to load the new MCP server")); 169 | console.log( 170 | chalk.gray("• You can now use Better Auth features directly in Open Code"), 171 | ); 172 | } 173 | 174 | function handleManualAction(mcpUrl: string, mcpName: string) { 175 | console.log(chalk.bold.blue("📝 Adding Better Auth MCP Configuration...")); 176 | 177 | const manualConfig = { 178 | [mcpName]: { 179 | url: mcpUrl, 180 | }, 181 | }; 182 | 183 | const configPath = path.join(process.cwd(), "mcp.json"); 184 | 185 | try { 186 | let existingConfig = {}; 187 | if (fs.existsSync(configPath)) { 188 | const existingContent = fs.readFileSync(configPath, "utf8"); 189 | existingConfig = JSON.parse(existingContent); 190 | } 191 | 192 | const mergedConfig = { 193 | ...existingConfig, 194 | ...manualConfig, 195 | }; 196 | 197 | fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2)); 198 | console.log(chalk.green(`\n✓ MCP configuration written to ${configPath}`)); 199 | console.log(chalk.green("✓ Better Auth MCP added successfully!")); 200 | } catch (error) { 201 | console.log( 202 | chalk.yellow( 203 | "\n⚠ Could not automatically write mcp.json. Please add this configuration manually:", 204 | ), 205 | ); 206 | console.log(chalk.cyan(JSON.stringify(manualConfig, null, 2))); 207 | } 208 | 209 | console.log(chalk.bold.white("\n✨ Next Steps:")); 210 | console.log(chalk.gray("• Restart your MCP client to load the new server")); 211 | console.log( 212 | chalk.gray( 213 | "• You can now use Better Auth features directly in your MCP client", 214 | ), 215 | ); 216 | } 217 | 218 | function showAllOptions(mcpUrl: string, mcpName: string) { 219 | console.log(chalk.bold.blue("🔌 Better Auth MCP Server")); 220 | console.log(chalk.gray("Choose your MCP client to get started:")); 221 | console.log(); 222 | 223 | console.log(chalk.bold.white("Available Commands:")); 224 | console.log(chalk.cyan(" --cursor ") + chalk.gray("Add to Cursor")); 225 | console.log( 226 | chalk.cyan(" --claude-code ") + chalk.gray("Add to Claude Code"), 227 | ); 228 | console.log(chalk.cyan(" --open-code ") + chalk.gray("Add to Open Code")); 229 | console.log( 230 | chalk.cyan(" --manual ") + chalk.gray("Manual configuration"), 231 | ); 232 | console.log(); 233 | } 234 | 235 | export const mcp = new Command("mcp") 236 | .description("Add Better Auth MCP server to MCP Clients") 237 | .option("--cursor", "Automatically open Cursor with the MCP configuration") 238 | .option("--claude-code", "Show Claude Code MCP configuration command") 239 | .option("--open-code", "Show Open Code MCP configuration") 240 | .option("--manual", "Show manual MCP configuration for mcp.json") 241 | .action(mcpAction); 242 | ``` -------------------------------------------------------------------------------- /packages/core/src/types/context.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | Account, 3 | BetterAuthDBSchema, 4 | SecondaryStorage, 5 | Session, 6 | User, 7 | Verification, 8 | } from "../db"; 9 | import type { OAuthProvider } from "../oauth2"; 10 | import { createLogger } from "../env"; 11 | import type { DBAdapter, Where } from "../db/adapter"; 12 | import type { BetterAuthCookies } from "./cookie"; 13 | import type { DBPreservedModels } from "../db"; 14 | import type { LiteralUnion } from "./helper"; 15 | import type { CookieOptions, EndpointContext } from "better-call"; 16 | import type { 17 | BetterAuthOptions, 18 | BetterAuthRateLimitOptions, 19 | } from "./init-options"; 20 | 21 | export type GenericEndpointContext< 22 | Options extends BetterAuthOptions = BetterAuthOptions, 23 | > = EndpointContext<string, any> & { 24 | context: AuthContext<Options>; 25 | }; 26 | 27 | export interface InternalAdapter< 28 | Options extends BetterAuthOptions = BetterAuthOptions, 29 | > { 30 | createOAuthUser( 31 | user: Omit<User, "id" | "createdAt" | "updatedAt">, 32 | account: Omit<Account, "userId" | "id" | "createdAt" | "updatedAt"> & 33 | Partial<Account>, 34 | ): Promise<{ user: User; account: Account }>; 35 | 36 | createUser<T extends Record<string, any>>( 37 | user: Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> & 38 | Partial<User> & 39 | Record<string, any>, 40 | ): Promise<T & User>; 41 | 42 | createAccount<T extends Record<string, any>>( 43 | account: Omit<Account, "id" | "createdAt" | "updatedAt"> & 44 | Partial<Account> & 45 | T, 46 | ): Promise<T & Account>; 47 | 48 | listSessions(userId: string): Promise<Session[]>; 49 | 50 | listUsers( 51 | limit?: number, 52 | offset?: number, 53 | sortBy?: { field: string; direction: "asc" | "desc" }, 54 | where?: Where[], 55 | ): Promise<User[]>; 56 | 57 | countTotalUsers(where?: Where[]): Promise<number>; 58 | 59 | deleteUser(userId: string): Promise<void>; 60 | 61 | createSession( 62 | userId: string, 63 | dontRememberMe?: boolean, 64 | override?: Partial<Session> & Record<string, any>, 65 | overrideAll?: boolean, 66 | ): Promise<Session>; 67 | 68 | findSession(token: string): Promise<{ 69 | session: Session & Record<string, any>; 70 | user: User & Record<string, any>; 71 | } | null>; 72 | 73 | findSessions( 74 | sessionTokens: string[], 75 | ): Promise<{ session: Session; user: User }[]>; 76 | 77 | updateSession( 78 | sessionToken: string, 79 | session: Partial<Session> & Record<string, any>, 80 | ): Promise<Session | null>; 81 | 82 | deleteSession(token: string): Promise<void>; 83 | 84 | deleteAccounts(userId: string): Promise<void>; 85 | 86 | deleteAccount(accountId: string): Promise<void>; 87 | 88 | deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>; 89 | 90 | findOAuthUser( 91 | email: string, 92 | accountId: string, 93 | providerId: string, 94 | ): Promise<{ user: User; accounts: Account[] } | null>; 95 | 96 | findUserByEmail( 97 | email: string, 98 | options?: { includeAccounts: boolean }, 99 | ): Promise<{ user: User; accounts: Account[] } | null>; 100 | 101 | findUserById(userId: string): Promise<User | null>; 102 | 103 | linkAccount( 104 | account: Omit<Account, "id" | "createdAt" | "updatedAt"> & Partial<Account>, 105 | ): Promise<Account>; 106 | 107 | // fixme: any type 108 | updateUser( 109 | userId: string, 110 | data: Partial<User> & Record<string, any>, 111 | ): Promise<any>; 112 | 113 | updateUserByEmail( 114 | email: string, 115 | data: Partial<User & Record<string, any>>, 116 | ): Promise<User>; 117 | 118 | updatePassword(userId: string, password: string): Promise<void>; 119 | 120 | findAccounts(userId: string): Promise<Account[]>; 121 | 122 | findAccount(accountId: string): Promise<Account | null>; 123 | 124 | findAccountByProviderId( 125 | accountId: string, 126 | providerId: string, 127 | ): Promise<Account | null>; 128 | 129 | findAccountByUserId(userId: string): Promise<Account[]>; 130 | 131 | updateAccount(id: string, data: Partial<Account>): Promise<Account>; 132 | 133 | createVerificationValue( 134 | data: Omit<Verification, "createdAt" | "id" | "updatedAt"> & 135 | Partial<Verification>, 136 | ): Promise<Verification>; 137 | 138 | findVerificationValue(identifier: string): Promise<Verification | null>; 139 | 140 | deleteVerificationValue(id: string): Promise<void>; 141 | 142 | deleteVerificationByIdentifier(identifier: string): Promise<void>; 143 | 144 | updateVerificationValue( 145 | id: string, 146 | data: Partial<Verification>, 147 | ): Promise<Verification>; 148 | } 149 | 150 | type CreateCookieGetterFn = ( 151 | cookieName: string, 152 | overrideAttributes?: Partial<CookieOptions>, 153 | ) => { 154 | name: string; 155 | attributes: CookieOptions; 156 | }; 157 | 158 | type CheckPasswordFn<Options extends BetterAuthOptions = BetterAuthOptions> = ( 159 | userId: string, 160 | ctx: GenericEndpointContext<Options>, 161 | ) => Promise<boolean>; 162 | 163 | export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> = 164 | { 165 | options: Options; 166 | appName: string; 167 | baseURL: string; 168 | trustedOrigins: string[]; 169 | oauthConfig?: { 170 | /** 171 | * This is dangerous and should only be used in dev or staging environments. 172 | */ 173 | skipStateCookieCheck?: boolean; 174 | }; 175 | /** 176 | * New session that will be set after the request 177 | * meaning: there is a `set-cookie` header that will set 178 | * the session cookie. This is the fetched session. And it's set 179 | * by `setNewSession` method. 180 | */ 181 | newSession: { 182 | session: Session & Record<string, any>; 183 | user: User & Record<string, any>; 184 | } | null; 185 | session: { 186 | session: Session & Record<string, any>; 187 | user: User & Record<string, any>; 188 | } | null; 189 | setNewSession: ( 190 | session: { 191 | session: Session & Record<string, any>; 192 | user: User & Record<string, any>; 193 | } | null, 194 | ) => void; 195 | socialProviders: OAuthProvider[]; 196 | authCookies: BetterAuthCookies; 197 | logger: ReturnType<typeof createLogger>; 198 | rateLimit: { 199 | enabled: boolean; 200 | window: number; 201 | max: number; 202 | storage: "memory" | "database" | "secondary-storage"; 203 | } & BetterAuthRateLimitOptions; 204 | adapter: DBAdapter<Options>; 205 | internalAdapter: InternalAdapter<Options>; 206 | createAuthCookie: CreateCookieGetterFn; 207 | secret: string; 208 | sessionConfig: { 209 | updateAge: number; 210 | expiresIn: number; 211 | freshAge: number; 212 | }; 213 | generateId: (options: { 214 | model: LiteralUnion<DBPreservedModels, string>; 215 | size?: number; 216 | }) => string | false; 217 | secondaryStorage: SecondaryStorage | undefined; 218 | password: { 219 | hash: (password: string) => Promise<string>; 220 | verify: (data: { password: string; hash: string }) => Promise<boolean>; 221 | config: { 222 | minPasswordLength: number; 223 | maxPasswordLength: number; 224 | }; 225 | checkPassword: CheckPasswordFn<Options>; 226 | }; 227 | tables: BetterAuthDBSchema; 228 | runMigrations: () => Promise<void>; 229 | publishTelemetry: (event: { 230 | type: string; 231 | anonymousId?: string; 232 | payload: Record<string, any>; 233 | }) => Promise<void>; 234 | /** 235 | * This skips the origin check for all requests. 236 | * 237 | * set to true by default for `test` environments and `false` 238 | * for other environments. 239 | * 240 | * It's inferred from the `options.advanced?.disableCSRFCheck` 241 | * option or `options.advanced?.disableOriginCheck` option. 242 | * 243 | * @default false 244 | */ 245 | skipOriginCheck: boolean; 246 | /** 247 | * This skips the CSRF check for all requests. 248 | * 249 | * This is inferred from the `options.advanced?. 250 | * disableCSRFCheck` option. 251 | * 252 | * @default false 253 | */ 254 | skipCSRFCheck: boolean; 255 | }; 256 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/callback.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { setSessionCookie } from "../../cookies"; 3 | import { setTokenUtil } from "../../oauth2/utils"; 4 | import { handleOAuthUserInfo } from "../../oauth2/link-account"; 5 | import { parseState } from "../../oauth2/state"; 6 | import { HIDE_METADATA } from "../../utils/hide-metadata"; 7 | import { createAuthEndpoint } from "@better-auth/core/api"; 8 | import { safeJSONParse } from "../../utils/json"; 9 | import type { OAuth2Tokens } from "@better-auth/core/oauth2"; 10 | 11 | const schema = z.object({ 12 | code: z.string().optional(), 13 | error: z.string().optional(), 14 | device_id: z.string().optional(), 15 | error_description: z.string().optional(), 16 | state: z.string().optional(), 17 | user: z.string().optional(), 18 | }); 19 | 20 | export const callbackOAuth = createAuthEndpoint( 21 | "/callback/:id", 22 | { 23 | method: ["GET", "POST"], 24 | body: schema.optional(), 25 | query: schema.optional(), 26 | metadata: HIDE_METADATA, 27 | }, 28 | async (c) => { 29 | let queryOrBody: z.infer<typeof schema>; 30 | const defaultErrorURL = 31 | c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`; 32 | try { 33 | if (c.method === "GET") { 34 | queryOrBody = schema.parse(c.query); 35 | } else if (c.method === "POST") { 36 | queryOrBody = schema.parse(c.body); 37 | } else { 38 | throw new Error("Unsupported method"); 39 | } 40 | } catch (e) { 41 | c.context.logger.error("INVALID_CALLBACK_REQUEST", e); 42 | throw c.redirect(`${defaultErrorURL}?error=invalid_callback_request`); 43 | } 44 | 45 | const { code, error, state, error_description, device_id } = queryOrBody; 46 | 47 | if (!state) { 48 | c.context.logger.error("State not found", error); 49 | const sep = defaultErrorURL.includes("?") ? "&" : "?"; 50 | const url = `${defaultErrorURL}${sep}state=state_not_found`; 51 | throw c.redirect(url); 52 | } 53 | 54 | const { 55 | codeVerifier, 56 | callbackURL, 57 | link, 58 | errorURL, 59 | newUserURL, 60 | requestSignUp, 61 | } = await parseState(c); 62 | 63 | function redirectOnError(error: string, description?: string) { 64 | const baseURL = errorURL ?? defaultErrorURL; 65 | 66 | const params = new URLSearchParams({ error }); 67 | if (description) params.set("error_description", description); 68 | 69 | const sep = baseURL.includes("?") ? "&" : "?"; 70 | const url = `${baseURL}${sep}${params.toString()}`; 71 | 72 | throw c.redirect(url); 73 | } 74 | 75 | if (error) { 76 | redirectOnError(error, error_description); 77 | } 78 | 79 | if (!code) { 80 | c.context.logger.error("Code not found"); 81 | throw redirectOnError("no_code"); 82 | } 83 | const provider = c.context.socialProviders.find( 84 | (p) => p.id === c.params.id, 85 | ); 86 | 87 | if (!provider) { 88 | c.context.logger.error( 89 | "Oauth provider with id", 90 | c.params.id, 91 | "not found", 92 | ); 93 | throw redirectOnError("oauth_provider_not_found"); 94 | } 95 | 96 | let tokens: OAuth2Tokens; 97 | try { 98 | tokens = await provider.validateAuthorizationCode({ 99 | code: code, 100 | codeVerifier, 101 | deviceId: device_id, 102 | redirectURI: `${c.context.baseURL}/callback/${provider.id}`, 103 | }); 104 | } catch (e) { 105 | c.context.logger.error("", e); 106 | throw redirectOnError("invalid_code"); 107 | } 108 | const userInfo = await provider 109 | .getUserInfo({ 110 | ...tokens, 111 | user: c.body?.user ? safeJSONParse<any>(c.body.user) : undefined, 112 | }) 113 | .then((res) => res?.user); 114 | 115 | if (!userInfo) { 116 | c.context.logger.error("Unable to get user info"); 117 | return redirectOnError("unable_to_get_user_info"); 118 | } 119 | 120 | if (!callbackURL) { 121 | c.context.logger.error("No callback URL found"); 122 | throw redirectOnError("no_callback_url"); 123 | } 124 | 125 | if (link) { 126 | const trustedProviders = 127 | c.context.options.account?.accountLinking?.trustedProviders; 128 | const isTrustedProvider = trustedProviders?.includes( 129 | provider.id as "apple", 130 | ); 131 | if ( 132 | (!isTrustedProvider && !userInfo.emailVerified) || 133 | c.context.options.account?.accountLinking?.enabled === false 134 | ) { 135 | c.context.logger.error("Unable to link account - untrusted provider"); 136 | return redirectOnError("unable_to_link_account"); 137 | } 138 | 139 | if ( 140 | userInfo.email !== link.email && 141 | c.context.options.account?.accountLinking?.allowDifferentEmails !== true 142 | ) { 143 | return redirectOnError("email_doesn't_match"); 144 | } 145 | 146 | const existingAccount = await c.context.internalAdapter.findAccount( 147 | String(userInfo.id), 148 | ); 149 | 150 | if (existingAccount) { 151 | if (existingAccount.userId.toString() !== link.userId.toString()) { 152 | return redirectOnError("account_already_linked_to_different_user"); 153 | } 154 | const updateData = Object.fromEntries( 155 | Object.entries({ 156 | accessToken: await setTokenUtil(tokens.accessToken, c.context), 157 | refreshToken: await setTokenUtil(tokens.refreshToken, c.context), 158 | idToken: tokens.idToken, 159 | accessTokenExpiresAt: tokens.accessTokenExpiresAt, 160 | refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, 161 | scope: tokens.scopes?.join(","), 162 | }).filter(([_, value]) => value !== undefined), 163 | ); 164 | await c.context.internalAdapter.updateAccount( 165 | existingAccount.id, 166 | updateData, 167 | ); 168 | } else { 169 | const newAccount = await c.context.internalAdapter.createAccount({ 170 | userId: link.userId, 171 | providerId: provider.id, 172 | accountId: String(userInfo.id), 173 | ...tokens, 174 | accessToken: await setTokenUtil(tokens.accessToken, c.context), 175 | refreshToken: await setTokenUtil(tokens.refreshToken, c.context), 176 | scope: tokens.scopes?.join(","), 177 | }); 178 | if (!newAccount) { 179 | return redirectOnError("unable_to_link_account"); 180 | } 181 | } 182 | let toRedirectTo: string; 183 | try { 184 | const url = callbackURL; 185 | toRedirectTo = url.toString(); 186 | } catch { 187 | toRedirectTo = callbackURL; 188 | } 189 | throw c.redirect(toRedirectTo); 190 | } 191 | 192 | if (!userInfo.email) { 193 | c.context.logger.error( 194 | "Provider did not return email. This could be due to misconfiguration in the provider settings.", 195 | ); 196 | return redirectOnError("email_not_found"); 197 | } 198 | 199 | const result = await handleOAuthUserInfo(c, { 200 | userInfo: { 201 | ...userInfo, 202 | id: String(userInfo.id), 203 | email: userInfo.email, 204 | name: userInfo.name || userInfo.email, 205 | }, 206 | account: { 207 | providerId: provider.id, 208 | accountId: String(userInfo.id), 209 | ...tokens, 210 | scope: tokens.scopes?.join(","), 211 | }, 212 | callbackURL, 213 | disableSignUp: 214 | (provider.disableImplicitSignUp && !requestSignUp) || 215 | provider.options?.disableSignUp, 216 | overrideUserInfo: provider.options?.overrideUserInfoOnSignIn, 217 | }); 218 | if (result.error) { 219 | c.context.logger.error(result.error.split(" ").join("_")); 220 | return redirectOnError(result.error.split(" ").join("_")); 221 | } 222 | const { session, user } = result.data!; 223 | await setSessionCookie(c, { 224 | session, 225 | user, 226 | }); 227 | let toRedirectTo: string; 228 | try { 229 | const url = result.isRegister ? newUserURL || callbackURL : callbackURL; 230 | toRedirectTo = url.toString(); 231 | } catch { 232 | toRedirectTo = result.isRegister 233 | ? newUserURL || callbackURL 234 | : callbackURL; 235 | } 236 | throw c.redirect(toRedirectTo); 237 | }, 238 | ); 239 | ``` -------------------------------------------------------------------------------- /packages/cli/src/commands/login.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Command } from "commander"; 2 | import { logger } from "better-auth"; 3 | import { createAuthClient } from "better-auth/client"; 4 | import { deviceAuthorizationClient } from "better-auth/client/plugins"; 5 | import chalk from "chalk"; 6 | import open from "open"; 7 | import yoctoSpinner from "yocto-spinner"; 8 | import * as z from "zod/v4"; 9 | import { intro, outro, confirm, isCancel, cancel } from "@clack/prompts"; 10 | import fs from "fs/promises"; 11 | import path from "path"; 12 | import os from "os"; 13 | 14 | const DEMO_URL = "https://demo.better-auth.com"; 15 | const CLIENT_ID = "better-auth-cli"; 16 | const CONFIG_DIR = path.join(os.homedir(), ".better-auth"); 17 | const TOKEN_FILE = path.join(CONFIG_DIR, "token.json"); 18 | 19 | export async function loginAction(opts: any) { 20 | const options = z 21 | .object({ 22 | serverUrl: z.string().optional(), 23 | clientId: z.string().optional(), 24 | }) 25 | .parse(opts); 26 | 27 | const serverUrl = options.serverUrl || DEMO_URL; 28 | const clientId = options.clientId || CLIENT_ID; 29 | 30 | intro(chalk.bold("🔐 Better Auth CLI Login (Demo)")); 31 | 32 | console.log( 33 | chalk.yellow( 34 | "⚠️ This is a demo feature for testing device authorization flow.", 35 | ), 36 | ); 37 | console.log( 38 | chalk.gray( 39 | " It connects to the Better Auth demo server for testing purposes.\n", 40 | ), 41 | ); 42 | 43 | // Check if already logged in 44 | const existingToken = await getStoredToken(); 45 | if (existingToken) { 46 | const shouldReauth = await confirm({ 47 | message: "You're already logged in. Do you want to log in again?", 48 | initialValue: false, 49 | }); 50 | 51 | if (isCancel(shouldReauth) || !shouldReauth) { 52 | cancel("Login cancelled"); 53 | process.exit(0); 54 | } 55 | } 56 | 57 | // Create the auth client 58 | const authClient = createAuthClient({ 59 | baseURL: serverUrl, 60 | plugins: [deviceAuthorizationClient()], 61 | }); 62 | 63 | const spinner = yoctoSpinner({ text: "Requesting device authorization..." }); 64 | spinner.start(); 65 | 66 | try { 67 | // Request device code 68 | const { data, error } = await authClient.device.code({ 69 | client_id: clientId, 70 | scope: "openid profile email", 71 | }); 72 | 73 | spinner.stop(); 74 | 75 | if (error || !data) { 76 | logger.error( 77 | `Failed to request device authorization: ${error?.error_description || "Unknown error"}`, 78 | ); 79 | process.exit(1); 80 | } 81 | 82 | const { 83 | device_code, 84 | user_code, 85 | verification_uri, 86 | verification_uri_complete, 87 | interval = 5, 88 | expires_in, 89 | } = data; 90 | 91 | // Display authorization instructions 92 | console.log(""); 93 | console.log(chalk.cyan("📱 Device Authorization Required")); 94 | console.log(""); 95 | console.log(`Please visit: ${chalk.underline.blue(verification_uri)}`); 96 | console.log(`Enter code: ${chalk.bold.green(user_code)}`); 97 | console.log(""); 98 | 99 | // Ask if user wants to open browser 100 | const shouldOpen = await confirm({ 101 | message: "Open browser automatically?", 102 | initialValue: true, 103 | }); 104 | 105 | if (!isCancel(shouldOpen) && shouldOpen) { 106 | const urlToOpen = verification_uri_complete || verification_uri; 107 | await open(urlToOpen); 108 | } 109 | 110 | // Start polling 111 | console.log( 112 | chalk.gray( 113 | `Waiting for authorization (expires in ${Math.floor(expires_in / 60)} minutes)...`, 114 | ), 115 | ); 116 | 117 | const token = await pollForToken( 118 | authClient, 119 | device_code, 120 | clientId, 121 | interval, 122 | ); 123 | 124 | if (token) { 125 | // Store the token 126 | await storeToken(token); 127 | 128 | // Get user info 129 | const { data: session } = await authClient.getSession({ 130 | fetchOptions: { 131 | headers: { 132 | Authorization: `Bearer ${token.access_token}`, 133 | }, 134 | }, 135 | }); 136 | 137 | outro( 138 | chalk.green( 139 | `✅ Demo login successful! Logged in as ${session?.user?.name || session?.user?.email || "User"}`, 140 | ), 141 | ); 142 | 143 | console.log( 144 | chalk.gray( 145 | "\n📝 Note: This was a demo authentication for testing purposes.", 146 | ), 147 | ); 148 | 149 | console.log( 150 | chalk.blue( 151 | "\nFor more information, visit: https://better-auth.com/docs/plugins/device-authorization", 152 | ), 153 | ); 154 | } 155 | } catch (err) { 156 | spinner.stop(); 157 | logger.error( 158 | `Login failed: ${err instanceof Error ? err.message : "Unknown error"}`, 159 | ); 160 | process.exit(1); 161 | } 162 | } 163 | 164 | async function pollForToken( 165 | authClient: any, 166 | deviceCode: string, 167 | clientId: string, 168 | initialInterval: number, 169 | ): Promise<any> { 170 | let pollingInterval = initialInterval; 171 | const spinner = yoctoSpinner({ text: "", color: "cyan" }); 172 | let dots = 0; 173 | 174 | return new Promise((resolve, reject) => { 175 | const poll = async () => { 176 | // Update spinner text with animated dots 177 | dots = (dots + 1) % 4; 178 | spinner.text = chalk.gray( 179 | `Polling for authorization${".".repeat(dots)}${" ".repeat(3 - dots)}`, 180 | ); 181 | if (!spinner.isSpinning) spinner.start(); 182 | 183 | try { 184 | const { data, error } = await authClient.device.token({ 185 | grant_type: "urn:ietf:params:oauth:grant-type:device_code", 186 | device_code: deviceCode, 187 | client_id: clientId, 188 | fetchOptions: { 189 | headers: { 190 | "user-agent": `Better Auth CLI`, 191 | }, 192 | }, 193 | }); 194 | 195 | if (data?.access_token) { 196 | spinner.stop(); 197 | resolve(data); 198 | return; 199 | } else if (error) { 200 | switch (error.error) { 201 | case "authorization_pending": 202 | // Continue polling 203 | break; 204 | case "slow_down": 205 | pollingInterval += 5; 206 | spinner.text = chalk.yellow( 207 | `Slowing down polling to ${pollingInterval}s`, 208 | ); 209 | break; 210 | case "access_denied": 211 | spinner.stop(); 212 | logger.error("Access was denied by the user"); 213 | process.exit(1); 214 | break; 215 | case "expired_token": 216 | spinner.stop(); 217 | logger.error("The device code has expired. Please try again."); 218 | process.exit(1); 219 | break; 220 | default: 221 | spinner.stop(); 222 | logger.error(`Error: ${error.error_description}`); 223 | process.exit(1); 224 | } 225 | } 226 | } catch (err) { 227 | spinner.stop(); 228 | logger.error( 229 | `Network error: ${err instanceof Error ? err.message : "Unknown error"}`, 230 | ); 231 | process.exit(1); 232 | } 233 | 234 | setTimeout(poll, pollingInterval * 1000); 235 | }; 236 | 237 | // Start polling after initial interval 238 | setTimeout(poll, pollingInterval * 1000); 239 | }); 240 | } 241 | 242 | async function storeToken(token: any): Promise<void> { 243 | try { 244 | // Ensure config directory exists 245 | await fs.mkdir(CONFIG_DIR, { recursive: true }); 246 | 247 | // Store token with metadata 248 | const tokenData = { 249 | access_token: token.access_token, 250 | token_type: token.token_type || "Bearer", 251 | scope: token.scope, 252 | created_at: new Date().toISOString(), 253 | }; 254 | 255 | await fs.writeFile(TOKEN_FILE, JSON.stringify(tokenData, null, 2), "utf-8"); 256 | } catch (error) { 257 | logger.warn("Failed to store authentication token locally"); 258 | } 259 | } 260 | 261 | async function getStoredToken(): Promise<any> { 262 | try { 263 | const data = await fs.readFile(TOKEN_FILE, "utf-8"); 264 | return JSON.parse(data); 265 | } catch { 266 | return null; 267 | } 268 | } 269 | 270 | export const login = new Command("login") 271 | .description( 272 | "Demo: Test device authorization flow with Better Auth demo server", 273 | ) 274 | .option("--server-url <url>", "The Better Auth server URL", DEMO_URL) 275 | .option("--client-id <id>", "The OAuth client ID", CLIENT_ID) 276 | .action(loginAction); 277 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/oauth-proxy/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { originCheck } from "../../api"; 3 | import { 4 | createAuthEndpoint, 5 | createAuthMiddleware, 6 | } from "@better-auth/core/api"; 7 | import { symmetricDecrypt, symmetricEncrypt } from "../../crypto"; 8 | import type { BetterAuthPlugin } from "@better-auth/core"; 9 | import { env } from "@better-auth/core/env"; 10 | import { getOrigin } from "../../utils/url"; 11 | import type { EndpointContext } from "better-call"; 12 | 13 | function getVenderBaseURL() { 14 | const vercel = env.VERCEL_URL ? `https://${env.VERCEL_URL}` : undefined; 15 | const netlify = env.NETLIFY_URL; 16 | const render = env.RENDER_URL; 17 | const aws = env.AWS_LAMBDA_FUNCTION_NAME; 18 | const google = env.GOOGLE_CLOUD_FUNCTION_NAME; 19 | const azure = env.AZURE_FUNCTION_NAME; 20 | 21 | return vercel || netlify || render || aws || google || azure; 22 | } 23 | 24 | export interface OAuthProxyOptions { 25 | /** 26 | * The current URL of the application. 27 | * The plugin will attempt to infer the current URL from your environment 28 | * by checking the base URL from popular hosting providers, 29 | * from the request URL if invoked by a client, 30 | * or as a fallback, from the `baseURL` in your auth config. 31 | * If the URL is not inferred correctly, you can provide a value here." 32 | */ 33 | currentURL?: string; 34 | /** 35 | * If a request in a production url it won't be proxied. 36 | * 37 | * default to `BETTER_AUTH_URL` 38 | */ 39 | productionURL?: string; 40 | } 41 | 42 | /** 43 | * A proxy plugin, that allows you to proxy OAuth requests. 44 | * Useful for development and preview deployments where 45 | * the redirect URL can't be known in advance to add to the OAuth provider. 46 | */ 47 | export const oAuthProxy = (opts?: OAuthProxyOptions) => { 48 | const resolveCurrentURL = (ctx: EndpointContext<string, any>) => { 49 | return new URL( 50 | opts?.currentURL || 51 | ctx.request?.url || 52 | getVenderBaseURL() || 53 | ctx.context.baseURL, 54 | ); 55 | }; 56 | 57 | const checkSkipProxy = (ctx: EndpointContext<string, any>) => { 58 | // if skip proxy header is set, we don't need to proxy 59 | const skipProxy = ctx.request?.headers.get("x-skip-oauth-proxy"); 60 | if (skipProxy) { 61 | return true; 62 | } 63 | const productionURL = opts?.productionURL || env.BETTER_AUTH_URL; 64 | if (productionURL === ctx.context.options.baseURL) { 65 | return true; 66 | } 67 | return false; 68 | }; 69 | 70 | return { 71 | id: "oauth-proxy", 72 | options: opts, 73 | endpoints: { 74 | oAuthProxy: createAuthEndpoint( 75 | "/oauth-proxy-callback", 76 | { 77 | method: "GET", 78 | query: z.object({ 79 | callbackURL: z.string().meta({ 80 | description: "The URL to redirect to after the proxy", 81 | }), 82 | cookies: z.string().meta({ 83 | description: "The cookies to set after the proxy", 84 | }), 85 | }), 86 | use: [originCheck((ctx) => ctx.query.callbackURL)], 87 | metadata: { 88 | openapi: { 89 | description: "OAuth Proxy Callback", 90 | parameters: [ 91 | { 92 | in: "query", 93 | name: "callbackURL", 94 | required: true, 95 | description: "The URL to redirect to after the proxy", 96 | }, 97 | { 98 | in: "query", 99 | name: "cookies", 100 | required: true, 101 | description: "The cookies to set after the proxy", 102 | }, 103 | ], 104 | responses: { 105 | 302: { 106 | description: "Redirect", 107 | headers: { 108 | Location: { 109 | description: "The URL to redirect to", 110 | schema: { 111 | type: "string", 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | }, 118 | }, 119 | }, 120 | async (ctx) => { 121 | const cookies = ctx.query.cookies; 122 | 123 | const decryptedCookies = await symmetricDecrypt({ 124 | key: ctx.context.secret, 125 | data: cookies, 126 | }).catch((e) => { 127 | ctx.context.logger.error(e); 128 | return null; 129 | }); 130 | const error = 131 | ctx.context.options.onAPIError?.errorURL || 132 | `${ctx.context.options.baseURL}/api/auth/error`; 133 | if (!decryptedCookies) { 134 | throw ctx.redirect( 135 | `${error}?error=OAuthProxy - Invalid cookies or secret`, 136 | ); 137 | } 138 | 139 | const isSecureContext = resolveCurrentURL(ctx).protocol === "https:"; 140 | const prefix = 141 | ctx.context.options.advanced?.cookiePrefix || "better-auth"; 142 | const cookieToSet = isSecureContext 143 | ? decryptedCookies 144 | : decryptedCookies 145 | .replace("Secure;", "") 146 | .replace(`__Secure-${prefix}`, prefix); 147 | ctx.setHeader("set-cookie", cookieToSet); 148 | throw ctx.redirect(ctx.query.callbackURL); 149 | }, 150 | ), 151 | }, 152 | hooks: { 153 | after: [ 154 | { 155 | matcher(context) { 156 | return !!( 157 | context.path?.startsWith("/callback") || 158 | context.path?.startsWith("/oauth2/callback") 159 | ); 160 | }, 161 | handler: createAuthMiddleware(async (ctx) => { 162 | const headers = ctx.context.responseHeaders; 163 | const location = headers?.get("location"); 164 | if (location?.includes("/oauth-proxy-callback?callbackURL")) { 165 | if (!location.startsWith("http")) { 166 | return; 167 | } 168 | const locationURL = new URL(location); 169 | const origin = locationURL.origin; 170 | /** 171 | * We don't want to redirect to the proxy URL if the origin is the same 172 | * as the current URL 173 | */ 174 | const productionURL = 175 | opts?.productionURL || 176 | ctx.context.options.baseURL || 177 | ctx.context.baseURL; 178 | if (origin === getOrigin(productionURL)) { 179 | const newLocation = locationURL.searchParams.get("callbackURL"); 180 | if (!newLocation) { 181 | return; 182 | } 183 | ctx.setHeader("location", newLocation); 184 | return; 185 | } 186 | 187 | const setCookies = headers?.get("set-cookie"); 188 | 189 | if (!setCookies) { 190 | return; 191 | } 192 | const encryptedCookies = await symmetricEncrypt({ 193 | key: ctx.context.secret, 194 | data: setCookies, 195 | }); 196 | const locationWithCookies = `${location}&cookies=${encodeURIComponent( 197 | encryptedCookies, 198 | )}`; 199 | ctx.setHeader("location", locationWithCookies); 200 | } 201 | }), 202 | }, 203 | ], 204 | before: [ 205 | { 206 | matcher() { 207 | return true; 208 | }, 209 | handler: createAuthMiddleware(async (ctx) => { 210 | const skipProxy = checkSkipProxy(ctx); 211 | if (skipProxy || ctx.path !== "/callback/:id") { 212 | return; 213 | } 214 | return { 215 | context: { 216 | context: { 217 | oauthConfig: { 218 | skipStateCookieCheck: true, 219 | }, 220 | }, 221 | }, 222 | }; 223 | }), 224 | }, 225 | { 226 | matcher(context) { 227 | return !!( 228 | context.path?.startsWith("/sign-in/social") || 229 | context.path?.startsWith("/sign-in/oauth2") 230 | ); 231 | }, 232 | handler: createAuthMiddleware(async (ctx) => { 233 | const skipProxy = checkSkipProxy(ctx); 234 | if (skipProxy) { 235 | return; 236 | } 237 | const url = resolveCurrentURL(ctx); 238 | if (!ctx.body) { 239 | return; 240 | } 241 | ctx.body.callbackURL = `${url.origin}${ 242 | ctx.context.options.basePath || "/api/auth" 243 | }/oauth-proxy-callback?callbackURL=${encodeURIComponent( 244 | ctx.body.callbackURL || ctx.context.baseURL, 245 | )}`; 246 | return { 247 | context: ctx, 248 | }; 249 | }), 250 | }, 251 | ], 252 | }, 253 | } satisfies BetterAuthPlugin; 254 | }; 255 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/db/get-tables.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { BetterAuthOptions } from "@better-auth/core"; 2 | import type { 3 | BetterAuthDBSchema, 4 | DBFieldAttribute, 5 | } from "@better-auth/core/db"; 6 | 7 | export const getAuthTables = ( 8 | options: BetterAuthOptions, 9 | ): BetterAuthDBSchema => { 10 | const pluginSchema = (options.plugins ?? []).reduce( 11 | (acc, plugin) => { 12 | const schema = plugin.schema; 13 | if (!schema) return acc; 14 | for (const [key, value] of Object.entries(schema)) { 15 | acc[key] = { 16 | fields: { 17 | ...acc[key]?.fields, 18 | ...value.fields, 19 | }, 20 | modelName: value.modelName || key, 21 | }; 22 | } 23 | return acc; 24 | }, 25 | {} as Record< 26 | string, 27 | { fields: Record<string, DBFieldAttribute>; modelName: string } 28 | >, 29 | ); 30 | 31 | const shouldAddRateLimitTable = options.rateLimit?.storage === "database"; 32 | const rateLimitTable = { 33 | rateLimit: { 34 | modelName: options.rateLimit?.modelName || "rateLimit", 35 | fields: { 36 | key: { 37 | type: "string", 38 | fieldName: options.rateLimit?.fields?.key || "key", 39 | }, 40 | count: { 41 | type: "number", 42 | fieldName: options.rateLimit?.fields?.count || "count", 43 | }, 44 | lastRequest: { 45 | type: "number", 46 | bigint: true, 47 | fieldName: options.rateLimit?.fields?.lastRequest || "lastRequest", 48 | }, 49 | }, 50 | }, 51 | } satisfies BetterAuthDBSchema; 52 | 53 | const { user, session, account, ...pluginTables } = pluginSchema; 54 | 55 | const sessionTable = { 56 | session: { 57 | modelName: options.session?.modelName || "session", 58 | fields: { 59 | expiresAt: { 60 | type: "date", 61 | required: true, 62 | fieldName: options.session?.fields?.expiresAt || "expiresAt", 63 | }, 64 | token: { 65 | type: "string", 66 | required: true, 67 | fieldName: options.session?.fields?.token || "token", 68 | unique: true, 69 | }, 70 | createdAt: { 71 | type: "date", 72 | required: true, 73 | fieldName: options.session?.fields?.createdAt || "createdAt", 74 | defaultValue: () => new Date(), 75 | }, 76 | updatedAt: { 77 | type: "date", 78 | required: true, 79 | fieldName: options.session?.fields?.updatedAt || "updatedAt", 80 | onUpdate: () => new Date(), 81 | }, 82 | ipAddress: { 83 | type: "string", 84 | required: false, 85 | fieldName: options.session?.fields?.ipAddress || "ipAddress", 86 | }, 87 | userAgent: { 88 | type: "string", 89 | required: false, 90 | fieldName: options.session?.fields?.userAgent || "userAgent", 91 | }, 92 | userId: { 93 | type: "string", 94 | fieldName: options.session?.fields?.userId || "userId", 95 | references: { 96 | model: options.user?.modelName || "user", 97 | field: "id", 98 | onDelete: "cascade", 99 | }, 100 | required: true, 101 | }, 102 | ...session?.fields, 103 | ...options.session?.additionalFields, 104 | }, 105 | order: 2, 106 | }, 107 | } satisfies BetterAuthDBSchema; 108 | 109 | return { 110 | user: { 111 | modelName: options.user?.modelName || "user", 112 | fields: { 113 | name: { 114 | type: "string", 115 | required: true, 116 | fieldName: options.user?.fields?.name || "name", 117 | sortable: true, 118 | }, 119 | email: { 120 | type: "string", 121 | unique: true, 122 | required: true, 123 | fieldName: options.user?.fields?.email || "email", 124 | sortable: true, 125 | }, 126 | emailVerified: { 127 | type: "boolean", 128 | defaultValue: false, 129 | required: true, 130 | fieldName: options.user?.fields?.emailVerified || "emailVerified", 131 | }, 132 | image: { 133 | type: "string", 134 | required: false, 135 | fieldName: options.user?.fields?.image || "image", 136 | }, 137 | createdAt: { 138 | type: "date", 139 | defaultValue: () => new Date(), 140 | required: true, 141 | fieldName: options.user?.fields?.createdAt || "createdAt", 142 | }, 143 | updatedAt: { 144 | type: "date", 145 | defaultValue: () => new Date(), 146 | onUpdate: () => new Date(), 147 | required: true, 148 | fieldName: options.user?.fields?.updatedAt || "updatedAt", 149 | }, 150 | ...user?.fields, 151 | ...options.user?.additionalFields, 152 | }, 153 | order: 1, 154 | }, 155 | //only add session table if it's not stored in secondary storage 156 | ...(!options.secondaryStorage || options.session?.storeSessionInDatabase 157 | ? sessionTable 158 | : {}), 159 | account: { 160 | modelName: options.account?.modelName || "account", 161 | fields: { 162 | accountId: { 163 | type: "string", 164 | required: true, 165 | fieldName: options.account?.fields?.accountId || "accountId", 166 | }, 167 | providerId: { 168 | type: "string", 169 | required: true, 170 | fieldName: options.account?.fields?.providerId || "providerId", 171 | }, 172 | userId: { 173 | type: "string", 174 | references: { 175 | model: options.user?.modelName || "user", 176 | field: "id", 177 | onDelete: "cascade", 178 | }, 179 | required: true, 180 | fieldName: options.account?.fields?.userId || "userId", 181 | }, 182 | accessToken: { 183 | type: "string", 184 | required: false, 185 | fieldName: options.account?.fields?.accessToken || "accessToken", 186 | }, 187 | refreshToken: { 188 | type: "string", 189 | required: false, 190 | fieldName: options.account?.fields?.refreshToken || "refreshToken", 191 | }, 192 | idToken: { 193 | type: "string", 194 | required: false, 195 | fieldName: options.account?.fields?.idToken || "idToken", 196 | }, 197 | accessTokenExpiresAt: { 198 | type: "date", 199 | required: false, 200 | fieldName: 201 | options.account?.fields?.accessTokenExpiresAt || 202 | "accessTokenExpiresAt", 203 | }, 204 | refreshTokenExpiresAt: { 205 | type: "date", 206 | required: false, 207 | fieldName: 208 | options.account?.fields?.refreshTokenExpiresAt || 209 | "refreshTokenExpiresAt", 210 | }, 211 | scope: { 212 | type: "string", 213 | required: false, 214 | fieldName: options.account?.fields?.scope || "scope", 215 | }, 216 | password: { 217 | type: "string", 218 | required: false, 219 | fieldName: options.account?.fields?.password || "password", 220 | }, 221 | createdAt: { 222 | type: "date", 223 | required: true, 224 | fieldName: options.account?.fields?.createdAt || "createdAt", 225 | defaultValue: () => new Date(), 226 | }, 227 | updatedAt: { 228 | type: "date", 229 | required: true, 230 | fieldName: options.account?.fields?.updatedAt || "updatedAt", 231 | onUpdate: () => new Date(), 232 | }, 233 | ...account?.fields, 234 | ...options.account?.additionalFields, 235 | }, 236 | order: 3, 237 | }, 238 | verification: { 239 | modelName: options.verification?.modelName || "verification", 240 | fields: { 241 | identifier: { 242 | type: "string", 243 | required: true, 244 | fieldName: options.verification?.fields?.identifier || "identifier", 245 | }, 246 | value: { 247 | type: "string", 248 | required: true, 249 | fieldName: options.verification?.fields?.value || "value", 250 | }, 251 | expiresAt: { 252 | type: "date", 253 | required: true, 254 | fieldName: options.verification?.fields?.expiresAt || "expiresAt", 255 | }, 256 | createdAt: { 257 | type: "date", 258 | required: true, 259 | defaultValue: () => new Date(), 260 | fieldName: options.verification?.fields?.createdAt || "createdAt", 261 | }, 262 | updatedAt: { 263 | type: "date", 264 | required: true, 265 | defaultValue: () => new Date(), 266 | onUpdate: () => new Date(), 267 | fieldName: options.verification?.fields?.updatedAt || "updatedAt", 268 | }, 269 | }, 270 | order: 4, 271 | }, 272 | ...pluginTables, 273 | ...(shouldAddRateLimitTable ? rateLimitTable : {}), 274 | } satisfies BetterAuthDBSchema; 275 | }; 276 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/last-login-method/last-login-method.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; 2 | import { setupServer } from "msw/node"; 3 | import { http, HttpResponse } from "msw"; 4 | import { getTestInstance } from "../../test-utils/test-instance"; 5 | import { lastLoginMethod } from "."; 6 | import { lastLoginMethodClient } from "./client"; 7 | import { parseCookies, parseSetCookieHeader } from "../../cookies"; 8 | import { DEFAULT_SECRET } from "../../utils/constants"; 9 | import type { GoogleProfile } from "@better-auth/core/social-providers"; 10 | import { signJWT } from "../../crypto"; 11 | 12 | let testIdToken: string; 13 | let handlers: ReturnType<typeof http.post>[]; 14 | 15 | const server = setupServer(); 16 | 17 | beforeAll(async () => { 18 | const data: GoogleProfile = { 19 | email: "[email protected]", 20 | email_verified: true, 21 | name: "OAuth Test User", 22 | picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", 23 | exp: 1234567890, 24 | sub: "1234567890", 25 | iat: 1234567890, 26 | aud: "test", 27 | azp: "test", 28 | nbf: 1234567890, 29 | iss: "test", 30 | locale: "en", 31 | jti: "test", 32 | given_name: "OAuth", 33 | family_name: "Test", 34 | }; 35 | testIdToken = await signJWT(data, DEFAULT_SECRET); 36 | 37 | handlers = [ 38 | http.post("https://oauth2.googleapis.com/token", () => { 39 | return HttpResponse.json({ 40 | access_token: "test-access-token", 41 | refresh_token: "test-refresh-token", 42 | id_token: testIdToken, 43 | }); 44 | }), 45 | ]; 46 | 47 | server.listen({ onUnhandledRequest: "bypass" }); 48 | server.use(...handlers); 49 | }); 50 | 51 | afterEach(() => { 52 | server.resetHandlers(); 53 | server.use(...handlers); 54 | }); 55 | 56 | afterAll(() => server.close()); 57 | 58 | describe("lastLoginMethod", async () => { 59 | const { client, cookieSetter, testUser } = await getTestInstance( 60 | { 61 | plugins: [lastLoginMethod()], 62 | }, 63 | { 64 | clientOptions: { 65 | plugins: [lastLoginMethodClient()], 66 | }, 67 | }, 68 | ); 69 | 70 | it("should set the last login method cookie", async () => { 71 | const headers = new Headers(); 72 | await client.signIn.email( 73 | { 74 | email: testUser.email, 75 | password: testUser.password, 76 | }, 77 | { 78 | onSuccess(context) { 79 | cookieSetter(headers)(context); 80 | }, 81 | }, 82 | ); 83 | const cookies = parseCookies(headers.get("cookie") || ""); 84 | expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); 85 | }); 86 | 87 | it("should set the last login method in the database", async () => { 88 | const { client, auth } = await getTestInstance({ 89 | plugins: [lastLoginMethod({ storeInDatabase: true })], 90 | }); 91 | const data = await client.signIn.email( 92 | { 93 | email: testUser.email, 94 | password: testUser.password, 95 | }, 96 | { throw: true }, 97 | ); 98 | const session = await auth.api.getSession({ 99 | headers: new Headers({ 100 | authorization: `Bearer ${data.token}`, 101 | }), 102 | }); 103 | expect(session?.user.lastLoginMethod).toBe("email"); 104 | }); 105 | 106 | it("should NOT set the last login method cookie on failed authentication", async () => { 107 | const headers = new Headers(); 108 | const response = await client.signIn.email( 109 | { 110 | email: testUser.email, 111 | password: "wrong-password", 112 | }, 113 | { 114 | onError(context) { 115 | cookieSetter(headers)(context); 116 | }, 117 | }, 118 | ); 119 | 120 | expect(response.error).toBeDefined(); 121 | 122 | const cookies = parseCookies(headers.get("cookie") || ""); 123 | expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); 124 | }); 125 | 126 | it("should NOT set the last login method cookie on failed OAuth callback", async () => { 127 | const headers = new Headers(); 128 | const response = await client.$fetch("/callback/google", { 129 | method: "GET", 130 | query: { 131 | code: "invalid-code", 132 | state: "invalid-state", 133 | }, 134 | onError(context) { 135 | cookieSetter(headers)(context); 136 | }, 137 | }); 138 | 139 | expect(response.error).toBeDefined(); 140 | 141 | const cookies = parseCookies(headers.get("cookie") || ""); 142 | expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); 143 | }); 144 | it("should update the last login method in the database on subsequent logins", async () => { 145 | const { client, auth } = await getTestInstance({ 146 | plugins: [lastLoginMethod({ storeInDatabase: true })], 147 | }); 148 | 149 | await client.signUp.email( 150 | { 151 | email: "[email protected]", 152 | password: "password123", 153 | name: "Test User", 154 | }, 155 | { throw: true }, 156 | ); 157 | 158 | const emailSignInData = await client.signIn.email( 159 | { 160 | email: "[email protected]", 161 | password: "password123", 162 | }, 163 | { throw: true }, 164 | ); 165 | 166 | let session = await auth.api.getSession({ 167 | headers: new Headers({ 168 | authorization: `Bearer ${emailSignInData.token}`, 169 | }), 170 | }); 171 | expect((session?.user as any).lastLoginMethod).toBe("email"); 172 | 173 | await client.signOut(); 174 | 175 | const emailSignInData2 = await client.signIn.email( 176 | { 177 | email: "[email protected]", 178 | password: "password123", 179 | }, 180 | { throw: true }, 181 | ); 182 | 183 | session = await auth.api.getSession({ 184 | headers: new Headers({ 185 | authorization: `Bearer ${emailSignInData2.token}`, 186 | }), 187 | }); 188 | 189 | expect((session?.user as any).lastLoginMethod).toBe("email"); 190 | }); 191 | 192 | it("should update the last login method in the database on subsequent logins with email and OAuth", async () => { 193 | const { client, auth, cookieSetter } = await getTestInstance({ 194 | plugins: [lastLoginMethod({ storeInDatabase: true })], 195 | account: { 196 | accountLinking: { 197 | enabled: true, 198 | trustedProviders: ["google"], 199 | }, 200 | }, 201 | }); 202 | 203 | await client.signUp.email( 204 | { 205 | email: "[email protected]", 206 | password: "password123", 207 | name: "GitHub Issue Demo User", 208 | }, 209 | { throw: true }, 210 | ); 211 | 212 | const emailSignInData = await client.signIn.email( 213 | { 214 | email: "[email protected]", 215 | password: "password123", 216 | }, 217 | { throw: true }, 218 | ); 219 | 220 | let session = await auth.api.getSession({ 221 | headers: new Headers({ 222 | authorization: `Bearer ${emailSignInData.token}`, 223 | }), 224 | }); 225 | 226 | expect((session?.user as any).lastLoginMethod).toBe("email"); 227 | 228 | await client.signOut(); 229 | 230 | const oAuthHeaders = new Headers(); 231 | const signInRes = await client.signIn.social({ 232 | provider: "google", 233 | callbackURL: "/callback", 234 | fetchOptions: { 235 | onSuccess: cookieSetter(oAuthHeaders), 236 | }, 237 | }); 238 | expect(signInRes.data).toMatchObject({ 239 | url: expect.stringContaining("google.com"), 240 | redirect: true, 241 | }); 242 | const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; 243 | 244 | const headers = new Headers(); 245 | await client.$fetch("/callback/google", { 246 | query: { 247 | state, 248 | code: "test", 249 | }, 250 | headers: oAuthHeaders, 251 | method: "GET", 252 | onError(context) { 253 | expect(context.response.status).toBe(302); 254 | const location = context.response.headers.get("location"); 255 | expect(location).toBeDefined(); 256 | 257 | cookieSetter(headers)(context as any); 258 | 259 | const cookies = parseSetCookieHeader( 260 | context.response.headers.get("set-cookie") || "", 261 | ); 262 | const lastLoginMethod = cookies.get( 263 | "better-auth.last_used_login_method", 264 | )?.value; 265 | if (lastLoginMethod) { 266 | expect(lastLoginMethod).toBe("google"); 267 | } 268 | }, 269 | }); 270 | 271 | const oauthSession = await client.getSession({ 272 | fetchOptions: { 273 | headers: headers, 274 | }, 275 | }); 276 | expect((oauthSession?.data?.user as any).lastLoginMethod).toBe("google"); 277 | }); 278 | }); 279 | ``` -------------------------------------------------------------------------------- /docs/content/docs/guides/browser-extension-guide.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Browser Extension Guide 3 | description: A step-by-step guide to creating a browser extension with Better Auth. 4 | --- 5 | 6 | In this guide, we'll walk you through the steps of creating a browser extension using <Link href="https://docs.plasmo.com/">Plasmo</Link> with Better Auth for authentication. 7 | 8 | If you would like to view a completed example, you can check out the <Link href="https://github.com/better-auth/examples/tree/main/browser-extension-example">browser extension example</Link>. 9 | 10 | <Callout type="warn"> 11 | The Plasmo framework does not provide a backend for the browser extension. 12 | This guide assumes you have{" "} 13 | <Link href="/docs/integrations/hono">a backend setup</Link> of Better Auth and 14 | are ready to create a browser extension to connect to it. 15 | </Callout> 16 | 17 | <Steps> 18 | 19 | <Step> 20 | ## Setup & Installations 21 | 22 | Initialize a new Plasmo project with TailwindCSS and a src directory. 23 | 24 | ```bash 25 | pnpm create plasmo --with-tailwindcss --with-src 26 | ``` 27 | 28 | Then, install the Better Auth package. 29 | 30 | ```bash 31 | pnpm add better-auth 32 | ``` 33 | 34 | To start the Plasmo development server, run the following command. 35 | 36 | ```bash 37 | pnpm dev 38 | ``` 39 | </Step> 40 | 41 | 42 | <Step> 43 | ## Configure tsconfig 44 | 45 | Configure the `tsconfig.json` file to include `strict` mode. 46 | 47 | For this demo, we have also changed the import alias from `~` to `@` and set it to the `src` directory. 48 | 49 | ```json title="tsconfig.json" 50 | { 51 | "compilerOptions": { 52 | "paths": { 53 | "@/_": [ 54 | "./src/_" 55 | ] 56 | }, 57 | "strict": true, 58 | "baseUrl": "." 59 | } 60 | } 61 | ``` 62 | </Step> 63 | 64 | 65 | <Step> 66 | ## Create the client auth instance 67 | 68 | Create a new file at `src/auth/auth-client.ts` and add the following code. 69 | 70 | <Files> 71 | <Folder name="src" defaultOpen> 72 | <Folder name="auth" defaultOpen> 73 | <File name="auth-client.ts" /> 74 | </Folder> 75 | </Folder> 76 | </Files> 77 | 78 | ```ts title="auth-client.ts" 79 | import { createAuthClient } from "better-auth/react" 80 | 81 | export const authClient = createAuthClient({ 82 | baseURL: "http://localhost:3000" /* Base URL of your Better Auth backend. */, 83 | plugins: [], 84 | }); 85 | ``` 86 | </Step> 87 | 88 | <Step> 89 | ## Configure the manifest 90 | 91 | We must ensure the extension knows the URL to the Better Auth backend. 92 | 93 | Head to your package.json file, and add the following code. 94 | 95 | ```json title="package.json" 96 | { 97 | //... 98 | "manifest": { 99 | "host_permissions": [ 100 | "https://URL_TO_YOUR_BACKEND" // localhost works too (e.g. http://localhost:3000) 101 | ] 102 | } 103 | } 104 | ``` 105 | </Step> 106 | 107 | 108 | <Step> 109 | ## You're now ready! 110 | 111 | You have now set up Better Auth for your browser extension. 112 | 113 | Add your desired UI and create your dream extension! 114 | 115 | To learn more about the client Better Auth API, check out the <Link href="/docs/concepts/client">client documentation</Link>. 116 | 117 | 118 | Here's a quick example 😎 119 | 120 | ```tsx title="src/popup.tsx" 121 | import { authClient } from "./auth/auth-client" 122 | 123 | 124 | function IndexPopup() { 125 | const {data, isPending, error} = authClient.useSession(); 126 | if(isPending){ 127 | return <>Loading...</> 128 | } 129 | if(error){ 130 | return <>Error: {error.message}</> 131 | } 132 | if(data){ 133 | return <>Signed in as {data.user.name}</> 134 | } 135 | } 136 | 137 | export default IndexPopup; 138 | ``` 139 | 140 | </Step> 141 | 142 | 143 | <Step> 144 | ## Bundle your extension 145 | 146 | To get a production build, run the following command. 147 | 148 | ```bash 149 | pnpm build 150 | ``` 151 | 152 | Head over to <Link href="chrome://extensions" target="_blank">chrome://extensions</Link> and enable developer mode. 153 | 154 | <img src="https://docs.plasmo.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeveloper_mode.76f090f7.png&w=1920&q=75" /> 155 | 156 | Click on "Load Unpacked" and navigate to your extension's `build/chrome-mv3-dev` (or `build/chrome-mv3-prod`) directory. 157 | 158 | To see your popup, click on the puzzle piece icon on the Chrome toolbar, and click on your extension. 159 | 160 | Learn more about <Link href="https://docs.plasmo.com/framework#loading-the-extension-in-chrome">bundling your extension here.</Link> 161 | </Step> 162 | 163 | <Step> 164 | ## Configure the server auth instance 165 | 166 | First, we will need your extension URL. 167 | 168 | An extension URL formed like this: `chrome-extension://YOUR_EXTENSION_ID`. 169 | 170 | You can find your extension ID at <Link href="chrome://extensions" target="_blank">chrome://extensions</Link>. 171 | 172 | <img src="/extension-id.png" width={500} /> 173 | 174 | Head to your server's auth file, and make sure that your extension's URL is added to the `trustedOrigins` list. 175 | 176 | 177 | ```ts title="server.ts" 178 | import { betterAuth } from "better-auth" 179 | import { auth } from "@/auth/auth" 180 | 181 | export const auth = betterAuth({ 182 | trustedOrigins: ["chrome-extension://YOUR_EXTENSION_ID"], 183 | }) 184 | ``` 185 | 186 | If you're developing multiple extensions or need to support different browser extensions with different IDs, you can use wildcard patterns: 187 | 188 | ```ts title="server.ts" 189 | export const auth = betterAuth({ 190 | trustedOrigins: [ 191 | // Support a specific extension ID 192 | "chrome-extension://YOUR_EXTENSION_ID", 193 | 194 | // Or support multiple extensions with wildcard (less secure) 195 | "chrome-extension://*" 196 | ], 197 | }) 198 | ``` 199 | 200 | <Callout type="warn"> 201 | Using wildcards for extension origins (`chrome-extension://*`) reduces security by trusting all extensions. 202 | It's safer to explicitly list each extension ID you trust. Only use wildcards for development and testing. 203 | </Callout> 204 | </Step> 205 | 206 | <Step> 207 | ## That's it! 208 | 209 | Everything is set up! You can now start developing your extension. 🎉 210 | </Step> 211 | 212 | </Steps> 213 | 214 | 215 | ## Wrapping Up 216 | 217 | Congratulations! You've successfully created a browser extension using Better Auth and Plasmo. 218 | We highly recommend you visit the <Link href="https://docs.plasmo.com/">Plasmo documentation</Link> to learn more about the framework. 219 | 220 | If you would like to view a completed example, you can check out the <Link href="https://github.com/better-auth/examples/tree/main/browser-extension-example">browser extension example</Link>. 221 | 222 | If you have any questions, feel free to open an issue on our <Link href="https://github.com/better-auth/better-auth/issues">GitHub repo</Link>, or join our <Link href="https://discord.gg/better-auth">Discord server</Link> for support. 223 | ``` -------------------------------------------------------------------------------- /packages/stripe/src/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | GenericEndpointContext, 3 | InferOptionSchema, 4 | Session, 5 | User, 6 | } from "better-auth"; 7 | import type Stripe from "stripe"; 8 | import type { subscriptions, user } from "./schema"; 9 | 10 | export type StripePlan = { 11 | /** 12 | * Monthly price id 13 | */ 14 | priceId?: string; 15 | /** 16 | * To use lookup key instead of price id 17 | * 18 | * https://docs.stripe.com/products-prices/ 19 | * manage-prices#lookup-keys 20 | */ 21 | lookupKey?: string; 22 | /** 23 | * A yearly discount price id 24 | * 25 | * useful when you want to offer a discount for 26 | * yearly subscription 27 | */ 28 | annualDiscountPriceId?: string; 29 | /** 30 | * To use lookup key instead of price id 31 | * 32 | * https://docs.stripe.com/products-prices/ 33 | * manage-prices#lookup-keys 34 | */ 35 | annualDiscountLookupKey?: string; 36 | /** 37 | * Plan name 38 | */ 39 | name: string; 40 | /** 41 | * Limits for the plan 42 | */ 43 | limits?: Record<string, number>; 44 | /** 45 | * Plan group name 46 | * 47 | * useful when you want to group plans or 48 | * when a user can subscribe to multiple plans. 49 | */ 50 | group?: string; 51 | /** 52 | * Free trial days 53 | */ 54 | freeTrial?: { 55 | /** 56 | * Number of days 57 | */ 58 | days: number; 59 | /** 60 | * A function that will be called when the trial 61 | * starts. 62 | * 63 | * @param subscription 64 | * @returns 65 | */ 66 | onTrialStart?: (subscription: Subscription) => Promise<void>; 67 | /** 68 | * A function that will be called when the trial 69 | * ends 70 | * 71 | * @param subscription - Subscription 72 | * @returns 73 | */ 74 | onTrialEnd?: ( 75 | data: { 76 | subscription: Subscription; 77 | }, 78 | ctx: GenericEndpointContext, 79 | ) => Promise<void>; 80 | /** 81 | * A function that will be called when the trial 82 | * expired. 83 | * @param subscription - Subscription 84 | * @returns 85 | */ 86 | onTrialExpired?: ( 87 | subscription: Subscription, 88 | ctx: GenericEndpointContext, 89 | ) => Promise<void>; 90 | }; 91 | }; 92 | 93 | export interface Subscription { 94 | /** 95 | * Database identifier 96 | */ 97 | id: string; 98 | /** 99 | * The plan name 100 | */ 101 | plan: string; 102 | /** 103 | * Stripe customer id 104 | */ 105 | stripeCustomerId?: string; 106 | /** 107 | * Stripe subscription id 108 | */ 109 | stripeSubscriptionId?: string; 110 | /** 111 | * Trial start date 112 | */ 113 | trialStart?: Date; 114 | /** 115 | * Trial end date 116 | */ 117 | trialEnd?: Date; 118 | /** 119 | * Price Id for the subscription 120 | */ 121 | priceId?: string; 122 | /** 123 | * To what reference id the subscription belongs to 124 | * @example 125 | * - userId for a user 126 | * - workspace id for a saas platform 127 | * - website id for a hosting platform 128 | * 129 | * @default - userId 130 | */ 131 | referenceId: string; 132 | /** 133 | * Subscription status 134 | */ 135 | status: 136 | | "active" 137 | | "canceled" 138 | | "incomplete" 139 | | "incomplete_expired" 140 | | "past_due" 141 | | "paused" 142 | | "trialing" 143 | | "unpaid"; 144 | /** 145 | * The billing cycle start date 146 | */ 147 | periodStart?: Date; 148 | /** 149 | * The billing cycle end date 150 | */ 151 | periodEnd?: Date; 152 | /** 153 | * Cancel at period end 154 | */ 155 | cancelAtPeriodEnd?: boolean; 156 | /** 157 | * A field to group subscriptions so you can have multiple subscriptions 158 | * for one reference id 159 | */ 160 | groupId?: string; 161 | /** 162 | * Number of seats for the subscription (useful for team plans) 163 | */ 164 | seats?: number; 165 | } 166 | 167 | export interface StripeOptions { 168 | /** 169 | * Stripe Client 170 | */ 171 | stripeClient: Stripe; 172 | /** 173 | * Stripe Webhook Secret 174 | * 175 | * @description Stripe webhook secret key 176 | */ 177 | stripeWebhookSecret: string; 178 | /** 179 | * Enable customer creation when a user signs up 180 | */ 181 | createCustomerOnSignUp?: boolean; 182 | /** 183 | * A callback to run after a customer has been created 184 | * @param customer - Customer Data 185 | * @param stripeCustomer - Stripe Customer Data 186 | * @returns 187 | */ 188 | onCustomerCreate?: ( 189 | data: { 190 | stripeCustomer: Stripe.Customer; 191 | user: User & { stripeCustomerId: string }; 192 | }, 193 | ctx: GenericEndpointContext, 194 | ) => Promise<void>; 195 | /** 196 | * A custom function to get the customer create 197 | * params 198 | * @param data - data containing user and session 199 | * @returns 200 | */ 201 | getCustomerCreateParams?: ( 202 | user: User, 203 | ctx: GenericEndpointContext, 204 | ) => Promise<Partial<Stripe.CustomerCreateParams>>; 205 | /** 206 | * Subscriptions 207 | */ 208 | subscription?: { 209 | enabled: boolean; 210 | /** 211 | * Subscription Configuration 212 | */ 213 | /** 214 | * List of plan 215 | */ 216 | plans: StripePlan[] | (() => StripePlan[] | Promise<StripePlan[]>); 217 | /** 218 | * Require email verification before a user is allowed to upgrade 219 | * their subscriptions 220 | * 221 | * @default false 222 | */ 223 | requireEmailVerification?: boolean; 224 | /** 225 | * A callback to run after a user has subscribed to a package 226 | * @param event - Stripe Event 227 | * @param subscription - Subscription Data 228 | * @returns 229 | */ 230 | onSubscriptionComplete?: ( 231 | data: { 232 | event: Stripe.Event; 233 | stripeSubscription: Stripe.Subscription; 234 | subscription: Subscription; 235 | plan: StripePlan; 236 | }, 237 | ctx: GenericEndpointContext, 238 | ) => Promise<void>; 239 | /** 240 | * A callback to run after a user is about to cancel their subscription 241 | * @returns 242 | */ 243 | onSubscriptionUpdate?: (data: { 244 | event: Stripe.Event; 245 | subscription: Subscription; 246 | }) => Promise<void>; 247 | /** 248 | * A callback to run after a user is about to cancel their subscription 249 | * @returns 250 | */ 251 | onSubscriptionCancel?: (data: { 252 | event?: Stripe.Event; 253 | subscription: Subscription; 254 | stripeSubscription: Stripe.Subscription; 255 | cancellationDetails?: Stripe.Subscription.CancellationDetails | null; 256 | }) => Promise<void>; 257 | /** 258 | * A function to check if the reference id is valid 259 | * and belongs to the user 260 | * 261 | * @param data - data containing user, session and referenceId 262 | * @param ctx - the context object 263 | * @returns 264 | */ 265 | authorizeReference?: ( 266 | data: { 267 | user: User & Record<string, any>; 268 | session: Session & Record<string, any>; 269 | referenceId: string; 270 | action: 271 | | "upgrade-subscription" 272 | | "list-subscription" 273 | | "cancel-subscription" 274 | | "restore-subscription" 275 | | "billing-portal"; 276 | }, 277 | ctx: GenericEndpointContext, 278 | ) => Promise<boolean>; 279 | /** 280 | * A callback to run after a user has deleted their subscription 281 | * @returns 282 | */ 283 | onSubscriptionDeleted?: (data: { 284 | event: Stripe.Event; 285 | stripeSubscription: Stripe.Subscription; 286 | subscription: Subscription; 287 | }) => Promise<void>; 288 | /** 289 | * parameters for session create params 290 | * 291 | * @param data - data containing user, session and plan 292 | * @param ctx - the context object 293 | */ 294 | getCheckoutSessionParams?: ( 295 | data: { 296 | user: User & Record<string, any>; 297 | session: Session & Record<string, any>; 298 | plan: StripePlan; 299 | subscription: Subscription; 300 | }, 301 | ctx: GenericEndpointContext, 302 | ) => 303 | | Promise<{ 304 | params?: Stripe.Checkout.SessionCreateParams; 305 | options?: Stripe.RequestOptions; 306 | }> 307 | | { 308 | params?: Stripe.Checkout.SessionCreateParams; 309 | options?: Stripe.RequestOptions; 310 | }; 311 | /** 312 | * Enable organization subscription 313 | */ 314 | organization?: { 315 | enabled: boolean; 316 | }; 317 | }; 318 | /** 319 | * A callback to run after a stripe event is received 320 | * @param event - Stripe Event 321 | * @returns 322 | */ 323 | onEvent?: (event: Stripe.Event) => Promise<void>; 324 | /** 325 | * Schema for the stripe plugin 326 | */ 327 | schema?: InferOptionSchema<typeof subscriptions & typeof user>; 328 | } 329 | 330 | export interface InputSubscription extends Omit<Subscription, "id"> {} 331 | ``` -------------------------------------------------------------------------------- /docs/content/docs/concepts/rate-limit.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Rate Limit 3 | description: How to limit the number of requests a user can make to the server in a given time period. 4 | --- 5 | 6 | Better Auth includes a built-in rate limiter to help manage traffic and prevent abuse. By default, in production mode, the rate limiter is set to: 7 | 8 | - Window: 60 seconds 9 | - Max Requests: 100 requests 10 | 11 | <Callout type="warning"> 12 | Server-side requests made using `auth.api` aren't affected by rate limiting. Rate limits only apply to client-initiated requests. 13 | </Callout> 14 | 15 | You can easily customize these settings by passing the rateLimit object to the betterAuth function. 16 | 17 | ```ts title="auth.ts" 18 | import { betterAuth } from "better-auth"; 19 | 20 | export const auth = betterAuth({ 21 | rateLimit: { 22 | window: 10, // time window in seconds 23 | max: 100, // max requests in the window 24 | }, 25 | }) 26 | ``` 27 | 28 | Rate limiting is disabled in development mode by default. In order to enable it, set `enabled` to `true`: 29 | 30 | ```ts title="auth.ts" 31 | export const auth = betterAuth({ 32 | rateLimit: { 33 | enabled: true, 34 | //...other options 35 | }, 36 | }) 37 | ``` 38 | 39 | In addition to the default settings, Better Auth provides custom rules for specific paths. For example: 40 | - `/sign-in/email`: Is limited to 3 requests within 10 seconds. 41 | 42 | In addition, plugins also define custom rules for specific paths. For example, `twoFactor` plugin has custom rules: 43 | - `/two-factor/verify`: Is limited to 3 requests within 10 seconds. 44 | 45 | These custom rules ensure that sensitive operations are protected with stricter limits. 46 | 47 | ## Configuring Rate Limit 48 | 49 | ### Connecting IP Address 50 | 51 | Rate limiting uses the connecting IP address to track the number of requests made by a user. The 52 | default header checked is `x-forwarded-for`, which is commonly used in production environments. If 53 | you are using a different header to track the user's IP address, you'll need to specify it. 54 | 55 | ```ts title="auth.ts" 56 | export const auth = betterAuth({ 57 | //...other options 58 | advanced: { 59 | ipAddress: { 60 | ipAddressHeaders: ["cf-connecting-ip"], // Cloudflare specific header example 61 | }, 62 | }, 63 | rateLimit: { 64 | enabled: true, 65 | window: 60, // time window in seconds 66 | max: 100, // max requests in the window 67 | }, 68 | }) 69 | ``` 70 | 71 | ### Rate Limit Window 72 | 73 | ```ts title="auth.ts" 74 | import { betterAuth } from "better-auth"; 75 | 76 | export const auth = betterAuth({ 77 | //...other options 78 | rateLimit: { 79 | window: 60, // time window in seconds 80 | max: 100, // max requests in the window 81 | }, 82 | }) 83 | ``` 84 | 85 | You can also pass custom rules for specific paths. 86 | 87 | ```ts title="auth.ts" 88 | import { betterAuth } from "better-auth"; 89 | 90 | export const auth = betterAuth({ 91 | //...other options 92 | rateLimit: { 93 | window: 60, // time window in seconds 94 | max: 100, // max requests in the window 95 | customRules: { 96 | "/sign-in/email": { 97 | window: 10, 98 | max: 3, 99 | }, 100 | "/two-factor/*": async (request)=> { 101 | // custom function to return rate limit window and max 102 | return { 103 | window: 10, 104 | max: 3, 105 | } 106 | } 107 | }, 108 | }, 109 | }) 110 | ``` 111 | 112 | If you like to disable rate limiting for a specific path, you can set it to `false` or return `false` from the custom rule function. 113 | 114 | ```ts title="auth.ts" 115 | import { betterAuth } from "better-auth"; 116 | 117 | export const auth = betterAuth({ 118 | //...other options 119 | rateLimit: { 120 | customRules: { 121 | "/get-session": false, 122 | }, 123 | }, 124 | }) 125 | ``` 126 | 127 | ### Storage 128 | 129 | By default, rate limit data is stored in memory, which may not be suitable for many use cases, particularly in serverless environments. To address this, you can use a database, secondary storage, or custom storage for storing rate limit data. 130 | 131 | **Using Database** 132 | 133 | ```ts title="auth.ts" 134 | import { betterAuth } from "better-auth"; 135 | 136 | export const auth = betterAuth({ 137 | //...other options 138 | rateLimit: { 139 | storage: "database", 140 | modelName: "rateLimit", //optional by default "rateLimit" is used 141 | }, 142 | }) 143 | ``` 144 | 145 | Make sure to run `migrate` to create the rate limit table in your database. 146 | 147 | ```bash 148 | npx @better-auth/cli migrate 149 | ``` 150 | 151 | **Using Secondary Storage** 152 | 153 | If a [Secondary Storage](/docs/concepts/database#secondary-storage) has been configured you can use that to store rate limit data. 154 | 155 | ```ts title="auth.ts" 156 | import { betterAuth } from "better-auth"; 157 | 158 | export const auth = betterAuth({ 159 | //...other options 160 | rateLimit: { 161 | storage: "secondary-storage" 162 | }, 163 | }) 164 | ``` 165 | 166 | **Custom Storage** 167 | 168 | If none of the above solutions suits your use case you can implement a `customStorage`. 169 | 170 | ```ts title="auth.ts" 171 | import { betterAuth } from "better-auth"; 172 | 173 | export const auth = betterAuth({ 174 | //...other options 175 | rateLimit: { 176 | customStorage: { 177 | get: async (key) => { 178 | // get rate limit data 179 | }, 180 | set: async (key, value) => { 181 | // set rate limit data 182 | }, 183 | }, 184 | }, 185 | }) 186 | ``` 187 | 188 | ## Handling Rate Limit Errors 189 | 190 | When a request exceeds the rate limit, Better Auth returns the following header: 191 | 192 | - `X-Retry-After`: The number of seconds until the user can make another request. 193 | 194 | To handle rate limit errors on the client side, you can manage them either globally or on a per-request basis. Since Better Auth clients wrap over Better Fetch, you can pass `fetchOptions` to handle rate limit errors 195 | 196 | **Global Handling** 197 | 198 | ```ts title="auth-client.ts" 199 | import { createAuthClient } from "better-auth/client"; 200 | 201 | export const authClient = createAuthClient({ 202 | fetchOptions: { 203 | onError: async (context) => { 204 | const { response } = context; 205 | if (response.status === 429) { 206 | const retryAfter = response.headers.get("X-Retry-After"); 207 | console.log(`Rate limit exceeded. Retry after ${retryAfter} seconds`); 208 | } 209 | }, 210 | } 211 | }) 212 | ``` 213 | 214 | 215 | **Per Request Handling** 216 | 217 | ```ts title="auth-client.ts" 218 | import { authClient } from "./auth-client"; 219 | 220 | await authClient.signIn.email({ 221 | fetchOptions: { 222 | onError: async (context) => { 223 | const { response } = context; 224 | if (response.status === 429) { 225 | const retryAfter = response.headers.get("X-Retry-After"); 226 | console.log(`Rate limit exceeded. Retry after ${retryAfter} seconds`); 227 | } 228 | }, 229 | } 230 | }) 231 | ``` 232 | 233 | ### Schema 234 | 235 | If you are using a database to store rate limit data you need this schema: 236 | 237 | Table Name: `rateLimit` 238 | 239 | <DatabaseTable 240 | fields={[ 241 | { 242 | name: "id", 243 | type: "string", 244 | description: "Database ID", 245 | isPrimaryKey: true 246 | }, 247 | { 248 | name: "key", 249 | type: "string", 250 | description: "Unique identifier for each rate limit key", 251 | }, 252 | { 253 | name: "count", 254 | type: "integer", 255 | description: "Time window in seconds" 256 | }, 257 | { 258 | name: "lastRequest", 259 | type: "bigint", 260 | description: "Max requests in the window" 261 | }]} 262 | /> 263 | ```