This is page 38 of 49. Use http://codebase.md/better-auth/better-auth?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── 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 │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /demo/nextjs/app/dashboard/user-card.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { client, signOut, useSession } from "@/lib/auth-client"; import { Session } from "@/lib/auth-types"; import { MobileIcon } from "@radix-ui/react-icons"; import { Edit, Fingerprint, Laptop, Loader2, LogOut, Plus, QrCode, ShieldCheck, ShieldOff, StopCircle, Trash, X, } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { toast } from "sonner"; import { UAParser } from "ua-parser-js"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import QRCode from "react-qr-code"; import CopyButton from "@/components/ui/copy-button"; import { Badge } from "@/components/ui/badge"; import { useQuery } from "@tanstack/react-query"; import { SubscriptionTierLabel } from "@/components/tier-labels"; import { Component } from "./change-plan"; import { Subscription } from "@better-auth/stripe"; export default function UserCard(props: { session: Session | null; activeSessions: Session["session"][]; subscription?: Subscription; }) { const router = useRouter(); const { data, isPending } = useSession(); const session = data || props.session; const [isTerminating, setIsTerminating] = useState<string>(); const [isPendingTwoFa, setIsPendingTwoFa] = useState<boolean>(false); const [twoFaPassword, setTwoFaPassword] = useState<string>(""); const [twoFactorDialog, setTwoFactorDialog] = useState<boolean>(false); const [twoFactorVerifyURI, setTwoFactorVerifyURI] = useState<string>(""); const [isSignOut, setIsSignOut] = useState<boolean>(false); const [emailVerificationPending, setEmailVerificationPending] = useState<boolean>(false); const [activeSessions, setActiveSessions] = useState(props.activeSessions); const removeActiveSession = (id: string) => setActiveSessions(activeSessions.filter((session) => session.id !== id)); const { data: subscription } = useQuery({ queryKey: ["subscriptions"], initialData: props.subscription ? props.subscription : null, queryFn: async () => { const res = await client.subscription.list({ fetchOptions: { throw: true, }, }); return res.length ? res[0] : null; }, }); return ( <Card> <CardHeader> <CardTitle>User</CardTitle> </CardHeader> <CardContent className="grid gap-8 grid-cols-1"> <div className="flex flex-col gap-2"> <div className="flex items-start justify-between"> <div className="flex items-center gap-4"> <Avatar className="hidden h-9 w-9 sm:flex "> <AvatarImage src={session?.user.image || undefined} alt="Avatar" className="object-cover" /> <AvatarFallback>{session?.user.name.charAt(0)}</AvatarFallback> </Avatar> <div className="grid"> <div className="flex items-center gap-1"> <p className="text-sm font-medium leading-none"> {session?.user.name} </p> {!!subscription && ( <Badge className="w-min p-px rounded-full" variant="outline" > <svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" viewBox="0 0 24 24" > <path fill="currentColor" d="m9.023 21.23l-1.67-2.814l-3.176-.685l.312-3.277L2.346 12L4.49 9.546L4.177 6.27l3.177-.685L9.023 2.77L12 4.027l2.977-1.258l1.67 2.816l3.176.684l-.312 3.277L21.655 12l-2.142 2.454l.311 3.277l-3.177.684l-1.669 2.816L12 19.973zm1.927-6.372L15.908 9.9l-.708-.72l-4.25 4.25l-2.15-2.138l-.708.708z" ></path> </svg> </Badge> )} </div> <p className="text-sm">{session?.user.email}</p> </div> </div> <EditUserDialog /> </div> <div className="flex items-center justify-between"> <div> <SubscriptionTierLabel tier={subscription?.plan?.toLowerCase() as "plus"} /> </div> <Component currentPlan={subscription?.plan?.toLowerCase() as "plus"} isTrial={subscription?.status === "trialing"} /> </div> </div> {session?.user.emailVerified ? null : ( <Alert> <AlertTitle>Verify Your Email Address</AlertTitle> <AlertDescription className="text-muted-foreground"> Please verify your email address. Check your inbox for the verification email. If you haven't received the email, click the button below to resend. <Button size="sm" variant="secondary" className="mt-2" onClick={async () => { await client.sendVerificationEmail( { email: session?.user.email || "", }, { onRequest(context) { setEmailVerificationPending(true); }, onError(context) { toast.error(context.error.message); setEmailVerificationPending(false); }, onSuccess() { toast.success("Verification email sent successfully"); setEmailVerificationPending(false); }, }, ); }} > {emailVerificationPending ? ( <Loader2 size={15} className="animate-spin" /> ) : ( "Resend Verification Email" )} </Button> </AlertDescription> </Alert> )} <div className="border-l-2 px-2 w-max gap-1 flex flex-col"> <p className="text-xs font-medium ">Active Sessions</p> {activeSessions .filter((session) => session.userAgent) .map((session) => { return ( <div key={session.id}> <div className="flex items-center gap-2 text-sm text-black font-medium dark:text-white"> {new UAParser(session.userAgent || "").getDevice().type === "mobile" ? ( <MobileIcon /> ) : ( <Laptop size={16} /> )} {new UAParser(session.userAgent || "").getOS().name || session.userAgent} , {new UAParser(session.userAgent || "").getBrowser().name} <button className="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline " onClick={async () => { setIsTerminating(session.id); const res = await client.revokeSession({ token: session.token, }); if (res.error) { toast.error(res.error.message); } else { toast.success("Session terminated successfully"); removeActiveSession(session.id); } if (session.id === props.session?.session.id) router.refresh(); setIsTerminating(undefined); }} > {isTerminating === session.id ? ( <Loader2 size={15} className="animate-spin" /> ) : session.id === props.session?.session.id ? ( "Sign Out" ) : ( "Terminate" )} </button> </div> </div> ); })} </div> <div className="border-y py-4 flex items-center flex-wrap justify-between gap-2"> <div className="flex flex-col gap-2"> <p className="text-sm">Passkeys</p> <div className="flex gap-2 flex-wrap"> <AddPasskey /> <ListPasskeys /> </div> </div> <div className="flex flex-col gap-2"> <p className="text-sm">Two Factor</p> <div className="flex gap-2"> {!!session?.user.twoFactorEnabled && ( <Dialog> <DialogTrigger asChild> <Button variant="outline" className="gap-2"> <QrCode size={16} /> <span className="md:text-sm text-xs">Scan QR Code</span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px] w-11/12"> <DialogHeader> <DialogTitle>Scan QR Code</DialogTitle> <DialogDescription> Scan the QR code with your TOTP app </DialogDescription> </DialogHeader> {twoFactorVerifyURI ? ( <> <div className="flex items-center justify-center"> <QRCode value={twoFactorVerifyURI} /> </div> <div className="flex gap-2 items-center justify-center"> <p className="text-sm text-muted-foreground"> Copy URI to clipboard </p> <CopyButton textToCopy={twoFactorVerifyURI} /> </div> </> ) : ( <div className="flex flex-col gap-2"> <PasswordInput value={twoFaPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTwoFaPassword(e.target.value) } placeholder="Enter Password" /> <Button onClick={async () => { if (twoFaPassword.length < 8) { toast.error( "Password must be at least 8 characters", ); return; } await client.twoFactor.getTotpUri( { password: twoFaPassword, }, { onSuccess(context) { setTwoFactorVerifyURI(context.data.totpURI); }, }, ); setTwoFaPassword(""); }} > Show QR Code </Button> </div> )} </DialogContent> </Dialog> )} <Dialog open={twoFactorDialog} onOpenChange={setTwoFactorDialog}> <DialogTrigger asChild> <Button variant={ session?.user.twoFactorEnabled ? "destructive" : "outline" } className="gap-2" > {session?.user.twoFactorEnabled ? ( <ShieldOff size={16} /> ) : ( <ShieldCheck size={16} /> )} <span className="md:text-sm text-xs"> {session?.user.twoFactorEnabled ? "Disable 2FA" : "Enable 2FA"} </span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px] w-11/12"> <DialogHeader> <DialogTitle> {session?.user.twoFactorEnabled ? "Disable 2FA" : "Enable 2FA"} </DialogTitle> <DialogDescription> {session?.user.twoFactorEnabled ? "Disable the second factor authentication from your account" : "Enable 2FA to secure your account"} </DialogDescription> </DialogHeader> {twoFactorVerifyURI ? ( <div className="flex flex-col gap-2"> <div className="flex items-center justify-center"> <QRCode value={twoFactorVerifyURI} /> </div> <Label htmlFor="password"> Scan the QR code with your TOTP app </Label> <Input value={twoFaPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTwoFaPassword(e.target.value) } placeholder="Enter OTP" /> </div> ) : ( <div className="flex flex-col gap-2"> <Label htmlFor="password">Password</Label> <PasswordInput id="password" placeholder="Password" value={twoFaPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTwoFaPassword(e.target.value) } /> </div> )} <DialogFooter> <Button disabled={isPendingTwoFa} onClick={async () => { if (twoFaPassword.length < 8 && !twoFactorVerifyURI) { toast.error("Password must be at least 8 characters"); return; } setIsPendingTwoFa(true); if (session?.user.twoFactorEnabled) { const res = await client.twoFactor.disable({ password: twoFaPassword, fetchOptions: { onError(context) { toast.error(context.error.message); }, onSuccess() { toast("2FA disabled successfully"); setTwoFactorDialog(false); }, }, }); } else { if (twoFactorVerifyURI) { await client.twoFactor.verifyTotp({ code: twoFaPassword, fetchOptions: { onError(context) { setIsPendingTwoFa(false); setTwoFaPassword(""); toast.error(context.error.message); }, onSuccess() { toast("2FA enabled successfully"); setTwoFactorVerifyURI(""); setIsPendingTwoFa(false); setTwoFaPassword(""); setTwoFactorDialog(false); }, }, }); return; } const res = await client.twoFactor.enable({ password: twoFaPassword, fetchOptions: { onError(context) { toast.error(context.error.message); }, onSuccess(ctx) { setTwoFactorVerifyURI(ctx.data.totpURI); // toast.success("2FA enabled successfully"); // setTwoFactorDialog(false); }, }, }); } setIsPendingTwoFa(false); setTwoFaPassword(""); }} > {isPendingTwoFa ? ( <Loader2 size={15} className="animate-spin" /> ) : session?.user.twoFactorEnabled ? ( "Disable 2FA" ) : ( "Enable 2FA" )} </Button> </DialogFooter> </DialogContent> </Dialog> </div> </div> </div> </CardContent> <CardFooter className="gap-2 justify-between items-center"> <ChangePassword /> {session?.session.impersonatedBy ? ( <Button className="gap-2 z-10" variant="secondary" onClick={async () => { setIsSignOut(true); await client.admin.stopImpersonating(); setIsSignOut(false); toast.info("Impersonation stopped successfully"); router.push("/admin"); }} disabled={isSignOut} > <span className="text-sm"> {isSignOut ? ( <Loader2 size={15} className="animate-spin" /> ) : ( <div className="flex items-center gap-2"> <StopCircle size={16} color="red" /> Stop Impersonation </div> )} </span> </Button> ) : ( <Button className="gap-2 z-10" variant="secondary" onClick={async () => { setIsSignOut(true); await signOut({ fetchOptions: { onSuccess() { router.push("/"); }, }, }); setIsSignOut(false); }} disabled={isSignOut} > <span className="text-sm"> {isSignOut ? ( <Loader2 size={15} className="animate-spin" /> ) : ( <div className="flex items-center gap-2"> <LogOut size={16} /> Sign Out </div> )} </span> </Button> )} </CardFooter> </Card> ); } async function convertImageToBase64(file: File): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } function ChangePassword() { const [currentPassword, setCurrentPassword] = useState<string>(""); const [newPassword, setNewPassword] = useState<string>(""); const [confirmPassword, setConfirmPassword] = useState<string>(""); const [loading, setLoading] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false); const [signOutDevices, setSignOutDevices] = useState<boolean>(false); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button className="gap-2 z-10" variant="outline" size="sm"> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" > <path fill="currentColor" d="M2.5 18.5v-1h19v1zm.535-5.973l-.762-.442l.965-1.693h-1.93v-.884h1.93l-.965-1.642l.762-.443L4 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L4 10.835zm8 0l-.762-.442l.966-1.693H9.308v-.884h1.93l-.965-1.642l.762-.443L12 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L12 10.835zm8 0l-.762-.442l.966-1.693h-1.931v-.884h1.93l-.965-1.642l.762-.443L20 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L20 10.835z" ></path> </svg> <span className="text-sm text-muted-foreground">Change Password</span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px] w-11/12"> <DialogHeader> <DialogTitle>Change Password</DialogTitle> <DialogDescription>Change your password</DialogDescription> </DialogHeader> <div className="grid gap-2"> <Label htmlFor="current-password">Current Password</Label> <PasswordInput id="current-password" value={currentPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCurrentPassword(e.target.value) } autoComplete="new-password" placeholder="Password" /> <Label htmlFor="new-password">New Password</Label> <PasswordInput value={newPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value) } autoComplete="new-password" placeholder="New Password" /> <Label htmlFor="password">Confirm Password</Label> <PasswordInput value={confirmPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value) } autoComplete="new-password" placeholder="Confirm Password" /> <div className="flex gap-2 items-center"> <Checkbox onCheckedChange={(checked) => checked ? setSignOutDevices(true) : setSignOutDevices(false) } /> <p className="text-sm">Sign out from other devices</p> </div> </div> <DialogFooter> <Button onClick={async () => { if (newPassword !== confirmPassword) { toast.error("Passwords do not match"); return; } if (newPassword.length < 8) { toast.error("Password must be at least 8 characters"); return; } setLoading(true); const res = await client.changePassword({ newPassword: newPassword, currentPassword: currentPassword, revokeOtherSessions: signOutDevices, }); setLoading(false); if (res.error) { toast.error( res.error.message || "Couldn't change your password! Make sure it's correct", ); } else { setOpen(false); toast.success("Password changed successfully"); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } }} > {loading ? ( <Loader2 size={15} className="animate-spin" /> ) : ( "Change Password" )} </Button> </DialogFooter> </DialogContent> </Dialog> ); } function EditUserDialog() { const { data, isPending, error } = useSession(); const [name, setName] = useState<string>(); const router = useRouter(); const [image, setImage] = useState<File | null>(null); const [imagePreview, setImagePreview] = useState<string | null>(null); const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) { setImage(file); const reader = new FileReader(); reader.onloadend = () => { setImagePreview(reader.result as string); }; reader.readAsDataURL(file); } }; const [open, setOpen] = useState<boolean>(false); const [isLoading, startTransition] = useTransition(); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button size="sm" className="gap-2" variant="secondary"> <Edit size={13} /> Edit User </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px] w-11/12"> <DialogHeader> <DialogTitle>Edit User</DialogTitle> <DialogDescription>Edit user information</DialogDescription> </DialogHeader> <div className="grid gap-2"> <Label htmlFor="name">Full Name</Label> <Input id="name" type="name" placeholder={data?.user.name} required onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setName(e.target.value); }} /> <div className="grid gap-2"> <Label htmlFor="image">Profile Image</Label> <div className="flex items-end gap-4"> {imagePreview && ( <div className="relative w-16 h-16 rounded-sm overflow-hidden"> <Image src={imagePreview} alt="Profile preview" layout="fill" objectFit="cover" /> </div> )} <div className="flex items-center gap-2 w-full"> <Input id="image" type="file" accept="image/*" onChange={handleImageChange} className="w-full text-muted-foreground" /> {imagePreview && ( <X className="cursor-pointer" onClick={() => { setImage(null); setImagePreview(null); }} /> )} </div> </div> </div> </div> <DialogFooter> <Button disabled={isLoading} onClick={async () => { startTransition(async () => { await client.updateUser({ image: image ? await convertImageToBase64(image) : undefined, name: name ? name : undefined, fetchOptions: { onSuccess: () => { toast.success("User updated successfully"); }, onError: (error) => { toast.error(error.error.message); }, }, }); startTransition(() => { setName(""); router.refresh(); setImage(null); setImagePreview(null); setOpen(false); }); }); }} > {isLoading ? ( <Loader2 size={15} className="animate-spin" /> ) : ( "Update" )} </Button> </DialogFooter> </DialogContent> </Dialog> ); } function AddPasskey() { const [isOpen, setIsOpen] = useState(false); const [passkeyName, setPasskeyName] = useState(""); const [isLoading, setIsLoading] = useState(false); const handleAddPasskey = async () => { if (!passkeyName) { toast.error("Passkey name is required"); return; } setIsLoading(true); const res = await client.passkey.addPasskey({ name: passkeyName, }); if (res?.error) { toast.error(res?.error.message); } else { setIsOpen(false); toast.success("Passkey added successfully. You can now use it to login."); } setIsLoading(false); }; return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="outline" className="gap-2 text-xs md:text-sm"> <Plus size={15} /> Add New Passkey </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px] w-11/12"> <DialogHeader> <DialogTitle>Add New Passkey</DialogTitle> <DialogDescription> Create a new passkey to securely access your account without a password. </DialogDescription> </DialogHeader> <div className="grid gap-2"> <Label htmlFor="passkey-name">Passkey Name</Label> <Input id="passkey-name" value={passkeyName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasskeyName(e.target.value) } /> </div> <DialogFooter> <Button disabled={isLoading} type="submit" onClick={handleAddPasskey} className="w-full" > {isLoading ? ( <Loader2 size={15} className="animate-spin" /> ) : ( <> <Fingerprint className="mr-2 h-4 w-4" /> Create Passkey </> )} </Button> </DialogFooter> </DialogContent> </Dialog> ); } function ListPasskeys() { const { data } = client.useListPasskeys(); const [isOpen, setIsOpen] = useState(false); const [passkeyName, setPasskeyName] = useState(""); const handleAddPasskey = async () => { if (!passkeyName) { toast.error("Passkey name is required"); return; } setIsLoading(true); const res = await client.passkey.addPasskey({ name: passkeyName, }); setIsLoading(false); if (res?.error) { toast.error(res?.error.message); } else { toast.success("Passkey added successfully. You can now use it to login."); } }; const [isLoading, setIsLoading] = useState(false); const [isDeletePasskey, setIsDeletePasskey] = useState<boolean>(false); return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="outline" className="text-xs md:text-sm"> <Fingerprint className="mr-2 h-4 w-4" /> <span>Passkeys {data?.length ? `[${data?.length}]` : ""}</span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px] w-11/12"> <DialogHeader> <DialogTitle>Passkeys</DialogTitle> <DialogDescription>List of passkeys</DialogDescription> </DialogHeader> {data?.length ? ( <Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> </TableRow> </TableHeader> <TableBody> {data.map((passkey) => ( <TableRow key={passkey.id} className="flex justify-between items-center" > <TableCell>{passkey.name || "My Passkey"}</TableCell> <TableCell className="text-right"> <button onClick={async () => { const res = await client.passkey.deletePasskey({ id: passkey.id, fetchOptions: { onRequest: () => { setIsDeletePasskey(true); }, onSuccess: () => { toast("Passkey deleted successfully"); setIsDeletePasskey(false); }, onError: (error) => { toast.error(error.error.message); setIsDeletePasskey(false); }, }, }); }} > {isDeletePasskey ? ( <Loader2 size={15} className="animate-spin" /> ) : ( <Trash size={15} className="cursor-pointer text-red-600" /> )} </button> </TableCell> </TableRow> ))} </TableBody> </Table> ) : ( <p className="text-sm text-muted-foreground">No passkeys found</p> )} {!data?.length && ( <div className="flex flex-col gap-2"> <div className="flex flex-col gap-2"> <Label htmlFor="passkey-name" className="text-sm"> New Passkey </Label> <Input id="passkey-name" value={passkeyName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasskeyName(e.target.value) } placeholder="My Passkey" /> </div> <Button type="submit" onClick={handleAddPasskey} className="w-full"> {isLoading ? ( <Loader2 size={15} className="animate-spin" /> ) : ( <> <Fingerprint className="mr-2 h-4 w-4" /> Create Passkey </> )} </Button> </div> )} <DialogFooter> <Button onClick={() => setIsOpen(false)}>Close</Button> </DialogFooter> </DialogContent> </Dialog> ); } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/mcp/index.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; import type { BetterAuthPlugin, BetterAuthOptions } from "@better-auth/core"; import { oidcProvider, type Client, type CodeVerificationValue, type OAuthAccessToken, type OIDCMetadata, type OIDCOptions, } from "../oidc-provider"; import { APIError, getSessionFromCtx } from "../../api"; import { base64 } from "@better-auth/utils/base64"; import { generateRandomString } from "../../crypto"; import { createHash } from "@better-auth/utils/hash"; import { getWebcryptoSubtle } from "@better-auth/utils"; import { SignJWT } from "jose"; import { parseSetCookieHeader } from "../../cookies"; import { schema } from "../oidc-provider/schema"; import { authorizeMCPOAuth } from "./authorize"; import { getBaseURL } from "../../utils/url"; import { isProduction } from "@better-auth/core/env"; import { logger } from "@better-auth/core/env"; import type { GenericEndpointContext } from "@better-auth/core"; interface MCPOptions { loginPage: string; resource?: string; oidcConfig?: OIDCOptions; } export const getMCPProviderMetadata = ( ctx: GenericEndpointContext, options?: OIDCOptions, ): OIDCMetadata => { const issuer = ctx.context.options.baseURL as string; const baseURL = ctx.context.baseURL; if (!issuer || !baseURL) { throw new APIError("INTERNAL_SERVER_ERROR", { error: "invalid_issuer", error_description: "issuer or baseURL is not set. If you're the app developer, please make sure to set the `baseURL` in your auth config.", }); } return { issuer, authorization_endpoint: `${baseURL}/mcp/authorize`, token_endpoint: `${baseURL}/mcp/token`, userinfo_endpoint: `${baseURL}/mcp/userinfo`, jwks_uri: `${baseURL}/mcp/jwks`, registration_endpoint: `${baseURL}/mcp/register`, scopes_supported: ["openid", "profile", "email", "offline_access"], response_types_supported: ["code"], response_modes_supported: ["query"], grant_types_supported: ["authorization_code", "refresh_token"], acr_values_supported: [ "urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze", ], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256", "none"], token_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post", "none", ], code_challenge_methods_supported: ["S256"], claims_supported: [ "sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name", ], ...options?.metadata, }; }; export const getMCPProtectedResourceMetadata = ( ctx: GenericEndpointContext, options?: MCPOptions, ) => { const baseURL = ctx.context.baseURL; return { resource: options?.resource ?? new URL(baseURL).origin, authorization_servers: [baseURL], jwks_uri: options?.oidcConfig?.metadata?.jwks_uri ?? `${baseURL}/mcp/jwks`, scopes_supported: options?.oidcConfig?.metadata?.scopes_supported ?? [ "openid", "profile", "email", "offline_access", ], bearer_methods_supported: ["header"], resource_signing_alg_values_supported: ["RS256", "none"], }; }; export const mcp = (options: MCPOptions) => { const opts = { codeExpiresIn: 600, defaultScope: "openid", accessTokenExpiresIn: 3600, refreshTokenExpiresIn: 604800, allowPlainCodeChallengeMethod: true, ...options.oidcConfig, loginPage: options.loginPage, scopes: [ "openid", "profile", "email", "offline_access", ...(options.oidcConfig?.scopes || []), ], }; const modelName = { oauthClient: "oauthApplication", oauthAccessToken: "oauthAccessToken", oauthConsent: "oauthConsent", }; const provider = oidcProvider(opts); return { id: "mcp", hooks: { after: [ { matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { const cookie = await ctx.getSignedCookie( "oidc_login_prompt", ctx.context.secret, ); const cookieName = ctx.context.authCookies.sessionToken.name; const parsedSetCookieHeader = parseSetCookieHeader( ctx.context.responseHeaders?.get("set-cookie") || "", ); const hasSessionToken = parsedSetCookieHeader.has(cookieName); if (!cookie || !hasSessionToken) { return; } ctx.setCookie("oidc_login_prompt", "", { maxAge: 0, }); const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value; const sessionToken = sessionCookie?.split(".")[0]!; if (!sessionToken) { return; } const session = await ctx.context.internalAdapter.findSession(sessionToken); if (!session) { return; } ctx.query = JSON.parse(cookie); ctx.query!.prompt = "consent"; ctx.context.session = session; const response = await authorizeMCPOAuth(ctx, opts); return response; }), }, ], }, endpoints: { getMcpOAuthConfig: createAuthEndpoint( "/.well-known/oauth-authorization-server", { method: "GET", metadata: { client: false, }, }, async (c) => { try { const metadata = getMCPProviderMetadata(c, options); return c.json(metadata); } catch (e) { console.log(e); return c.json(null); } }, ), getMCPProtectedResource: createAuthEndpoint( "/.well-known/oauth-protected-resource", { method: "GET", metadata: { client: false, }, }, async (c) => { const metadata = getMCPProtectedResourceMetadata(c, options); return c.json(metadata); }, ), mcpOAuthAuthorize: createAuthEndpoint( "/mcp/authorize", { method: "GET", query: z.record(z.string(), z.any()), metadata: { openapi: { description: "Authorize an OAuth2 request using MCP", responses: { "200": { description: "Authorization response generated successfully", content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "Authorization response, contents depend on the authorize function implementation", }, }, }, }, }, }, }, }, async (ctx) => { return authorizeMCPOAuth(ctx, opts); }, ), mcpOAuthToken: createAuthEndpoint( "/mcp/token", { method: "POST", body: z.record(z.any(), z.any()), metadata: { isAction: false, }, }, async (ctx) => { //cors ctx.setHeader("Access-Control-Allow-Origin", "*"); ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); ctx.setHeader( "Access-Control-Allow-Headers", "Content-Type, Authorization", ); ctx.setHeader("Access-Control-Max-Age", "86400"); let { body } = ctx; if (!body) { throw ctx.error("BAD_REQUEST", { error_description: "request body not found", error: "invalid_request", }); } if (body instanceof FormData) { body = Object.fromEntries(body.entries()); } if (!(body instanceof Object)) { throw new APIError("BAD_REQUEST", { error_description: "request body is not an object", error: "invalid_request", }); } let { client_id, client_secret } = body; const authorization = ctx.request?.headers.get("authorization") || null; if ( authorization && !client_id && !client_secret && authorization.startsWith("Basic ") ) { try { const encoded = authorization.replace("Basic ", ""); const decoded = new TextDecoder().decode(base64.decode(encoded)); if (!decoded.includes(":")) { throw new APIError("UNAUTHORIZED", { error_description: "invalid authorization header format", error: "invalid_client", }); } const [id, secret] = decoded.split(":"); if (!id || !secret) { throw new APIError("UNAUTHORIZED", { error_description: "invalid authorization header format", error: "invalid_client", }); } client_id = id; client_secret = secret; } catch (error) { throw new APIError("UNAUTHORIZED", { error_description: "invalid authorization header format", error: "invalid_client", }); } } const { grant_type, code, redirect_uri, refresh_token, code_verifier, } = body; if (grant_type === "refresh_token") { if (!refresh_token) { throw new APIError("BAD_REQUEST", { error_description: "refresh_token is required", error: "invalid_request", }); } const token = await ctx.context.adapter.findOne<OAuthAccessToken>({ model: "oauthAccessToken", where: [ { field: "refreshToken", value: refresh_token.toString(), }, ], }); if (!token) { throw new APIError("UNAUTHORIZED", { error_description: "invalid refresh token", error: "invalid_grant", }); } if (token.clientId !== client_id?.toString()) { throw new APIError("UNAUTHORIZED", { error_description: "invalid client_id", error: "invalid_client", }); } if (token.refreshTokenExpiresAt < new Date()) { throw new APIError("UNAUTHORIZED", { error_description: "refresh token expired", error: "invalid_grant", }); } const accessToken = generateRandomString(32, "a-z", "A-Z"); const newRefreshToken = generateRandomString(32, "a-z", "A-Z"); const accessTokenExpiresAt = new Date( Date.now() + opts.accessTokenExpiresIn * 1000, ); const refreshTokenExpiresAt = new Date( Date.now() + opts.refreshTokenExpiresIn * 1000, ); await ctx.context.adapter.create({ model: modelName.oauthAccessToken, data: { accessToken, refreshToken: newRefreshToken, accessTokenExpiresAt, refreshTokenExpiresAt, clientId: client_id.toString(), userId: token.userId, scopes: token.scopes, createdAt: new Date(), updatedAt: new Date(), }, }); return ctx.json({ access_token: accessToken, token_type: "bearer", expires_in: opts.accessTokenExpiresIn, refresh_token: newRefreshToken, scope: token.scopes, }); } if (!code) { throw new APIError("BAD_REQUEST", { error_description: "code is required", error: "invalid_request", }); } if (opts.requirePKCE && !code_verifier) { throw new APIError("BAD_REQUEST", { error_description: "code verifier is missing", error: "invalid_request", }); } /** * We need to check if the code is valid before we can proceed * with the rest of the request. */ const verificationValue = await ctx.context.internalAdapter.findVerificationValue( code.toString(), ); if (!verificationValue) { throw new APIError("UNAUTHORIZED", { error_description: "invalid code", error: "invalid_grant", }); } if (verificationValue.expiresAt < new Date()) { throw new APIError("UNAUTHORIZED", { error_description: "code expired", error: "invalid_grant", }); } await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); if (!client_id) { throw new APIError("UNAUTHORIZED", { error_description: "client_id is required", error: "invalid_client", }); } if (!grant_type) { throw new APIError("BAD_REQUEST", { error_description: "grant_type is required", error: "invalid_request", }); } if (grant_type !== "authorization_code") { throw new APIError("BAD_REQUEST", { error_description: "grant_type must be 'authorization_code'", error: "unsupported_grant_type", }); } if (!redirect_uri) { throw new APIError("BAD_REQUEST", { error_description: "redirect_uri is required", error: "invalid_request", }); } const client = await ctx.context.adapter .findOne<Record<string, any>>({ model: modelName.oauthClient, where: [{ field: "clientId", value: client_id.toString() }], }) .then((res) => { if (!res) { return null; } return { ...res, redirectURLs: res.redirectURLs.split(","), metadata: res.metadata ? JSON.parse(res.metadata) : {}, } as Client; }); if (!client) { throw new APIError("UNAUTHORIZED", { error_description: "invalid client_id", error: "invalid_client", }); } if (client.disabled) { throw new APIError("UNAUTHORIZED", { error_description: "client is disabled", error: "invalid_client", }); } // For public clients (type: 'public'), validate PKCE instead of client_secret if (client.type === "public") { // Public clients must use PKCE if (!code_verifier) { throw new APIError("BAD_REQUEST", { error_description: "code verifier is required for public clients", error: "invalid_request", }); } // PKCE validation happens later in the flow, so we skip client_secret validation } else { // For confidential clients, validate client_secret if (!client_secret) { throw new APIError("UNAUTHORIZED", { error_description: "client_secret is required for confidential clients", error: "invalid_client", }); } const isValidSecret = client.clientSecret === client_secret.toString(); if (!isValidSecret) { throw new APIError("UNAUTHORIZED", { error_description: "invalid client_secret", error: "invalid_client", }); } } const value = JSON.parse( verificationValue.value, ) as CodeVerificationValue; if (value.clientId !== client_id.toString()) { throw new APIError("UNAUTHORIZED", { error_description: "invalid client_id", error: "invalid_client", }); } if (value.redirectURI !== redirect_uri.toString()) { throw new APIError("UNAUTHORIZED", { error_description: "invalid redirect_uri", error: "invalid_client", }); } if (value.codeChallenge && !code_verifier) { throw new APIError("BAD_REQUEST", { error_description: "code verifier is missing", error: "invalid_request", }); } const challenge = value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest( code_verifier, ); if (challenge !== value.codeChallenge) { throw new APIError("UNAUTHORIZED", { error_description: "code verification failed", error: "invalid_request", }); } const requestedScopes = value.scope; await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); const accessToken = generateRandomString(32, "a-z", "A-Z"); const refreshToken = generateRandomString(32, "A-Z", "a-z"); const accessTokenExpiresAt = new Date( Date.now() + opts.accessTokenExpiresIn * 1000, ); const refreshTokenExpiresAt = new Date( Date.now() + opts.refreshTokenExpiresIn * 1000, ); await ctx.context.adapter.create({ model: modelName.oauthAccessToken, data: { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt, clientId: client_id.toString(), userId: value.userId, scopes: requestedScopes.join(" "), createdAt: new Date(), updatedAt: new Date(), }, }); const user = await ctx.context.internalAdapter.findUserById( value.userId, ); if (!user) { throw new APIError("UNAUTHORIZED", { error_description: "user not found", error: "invalid_grant", }); } let secretKey = { alg: "HS256", key: await getWebcryptoSubtle().generateKey( { name: "HMAC", hash: "SHA-256", }, true, ["sign", "verify"], ), }; const profile = { given_name: user.name.split(" ")[0]!, family_name: user.name.split(" ")[1]!, name: user.name, profile: user.image, updated_at: user.updatedAt.toISOString(), }; const email = { email: user.email, email_verified: user.emailVerified, }; const userClaims = { ...(requestedScopes.includes("profile") ? profile : {}), ...(requestedScopes.includes("email") ? email : {}), }; const additionalUserClaims = opts.getAdditionalUserInfoClaim ? await opts.getAdditionalUserInfoClaim( user, requestedScopes, client, ) : {}; const idToken = await new SignJWT({ sub: user.id, aud: client_id.toString(), iat: Date.now(), auth_time: ctx.context.session ? new Date(ctx.context.session.session.createdAt).getTime() : undefined, nonce: value.nonce, acr: "urn:mace:incommon:iap:silver", // default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata ...userClaims, ...additionalUserClaims, }) .setProtectedHeader({ alg: secretKey.alg }) .setIssuedAt() .setExpirationTime( Math.floor(Date.now() / 1000) + opts.accessTokenExpiresIn, ) .sign(secretKey.key); return ctx.json( { access_token: accessToken, token_type: "Bearer", expires_in: opts.accessTokenExpiresIn, refresh_token: requestedScopes.includes("offline_access") ? refreshToken : undefined, scope: requestedScopes.join(" "), id_token: requestedScopes.includes("openid") ? idToken : undefined, }, { headers: { "Cache-Control": "no-store", Pragma: "no-cache", }, }, ); }, ), registerMcpClient: createAuthEndpoint( "/mcp/register", { method: "POST", body: z.object({ redirect_uris: z.array(z.string()), token_endpoint_auth_method: z .enum(["none", "client_secret_basic", "client_secret_post"]) .default("client_secret_basic") .optional(), grant_types: z .array( z.enum([ "authorization_code", "implicit", "password", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer", "urn:ietf:params:oauth:grant-type:saml2-bearer", ]), ) .default(["authorization_code"]) .optional(), response_types: z .array(z.enum(["code", "token"])) .default(["code"]) .optional(), client_name: z.string().optional(), client_uri: z.string().optional(), logo_uri: z.string().optional(), scope: z.string().optional(), contacts: z.array(z.string()).optional(), tos_uri: z.string().optional(), policy_uri: z.string().optional(), jwks_uri: z.string().optional(), jwks: z.record(z.string(), z.any()).optional(), metadata: z.record(z.any(), z.any()).optional(), software_id: z.string().optional(), software_version: z.string().optional(), software_statement: z.string().optional(), }), metadata: { openapi: { description: "Register an OAuth2 application", responses: { "200": { description: "OAuth2 application registered successfully", content: { "application/json": { schema: { type: "object", properties: { name: { type: "string", description: "Name of the OAuth2 application", }, icon: { type: "string", nullable: true, description: "Icon URL for the application", }, metadata: { type: "object", additionalProperties: true, nullable: true, description: "Additional metadata for the application", }, clientId: { type: "string", description: "Unique identifier for the client", }, clientSecret: { type: "string", description: "Secret key for the client. Not included for public clients.", }, redirectURLs: { type: "array", items: { type: "string", format: "uri" }, description: "List of allowed redirect URLs", }, type: { type: "string", description: "Type of the client", enum: ["web", "public"], }, authenticationScheme: { type: "string", description: "Authentication scheme used by the client", enum: ["client_secret", "none"], }, disabled: { type: "boolean", description: "Whether the client is disabled", enum: [false], }, userId: { type: "string", nullable: true, description: "ID of the user who registered the client, null if registered anonymously", }, createdAt: { type: "string", format: "date-time", description: "Creation timestamp", }, updatedAt: { type: "string", format: "date-time", description: "Last update timestamp", }, }, required: [ "name", "clientId", "redirectURLs", "type", "authenticationScheme", "disabled", "createdAt", "updatedAt", ], }, }, }, }, }, }, }, }, async (ctx) => { const body = ctx.body; const session = await getSessionFromCtx(ctx); ctx.setHeader("Access-Control-Allow-Origin", "*"); ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); ctx.setHeader( "Access-Control-Allow-Headers", "Content-Type, Authorization", ); ctx.setHeader("Access-Control-Max-Age", "86400"); ctx.headers?.set("Access-Control-Max-Age", "86400"); if ( (!body.grant_types || body.grant_types.includes("authorization_code") || body.grant_types.includes("implicit")) && (!body.redirect_uris || body.redirect_uris.length === 0) ) { throw new APIError("BAD_REQUEST", { error: "invalid_redirect_uri", error_description: "Redirect URIs are required for authorization_code and implicit grant types", }); } if (body.grant_types && body.response_types) { if ( body.grant_types.includes("authorization_code") && !body.response_types.includes("code") ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: "When 'authorization_code' grant type is used, 'code' response type must be included", }); } if ( body.grant_types.includes("implicit") && !body.response_types.includes("token") ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: "When 'implicit' grant type is used, 'token' response type must be included", }); } } const clientId = opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z"); const clientSecret = opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z"); // Determine client type based on auth method const clientType = body.token_endpoint_auth_method === "none" ? "public" : "web"; const finalClientSecret = clientType === "public" ? "" : clientSecret; await ctx.context.adapter.create({ model: modelName.oauthClient, data: { name: body.client_name, icon: body.logo_uri, metadata: body.metadata ? JSON.stringify(body.metadata) : null, clientId: clientId, clientSecret: finalClientSecret, redirectURLs: body.redirect_uris.join(","), type: clientType, authenticationScheme: body.token_endpoint_auth_method || "client_secret_basic", disabled: false, userId: session?.session.userId, createdAt: new Date(), updatedAt: new Date(), }, }); const responseData = { client_id: clientId, client_id_issued_at: Math.floor(Date.now() / 1000), redirect_uris: body.redirect_uris, token_endpoint_auth_method: body.token_endpoint_auth_method || "client_secret_basic", grant_types: body.grant_types || ["authorization_code"], response_types: body.response_types || ["code"], client_name: body.client_name, client_uri: body.client_uri, logo_uri: body.logo_uri, scope: body.scope, contacts: body.contacts, tos_uri: body.tos_uri, policy_uri: body.policy_uri, jwks_uri: body.jwks_uri, jwks: body.jwks, software_id: body.software_id, software_version: body.software_version, software_statement: body.software_statement, metadata: body.metadata, ...(clientType !== "public" ? { client_secret: finalClientSecret, client_secret_expires_at: 0, // 0 means it doesn't expire } : {}), }; return new Response(JSON.stringify(responseData), { status: 201, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", Pragma: "no-cache", }, }); }, ), getMcpSession: createAuthEndpoint( "/mcp/get-session", { method: "GET", requireHeaders: true, }, async (c) => { const accessToken = c.headers ?.get("Authorization") ?.replace("Bearer ", ""); if (!accessToken) { c.headers?.set("WWW-Authenticate", "Bearer"); return c.json(null); } const accessTokenData = await c.context.adapter.findOne<OAuthAccessToken>({ model: modelName.oauthAccessToken, where: [ { field: "accessToken", value: accessToken, }, ], }); if (!accessTokenData) { return c.json(null); } return c.json(accessTokenData); }, ), }, schema, } satisfies BetterAuthPlugin; }; export const withMcpAuth = < Auth extends { api: { getMcpSession: (...args: any) => Promise<OAuthAccessToken | null>; }; options: BetterAuthOptions; }, >( auth: Auth, handler: ( req: Request, sesssion: OAuthAccessToken, ) => Response | Promise<Response>, ) => { return async (req: Request) => { const baseURL = getBaseURL(auth.options.baseURL, auth.options.basePath); if (!baseURL && !isProduction) { logger.warn("Unable to get the baseURL, please check your config!"); } const session = await auth.api.getMcpSession({ headers: req.headers, }); const wwwAuthenticateValue = `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"`; if (!session) { return Response.json( { jsonrpc: "2.0", error: { code: -32000, message: "Unauthorized: Authentication required", "www-authenticate": wwwAuthenticateValue, }, id: null, }, { status: 401, headers: { "WWW-Authenticate": wwwAuthenticateValue, // we also add this headers otherwise browser based clients will not be able to read the `www-authenticate` header "Access-Control-Expose-Headers": "WWW-Authenticate", }, }, ); } return handler(req, session); }; }; export const oAuthDiscoveryMetadata = < Auth extends { api: { getMcpOAuthConfig: (...args: any) => any; }; }, >( auth: Auth, ) => { return async (request: Request) => { const res = await auth.api.getMcpOAuthConfig(); return new Response(JSON.stringify(res), { status: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", }, }); }; }; export const oAuthProtectedResourceMetadata = < Auth extends { api: { getMCPProtectedResource: (...args: any) => any; }; }, >( auth: Auth, ) => { return async (request: Request) => { const res = await auth.api.getMCPProtectedResource(); return new Response(JSON.stringify(res), { status: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", }, }); }; }; ``` -------------------------------------------------------------------------------- /packages/cli/src/commands/init.ts: -------------------------------------------------------------------------------- ```typescript import { parse } from "dotenv"; import semver from "semver"; import { format as prettierFormat } from "prettier"; import { Command } from "commander"; import * as z from "zod/v4"; import { existsSync } from "fs"; import path from "path"; import fs from "fs/promises"; import { getPackageInfo } from "../utils/get-package-info"; import chalk from "chalk"; import { cancel, confirm, intro, isCancel, log, multiselect, outro, select, spinner, text, } from "@clack/prompts"; import { installDependencies } from "../utils/install-dependencies"; import { checkPackageManagers } from "../utils/check-package-managers"; import { formatMilliseconds } from "../utils/format-ms"; import { generateSecretHash } from "./secret"; import { generateAuthConfig } from "../generators/auth-config"; import { getTsconfigInfo } from "../utils/get-tsconfig-info"; /** * Should only use any database that is core DBs, and supports the Better Auth CLI generate functionality. */ const supportedDatabases = [ // Built-in kysely "sqlite", "mysql", "mssql", "postgres", // Drizzle "drizzle:pg", "drizzle:mysql", "drizzle:sqlite", // Prisma "prisma:postgresql", "prisma:mysql", "prisma:sqlite", // Mongo "mongodb", ] as const; export type SupportedDatabases = (typeof supportedDatabases)[number]; export const supportedPlugins = [ { id: "two-factor", name: "twoFactor", path: `better-auth/plugins`, clientName: "twoFactorClient", clientPath: "better-auth/client/plugins", }, { id: "username", name: "username", clientName: "usernameClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "anonymous", name: "anonymous", clientName: "anonymousClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "phone-number", name: "phoneNumber", clientName: "phoneNumberClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "magic-link", name: "magicLink", clientName: "magicLinkClient", clientPath: "better-auth/client/plugins", path: `better-auth/plugins`, }, { id: "email-otp", name: "emailOTP", clientName: "emailOTPClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "passkey", name: "passkey", clientName: "passkeyClient", path: `better-auth/plugins/passkey`, clientPath: "better-auth/client/plugins", }, { id: "generic-oauth", name: "genericOAuth", clientName: "genericOAuthClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "one-tap", name: "oneTap", clientName: "oneTapClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "api-key", name: "apiKey", clientName: "apiKeyClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "admin", name: "admin", clientName: "adminClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "organization", name: "organization", clientName: "organizationClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "oidc", name: "oidcProvider", clientName: "oidcClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "sso", name: "sso", clientName: "ssoClient", path: `@better-auth/sso`, clientPath: "@better-auth/sso/client", }, { id: "bearer", name: "bearer", clientName: undefined, path: `better-auth/plugins`, clientPath: undefined, }, { id: "multi-session", name: "multiSession", clientName: "multiSessionClient", path: `better-auth/plugins`, clientPath: "better-auth/client/plugins", }, { id: "oauth-proxy", name: "oAuthProxy", clientName: undefined, path: `better-auth/plugins`, clientPath: undefined, }, { id: "open-api", name: "openAPI", clientName: undefined, path: `better-auth/plugins`, clientPath: undefined, }, { id: "jwt", name: "jwt", clientName: undefined, clientPath: undefined, path: `better-auth/plugins`, }, { id: "next-cookies", name: "nextCookies", clientPath: undefined, clientName: undefined, path: `better-auth/next-js`, }, ] as const; export type SupportedPlugin = (typeof supportedPlugins)[number]; const defaultFormatOptions = { trailingComma: "all" as const, useTabs: false, tabWidth: 4, }; const getDefaultAuthConfig = async ({ appName }: { appName?: string }) => await prettierFormat( [ "import { betterAuth } from 'better-auth';", "", "export const auth = betterAuth({", appName ? `appName: "${appName}",` : "", "plugins: [],", "});", ].join("\n"), { filepath: "auth.ts", ...defaultFormatOptions, }, ); type SupportedFrameworks = | "vanilla" | "react" | "vue" | "svelte" | "solid" | "nextjs"; type Import = { path: string; variables: | { asType?: boolean; name: string; as?: string }[] | { asType?: boolean; name: string; as?: string }; }; const getDefaultAuthClientConfig = async ({ auth_config_path, framework, clientPlugins, }: { framework: SupportedFrameworks; auth_config_path: string; clientPlugins: { id: string; name: string; contents: string; imports: Import[]; }[]; }) => { function groupImportVariables(): Import[] { const result: Import[] = [ { path: "better-auth/client/plugins", variables: [{ name: "inferAdditionalFields" }], }, ]; for (const plugin of clientPlugins) { for (const import_ of plugin.imports) { if (Array.isArray(import_.variables)) { for (const variable of import_.variables) { const existingIndex = result.findIndex( (x) => x.path === import_.path, ); if (existingIndex !== -1) { const vars = result[existingIndex]!.variables; if (Array.isArray(vars)) { vars.push(variable); } else { result[existingIndex]!.variables = [vars, variable]; } } else { result.push({ path: import_.path, variables: [variable], }); } } } else { const existingIndex = result.findIndex( (x) => x.path === import_.path, ); if (existingIndex !== -1) { const vars = result[existingIndex]!.variables; if (Array.isArray(vars)) { vars.push(import_.variables); } else { result[existingIndex]!.variables = [vars, import_.variables]; } } else { result.push({ path: import_.path, variables: [import_.variables], }); } } } } return result; } let imports = groupImportVariables(); let importString = ""; for (const import_ of imports) { if (Array.isArray(import_.variables)) { importString += `import { ${import_.variables .map( (x) => `${x.asType ? "type " : ""}${x.name}${x.as ? ` as ${x.as}` : ""}`, ) .join(", ")} } from "${import_.path}";\n`; } else { importString += `import ${import_.variables.asType ? "type " : ""}${ import_.variables.name }${import_.variables.as ? ` as ${import_.variables.as}` : ""} from "${ import_.path }";\n`; } } return await prettierFormat( [ `import { createAuthClient } from "better-auth/${ framework === "nextjs" ? "react" : framework === "vanilla" ? "client" : framework }";`, `import type { auth } from "${auth_config_path}";`, importString, ``, `export const authClient = createAuthClient({`, `baseURL: "http://localhost:3000",`, `plugins: [inferAdditionalFields<typeof auth>(),${clientPlugins .map((x) => `${x.name}(${x.contents})`) .join(", ")}],`, `});`, ].join("\n"), { filepath: "auth-client.ts", ...defaultFormatOptions, }, ); }; const optionsSchema = z.object({ cwd: z.string(), config: z.string().optional(), database: z.enum(supportedDatabases).optional(), "skip-db": z.boolean().optional(), "skip-plugins": z.boolean().optional(), "package-manager": z.string().optional(), tsconfig: z.string().optional(), }); const outroText = `🥳 All Done, Happy Hacking!`; export async function initAction(opts: any) { console.log(); intro("👋 Initializing Better Auth"); const options = optionsSchema.parse(opts); const cwd = path.resolve(options.cwd); let packageManagerPreference: "bun" | "pnpm" | "yarn" | "npm" | undefined = undefined; let config_path: string = ""; let framework: SupportedFrameworks = "vanilla"; const format = async (code: string) => await prettierFormat(code, { filepath: config_path, ...defaultFormatOptions, }); // ===== package.json ===== let packageInfo: Record<string, any>; try { packageInfo = getPackageInfo(cwd); } catch (error) { log.error(`❌ Couldn't read your package.json file. (dir: ${cwd})`); log.error(JSON.stringify(error, null, 2)); process.exit(1); } // ===== ENV files ===== const envFiles = await getEnvFiles(cwd); if (!envFiles.length) { outro("❌ No .env files found. Please create an env file first."); process.exit(0); } let targetEnvFile: string; if (envFiles.includes(".env")) targetEnvFile = ".env"; else if (envFiles.includes(".env.local")) targetEnvFile = ".env.local"; else if (envFiles.includes(".env.development")) targetEnvFile = ".env.development"; else if (envFiles.length === 1) targetEnvFile = envFiles[0]!; else targetEnvFile = "none"; // ===== tsconfig.json ===== let tsconfigInfo: Record<string, any>; try { const tsconfigPath = options.tsconfig !== undefined ? path.resolve(cwd, options.tsconfig) : path.join(cwd, "tsconfig.json"); tsconfigInfo = await getTsconfigInfo(cwd, tsconfigPath); } catch (error) { log.error(`❌ Couldn't read your tsconfig.json file. (dir: ${cwd})`); console.error(error); process.exit(1); } if ( !( "compilerOptions" in tsconfigInfo && "strict" in tsconfigInfo.compilerOptions && tsconfigInfo.compilerOptions.strict === true ) ) { log.warn( `Better Auth requires your tsconfig.json to have "compilerOptions.strict" set to true.`, ); const shouldAdd = await confirm({ message: `Would you like us to set ${chalk.bold( `strict`, )} to ${chalk.bold(`true`)}?`, }); if (isCancel(shouldAdd)) { cancel(`✋ Operation cancelled.`); process.exit(0); } if (shouldAdd) { try { await fs.writeFile( path.join(cwd, "tsconfig.json"), await prettierFormat( JSON.stringify( Object.assign(tsconfigInfo, { compilerOptions: { strict: true, }, }), ), { filepath: "tsconfig.json", ...defaultFormatOptions }, ), "utf-8", ); log.success(`🚀 tsconfig.json successfully updated!`); } catch (error) { log.error( `Failed to add "compilerOptions.strict" to your tsconfig.json file.`, ); console.error(error); process.exit(1); } } } // ===== install better-auth ===== const s = spinner({ indicator: "dots" }); s.start(`Checking better-auth installation`); let latest_betterauth_version: string; try { latest_betterauth_version = await getLatestNpmVersion("better-auth"); } catch (error) { log.error(`❌ Couldn't get latest version of better-auth.`); console.error(error); process.exit(1); } if ( !packageInfo.dependencies || !Object.keys(packageInfo.dependencies).includes("better-auth") ) { s.stop("Finished fetching latest version of better-auth."); const s2 = spinner({ indicator: "dots" }); const shouldInstallBetterAuthDep = await confirm({ message: `Would you like to install Better Auth?`, }); if (isCancel(shouldInstallBetterAuthDep)) { cancel(`✋ Operation cancelled.`); process.exit(0); } if (packageManagerPreference === undefined) { packageManagerPreference = await getPackageManager(); } if (shouldInstallBetterAuthDep) { s2.start( `Installing Better Auth using ${chalk.bold(packageManagerPreference)}`, ); try { const start = Date.now(); await installDependencies({ dependencies: ["better-auth@latest"], packageManager: packageManagerPreference, cwd: cwd, }); s2.stop( `Better Auth installed ${chalk.greenBright( `successfully`, )}! ${chalk.gray(`(${formatMilliseconds(Date.now() - start)})`)}`, ); } catch (error: any) { s2.stop(`Failed to install Better Auth:`); console.error(error); process.exit(1); } } } else if ( packageInfo.dependencies["better-auth"] !== "workspace:*" && semver.lt( semver.coerce(packageInfo.dependencies["better-auth"])?.toString()!, semver.clean(latest_betterauth_version)!, ) ) { s.stop("Finished fetching latest version of better-auth."); const shouldInstallBetterAuthDep = await confirm({ message: `Your current Better Auth dependency is out-of-date. Would you like to update it? (${chalk.bold( packageInfo.dependencies["better-auth"], )} → ${chalk.bold(`v${latest_betterauth_version}`)})`, }); if (isCancel(shouldInstallBetterAuthDep)) { cancel(`✋ Operation cancelled.`); process.exit(0); } if (shouldInstallBetterAuthDep) { if (packageManagerPreference === undefined) { packageManagerPreference = await getPackageManager(); } const s = spinner({ indicator: "dots" }); s.start( `Updating Better Auth using ${chalk.bold(packageManagerPreference)}`, ); try { const start = Date.now(); await installDependencies({ dependencies: ["better-auth@latest"], packageManager: packageManagerPreference, cwd: cwd, }); s.stop( `Better Auth updated ${chalk.greenBright( `successfully`, )}! ${chalk.gray(`(${formatMilliseconds(Date.now() - start)})`)}`, ); } catch (error: any) { s.stop(`Failed to update Better Auth:`); log.error(error.message); process.exit(1); } } } else { s.stop(`Better Auth dependencies are ${chalk.greenBright(`up to date`)}!`); } // ===== appName ===== const packageJson = getPackageInfo(cwd); let appName: string; if (!packageJson.name) { const newAppName = await text({ message: "What is the name of your application?", }); if (isCancel(newAppName)) { cancel("✋ Operation cancelled."); process.exit(0); } appName = newAppName; } else { appName = packageJson.name; } // ===== config path ===== let possiblePaths = ["auth.ts", "auth.tsx", "auth.js", "auth.jsx"]; possiblePaths = [ ...possiblePaths, ...possiblePaths.map((it) => `lib/server/${it}`), ...possiblePaths.map((it) => `server/${it}`), ...possiblePaths.map((it) => `lib/${it}`), ...possiblePaths.map((it) => `utils/${it}`), ]; possiblePaths = [ ...possiblePaths, ...possiblePaths.map((it) => `src/${it}`), ...possiblePaths.map((it) => `app/${it}`), ]; if (options.config) { config_path = path.join(cwd, options.config); } else { for (const possiblePath of possiblePaths) { const doesExist = existsSync(path.join(cwd, possiblePath)); if (doesExist) { config_path = path.join(cwd, possiblePath); break; } } } // ===== create auth config ===== let current_user_config = ""; let database: SupportedDatabases | null = null; let add_plugins: SupportedPlugin[] = []; if (!config_path) { const shouldCreateAuthConfig = await select({ message: `Would you like to create an auth config file?`, options: [ { label: "Yes", value: "yes" }, { label: "No", value: "no" }, ], }); if (isCancel(shouldCreateAuthConfig)) { cancel(`✋ Operation cancelled.`); process.exit(0); } if (shouldCreateAuthConfig === "yes") { const shouldSetupDb = await confirm({ message: `Would you like to set up your ${chalk.bold(`database`)}?`, initialValue: true, }); if (isCancel(shouldSetupDb)) { cancel(`✋ Operating cancelled.`); process.exit(0); } if (shouldSetupDb) { const prompted_database = await select({ message: "Choose a Database Dialect", options: supportedDatabases.map((it) => ({ value: it, label: it })), }); if (isCancel(prompted_database)) { cancel(`✋ Operating cancelled.`); process.exit(0); } database = prompted_database; } if (options["skip-plugins"] !== false) { const shouldSetupPlugins = await confirm({ message: `Would you like to set up ${chalk.bold(`plugins`)}?`, }); if (isCancel(shouldSetupPlugins)) { cancel(`✋ Operating cancelled.`); process.exit(0); } if (shouldSetupPlugins) { const prompted_plugins = await multiselect({ message: "Select your new plugins", options: supportedPlugins .filter((x) => x.id !== "next-cookies") .map((x) => ({ value: x.id, label: x.id })), required: false, }); if (isCancel(prompted_plugins)) { cancel(`✋ Operating cancelled.`); process.exit(0); } add_plugins = prompted_plugins.map( (x) => supportedPlugins.find((y) => y.id === x)!, ); const possible_next_config_paths = [ "next.config.js", "next.config.ts", "next.config.mjs", ".next/server/next.config.js", ".next/server/next.config.ts", ".next/server/next.config.mjs", ]; for (const possible_next_config_path of possible_next_config_paths) { if (existsSync(path.join(cwd, possible_next_config_path))) { framework = "nextjs"; break; } } if (framework === "nextjs") { const result = await confirm({ message: `It looks like you're using NextJS. Do you want to add the next-cookies plugin? ${chalk.bold( `(Recommended)`, )}`, }); if (isCancel(result)) { cancel(`✋ Operating cancelled.`); process.exit(0); } if (result) { add_plugins.push( supportedPlugins.find((x) => x.id === "next-cookies")!, ); } } } } const filePath = path.join(cwd, "auth.ts"); config_path = filePath; log.info(`Creating auth config file: ${filePath}`); try { current_user_config = await getDefaultAuthConfig({ appName, }); const { dependencies, envs, generatedCode } = await generateAuthConfig({ current_user_config, format, //@ts-expect-error s, plugins: add_plugins, database, }); current_user_config = generatedCode; await fs.writeFile(filePath, current_user_config); config_path = filePath; log.success(`🚀 Auth config file successfully created!`); if (envs.length !== 0) { log.info( `There are ${envs.length} environment variables for your database of choice.`, ); const shouldUpdateEnvs = await confirm({ message: `Would you like us to update your ENV files?`, }); if (isCancel(shouldUpdateEnvs)) { cancel("✋ Operation cancelled."); process.exit(0); } if (shouldUpdateEnvs) { const filesToUpdate = await multiselect({ message: "Select the .env files you want to update", options: envFiles.map((x) => ({ value: path.join(cwd, x), label: x, })), required: false, }); if (isCancel(filesToUpdate)) { cancel("✋ Operation cancelled."); process.exit(0); } if (filesToUpdate.length === 0) { log.info("No .env files to update. Skipping..."); } else { try { await updateEnvs({ files: filesToUpdate, envs, isCommented: true, }); } catch (error) { log.error(`Failed to update .env files:`); log.error(JSON.stringify(error, null, 2)); process.exit(1); } log.success(`🚀 ENV files successfully updated!`); } } } if (dependencies.length !== 0) { log.info( `There are ${ dependencies.length } dependencies to install. (${dependencies .map((x) => chalk.green(x)) .join(", ")})`, ); const shouldInstallDeps = await confirm({ message: `Would you like us to install dependencies?`, }); if (isCancel(shouldInstallDeps)) { cancel("✋ Operation cancelled."); process.exit(0); } if (shouldInstallDeps) { const s = spinner({ indicator: "dots" }); if (packageManagerPreference === undefined) { packageManagerPreference = await getPackageManager(); } s.start( `Installing dependencies using ${chalk.bold( packageManagerPreference, )}...`, ); try { const start = Date.now(); await installDependencies({ dependencies: dependencies, packageManager: packageManagerPreference, cwd: cwd, }); s.stop( `Dependencies installed ${chalk.greenBright( `successfully`, )} ${chalk.gray( `(${formatMilliseconds(Date.now() - start)})`, )}`, ); } catch (error: any) { s.stop( `Failed to install dependencies using ${packageManagerPreference}:`, ); log.error(error.message); process.exit(1); } } } } catch (error) { log.error(`Failed to create auth config file: ${filePath}`); console.error(error); process.exit(1); } } else if (shouldCreateAuthConfig === "no") { log.info(`Skipping auth config file creation.`); } } else { log.message(); log.success(`Found auth config file. ${chalk.gray(`(${config_path})`)}`); log.message(); } // ===== auth client path ===== let possibleClientPaths = [ "auth-client.ts", "auth-client.tsx", "auth-client.js", "auth-client.jsx", "client.ts", "client.tsx", "client.js", "client.jsx", ]; possibleClientPaths = [ ...possibleClientPaths, ...possibleClientPaths.map((it) => `lib/server/${it}`), ...possibleClientPaths.map((it) => `server/${it}`), ...possibleClientPaths.map((it) => `lib/${it}`), ...possibleClientPaths.map((it) => `utils/${it}`), ]; possibleClientPaths = [ ...possibleClientPaths, ...possibleClientPaths.map((it) => `src/${it}`), ...possibleClientPaths.map((it) => `app/${it}`), ]; let authClientConfigPath: string | null = null; for (const possiblePath of possibleClientPaths) { const doesExist = existsSync(path.join(cwd, possiblePath)); if (doesExist) { authClientConfigPath = path.join(cwd, possiblePath); break; } } if (!authClientConfigPath) { const choice = await select({ message: `Would you like to create an auth client config file?`, options: [ { label: "Yes", value: "yes" }, { label: "No", value: "no" }, ], }); if (isCancel(choice)) { cancel(`✋ Operation cancelled.`); process.exit(0); } if (choice === "yes") { authClientConfigPath = path.join(cwd, "auth-client.ts"); log.info(`Creating auth client config file: ${authClientConfigPath}`); try { let contents = await getDefaultAuthClientConfig({ auth_config_path: ( "./" + path.join(config_path.replace(cwd, "")) ).replace(".//", "./"), clientPlugins: add_plugins .filter((x) => x.clientName) .map((plugin) => { let contents = ""; if (plugin.id === "one-tap") { contents = `{ clientId: "MY_CLIENT_ID" }`; } return { contents, id: plugin.id, name: plugin.clientName!, imports: [ { path: "better-auth/client/plugins", variables: [{ name: plugin.clientName! }], }, ], }; }), framework: framework, }); await fs.writeFile(authClientConfigPath, contents); log.success(`🚀 Auth client config file successfully created!`); } catch (error) { log.error( `Failed to create auth client config file: ${authClientConfigPath}`, ); log.error(JSON.stringify(error, null, 2)); process.exit(1); } } else if (choice === "no") { log.info(`Skipping auth client config file creation.`); } } else { log.success( `Found auth client config file. ${chalk.gray( `(${authClientConfigPath})`, )}`, ); } if (targetEnvFile !== "none") { try { const fileContents = await fs.readFile( path.join(cwd, targetEnvFile), "utf8", ); const parsed = parse(fileContents); let isMissingSecret = false; let isMissingUrl = false; if (parsed.BETTER_AUTH_SECRET === undefined) isMissingSecret = true; if (parsed.BETTER_AUTH_URL === undefined) isMissingUrl = true; if (isMissingSecret || isMissingUrl) { let txt = ""; if (isMissingSecret && !isMissingUrl) txt = chalk.bold(`BETTER_AUTH_SECRET`); else if (!isMissingSecret && isMissingUrl) txt = chalk.bold(`BETTER_AUTH_URL`); else txt = chalk.bold.underline(`BETTER_AUTH_SECRET`) + ` and ` + chalk.bold.underline(`BETTER_AUTH_URL`); log.warn(`Missing ${txt} in ${targetEnvFile}`); const shouldAdd = await select({ message: `Do you want to add ${txt} to ${targetEnvFile}?`, options: [ { label: "Yes", value: "yes" }, { label: "No", value: "no" }, { label: "Choose other file(s)", value: "other" }, ], }); if (isCancel(shouldAdd)) { cancel(`✋ Operation cancelled.`); process.exit(0); } let envs: string[] = []; if (isMissingSecret) { envs.push("BETTER_AUTH_SECRET"); } if (isMissingUrl) { envs.push("BETTER_AUTH_URL"); } if (shouldAdd === "yes") { try { await updateEnvs({ files: [path.join(cwd, targetEnvFile)], envs: envs, isCommented: false, }); } catch (error) { log.error(`Failed to add ENV variables to ${targetEnvFile}`); log.error(JSON.stringify(error, null, 2)); process.exit(1); } log.success(`🚀 ENV variables successfully added!`); if (isMissingUrl) { log.info( `Be sure to update your BETTER_AUTH_URL according to your app's needs.`, ); } } else if (shouldAdd === "no") { log.info(`Skipping ENV step.`); } else if (shouldAdd === "other") { if (!envFiles.length) { cancel("No env files found. Please create an env file first."); process.exit(0); } const envFilesToUpdate = await multiselect({ message: "Select the .env files you want to update", options: envFiles.map((x) => ({ value: path.join(cwd, x), label: x, })), required: false, }); if (isCancel(envFilesToUpdate)) { cancel("✋ Operation cancelled."); process.exit(0); } if (envFilesToUpdate.length === 0) { log.info("No .env files to update. Skipping..."); } else { try { await updateEnvs({ files: envFilesToUpdate, envs: envs, isCommented: false, }); } catch (error) { log.error(`Failed to update .env files:`); log.error(JSON.stringify(error, null, 2)); process.exit(1); } log.success(`🚀 ENV files successfully updated!`); } } } } catch (error) { // if fails, ignore, and do not proceed with ENV operations. } } outro(outroText); console.log(); process.exit(0); } // ===== Init Command ===== export const init = new Command("init") .option("-c, --cwd <cwd>", "The working directory.", process.cwd()) .option( "--config <config>", "The path to the auth configuration file. defaults to the first `auth.ts` file found.", ) .option("--tsconfig <tsconfig>", "The path to the tsconfig file.") .option("--skip-db", "Skip the database setup.") .option("--skip-plugins", "Skip the plugins setup.") .option( "--package-manager <package-manager>", "The package manager you want to use.", ) .action(initAction); async function getLatestNpmVersion(packageName: string): Promise<string> { try { const response = await fetch(`https://registry.npmjs.org/${packageName}`); if (!response.ok) { throw new Error(`Package not found: ${response.statusText}`); } const data = await response.json(); return data["dist-tags"].latest; // Get the latest version from dist-tags } catch (error: any) { throw error?.message; } } async function getPackageManager() { const { hasBun, hasPnpm } = await checkPackageManagers(); if (!hasBun && !hasPnpm) return "npm"; const packageManagerOptions: { value: "bun" | "pnpm" | "yarn" | "npm"; label?: string; hint?: string; }[] = []; if (hasPnpm) { packageManagerOptions.push({ value: "pnpm", label: "pnpm", hint: "recommended", }); } if (hasBun) { packageManagerOptions.push({ value: "bun", label: "bun", }); } packageManagerOptions.push({ value: "npm", hint: "not recommended", }); let packageManager = await select({ message: "Choose a package manager", options: packageManagerOptions, }); if (isCancel(packageManager)) { cancel(`Operation cancelled.`); process.exit(0); } return packageManager; } async function getEnvFiles(cwd: string) { const files = await fs.readdir(cwd); return files.filter((x) => x.startsWith(".env")); } async function updateEnvs({ envs, files, isCommented, }: { /** * The ENVs to append to the file */ envs: string[]; /** * Full file paths */ files: string[]; /** * Whether to comment the all of the envs or not */ isCommented: boolean; }) { let previouslyGeneratedSecret: string | null = null; for (const file of files) { const content = await fs.readFile(file, "utf8"); const lines = content.split("\n"); const newLines = envs.map( (x) => `${isCommented ? "# " : ""}${x}=${ getEnvDescription(x) ?? `"some_value"` }`, ); newLines.push(""); newLines.push(...lines); await fs.writeFile(file, newLines.join("\n"), "utf8"); } function getEnvDescription(env: string) { if (env === "DATABASE_HOST") { return `"The host of your database"`; } if (env === "DATABASE_PORT") { return `"The port of your database"`; } if (env === "DATABASE_USER") { return `"The username of your database"`; } if (env === "DATABASE_PASSWORD") { return `"The password of your database"`; } if (env === "DATABASE_NAME") { return `"The name of your database"`; } if (env === "DATABASE_URL") { return `"The URL of your database"`; } if (env === "BETTER_AUTH_SECRET") { previouslyGeneratedSecret = previouslyGeneratedSecret ?? generateSecretHash(); return `"${previouslyGeneratedSecret}"`; } if (env === "BETTER_AUTH_URL") { return `"http://localhost:3000" # Your APP URL`; } } } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/email-otp/index.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { APIError, getSessionFromCtx } from "../../api"; import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; import type { BetterAuthPlugin } from "@better-auth/core"; import { generateRandomString, symmetricDecrypt, symmetricEncrypt, } from "../../crypto"; import { getDate } from "../../utils/date"; import { setCookieCache, setSessionCookie } from "../../cookies"; import { getEndpointResponse } from "../../utils/plugin-helper"; import { defaultKeyHasher, splitAtLastColon } from "./utils"; import type { GenericEndpointContext } from "@better-auth/core"; import { defineErrorCodes } from "@better-auth/core/utils"; export interface EmailOTPOptions { /** * Function to send email verification */ sendVerificationOTP: ( data: { email: string; otp: string; type: "sign-in" | "email-verification" | "forget-password"; }, request?: Request, ) => Promise<void>; /** * Length of the OTP * * @default 6 */ otpLength?: number; /** * Expiry time of the OTP in seconds * * @default 300 (5 minutes) */ expiresIn?: number; /** * Custom function to generate otp */ generateOTP?: ( data: { email: string; type: "sign-in" | "email-verification" | "forget-password"; }, request?: Request, ) => string | undefined; /** * Send email verification on sign-up * * @Default false */ sendVerificationOnSignUp?: boolean; /** * A boolean value that determines whether to prevent * automatic sign-up when the user is not registered. * * @Default false */ disableSignUp?: boolean; /** * Allowed attempts for the OTP code * @default 3 */ allowedAttempts?: number; /** * Store the OTP in your database in a secure way * Note: This will not affect the OTP sent to the user, it will only affect the OTP stored in your database * * @default "plain" */ storeOTP?: | "hashed" | "plain" | "encrypted" | { hash: (otp: string) => Promise<string> } | { encrypt: (otp: string) => Promise<string>; decrypt: (otp: string) => Promise<string>; }; /** * Override the default email verification to use email otp instead * * @default false */ overrideDefaultEmailVerification?: boolean; } const types = ["email-verification", "sign-in", "forget-password"] as const; const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const defaultOTPGenerator = (options: EmailOTPOptions) => generateRandomString(options.otpLength ?? 6, "0-9"); const ERROR_CODES = defineErrorCodes({ OTP_EXPIRED: "otp expired", INVALID_OTP: "Invalid OTP", INVALID_EMAIL: "Invalid email", USER_NOT_FOUND: "User not found", TOO_MANY_ATTEMPTS: "Too many attempts", }); export const emailOTP = (options: EmailOTPOptions) => { const opts = { expiresIn: 5 * 60, generateOTP: () => defaultOTPGenerator(options), storeOTP: "plain", ...options, } satisfies EmailOTPOptions; async function storeOTP(ctx: GenericEndpointContext, otp: string) { if (opts.storeOTP === "encrypted") { return await symmetricEncrypt({ key: ctx.context.secret, data: otp, }); } if (opts.storeOTP === "hashed") { return await defaultKeyHasher(otp); } if (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) { return await opts.storeOTP.hash(otp); } if (typeof opts.storeOTP === "object" && "encrypt" in opts.storeOTP) { return await opts.storeOTP.encrypt(otp); } return otp; } async function verifyStoredOTP( ctx: GenericEndpointContext, storedOtp: string, otp: string, ): Promise<boolean> { if (opts.storeOTP === "encrypted") { return ( (await symmetricDecrypt({ key: ctx.context.secret, data: storedOtp, })) === otp ); } if (opts.storeOTP === "hashed") { const hashedOtp = await defaultKeyHasher(otp); return hashedOtp === storedOtp; } if (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) { const hashedOtp = await opts.storeOTP.hash(otp); return hashedOtp === storedOtp; } if (typeof opts.storeOTP === "object" && "decrypt" in opts.storeOTP) { const decryptedOtp = await opts.storeOTP.decrypt(storedOtp); return decryptedOtp === otp; } return otp === storedOtp; } const endpoints = { /** * ### Endpoint * * POST `/email-otp/send-verification-otp` * * ### API Methods * * **server:** * `auth.api.sendVerificationOTP` * * **client:** * `authClient.emailOtp.sendVerificationOtp` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-email-otp-send-verification-otp) */ sendVerificationOTP: createAuthEndpoint( "/email-otp/send-verification-otp", { method: "POST", body: z.object({ email: z.string({}).meta({ description: "Email address to send the OTP", }), type: z.enum(types).meta({ description: "Type of the OTP", }), }), metadata: { openapi: { description: "Send verification OTP", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { if (!options?.sendVerificationOTP) { ctx.context.logger.error( "send email verification is not implemented", ); throw new APIError("BAD_REQUEST", { message: "send email verification is not implemented", }); } const email = ctx.body.email; if (!emailRegex.test(email)) { throw ctx.error("BAD_REQUEST", { message: ERROR_CODES.INVALID_EMAIL, }); } if (opts.disableSignUp) { const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.USER_NOT_FOUND, }); } } else if (ctx.body.type === "forget-password") { const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { return ctx.json({ success: true, }); } } let otp = opts.generateOTP({ email, type: ctx.body.type }, ctx.request) || defaultOTPGenerator(opts); let storedOTP = await storeOTP(ctx, otp); await ctx.context.internalAdapter .createVerificationValue({ value: `${storedOTP}:0`, identifier: `${ctx.body.type}-otp-${email}`, expiresAt: getDate(opts.expiresIn, "sec"), }) .catch(async (error) => { // might be duplicate key error await ctx.context.internalAdapter.deleteVerificationByIdentifier( `${ctx.body.type}-otp-${email}`, ); //try again await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, identifier: `${ctx.body.type}-otp-${email}`, expiresAt: getDate(opts.expiresIn, "sec"), }); }); await options.sendVerificationOTP( { email, otp, type: ctx.body.type, }, ctx.request, ); return ctx.json({ success: true, }); }, ), }; return { id: "email-otp", init(ctx) { if (!opts.overrideDefaultEmailVerification) { return; } return { options: { emailVerification: { async sendVerificationEmail(data, request) { await endpoints.sendVerificationOTP({ //@ts-expect-error - we need to pass the context context: ctx, request: request, body: { email: data.user.email, type: "email-verification", }, ctx, }); }, }, }, }; }, endpoints: { ...endpoints, createVerificationOTP: createAuthEndpoint( "/email-otp/create-verification-otp", { method: "POST", body: z.object({ email: z.string({}).meta({ description: "Email address to send the OTP", }), type: z.enum(types).meta({ required: true, description: "Type of the OTP", }), }), metadata: { SERVER_ONLY: true, openapi: { description: "Create verification OTP", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "string", }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.body.email; const otp = opts.generateOTP({ email, type: ctx.body.type }, ctx.request) || defaultOTPGenerator(opts); let storedOTP = await storeOTP(ctx, otp); await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, identifier: `${ctx.body.type}-otp-${email}`, expiresAt: getDate(opts.expiresIn, "sec"), }); return otp; }, ), /** * ### Endpoint * * GET `/email-otp/get-verification-otp` * * ### API Methods * * **server:** * `auth.api.getVerificationOTP` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-email-otp-get-verification-otp) */ getVerificationOTP: createAuthEndpoint( "/email-otp/get-verification-otp", { method: "GET", query: z.object({ email: z.string({}).meta({ description: "Email address the OTP was sent to", }), type: z.enum(types).meta({ required: true, description: "Type of the OTP", }), }), metadata: { SERVER_ONLY: true, openapi: { description: "Get verification OTP", responses: { "200": { description: "OTP retrieved successfully or not found/expired", content: { "application/json": { schema: { type: "object", properties: { otp: { type: "string", nullable: true, description: "The stored OTP, or null if not found or expired", }, }, required: ["otp"], }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.query.email; const verificationValue = await ctx.context.internalAdapter.findVerificationValue( `${ctx.query.type}-otp-${email}`, ); if (!verificationValue || verificationValue.expiresAt < new Date()) { return ctx.json({ otp: null, }); } if ( opts.storeOTP === "hashed" || (typeof opts.storeOTP === "object" && "hash" in opts.storeOTP) ) { throw new APIError("BAD_REQUEST", { message: "OTP is hashed, cannot return the plain text OTP", }); } let [storedOtp, _attempts] = splitAtLastColon( verificationValue.value, ); let otp = storedOtp; if (opts.storeOTP === "encrypted") { otp = await symmetricDecrypt({ key: ctx.context.secret, data: storedOtp, }); } if (typeof opts.storeOTP === "object" && "decrypt" in opts.storeOTP) { otp = await opts.storeOTP.decrypt(storedOtp); } return ctx.json({ otp, }); }, ), /** * ### Endpoint * * GET `/email-otp/check-verification-otp` * * ### API Methods * * **server:** * `auth.api.checkVerificationOTP` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-email-otp-check-verification-otp) */ checkVerificationOTP: createAuthEndpoint( "/email-otp/check-verification-otp", { method: "POST", body: z.object({ email: z.string().meta({ description: "Email address the OTP was sent to", }), type: z.enum(types).meta({ required: true, description: "Type of the OTP", }), otp: z.string().meta({ required: true, description: "OTP to verify", }), }), metadata: { openapi: { description: "Check if a verification OTP is valid", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.body.email; if (!emailRegex.test(email)) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_EMAIL, }); } const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.USER_NOT_FOUND, }); } const verificationValue = await ctx.context.internalAdapter.findVerificationValue( `${ctx.body.type}-otp-${email}`, ); if (!verificationValue) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } if (verificationValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); throw new APIError("BAD_REQUEST", { message: ERROR_CODES.OTP_EXPIRED, }); } const [otpValue, attempts] = splitAtLastColon( verificationValue.value, ); const allowedAttempts = options?.allowedAttempts || 3; if (attempts && parseInt(attempts) >= allowedAttempts) { await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); throw new APIError("FORBIDDEN", { message: ERROR_CODES.TOO_MANY_ATTEMPTS, }); } const verified = await verifyStoredOTP(ctx, otpValue, ctx.body.otp); if (!verified) { await ctx.context.internalAdapter.updateVerificationValue( verificationValue.id, { value: `${otpValue}:${parseInt(attempts || "0") + 1}`, }, ); throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } return ctx.json({ success: true, }); }, ), /** * ### Endpoint * * POST `/email-otp/verify-email` * * ### API Methods * * **server:** * `auth.api.verifyEmailOTP` * * **client:** * `authClient.emailOtp.verifyEmail` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-email-otp-verify-email) */ verifyEmailOTP: createAuthEndpoint( "/email-otp/verify-email", { method: "POST", body: z.object({ email: z.string({}).meta({ description: "Email address to verify", }), otp: z.string().meta({ required: true, description: "OTP to verify", }), }), metadata: { openapi: { description: "Verify email with OTP", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", description: "Indicates if the verification was successful", enum: [true], }, token: { type: "string", nullable: true, description: "Session token if autoSignInAfterVerification is enabled, otherwise null", }, user: { $ref: "#/components/schemas/User", }, required: ["status", "token", "user"], }, }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.body.email; if (!emailRegex.test(email)) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_EMAIL, }); } const verificationValue = await ctx.context.internalAdapter.findVerificationValue( `email-verification-otp-${email}`, ); if (!verificationValue) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } if (verificationValue.expiresAt < new Date()) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.OTP_EXPIRED, }); } const [otpValue, attempts] = splitAtLastColon( verificationValue.value, ); const allowedAttempts = options?.allowedAttempts || 3; if (attempts && parseInt(attempts) >= allowedAttempts) { await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); throw new APIError("FORBIDDEN", { message: ERROR_CODES.TOO_MANY_ATTEMPTS, }); } const verified = await verifyStoredOTP(ctx, otpValue, ctx.body.otp); if (!verified) { await ctx.context.internalAdapter.updateVerificationValue( verificationValue.id, { value: `${otpValue}:${parseInt(attempts || "0") + 1}`, }, ); throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.USER_NOT_FOUND, }); } const updatedUser = await ctx.context.internalAdapter.updateUser( user.user.id, { email, emailVerified: true, }, ); await ctx.context.options.emailVerification?.onEmailVerification?.( updatedUser, ctx.request, ); if ( ctx.context.options.emailVerification?.autoSignInAfterVerification ) { const session = await ctx.context.internalAdapter.createSession( updatedUser.id, ); await setSessionCookie(ctx, { session, user: updatedUser, }); return ctx.json({ status: true, token: session.token, user: { id: updatedUser.id, email: updatedUser.email, emailVerified: updatedUser.emailVerified, name: updatedUser.name, image: updatedUser.image, createdAt: updatedUser.createdAt, updatedAt: updatedUser.updatedAt, }, }); } const currentSession = await getSessionFromCtx(ctx); if (currentSession && updatedUser.emailVerified) { const dontRememberMeCookie = await ctx.getSignedCookie( ctx.context.authCookies.dontRememberToken.name, ctx.context.secret, ); await setCookieCache( ctx, { session: currentSession.session, user: { ...currentSession.user, emailVerified: true, }, }, !!dontRememberMeCookie, ); } return ctx.json({ status: true, token: null, user: { id: updatedUser.id, email: updatedUser.email, emailVerified: updatedUser.emailVerified, name: updatedUser.name, image: updatedUser.image, createdAt: updatedUser.createdAt, updatedAt: updatedUser.updatedAt, }, }); }, ), /** * ### Endpoint * * POST `/sign-in/email-otp` * * ### API Methods * * **server:** * `auth.api.signInEmailOTP` * * **client:** * `authClient.signIn.emailOtp` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-sign-in-email-otp) */ signInEmailOTP: createAuthEndpoint( "/sign-in/email-otp", { method: "POST", body: z.object({ email: z.string({}).meta({ description: "Email address to sign in", }), otp: z.string().meta({ required: true, description: "OTP sent to the email", }), }), metadata: { openapi: { description: "Sign in with OTP", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string", description: "Session token for the authenticated session", }, user: { $ref: "#/components/schemas/User", }, }, required: ["token", "user"], }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.body.email; const verificationValue = await ctx.context.internalAdapter.findVerificationValue( `sign-in-otp-${email}`, ); if (!verificationValue) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } if (verificationValue.expiresAt < new Date()) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.OTP_EXPIRED, }); } const [otpValue, attempts] = splitAtLastColon( verificationValue.value, ); const allowedAttempts = options?.allowedAttempts || 3; if (attempts && parseInt(attempts) >= allowedAttempts) { await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); throw new APIError("FORBIDDEN", { message: ERROR_CODES.TOO_MANY_ATTEMPTS, }); } const verified = await verifyStoredOTP(ctx, otpValue, ctx.body.otp); if (!verified) { await ctx.context.internalAdapter.updateVerificationValue( verificationValue.id, { value: `${otpValue}:${parseInt(attempts || "0") + 1}`, }, ); throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { if (opts.disableSignUp) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.USER_NOT_FOUND, }); } const newUser = await ctx.context.internalAdapter.createUser({ email, emailVerified: true, name: "", }); const session = await ctx.context.internalAdapter.createSession( newUser.id, ); await setSessionCookie(ctx, { session, user: newUser, }); return ctx.json({ token: session.token, user: { id: newUser.id, email: newUser.email, emailVerified: newUser.emailVerified, name: newUser.name, image: newUser.image, createdAt: newUser.createdAt, updatedAt: newUser.updatedAt, }, }); } if (!user.user.emailVerified) { await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true, }); } const session = await ctx.context.internalAdapter.createSession( user.user.id, ); await setSessionCookie(ctx, { session, user: user.user, }); return ctx.json({ token: session.token, user: { id: user.user.id, email: user.user.email, emailVerified: user.user.emailVerified, name: user.user.name, image: user.user.image, createdAt: user.user.createdAt, updatedAt: user.user.updatedAt, }, }); }, ), /** * ### Endpoint * * POST `/forget-password/email-otp` * * ### API Methods * * **server:** * `auth.api.forgetPasswordEmailOTP` * * **client:** * `authClient.forgetPassword.emailOtp` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-forget-password-email-otp) */ forgetPasswordEmailOTP: createAuthEndpoint( "/forget-password/email-otp", { method: "POST", body: z.object({ email: z.string().meta({ description: "Email address to send the OTP", }), }), metadata: { openapi: { description: "Send a password reset OTP to the user", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", description: "Indicates if the OTP was sent successfully", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.body.email; const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.USER_NOT_FOUND, }); } const otp = opts.generateOTP({ email, type: "forget-password" }, ctx.request) || defaultOTPGenerator(opts); let storedOTP = await storeOTP(ctx, otp); await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, identifier: `forget-password-otp-${email}`, expiresAt: getDate(opts.expiresIn, "sec"), }); await options.sendVerificationOTP( { email, otp, type: "forget-password", }, ctx.request, ); return ctx.json({ success: true, }); }, ), /** * ### Endpoint * * POST `/email-otp/reset-password` * * ### API Methods * * **server:** * `auth.api.resetPasswordEmailOTP` * * **client:** * `authClient.emailOtp.resetPassword` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/email-otp#api-method-email-otp-reset-password) */ resetPasswordEmailOTP: createAuthEndpoint( "/email-otp/reset-password", { method: "POST", body: z.object({ email: z.string().meta({ description: "Email address to reset the password", }), otp: z.string().meta({ description: "OTP sent to the email", }), password: z.string().meta({ description: "New password", }), }), metadata: { openapi: { description: "Reset user password with OTP", responses: { 200: { description: "Success", contnt: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const email = ctx.body.email; const user = await ctx.context.internalAdapter.findUserByEmail( email, { includeAccounts: true, }, ); if (!user) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.USER_NOT_FOUND, }); } const verificationValue = await ctx.context.internalAdapter.findVerificationValue( `forget-password-otp-${email}`, ); if (!verificationValue) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } if (verificationValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); throw new APIError("BAD_REQUEST", { message: ERROR_CODES.OTP_EXPIRED, }); } const [otpValue, attempts] = splitAtLastColon( verificationValue.value, ); const allowedAttempts = options?.allowedAttempts || 3; if (attempts && parseInt(attempts) >= allowedAttempts) { await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); throw new APIError("FORBIDDEN", { message: ERROR_CODES.TOO_MANY_ATTEMPTS, }); } const verified = await verifyStoredOTP(ctx, otpValue, ctx.body.otp); if (!verified) { await ctx.context.internalAdapter.updateVerificationValue( verificationValue.id, { value: `${otpValue}:${parseInt(attempts || "0") + 1}`, }, ); throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_OTP, }); } await ctx.context.internalAdapter.deleteVerificationValue( verificationValue.id, ); const passwordHash = await ctx.context.password.hash( ctx.body.password, ); const account = user.accounts.find( (account) => account.providerId === "credential", ); if (!account) { await ctx.context.internalAdapter.createAccount({ userId: user.user.id, providerId: "credential", accountId: user.user.id, password: passwordHash, }); } else { await ctx.context.internalAdapter.updatePassword( user.user.id, passwordHash, ); } if (ctx.context.options.emailAndPassword?.onPasswordReset) { await ctx.context.options.emailAndPassword.onPasswordReset( { user: user.user, }, ctx.request, ); } if (!user.user.emailVerified) { await ctx.context.internalAdapter.updateUser(user.user.id, { emailVerified: true, }); } return ctx.json({ success: true, }); }, ), }, hooks: { after: [ { matcher(context) { return !!( context.path?.startsWith("/sign-up") && opts.sendVerificationOnSignUp ); }, handler: createAuthMiddleware(async (ctx) => { const response = await getEndpointResponse<{ user: { email: string }; }>(ctx); const email = response?.user.email; if (email) { const otp = opts.generateOTP({ email, type: ctx.body.type }, ctx.request) || defaultOTPGenerator(opts); let storedOTP = await storeOTP(ctx, otp); await ctx.context.internalAdapter.createVerificationValue({ value: `${storedOTP}:0`, identifier: `email-verification-otp-${email}`, expiresAt: getDate(opts.expiresIn, "sec"), }); await options.sendVerificationOTP( { email, otp, type: "email-verification", }, ctx.request, ); } }), }, ], }, $ERROR_CODES: ERROR_CODES, rateLimit: [ { pathMatcher(path) { return path === "/email-otp/send-verification-otp"; }, window: 60, max: 3, }, { pathMatcher(path) { return path === "/email-otp/check-verification-otp"; }, window: 60, max: 3, }, { pathMatcher(path) { return path === "/email-otp/verify-email"; }, window: 60, max: 3, }, { pathMatcher(path) { return path === "/sign-in/email-otp"; }, window: 60, max: 3, }, ], } satisfies BetterAuthPlugin; }; ```