This is page 30 of 70. 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 │ └── stateless │ ├── .env.example │ ├── .gitignore │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── src │ │ ├── app │ │ │ ├── api │ │ │ │ ├── auth │ │ │ │ │ └── [...all] │ │ │ │ │ └── route.ts │ │ │ │ └── user │ │ │ │ └── route.ts │ │ │ ├── dashboard │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── lib │ │ ├── auth-client.ts │ │ └── auth.ts │ ├── tailwind.config.ts │ └── tsconfig.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 │ │ │ ├── polar.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 │ ├── 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-custom-schema.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-schema.test.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 │ │ │ │ ├── polar.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 │ │ │ └── index.ts │ │ ├── test │ │ │ └── expo.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.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/content/docs/plugins/phone-number.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Phone Number 3 | description: Phone number plugin 4 | --- 5 | 6 | The phone number plugin extends the authentication system by allowing users to sign in and sign up using their phone number. It includes OTP (One-Time Password) functionality to verify phone numbers. 7 | 8 | ## Installation 9 | 10 | <Steps> 11 | <Step> 12 | ### Add Plugin to the server 13 | 14 | ```ts title="auth.ts" 15 | import { betterAuth } from "better-auth" 16 | import { phoneNumber } from "better-auth/plugins" 17 | 18 | const auth = betterAuth({ 19 | plugins: [ 20 | phoneNumber({ // [!code highlight] 21 | sendOTP: ({ phoneNumber, code }, request) => { // [!code highlight] 22 | // Implement sending OTP code via SMS // [!code highlight] 23 | } // [!code highlight] 24 | }) // [!code highlight] 25 | ] 26 | }) 27 | ``` 28 | </Step> 29 | <Step> 30 | ### Migrate the database 31 | 32 | Run the migration or generate the schema to add the necessary fields and tables to the database. 33 | 34 | <Tabs items={["migrate", "generate"]}> 35 | <Tab value="migrate"> 36 | ```bash 37 | npx @better-auth/cli migrate 38 | ``` 39 | </Tab> 40 | <Tab value="generate"> 41 | ```bash 42 | npx @better-auth/cli generate 43 | ``` 44 | </Tab> 45 | </Tabs> 46 | See the [Schema](#schema) section to add the fields manually. 47 | </Step> 48 | <Step> 49 | ### Add the client plugin 50 | 51 | ```ts title="auth-client.ts" 52 | import { createAuthClient } from "better-auth/client" 53 | import { phoneNumberClient } from "better-auth/client/plugins" 54 | 55 | const authClient = createAuthClient({ 56 | plugins: [ // [!code highlight] 57 | phoneNumberClient() // [!code highlight] 58 | ] // [!code highlight] 59 | }) 60 | ``` 61 | </Step> 62 | </Steps> 63 | 64 | ## Usage 65 | 66 | ### Send OTP for Verification 67 | 68 | To send an OTP to a user's phone number for verification, you can use the `sendVerificationCode` endpoint. 69 | 70 | <APIMethod path="/phone-number/send-otp" method="POST"> 71 | ```ts 72 | type sendPhoneNumberOTP = { 73 | /** 74 | * Phone number to send OTP. 75 | */ 76 | phoneNumber: string = "+1234567890" 77 | } 78 | ``` 79 | </APIMethod> 80 | 81 | ### Verify Phone Number 82 | 83 | After the OTP is sent, users can verify their phone number by providing the code. 84 | 85 | <APIMethod path="/phone-number/verify" method="POST"> 86 | ```ts 87 | type verifyPhoneNumber = { 88 | /** 89 | * Phone number to verify. 90 | */ 91 | phoneNumber: string = "+1234567890" 92 | /** 93 | * OTP code. 94 | */ 95 | code: string = "123456" 96 | /** 97 | * Disable session creation after verification. 98 | */ 99 | disableSession?: boolean = false 100 | /** 101 | * Check if there is a session and update the phone number. 102 | */ 103 | updatePhoneNumber?: boolean = true 104 | } 105 | ``` 106 | </APIMethod> 107 | 108 | <Callout> 109 | When the phone number is verified, the `phoneNumberVerified` field in the user table is set to `true`. If `disableSession` is not set to `true`, a session is created for the user. Additionally, if `callbackOnVerification` is provided, it will be called. 110 | </Callout> 111 | 112 | ### Allow Sign-Up with Phone Number 113 | 114 | To allow users to sign up using their phone number, you can pass `signUpOnVerification` option to your plugin configuration. It requires you to pass `getTempEmail` function to generate a temporary email for the user. 115 | 116 | ```ts title="auth.ts" 117 | export const auth = betterAuth({ 118 | plugins: [ 119 | phoneNumber({ 120 | sendOTP: ({ phoneNumber, code }, request) => { 121 | // Implement sending OTP code via SMS 122 | }, 123 | signUpOnVerification: { 124 | getTempEmail: (phoneNumber) => { 125 | return `${phoneNumber}@my-site.com` 126 | }, 127 | //optionally, you can also pass `getTempName` function to generate a temporary name for the user 128 | getTempName: (phoneNumber) => { 129 | return phoneNumber //by default, it will use the phone number as the name 130 | } 131 | } 132 | }) 133 | ] 134 | }) 135 | ``` 136 | 137 | ### Sign In with Phone Number 138 | 139 | In addition to signing in a user using send-verify flow, you can also use phone number as an identifier and sign in a user using phone number and password. 140 | 141 | <APIMethod path="/sign-in/phone-number" method="POST"> 142 | ```ts 143 | type signInPhoneNumber = { 144 | /** 145 | * Phone number to sign in. 146 | */ 147 | phoneNumber: string = "+1234567890" 148 | /** 149 | * Password to use for sign in. 150 | */ 151 | password: string 152 | /** 153 | * Remember the session. 154 | */ 155 | rememberMe?: boolean = true 156 | } 157 | ``` 158 | </APIMethod> 159 | 160 | ### Update Phone Number 161 | 162 | Updating phone number uses the same process as verifying a phone number. The user will receive an OTP code to verify the new phone number. 163 | 164 | ```ts title="auth-client.ts" 165 | await authClient.phoneNumber.sendOtp({ 166 | phoneNumber: "+1234567890" // New phone number 167 | }) 168 | ``` 169 | 170 | Then verify the new phone number with the OTP code. 171 | 172 | ```ts title="auth-client.ts" 173 | const isVerified = await authClient.phoneNumber.verify({ 174 | phoneNumber: "+1234567890", 175 | code: "123456", 176 | updatePhoneNumber: true // Set to true to update the phone number [!code highlight] 177 | }) 178 | ``` 179 | 180 | If a user session exist the phone number will be updated automatically. 181 | 182 | 183 | ### Disable Session Creation 184 | 185 | By default, the plugin creates a session for the user after verifying the phone number. You can disable this behavior by passing `disableSession: true` to the `verify` method. 186 | 187 | ```ts title="auth-client.ts" 188 | const isVerified = await authClient.phoneNumber.verify({ 189 | phoneNumber: "+1234567890", 190 | code: "123456", 191 | disableSession: true // [!code highlight] 192 | }) 193 | ``` 194 | 195 | ### Request Password Reset 196 | 197 | To initiate a request password reset flow using `phoneNumber`, you can start by calling `requestPasswordReset` on the client to send an OTP code to the user's phone number. 198 | 199 | <APIMethod path="/phone-number/request-password-reset" method="POST"> 200 | ```ts 201 | type requestPasswordResetPhoneNumber = { 202 | /** 203 | * The phone number which is associated with the user. 204 | */ 205 | phoneNumber: string = "+1234567890" 206 | } 207 | ``` 208 | </APIMethod> 209 | 210 | Then, you can reset the password by calling `resetPassword` on the client with the OTP code and the new password. 211 | 212 | <APIMethod path="/phone-number/reset-password" method="POST"> 213 | ```ts 214 | type resetPasswordPhoneNumber = { 215 | /** 216 | * The one time password to reset the password. 217 | */ 218 | otp: string = "123456" 219 | /** 220 | * The phone number to the account which intends to reset the password for. 221 | */ 222 | phoneNumber: string = "+1234567890" 223 | /** 224 | * The new password. 225 | */ 226 | newPassword: string = "new-and-secure-password" 227 | } 228 | ``` 229 | </APIMethod> 230 | 231 | ## Options 232 | 233 | - `otpLength`: The length of the OTP code to be generated. Default is `6`. 234 | - `sendOTP`: A function that sends the OTP code to the user's phone number. It takes the phone number and the OTP code as arguments. 235 | - `expiresIn`: The time in seconds after which the OTP code expires. Default is `300` seconds. 236 | - `callbackOnVerification`: A function that is called after the phone number is verified. It takes the phone number and the user object as the first argument and a request object as the second argument. 237 | ```ts 238 | export const auth = betterAuth({ 239 | plugins: [ 240 | phoneNumber({ 241 | sendOTP: ({ phoneNumber, code }, request) => { 242 | // Implement sending OTP code via SMS 243 | }, 244 | callbackOnVerification: async ({ phoneNumber, user }, request) => { 245 | // Implement callback after phone number verification 246 | } 247 | }) 248 | ] 249 | }) 250 | ``` 251 | - `sendPasswordResetOTP`: A function that sends the OTP code to the user's phone number for password reset. It takes the phone number and the OTP code as arguments. 252 | - `phoneNumberValidator`: A custom function to validate the phone number. It takes the phone number as an argument and returns a boolean indicating whether the phone number is valid. 253 | - `signUpOnVerification`: An object with the following properties: 254 | - `getTempEmail`: A function that generates a temporary email for the user. It takes the phone number as an argument and returns the temporary email. 255 | - `getTempName`: A function that generates a temporary name for the user. It takes the phone number as an argument and returns the temporary name. 256 | 257 | - `requireVerification`: When enabled, users cannot sign in with their phone number until it has been verified. If an unverified user attempts to sign in, the server will respond with a 401 error (PHONE_NUMBER_NOT_VERIFIED) and automatically trigger an OTP send to start the verification process. 258 | 259 | ## Schema 260 | 261 | The plugin requires 2 fields to be added to the user table 262 | 263 | ### User Table 264 | <DatabaseTable 265 | fields={[ 266 | { 267 | name: "phoneNumber", 268 | type: "string", 269 | description: "The phone number of the user", 270 | isUnique: true, 271 | isOptional: true 272 | }, 273 | { 274 | name: "phoneNumberVerified", 275 | type: "boolean", 276 | description: "Whether the phone number is verified or not", 277 | defaultValue: false, 278 | isOptional: true 279 | }, 280 | ]} 281 | /> 282 | 283 | ### OTP Verification Attempts 284 | 285 | The phone number plugin includes a built-in protection against brute force attacks by limiting the number of verification attempts for each OTP code. 286 | 287 | ```typescript 288 | phoneNumber({ 289 | allowedAttempts: 3, // default is 3 290 | // ... other options 291 | }) 292 | ``` 293 | 294 | When a user exceeds the allowed number of verification attempts: 295 | - The OTP code is automatically deleted 296 | - Further verification attempts will return a 403 (Forbidden) status with "Too many attempts" message 297 | - The user will need to request a new OTP code to continue 298 | 299 | Example error response after exceeding attempts: 300 | ```json 301 | { 302 | "error": { 303 | "status": 403, 304 | "message": "Too many attempts" 305 | } 306 | } 307 | ``` 308 | 309 | <Callout type="warning"> 310 | When receiving a 403 status, prompt the user to request a new OTP code 311 | </Callout> 312 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/client/client.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | // @vitest-environment happy-dom 2 | import { describe, expect, expectTypeOf, it, vi } from "vitest"; 3 | import { createAuthClient as createSolidClient } from "./solid"; 4 | import { createAuthClient as createReactClient } from "./react"; 5 | import { createAuthClient as createVueClient } from "./vue"; 6 | import { createAuthClient as createSvelteClient } from "./svelte"; 7 | import { createAuthClient as createVanillaClient } from "./vanilla"; 8 | import { testClientPlugin, testClientPlugin2 } from "./test-plugin"; 9 | import type { Accessor } from "solid-js"; 10 | import type { Ref } from "vue"; 11 | import type { ReadableAtom } from "nanostores"; 12 | import type { Session, SessionQueryParams } from "../types"; 13 | import { BetterFetchError } from "@better-fetch/fetch"; 14 | import { twoFactorClient } from "../plugins"; 15 | import { organizationClient, passkeyClient } from "./plugins"; 16 | import { isProxy } from "node:util/types"; 17 | 18 | describe("run time proxy", async () => { 19 | it("atom in proxy should not be proxy", async () => { 20 | const client = createVanillaClient(); 21 | const atom = client.$store.atoms.session; 22 | expect(isProxy(atom)).toBe(false); 23 | }); 24 | 25 | it("proxy api should be called", async () => { 26 | let apiCalled = false; 27 | const client = createSolidClient({ 28 | plugins: [testClientPlugin()], 29 | fetchOptions: { 30 | customFetchImpl: async (url, init) => { 31 | apiCalled = true; 32 | return new Response(); 33 | }, 34 | baseURL: "http://localhost:3000", 35 | }, 36 | }); 37 | await client.test(); 38 | expect(apiCalled).toBe(true); 39 | }); 40 | 41 | it("state listener should be called on matched path", async () => { 42 | const client = createSolidClient({ 43 | plugins: [testClientPlugin()], 44 | fetchOptions: { 45 | customFetchImpl: async (url, init) => { 46 | return new Response(); 47 | }, 48 | baseURL: "http://localhost:3000", 49 | }, 50 | }); 51 | const res = client.useComputedAtom(); 52 | expect(res()).toBe(0); 53 | await client.test(); 54 | vi.useFakeTimers(); 55 | setTimeout(() => { 56 | expect(res()).toBe(1); 57 | }, 100); 58 | }); 59 | 60 | it("should call useSession", async () => { 61 | let returnNull = false; 62 | const client = createSolidClient({ 63 | plugins: [testClientPlugin()], 64 | fetchOptions: { 65 | customFetchImpl: async () => { 66 | if (returnNull) { 67 | return new Response(JSON.stringify(null)); 68 | } 69 | return new Response( 70 | JSON.stringify({ 71 | user: { 72 | id: 1, 73 | email: "[email protected]", 74 | }, 75 | }), 76 | ); 77 | }, 78 | baseURL: "http://localhost:3000", 79 | }, 80 | }); 81 | const res = client.useSession(); 82 | vi.useFakeTimers(); 83 | await vi.advanceTimersByTimeAsync(1); 84 | expect(res()).toMatchObject({ 85 | data: { user: { id: 1, email: "[email protected]" } }, 86 | error: null, 87 | isPending: false, 88 | }); 89 | /** 90 | * recall 91 | */ 92 | returnNull = true; 93 | await client.test2.signOut(); 94 | await vi.advanceTimersByTimeAsync(10); 95 | expect(res()).toMatchObject({ 96 | data: null, 97 | error: null, 98 | isPending: false, 99 | }); 100 | }); 101 | 102 | it("should allow second argument fetch options", async () => { 103 | let called = false; 104 | const client = createSolidClient({ 105 | plugins: [testClientPlugin()], 106 | fetchOptions: { 107 | customFetchImpl: async (url, init) => { 108 | return new Response(); 109 | }, 110 | baseURL: "http://localhost:3000", 111 | }, 112 | }); 113 | await client.test( 114 | {}, 115 | { 116 | onSuccess(context) { 117 | called = true; 118 | }, 119 | }, 120 | ); 121 | expect(called).toBe(true); 122 | }); 123 | 124 | it("should not expose a 'then', 'catch', 'finally' property on the proxy", async () => { 125 | const client = createSolidClient({ 126 | plugins: [testClientPlugin()], 127 | fetchOptions: { 128 | customFetchImpl: async () => new Response(), 129 | baseURL: "http://localhost:3000", 130 | }, 131 | }); 132 | const proxy = (client as any).test; 133 | expect(proxy.then).toBeUndefined(); 134 | expect(proxy.catch).toBeUndefined(); 135 | expect(proxy.finally).toBeUndefined(); 136 | }); 137 | }); 138 | 139 | describe("type", () => { 140 | it("should infer session additional fields", () => { 141 | const client = createReactClient({ 142 | plugins: [testClientPlugin()], 143 | baseURL: "http://localhost:3000", 144 | fetchOptions: { 145 | customFetchImpl: async (url, init) => { 146 | return new Response(); 147 | }, 148 | }, 149 | }); 150 | type ReturnedSession = ReturnType<typeof client.useSession>; 151 | expectTypeOf<ReturnedSession>().toMatchTypeOf<{ 152 | data: { 153 | user: { 154 | id: string; 155 | email: string; 156 | emailVerified: boolean; 157 | name: string; 158 | createdAt: Date; 159 | updatedAt: Date; 160 | image?: string | undefined | null; 161 | testField4: string; 162 | testField?: string | undefined | null; 163 | testField2?: number | undefined | null; 164 | }; 165 | session: Session; 166 | } | null; 167 | error: BetterFetchError | null; 168 | isPending: boolean; 169 | }>(); 170 | }); 171 | it("should infer resolved hooks react", () => { 172 | const client = createReactClient({ 173 | plugins: [testClientPlugin()], 174 | baseURL: "http://localhost:3000", 175 | fetchOptions: { 176 | customFetchImpl: async (url, init) => { 177 | return new Response(); 178 | }, 179 | }, 180 | }); 181 | expectTypeOf(client.useComputedAtom).toEqualTypeOf<() => number>(); 182 | }); 183 | it("should infer resolved hooks solid", () => { 184 | const client = createSolidClient({ 185 | plugins: [testClientPlugin()], 186 | baseURL: "http://localhost:3000", 187 | fetchOptions: { 188 | customFetchImpl: async (url, init) => { 189 | return new Response(); 190 | }, 191 | }, 192 | }); 193 | expectTypeOf(client.useComputedAtom).toEqualTypeOf< 194 | () => Accessor<number> 195 | >(); 196 | }); 197 | it("should infer resolved hooks vue", () => { 198 | const client = createVueClient({ 199 | plugins: [testClientPlugin()], 200 | baseURL: "http://localhost:3000", 201 | fetchOptions: { 202 | customFetchImpl: async (url, init) => { 203 | return new Response(); 204 | }, 205 | }, 206 | }); 207 | expectTypeOf(client.useComputedAtom).toEqualTypeOf< 208 | () => Readonly<Ref<number>> 209 | >(); 210 | }); 211 | it("should infer resolved hooks svelte", () => { 212 | const client = createSvelteClient({ 213 | plugins: [testClientPlugin()], 214 | baseURL: "http://localhost:3000", 215 | fetchOptions: { 216 | customFetchImpl: async (url, init) => { 217 | return new Response(); 218 | }, 219 | }, 220 | }); 221 | expectTypeOf(client.useComputedAtom).toEqualTypeOf< 222 | () => ReadableAtom<number> 223 | >(); 224 | }); 225 | 226 | it("should infer actions", () => { 227 | const client = createSolidClient({ 228 | plugins: [testClientPlugin(), testClientPlugin2()], 229 | baseURL: "http://localhost:3000", 230 | fetchOptions: { 231 | customFetchImpl: async (url, init) => { 232 | return new Response(); 233 | }, 234 | }, 235 | }); 236 | expectTypeOf(client.setTestAtom).toEqualTypeOf<(value: boolean) => void>(); 237 | expectTypeOf(client.test.signOut).toEqualTypeOf<() => Promise<void>>(); 238 | }); 239 | 240 | it("should infer session", () => { 241 | const client = createSolidClient({ 242 | plugins: [testClientPlugin(), testClientPlugin2(), twoFactorClient()], 243 | baseURL: "http://localhost:3000", 244 | fetchOptions: { 245 | customFetchImpl: async (url, init) => { 246 | return new Response(); 247 | }, 248 | }, 249 | }); 250 | const $infer = client.$Infer; 251 | expectTypeOf<typeof $infer.Session>().toEqualTypeOf<{ 252 | session: { 253 | id: string; 254 | userId: string; 255 | expiresAt: Date; 256 | token: string; 257 | ipAddress?: string | undefined | null; 258 | userAgent?: string | undefined | null; 259 | createdAt: Date; 260 | updatedAt: Date; 261 | }; 262 | user: { 263 | id: string; 264 | email: string; 265 | emailVerified: boolean; 266 | name: string; 267 | createdAt: Date; 268 | updatedAt: Date; 269 | image?: string | undefined | null; 270 | testField4: string; 271 | testField?: string | undefined | null; 272 | testField2?: number | undefined | null; 273 | twoFactorEnabled: boolean | undefined | null; 274 | }; 275 | }>(); 276 | }); 277 | 278 | it("should infer session react", () => { 279 | const client = createReactClient({ 280 | plugins: [organizationClient(), twoFactorClient(), passkeyClient()], 281 | }); 282 | const $infer = client.$Infer.Session; 283 | expectTypeOf<typeof $infer.user>().toEqualTypeOf<{ 284 | name: string; 285 | id: string; 286 | email: string; 287 | emailVerified: boolean; 288 | createdAt: Date; 289 | updatedAt: Date; 290 | image?: string | undefined | null; 291 | twoFactorEnabled: boolean | undefined | null; 292 | }>(); 293 | }); 294 | 295 | it("should infer `throw:true` in fetch options", async () => { 296 | const client = createReactClient({ 297 | plugins: [testClientPlugin()], 298 | baseURL: "http://localhost:3000", 299 | fetchOptions: { 300 | throw: true, 301 | customFetchImpl: async (url, init) => { 302 | return new Response(); 303 | }, 304 | }, 305 | }); 306 | const data = client.getSession(); 307 | expectTypeOf(data).toMatchTypeOf< 308 | Promise<{ 309 | user: { 310 | id: string; 311 | email: string; 312 | emailVerified: boolean; 313 | name: string; 314 | createdAt: Date; 315 | updatedAt: Date; 316 | image?: string | undefined | null; 317 | testField4: string; 318 | testField?: string | undefined | null; 319 | testField2?: number | undefined | null; 320 | }; 321 | session: { 322 | id: string; 323 | userId: string; 324 | expiresAt: Date; 325 | ipAddress?: string | undefined | null; 326 | userAgent?: string | undefined | null; 327 | }; 328 | } | null> 329 | >(); 330 | }); 331 | 332 | it("should infer `error` schema correctly", async () => { 333 | const client = createSolidClient({ 334 | plugins: [testClientPlugin()], 335 | baseURL: "http://localhost:3000", 336 | fetchOptions: { 337 | customFetchImpl: async (url, init) => { 338 | return new Response(); 339 | }, 340 | }, 341 | }); 342 | const { error } = await client.test(); 343 | expectTypeOf(error!).toMatchObjectType<{ 344 | code: number; 345 | message: string; 346 | test: boolean; 347 | }>(); 348 | }); 349 | 350 | it("should support refetch with query parameters", () => { 351 | const client = createReactClient({ 352 | plugins: [testClientPlugin()], 353 | baseURL: "http://localhost:3000", 354 | fetchOptions: { 355 | customFetchImpl: async (url, init) => { 356 | return new Response(); 357 | }, 358 | }, 359 | }); 360 | 361 | type UseSessionReturn = ReturnType<typeof client.useSession>; 362 | expectTypeOf<UseSessionReturn>().toMatchTypeOf<{ 363 | data: { 364 | user: { 365 | id: string; 366 | email: string; 367 | emailVerified: boolean; 368 | name: string; 369 | createdAt: Date; 370 | updatedAt: Date; 371 | image?: string | undefined | null; 372 | testField4: string; 373 | testField?: string | undefined | null; 374 | testField2?: number | undefined | null; 375 | }; 376 | session: Session; 377 | } | null; 378 | isPending: boolean; 379 | error: BetterFetchError | null; 380 | refetch: (queryParams?: { query?: SessionQueryParams }) => void; 381 | }>(); 382 | }); 383 | }); 384 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/routes/crud-members.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect } from "vitest"; 2 | import { getTestInstance } from "../../../test-utils/test-instance"; 3 | import { organization } from "../organization"; 4 | import { createAuthClient } from "../../../client"; 5 | import { organizationClient } from "../client"; 6 | import { ORGANIZATION_ERROR_CODES } from "../error-codes"; 7 | 8 | describe("listMembers", async () => { 9 | const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({ 10 | plugins: [organization()], 11 | }); 12 | const ctx = await auth.$context; 13 | const { headers } = await signInWithTestUser(); 14 | const client = createAuthClient({ 15 | plugins: [organizationClient()], 16 | baseURL: "http://localhost:3000/api/auth", 17 | fetchOptions: { 18 | customFetchImpl: async (url, init) => { 19 | return auth.handler(new Request(url, init)); 20 | }, 21 | }, 22 | }); 23 | const org = await client.organization.create({ 24 | name: "test", 25 | slug: "test", 26 | metadata: { 27 | test: "test", 28 | }, 29 | fetchOptions: { 30 | headers, 31 | }, 32 | }); 33 | const secondOrg = await client.organization.create({ 34 | name: "test-second", 35 | slug: "test-second", 36 | metadata: { 37 | test: "second-org", 38 | }, 39 | fetchOptions: { 40 | headers, 41 | }, 42 | }); 43 | 44 | for (let i = 0; i < 10; i++) { 45 | const user = await ctx.adapter.create({ 46 | model: "user", 47 | data: { 48 | email: `test${i}@test.com`, 49 | name: `test${i}`, 50 | }, 51 | }); 52 | await auth.api.addMember({ 53 | body: { 54 | organizationId: org.data?.id as string, 55 | userId: user.id, 56 | role: "member", 57 | }, 58 | }); 59 | } 60 | it("should return all members", async () => { 61 | await client.organization.setActive({ 62 | organizationId: org.data?.id as string, 63 | fetchOptions: { 64 | headers, 65 | }, 66 | }); 67 | const members = await client.organization.listMembers({ 68 | fetchOptions: { 69 | headers, 70 | }, 71 | }); 72 | expect(members.data?.members.length).toBe(11); 73 | expect(members.data?.total).toBe(11); 74 | }); 75 | 76 | it("should limit the number of members", async () => { 77 | const members = await client.organization.listMembers({ 78 | fetchOptions: { 79 | headers, 80 | }, 81 | query: { 82 | limit: 5, 83 | }, 84 | }); 85 | expect(members.data?.members.length).toBe(5); 86 | expect(members.data?.total).toBe(11); 87 | }); 88 | 89 | it("should offset the members", async () => { 90 | const members = await client.organization.listMembers({ 91 | fetchOptions: { 92 | headers, 93 | }, 94 | query: { 95 | offset: 5, 96 | }, 97 | }); 98 | expect(members.data?.members.length).toBe(6); 99 | expect(members.data?.total).toBe(11); 100 | }); 101 | 102 | it("should filter the members", async () => { 103 | const members = await client.organization.listMembers({ 104 | fetchOptions: { 105 | headers, 106 | }, 107 | query: { 108 | filterField: "createdAt", 109 | filterOperator: "gt", 110 | filterValue: new Date( 111 | Date.now() - 1000 * 60 * 60 * 24 * 30, 112 | ).toISOString(), 113 | }, 114 | }); 115 | expect(members.data?.members.length).toBe(0); 116 | expect(members.data?.total).toBe(0); 117 | }); 118 | 119 | it("should sort the members", async () => { 120 | const defaultMembers = await client.organization.listMembers({ 121 | fetchOptions: { 122 | headers, 123 | }, 124 | }); 125 | const firstMember = defaultMembers.data?.members[0]; 126 | if (!firstMember) { 127 | throw new Error("No first member found"); 128 | } 129 | const secondMember = defaultMembers.data?.members[1]; 130 | if (!secondMember) { 131 | throw new Error("No second member found"); 132 | } 133 | await ctx.adapter.update({ 134 | model: "member", 135 | where: [{ field: "id", value: secondMember.id }], 136 | update: { 137 | // update the second member to be the oldest 138 | createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), 139 | }, 140 | }); 141 | const lastMember = 142 | defaultMembers.data?.members[defaultMembers.data?.members.length - 1]; 143 | if (!lastMember) { 144 | throw new Error("No last member found"); 145 | } 146 | const oneBeforeLastMember = 147 | defaultMembers.data?.members[defaultMembers.data?.members.length - 2]; 148 | if (!oneBeforeLastMember) { 149 | throw new Error("No one before last member found"); 150 | } 151 | await ctx.adapter.update({ 152 | model: "member", 153 | where: [{ field: "id", value: oneBeforeLastMember.id }], 154 | update: { 155 | // update the one before last member to be the newest 156 | createdAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 157 | }, 158 | }); 159 | const members = await client.organization.listMembers({ 160 | fetchOptions: { 161 | headers, 162 | }, 163 | query: { 164 | sortBy: "createdAt", 165 | sortDirection: "asc", 166 | }, 167 | }); 168 | expect(members.data?.members[0]!.id).not.toBe(firstMember.id); 169 | expect( 170 | members.data?.members[members.data?.members.length - 1]!.id, 171 | ).not.toBe(lastMember.id); 172 | expect(members.data?.members[0]!.id).toBe(secondMember.id); 173 | expect(members.data?.members[members.data?.members.length - 1]!.id).toBe( 174 | oneBeforeLastMember.id, 175 | ); 176 | }); 177 | 178 | it("should list members by organization id", async () => { 179 | const members = await client.organization.listMembers({ 180 | fetchOptions: { 181 | headers, 182 | }, 183 | query: { 184 | organizationId: secondOrg.data?.id as string, 185 | }, 186 | }); 187 | expect(members.data?.members.length).toBe(1); 188 | expect(members.data?.total).toBe(1); 189 | }); 190 | 191 | it("should not list members if not a member", async () => { 192 | const newHeaders = new Headers(); 193 | await client.signUp.email({ 194 | email: "[email protected]", 195 | name: "test22", 196 | password: "password", 197 | fetchOptions: { 198 | onSuccess: cookieSetter(newHeaders), 199 | }, 200 | }); 201 | const members = await client.organization.listMembers({ 202 | fetchOptions: { 203 | headers: newHeaders, 204 | }, 205 | query: { 206 | organizationId: org.data?.id as string, 207 | }, 208 | }); 209 | expect(members.error).toBeTruthy(); 210 | expect(members.error?.message).toBe( 211 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, 212 | ); 213 | }); 214 | }); 215 | 216 | describe("updateMemberRole", async () => { 217 | const { auth, signInWithTestUser, cookieSetter, customFetchImpl } = 218 | await getTestInstance({ 219 | plugins: [organization()], 220 | }); 221 | 222 | it("should update the member role", async () => { 223 | const { headers, user } = await signInWithTestUser(); 224 | const client = createAuthClient({ 225 | plugins: [organizationClient()], 226 | baseURL: "http://localhost:3000/api/auth", 227 | fetchOptions: { 228 | customFetchImpl, 229 | }, 230 | }); 231 | 232 | const org = await client.organization.create({ 233 | name: "test", 234 | slug: "test", 235 | metadata: { 236 | test: "test", 237 | }, 238 | fetchOptions: { 239 | headers, 240 | }, 241 | }); 242 | 243 | const newUser = await auth.api.signUpEmail({ 244 | body: { 245 | email: "[email protected]", 246 | name: "test", 247 | password: "password", 248 | }, 249 | }); 250 | 251 | const member = await auth.api.addMember({ 252 | body: { 253 | organizationId: org.data?.id as string, 254 | userId: newUser.user.id, 255 | role: "member", 256 | }, 257 | }); 258 | const updatedMember = await client.organization.updateMemberRole( 259 | { 260 | organizationId: org.data?.id as string, 261 | memberId: member?.id as string, 262 | role: "admin", 263 | }, 264 | { 265 | headers, 266 | }, 267 | ); 268 | expect(updatedMember.data?.role).toBe("admin"); 269 | }); 270 | 271 | it("should not update the member role if the member updating is not a member ", async () => { 272 | const { headers, user } = await signInWithTestUser(); 273 | const client = createAuthClient({ 274 | plugins: [organizationClient()], 275 | baseURL: "http://localhost:3000/api/auth", 276 | fetchOptions: { 277 | customFetchImpl, 278 | }, 279 | }); 280 | 281 | const org = await client.organization.create({ 282 | name: "test", 283 | slug: "test", 284 | metadata: { 285 | test: "test", 286 | }, 287 | fetchOptions: { 288 | headers, 289 | }, 290 | }); 291 | 292 | const newUser = await auth.api.signUpEmail({ 293 | body: { 294 | email: "[email protected]", 295 | name: "test", 296 | password: "password", 297 | }, 298 | }); 299 | const newOrg = await client.organization.create( 300 | { 301 | name: "test2", 302 | slug: "test2", 303 | metadata: { 304 | test: "test", 305 | }, 306 | }, 307 | { 308 | headers: new Headers({ 309 | authorization: `Bearer ${newUser.token}`, 310 | }), 311 | }, 312 | ); 313 | await auth.api.addMember({ 314 | body: { 315 | organizationId: newOrg.data?.id as string, 316 | userId: user.id, 317 | role: "admin", 318 | }, 319 | }); 320 | const updatedMember = await client.organization.updateMemberRole( 321 | { 322 | organizationId: newOrg.data?.id as string, 323 | memberId: newOrg.data?.members[0]?.id as string, 324 | role: "admin", 325 | }, 326 | { 327 | headers, 328 | }, 329 | ); 330 | expect(updatedMember.error).toBeTruthy(); 331 | expect(updatedMember.error?.message).toBe( 332 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER, 333 | ); 334 | }); 335 | }); 336 | 337 | describe("activeMemberRole", async () => { 338 | const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({ 339 | plugins: [organization()], 340 | }); 341 | const ctx = await auth.$context; 342 | const { headers } = await signInWithTestUser(); 343 | const client = createAuthClient({ 344 | plugins: [organizationClient()], 345 | baseURL: "http://localhost:3000/api/auth", 346 | fetchOptions: { 347 | customFetchImpl: async (url, init) => { 348 | return auth.handler(new Request(url, init)); 349 | }, 350 | }, 351 | }); 352 | const org = await client.organization.create({ 353 | name: "test", 354 | slug: "test", 355 | metadata: { 356 | test: "test", 357 | }, 358 | fetchOptions: { 359 | headers, 360 | }, 361 | }); 362 | const secondOrg = await client.organization.create({ 363 | name: "test-second", 364 | slug: "test-second", 365 | metadata: { 366 | test: "second-org", 367 | }, 368 | fetchOptions: { 369 | headers, 370 | }, 371 | }); 372 | 373 | let selectedUserId = ""; 374 | for (let i = 0; i < 10; i++) { 375 | const user = await ctx.adapter.create({ 376 | model: "user", 377 | data: { 378 | email: `test${i}@test.com`, 379 | name: `test${i}`, 380 | }, 381 | }); 382 | 383 | if (i == 0) { 384 | selectedUserId = user.id; 385 | } 386 | 387 | await auth.api.addMember({ 388 | body: { 389 | organizationId: org.data?.id as string, 390 | userId: user.id, 391 | role: "member", 392 | }, 393 | }); 394 | } 395 | 396 | it("should return the active member role on active organization", async () => { 397 | await client.organization.setActive({ 398 | organizationId: org.data?.id as string, 399 | fetchOptions: { 400 | headers, 401 | }, 402 | }); 403 | 404 | const activeMember = await client.organization.getActiveMemberRole({ 405 | fetchOptions: { 406 | headers, 407 | }, 408 | }); 409 | 410 | expect(activeMember.data?.role).toBe("owner"); 411 | }); 412 | 413 | it("should return active member role on organization", async () => { 414 | await client.organization.setActive({ 415 | organizationId: org.data?.id as string, 416 | fetchOptions: { 417 | headers, 418 | }, 419 | }); 420 | 421 | const activeMember = await client.organization.getActiveMemberRole({ 422 | query: { 423 | userId: selectedUserId, 424 | }, 425 | fetchOptions: { 426 | headers, 427 | }, 428 | }); 429 | 430 | expect(activeMember.data?.role).toBe("member"); 431 | }); 432 | }); 433 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "../../api"; 2 | import { createAuthMiddleware } from "@better-auth/core/api"; 3 | import type { BetterAuthPlugin } from "@better-auth/core"; 4 | import { mergeSchema } from "../../db"; 5 | import { apiKeySchema } from "./schema"; 6 | import { getIp } from "../../utils/get-request-ip"; 7 | import { getDate } from "../../utils/date"; 8 | import type { ApiKeyOptions } from "./types"; 9 | import { createApiKeyRoutes, deleteAllExpiredApiKeys } from "./routes"; 10 | import { validateApiKey } from "./routes/verify-api-key"; 11 | import { base64Url } from "@better-auth/utils/base64"; 12 | import { createHash } from "@better-auth/utils/hash"; 13 | import { defineErrorCodes } from "@better-auth/core/utils"; 14 | 15 | export const defaultKeyHasher = async (key: string) => { 16 | const hash = await createHash("SHA-256").digest( 17 | new TextEncoder().encode(key), 18 | ); 19 | const hashed = base64Url.encode(new Uint8Array(hash), { 20 | padding: false, 21 | }); 22 | return hashed; 23 | }; 24 | 25 | export const ERROR_CODES = defineErrorCodes({ 26 | INVALID_METADATA_TYPE: "metadata must be an object or undefined", 27 | REFILL_AMOUNT_AND_INTERVAL_REQUIRED: 28 | "refillAmount is required when refillInterval is provided", 29 | REFILL_INTERVAL_AND_AMOUNT_REQUIRED: 30 | "refillInterval is required when refillAmount is provided", 31 | USER_BANNED: "User is banned", 32 | UNAUTHORIZED_SESSION: "Unauthorized or invalid session", 33 | KEY_NOT_FOUND: "API Key not found", 34 | KEY_DISABLED: "API Key is disabled", 35 | KEY_EXPIRED: "API Key has expired", 36 | USAGE_EXCEEDED: "API Key has reached its usage limit", 37 | KEY_NOT_RECOVERABLE: "API Key is not recoverable", 38 | EXPIRES_IN_IS_TOO_SMALL: 39 | "The expiresIn is smaller than the predefined minimum value.", 40 | EXPIRES_IN_IS_TOO_LARGE: 41 | "The expiresIn is larger than the predefined maximum value.", 42 | INVALID_REMAINING: "The remaining count is either too large or too small.", 43 | INVALID_PREFIX_LENGTH: "The prefix length is either too large or too small.", 44 | INVALID_NAME_LENGTH: "The name length is either too large or too small.", 45 | METADATA_DISABLED: "Metadata is disabled.", 46 | RATE_LIMIT_EXCEEDED: "Rate limit exceeded.", 47 | NO_VALUES_TO_UPDATE: "No values to update.", 48 | KEY_DISABLED_EXPIRATION: "Custom key expiration values are disabled.", 49 | INVALID_API_KEY: "Invalid API key.", 50 | INVALID_USER_ID_FROM_API_KEY: "The user id from the API key is invalid.", 51 | INVALID_API_KEY_GETTER_RETURN_TYPE: 52 | "API Key getter returned an invalid key type. Expected string.", 53 | SERVER_ONLY_PROPERTY: 54 | "The property you're trying to set can only be set from the server auth instance only.", 55 | FAILED_TO_UPDATE_API_KEY: "Failed to update API key", 56 | NAME_REQUIRED: "API Key name is required.", 57 | }); 58 | 59 | export const API_KEY_TABLE_NAME = "apikey"; 60 | 61 | export const apiKey = (options?: ApiKeyOptions) => { 62 | const opts = { 63 | ...options, 64 | apiKeyHeaders: options?.apiKeyHeaders ?? "x-api-key", 65 | defaultKeyLength: options?.defaultKeyLength || 64, 66 | maximumPrefixLength: options?.maximumPrefixLength ?? 32, 67 | minimumPrefixLength: options?.minimumPrefixLength ?? 1, 68 | maximumNameLength: options?.maximumNameLength ?? 32, 69 | minimumNameLength: options?.minimumNameLength ?? 1, 70 | enableMetadata: options?.enableMetadata ?? false, 71 | disableKeyHashing: options?.disableKeyHashing ?? false, 72 | requireName: options?.requireName ?? false, 73 | rateLimit: { 74 | enabled: 75 | options?.rateLimit?.enabled === undefined 76 | ? true 77 | : options?.rateLimit?.enabled, 78 | timeWindow: options?.rateLimit?.timeWindow ?? 1000 * 60 * 60 * 24, 79 | maxRequests: options?.rateLimit?.maxRequests ?? 10, 80 | }, 81 | keyExpiration: { 82 | defaultExpiresIn: options?.keyExpiration?.defaultExpiresIn ?? null, 83 | disableCustomExpiresTime: 84 | options?.keyExpiration?.disableCustomExpiresTime ?? false, 85 | maxExpiresIn: options?.keyExpiration?.maxExpiresIn ?? 365, 86 | minExpiresIn: options?.keyExpiration?.minExpiresIn ?? 1, 87 | }, 88 | startingCharactersConfig: { 89 | shouldStore: options?.startingCharactersConfig?.shouldStore ?? true, 90 | charactersLength: 91 | options?.startingCharactersConfig?.charactersLength ?? 6, 92 | }, 93 | enableSessionForAPIKeys: options?.enableSessionForAPIKeys ?? false, 94 | } satisfies ApiKeyOptions; 95 | 96 | const schema = mergeSchema( 97 | apiKeySchema({ 98 | rateLimitMax: opts.rateLimit.maxRequests, 99 | timeWindow: opts.rateLimit.timeWindow, 100 | }), 101 | opts.schema, 102 | ); 103 | 104 | const getter = 105 | opts.customAPIKeyGetter || 106 | ((ctx) => { 107 | if (Array.isArray(opts.apiKeyHeaders)) { 108 | for (const header of opts.apiKeyHeaders) { 109 | const value = ctx.headers?.get(header); 110 | if (value) { 111 | return value; 112 | } 113 | } 114 | } else { 115 | return ctx.headers?.get(opts.apiKeyHeaders); 116 | } 117 | }); 118 | 119 | const keyGenerator = 120 | opts.customKeyGenerator || 121 | (async (options: { length: number; prefix: string | undefined }) => { 122 | const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 123 | let apiKey = `${options.prefix || ""}`; 124 | for (let i = 0; i < options.length; i++) { 125 | const randomIndex = Math.floor(Math.random() * characters.length); 126 | apiKey += characters[randomIndex]; 127 | } 128 | 129 | return apiKey; 130 | }); 131 | 132 | const routes = createApiKeyRoutes({ keyGenerator, opts, schema }); 133 | 134 | return { 135 | id: "api-key", 136 | $ERROR_CODES: ERROR_CODES, 137 | hooks: { 138 | before: [ 139 | { 140 | matcher: (ctx) => !!getter(ctx) && opts.enableSessionForAPIKeys, 141 | handler: createAuthMiddleware(async (ctx) => { 142 | const key = getter(ctx)!; 143 | 144 | if (typeof key !== "string") { 145 | throw new APIError("BAD_REQUEST", { 146 | message: ERROR_CODES.INVALID_API_KEY_GETTER_RETURN_TYPE, 147 | }); 148 | } 149 | 150 | if (key.length < opts.defaultKeyLength) { 151 | // if the key is shorter than the default key length, than we know the key is invalid. 152 | // we can't check if the key is exactly equal to the default key length, because 153 | // a prefix may be added to the key. 154 | throw new APIError("FORBIDDEN", { 155 | message: ERROR_CODES.INVALID_API_KEY, 156 | }); 157 | } 158 | 159 | if (opts.customAPIKeyValidator) { 160 | const isValid = await opts.customAPIKeyValidator({ ctx, key }); 161 | if (!isValid) { 162 | throw new APIError("FORBIDDEN", { 163 | message: ERROR_CODES.INVALID_API_KEY, 164 | }); 165 | } 166 | } 167 | 168 | const hashed = opts.disableKeyHashing 169 | ? key 170 | : await defaultKeyHasher(key); 171 | 172 | const apiKey = await validateApiKey({ 173 | hashedKey: hashed, 174 | ctx, 175 | opts, 176 | schema, 177 | }); 178 | 179 | //for cleanup purposes 180 | deleteAllExpiredApiKeys(ctx.context).catch((err) => { 181 | ctx.context.logger.error( 182 | "Failed to delete expired API keys:", 183 | err, 184 | ); 185 | }); 186 | 187 | const user = await ctx.context.internalAdapter.findUserById( 188 | apiKey.userId, 189 | ); 190 | if (!user) { 191 | throw new APIError("UNAUTHORIZED", { 192 | message: ERROR_CODES.INVALID_USER_ID_FROM_API_KEY, 193 | }); 194 | } 195 | 196 | const session = { 197 | user, 198 | session: { 199 | id: apiKey.id, 200 | token: key, 201 | userId: apiKey.userId, 202 | userAgent: ctx.request?.headers.get("user-agent") ?? null, 203 | ipAddress: ctx.request 204 | ? getIp(ctx.request, ctx.context.options) 205 | : null, 206 | createdAt: new Date(), 207 | updatedAt: new Date(), 208 | expiresAt: 209 | apiKey.expiresAt || 210 | getDate( 211 | ctx.context.options.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days 212 | "ms", 213 | ), 214 | }, 215 | }; 216 | 217 | // Always set the session context for API key authentication 218 | ctx.context.session = session; 219 | 220 | if (ctx.path === "/get-session") { 221 | return session; 222 | } else { 223 | return { 224 | context: ctx, 225 | }; 226 | } 227 | }), 228 | }, 229 | ], 230 | }, 231 | endpoints: { 232 | /** 233 | * ### Endpoint 234 | * 235 | * POST `/api-key/create` 236 | * 237 | * ### API Methods 238 | * 239 | * **server:** 240 | * `auth.api.createApiKey` 241 | * 242 | * **client:** 243 | * `authClient.apiKey.create` 244 | * 245 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-create) 246 | */ 247 | createApiKey: routes.createApiKey, 248 | /** 249 | * ### Endpoint 250 | * 251 | * POST `/api-key/verify` 252 | * 253 | * ### API Methods 254 | * 255 | * **server:** 256 | * `auth.api.verifyApiKey` 257 | * 258 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-verify) 259 | */ 260 | verifyApiKey: routes.verifyApiKey, 261 | /** 262 | * ### Endpoint 263 | * 264 | * GET `/api-key/get` 265 | * 266 | * ### API Methods 267 | * 268 | * **server:** 269 | * `auth.api.getApiKey` 270 | * 271 | * **client:** 272 | * `authClient.apiKey.get` 273 | * 274 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-get) 275 | */ 276 | getApiKey: routes.getApiKey, 277 | /** 278 | * ### Endpoint 279 | * 280 | * POST `/api-key/update` 281 | * 282 | * ### API Methods 283 | * 284 | * **server:** 285 | * `auth.api.updateApiKey` 286 | * 287 | * **client:** 288 | * `authClient.apiKey.update` 289 | * 290 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-update) 291 | */ 292 | updateApiKey: routes.updateApiKey, 293 | /** 294 | * ### Endpoint 295 | * 296 | * POST `/api-key/delete` 297 | * 298 | * ### API Methods 299 | * 300 | * **server:** 301 | * `auth.api.deleteApiKey` 302 | * 303 | * **client:** 304 | * `authClient.apiKey.delete` 305 | * 306 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-delete) 307 | */ 308 | deleteApiKey: routes.deleteApiKey, 309 | /** 310 | * ### Endpoint 311 | * 312 | * GET `/api-key/list` 313 | * 314 | * ### API Methods 315 | * 316 | * **server:** 317 | * `auth.api.listApiKeys` 318 | * 319 | * **client:** 320 | * `authClient.apiKey.list` 321 | * 322 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-list) 323 | */ 324 | listApiKeys: routes.listApiKeys, 325 | /** 326 | * ### Endpoint 327 | * 328 | * POST `/api-key/delete-all-expired-api-keys` 329 | * 330 | * ### API Methods 331 | * 332 | * **server:** 333 | * `auth.api.deleteAllExpiredApiKeys` 334 | * 335 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-delete-all-expired-api-keys) 336 | */ 337 | deleteAllExpiredApiKeys: routes.deleteAllExpiredApiKeys, 338 | }, 339 | schema, 340 | } satisfies BetterAuthPlugin; 341 | }; 342 | ``` -------------------------------------------------------------------------------- /docs/content/docs/integrations/next.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Next.js integration 3 | description: Integrate Better Auth with Next.js. 4 | --- 5 | 6 | Better Auth can be easily integrated with Next.js. Before you start, make sure you have a Better Auth instance configured. If you haven't done that yet, check out the [installation](/docs/installation). 7 | 8 | ### Create API Route 9 | 10 | We need to mount the handler to an API route. Create a route file inside `/api/auth/[...all]` directory. And add the following code: 11 | 12 | ```ts title="api/auth/[...all]/route.ts" 13 | import { auth } from "@/lib/auth"; 14 | import { toNextJsHandler } from "better-auth/next-js"; 15 | 16 | export const { GET, POST } = toNextJsHandler(auth.handler); 17 | ``` 18 | 19 | <Callout type="info"> 20 | You can change the path on your better-auth configuration but it's recommended to keep it as `/api/auth/[...all]` 21 | </Callout> 22 | 23 | 24 | For `pages` route, you need to use `toNodeHandler` instead of `toNextJsHandler` and set `bodyParser` to `false` in the `config` object. Here is an example: 25 | 26 | ```ts title="pages/api/auth/[...all].ts" 27 | import { toNodeHandler } from "better-auth/node" 28 | import { auth } from "@/lib/auth" 29 | 30 | // Disallow body parsing, we will parse it manually 31 | export const config = { api: { bodyParser: false } } 32 | 33 | export default toNodeHandler(auth.handler) 34 | ``` 35 | 36 | ## Create a client 37 | 38 | Create a client instance. You can name the file anything you want. Here we are creating `client.ts` file inside the `lib/` directory. 39 | 40 | ```ts title="auth-client.ts" 41 | import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react 42 | 43 | export const authClient = createAuthClient({ 44 | //you can pass client configuration here 45 | }) 46 | ``` 47 | 48 | Once you have created the client, you can use it to sign up, sign in, and perform other actions. 49 | Some of the actions are reactive. The client uses [nano-store](https://github.com/nanostores/nanostores) to store the state and re-render the components when the state changes. 50 | 51 | The client also uses [better-fetch](https://github.com/bekacru/better-fetch) to make the requests. You can pass the fetch configuration to the client. 52 | 53 | 54 | ## RSC and Server actions 55 | 56 | The `api` object exported from the auth instance contains all the actions that you can perform on the server. Every endpoint made inside Better Auth is a invocable as a function. Including plugins endpoints. 57 | 58 | **Example: Getting Session on a server action** 59 | 60 | ```tsx title="server.ts" 61 | import { auth } from "@/lib/auth" 62 | import { headers } from "next/headers" 63 | 64 | const someAuthenticatedAction = async () => { 65 | "use server"; 66 | const session = await auth.api.getSession({ 67 | headers: await headers() 68 | }) 69 | }; 70 | ``` 71 | 72 | **Example: Getting Session on a RSC** 73 | 74 | 75 | ```tsx 76 | import { auth } from "@/lib/auth" 77 | import { headers } from "next/headers" 78 | 79 | export async function ServerComponent() { 80 | const session = await auth.api.getSession({ 81 | headers: await headers() 82 | }) 83 | if(!session) { 84 | return <div>Not authenticated</div> 85 | } 86 | return ( 87 | <div> 88 | <h1>Welcome {session.user.name}</h1> 89 | </div> 90 | ) 91 | } 92 | ``` 93 | 94 | <Callout type="warn">As RSCs cannot set cookies, the [cookie cache](/docs/concepts/session-management#cookie-cache) will not be refreshed until the server is interacted with from the client via Server Actions or Route Handlers.</Callout> 95 | 96 | ### Server Action Cookies 97 | 98 | When you call a function that needs to set cookies, like `signInEmail` or `signUpEmail` in a server action, cookies won’t be set. This is because server actions need to use the `cookies` helper from Next.js to set cookies. 99 | 100 | To simplify this, you can use the `nextCookies` plugin, which will automatically set cookies for you whenever a `Set-Cookie` header is present in the response. 101 | 102 | ```ts title="auth.ts" 103 | import { betterAuth } from "better-auth"; 104 | import { nextCookies } from "better-auth/next-js"; 105 | 106 | export const auth = betterAuth({ 107 | //...your config 108 | plugins: [nextCookies()] // make sure this is the last plugin in the array // [!code highlight] 109 | }) 110 | ``` 111 | 112 | Now, when you call functions that set cookies, they will be automatically set. 113 | 114 | ```ts 115 | "use server"; 116 | import { auth } from "@/lib/auth" 117 | 118 | const signIn = async () => { 119 | await auth.api.signInEmail({ 120 | body: { 121 | email: "[email protected]", 122 | password: "password", 123 | } 124 | }) 125 | } 126 | ``` 127 | 128 | ## Middleware 129 | 130 | In Next.js middleware, it's recommended to only check for the existence of a session cookie to handle redirection. To avoid blocking requests by making API or database calls. 131 | 132 | You can use the `getSessionCookie` helper from Better Auth for this purpose: 133 | 134 | <Callout type="warn"> 135 | The <code>getSessionCookie()</code> function does not automatically reference the auth config specified in <code>auth.ts</code>. Therefore, if you customized the cookie name or prefix, you need to ensure that the configuration in <code>getSessionCookie()</code> matches the config defined in your <code>auth.ts</code>. 136 | </Callout> 137 | 138 | ```ts 139 | import { NextRequest, NextResponse } from "next/server"; 140 | import { getSessionCookie } from "better-auth/cookies"; 141 | 142 | export async function middleware(request: NextRequest) { 143 | const sessionCookie = getSessionCookie(request); 144 | 145 | // THIS IS NOT SECURE! 146 | // This is the recommended approach to optimistically redirect users 147 | // We recommend handling auth checks in each page/route 148 | if (!sessionCookie) { 149 | return NextResponse.redirect(new URL("/", request.url)); 150 | } 151 | 152 | return NextResponse.next(); 153 | } 154 | 155 | export const config = { 156 | matcher: ["/dashboard"], // Specify the routes the middleware applies to 157 | }; 158 | ``` 159 | 160 | <Callout type="warn"> 161 | **Security Warning:** The `getSessionCookie` function only checks for the 162 | existence of a session cookie; it does **not** validate it. Relying solely 163 | on this check for security is dangerous, as anyone can manually create a 164 | cookie to bypass it. You must always validate the session on your server for 165 | any protected actions or pages. 166 | </Callout> 167 | 168 | <Callout type="info"> 169 | If you have a custom cookie name or prefix, you can pass it to the `getSessionCookie` function. 170 | ```ts 171 | const sessionCookie = getSessionCookie(request, { 172 | cookieName: "my_session_cookie", 173 | cookiePrefix: "my_prefix" 174 | }); 175 | ``` 176 | </Callout> 177 | 178 | Alternatively, you can use the `getCookieCache` helper to get the session object from the cookie cache. 179 | 180 | ```ts 181 | import { getCookieCache } from "better-auth/cookies"; 182 | 183 | export async function middleware(request: NextRequest) { 184 | const session = await getCookieCache(request); 185 | if (!session) { 186 | return NextResponse.redirect(new URL("/sign-in", request.url)); 187 | } 188 | return NextResponse.next(); 189 | } 190 | ``` 191 | 192 | ### How to handle auth checks in each page/route 193 | 194 | In this example, we are using the `auth.api.getSession` function within a server component to get the session object, 195 | then we are checking if the session is valid. If it's not, we are redirecting the user to the sign-in page. 196 | 197 | ```tsx title="app/dashboard/page.tsx" 198 | import { auth } from "@/lib/auth"; 199 | import { headers } from "next/headers"; 200 | import { redirect } from "next/navigation"; 201 | 202 | export default async function DashboardPage() { 203 | const session = await auth.api.getSession({ 204 | headers: await headers() 205 | }) 206 | 207 | if(!session) { 208 | redirect("/sign-in") 209 | } 210 | 211 | return ( 212 | <div> 213 | <h1>Welcome {session.user.name}</h1> 214 | </div> 215 | ) 216 | } 217 | ``` 218 | 219 | ### For Next.js release `15.1.7` and below 220 | 221 | If you need the full session object, you'll have to fetch it from the `/get-session` API route. Since Next.js middleware doesn't support running Node.js APIs directly, you must make an HTTP request. 222 | 223 | <Callout> 224 | The example uses [better-fetch](https://better-fetch.vercel.app), but you can use any fetch library. 225 | </Callout> 226 | 227 | ```ts 228 | import { betterFetch } from "@better-fetch/fetch"; 229 | import type { auth } from "@/lib/auth"; 230 | import { NextRequest, NextResponse } from "next/server"; 231 | 232 | type Session = typeof auth.$Infer.Session; 233 | 234 | export async function middleware(request: NextRequest) { 235 | const { data: session } = await betterFetch<Session>("/api/auth/get-session", { 236 | baseURL: request.nextUrl.origin, 237 | headers: { 238 | cookie: request.headers.get("cookie") || "", // Forward the cookies from the request 239 | }, 240 | }); 241 | 242 | if (!session) { 243 | return NextResponse.redirect(new URL("/sign-in", request.url)); 244 | } 245 | 246 | return NextResponse.next(); 247 | } 248 | 249 | export const config = { 250 | matcher: ["/dashboard"], // Apply middleware to specific routes 251 | }; 252 | ``` 253 | 254 | ### For Next.js release `15.2.0` and above 255 | 256 | From the version 15.2.0, Next.js allows you to use the `Node.js` runtime in middleware. This means you can use the `auth.api` object directly in middleware. 257 | 258 | <Callout type="warn"> 259 | You may refer to the [Next.js documentation](https://nextjs.org/docs/app/building-your-application/routing/middleware#runtime) for more information about runtime configuration, and how to enable it. 260 | Be careful when using the new runtime. It's an experimental feature and it may be subject to breaking changes. 261 | </Callout> 262 | 263 | ```ts 264 | import { NextRequest, NextResponse } from "next/server"; 265 | import { headers } from "next/headers"; 266 | import { auth } from "@/lib/auth"; 267 | 268 | export async function middleware(request: NextRequest) { 269 | const session = await auth.api.getSession({ 270 | headers: await headers() 271 | }) 272 | 273 | if(!session) { 274 | return NextResponse.redirect(new URL("/sign-in", request.url)); 275 | } 276 | 277 | return NextResponse.next(); 278 | } 279 | 280 | export const config = { 281 | runtime: "nodejs", 282 | matcher: ["/dashboard"], // Apply middleware to specific routes 283 | }; 284 | ``` 285 | 286 | ## Next.js 16 Compatibility 287 | 288 | Better Auth is fully compatible with Next.js 16. You can refer to the [Next.js 16 beta](https://nextjs.org/blog/next-16-beta) for more details on the new features and changes. 289 | 290 | Here are some important changes to note when using Better Auth with Next.js 16: 291 | 292 | ### Middleware File Rename 293 | 294 | In Next.js 16, the `middleware.ts` file convention has been deprecated in favor of `proxy.ts`. To migrate: 295 | 296 | 1. Rename your `middleware.ts` file to `proxy.ts` 297 | 2. All functionality remains the same - just the filename changes 298 | 299 | ```bash 300 | # Rename your middleware file 301 | mv middleware.ts proxy.ts 302 | ``` 303 | 304 | <Callout type="info"> 305 | The changes above are related to Next.js 16 updates. 306 | All Better Auth functionality remains the same. 307 | </Callout> 308 | ``` -------------------------------------------------------------------------------- /docs/content/docs/guides/supabase-migration-guide.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Migrating from Supabase Auth to Better Auth 3 | description: A step-by-step guide to transitioning from Supabase Auth to Better Auth. 4 | --- 5 | 6 | In this guide, we'll walk through the steps to migrate a project from Supabase Auth to Better Auth. 7 | 8 | <Callout type="warn"> 9 | This migration will invalidate all active sessions. While this guide doesn't currently cover migrating two-factor (2FA) or Row Level Security (RLS) configurations, both should be possible with additional steps. 10 | </Callout> 11 | 12 | 13 | ## Before You Begin 14 | 15 | Before starting the migration process, set up Better Auth in your project. Follow the [installation guide](/docs/installation) to get started. 16 | 17 | 18 | <Steps> 19 | <Step> 20 | ### Connect to your database 21 | 22 | You'll need to connect to your database to migrate the users and accounts. Copy your `DATABASE_URL` from your Supabase project and use it to connect to your database. And for this example, we'll need to install `pg` to connect to the database. 23 | 24 | ```package-install 25 | npm install pg 26 | ``` 27 | 28 | And then you can use the following code to connect to your database. 29 | 30 | ```ts title="auth.ts" 31 | import { Pool } from "pg"; 32 | 33 | export const auth = betterAuth({ 34 | database: new Pool({ 35 | connectionString: process.env.DATABASE_URL 36 | }), 37 | }) 38 | ``` 39 | </Step> 40 | <Step> 41 | ### Enable Email and Password (Optional) 42 | 43 | Enable the email and password in your auth config. 44 | 45 | ```ts title="auth.ts" 46 | import { admin, anonymous } from "better-auth/plugins"; 47 | 48 | export const auth = betterAuth({ 49 | database: new Pool({ 50 | connectionString: process.env.DATABASE_URL 51 | }), 52 | emailVerification: { 53 | sendEmailVerification: async(user)=>{ 54 | // send email verification email 55 | // implement your own logic here 56 | } 57 | }, 58 | emailAndPassword: { // [!code highlight] 59 | enabled: true, // [!code highlight] 60 | } // [!code highlight] 61 | }) 62 | ``` 63 | </Step> 64 | <Step> 65 | ### Setup Social Providers (Optional) 66 | 67 | Add social providers you have enabled in your Supabase project in your auth config. 68 | 69 | ```ts title="auth.ts" 70 | import { admin, anonymous } from "better-auth/plugins"; 71 | 72 | export const auth = betterAuth({ 73 | database: new Pool({ 74 | connectionString: process.env.DATABASE_URL 75 | }), 76 | emailAndPassword: { 77 | enabled: true, 78 | }, 79 | socialProviders: { // [!code highlight] 80 | github: { // [!code highlight] 81 | clientId: process.env.GITHUB_CLIENT_ID, // [!code highlight] 82 | clientSecret: process.env.GITHUB_CLIENT_SECRET, // [!code highlight] 83 | } // [!code highlight] 84 | } // [!code highlight] 85 | }) 86 | ``` 87 | </Step> 88 | <Step> 89 | ### Add admin and anonymous plugins (Optional) 90 | 91 | Add the [admin](/docs/plugins/admin) and [anonymous](/docs/plugins/anonymous) plugins to your auth config. 92 | 93 | ```ts title="auth.ts" 94 | import { admin, anonymous } from "better-auth/plugins"; 95 | 96 | export const auth = betterAuth({ 97 | database: new Pool({ 98 | connectionString: process.env.DATABASE_URL 99 | }), 100 | emailAndPassword: { 101 | enabled: true, 102 | }, 103 | socialProviders: { 104 | github: { 105 | clientId: process.env.GITHUB_CLIENT_ID!, 106 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 107 | } 108 | }, 109 | plugins: [admin(), anonymous()], // [!code highlight] 110 | }) 111 | ``` 112 | </Step> 113 | <Step> 114 | ### Run the migration 115 | 116 | Run the migration to create the necessary tables in your database. 117 | 118 | ```bash title="Terminal" 119 | npx @better-auth/cli migrate 120 | ``` 121 | 122 | This will create the following tables in your database: 123 | 124 | - [`user`](/docs/concepts/database#user) 125 | - [`account`](/docs/concepts/database#account) 126 | - [`session`](/docs/concepts/database#session) 127 | - [`verification`](/docs/concepts/database#verification) 128 | 129 | This tables will be created on the `public` schema. 130 | </Step> 131 | <Step> 132 | ### Copy the migration script 133 | 134 | Now that we have the necessary tables in our database, we can run the migration script to migrate the users and accounts from Supabase to Better Auth. 135 | 136 | Start by creating a `.ts` file in your project. 137 | 138 | ```bash title="Terminal" 139 | touch migration.ts 140 | ``` 141 | 142 | And then copy and paste the following code into the file. 143 | 144 | ```ts title="migration.ts" 145 | import { Pool } from "pg"; 146 | import { auth } from "./auth"; 147 | import { User as SupabaseUser } from "@supabase/supabase-js"; 148 | 149 | type User = SupabaseUser & { 150 | is_super_admin: boolean; 151 | raw_user_meta_data: { 152 | avatar_url: string; 153 | }; 154 | encrypted_password: string; 155 | email_confirmed_at: string; 156 | created_at: string; 157 | updated_at: string; 158 | is_anonymous: boolean; 159 | identities: { 160 | provider: string; 161 | identity_data: { 162 | sub: string; 163 | email: string; 164 | }; 165 | created_at: string; 166 | updated_at: string; 167 | }; 168 | }; 169 | 170 | const migrateFromSupabase = async () => { 171 | const ctx = await auth.$context; 172 | const db = ctx.options.database as Pool; 173 | const users = await db 174 | .query(` 175 | SELECT 176 | u.*, 177 | COALESCE( 178 | json_agg( 179 | i.* ORDER BY i.id 180 | ) FILTER (WHERE i.id IS NOT NULL), 181 | '[]'::json 182 | ) as identities 183 | FROM auth.users u 184 | LEFT JOIN auth.identities i ON u.id = i.user_id 185 | GROUP BY u.id 186 | `) 187 | .then((res) => res.rows as User[]); 188 | for (const user of users) { 189 | if (!user.email) { 190 | continue; 191 | } 192 | await ctx.adapter 193 | .create({ 194 | model: "user", 195 | data: { 196 | id: user.id, 197 | email: user.email, 198 | name: user.email, 199 | role: user.is_super_admin ? "admin" : user.role, 200 | emailVerified: !!user.email_confirmed_at, 201 | image: user.raw_user_meta_data.avatar_url, 202 | createdAt: new Date(user.created_at), 203 | updatedAt: new Date(user.updated_at), 204 | isAnonymous: user.is_anonymous, 205 | }, 206 | }) 207 | .catch(() => {}); 208 | for (const identity of user.identities) { 209 | const existingAccounts = await ctx.internalAdapter.findAccounts(user.id); 210 | 211 | if (identity.provider === "email") { 212 | const hasCredential = existingAccounts.find( 213 | (account) => account.providerId === "credential", 214 | ); 215 | if (!hasCredential) { 216 | await ctx.adapter 217 | .create({ 218 | model: "account", 219 | data: { 220 | userId: user.id, 221 | providerId: "credential", 222 | accountId: user.id, 223 | password: user.encrypted_password, 224 | createdAt: new Date(user.created_at), 225 | updatedAt: new Date(user.updated_at), 226 | }, 227 | }) 228 | .catch(() => {}); 229 | } 230 | } 231 | const supportedProviders = Object.keys(ctx.options.socialProviders || {}) 232 | if (supportedProviders.includes(identity.provider)) { 233 | const hasAccount = existingAccounts.find( 234 | (account) => account.providerId === identity.provider, 235 | ); 236 | if (!hasAccount) { 237 | await ctx.adapter.create({ 238 | model: "account", 239 | data: { 240 | userId: user.id, 241 | providerId: identity.provider, 242 | accountId: identity.identity_data?.sub, 243 | createdAt: new Date(identity.created_at ?? user.created_at), 244 | updatedAt: new Date(identity.updated_at ?? user.updated_at), 245 | }, 246 | }); 247 | } 248 | } 249 | } 250 | } 251 | }; 252 | migrateFromSupabase(); 253 | ``` 254 | </Step> 255 | 256 | <Step> 257 | ### Customize the migration script (Optional) 258 | 259 | - `name`: the migration script will use the user's email as the name. You might want to customize it if you have the user display name in your database. 260 | - `socialProviderList`: the migration script will use the social providers you have enabled in your auth config. You might want to customize it if you have additional social providers that you haven't enabled in your auth config. 261 | - `role`: remove `role` if you're not using the `admin` plugin 262 | - `isAnonymous`: remove `isAnonymous` if you're not using the `anonymous` plugin. 263 | - update other tables that reference the `users` table to use the `id` field. 264 | </Step> 265 | <Step> 266 | ### Run the migration script 267 | 268 | Run the migration script to migrate the users and accounts from Supabase to Better Auth. 269 | 270 | ```bash title="Terminal" 271 | bun migration.ts # or use node, ts-node, etc. 272 | ``` 273 | </Step> 274 | <Step> 275 | ### Change password hashing algorithm 276 | 277 | By default, Better Auth uses the `scrypt` algorithm to hash passwords. Since Supabase uses `bcrypt`, you'll need to configure Better Auth to use bcrypt for password verification. 278 | 279 | First, install bcrypt: 280 | 281 | ```bash 282 | npm install bcrypt 283 | npm install -D @types/bcrypt 284 | ``` 285 | 286 | Then update your auth configuration: 287 | 288 | ```ts title="auth.ts" 289 | import { betterAuth } from "better-auth"; 290 | import bcrypt from "bcrypt"; 291 | 292 | export const auth = betterAuth({ 293 | emailAndPassword: { 294 | password: { 295 | hash: async (password) => { 296 | return await bcrypt.hash(password, 10); 297 | }, 298 | verify: async ({ hash, password }) => { 299 | return await bcrypt.compare(password, hash); 300 | } 301 | } 302 | } 303 | }) 304 | ``` 305 | </Step> 306 | <Step> 307 | ### Update your code 308 | 309 | Update your codebase from Supabase auth calls to Better Auth API. 310 | 311 | Here's a list of the Supabase auth API calls and their Better Auth counterparts. 312 | 313 | - `supabase.auth.signUp` -> `authClient.signUp.email` 314 | - `supabase.auth.signInWithPassword` -> `authClient.signIn.email` 315 | - `supabase.auth.signInWithOAuth` -> `authClient.signIn.social` 316 | - `supabase.auth.signInAnonymously` -> `authClient.signIn.anonymous` 317 | - `supabase.auth.signOut` -> `authClient.signOut` 318 | - `supabase.auth.getSession` -> `authClient.getSession` - you can also use `authClient.useSession` for reactive state 319 | 320 | Learn more: 321 | - [Basic Usage](/docs/basic-usage): Learn how to use the auth client to sign up, sign in, and sign out. 322 | - [Email and Password](/docs/authentication/email-and-password): Learn how to add email and password authentication to your project. 323 | - [Anonymous](/docs/plugins/anonymous): Learn how to add anonymous authentication to your project. 324 | - [Admin](/docs/plugins/admin): Learn how to add admin authentication to your project. 325 | - [Email OTP](/docs/authentication/email-otp): Learn how to add email OTP authentication to your project. 326 | - [Hooks](/docs/concepts/hooks): Learn how to use the hooks to listen for events. 327 | - [Next.js](/docs/integrations/next): Learn how to use the auth client in a Next.js project. 328 | </Step> 329 | </Steps> 330 | 331 | ### Middleware 332 | 333 | To protect routes with middleware, refer to the [Next.js middleware guide](/docs/integrations/next#middleware) or your framework's documentation. 334 | 335 | ## Wrapping Up 336 | 337 | Congratulations! You've successfully migrated from Supabase Auth to Better Auth. 338 | 339 | Better Auth offers greater flexibility and more features—be sure to explore the [documentation](/docs) to unlock its full potential. 340 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/call.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it } from "vitest"; 2 | import { APIError } from "better-call"; 3 | import { getEndpoints, router } from "./api"; 4 | import { 5 | createAuthEndpoint, 6 | createAuthMiddleware, 7 | } from "@better-auth/core/api"; 8 | import { init } from "./init"; 9 | import type { BetterAuthOptions, BetterAuthPlugin } from "@better-auth/core"; 10 | import * as z from "zod"; 11 | import { createAuthClient } from "./client"; 12 | import { bearer } from "./plugins"; 13 | 14 | describe("call", async () => { 15 | const q = z.optional( 16 | z.object({ 17 | testBeforeHook: z.string().optional(), 18 | testBeforeGlobal: z.string().optional(), 19 | testAfterHook: z.string().optional(), 20 | testAfterGlobal: z.string().optional(), 21 | testContext: z.string().optional(), 22 | message: z.string().optional(), 23 | }), 24 | ); 25 | const testPlugin = { 26 | id: "test", 27 | endpoints: { 28 | test: createAuthEndpoint( 29 | "/test", 30 | { 31 | method: "GET", 32 | query: q, 33 | }, 34 | async (ctx) => { 35 | return ctx.json({ success: ctx.query?.message || "true" }); 36 | }, 37 | ), 38 | testCookies: createAuthEndpoint( 39 | "/test/cookies", 40 | { 41 | method: "POST", 42 | query: q, 43 | body: z.object({ 44 | cookies: z.array(z.object({ name: z.string(), value: z.string() })), 45 | }), 46 | }, 47 | async (ctx) => { 48 | for (const cookie of ctx.body.cookies) { 49 | ctx.setCookie(cookie.name, cookie.value); 50 | } 51 | return ctx.json({ success: true }); 52 | }, 53 | ), 54 | testThrow: createAuthEndpoint( 55 | "/test/throw", 56 | { 57 | method: "GET", 58 | query: q, 59 | }, 60 | async (ctx) => { 61 | if (ctx.query?.message === "throw-api-error") { 62 | throw new APIError("BAD_REQUEST", { 63 | message: "Test error", 64 | }); 65 | } 66 | if (ctx.query?.message === "throw-error") { 67 | throw new Error("Test error"); 68 | } 69 | if (ctx.query?.message === "throw redirect") { 70 | throw ctx.redirect("/test"); 71 | } 72 | if (ctx.query?.message === "redirect with additional header") { 73 | ctx.setHeader("key", "value"); 74 | throw ctx.redirect("/test"); 75 | } 76 | throw new APIError("BAD_REQUEST", { 77 | message: ctx.query?.message, 78 | }); 79 | }, 80 | ), 81 | }, 82 | } satisfies BetterAuthPlugin; 83 | 84 | const testPlugin2 = { 85 | id: "test2", 86 | hooks: { 87 | before: [ 88 | { 89 | matcher(ctx) { 90 | return ctx.path === "/test"; 91 | }, 92 | handler: createAuthMiddleware(async (ctx) => { 93 | const query = ctx.query; 94 | if (!query) { 95 | return; 96 | } 97 | if (query.testBeforeHook) { 98 | return ctx.json({ 99 | before: "test", 100 | }); 101 | } 102 | if (query.testContext) { 103 | ctx.query = { 104 | message: query.testContext, 105 | }; 106 | return { 107 | context: ctx, 108 | }; 109 | } 110 | }), 111 | }, 112 | ], 113 | after: [ 114 | { 115 | matcher(ctx) { 116 | return ctx.path === "/test"; 117 | }, 118 | handler: createAuthMiddleware(async (ctx) => { 119 | const query = ctx.query?.testAfterHook; 120 | if (!query) { 121 | return; 122 | } 123 | 124 | return ctx.json({ 125 | after: "test", 126 | }); 127 | }), 128 | }, 129 | { 130 | matcher(ctx) { 131 | return ctx.path === "/test/cookies"; 132 | }, 133 | handler: createAuthMiddleware(async (ctx) => { 134 | const query = ctx.query?.testAfterHook; 135 | if (!query) { 136 | return; 137 | } 138 | ctx.setCookie("after", "test"); 139 | }), 140 | }, 141 | { 142 | matcher(ctx) { 143 | return ( 144 | (ctx.path === "/test/throw" && 145 | ctx.query?.message === "throw-after-hook") || 146 | ctx.query?.message === "throw-chained-hook" 147 | ); 148 | }, 149 | handler: createAuthMiddleware(async (ctx) => { 150 | if (ctx.query?.message === "throw-chained-hook") { 151 | throw new APIError("BAD_REQUEST", { 152 | message: "from chained hook 1", 153 | }); 154 | } 155 | if (ctx.context.returned instanceof APIError) { 156 | throw ctx.error("BAD_REQUEST", { 157 | message: "from after hook", 158 | }); 159 | } 160 | }), 161 | }, 162 | { 163 | matcher(ctx) { 164 | return ( 165 | ctx.path === "/test/throw" && 166 | ctx.query?.message === "throw-chained-hook" 167 | ); 168 | }, 169 | handler: createAuthMiddleware(async (ctx) => { 170 | if (ctx.context.returned instanceof APIError) { 171 | const returned = ctx.context.returned; 172 | const message = returned.message; 173 | throw new APIError("BAD_REQUEST", { 174 | message: message.replace("1", "2"), 175 | }); 176 | } 177 | }), 178 | }, 179 | ], 180 | }, 181 | } satisfies BetterAuthPlugin; 182 | const options = { 183 | baseURL: "http://localhost:3000", 184 | plugins: [testPlugin, testPlugin2, bearer()], 185 | emailAndPassword: { 186 | enabled: true, 187 | }, 188 | hooks: { 189 | before: createAuthMiddleware(async (ctx) => { 190 | if (ctx.path === "/sign-up/email") { 191 | return { 192 | context: { 193 | body: { 194 | ...ctx.body, 195 | email: "[email protected]", 196 | }, 197 | }, 198 | }; 199 | } 200 | if (ctx.query?.testBeforeGlobal) { 201 | return ctx.json({ before: "global" }); 202 | } 203 | }), 204 | after: createAuthMiddleware(async (ctx) => { 205 | if (ctx.query?.testAfterGlobal) { 206 | return ctx.json({ after: "global" }); 207 | } 208 | }), 209 | }, 210 | } satisfies BetterAuthOptions; 211 | const authContext = init(options); 212 | const { api } = getEndpoints(authContext, options); 213 | 214 | const r = router(await authContext, options); 215 | const client = createAuthClient({ 216 | baseURL: "http://localhost:3000", 217 | fetchOptions: { 218 | customFetchImpl: async (url, init) => { 219 | return r.handler(new Request(url, init)); 220 | }, 221 | }, 222 | }); 223 | 224 | it("should call api", async () => { 225 | const response = await api.test(); 226 | expect(response).toMatchObject({ 227 | success: "true", 228 | }); 229 | }); 230 | 231 | it("should set cookies", async () => { 232 | const response = await api.testCookies({ 233 | body: { 234 | cookies: [ 235 | { 236 | name: "test-cookie", 237 | value: "test-value", 238 | }, 239 | ], 240 | }, 241 | returnHeaders: true, 242 | }); 243 | const setCookies = response.headers.get("set-cookie"); 244 | expect(setCookies).toContain("test-cookie=test-value"); 245 | }); 246 | 247 | it("should intercept on before hook", async () => { 248 | const response = await api.test({ 249 | query: { 250 | testBeforeHook: "true", 251 | }, 252 | }); 253 | expect(response).toMatchObject({ 254 | before: "test", 255 | }); 256 | }); 257 | 258 | it("should change context on before hook", async () => { 259 | const response = await api.test({ 260 | query: { 261 | testContext: "context-changed", 262 | }, 263 | }); 264 | expect(response).toMatchObject({ 265 | success: "context-changed", 266 | }); 267 | }); 268 | 269 | it("should intercept on after hook", async () => { 270 | const response = await api.test({ 271 | query: { 272 | testAfterHook: "true", 273 | }, 274 | }); 275 | expect(response).toMatchObject({ 276 | after: "test", 277 | }); 278 | }); 279 | 280 | it("should return Response object", async () => { 281 | const response = await api.test({ 282 | asResponse: true, 283 | }); 284 | expect(response).toBeInstanceOf(Response); 285 | }); 286 | 287 | it("should set cookies on after hook", async () => { 288 | const response = await api.testCookies({ 289 | body: { 290 | cookies: [ 291 | { 292 | name: "test-cookie", 293 | value: "test-value", 294 | }, 295 | ], 296 | }, 297 | query: { 298 | testAfterHook: "true", 299 | }, 300 | returnHeaders: true, 301 | }); 302 | const setCookies = response.headers.get("set-cookie"); 303 | expect(setCookies).toContain("after=test"); 304 | expect(setCookies).toContain("test-cookie=test-value"); 305 | }); 306 | 307 | it("should throw APIError", async () => { 308 | await expect( 309 | api.testThrow({ 310 | query: { 311 | message: "throw-api-error", 312 | }, 313 | }), 314 | ).rejects.toThrowError(APIError); 315 | }); 316 | 317 | it("should throw Error", async () => { 318 | await expect( 319 | api.testThrow({ 320 | query: { 321 | message: "throw-error", 322 | }, 323 | }), 324 | ).rejects.toThrowError(Error); 325 | }); 326 | 327 | it("should redirect", async () => { 328 | await api 329 | .testThrow({ 330 | query: { 331 | message: "throw redirect", 332 | }, 333 | }) 334 | .catch((e) => { 335 | expect(e).toBeInstanceOf(APIError); 336 | 337 | expect(e.status).toBe("FOUND"); 338 | expect(e.headers.get("Location")).toBe("/test"); 339 | }); 340 | }); 341 | 342 | it("should include base headers with redirect", async () => { 343 | await api 344 | .testThrow({ 345 | query: { 346 | message: "redirect with additional header", 347 | }, 348 | }) 349 | .catch((e) => { 350 | expect(e).toBeInstanceOf(APIError); 351 | expect(e.status).toBe("FOUND"); 352 | expect(e.headers.get("Location")).toBe("/test"); 353 | expect(e.headers.get("key")).toBe("value"); 354 | }); 355 | }); 356 | 357 | it("should throw from after hook", async () => { 358 | await api 359 | .testThrow({ 360 | query: { 361 | message: "throw-after-hook", 362 | }, 363 | }) 364 | .catch((e) => { 365 | expect(e).toBeInstanceOf(APIError); 366 | expect(e.status).toBe("BAD_REQUEST"); 367 | expect(e.message).toContain("from after hook"); 368 | }); 369 | }); 370 | 371 | it("should throw from chained hook", async () => { 372 | await api 373 | .testThrow({ 374 | query: { 375 | message: "throw-chained-hook", 376 | }, 377 | }) 378 | .catch((e) => { 379 | expect(e).toBeInstanceOf(APIError); 380 | expect(e.status).toBe("BAD_REQUEST"); 381 | expect(e.message).toContain("from chained hook 2"); 382 | }); 383 | }); 384 | 385 | it("should intercept on global before hook", async () => { 386 | const response = await api.test({ 387 | query: { 388 | testBeforeGlobal: "true", 389 | }, 390 | }); 391 | expect(response).toMatchObject({ 392 | before: "global", 393 | }); 394 | }); 395 | 396 | it("should intercept on global after hook", async () => { 397 | const response = await api.test({ 398 | query: { 399 | testAfterGlobal: "true", 400 | }, 401 | }); 402 | expect(response).toMatchObject({ 403 | after: "global", 404 | }); 405 | }); 406 | 407 | it("global before hook should change the context", async (ctx) => { 408 | const response = await api.signUpEmail({ 409 | body: { 410 | email: "[email protected]", 411 | password: "password", 412 | name: "test", 413 | }, 414 | }); 415 | const session = await api.getSession({ 416 | headers: new Headers({ 417 | Authorization: `Bearer ${response?.token}`, 418 | }), 419 | }); 420 | expect(session?.user.email).toBe("[email protected]"); 421 | }); 422 | 423 | it("should fetch using a client", async () => { 424 | const response = await client.$fetch("/ok"); 425 | expect(response.data).toMatchObject({ 426 | ok: true, 427 | }); 428 | }); 429 | 430 | it("should fetch using a client with query", async () => { 431 | const response = await client.$fetch("/test", { 432 | query: { 433 | message: "test", 434 | }, 435 | }); 436 | expect(response.data).toMatchObject({ 437 | success: "test", 438 | }); 439 | }); 440 | 441 | it("should set cookies using a client", async () => { 442 | await client.$fetch("/test/cookies", { 443 | method: "POST", 444 | body: { 445 | cookies: [ 446 | { 447 | name: "test-cookie", 448 | value: "test-value", 449 | }, 450 | ], 451 | }, 452 | onResponse(context) { 453 | expect(context.response.headers.get("set-cookie")).toContain( 454 | "test-cookie=test-value", 455 | ); 456 | }, 457 | }); 458 | }); 459 | }); 460 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/phone-number/phone-number.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { getTestInstance } from "../../test-utils/test-instance"; 3 | import { phoneNumber } from "."; 4 | import { createAuthClient } from "../../client"; 5 | import { phoneNumberClient } from "./client"; 6 | import { bearer } from "../bearer"; 7 | 8 | describe("phone-number", async (it) => { 9 | let otp = ""; 10 | 11 | const { customFetchImpl, sessionSetter } = await getTestInstance({ 12 | plugins: [ 13 | phoneNumber({ 14 | async sendOTP({ code }) { 15 | otp = code; 16 | }, 17 | signUpOnVerification: { 18 | getTempEmail(phoneNumber) { 19 | return `temp-${phoneNumber}`; 20 | }, 21 | }, 22 | }), 23 | ], 24 | }); 25 | 26 | const client = createAuthClient({ 27 | baseURL: "http://localhost:3000", 28 | plugins: [phoneNumberClient()], 29 | fetchOptions: { 30 | customFetchImpl, 31 | }, 32 | }); 33 | 34 | const headers = new Headers(); 35 | 36 | const testPhoneNumber = "+251911121314"; 37 | it("should send verification code", async () => { 38 | const res = await client.phoneNumber.sendOtp({ 39 | phoneNumber: testPhoneNumber, 40 | }); 41 | expect(res.error).toBe(null); 42 | expect(otp).toHaveLength(6); 43 | }); 44 | 45 | it("should verify phone number", async () => { 46 | const res = await client.phoneNumber.verify( 47 | { 48 | phoneNumber: testPhoneNumber, 49 | code: otp, 50 | }, 51 | { 52 | onSuccess: sessionSetter(headers), 53 | }, 54 | ); 55 | expect(res.error).toBe(null); 56 | expect(res.data?.status).toBe(true); 57 | }); 58 | 59 | it("shouldn't verify again with the same code", async () => { 60 | const res = await client.phoneNumber.verify({ 61 | phoneNumber: testPhoneNumber, 62 | code: otp, 63 | }); 64 | expect(res.error?.status).toBe(400); 65 | }); 66 | 67 | it("should update phone number", async () => { 68 | const newPhoneNumber = "+0123456789"; 69 | await client.phoneNumber.sendOtp({ 70 | phoneNumber: newPhoneNumber, 71 | fetchOptions: { 72 | headers, 73 | }, 74 | }); 75 | const res = await client.phoneNumber.verify({ 76 | phoneNumber: newPhoneNumber, 77 | updatePhoneNumber: true, 78 | code: otp, 79 | fetchOptions: { 80 | headers, 81 | }, 82 | }); 83 | const user = await client.getSession({ 84 | fetchOptions: { 85 | headers, 86 | }, 87 | }); 88 | expect(user.data?.user.phoneNumber).toBe(newPhoneNumber); 89 | expect(user.data?.user.phoneNumberVerified).toBe(true); 90 | }); 91 | 92 | it("should not verify if code expired", async () => { 93 | vi.useFakeTimers(); 94 | await client.phoneNumber.sendOtp({ 95 | phoneNumber: "+25120201212", 96 | }); 97 | vi.advanceTimersByTime(1000 * 60 * 5 + 1); // 5 minutes + 1ms 98 | const res = await client.phoneNumber.verify({ 99 | phoneNumber: "+25120201212", 100 | code: otp, 101 | }); 102 | expect(res.error?.status).toBe(400); 103 | }); 104 | }); 105 | 106 | describe("phone auth flow", async () => { 107 | let otp = ""; 108 | 109 | const { customFetchImpl, sessionSetter, auth } = await getTestInstance({ 110 | plugins: [ 111 | phoneNumber({ 112 | async sendOTP({ code }) { 113 | otp = code; 114 | }, 115 | signUpOnVerification: { 116 | getTempEmail(phoneNumber) { 117 | return `temp-${phoneNumber}`; 118 | }, 119 | }, 120 | }), 121 | bearer(), 122 | ], 123 | user: { 124 | changeEmail: { 125 | enabled: true, 126 | }, 127 | }, 128 | }); 129 | 130 | const client = createAuthClient({ 131 | baseURL: "http://localhost:3000", 132 | plugins: [phoneNumberClient()], 133 | fetchOptions: { 134 | customFetchImpl, 135 | }, 136 | }); 137 | 138 | it("should send otp", async () => { 139 | const res = await client.phoneNumber.sendOtp({ 140 | phoneNumber: "+251911121314", 141 | }); 142 | expect(res.error).toBe(null); 143 | expect(otp).toHaveLength(6); 144 | }); 145 | 146 | it("should verify phone number and create user & session", async () => { 147 | const res = await client.phoneNumber.verify({ 148 | phoneNumber: "+251911121314", 149 | code: otp, 150 | }); 151 | const session = await client.getSession({ 152 | fetchOptions: { 153 | headers: { 154 | Authorization: `Bearer ${res.data?.token}`, 155 | }, 156 | throw: true, 157 | }, 158 | }); 159 | expect(session?.user.phoneNumberVerified).toBe(true); 160 | expect(session?.user.email).toBe("temp-+251911121314"); 161 | expect(session?.session.token).toBeDefined(); 162 | }); 163 | 164 | let headers = new Headers(); 165 | it("should go through send-verify and sign-in the user", async () => { 166 | await client.phoneNumber.sendOtp({ 167 | phoneNumber: "+251911121314", 168 | }); 169 | const res = await client.phoneNumber.verify( 170 | { 171 | phoneNumber: "+251911121314", 172 | code: otp, 173 | }, 174 | { 175 | onSuccess: sessionSetter(headers), 176 | }, 177 | ); 178 | expect(res.data?.status).toBe(true); 179 | }); 180 | 181 | const newEmail = "[email protected]"; 182 | it("should set password and update user", async () => { 183 | const res = await auth.api.setPassword({ 184 | body: { 185 | newPassword: "password", 186 | }, 187 | headers, 188 | }); 189 | const changedEmailRes = await client.changeEmail({ 190 | newEmail, 191 | fetchOptions: { 192 | headers, 193 | }, 194 | }); 195 | expect(changedEmailRes.error).toBe(null); 196 | expect(changedEmailRes.data?.status).toBe(true); 197 | }); 198 | 199 | it("should sign in with phone number and password", async () => { 200 | const res = await client.signIn.phoneNumber({ 201 | phoneNumber: "+251911121314", 202 | password: "password", 203 | }); 204 | expect(res.data?.token).toBeDefined(); 205 | }); 206 | 207 | it("should sign in with new email", async () => { 208 | const res = await client.signIn.email({ 209 | email: newEmail, 210 | password: "password", 211 | }); 212 | expect(res.error).toBe(null); 213 | }); 214 | }); 215 | 216 | describe("verify phone-number", async (it) => { 217 | let otp = ""; 218 | 219 | const { customFetchImpl, sessionSetter } = await getTestInstance({ 220 | plugins: [ 221 | phoneNumber({ 222 | async sendOTP({ code }) { 223 | otp = code; 224 | }, 225 | signUpOnVerification: { 226 | getTempEmail(phoneNumber) { 227 | return `temp-${phoneNumber}`; 228 | }, 229 | }, 230 | allowedAttempts: 3, 231 | }), 232 | ], 233 | }); 234 | 235 | const client = createAuthClient({ 236 | baseURL: "http://localhost:3000", 237 | plugins: [phoneNumberClient()], 238 | fetchOptions: { 239 | customFetchImpl, 240 | }, 241 | }); 242 | 243 | const headers = new Headers(); 244 | 245 | const testPhoneNumber = "+251911121314"; 246 | 247 | it("should verify the last code", async () => { 248 | await client.phoneNumber.sendOtp({ 249 | phoneNumber: testPhoneNumber, 250 | }); 251 | vi.useFakeTimers(); 252 | vi.advanceTimersByTime(1000); 253 | await client.phoneNumber.sendOtp({ 254 | phoneNumber: testPhoneNumber, 255 | }); 256 | vi.advanceTimersByTime(1000); 257 | await client.phoneNumber.sendOtp({ 258 | phoneNumber: testPhoneNumber, 259 | }); 260 | const res = await client.phoneNumber.verify( 261 | { 262 | phoneNumber: testPhoneNumber, 263 | code: otp, 264 | }, 265 | { 266 | onSuccess: sessionSetter(headers), 267 | }, 268 | ); 269 | expect(res.error).toBe(null); 270 | expect(res.data?.status).toBe(true); 271 | }); 272 | 273 | it("should block after exceeding allowed attempts", async () => { 274 | await client.phoneNumber.sendOtp({ 275 | phoneNumber: testPhoneNumber, 276 | }); 277 | 278 | for (let i = 0; i < 3; i++) { 279 | const res = await client.phoneNumber.verify({ 280 | phoneNumber: testPhoneNumber, 281 | code: "000000", 282 | }); 283 | expect(res.error?.status).toBe(400); 284 | expect(res.error?.message).toBe("Invalid OTP"); 285 | } 286 | 287 | //Try one more time - should be blocked 288 | const res = await client.phoneNumber.verify({ 289 | phoneNumber: testPhoneNumber, 290 | code: "000000", 291 | }); 292 | expect(res.error?.status).toBe(403); 293 | expect(res.error?.message).toBe("Too many attempts"); 294 | }); 295 | }); 296 | 297 | describe("reset password flow attempts", async (it) => { 298 | let otp = ""; 299 | let resetOtp = ""; 300 | 301 | const { customFetchImpl, sessionSetter } = await getTestInstance({ 302 | plugins: [ 303 | phoneNumber({ 304 | async sendOTP({ code }) { 305 | console.log("sendOTP", code); 306 | otp = code; 307 | }, 308 | sendPasswordResetOTP(data, request) { 309 | resetOtp = data.code; 310 | }, 311 | signUpOnVerification: { 312 | getTempEmail(phoneNumber) { 313 | return `temp-${phoneNumber}`; 314 | }, 315 | }, 316 | allowedAttempts: 3, 317 | }), 318 | ], 319 | }); 320 | 321 | const client = createAuthClient({ 322 | baseURL: "http://localhost:3000", 323 | plugins: [phoneNumberClient()], 324 | fetchOptions: { 325 | customFetchImpl, 326 | }, 327 | }); 328 | 329 | const testPhoneNumber = "+251911121314"; 330 | 331 | it("should block reset password after exceeding allowed attempts", async () => { 332 | //register phone number 333 | await client.phoneNumber.sendOtp({ 334 | phoneNumber: testPhoneNumber, 335 | }); 336 | await client.phoneNumber.verify({ 337 | phoneNumber: testPhoneNumber, 338 | code: otp, 339 | }); 340 | 341 | await client.phoneNumber.requestPasswordReset({ 342 | phoneNumber: testPhoneNumber, 343 | }); 344 | 345 | for (let i = 0; i < 3; i++) { 346 | const res = await client.phoneNumber.resetPassword({ 347 | phoneNumber: testPhoneNumber, 348 | otp: otp, 349 | newPassword: "password", 350 | }); 351 | expect(res.error?.status).toBe(400); 352 | expect(res.error?.message).toBe("Invalid OTP"); 353 | } 354 | 355 | const res = await client.phoneNumber.resetPassword({ 356 | phoneNumber: testPhoneNumber, 357 | otp: otp, 358 | newPassword: "password", 359 | }); 360 | expect(res.error?.status).toBe(403); 361 | expect(res.error?.message).toBe("Too many attempts"); 362 | }); 363 | 364 | it("should successfully reset password with correct code", async () => { 365 | await client.phoneNumber.requestPasswordReset({ 366 | phoneNumber: testPhoneNumber, 367 | }); 368 | 369 | const resetPasswordRes = await client.phoneNumber.resetPassword({ 370 | phoneNumber: testPhoneNumber, 371 | otp: resetOtp, 372 | newPassword: "password", 373 | }); 374 | 375 | expect(resetPasswordRes.error).toBe(null); 376 | expect(resetPasswordRes.data?.status).toBe(true); 377 | }); 378 | 379 | it("shouldn't allow to re-use the same OTP code", async () => { 380 | const res = await client.phoneNumber.resetPassword({ 381 | phoneNumber: testPhoneNumber, 382 | otp: resetOtp, 383 | newPassword: "password", 384 | }); 385 | expect(res.error?.status).toBe(400); 386 | }); 387 | }); 388 | 389 | describe("phone number verification requirement", async () => { 390 | let otp = ""; 391 | const { customFetchImpl } = await getTestInstance({ 392 | plugins: [ 393 | phoneNumber({ 394 | async sendOTP({ code }) { 395 | otp = code; 396 | }, 397 | requireVerification: true, 398 | signUpOnVerification: { 399 | getTempEmail(phoneNumber) { 400 | return `temp-${phoneNumber}`; 401 | }, 402 | }, 403 | }), 404 | ], 405 | user: { 406 | changeEmail: { 407 | enabled: true, 408 | }, 409 | }, 410 | }); 411 | 412 | const client = createAuthClient({ 413 | baseURL: "http://localhost:3000", 414 | plugins: [phoneNumberClient()], 415 | fetchOptions: { 416 | customFetchImpl, 417 | }, 418 | }); 419 | 420 | const testPhoneNumber = "+251911121314"; 421 | const testPassword = "password123"; 422 | const testEmail = "[email protected]"; 423 | 424 | it("should not allow sign in with unverified phone number and trigger OTP send", async () => { 425 | await client.signUp.email({ 426 | email: testEmail, 427 | password: testPassword, 428 | name: "test", 429 | phoneNumber: testPhoneNumber, 430 | }); 431 | const signInRes = await client.signIn.phoneNumber({ 432 | phoneNumber: testPhoneNumber, 433 | password: testPassword, 434 | }); 435 | expect(signInRes.error?.status).toBe(401); 436 | expect(signInRes.error?.code).toMatch("PHONE_NUMBER_NOT_VERIFIED"); 437 | expect(otp).toHaveLength(6); 438 | }); 439 | }); 440 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/two-factor/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { generateRandomString } from "../../crypto/random"; 2 | import * as z from "zod"; 3 | import { 4 | createAuthEndpoint, 5 | createAuthMiddleware, 6 | } from "@better-auth/core/api"; 7 | import { sessionMiddleware } from "../../api"; 8 | import { symmetricEncrypt } from "../../crypto"; 9 | import type { BetterAuthPlugin } from "@better-auth/core"; 10 | import { 11 | backupCode2fa, 12 | generateBackupCodes, 13 | type BackupCodeOptions, 14 | } from "./backup-codes"; 15 | import { otp2fa } from "./otp"; 16 | import { totp2fa } from "./totp"; 17 | import type { TwoFactorOptions, UserWithTwoFactor } from "./types"; 18 | import { mergeSchema } from "../../db/schema"; 19 | import { TWO_FACTOR_COOKIE_NAME, TRUST_DEVICE_COOKIE_NAME } from "./constant"; 20 | import { validatePassword } from "../../utils/password"; 21 | import { APIError } from "better-call"; 22 | import { deleteSessionCookie, setSessionCookie } from "../../cookies"; 23 | import { schema } from "./schema"; 24 | import { BASE_ERROR_CODES } from "@better-auth/core/error"; 25 | import { createOTP } from "@better-auth/utils/otp"; 26 | import { createHMAC } from "@better-auth/utils/hmac"; 27 | import { TWO_FACTOR_ERROR_CODES } from "./error-code"; 28 | export * from "./error-code"; 29 | 30 | export const twoFactor = (options?: TwoFactorOptions) => { 31 | const opts = { 32 | twoFactorTable: "twoFactor", 33 | }; 34 | const backupCodeOptions = { 35 | storeBackupCodes: "encrypted", 36 | ...options?.backupCodeOptions, 37 | } satisfies BackupCodeOptions; 38 | const totp = totp2fa(options?.totpOptions); 39 | const backupCode = backupCode2fa(backupCodeOptions); 40 | const otp = otp2fa(options?.otpOptions); 41 | 42 | return { 43 | id: "two-factor", 44 | endpoints: { 45 | ...totp.endpoints, 46 | ...otp.endpoints, 47 | ...backupCode.endpoints, 48 | /** 49 | * ### Endpoint 50 | * 51 | * POST `/two-factor/enable` 52 | * 53 | * ### API Methods 54 | * 55 | * **server:** 56 | * `auth.api.enableTwoFactor` 57 | * 58 | * **client:** 59 | * `authClient.twoFactor.enable` 60 | * 61 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-enable) 62 | */ 63 | enableTwoFactor: createAuthEndpoint( 64 | "/two-factor/enable", 65 | { 66 | method: "POST", 67 | body: z.object({ 68 | password: z.string().meta({ 69 | description: "User password", 70 | }), 71 | issuer: z 72 | .string() 73 | .meta({ 74 | description: "Custom issuer for the TOTP URI", 75 | }) 76 | .optional(), 77 | }), 78 | use: [sessionMiddleware], 79 | metadata: { 80 | openapi: { 81 | summary: "Enable two factor authentication", 82 | description: 83 | "Use this endpoint to enable two factor authentication. This will generate a TOTP URI and backup codes. Once the user verifies the TOTP URI, the two factor authentication will be enabled.", 84 | responses: { 85 | 200: { 86 | description: "Successful response", 87 | content: { 88 | "application/json": { 89 | schema: { 90 | type: "object", 91 | properties: { 92 | totpURI: { 93 | type: "string", 94 | description: "TOTP URI", 95 | }, 96 | backupCodes: { 97 | type: "array", 98 | items: { 99 | type: "string", 100 | }, 101 | description: "Backup codes", 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | }, 108 | }, 109 | }, 110 | }, 111 | }, 112 | async (ctx) => { 113 | const user = ctx.context.session.user as UserWithTwoFactor; 114 | const { password, issuer } = ctx.body; 115 | const isPasswordValid = await validatePassword(ctx, { 116 | password, 117 | userId: user.id, 118 | }); 119 | if (!isPasswordValid) { 120 | throw new APIError("BAD_REQUEST", { 121 | message: BASE_ERROR_CODES.INVALID_PASSWORD, 122 | }); 123 | } 124 | const secret = generateRandomString(32); 125 | const encryptedSecret = await symmetricEncrypt({ 126 | key: ctx.context.secret, 127 | data: secret, 128 | }); 129 | const backupCodes = await generateBackupCodes( 130 | ctx.context.secret, 131 | backupCodeOptions, 132 | ); 133 | if (options?.skipVerificationOnEnable) { 134 | const updatedUser = await ctx.context.internalAdapter.updateUser( 135 | user.id, 136 | { 137 | twoFactorEnabled: true, 138 | }, 139 | ); 140 | const newSession = await ctx.context.internalAdapter.createSession( 141 | updatedUser.id, 142 | false, 143 | ctx.context.session.session, 144 | ); 145 | /** 146 | * Update the session cookie with the new user data 147 | */ 148 | await setSessionCookie(ctx, { 149 | session: newSession, 150 | user: updatedUser, 151 | }); 152 | 153 | //remove current session 154 | await ctx.context.internalAdapter.deleteSession( 155 | ctx.context.session.session.token, 156 | ); 157 | } 158 | //delete existing two factor 159 | await ctx.context.adapter.deleteMany({ 160 | model: opts.twoFactorTable, 161 | where: [ 162 | { 163 | field: "userId", 164 | value: user.id, 165 | }, 166 | ], 167 | }); 168 | 169 | await ctx.context.adapter.create({ 170 | model: opts.twoFactorTable, 171 | data: { 172 | secret: encryptedSecret, 173 | backupCodes: backupCodes.encryptedBackupCodes, 174 | userId: user.id, 175 | }, 176 | }); 177 | const totpURI = createOTP(secret, { 178 | digits: options?.totpOptions?.digits || 6, 179 | period: options?.totpOptions?.period, 180 | }).url(issuer || options?.issuer || ctx.context.appName, user.email); 181 | return ctx.json({ totpURI, backupCodes: backupCodes.backupCodes }); 182 | }, 183 | ), 184 | /** 185 | * ### Endpoint 186 | * 187 | * POST `/two-factor/disable` 188 | * 189 | * ### API Methods 190 | * 191 | * **server:** 192 | * `auth.api.disableTwoFactor` 193 | * 194 | * **client:** 195 | * `authClient.twoFactor.disable` 196 | * 197 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-disable) 198 | */ 199 | disableTwoFactor: createAuthEndpoint( 200 | "/two-factor/disable", 201 | { 202 | method: "POST", 203 | body: z.object({ 204 | password: z.string().meta({ 205 | description: "User password", 206 | }), 207 | }), 208 | use: [sessionMiddleware], 209 | metadata: { 210 | openapi: { 211 | summary: "Disable two factor authentication", 212 | description: 213 | "Use this endpoint to disable two factor authentication.", 214 | responses: { 215 | 200: { 216 | description: "Successful response", 217 | content: { 218 | "application/json": { 219 | schema: { 220 | type: "object", 221 | properties: { 222 | status: { 223 | type: "boolean", 224 | }, 225 | }, 226 | }, 227 | }, 228 | }, 229 | }, 230 | }, 231 | }, 232 | }, 233 | }, 234 | async (ctx) => { 235 | const user = ctx.context.session.user as UserWithTwoFactor; 236 | const { password } = ctx.body; 237 | const isPasswordValid = await validatePassword(ctx, { 238 | password, 239 | userId: user.id, 240 | }); 241 | if (!isPasswordValid) { 242 | throw new APIError("BAD_REQUEST", { 243 | message: "Invalid password", 244 | }); 245 | } 246 | const updatedUser = await ctx.context.internalAdapter.updateUser( 247 | user.id, 248 | { 249 | twoFactorEnabled: false, 250 | }, 251 | ); 252 | await ctx.context.adapter.delete({ 253 | model: opts.twoFactorTable, 254 | where: [ 255 | { 256 | field: "userId", 257 | value: updatedUser.id, 258 | }, 259 | ], 260 | }); 261 | const newSession = await ctx.context.internalAdapter.createSession( 262 | updatedUser.id, 263 | false, 264 | ctx.context.session.session, 265 | ); 266 | /** 267 | * Update the session cookie with the new user data 268 | */ 269 | await setSessionCookie(ctx, { 270 | session: newSession, 271 | user: updatedUser, 272 | }); 273 | //remove current session 274 | await ctx.context.internalAdapter.deleteSession( 275 | ctx.context.session.session.token, 276 | ); 277 | return ctx.json({ status: true }); 278 | }, 279 | ), 280 | }, 281 | options: options, 282 | hooks: { 283 | after: [ 284 | { 285 | matcher(context) { 286 | return ( 287 | context.path === "/sign-in/email" || 288 | context.path === "/sign-in/username" || 289 | context.path === "/sign-in/phone-number" 290 | ); 291 | }, 292 | handler: createAuthMiddleware(async (ctx) => { 293 | const data = ctx.context.newSession; 294 | if (!data) { 295 | return; 296 | } 297 | 298 | if (!data?.user.twoFactorEnabled) { 299 | return; 300 | } 301 | // Check for trust device cookie 302 | const trustDeviceCookieName = ctx.context.createAuthCookie( 303 | TRUST_DEVICE_COOKIE_NAME, 304 | ); 305 | const trustDeviceCookie = await ctx.getSignedCookie( 306 | trustDeviceCookieName.name, 307 | ctx.context.secret, 308 | ); 309 | if (trustDeviceCookie) { 310 | const [token, sessionToken] = trustDeviceCookie.split("!"); 311 | const expectedToken = await createHMAC( 312 | "SHA-256", 313 | "base64urlnopad", 314 | ).sign(ctx.context.secret, `${data.user.id}!${sessionToken}`); 315 | 316 | if (token === expectedToken) { 317 | // Trust device cookie is valid, refresh it and skip 2FA 318 | const newToken = await createHMAC( 319 | "SHA-256", 320 | "base64urlnopad", 321 | ).sign(ctx.context.secret, `${data.user.id}!${sessionToken}`); 322 | await ctx.setSignedCookie( 323 | trustDeviceCookieName.name, 324 | `${newToken}!${data.session.token}`, 325 | ctx.context.secret, 326 | trustDeviceCookieName.attributes, 327 | ); 328 | return; 329 | } 330 | } 331 | 332 | /** 333 | * remove the session cookie. It's set by the sign in credential 334 | */ 335 | deleteSessionCookie(ctx, true); 336 | await ctx.context.internalAdapter.deleteSession(data.session.token); 337 | const maxAge = (options?.otpOptions?.period ?? 3) * 60; // 3 minutes 338 | const twoFactorCookie = ctx.context.createAuthCookie( 339 | TWO_FACTOR_COOKIE_NAME, 340 | { 341 | maxAge, 342 | }, 343 | ); 344 | const identifier = `2fa-${generateRandomString(20)}`; 345 | await ctx.context.internalAdapter.createVerificationValue({ 346 | value: data.user.id, 347 | identifier, 348 | expiresAt: new Date(Date.now() + maxAge * 1000), 349 | }); 350 | await ctx.setSignedCookie( 351 | twoFactorCookie.name, 352 | identifier, 353 | ctx.context.secret, 354 | twoFactorCookie.attributes, 355 | ); 356 | return ctx.json({ 357 | twoFactorRedirect: true, 358 | }); 359 | }), 360 | }, 361 | ], 362 | }, 363 | schema: mergeSchema(schema, options?.schema), 364 | rateLimit: [ 365 | { 366 | pathMatcher(path) { 367 | return path.startsWith("/two-factor/"); 368 | }, 369 | window: 10, 370 | max: 3, 371 | }, 372 | ], 373 | $ERROR_CODES: TWO_FACTOR_ERROR_CODES, 374 | } satisfies BetterAuthPlugin; 375 | }; 376 | 377 | export * from "./client"; 378 | export * from "./types"; 379 | ```