This is page 46 of 67. 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 │ ├── middleware.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── user-additional-fields.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── sso │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sso.test.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 │ ├── 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 │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── 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 │ │ │ ├── middleware │ │ │ │ └── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/email-otp/email-otp.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { getTestInstance } from "../../test-utils/test-instance"; 3 | import { emailOTP } from "."; 4 | import { emailOTPClient } from "./client"; 5 | import { bearer } from "../bearer"; 6 | import { splitAtLastColon } from "./utils"; 7 | import { createAuthClient } from "../../client"; 8 | 9 | describe("email-otp", async () => { 10 | const otpFn = vi.fn(); 11 | let otp = ""; 12 | const { client, testUser, auth } = await getTestInstance( 13 | { 14 | plugins: [ 15 | bearer(), 16 | emailOTP({ 17 | async sendVerificationOTP({ email, otp: _otp, type }) { 18 | otp = _otp; 19 | otpFn(email, _otp, type); 20 | }, 21 | sendVerificationOnSignUp: true, 22 | }), 23 | ], 24 | emailVerification: { 25 | autoSignInAfterVerification: true, 26 | }, 27 | }, 28 | { 29 | clientOptions: { 30 | plugins: [emailOTPClient()], 31 | }, 32 | }, 33 | ); 34 | 35 | it("should verify email with otp", async () => { 36 | const res = await client.emailOtp.sendVerificationOtp({ 37 | email: testUser.email, 38 | type: "email-verification", 39 | }); 40 | expect(res.data?.success).toBe(true); 41 | expect(otp.length).toBe(6); 42 | expect(otpFn).toHaveBeenCalledWith( 43 | testUser.email, 44 | otp, 45 | "email-verification", 46 | ); 47 | const verifiedUser = await client.emailOtp.verifyEmail({ 48 | email: testUser.email, 49 | otp, 50 | }); 51 | expect(verifiedUser.data?.status).toBe(true); 52 | }); 53 | 54 | it("should sign-in with otp", async () => { 55 | const res = await client.emailOtp.sendVerificationOtp({ 56 | email: testUser.email, 57 | type: "sign-in", 58 | }); 59 | expect(res.data?.success).toBe(true); 60 | expect(otp.length).toBe(6); 61 | expect(otpFn).toHaveBeenCalledWith(testUser.email, otp, "sign-in"); 62 | const verifiedUser = await client.signIn.emailOtp( 63 | { 64 | email: testUser.email, 65 | otp, 66 | }, 67 | { 68 | onSuccess: (ctx) => { 69 | const header = ctx.response.headers.get("set-cookie"); 70 | expect(header).toContain("better-auth.session_token"); 71 | }, 72 | }, 73 | ); 74 | expect(verifiedUser.data?.token).toBeDefined(); 75 | }); 76 | 77 | it("should sign-up with otp", async () => { 78 | const testUser2 = { 79 | email: "[email protected]", 80 | }; 81 | await client.emailOtp.sendVerificationOtp({ 82 | email: testUser2.email, 83 | type: "sign-in", 84 | }); 85 | const newUser = await client.signIn.emailOtp( 86 | { 87 | email: testUser2.email, 88 | otp, 89 | }, 90 | { 91 | onSuccess: (ctx) => { 92 | const header = ctx.response.headers.get("set-cookie"); 93 | expect(header).toContain("better-auth.session_token"); 94 | }, 95 | }, 96 | ); 97 | expect(newUser.data?.token).toBeDefined(); 98 | }); 99 | 100 | it("should send verification otp on sign-up", async () => { 101 | const testUser2 = { 102 | email: "[email protected]", 103 | password: "password", 104 | name: "test", 105 | }; 106 | await client.signUp.email(testUser2); 107 | expect(otpFn).toHaveBeenCalledWith( 108 | testUser2.email, 109 | otp, 110 | "email-verification", 111 | ); 112 | }); 113 | 114 | it("should send forget password otp", async () => { 115 | await client.emailOtp.sendVerificationOtp({ 116 | email: testUser.email, 117 | type: "forget-password", 118 | }); 119 | }); 120 | 121 | it("should reset password", async () => { 122 | await client.emailOtp.resetPassword({ 123 | email: testUser.email, 124 | otp, 125 | password: "changed-password", 126 | }); 127 | const { data } = await client.signIn.email({ 128 | email: testUser.email, 129 | password: "changed-password", 130 | }); 131 | expect(data?.user).toBeDefined(); 132 | }); 133 | 134 | it("should call onPasswordReset callback when resetting password", async () => { 135 | const onPasswordResetMock = vi.fn(); 136 | const { client, testUser } = await getTestInstance( 137 | { 138 | plugins: [ 139 | bearer(), 140 | emailOTP({ 141 | async sendVerificationOTP({ email, otp: _otp, type }) { 142 | otp = _otp; 143 | otpFn(email, _otp, type); 144 | }, 145 | sendVerificationOnSignUp: true, 146 | }), 147 | ], 148 | emailAndPassword: { 149 | enabled: true, 150 | onPasswordReset: onPasswordResetMock, 151 | }, 152 | }, 153 | { 154 | clientOptions: { 155 | plugins: [emailOTPClient()], 156 | }, 157 | }, 158 | ); 159 | 160 | await client.emailOtp.sendVerificationOtp({ 161 | email: testUser.email, 162 | type: "forget-password", 163 | }); 164 | 165 | await client.emailOtp.resetPassword({ 166 | email: testUser.email, 167 | otp, 168 | password: "new-password", 169 | }); 170 | 171 | expect(onPasswordResetMock).toHaveBeenCalledWith( 172 | { user: expect.objectContaining({ email: testUser.email }) }, 173 | expect.any(Object), 174 | ); 175 | }); 176 | 177 | it("should reset password and create credential account", async () => { 178 | const testUser2 = { 179 | email: "[email protected]", 180 | }; 181 | await client.emailOtp.sendVerificationOtp({ 182 | email: testUser2.email, 183 | type: "sign-in", 184 | }); 185 | await client.signIn.emailOtp( 186 | { 187 | email: testUser2.email, 188 | otp, 189 | }, 190 | { 191 | onSuccess: (ctx) => { 192 | const header = ctx.response.headers.get("set-cookie"); 193 | expect(header).toContain("better-auth.session_token"); 194 | }, 195 | }, 196 | ); 197 | await client.emailOtp.sendVerificationOtp({ 198 | email: testUser2.email, 199 | type: "forget-password", 200 | }); 201 | await client.emailOtp.resetPassword({ 202 | email: testUser2.email, 203 | otp, 204 | password: "password", 205 | }); 206 | const res = await client.signIn.email({ 207 | email: testUser2.email, 208 | password: "password", 209 | }); 210 | expect(res.data?.token).toBeDefined(); 211 | }); 212 | 213 | it("should fail on invalid email", async () => { 214 | const res = await client.emailOtp.sendVerificationOtp({ 215 | email: "invalid-email", 216 | type: "email-verification", 217 | }); 218 | expect(res.error?.status).toBe(400); 219 | expect(res.error?.code).toBe("INVALID_EMAIL"); 220 | }); 221 | 222 | it("should fail on expired otp", async () => { 223 | await client.emailOtp.sendVerificationOtp({ 224 | email: testUser.email, 225 | type: "email-verification", 226 | }); 227 | vi.useFakeTimers(); 228 | await vi.advanceTimersByTimeAsync(1000 * 60 * 6); 229 | const res = await client.emailOtp.verifyEmail({ 230 | email: testUser.email, 231 | otp, 232 | }); 233 | expect(res.error?.status).toBe(400); 234 | expect(res.error?.code).toBe("OTP_EXPIRED"); 235 | }); 236 | 237 | it("should not fail on time elapsed", async () => { 238 | await client.emailOtp.sendVerificationOtp({ 239 | email: testUser.email, 240 | type: "email-verification", 241 | }); 242 | vi.useFakeTimers(); 243 | await vi.advanceTimersByTimeAsync(1000 * 60 * 4); 244 | const res = await client.emailOtp.verifyEmail({ 245 | email: testUser.email, 246 | otp, 247 | }); 248 | const session = await client.getSession({ 249 | fetchOptions: { 250 | headers: { 251 | Authorization: `Bearer ${res.data?.token}`, 252 | }, 253 | }, 254 | }); 255 | expect(res.data?.status).toBe(true); 256 | expect(session.data?.user.emailVerified).toBe(true); 257 | }); 258 | 259 | it("should create verification otp on server", async () => { 260 | otp = await auth.api.createVerificationOTP({ 261 | body: { 262 | type: "sign-in", 263 | email: "[email protected]", 264 | }, 265 | }); 266 | otp = await auth.api.createVerificationOTP({ 267 | body: { 268 | type: "sign-in", 269 | email: "[email protected]", 270 | }, 271 | }); 272 | expect(otp.length).toBe(6); 273 | }); 274 | 275 | it("should get verification otp on server", async () => { 276 | const res = await auth.api.getVerificationOTP({ 277 | query: { 278 | email: "[email protected]", 279 | type: "sign-in", 280 | }, 281 | }); 282 | }); 283 | 284 | it("should work with custom options", async () => { 285 | const { client, testUser, auth } = await getTestInstance( 286 | { 287 | plugins: [ 288 | bearer(), 289 | emailOTP({ 290 | async sendVerificationOTP({ email, otp: _otp, type }) { 291 | otp = _otp; 292 | otpFn(email, _otp, type); 293 | }, 294 | sendVerificationOnSignUp: true, 295 | expiresIn: 10, 296 | otpLength: 8, 297 | }), 298 | ], 299 | emailVerification: { 300 | autoSignInAfterVerification: true, 301 | }, 302 | }, 303 | { 304 | clientOptions: { 305 | plugins: [emailOTPClient()], 306 | }, 307 | }, 308 | ); 309 | await client.emailOtp.sendVerificationOtp({ 310 | type: "email-verification", 311 | email: testUser.email, 312 | }); 313 | expect(otp.length).toBe(8); 314 | vi.useFakeTimers(); 315 | await vi.advanceTimersByTimeAsync(11 * 1000); 316 | const verifyRes = await client.emailOtp.verifyEmail({ 317 | email: testUser.email, 318 | otp, 319 | }); 320 | expect(verifyRes.error?.code).toBe("OTP_EXPIRED"); 321 | }); 322 | }); 323 | 324 | describe("email-otp-verify", async () => { 325 | const otpFn = vi.fn(); 326 | const otp = [""]; 327 | const { client, testUser, auth } = await getTestInstance( 328 | { 329 | plugins: [ 330 | emailOTP({ 331 | async sendVerificationOTP({ email, otp: _otp, type }) { 332 | otp.push(_otp); 333 | otpFn(email, _otp, type); 334 | }, 335 | sendVerificationOnSignUp: true, 336 | disableSignUp: true, 337 | }), 338 | ], 339 | }, 340 | { 341 | clientOptions: { 342 | plugins: [emailOTPClient()], 343 | }, 344 | }, 345 | ); 346 | 347 | it("should return USER_NOT_FOUND error when disableSignUp and user not registered", async () => { 348 | const response = await client.emailOtp.sendVerificationOtp({ 349 | email: "[email protected]", 350 | type: "email-verification", 351 | }); 352 | 353 | expect(response.error?.message).toBe("User not found"); 354 | // Existing user should still succeed 355 | const successRes = await client.emailOtp.sendVerificationOtp({ 356 | email: testUser.email, 357 | type: "email-verification", 358 | }); 359 | expect(successRes.error).toBeFalsy(); 360 | }); 361 | 362 | it("should verify email with last otp", async () => { 363 | await client.emailOtp.sendVerificationOtp({ 364 | email: testUser.email, 365 | type: "email-verification", 366 | }); 367 | await client.emailOtp.sendVerificationOtp({ 368 | email: testUser.email, 369 | type: "email-verification", 370 | }); 371 | await client.emailOtp.sendVerificationOtp({ 372 | email: testUser.email, 373 | type: "email-verification", 374 | }); 375 | }); 376 | 377 | it("should block after exceeding allowed attempts", async () => { 378 | await client.emailOtp.sendVerificationOtp({ 379 | email: testUser.email, 380 | type: "email-verification", 381 | }); 382 | 383 | for (let i = 0; i < 3; i++) { 384 | const res = await client.emailOtp.verifyEmail({ 385 | email: testUser.email, 386 | otp: "wrong-otp", 387 | }); 388 | expect(res.error?.status).toBe(400); 389 | expect(res.error?.message).toBe("Invalid OTP"); 390 | } 391 | 392 | //Try one more time - should be blocked 393 | const res = await client.emailOtp.verifyEmail({ 394 | email: testUser.email, 395 | otp: "000000", 396 | }); 397 | expect(res.error?.status).toBe(403); 398 | expect(res.error?.message).toBe("Too many attempts"); 399 | }); 400 | 401 | it("should block reset password after exceeding allowed attempts", async () => { 402 | await client.emailOtp.sendVerificationOtp({ 403 | email: testUser.email, 404 | type: "forget-password", 405 | }); 406 | 407 | for (let i = 0; i < 3; i++) { 408 | const res = await client.emailOtp.resetPassword({ 409 | email: testUser.email, 410 | otp: "wrong-otp", 411 | password: "new-password", 412 | }); 413 | expect(res.error?.status).toBe(400); 414 | expect(res.error?.message).toBe("Invalid OTP"); 415 | } 416 | 417 | // Try one more time - should be blocked 418 | const res = await client.emailOtp.resetPassword({ 419 | email: testUser.email, 420 | otp: "000000", 421 | password: "new-password", 422 | }); 423 | expect(res.error?.status).toBe(403); 424 | expect(res.error?.message).toBe("Too many attempts"); 425 | }); 426 | }); 427 | 428 | describe("custom rate limiting storage", async () => { 429 | const { client, testUser } = await getTestInstance({ 430 | rateLimit: { 431 | enabled: true, 432 | }, 433 | plugins: [ 434 | emailOTP({ 435 | async sendVerificationOTP(data, request) {}, 436 | }), 437 | ], 438 | }); 439 | 440 | it.each([ 441 | { 442 | path: "/email-otp/send-verification-otp", 443 | body: { 444 | email: "[email protected]", 445 | type: "sign-in", 446 | }, 447 | }, 448 | { 449 | path: "/sign-in/email-otp", 450 | body: { 451 | email: "[email protected]", 452 | otp: "12312", 453 | }, 454 | }, 455 | { 456 | path: "/email-otp/verify-email", 457 | body: { 458 | email: "[email protected]", 459 | otp: "12312", 460 | }, 461 | }, 462 | ])("should rate limit send verification endpoint", async ({ path, body }) => { 463 | for (let i = 0; i < 10; i++) { 464 | const response = await client.$fetch(path, { 465 | method: "POST", 466 | body, 467 | }); 468 | if (i >= 3) { 469 | expect(response.error?.status).toBe(429); 470 | } 471 | } 472 | vi.useFakeTimers(); 473 | await vi.advanceTimersByTimeAsync(60 * 1000); 474 | const response = await client.$fetch(path, { 475 | method: "POST", 476 | body, 477 | }); 478 | expect(response.error?.status).not.toBe(429); 479 | }); 480 | }); 481 | 482 | describe("custom generate otpFn", async () => { 483 | const { client, testUser } = await getTestInstance( 484 | { 485 | plugins: [ 486 | emailOTP({ 487 | async sendVerificationOTP(data, request) {}, 488 | generateOTP(data, request) { 489 | return "123456"; 490 | }, 491 | }), 492 | ], 493 | }, 494 | { 495 | clientOptions: { 496 | plugins: [emailOTPClient()], 497 | }, 498 | }, 499 | ); 500 | 501 | it("should generate otp", async () => { 502 | const res = await client.emailOtp.sendVerificationOtp({ 503 | email: testUser.email, 504 | type: "email-verification", 505 | }); 506 | expect(res.data?.success).toBe(true); 507 | }); 508 | 509 | it("should verify email with otp", async () => { 510 | const res = await client.emailOtp.verifyEmail({ 511 | email: testUser.email, 512 | otp: "123456", 513 | }); 514 | expect(res.data?.status).toBe(true); 515 | }); 516 | }); 517 | 518 | describe("custom storeOTP", async () => { 519 | // Testing hashed OTPs. 520 | describe("hashed", async () => { 521 | let sendVerificationOtpFn = async (data: { 522 | email: string; 523 | otp: string; 524 | type: "sign-in" | "email-verification" | "forget-password"; 525 | }) => {}; 526 | 527 | function getTheSentOTP() { 528 | let gotOtp: string | null = null; 529 | let sub = (otp: string) => {}; 530 | sendVerificationOtpFn = async (data) => { 531 | gotOtp = data.otp; 532 | sub(data.otp); 533 | }; 534 | return { 535 | get: () => 536 | new Promise<string>((resolve) => { 537 | if (gotOtp) { 538 | resolve(gotOtp); 539 | } else { 540 | sub = (otp) => { 541 | gotOtp = otp; 542 | resolve(otp); 543 | }; 544 | } 545 | }), 546 | }; 547 | } 548 | 549 | const { client, testUser, auth } = await getTestInstance( 550 | { 551 | plugins: [ 552 | emailOTP({ 553 | sendVerificationOTP: async (d) => { 554 | await sendVerificationOtpFn(d); 555 | }, 556 | storeOTP: "hashed", 557 | }), 558 | ], 559 | }, 560 | { 561 | clientOptions: { 562 | plugins: [emailOTPClient()], 563 | }, 564 | }, 565 | ); 566 | const authCtx = await auth.$context; 567 | const userEmail1 = `${crypto.randomUUID()}@email.com`; 568 | 569 | let validOTP = ""; 570 | 571 | it("should create a hashed otp", async () => { 572 | const { get } = getTheSentOTP(); 573 | await client.emailOtp.sendVerificationOtp({ 574 | email: userEmail1, 575 | type: "sign-in", 576 | }); 577 | const verificationValue = 578 | await authCtx.internalAdapter.findVerificationValue( 579 | `sign-in-otp-${userEmail1}`, 580 | ); 581 | 582 | const storedOtp = verificationValue?.value || ""; 583 | const otp = await get(); 584 | validOTP = otp; 585 | expect(storedOtp.length !== 0).toBe(true); 586 | expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp); 587 | expect(storedOtp.endsWith(":0")).toBe(true); 588 | }); 589 | 590 | it("should not be allowed to get otp if storeOTP is hashed", async () => { 591 | try { 592 | await auth.api.getVerificationOTP({ 593 | query: { 594 | email: userEmail1, 595 | type: "sign-in", 596 | }, 597 | }); 598 | } catch (error: any) { 599 | expect(error.statusCode).toBe(400); 600 | expect(error.status).toBe("BAD_REQUEST"); 601 | expect(error.body.code).toBe( 602 | "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP", 603 | ); 604 | return; 605 | } 606 | // Should not reach here given the above should throw and thus return. 607 | expect(true).toBe(false); 608 | }); 609 | 610 | it("should be able to sign in with normal otp", async () => { 611 | const res = await client.signIn.emailOtp({ 612 | email: userEmail1, 613 | otp: validOTP, 614 | }); 615 | expect(res.data?.user.email).toBe(userEmail1); 616 | expect(res.data?.token).toBeDefined(); 617 | }); 618 | }); 619 | 620 | // Testing encrypted OTPs. 621 | describe("encrypted", async () => { 622 | let sendVerificationOtpFn = async (data: { 623 | email: string; 624 | otp: string; 625 | type: "sign-in" | "email-verification" | "forget-password"; 626 | }) => {}; 627 | 628 | function getTheSentOTP() { 629 | let gotOtp: string | null = null; 630 | let sub = (otp: string) => {}; 631 | sendVerificationOtpFn = async (data) => { 632 | gotOtp = data.otp; 633 | sub(data.otp); 634 | }; 635 | return { 636 | get: () => 637 | new Promise<string>((resolve) => { 638 | if (gotOtp) { 639 | resolve(gotOtp); 640 | } else { 641 | sub = (otp) => { 642 | gotOtp = otp; 643 | resolve(otp); 644 | }; 645 | } 646 | }), 647 | }; 648 | } 649 | 650 | const { client, testUser, auth } = await getTestInstance( 651 | { 652 | plugins: [ 653 | emailOTP({ 654 | sendVerificationOTP: async (d) => { 655 | await sendVerificationOtpFn(d); 656 | }, 657 | storeOTP: "encrypted", 658 | }), 659 | ], 660 | }, 661 | { 662 | clientOptions: { 663 | plugins: [emailOTPClient()], 664 | }, 665 | }, 666 | ); 667 | const authCtx = await auth.$context; 668 | const userEmail1 = `${crypto.randomUUID()}@email.com`; 669 | 670 | let encryptedOtp = ""; 671 | let validOTP = ""; 672 | 673 | it("should create an encrypted otp", async () => { 674 | const { get } = getTheSentOTP(); 675 | await client.emailOtp.sendVerificationOtp({ 676 | email: userEmail1, 677 | type: "sign-in", 678 | }); 679 | const verificationValue = 680 | await authCtx.internalAdapter.findVerificationValue( 681 | `sign-in-otp-${userEmail1}`, 682 | ); 683 | 684 | const storedOtp = verificationValue?.value || ""; 685 | const otp = await get(); 686 | expect(storedOtp.length !== 0).toBe(true); 687 | expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp); 688 | expect(storedOtp.endsWith(":0")).toBe(true); 689 | encryptedOtp = storedOtp; 690 | validOTP = otp; 691 | }); 692 | 693 | it("should be allowed to get otp if storeOTP is encrypted", async () => { 694 | try { 695 | const res = await auth.api.getVerificationOTP({ 696 | query: { 697 | email: userEmail1, 698 | type: "sign-in", 699 | }, 700 | }); 701 | if (!res.otp) { 702 | expect(true).toBe(false); 703 | return; 704 | } 705 | expect(res.otp).toEqual(validOTP); 706 | expect(res.otp.length).toBe(6); 707 | } catch (error: any) { 708 | expect(error).not.toBeDefined(); 709 | } 710 | }); 711 | 712 | it("should be able to sign in with encrypted otp", async () => { 713 | const res = await client.signIn.emailOtp({ 714 | email: userEmail1, 715 | otp: validOTP, 716 | }); 717 | expect(res.data?.user.email).toBe(userEmail1); 718 | expect(res.data?.token).toBeDefined(); 719 | }); 720 | }); 721 | 722 | describe("custom encryptor", async () => { 723 | let sendVerificationOtpFn = async (data: { 724 | email: string; 725 | otp: string; 726 | type: "sign-in" | "email-verification" | "forget-password"; 727 | }) => {}; 728 | 729 | function getTheSentOTP() { 730 | let gotOtp: string | null = null; 731 | let sub = (otp: string) => {}; 732 | sendVerificationOtpFn = async (data) => { 733 | gotOtp = data.otp; 734 | sub(data.otp); 735 | }; 736 | return { 737 | get: () => 738 | new Promise<string>((resolve) => { 739 | if (gotOtp) { 740 | resolve(gotOtp); 741 | } else { 742 | sub = (otp) => { 743 | gotOtp = otp; 744 | resolve(otp); 745 | }; 746 | } 747 | }), 748 | }; 749 | } 750 | 751 | const { client, testUser, auth } = await getTestInstance( 752 | { 753 | plugins: [ 754 | emailOTP({ 755 | sendVerificationOTP: async (d) => { 756 | await sendVerificationOtpFn(d); 757 | }, 758 | storeOTP: { 759 | encrypt: async (otp) => { 760 | return otp + "encrypted"; 761 | }, 762 | decrypt: async (otp) => { 763 | return otp.replace("encrypted", ""); 764 | }, 765 | }, 766 | }), 767 | ], 768 | }, 769 | { 770 | clientOptions: { 771 | plugins: [emailOTPClient()], 772 | }, 773 | }, 774 | ); 775 | const authCtx = await auth.$context; 776 | 777 | let validOTP = ""; 778 | let userEmail1 = `${crypto.randomUUID()}@email.com`; 779 | 780 | it("should create a custom encryptor otp", async () => { 781 | const { get } = getTheSentOTP(); 782 | await client.emailOtp.sendVerificationOtp({ 783 | email: userEmail1, 784 | type: "sign-in", 785 | }); 786 | const verificationValue = 787 | await authCtx.internalAdapter.findVerificationValue( 788 | `sign-in-otp-${userEmail1}`, 789 | ); 790 | const storedOtp = verificationValue?.value || ""; 791 | const otp = await get(); 792 | expect(storedOtp.length !== 0).toBe(true); 793 | expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp); 794 | expect(storedOtp.endsWith(":0")).toBe(true); 795 | validOTP = otp; 796 | }); 797 | 798 | it("should be allowed to get otp if storeOTP is custom encryptor", async () => { 799 | try { 800 | const res = await auth.api.getVerificationOTP({ 801 | query: { 802 | email: userEmail1, 803 | type: "sign-in", 804 | }, 805 | }); 806 | if (!res.otp) { 807 | expect(true).toBe(false); 808 | return; 809 | } 810 | expect(res.otp).toEqual(validOTP); 811 | expect(res.otp.length).toBe(6); 812 | } catch (error: any) { 813 | console.error(error); 814 | expect(error).not.toBeDefined(); 815 | } 816 | }); 817 | 818 | it("should be able to sign in with custom encryptor otp", async () => { 819 | const res = await client.signIn.emailOtp({ 820 | email: userEmail1, 821 | otp: validOTP, 822 | }); 823 | expect(res.data?.user.email).toBe(userEmail1); 824 | expect(res.data?.token).toBeDefined(); 825 | }); 826 | }); 827 | 828 | describe("custom hasher", async () => { 829 | let sendVerificationOtpFn = async (data: { 830 | email: string; 831 | otp: string; 832 | type: "sign-in" | "email-verification" | "forget-password"; 833 | }) => {}; 834 | 835 | function getTheSentOTP() { 836 | let gotOtp: string | null = null; 837 | let sub = (otp: string) => {}; 838 | sendVerificationOtpFn = async (data) => { 839 | gotOtp = data.otp; 840 | sub(data.otp); 841 | }; 842 | return { 843 | get: () => 844 | new Promise<string>((resolve) => { 845 | if (gotOtp) { 846 | resolve(gotOtp); 847 | } else { 848 | sub = (otp) => { 849 | gotOtp = otp; 850 | resolve(otp); 851 | }; 852 | } 853 | }), 854 | }; 855 | } 856 | 857 | const { client, testUser, auth } = await getTestInstance( 858 | { 859 | plugins: [ 860 | emailOTP({ 861 | sendVerificationOTP: async (d) => { 862 | await sendVerificationOtpFn(d); 863 | }, 864 | storeOTP: { 865 | hash: async (otp) => { 866 | return otp + "hashed"; 867 | }, 868 | }, 869 | }), 870 | ], 871 | }, 872 | { 873 | clientOptions: { 874 | plugins: [emailOTPClient()], 875 | }, 876 | }, 877 | ); 878 | const authCtx = await auth.$context; 879 | 880 | let validOTP = ""; 881 | let userEmail1 = `${crypto.randomUUID()}@email.com`; 882 | 883 | it("should create a custom hasher otp", async () => { 884 | const { get } = getTheSentOTP(); 885 | await client.emailOtp.sendVerificationOtp({ 886 | email: userEmail1, 887 | type: "sign-in", 888 | }); 889 | const verificationValue = 890 | await authCtx.internalAdapter.findVerificationValue( 891 | `sign-in-otp-${userEmail1}`, 892 | ); 893 | const storedOtp = verificationValue?.value || ""; 894 | const otp = await get(); 895 | expect(storedOtp.length !== 0).toBe(true); 896 | expect(splitAtLastColon(storedOtp)[0]).not.toBe(otp); 897 | expect(storedOtp.endsWith(":0")).toBe(true); 898 | validOTP = otp; 899 | }); 900 | 901 | it("should be allowed to get otp if storeOTP is custom hasher", async () => { 902 | try { 903 | const result = await auth.api.getVerificationOTP({ 904 | query: { 905 | email: userEmail1, 906 | type: "sign-in", 907 | }, 908 | }); 909 | } catch (error: any) { 910 | expect(error.statusCode).toBe(400); 911 | expect(error.status).toBe("BAD_REQUEST"); 912 | expect(error.body.code).toBe( 913 | "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP", 914 | ); 915 | return; 916 | } 917 | // Should not reach here given the above should throw and thus return. 918 | expect(true).toBe(false); 919 | }); 920 | 921 | it("should be able to sign in with custom hasher otp", async () => { 922 | const res = await client.signIn.emailOtp({ 923 | email: userEmail1, 924 | otp: validOTP, 925 | }); 926 | expect(res.data?.user.email).toBe(userEmail1); 927 | expect(res.data?.token).toBeDefined(); 928 | }); 929 | }); 930 | }); 931 | 932 | describe("override default email verification", async () => { 933 | let otp = ""; 934 | const { cookieSetter, customFetchImpl } = await getTestInstance({ 935 | emailAndPassword: { 936 | enabled: true, 937 | }, 938 | emailVerification: { 939 | sendOnSignUp: true, 940 | }, 941 | plugins: [ 942 | emailOTP({ 943 | async sendVerificationOTP(data, request) { 944 | otp = data.otp; 945 | }, 946 | overrideDefaultEmailVerification: true, 947 | }), 948 | ], 949 | }); 950 | 951 | const client = createAuthClient({ 952 | plugins: [emailOTPClient()], 953 | baseURL: "http://localhost:3000", 954 | fetchOptions: { 955 | customFetchImpl, 956 | }, 957 | }); 958 | 959 | const headers = new Headers(); 960 | it("should send verification email on sign up", async () => { 961 | await client.signUp.email( 962 | { 963 | email: "[email protected]", 964 | password: "password", 965 | name: "Test User", 966 | }, 967 | { 968 | onSuccess: cookieSetter(headers), 969 | }, 970 | ); 971 | expect(otp.length).toBe(6); 972 | }); 973 | 974 | it("should verify email with otp", async () => { 975 | const res = await client.emailOtp.verifyEmail({ 976 | email: "[email protected]", 977 | otp, 978 | }); 979 | expect(res.data?.status).toBe(true); 980 | expect(res.data?.user.emailVerified).toBe(true); 981 | }); 982 | 983 | it("should by default not override default email verification", async () => { 984 | const sendVerificationOTP = vi.fn(); 985 | const { client } = await getTestInstance({ 986 | emailAndPassword: { 987 | enabled: true, 988 | }, 989 | emailVerification: { 990 | sendOnSignUp: true, 991 | async sendVerificationEmail(data, request) { 992 | sendVerificationOTP(data, request); 993 | }, 994 | }, 995 | plugins: [ 996 | emailOTP({ 997 | async sendVerificationOTP(data, request) { 998 | // 999 | }, 1000 | }), 1001 | ], 1002 | }); 1003 | await client.signUp.email( 1004 | { 1005 | email: "[email protected]", 1006 | password: "password", 1007 | name: "Test User", 1008 | }, 1009 | { 1010 | onSuccess: cookieSetter(headers), 1011 | }, 1012 | ); 1013 | expect(sendVerificationOTP).toHaveBeenCalled(); 1014 | }); 1015 | }); 1016 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/device-authorization/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { APIError } from "better-call"; 3 | import { createAuthEndpoint } from "@better-auth/core/middleware"; 4 | import type { InferOptionSchema } from "../../types/plugins"; 5 | import type { BetterAuthPlugin } from "@better-auth/core"; 6 | import { generateRandomString } from "../../crypto"; 7 | import { getSessionFromCtx } from "../../api/routes/session"; 8 | import { ms, type StringValue as MSStringValue } from "ms"; 9 | import { schema, type DeviceCode } from "./schema"; 10 | import { mergeSchema } from "../../db"; 11 | import { defineErrorCodes } from "@better-auth/core/utils"; 12 | 13 | const msStringValueSchema = z.custom<MSStringValue>( 14 | (val) => { 15 | try { 16 | ms(val as MSStringValue); 17 | } catch (e) { 18 | return false; 19 | } 20 | return true; 21 | }, 22 | { 23 | message: 24 | "Invalid time string format. Use formats like '30m', '5s', '1h', etc.", 25 | }, 26 | ); 27 | 28 | export const $deviceAuthorizationOptionsSchema = z.object({ 29 | expiresIn: msStringValueSchema 30 | .default("30m") 31 | .describe( 32 | "Time in seconds until the device code expires. Use formats like '30m', '5s', '1h', etc.", 33 | ), 34 | interval: msStringValueSchema 35 | .default("5s") 36 | .describe( 37 | "Time in seconds between polling attempts. Use formats like '30m', '5s', '1h', etc.", 38 | ), 39 | deviceCodeLength: z 40 | .number() 41 | .int() 42 | .positive() 43 | .default(40) 44 | .describe( 45 | "Length of the device code to be generated. Default is 40 characters.", 46 | ), 47 | userCodeLength: z 48 | .number() 49 | .int() 50 | .positive() 51 | .default(8) 52 | .describe( 53 | "Length of the user code to be generated. Default is 8 characters.", 54 | ), 55 | generateDeviceCode: z 56 | .custom<() => string | Promise<string>>( 57 | (val) => typeof val === "function", 58 | { 59 | message: 60 | "generateDeviceCode must be a function that returns a string or a promise that resolves to a string.", 61 | }, 62 | ) 63 | .optional() 64 | .describe( 65 | "Function to generate a device code. If not provided, a default random string generator will be used.", 66 | ), 67 | generateUserCode: z 68 | .custom<() => string | Promise<string>>( 69 | (val) => typeof val === "function", 70 | { 71 | message: 72 | "generateUserCode must be a function that returns a string or a promise that resolves to a string.", 73 | }, 74 | ) 75 | .optional() 76 | .describe( 77 | "Function to generate a user code. If not provided, a default random string generator will be used.", 78 | ), 79 | validateClient: z 80 | .custom<(clientId: string) => boolean | Promise<boolean>>( 81 | (val) => typeof val === "function", 82 | { 83 | message: 84 | "validateClient must be a function that returns a boolean or a promise that resolves to a boolean.", 85 | }, 86 | ) 87 | .optional() 88 | .describe( 89 | "Function to validate the client ID. If not provided, no validation will be performed.", 90 | ), 91 | onDeviceAuthRequest: z 92 | .custom< 93 | (clientId: string, scope: string | undefined) => void | Promise<void> 94 | >((val) => typeof val === "function", { 95 | message: 96 | "onDeviceAuthRequest must be a function that returns void or a promise that resolves to void.", 97 | }) 98 | .optional() 99 | .describe( 100 | "Function to handle device authorization requests. If not provided, no additional actions will be taken.", 101 | ), 102 | schema: z.custom<InferOptionSchema<typeof schema>>(() => true), 103 | }); 104 | 105 | /** 106 | * @see {$deviceAuthorizationOptionsSchema} 107 | */ 108 | export type DeviceAuthorizationOptions = { 109 | expiresIn: MSStringValue; 110 | interval: MSStringValue; 111 | deviceCodeLength: number; 112 | userCodeLength: number; 113 | generateDeviceCode?: () => string | Promise<string>; 114 | generateUserCode?: () => string | Promise<string>; 115 | validateClient?: (clientId: string) => boolean | Promise<boolean>; 116 | onDeviceAuthRequest?: ( 117 | clientId: string, 118 | scope: string | undefined, 119 | ) => void | Promise<void>; 120 | schema?: InferOptionSchema<typeof schema>; 121 | }; 122 | 123 | export { deviceAuthorizationClient } from "./client"; 124 | 125 | const DEVICE_AUTHORIZATION_ERROR_CODES = defineErrorCodes({ 126 | INVALID_DEVICE_CODE: "Invalid device code", 127 | EXPIRED_DEVICE_CODE: "Device code has expired", 128 | EXPIRED_USER_CODE: "User code has expired", 129 | AUTHORIZATION_PENDING: "Authorization pending", 130 | ACCESS_DENIED: "Access denied", 131 | INVALID_USER_CODE: "Invalid user code", 132 | DEVICE_CODE_ALREADY_PROCESSED: "Device code already processed", 133 | POLLING_TOO_FREQUENTLY: "Polling too frequently", 134 | USER_NOT_FOUND: "User not found", 135 | FAILED_TO_CREATE_SESSION: "Failed to create session", 136 | INVALID_DEVICE_CODE_STATUS: "Invalid device code status", 137 | AUTHENTICATION_REQUIRED: "Authentication required", 138 | }); 139 | 140 | const defaultCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; 141 | 142 | /** 143 | * @internal 144 | */ 145 | const defaultGenerateDeviceCode = (length: number) => { 146 | return generateRandomString(length, "a-z", "A-Z", "0-9"); 147 | }; 148 | 149 | /** 150 | * @internal 151 | */ 152 | const defaultGenerateUserCode = (length: number) => { 153 | const chars = new Uint8Array(length); 154 | return Array.from(crypto.getRandomValues(chars)) 155 | .map((byte) => defaultCharset[byte % defaultCharset.length]) 156 | .join(""); 157 | }; 158 | 159 | export const deviceAuthorization = ( 160 | options: Partial<DeviceAuthorizationOptions> = {}, 161 | ) => { 162 | const opts = $deviceAuthorizationOptionsSchema.parse(options); 163 | const generateDeviceCode = async () => { 164 | if (opts.generateDeviceCode) { 165 | return opts.generateDeviceCode(); 166 | } 167 | return defaultGenerateDeviceCode(opts.deviceCodeLength); 168 | }; 169 | 170 | const generateUserCode = async () => { 171 | if (opts.generateUserCode) { 172 | return opts.generateUserCode(); 173 | } 174 | return defaultGenerateUserCode(opts.userCodeLength); 175 | }; 176 | 177 | return { 178 | id: "device-authorization", 179 | schema: mergeSchema(schema, options?.schema), 180 | endpoints: { 181 | deviceCode: createAuthEndpoint( 182 | "/device/code", 183 | { 184 | method: "POST", 185 | body: z.object({ 186 | client_id: z.string().meta({ 187 | description: "The client ID of the application", 188 | }), 189 | scope: z 190 | .string() 191 | .meta({ 192 | description: "Space-separated list of scopes", 193 | }) 194 | .optional(), 195 | }), 196 | error: z.object({ 197 | error: z.enum(["invalid_request", "invalid_client"]).meta({ 198 | description: "Error code", 199 | }), 200 | error_description: z.string().meta({ 201 | description: "Detailed error description", 202 | }), 203 | }), 204 | metadata: { 205 | openapi: { 206 | description: `Request a device and user code 207 | 208 | Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#section-3.2)`, 209 | responses: { 210 | 200: { 211 | description: "Success", 212 | content: { 213 | "application/json": { 214 | schema: { 215 | type: "object", 216 | properties: { 217 | device_code: { 218 | type: "string", 219 | description: "The device verification code", 220 | }, 221 | user_code: { 222 | type: "string", 223 | description: "The user code to display", 224 | }, 225 | verification_uri: { 226 | type: "string", 227 | description: "The URL for user verification", 228 | }, 229 | verification_uri_complete: { 230 | type: "string", 231 | description: "The complete URL with user code", 232 | }, 233 | expires_in: { 234 | type: "number", 235 | description: 236 | "Lifetime in seconds of the device code", 237 | }, 238 | interval: { 239 | type: "number", 240 | description: "Minimum polling interval in seconds", 241 | }, 242 | }, 243 | }, 244 | }, 245 | }, 246 | }, 247 | 400: { 248 | description: "Error response", 249 | content: { 250 | "application/json": { 251 | schema: { 252 | type: "object", 253 | properties: { 254 | error: { 255 | type: "string", 256 | enum: ["invalid_request", "invalid_client"], 257 | }, 258 | error_description: { 259 | type: "string", 260 | }, 261 | }, 262 | }, 263 | }, 264 | }, 265 | }, 266 | }, 267 | }, 268 | }, 269 | }, 270 | async (ctx) => { 271 | if (opts.validateClient) { 272 | const isValid = await opts.validateClient(ctx.body.client_id); 273 | if (!isValid) { 274 | throw new APIError("BAD_REQUEST", { 275 | error: "invalid_client", 276 | error_description: "Invalid client ID", 277 | }); 278 | } 279 | } 280 | 281 | if (opts.onDeviceAuthRequest) { 282 | await opts.onDeviceAuthRequest(ctx.body.client_id, ctx.body.scope); 283 | } 284 | 285 | const deviceCode = await generateDeviceCode(); 286 | const userCode = await generateUserCode(); 287 | const expiresIn = ms(opts.expiresIn); 288 | const expiresAt = new Date(Date.now() + expiresIn); 289 | 290 | await ctx.context.adapter.create({ 291 | model: "deviceCode", 292 | data: { 293 | deviceCode, 294 | userCode, 295 | expiresAt, 296 | status: "pending", 297 | pollingInterval: ms(opts.interval), 298 | clientId: ctx.body.client_id, 299 | scope: ctx.body.scope, 300 | }, 301 | }); 302 | 303 | const baseURL = new URL(ctx.context.baseURL); 304 | const verification_uri = new URL("/device", baseURL); 305 | 306 | const verification_uri_complete = new URL(verification_uri); 307 | verification_uri_complete.searchParams.set( 308 | "user_code", 309 | // should we support custom formatting function here? 310 | encodeURIComponent(userCode), 311 | ); 312 | 313 | return ctx.json( 314 | { 315 | device_code: deviceCode, 316 | user_code: userCode, 317 | verification_uri: verification_uri.toString(), 318 | verification_uri_complete: verification_uri_complete.toString(), 319 | expires_in: Math.floor(expiresIn / 1000), 320 | interval: Math.floor(ms(opts.interval) / 1000), 321 | }, 322 | { 323 | headers: { 324 | "Cache-Control": "no-store", 325 | }, 326 | }, 327 | ); 328 | }, 329 | ), 330 | deviceToken: createAuthEndpoint( 331 | "/device/token", 332 | { 333 | method: "POST", 334 | body: z.object({ 335 | grant_type: z 336 | .literal("urn:ietf:params:oauth:grant-type:device_code") 337 | .meta({ 338 | description: "The grant type for device flow", 339 | }), 340 | device_code: z.string().meta({ 341 | description: "The device verification code", 342 | }), 343 | client_id: z.string().meta({ 344 | description: "The client ID of the application", 345 | }), 346 | }), 347 | error: z.object({ 348 | error: z 349 | .enum([ 350 | "authorization_pending", 351 | "slow_down", 352 | "expired_token", 353 | "access_denied", 354 | "invalid_request", 355 | "invalid_grant", 356 | ]) 357 | .meta({ 358 | description: "Error code", 359 | }), 360 | error_description: z.string().meta({ 361 | description: "Detailed error description", 362 | }), 363 | }), 364 | metadata: { 365 | openapi: { 366 | description: `Exchange device code for access token 367 | 368 | Follow [rfc8628#section-3.4](https://datatracker.ietf.org/doc/html/rfc8628#section-3.4)`, 369 | responses: { 370 | 200: { 371 | description: "Success", 372 | content: { 373 | "application/json": { 374 | schema: { 375 | type: "object", 376 | properties: { 377 | session: { 378 | $ref: "#/components/schemas/Session", 379 | }, 380 | user: { 381 | $ref: "#/components/schemas/User", 382 | }, 383 | }, 384 | }, 385 | }, 386 | }, 387 | }, 388 | 400: { 389 | description: "Error response", 390 | content: { 391 | "application/json": { 392 | schema: { 393 | type: "object", 394 | properties: { 395 | error: { 396 | type: "string", 397 | enum: [ 398 | "authorization_pending", 399 | "slow_down", 400 | "expired_token", 401 | "access_denied", 402 | "invalid_request", 403 | "invalid_grant", 404 | ], 405 | }, 406 | error_description: { 407 | type: "string", 408 | }, 409 | }, 410 | }, 411 | }, 412 | }, 413 | }, 414 | }, 415 | }, 416 | }, 417 | }, 418 | async (ctx) => { 419 | const { device_code, client_id } = ctx.body; 420 | 421 | if (opts.validateClient) { 422 | const isValid = await opts.validateClient(client_id); 423 | if (!isValid) { 424 | throw new APIError("BAD_REQUEST", { 425 | error: "invalid_grant", 426 | error_description: "Invalid client ID", 427 | }); 428 | } 429 | } 430 | 431 | const deviceCodeRecord = await ctx.context.adapter.findOne<{ 432 | id: string; 433 | deviceCode: string; 434 | userCode: string; 435 | userId?: string; 436 | expiresAt: Date; 437 | status: string; 438 | lastPolledAt?: Date; 439 | pollingInterval?: number; 440 | clientId?: string; 441 | scope?: string; 442 | }>({ 443 | model: "deviceCode", 444 | where: [ 445 | { 446 | field: "deviceCode", 447 | value: device_code, 448 | }, 449 | ], 450 | }); 451 | 452 | if (!deviceCodeRecord) { 453 | throw new APIError("BAD_REQUEST", { 454 | error: "invalid_grant", 455 | error_description: 456 | DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE, 457 | }); 458 | } 459 | 460 | if ( 461 | deviceCodeRecord.clientId && 462 | deviceCodeRecord.clientId !== client_id 463 | ) { 464 | throw new APIError("BAD_REQUEST", { 465 | error: "invalid_grant", 466 | error_description: "Client ID mismatch", 467 | }); 468 | } 469 | 470 | // Check for rate limiting 471 | if ( 472 | deviceCodeRecord.lastPolledAt && 473 | deviceCodeRecord.pollingInterval 474 | ) { 475 | const timeSinceLastPoll = 476 | Date.now() - new Date(deviceCodeRecord.lastPolledAt).getTime(); 477 | const minInterval = deviceCodeRecord.pollingInterval; 478 | 479 | if (timeSinceLastPoll < minInterval) { 480 | throw new APIError("BAD_REQUEST", { 481 | error: "slow_down", 482 | error_description: 483 | DEVICE_AUTHORIZATION_ERROR_CODES.POLLING_TOO_FREQUENTLY, 484 | }); 485 | } 486 | } 487 | 488 | // Update last polled time 489 | await ctx.context.adapter.update({ 490 | model: "deviceCode", 491 | where: [ 492 | { 493 | field: "id", 494 | value: deviceCodeRecord.id, 495 | }, 496 | ], 497 | update: { 498 | lastPolledAt: new Date(), 499 | }, 500 | }); 501 | 502 | if (deviceCodeRecord.expiresAt < new Date()) { 503 | await ctx.context.adapter.delete({ 504 | model: "deviceCode", 505 | where: [ 506 | { 507 | field: "id", 508 | value: deviceCodeRecord.id, 509 | }, 510 | ], 511 | }); 512 | throw new APIError("BAD_REQUEST", { 513 | error: "expired_token", 514 | error_description: 515 | DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_DEVICE_CODE, 516 | }); 517 | } 518 | 519 | if (deviceCodeRecord.status === "pending") { 520 | throw new APIError("BAD_REQUEST", { 521 | error: "authorization_pending", 522 | error_description: 523 | DEVICE_AUTHORIZATION_ERROR_CODES.AUTHORIZATION_PENDING, 524 | }); 525 | } 526 | 527 | if (deviceCodeRecord.status === "denied") { 528 | await ctx.context.adapter.delete({ 529 | model: "deviceCode", 530 | where: [ 531 | { 532 | field: "id", 533 | value: deviceCodeRecord.id, 534 | }, 535 | ], 536 | }); 537 | throw new APIError("BAD_REQUEST", { 538 | error: "access_denied", 539 | error_description: DEVICE_AUTHORIZATION_ERROR_CODES.ACCESS_DENIED, 540 | }); 541 | } 542 | 543 | if ( 544 | deviceCodeRecord.status === "approved" && 545 | deviceCodeRecord.userId 546 | ) { 547 | // Delete the device code after successful authorization 548 | await ctx.context.adapter.delete({ 549 | model: "deviceCode", 550 | where: [ 551 | { 552 | field: "id", 553 | value: deviceCodeRecord.id, 554 | }, 555 | ], 556 | }); 557 | 558 | const user = await ctx.context.internalAdapter.findUserById( 559 | deviceCodeRecord.userId, 560 | ); 561 | 562 | if (!user) { 563 | throw new APIError("INTERNAL_SERVER_ERROR", { 564 | error: "server_error", 565 | error_description: 566 | DEVICE_AUTHORIZATION_ERROR_CODES.USER_NOT_FOUND, 567 | }); 568 | } 569 | 570 | const session = await ctx.context.internalAdapter.createSession( 571 | user.id, 572 | ctx, 573 | ); 574 | 575 | if (!session) { 576 | throw new APIError("INTERNAL_SERVER_ERROR", { 577 | error: "server_error", 578 | error_description: 579 | DEVICE_AUTHORIZATION_ERROR_CODES.FAILED_TO_CREATE_SESSION, 580 | }); 581 | } 582 | 583 | // Set new session context for hooks and plugins 584 | // (matches setSessionCookie logic) 585 | ctx.context.setNewSession({ 586 | session, 587 | user, 588 | }); 589 | 590 | // If secondary storage is enabled, store the session data in the secondary storage 591 | // (matches setSessionCookie logic) 592 | if (ctx.context.options.secondaryStorage) { 593 | await ctx.context.secondaryStorage?.set( 594 | session.token, 595 | JSON.stringify({ 596 | user, 597 | session, 598 | }), 599 | Math.floor( 600 | (new Date(session.expiresAt).getTime() - Date.now()) / 1000, 601 | ), 602 | ); 603 | } 604 | 605 | // Return OAuth 2.0 compliant token response 606 | return ctx.json( 607 | { 608 | access_token: session.token, 609 | token_type: "Bearer", 610 | expires_in: Math.floor( 611 | (new Date(session.expiresAt).getTime() - Date.now()) / 1000, 612 | ), 613 | scope: deviceCodeRecord.scope || "", 614 | }, 615 | { 616 | headers: { 617 | "Cache-Control": "no-store", 618 | Pragma: "no-cache", 619 | }, 620 | }, 621 | ); 622 | } 623 | 624 | throw new APIError("INTERNAL_SERVER_ERROR", { 625 | error: "server_error", 626 | error_description: 627 | DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE_STATUS, 628 | }); 629 | }, 630 | ), 631 | deviceVerify: createAuthEndpoint( 632 | "/device", 633 | { 634 | method: "GET", 635 | query: z.object({ 636 | user_code: z.string().meta({ 637 | description: "The user code to verify", 638 | }), 639 | }), 640 | error: z.object({ 641 | error: z.enum(["invalid_request"]).meta({ 642 | description: "Error code", 643 | }), 644 | error_description: z.string().meta({ 645 | description: "Detailed error description", 646 | }), 647 | }), 648 | metadata: { 649 | openapi: { 650 | description: "Display device verification page", 651 | responses: { 652 | 200: { 653 | description: "Verification page HTML", 654 | content: { 655 | "application/json": { 656 | schema: { 657 | type: "object", 658 | properties: { 659 | user_code: { 660 | type: "string", 661 | description: "The user code to verify", 662 | }, 663 | status: { 664 | type: "string", 665 | enum: ["pending", "approved", "denied"], 666 | description: 667 | "Current status of the device authorization", 668 | }, 669 | }, 670 | }, 671 | }, 672 | }, 673 | }, 674 | }, 675 | }, 676 | }, 677 | }, 678 | async (ctx) => { 679 | // This endpoint would typically serve an HTML page for user verification 680 | // For now, we'll return a simple JSON response 681 | const { user_code } = ctx.query; 682 | const cleanUserCode = user_code.replace(/-/g, ""); 683 | 684 | const deviceCodeRecord = 685 | await ctx.context.adapter.findOne<DeviceCode>({ 686 | model: "deviceCode", 687 | where: [ 688 | { 689 | field: "userCode", 690 | value: cleanUserCode, 691 | }, 692 | ], 693 | }); 694 | 695 | if (!deviceCodeRecord) { 696 | throw new APIError("BAD_REQUEST", { 697 | error: "invalid_request", 698 | error_description: 699 | DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE, 700 | }); 701 | } 702 | 703 | if (deviceCodeRecord.expiresAt < new Date()) { 704 | throw new APIError("BAD_REQUEST", { 705 | error: "expired_token", 706 | error_description: 707 | DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE, 708 | }); 709 | } 710 | 711 | return ctx.json({ 712 | user_code: user_code, 713 | status: deviceCodeRecord.status, 714 | }); 715 | }, 716 | ), 717 | deviceApprove: createAuthEndpoint( 718 | "/device/approve", 719 | { 720 | method: "POST", 721 | body: z.object({ 722 | userCode: z.string().meta({ 723 | description: "The user code to approve", 724 | }), 725 | }), 726 | error: z.object({ 727 | error: z 728 | .enum([ 729 | "invalid_request", 730 | "expired_token", 731 | "device_code_already_processed", 732 | ]) 733 | .meta({ 734 | description: "Error code", 735 | }), 736 | error_description: z.string().meta({ 737 | description: "Detailed error description", 738 | }), 739 | }), 740 | requireHeaders: true, 741 | metadata: { 742 | openapi: { 743 | description: "Approve device authorization", 744 | responses: { 745 | 200: { 746 | description: "Success", 747 | content: { 748 | "application/json": { 749 | schema: { 750 | type: "object", 751 | properties: { 752 | success: { 753 | type: "boolean", 754 | }, 755 | }, 756 | }, 757 | }, 758 | }, 759 | }, 760 | }, 761 | }, 762 | }, 763 | }, 764 | async (ctx) => { 765 | const session = await getSessionFromCtx(ctx); 766 | if (!session) { 767 | throw new APIError("UNAUTHORIZED", { 768 | error: "unauthorized", 769 | error_description: 770 | DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED, 771 | }); 772 | } 773 | 774 | const { userCode } = ctx.body; 775 | 776 | const deviceCodeRecord = 777 | await ctx.context.adapter.findOne<DeviceCode>({ 778 | model: "deviceCode", 779 | where: [ 780 | { 781 | field: "userCode", 782 | value: userCode, 783 | }, 784 | ], 785 | }); 786 | 787 | if (!deviceCodeRecord) { 788 | throw new APIError("BAD_REQUEST", { 789 | error: "invalid_request", 790 | error_description: 791 | DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE, 792 | }); 793 | } 794 | 795 | if (deviceCodeRecord.expiresAt < new Date()) { 796 | throw new APIError("BAD_REQUEST", { 797 | error: "expired_token", 798 | error_description: 799 | DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE, 800 | }); 801 | } 802 | 803 | if (deviceCodeRecord.status !== "pending") { 804 | throw new APIError("BAD_REQUEST", { 805 | error: "invalid_request", 806 | error_description: 807 | DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED, 808 | }); 809 | } 810 | 811 | // Update device code with approved status and user ID 812 | await ctx.context.adapter.update({ 813 | model: "deviceCode", 814 | where: [ 815 | { 816 | field: "id", 817 | value: deviceCodeRecord.id, 818 | }, 819 | ], 820 | update: { 821 | status: "approved", 822 | userId: session.user.id, 823 | }, 824 | }); 825 | 826 | return ctx.json({ 827 | success: true, 828 | }); 829 | }, 830 | ), 831 | deviceDeny: createAuthEndpoint( 832 | "/device/deny", 833 | { 834 | method: "POST", 835 | body: z.object({ 836 | userCode: z.string().meta({ 837 | description: "The user code to deny", 838 | }), 839 | }), 840 | error: z.object({ 841 | error: z.enum(["invalid_request", "expired_token"]).meta({ 842 | description: "Error code", 843 | }), 844 | error_description: z.string().meta({ 845 | description: "Detailed error description", 846 | }), 847 | }), 848 | metadata: { 849 | openapi: { 850 | description: "Deny device authorization", 851 | responses: { 852 | 200: { 853 | description: "Success", 854 | content: { 855 | "application/json": { 856 | schema: { 857 | type: "object", 858 | properties: { 859 | success: { 860 | type: "boolean", 861 | }, 862 | }, 863 | }, 864 | }, 865 | }, 866 | }, 867 | }, 868 | }, 869 | }, 870 | }, 871 | async (ctx) => { 872 | const { userCode } = ctx.body; 873 | const cleanUserCode = userCode.replace(/-/g, ""); 874 | 875 | const deviceCodeRecord = 876 | await ctx.context.adapter.findOne<DeviceCode>({ 877 | model: "deviceCode", 878 | where: [ 879 | { 880 | field: "userCode", 881 | value: cleanUserCode, 882 | }, 883 | ], 884 | }); 885 | 886 | if (!deviceCodeRecord) { 887 | throw new APIError("BAD_REQUEST", { 888 | error: "invalid_request", 889 | error_description: 890 | DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE, 891 | }); 892 | } 893 | 894 | if (deviceCodeRecord.expiresAt < new Date()) { 895 | throw new APIError("BAD_REQUEST", { 896 | error: "expired_token", 897 | error_description: 898 | DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE, 899 | }); 900 | } 901 | 902 | if (deviceCodeRecord.status !== "pending") { 903 | throw new APIError("BAD_REQUEST", { 904 | error: "invalid_request", 905 | error_description: 906 | DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED, 907 | }); 908 | } 909 | 910 | // Update device code with denied status 911 | await ctx.context.adapter.update({ 912 | model: "deviceCode", 913 | where: [ 914 | { 915 | field: "id", 916 | value: deviceCodeRecord.id, 917 | }, 918 | ], 919 | update: { 920 | status: "denied", 921 | }, 922 | }); 923 | 924 | return ctx.json({ 925 | success: true, 926 | }); 927 | }, 928 | ), 929 | }, 930 | $ERROR_CODES: DEVICE_AUTHORIZATION_ERROR_CODES, 931 | } satisfies BetterAuthPlugin; 932 | }; 933 | ``` -------------------------------------------------------------------------------- /docs/content/docs/guides/auth0-migration-guide.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Migrating from Auth0 to Better Auth 3 | description: A step-by-step guide to transitioning from Auth0 to Better Auth. 4 | --- 5 | 6 | In this guide, we'll walk through the steps to migrate a project from Auth0 to Better Auth — including email/password with proper hashing, social/external accounts, two-factor authentication, and more. 7 | 8 | <Callout type="warn"> 9 | This migration will invalidate all active sessions. This guide doesn't currently show you how to migrate Organizations but it should be possible with additional steps and the [Organization](/docs/plugins/organization) Plugin. 10 | </Callout> 11 | 12 | ## Before You Begin 13 | 14 | Before starting the migration process, set up Better Auth in your project. Follow the [installation guide](/docs/installation) to get started. 15 | 16 | <Steps> 17 | <Step> 18 | ### Connect to your database 19 | 20 | You'll need to connect to your database to migrate the users and accounts. You can use any database you want, but for this example, we'll use PostgreSQL. 21 | 22 | ```package-install 23 | npm install pg 24 | ``` 25 | 26 | And then you can use the following code to connect to your database. 27 | 28 | ```ts title="auth.ts" 29 | import { Pool } from "pg"; 30 | 31 | export const auth = betterAuth({ 32 | database: new Pool({ 33 | connectionString: process.env.DATABASE_URL 34 | }), 35 | }) 36 | ``` 37 | </Step> 38 | <Step> 39 | ### Enable Email and Password (Optional) 40 | 41 | Enable the email and password in your auth config and implement your own logic for sending verification emails, reset password emails, etc. 42 | 43 | ```ts title="auth.ts" 44 | import { betterAuth } from "better-auth"; 45 | 46 | export const auth = betterAuth({ 47 | database: new Pool({ 48 | connectionString: process.env.DATABASE_URL 49 | }), 50 | emailAndPassword: { // [!code highlight] 51 | enabled: true, // [!code highlight] 52 | }, // [!code highlight] 53 | emailVerification: { 54 | sendVerificationEmail: async({ user, url })=>{ 55 | // implement your logic here to send email verification 56 | } 57 | }, 58 | }) 59 | ``` 60 | 61 | See [Email and Password](/docs/authentication/email-password) for more configuration options. 62 | </Step> 63 | <Step> 64 | ### Setup Social Providers (Optional) 65 | 66 | Add social providers you have enabled in your Auth0 project in your auth config. 67 | 68 | ```ts title="auth.ts" 69 | import { betterAuth } from "better-auth"; 70 | 71 | export const auth = betterAuth({ 72 | database: new Pool({ 73 | connectionString: process.env.DATABASE_URL 74 | }), 75 | emailAndPassword: { 76 | enabled: true, 77 | }, 78 | socialProviders: { // [!code highlight] 79 | google: { // [!code highlight] 80 | clientId: process.env.GOOGLE_CLIENT_ID, // [!code highlight] 81 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, // [!code highlight] 82 | }, // [!code highlight] 83 | github: { // [!code highlight] 84 | clientId: process.env.GITHUB_CLIENT_ID, // [!code highlight] 85 | clientSecret: process.env.GITHUB_CLIENT_SECRET, // [!code highlight] 86 | } // [!code highlight] 87 | } // [!code highlight] 88 | }) 89 | ``` 90 | </Step> 91 | <Step> 92 | ### Add Plugins (Optional) 93 | 94 | You can add the following plugins to your auth config based on your needs. 95 | 96 | [Admin](/docs/plugins/admin) Plugin will allow you to manage users, user impersonations and app level roles and permissions. 97 | 98 | [Two Factor](/docs/plugins/2fa) Plugin will allow you to add two-factor authentication to your application. 99 | 100 | [Username](/docs/plugins/username) Plugin will allow you to add username authentication to your application. 101 | 102 | ```ts title="auth.ts" 103 | import { Pool } from "pg"; 104 | import { betterAuth } from "better-auth"; 105 | import { admin, twoFactor, username } from "better-auth/plugins"; 106 | 107 | export const auth = betterAuth({ 108 | database: new Pool({ 109 | connectionString: process.env.DATABASE_URL 110 | }), 111 | emailAndPassword: { 112 | enabled: true, 113 | password: { 114 | verify: (data) => { 115 | // this for an edgecase that you might run in to on verifying the password 116 | } 117 | } 118 | }, 119 | socialProviders: { 120 | google: { 121 | clientId: process.env.GOOGLE_CLIENT_ID!, 122 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!, 123 | }, 124 | github: { 125 | clientId: process.env.GITHUB_CLIENT_ID!, 126 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 127 | } 128 | }, 129 | plugins: [admin(), twoFactor(), username()], // [!code highlight] 130 | }) 131 | ``` 132 | </Step> 133 | <Step> 134 | ### Generate Schema 135 | 136 | If you're using a custom database adapter, generate the schema: 137 | 138 | ```sh 139 | npx @better-auth/cli generate 140 | ``` 141 | 142 | or if you're using the default adapter, you can use the following command: 143 | 144 | ```sh 145 | npx @better-auth/cli migrate 146 | ``` 147 | </Step> 148 | <Step> 149 | ### Install Dependencies 150 | 151 | Install the required dependencies for the migration: 152 | 153 | ```bash 154 | npm install auth0 155 | ``` 156 | </Step> 157 | <Step> 158 | ### Create the migration script 159 | 160 | Create a new file called `migrate-auth0.ts` in the `scripts` folder and add the following code: 161 | 162 | <Callout type="info"> 163 | Instead of using the Management API, you can use Auth0's bulk user export functionality and pass the exported JSON data directly to the `auth0Users` array. This is especially useful if you need to migrate password hashes and complete user data, which are not available through the Management API. 164 | 165 | **Important Notes:** 166 | - Password hashes export is only available for Auth0 Enterprise users 167 | - Free plan users cannot export password hashes and will need to request a support ticket 168 | - For detailed information about bulk user exports, see the [Auth0 Bulk User Export Documentation](https://auth0.com/docs/manage-users/user-migration/bulk-user-exports) 169 | - For password hash export details, refer to [Exporting Password Hashes](https://auth0.com/docs/troubleshoot/customer-support/manage-subscriptions/export-data#user-passwords) 170 | 171 | Example: 172 | ```ts 173 | // Replace this with your exported users JSON data 174 | const auth0Users = [ 175 | { 176 | "email": "[email protected]", 177 | "email_verified": false, 178 | "name": "Hello world", 179 | // Note: password_hash is only available for Enterprise users 180 | "password_hash": "$2b$10$w4kfaZVjrcQ6ZOMiG.M8JeNvnVQkPKZV03pbDUHbxy9Ug0h/McDXi", 181 | // ... other user data 182 | } 183 | ]; 184 | ``` 185 | </Callout> 186 | 187 | ```ts title="scripts/migrate-auth0.ts" 188 | import { ManagementClient } from 'auth0'; 189 | import { generateRandomString, symmetricEncrypt } from "better-auth/crypto"; 190 | import { auth } from '@/lib/auth'; 191 | 192 | const auth0Client = new ManagementClient({ 193 | domain: process.env.AUTH0_DOMAIN!, 194 | clientId: process.env.AUTH0_CLIENT_ID!, 195 | clientSecret: process.env.AUTH0_SECRET!, 196 | }); 197 | 198 | 199 | 200 | function safeDateConversion(timestamp?: string | number): Date { 201 | if (!timestamp) return new Date(); 202 | 203 | const numericTimestamp = typeof timestamp === 'string' ? Date.parse(timestamp) : timestamp; 204 | 205 | const milliseconds = numericTimestamp < 1000000000000 ? numericTimestamp * 1000 : numericTimestamp; 206 | 207 | const date = new Date(milliseconds); 208 | 209 | if (isNaN(date.getTime())) { 210 | console.warn(`Invalid timestamp: ${timestamp}, falling back to current date`); 211 | return new Date(); 212 | } 213 | 214 | // Check for unreasonable dates (before 2000 or after 2100) 215 | const year = date.getFullYear(); 216 | if (year < 2000 || year > 2100) { 217 | console.warn(`Suspicious date year: ${year}, falling back to current date`); 218 | return new Date(); 219 | } 220 | 221 | return date; 222 | } 223 | 224 | // Helper function to generate backup codes for 2FA 225 | async function generateBackupCodes(secret: string) { 226 | const key = secret; 227 | const backupCodes = Array.from({ length: 10 }) 228 | .fill(null) 229 | .map(() => generateRandomString(10, "a-z", "0-9", "A-Z")) 230 | .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`); 231 | 232 | const encCodes = await symmetricEncrypt({ 233 | data: JSON.stringify(backupCodes), 234 | key: key, 235 | }); 236 | return encCodes; 237 | } 238 | 239 | function mapAuth0RoleToBetterAuthRole(auth0Roles: string[]) { 240 | if (typeof auth0Roles === 'string') return auth0Roles; 241 | if (Array.isArray(auth0Roles)) return auth0Roles.join(','); 242 | } 243 | // helper function to migrate password from auth0 to better auth for custom hashes and algs 244 | async function migratePassword(auth0User: any) { 245 | if (auth0User.password_hash) { 246 | if (auth0User.password_hash.startsWith('$2a$') || auth0User.password_hash.startsWith('$2b$')) { 247 | return auth0User.password_hash; 248 | } 249 | } 250 | 251 | if (auth0User.custom_password_hash) { 252 | const customHash = auth0User.custom_password_hash; 253 | 254 | if (customHash.algorithm === 'bcrypt') { 255 | const hash = customHash.hash.value; 256 | if (hash.startsWith('$2a$') || hash.startsWith('$2b$')) { 257 | return hash; 258 | } 259 | } 260 | 261 | return JSON.stringify({ 262 | algorithm: customHash.algorithm, 263 | hash: { 264 | value: customHash.hash.value, 265 | encoding: customHash.hash.encoding || 'utf8', 266 | ...(customHash.hash.digest && { digest: customHash.hash.digest }), 267 | ...(customHash.hash.key && { 268 | key: { 269 | value: customHash.hash.key.value, 270 | encoding: customHash.hash.key.encoding || 'utf8' 271 | } 272 | }) 273 | }, 274 | ...(customHash.salt && { 275 | salt: { 276 | value: customHash.salt.value, 277 | encoding: customHash.salt.encoding || 'utf8', 278 | position: customHash.salt.position || 'prefix' 279 | } 280 | }), 281 | ...(customHash.password && { 282 | password: { 283 | encoding: customHash.password.encoding || 'utf8' 284 | } 285 | }), 286 | ...(customHash.algorithm === 'scrypt' && { 287 | keylen: customHash.keylen, 288 | cost: customHash.cost || 16384, 289 | blockSize: customHash.blockSize || 8, 290 | parallelization: customHash.parallelization || 1 291 | }) 292 | }); 293 | } 294 | 295 | return null; 296 | } 297 | 298 | async function migrateMFAFactors(auth0User: any, userId: string | undefined, ctx: any) { 299 | if (!userId || !auth0User.mfa_factors || !Array.isArray(auth0User.mfa_factors)) { 300 | return; 301 | } 302 | 303 | for (const factor of auth0User.mfa_factors) { 304 | try { 305 | if (factor.totp && factor.totp.secret) { 306 | await ctx.adapter.create({ 307 | model: "twoFactor", 308 | data: { 309 | userId: userId, 310 | secret: factor.totp.secret, 311 | backupCodes: await generateBackupCodes(factor.totp.secret) 312 | } 313 | }); 314 | } 315 | } catch (error) { 316 | console.error(`Failed to migrate MFA factor for user ${userId}:`, error); 317 | } 318 | } 319 | } 320 | 321 | async function migrateOAuthAccounts(auth0User: any, userId: string | undefined, ctx: any) { 322 | if (!userId || !auth0User.identities || !Array.isArray(auth0User.identities)) { 323 | return; 324 | } 325 | 326 | for (const identity of auth0User.identities) { 327 | try { 328 | const providerId = identity.provider === 'auth0' ? "credential" : identity.provider.split("-")[0]; 329 | await ctx.adapter.create({ 330 | model: "account", 331 | data: { 332 | id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`, 333 | userId: userId, 334 | password: await migratePassword(auth0User), 335 | providerId: providerId || identity.provider, 336 | accountId: identity.user_id, 337 | accessToken: identity.access_token, 338 | tokenType: identity.token_type, 339 | refreshToken: identity.refresh_token, 340 | accessTokenExpiresAt: identity.expires_in ? new Date(Date.now() + identity.expires_in * 1000) : undefined, 341 | // if you are enterprise user, you can get the refresh tokens or all the tokensets - auth0Client.users.getAllTokensets 342 | refreshTokenExpiresAt: identity.refresh_token_expires_in ? new Date(Date.now() + identity.refresh_token_expires_in * 1000) : undefined, 343 | 344 | scope: identity.scope, 345 | idToken: identity.id_token, 346 | createdAt: safeDateConversion(auth0User.created_at), 347 | updatedAt: safeDateConversion(auth0User.updated_at) 348 | }, 349 | forceAllowId: true 350 | }).catch((error: Error) => { 351 | console.error(`Failed to create OAuth account for user ${userId} with provider ${providerId}:`, error); 352 | return ctx.adapter.create({ 353 | // Try creating without optional fields if the first attempt failed 354 | model: "account", 355 | data: { 356 | id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`, 357 | userId: userId, 358 | password: migratePassword(auth0User), 359 | providerId: providerId, 360 | accountId: identity.user_id, 361 | accessToken: identity.access_token, 362 | tokenType: identity.token_type, 363 | refreshToken: identity.refresh_token, 364 | accessTokenExpiresAt: identity.expires_in ? new Date(Date.now() + identity.expires_in * 1000) : undefined, 365 | refreshTokenExpiresAt: identity.refresh_token_expires_in ? new Date(Date.now() + identity.refresh_token_expires_in * 1000) : undefined, 366 | scope: identity.scope, 367 | idToken: identity.id_token, 368 | createdAt: safeDateConversion(auth0User.created_at), 369 | updatedAt: safeDateConversion(auth0User.updated_at) 370 | }, 371 | forceAllowId: true 372 | }); 373 | }); 374 | 375 | console.log(`Successfully migrated OAuth account for user ${userId} with provider ${providerId}`); 376 | } catch (error) { 377 | console.error(`Failed to migrate OAuth account for user ${userId}:`, error); 378 | } 379 | } 380 | } 381 | 382 | async function migrateOrganizations(ctx: any) { 383 | try { 384 | const organizations = await auth0Client.organizations.getAll(); 385 | for (const org of organizations.data || []) { 386 | try { 387 | await ctx.adapter.create({ 388 | model: "organization", 389 | data: { 390 | id: org.id, 391 | name: org.display_name || org.id, 392 | slug: (org.display_name || org.id).toLowerCase().replace(/[^a-z0-9]/g, '-'), 393 | logo: org.branding?.logo_url, 394 | metadata: JSON.stringify(org.metadata || {}), 395 | createdAt: safeDateConversion(org.created_at), 396 | }, 397 | forceAllowId: true 398 | }); 399 | const members = await auth0Client.organizations.getMembers({ id: org.id }); 400 | for (const member of members.data || []) { 401 | try { 402 | const userRoles = await auth0Client.organizations.getMemberRoles({ 403 | id: org.id, 404 | user_id: member.user_id 405 | }); 406 | const role = mapAuth0RoleToBetterAuthRole(userRoles.data?.map(r => r.name) || []); 407 | await ctx.adapter.create({ 408 | model: "member", 409 | data: { 410 | id: `${org.id}|${member.user_id}`, 411 | organizationId: org.id, 412 | userId: member.user_id, 413 | role: role, 414 | createdAt: new Date() 415 | }, 416 | forceAllowId: true 417 | }); 418 | 419 | console.log(`Successfully migrated member ${member.user_id} for organization ${org.display_name || org.id}`); 420 | } catch (error) { 421 | console.error(`Failed to migrate member ${member.user_id} for organization ${org.display_name || org.id}:`, error); 422 | } 423 | } 424 | 425 | console.log(`Successfully migrated organization: ${org.display_name || org.id}`); 426 | } catch (error) { 427 | console.error(`Failed to migrate organization ${org.display_name || org.id}:`, error); 428 | } 429 | } 430 | console.log('Organization migration completed'); 431 | } catch (error) { 432 | console.error('Failed to migrate organizations:', error); 433 | } 434 | } 435 | 436 | async function migrateFromAuth0() { 437 | try { 438 | const ctx = await auth.$context; 439 | const isAdminEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "admin"); 440 | const isUsernameEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "username"); 441 | const isOrganizationEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "organization"); 442 | const perPage = 100; 443 | const auth0Users: any[] = []; 444 | let pageNumber = 0; 445 | 446 | while (true) { 447 | try { 448 | const params = { 449 | per_page: perPage, 450 | page: pageNumber, 451 | include_totals: true, 452 | }; 453 | const response = (await auth0Client.users.getAll(params)).data as any; 454 | const users = response.users || []; 455 | if (users.length === 0) break; 456 | auth0Users.push(...users); 457 | pageNumber++; 458 | 459 | if (users.length < perPage) break; 460 | } catch (error) { 461 | console.error('Error fetching users:', error); 462 | break; 463 | } 464 | } 465 | 466 | 467 | console.log(`Found ${auth0Users.length} users to migrate`); 468 | 469 | for (const auth0User of auth0Users) { 470 | try { 471 | // Determine if this is a password-based or OAuth user 472 | const isOAuthUser = auth0User.identities?.some((identity: any) => identity.provider !== 'auth0'); 473 | // Base user data that's common for both types 474 | const baseUserData = { 475 | id: auth0User.user_id, 476 | email: auth0User.email, 477 | emailVerified: auth0User.email_verified || false, 478 | name: auth0User.name || auth0User.nickname, 479 | image: auth0User.picture, 480 | createdAt: safeDateConversion(auth0User.created_at), 481 | updatedAt: safeDateConversion(auth0User.updated_at), 482 | ...(isAdminEnabled ? { 483 | banned: auth0User.blocked || false, 484 | role: mapAuth0RoleToBetterAuthRole(auth0User.roles || []), 485 | } : {}), 486 | 487 | ...(isUsernameEnabled ? { 488 | username: auth0User.username || auth0User.nickname, 489 | } : {}), 490 | 491 | }; 492 | 493 | const createdUser = await ctx.adapter.create({ 494 | model: "user", 495 | data: { 496 | ...baseUserData, 497 | }, 498 | forceAllowId: true 499 | }); 500 | 501 | if (!createdUser?.id) { 502 | throw new Error('Failed to create user'); 503 | } 504 | 505 | 506 | await migrateOAuthAccounts(auth0User, createdUser.id, ctx) 507 | console.log(`Successfully migrated user: ${auth0User.email}`); 508 | } catch (error) { 509 | console.error(`Failed to migrate user ${auth0User.email}:`, error); 510 | } 511 | } 512 | if (isOrganizationEnabled) { 513 | await migrateOrganizations(ctx); 514 | } 515 | // the reset of migration will be here. 516 | console.log('Migration completed successfully'); 517 | } catch (error) { 518 | console.error('Migration failed:', error); 519 | throw error; 520 | } 521 | } 522 | 523 | migrateFromAuth0() 524 | .then(() => { 525 | console.log('Migration completed'); 526 | process.exit(0); 527 | }) 528 | .catch((error) => { 529 | console.error('Migration failed:', error); 530 | process.exit(1); 531 | }); 532 | ``` 533 | 534 | Make sure to replace the Auth0 environment variables with your own values: 535 | - `AUTH0_DOMAIN` 536 | - `AUTH0_CLIENT_ID` 537 | - `AUTH0_SECRET` 538 | </Step> 539 | 540 | <Step> 541 | ### Run the migration 542 | 543 | Run the migration script: 544 | 545 | ```sh 546 | bun run scripts/migrate-auth0.ts # or use your preferred runtime 547 | ``` 548 | 549 | <Callout type="warning"> 550 | Important considerations: 551 | 1. Test the migration in a development environment first 552 | 2. Monitor the migration process for any errors 553 | 3. Verify the migrated data in Better Auth before proceeding 554 | 4. Keep Auth0 installed and configured until the migration is complete 555 | 5. The script handles bcrypt password hashes by default. For custom password hashing algorithms, you'll need to modify the `migratePassword` function 556 | </Callout> 557 | 558 | </Step> 559 | 560 | <Step> 561 | ### Change password hashing algorithm 562 | 563 | By default, Better Auth uses the `scrypt` algorithm to hash passwords. Since Auth0 uses `bcrypt`, you'll need to configure Better Auth to use bcrypt for password verification. 564 | 565 | First, install bcrypt: 566 | 567 | ```bash 568 | npm install bcrypt 569 | npm install -D @types/bcrypt 570 | ``` 571 | 572 | Then update your auth configuration: 573 | 574 | ```ts title="auth.ts" 575 | import { betterAuth } from "better-auth"; 576 | import bcrypt from "bcrypt"; 577 | 578 | export const auth = betterAuth({ 579 | emailAndPassword: { 580 | password: { 581 | hash: async (password) => { 582 | return await bcrypt.hash(password, 10); 583 | }, 584 | verify: async ({ hash, password }) => { 585 | return await bcrypt.compare(password, hash); 586 | } 587 | } 588 | } 589 | }) 590 | ``` 591 | </Step> 592 | <Step> 593 | ### Verify the migration 594 | 595 | After running the migration, verify that: 596 | 1. All users have been properly migrated 597 | 2. Social connections are working 598 | 3. Password-based authentication is working 599 | 4. Two-factor authentication settings are preserved (if enabled) 600 | 5. User roles and permissions are correctly mapped 601 | </Step> 602 | <Step> 603 | ### Update your components 604 | 605 | Now that the data is migrated, update your components to use Better Auth. Here's an example for the sign-in component: 606 | 607 | ```tsx title="components/auth/sign-in.tsx" 608 | import { authClient } from "better-auth/client"; 609 | 610 | export const SignIn = () => { 611 | const handleSignIn = async () => { 612 | const { data, error } = await authClient.signIn.email({ 613 | email: "[email protected]", 614 | password: "helloworld", 615 | }); 616 | 617 | if (error) { 618 | console.error(error); 619 | return; 620 | } 621 | // Handle successful sign in 622 | }; 623 | 624 | return ( 625 | <form onSubmit={handleSignIn}> 626 | <button type="submit">Sign in</button> 627 | </form> 628 | ); 629 | }; 630 | ``` 631 | </Step> 632 | <Step> 633 | ### Update the middleware 634 | 635 | Replace your Auth0 middleware with Better Auth's middleware: 636 | 637 | ```ts title="middleware.ts" 638 | import { NextRequest, NextResponse } from "next/server"; 639 | import { getSessionCookie } from "better-auth/cookies"; 640 | 641 | export async function middleware(request: NextRequest) { 642 | const sessionCookie = getSessionCookie(request); 643 | const { pathname } = request.nextUrl; 644 | 645 | if (sessionCookie && ["/login", "/signup"].includes(pathname)) { 646 | return NextResponse.redirect(new URL("/dashboard", request.url)); 647 | } 648 | 649 | if (!sessionCookie && pathname.startsWith("/dashboard")) { 650 | return NextResponse.redirect(new URL("/login", request.url)); 651 | } 652 | 653 | return NextResponse.next(); 654 | } 655 | 656 | export const config = { 657 | matcher: ["/dashboard", "/login", "/signup"], 658 | }; 659 | ``` 660 | </Step> 661 | <Step> 662 | ### Remove Auth0 Dependencies 663 | 664 | Once you've verified everything is working correctly with Better Auth, remove Auth0: 665 | 666 | ```bash 667 | npm remove @auth0/auth0-react @auth0/auth0-spa-js @auth0/nextjs-auth0 668 | ``` 669 | </Step> 670 | </Steps> 671 | 672 | ## Additional Considerations 673 | 674 | ### Password Migration 675 | The migration script handles bcrypt password hashes by default. If you're using custom password hashing algorithms in Auth0, you'll need to modify the `migratePassword` function in the migration script to handle your specific case. 676 | 677 | ### Role Mapping 678 | The script includes a basic role mapping function (`mapAuth0RoleToBetterAuthRole`). Customize this function based on your Auth0 roles and Better Auth role requirements. 679 | 680 | ### Rate Limiting 681 | The migration script includes pagination to handle large numbers of users. Adjust the `perPage` value based on your needs and Auth0's rate limits. 682 | 683 | ## Wrapping Up 684 | 685 | Now! You've successfully migrated from Auth0 to Better Auth. 686 | 687 | Better Auth offers greater flexibility and more features—be sure to explore the [documentation](/docs) to unlock its full potential. ```