This is page 24 of 51. 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-declaration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.base.json ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /docs/content/docs/plugins/last-login-method.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Last Login Method description: Track and display the last authentication method used by users --- The last login method plugin tracks the most recent authentication method used by users (email, OAuth providers, etc.). This enables you to display helpful indicators on login pages, such as "Last signed in with Google" or prioritize certain login methods based on user preferences. ## Installation <Steps> <Step> ### Add the plugin to your auth config ```ts title="auth.ts" import { betterAuth } from "better-auth" import { lastLoginMethod } from "better-auth/plugins" // [!code highlight] export const auth = betterAuth({ // ... other config options plugins: [ lastLoginMethod() // [!code highlight] ] }) ``` </Step> <Step> ### Add the client plugin to your auth client ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" import { lastLoginMethodClient } from "better-auth/client/plugins" // [!code highlight] export const authClient = createAuthClient({ plugins: [ lastLoginMethodClient() // [!code highlight] ] }) ``` </Step> </Steps> ## Usage Once installed, the plugin automatically tracks the last authentication method used by users. You can then retrieve and display this information in your application. ### Getting the Last Used Method The client plugin provides several methods to work with the last login method: ```ts title="app.tsx" import { authClient } from "@/lib/auth-client" // Get the last used login method const lastMethod = authClient.getLastUsedLoginMethod() console.log(lastMethod) // "google", "email", "github", etc. // Check if a specific method was last used const wasGoogle = authClient.isLastUsedLoginMethod("google") // Clear the stored method authClient.clearLastUsedLoginMethod() ``` ### UI Integration Example Here's how to use the plugin to enhance your login page: ```tsx title="sign-in.tsx" import { authClient } from "@/lib/auth-client" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" export function SignInPage() { const lastMethod = authClient.getLastUsedLoginMethod() return ( <div className="space-y-4"> <h1>Sign In</h1> {/* Email sign in */} <div className="relative"> <Button onClick={() => authClient.signIn.email({...})} variant={lastMethod === "email" ? "default" : "outline"} className="w-full" > Sign in with Email {lastMethod === "email" && ( <Badge className="ml-2">Last used</Badge> )} </Button> </div> {/* OAuth providers */} <div className="relative"> <Button onClick={() => authClient.signIn.social({ provider: "google" })} variant={lastMethod === "google" ? "default" : "outline"} className="w-full" > Continue with Google {lastMethod === "google" && ( <Badge className="ml-2">Last used</Badge> )} </Button> </div> <div className="relative"> <Button onClick={() => authClient.signIn.social({ provider: "github" })} variant={lastMethod === "github" ? "default" : "outline"} className="w-full" > Continue with GitHub {lastMethod === "github" && ( <Badge className="ml-2">Last used</Badge> )} </Button> </div> </div> ) } ``` ## Database Persistence By default, the last login method is stored only in cookies. For more persistent tracking and analytics, you can enable database storage. <Steps> <Step> ### Enable database storage Set `storeInDatabase` to `true` in your plugin configuration: ```ts title="auth.ts" import { betterAuth } from "better-auth" import { lastLoginMethod } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ lastLoginMethod({ storeInDatabase: true // [!code highlight] }) ] }) ``` </Step> <Step> ### Run database migration The plugin will automatically add a `lastLoginMethod` field to your user table. Run the migration to apply the changes: <Tabs items={["migrate", "generate"]}> <Tab value="migrate"> ```bash npx @better-auth/cli migrate ``` </Tab> <Tab value="generate"> ```bash npx @better-auth/cli generate ``` </Tab> </Tabs> </Step> <Step> ### Access database field When database storage is enabled, the `lastLoginMethod` field becomes available in user objects: ```ts title="user-profile.tsx" import { auth } from "@/lib/auth" // Server-side access const session = await auth.api.getSession({ headers }) console.log(session?.user.lastLoginMethod) // "google", "email", etc. // Client-side access via session const { data: session } = authClient.useSession() console.log(session?.user.lastLoginMethod) ``` </Step> </Steps> ### Database Schema When `storeInDatabase` is enabled, the plugin adds the following field to the `user` table: Table: `user` <DatabaseTable fields={[ { name: "lastLoginMethod", type: "string", description: "The last authentication method used by the user", isOptional: true }, ]} /> ### Custom Schema Configuration You can customize the database field name: ```ts title="auth.ts" import { betterAuth } from "better-auth" import { lastLoginMethod } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ lastLoginMethod({ storeInDatabase: true, schema: { user: { lastLoginMethod: "last_auth_method" // Custom field name } } }) ] }) ``` ## Configuration Options The last login method plugin accepts the following options: ### Server Options ```ts title="auth.ts" import { betterAuth } from "better-auth" import { lastLoginMethod } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ lastLoginMethod({ // Cookie configuration cookieName: "better-auth.last_used_login_method", // Default: "better-auth.last_used_login_method" maxAge: 60 * 60 * 24 * 30, // Default: 30 days in seconds // Database persistence storeInDatabase: false, // Default: false // Custom method resolution customResolveMethod: (ctx) => { // Custom logic to determine the login method if (ctx.path === "/oauth/callback/custom-provider") { return "custom-provider" } // Return null to use default resolution return null }, // Schema customization (when storeInDatabase is true) schema: { user: { lastLoginMethod: "custom_field_name" } } }) ] }) ``` **cookieName**: `string` - The name of the cookie used to store the last login method - Default: `"better-auth.last_used_login_method"` - **Note**: This cookie is `httpOnly: false` to allow client-side JavaScript access for UI features **maxAge**: `number` - Cookie expiration time in seconds - Default: `2592000` (30 days) **storeInDatabase**: `boolean` - Whether to store the last login method in the database - Default: `false` - When enabled, adds a `lastLoginMethod` field to the user table **customResolveMethod**: `(ctx: GenericEndpointContext) => string | null` - Custom function to determine the login method from the request context - Return `null` to use the default resolution logic - Useful for custom OAuth providers or authentication flows **schema**: `object` - Customize database field names when `storeInDatabase` is enabled - Allows mapping the `lastLoginMethod` field to a custom column name ### Client Options ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" import { lastLoginMethodClient } from "better-auth/client/plugins" export const authClient = createAuthClient({ plugins: [ lastLoginMethodClient({ cookieName: "better-auth.last_used_login_method" // Default: "better-auth.last_used_login_method" }) ] }) ``` **cookieName**: `string` - The name of the cookie to read the last login method from - Must match the server-side `cookieName` configuration - Default: `"better-auth.last_used_login_method"` ### Default Method Resolution By default, the plugin tracks these authentication methods: - **Email authentication**: `"email"` - **OAuth providers**: Provider ID (e.g., `"google"`, `"github"`, `"discord"`) - **OAuth2 callbacks**: Provider ID from URL path - **Sign up methods**: Tracked the same as sign in methods The plugin automatically detects the method from these endpoints: - `/callback/:id` - OAuth callback with provider ID - `/oauth2/callback/:id` - OAuth2 callback with provider ID - `/sign-in/email` - Email sign in - `/sign-up/email` - Email sign up ## Cross-Domain Support The plugin automatically inherits cookie settings from Better Auth's centralized cookie system. This solves the problem where the last login method wouldn't persist across: - **Cross-subdomain setups**: `auth.example.com` → `app.example.com` - **Cross-origin setups**: `api.company.com` → `app.different.com` When you enable `crossSubDomainCookies` or `crossOriginCookies` in your Better Auth config, the plugin will automatically use the same domain, secure, and sameSite settings as your session cookies, ensuring consistent behavior across your application. ## Advanced Examples ### Custom Provider Tracking If you have custom OAuth providers or authentication methods, you can use the `customResolveMethod` option: ```ts title="auth.ts" import { betterAuth } from "better-auth" import { lastLoginMethod } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ lastLoginMethod({ customResolveMethod: (ctx) => { // Track custom SAML provider if (ctx.path === "/saml/callback") { return "saml" } // Track magic link authentication if (ctx.path === "/magic-link/verify") { return "magic-link" } // Track phone authentication if (ctx.path === "/sign-in/phone") { return "phone" } // Return null to use default logic return null } }) ] }) ``` ``` -------------------------------------------------------------------------------- /docs/components/builder/sign-in.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Key } from "lucide-react"; import Link from "next/link"; import { useAtom } from "jotai"; import { optionsAtom } from "./store"; import { socialProviders } from "./social-provider"; import { cn } from "@/lib/utils"; export default function SignIn() { const [options] = useAtom(optionsAtom); return ( <Card className="z-50 rounded-none max-w-full"> <CardHeader> <CardTitle className="text-lg md:text-xl">Sign In</CardTitle> <CardDescription className="text-xs md:text-sm"> Enter your email below to login to your account </CardDescription> </CardHeader> <CardContent> <div className="grid gap-4"> {options.email && ( <> <div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="[email protected]" required /> </div> <div className="grid gap-2"> <div className="flex items-center"> <Label htmlFor="password">Password</Label> {options.requestPasswordReset && ( <Link href="#" className="ml-auto inline-block text-sm underline" > Forgot your password? </Link> )} </div> <Input id="password" type="password" placeholder="password" autoComplete="password" /> </div> {options.rememberMe && ( <div className="flex items-center gap-2"> <Checkbox /> <Label>Remember me</Label> </div> )} </> )} {options.magicLink && ( <div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="[email protected]" required /> <Button className="gap-2" onClick={async () => {}}> Sign-in with Magic Link </Button> </div> )} {options.email && ( <Button type="submit" className="w-full" onClick={async () => {}}> Login </Button> )} {options.passkey && ( <Button variant="secondary" className="gap-2"> <Key size={16} /> Sign-in with Passkey </Button> )} <div className={cn( "w-full gap-2 flex items-center justify-between", options.socialProviders.length > 3 ? "flex-row flex-wrap" : "flex-col", )} > {Object.keys(socialProviders).map((provider) => { if (options.socialProviders.includes(provider)) { const { Icon } = socialProviders[provider as keyof typeof socialProviders]; return ( <Button key={provider} variant="outline" className={cn( options.socialProviders.length > 3 ? "flex-grow" : "w-full gap-2", )} > <Icon width="1.2em" height="1.2em" /> {options.socialProviders.length <= 3 && "Sign in with " + provider.charAt(0).toUpperCase() + provider.slice(1)} </Button> ); } return null; })} </div> </div> </CardContent> {options.label && ( <CardFooter> <div className="flex justify-center w-full border-t py-4"> <p className="text-center text-xs text-neutral-500"> built with{" "} <Link href="https://better-auth.com" className="underline" target="_blank" > <span className="dark:text-white/70 cursor-pointer"> better-auth. </span> </Link> </p> </div> </CardFooter> )} </Card> ); } export const signInString = (options: any) => `"use client" import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { useState } from "react"; import { Loader2, Key } from "lucide-react"; import { signIn } from "@/lib/auth-client"; import Link from "next/link"; import { cn } from "@/lib/utils"; export default function SignIn() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); ${ options.rememberMe ? "const [rememberMe, setRememberMe] = useState(false);" : "" } return ( <Card className="max-w-md"> <CardHeader> <CardTitle className="text-lg md:text-xl">Sign In</CardTitle> <CardDescription className="text-xs md:text-sm"> Enter your email below to login to your account </CardDescription> </CardHeader> <CardContent> <div className="grid gap-4"> ${ options.email ? `<div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="[email protected]" required onChange={(e) => { setEmail(e.target.value); }} value={email} /> </div> <div className="grid gap-2"> <div className="flex items-center"> <Label htmlFor="password">Password</Label> ${ options.requestPasswordReset ? `<Link href="#" className="ml-auto inline-block text-sm underline" > Forgot your password? </Link>` : "" } </div> <Input id="password" type="password" placeholder="password" autoComplete="password" value={password} onChange={(e) => setPassword(e.target.value)} /> </div> ${ options.rememberMe ? `<div className="flex items-center gap-2"> <Checkbox id="remember" onClick={() => { setRememberMe(!rememberMe); }} /> <Label htmlFor="remember">Remember me</Label> </div>` : "" }` : "" } ${ options.magicLink ? `<div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="[email protected]" required onChange={(e) => { setEmail(e.target.value); }} value={email} /> <Button disabled={loading} className="gap-2" onClick={async () => { await signIn.magicLink( { email }, { onRequest: (ctx) => { setLoading(true); }, onResponse: (ctx) => { setLoading(false); }, }, ); }}> {loading ? ( <Loader2 size={16} className="animate-spin" /> ):( Sign-in with Magic Link )} </Button> </div>` : "" } ${ options.email ? `<Button type="submit" className="w-full" disabled={loading} onClick={async () => { await signIn.email( { email, password }, { onRequest: (ctx) => { setLoading(true); }, onResponse: (ctx) => { setLoading(false); }, }, ); }} > {loading ? ( <Loader2 size={16} className="animate-spin" /> ) : ( <p> Login </p> )} </Button>` : "" } ${ options.passkey ? `<Button variant="secondary" disabled={loading} className="gap-2" onClick={async () => { await signIn.passkey( { onRequest: (ctx) => { setLoading(true); }, onResponse: (ctx) => { setLoading(false); }, }, ) }} > <Key size={16} /> Sign-in with Passkey </Button>` : "" } ${ options.socialProviders?.length > 0 ? `<div className={cn( "w-full gap-2 flex items-center", ${ options.socialProviders.length > 3 ? '"justify-between flex-wrap"' : '"justify-between flex-col"' } )}> ${options.socialProviders .map((provider: string) => { const icon = socialProviders[provider as keyof typeof socialProviders] ?.stringIcon || ""; return `\n\t\t\t\t<Button variant="outline" className={cn( ${ options.socialProviders.length > 3 ? '"flex-grow"' : '"w-full gap-2"' } )} disabled={loading} onClick={async () => { await signIn.social( { provider: "${provider}", callbackURL: "/dashboard" }, { onRequest: (ctx) => { setLoading(true); }, onResponse: (ctx) => { setLoading(false); }, }, ); }} > ${icon} ${ options.socialProviders.length <= 3 ? `Sign in with ${ provider.charAt(0).toUpperCase() + provider.slice(1) }` : "" } </Button>`; }) .join("")} </div>` : "" } </div> </CardContent> ${ options.label ? `<CardFooter> <div className="flex justify-center w-full border-t py-4"> <p className="text-center text-xs text-neutral-500"> built with{" "} <Link href="https://better-auth.com" className="underline" target="_blank" > <span className="dark:text-white/70 cursor-pointer"> better-auth. </span> </Link> </p> </div> </CardFooter>` : "" } </Card> ); }`; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/update-user.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import type { Account } from "../../types"; describe("updateUser", async () => { const sendChangeEmail = vi.fn(); let emailVerificationToken = ""; const { client, testUser, sessionSetter, db, customFetchImpl, signInWithTestUser, } = await getTestInstance({ emailVerification: { async sendVerificationEmail({ user, url, token }) { emailVerificationToken = token; }, }, user: { changeEmail: { enabled: true, sendChangeEmailVerification: async ({ user, newEmail, url, token }) => { sendChangeEmail(user, newEmail, url, token); }, }, }, }); // Sign in once for all tests in this describe block const { runWithUser: globalRunWithClient } = await signInWithTestUser(); it("should update the user's name", async () => { await globalRunWithClient(async () => { const updated = await client.updateUser({ name: "newName", image: "https://example.com/image.jpg", }); const sessionRes = await client.getSession(); expect(updated.data?.status).toBe(true); expect(sessionRes.data?.user.name).toBe("newName"); }); }); it("should unset image", async () => { await globalRunWithClient(async () => { const updated = await client.updateUser({ image: null, }); const sessionRes = await client.getSession(); expect(sessionRes.data?.user.image).toBeNull(); }); }); it("should update user email", async () => { const newEmail = "[email protected]"; await globalRunWithClient(async () => { const res = await client.changeEmail({ newEmail, }); const sessionRes = await client.getSession(); expect(sessionRes.data?.user.email).toBe(newEmail); expect(sessionRes.data?.user.emailVerified).toBe(false); }); }); it("should verify email", async () => { await globalRunWithClient(async () => { await client.verifyEmail({ query: { token: emailVerificationToken, }, }); const sessionRes = await client.getSession(); expect(sessionRes.data?.user.emailVerified).toBe(true); }); }); it("should send email verification before update", async () => { await db.update({ model: "user", update: { emailVerified: true, }, where: [ { field: "email", value: "[email protected]", }, ], }); await globalRunWithClient(async () => { await client.changeEmail({ newEmail: "[email protected]", }); }); expect(sendChangeEmail).toHaveBeenCalledWith( expect.objectContaining({ email: "[email protected]", }), "[email protected]", expect.any(String), expect.any(String), ); }); it("should update the user's password", async () => { const newEmail = "[email protected]"; await globalRunWithClient(async () => { const updated = await client.changePassword({ newPassword: "newPassword", currentPassword: testUser.password, revokeOtherSessions: true, }); expect(updated).toBeDefined(); }); const signInRes = await client.signIn.email({ email: newEmail, password: "newPassword", }); expect(signInRes.data?.user).toBeDefined(); const signInCurrentPassword = await client.signIn.email({ email: testUser.email, password: testUser.password, }); expect(signInCurrentPassword.data).toBeNull(); }); it("should update account's updatedAt when changing password", async () => { const newHeaders = new Headers(); await client.signUp.email({ name: "Test User", email: "[email protected]", password: "originalPassword", fetchOptions: { onSuccess: sessionSetter(newHeaders), }, }); // Get the initial account data const initialSession = await client.getSession({ fetchOptions: { headers: newHeaders, throw: true, }, }); const userId = initialSession?.user.id; // Get initial account updatedAt const initialAccounts: Account[] = await db.findMany({ model: "account", where: [ { field: "userId", value: userId!, }, { field: "providerId", value: "credential", }, ], }); expect(initialAccounts.length).toBe(1); const initialUpdatedAt = initialAccounts[0]!.updatedAt; await new Promise((resolve) => setTimeout(resolve, 100)); // Change password const updated = await client.changePassword({ newPassword: "newPassword123", currentPassword: "originalPassword", fetchOptions: { headers: newHeaders, }, }); expect(updated.data).toBeDefined(); // Get updated account data const updatedAccounts: Account[] = await db.findMany({ model: "account", where: [ { field: "userId", value: userId!, }, { field: "providerId", value: "credential", }, ], }); expect(updatedAccounts.length).toBe(1); const newUpdatedAt = updatedAccounts[0]!.updatedAt; // Verify updatedAt was refreshed expect(newUpdatedAt).not.toBe(initialUpdatedAt); expect(new Date(newUpdatedAt).getTime()).toBeGreaterThan( new Date(initialUpdatedAt).getTime(), ); }); it("should not update password if current password is wrong", async () => { const newHeaders = new Headers(); await client.signUp.email({ name: "name", email: "[email protected]", password: "password", fetchOptions: { onSuccess: sessionSetter(newHeaders), }, }); const res = await client.changePassword({ newPassword: "newPassword", currentPassword: "wrongPassword", fetchOptions: { headers: newHeaders, }, }); expect(res.data).toBeNull(); const signInAttempt = await client.signIn.email({ email: "[email protected]", password: "newPassword", }); expect(signInAttempt.data).toBeNull(); }); it("should revoke other sessions", async () => { await globalRunWithClient(async (headers) => { const newHeaders = new Headers(); await client.changePassword({ newPassword: "newPassword", currentPassword: testUser.password, revokeOtherSessions: true, fetchOptions: { onSuccess: sessionSetter(newHeaders), }, }); const cookie = newHeaders.get("cookie"); const oldCookie = headers.get("cookie"); expect(cookie).not.toBe(oldCookie); // Try to use the old session - it should be revoked const sessionAttempt = await client.getSession(); // The old session should still be invalidated even though we're using runWithClient // because revokeOtherSessions should have invalidated it on the server expect(sessionAttempt.data).toBeNull(); }); }); it("shouldn't pass defaults", async () => { const { client, sessionSetter, db } = await getTestInstance( { user: { additionalFields: { newField: { type: "string", defaultValue: "default", }, }, }, }, { disableTestUser: true, }, ); const headers = new Headers(); await client.signUp.email({ email: "[email protected]", name: "name", password: "password", fetchOptions: { onSuccess: sessionSetter(headers), }, }); const res = await db.update<{ newField: string }>({ model: "user", update: { newField: "new", }, where: [ { field: "email", value: "[email protected]", }, ], }); expect(res?.newField).toBe("new"); const updated = await client.updateUser({ name: "newName", fetchOptions: { headers, }, }); const session = await client.getSession({ fetchOptions: { headers, throw: true, }, }); // @ts-expect-error expect(session?.user.newField).toBe("new"); }); it("should propagate updates across sessions when secondaryStorage is enabled", async () => { const store = new Map<string, string>(); const { client: authClient, signInWithTestUser: signIn } = await getTestInstance({ secondaryStorage: { set(key, value) { store.set(key, value); }, get(key) { return store.get(key) || null; }, delete(key) { store.delete(key); }, }, }); const { headers: headers1 } = await signIn(); const { headers: headers2 } = await signIn(); await authClient.updateUser({ name: "updatedName", fetchOptions: { headers: headers1, }, }); const secondSession = await authClient.getSession({ fetchOptions: { headers: headers2, throw: true, }, }); expect(secondSession?.user.name).toBe("updatedName"); const firstSession = await authClient.getSession({ fetchOptions: { headers: headers1, throw: true, }, }); expect(firstSession?.user.name).toBe("updatedName"); }); }); describe("delete user", async () => { it("should not delete user if deleteUser is disabled", async () => { const { client, signInWithTestUser } = await getTestInstance({ user: { deleteUser: { enabled: false, }, }, }); const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const res = await client.deleteUser(); console.log(res); }); }); it("should delete the user with a fresh session", async () => { const { client, signInWithTestUser } = await getTestInstance({ user: { deleteUser: { enabled: true, }, }, session: { freshAge: 1000, }, }); const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const res = await client.deleteUser(); expect(res.data).toMatchObject({ success: true, }); const session = await client.getSession(); expect(session.data).toBeNull(); }); }); it("should delete with verification flow and password", async () => { let token = ""; const { client, signInWithTestUser, testUser } = await getTestInstance({ user: { deleteUser: { enabled: true, async sendDeleteAccountVerification(data, _) { token = data.token; }, }, }, }); const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const res = await client.deleteUser({ password: testUser.password, }); expect(res.data).toMatchObject({ success: true, }); expect(token.length).toBe(32); const session = await client.getSession(); expect(session.data).toBeDefined(); const deleteCallbackRes = await client.deleteUser({ token, }); expect(deleteCallbackRes.data).toMatchObject({ success: true, }); const nullSession = await client.getSession(); expect(nullSession.data).toBeNull(); }); }); it("should ignore cookie cache for sensitive operations like changePassword", async () => { const { client: cacheClient, sessionSetter: cacheSessionSetter } = await getTestInstance( { session: { cookieCache: { enabled: true, maxAge: 60, }, }, }, { disableTestUser: true, }, ); const uniqueEmail = `cache-test-${Date.now()}@test.com`; const testPassword = "testPassword123"; await cacheClient.signUp.email({ email: uniqueEmail, password: testPassword, name: "Cache Test User", }); const cacheHeaders = new Headers(); await cacheClient.signIn.email({ email: uniqueEmail, password: testPassword, fetchOptions: { onSuccess: cacheSessionSetter(cacheHeaders), }, }); const initialSession = await cacheClient.getSession({ fetchOptions: { headers: cacheHeaders, throw: true, }, }); expect(initialSession?.user).toBeDefined(); const changePasswordResult = await cacheClient.changePassword({ newPassword: "newSecurePassword123", currentPassword: testPassword, revokeOtherSessions: true, fetchOptions: { headers: cacheHeaders, }, }); expect(changePasswordResult.data).toBeDefined(); const sessionAfterPasswordChange = await cacheClient.getSession({ fetchOptions: { headers: cacheHeaders, }, }); expect(sessionAfterPasswordChange.data).toBeNull(); }); }); ``` -------------------------------------------------------------------------------- /docs/content/docs/authentication/email-password.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Email & Password description: Implementing email and password authentication with Better Auth. --- Email and password authentication is a common method used by many applications. Better Auth provides a built-in email and password authenticator that you can easily integrate into your project. <Callout type="info"> If you prefer username-based authentication, check out the{" "} <Link href="/docs/plugins/username">username plugin</Link>. It extends the email and password authenticator with username support. </Callout> ## Enable Email and Password To enable email and password authentication, you need to set the `emailAndPassword.enabled` option to `true` in the `auth` configuration. ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ emailAndPassword: { // [!code highlight] enabled: true, // [!code highlight] }, // [!code highlight] }); ``` <Callout type="info"> If it's not enabled, it'll not allow you to sign in or sign up with email and password. </Callout> ## Usage ### Sign Up To sign a user up, you can use the `signUp.email` function provided by the client. <APIMethod path="/sign-up/email" method="POST"> ```ts type signUpEmail = { /** * The name of the user. */ name: string = "John Doe" /** * The email address of the user. */ email: string = "[email protected]" /** * The password of the user. It should be at least 8 characters long and max 128 by default. */ password: string = "password1234" /** * An optional profile image of the user. */ image?: string = "https://example.com/image.png" /** * An optional URL to redirect to after the user signs up. */ callbackURL?: string = "https://example.com/callback" } ``` </APIMethod> <Callout> These are the default properties for the sign up email endpoint, however it's possible that with [additional fields](/docs/concepts/typescript#additional-fields) or special plugins you can pass more properties to the endpoint. </Callout> ### Sign In To sign a user in, you can use the `signIn.email` function provided by the client. <APIMethod path="/sign-in/email" method="POST" requireSession> ```ts type signInEmail = { /** * The email address of the user. */ email: string = "[email protected]" /** * The password of the user. It should be at least 8 characters long and max 128 by default. */ password: string = "password1234" /** * If false, the user will be signed out when the browser is closed. (optional) (default: true) */ rememberMe?: boolean = true /** * An optional URL to redirect to after the user signs in. (optional) */ callbackURL?: string = "https://example.com/callback" } ``` </APIMethod> <Callout> These are the default properties for the sign in email endpoint, however it's possible that with [additional fields](/docs/concepts/typescript#additional-fields) or special plugins you can pass different properties to the endpoint. </Callout> ### Sign Out To sign a user out, you can use the `signOut` function provided by the client. <APIMethod path="/sign-out" method="POST" requireSession noResult> ```ts type signOut = { } ``` </APIMethod> you can pass `fetchOptions` to redirect onSuccess ```ts title="auth-client.ts" await authClient.signOut({ fetchOptions: { onSuccess: () => { router.push("/login"); // redirect to login page }, }, }); ``` ### Email Verification To enable email verification, you need to pass a function that sends a verification email with a link. The `sendVerificationEmail` function takes a data object with the following properties: - `user`: The user object. - `url`: The URL to send to the user which contains the token. - `token`: A verification token used to complete the email verification. and a `request` object as the second parameter. ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { sendEmail } from "./email"; // your email sending function export const auth = betterAuth({ emailVerification: { sendVerificationEmail: async ( { user, url, token }, request) => { await sendEmail({ to: user.email, subject: "Verify your email address", text: `Click the link to verify your email: ${url}`, }); }, }, }); ``` On the client side you can use `sendVerificationEmail` function to send verification link to user. This will trigger the `sendVerificationEmail` function you provided in the `auth` configuration. Once the user clicks on the link in the email, if the token is valid, the user will be redirected to the URL provided in the `callbackURL` parameter. If the token is invalid, the user will be redirected to the URL provided in the `callbackURL` parameter with an error message in the query string `?error=invalid_token`. #### Require Email Verification If you enable require email verification, users must verify their email before they can log in. And every time a user tries to sign in, sendVerificationEmail is called. <Callout> This only works if you have sendVerificationEmail implemented and if the user is trying to sign in with email and password. </Callout> ```ts title="auth.ts" export const auth = betterAuth({ emailAndPassword: { requireEmailVerification: true, }, }); ``` If a user tries to sign in without verifying their email, you can handle the error and show a message to the user. ```ts title="auth-client.ts" await authClient.signIn.email( { email: "[email protected]", password: "password", }, { onError: (ctx) => { // Handle the error if (ctx.error.status === 403) { alert("Please verify your email address"); } //you can also show the original error message alert(ctx.error.message); }, } ); ``` #### Triggering manually Email Verification You can trigger the email verification manually by calling the `sendVerificationEmail` function. ```ts await authClient.sendVerificationEmail({ email: "[email protected]", callbackURL: "/", // The redirect URL after verification }); ``` ### Request Password Reset To allow users to reset a password first you need to provide `sendResetPassword` function to the email and password authenticator. The `sendResetPassword` function takes a data object with the following properties: - `user`: The user object. - `url`: The URL to send to the user which contains the token. - `token`: A verification token used to complete the password reset. and a `request` object as the second parameter. ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { sendEmail } from "./email"; // your email sending function export const auth = betterAuth({ emailAndPassword: { enabled: true, sendResetPassword: async ({user, url, token}, request) => { await sendEmail({ to: user.email, subject: "Reset your password", text: `Click the link to reset your password: ${url}`, }); }, onPasswordReset: async ({ user }, request) => { // your logic here console.log(`Password for user ${user.email} has been reset.`); }, }, }); ``` Additionally, you can provide an `onPasswordReset` callback to execute logic after a password has been successfully reset. Once you configured your server you can call `requestPasswordReset` function to send reset password link to user. If the user exists, it will trigger the `sendResetPassword` function you provided in the auth config. <APIMethod path="/request-password-reset" method="POST"> ```ts type requestPasswordReset = { /** * The email address of the user to send a password reset email to */ email: string = "[email protected]" /** * The URL to redirect the user to reset their password. If the token isn't valid or expired, it'll be redirected with a query parameter `?error=INVALID_TOKEN`. If the token is valid, it'll be redirected with a query parameter `?token=VALID_TOKEN */ redirectTo?: string = "https://example.com/reset-password" } ``` </APIMethod> When a user clicks on the link in the email, they will be redirected to the reset password page. You can add the reset password page to your app. Then you can use `resetPassword` function to reset the password. It takes an object with the following properties: - `newPassword`: The new password of the user. ```ts title="auth-client.ts" const { data, error } = await authClient.resetPassword({ newPassword: "password1234", token, }); ``` <APIMethod path="/reset-password" method="POST"> ```ts const token = new URLSearchParams(window.location.search).get("token"); if (!token) { // Handle the error } type resetPassword = { /** * The new password to set */ newPassword: string = "password1234" /** * The token to reset the password */ token: string } ``` </APIMethod> ### Update password A user's password isn't stored in the user table. Instead, it's stored in the account table. To change the password of a user, you can use one of the following approaches: <APIMethod path="/change-password" method="POST" requireSession> ```ts type changePassword = { /** * The new password to set */ newPassword: string = "newpassword1234" /** * The current user password */ currentPassword: string = "oldpassword1234" /** * When set to true, all other active sessions for this user will be invalidated */ revokeOtherSessions?: boolean = true } ``` </APIMethod> ### Configuration **Password** Better Auth stores passwords inside the `account` table with `providerId` set to `credential`. **Password Hashing**: Better Auth uses `scrypt` to hash passwords. The `scrypt` algorithm is designed to be slow and memory-intensive to make it difficult for attackers to brute force passwords. OWASP recommends using `scrypt` if `argon2id` is not available. We decided to use `scrypt` because it's natively supported by Node.js. You can pass custom password hashing algorithm by setting `passwordHasher` option in the `auth` configuration. ```ts title="auth.ts" import { betterAuth } from "better-auth" import { scrypt } from "scrypt" export const auth = betterAuth({ //...rest of the options emailAndPassword: { password: { hash: // your custom password hashing function verify: // your custom password verification function } } }) ``` <TypeTable type={{ enabled: { description: "Enable email and password authentication.", type: "boolean", default: "false", }, disableSignUp: { description: "Disable email and password sign up.", type: "boolean", default: "false" }, minPasswordLength: { description: "The minimum length of a password.", type: "number", default: 8, }, maxPasswordLength: { description: "The maximum length of a password.", type: "number", default: 128, }, sendResetPassword: { description: "Sends a password reset email. It takes a function that takes two parameters: token and user.", type: "function", }, onPasswordReset: { description: "A callback function that is triggered when a user's password is changed successfully.", type: "function", }, resetPasswordTokenExpiresIn: { description: "Number of seconds the reset password token is valid for.", type: "number", default: 3600 }, password: { description: "Password configuration.", type: "object", properties: { hash: { description: "custom password hashing function", type: "function", }, verify: { description: "custom password verification function", type: "function", }, }, }, }} /> ``` -------------------------------------------------------------------------------- /docs/components/nav-bar.tsx: -------------------------------------------------------------------------------- ```typescript import Link from "next/link"; import { ThemeToggle } from "@/components/theme-toggler"; import { NavbarMobile, NavbarMobileBtn } from "./nav-mobile"; import { NavLink } from "./nav-link"; import { Logo } from "./logo"; import LogoContextMenu from "./logo-context-menu"; import DarkPng from "../public/branding/better-auth-logo-dark.png"; import WhitePng from "../public/branding/better-auth-logo-light.png"; import { MobileSearchIcon } from "@/components/mobile-search-icon"; export const Navbar = () => { const logoAssets = { darkSvg: ` <svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="500" height="500" fill="black"/> <rect x="69" y="121" width="86.9879" height="259" fill="white"/> <rect x="337.575" y="121" width="92.4247" height="259" fill="white"/> <rect x="427.282" y="121" width="83.4555" height="174.52" transform="rotate(90 427.282 121)" fill="white"/> <rect x="430" y="296.544" width="83.4555" height="177.238" transform="rotate(90 430 296.544)" fill="white"/> <rect x="252.762" y="204.455" width="92.0888" height="96.7741" transform="rotate(90 252.762 204.455)" fill="white"/> </svg> `, whiteSvg: ` <svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="500" height="500" fill="white"/> <rect x="69" y="121" width="86.9879" height="259" fill="black"/> <rect x="337.575" y="121" width="92.4247" height="259" fill="black"/> <rect x="427.282" y="121" width="83.4555" height="174.52" transform="rotate(90 427.282 121)" fill="black"/> <rect x="430" y="296.544" width="83.4555" height="177.238" transform="rotate(90 430 296.544)" fill="black"/> <rect x="252.762" y="204.455" width="92.0888" height="96.7741" transform="rotate(90 252.762 204.455)" fill="black"/> </svg> `, darkWordmark: ` <svg width="1024" height="256" viewBox="0 0 1024 256" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="1024" height="256" fill="black"/> <rect x="96" y="79" width="34.6988" height="97.5904" fill="white"/> <rect x="203.133" y="79" width="36.8675" height="97.5904" fill="white"/> <rect x="238.916" y="79" width="31.4458" height="69.6144" transform="rotate(90 238.916 79)" fill="white"/> <rect x="240" y="145.145" width="31.4458" height="70.6988" transform="rotate(90 240 145.145)" fill="white"/> <rect x="169.301" y="110.446" width="34.6988" height="38.6024" transform="rotate(90 169.301 110.446)" fill="white"/> <path d="M281.832 162V93.84H305.256C313.32 93.84 319.368 95.312 323.4 98.256C327.432 101.2 329.448 105.84 329.448 112.176C329.448 116.016 328.36 119.248 326.184 121.872C324.072 124.432 321.128 126.064 317.352 126.768C322.024 127.408 325.672 129.232 328.296 132.24C330.984 135.184 332.328 138.864 332.328 143.28C332.328 149.488 330.312 154.16 326.28 157.296C322.248 160.432 316.52 162 309.096 162H281.832ZM290.088 123.312H305.256C310.248 123.312 314.088 122.384 316.776 120.528C319.464 118.608 320.808 115.952 320.808 112.56C320.808 105.456 315.624 101.904 305.256 101.904H290.088V123.312ZM290.088 153.936H309.096C313.768 153.936 317.352 152.976 319.848 151.056C322.408 149.136 323.688 146.384 323.688 142.8C323.688 139.216 322.408 136.432 319.848 134.448C317.352 132.4 313.768 131.376 309.096 131.376H290.088V153.936ZM345.301 162V93.84H388.117V101.904H353.557V123.888H386.965V131.76H353.557V153.936H388.885V162H345.301ZM416.681 162V101.904H395.465V93.84H446.153V101.904H424.937V162H416.681ZM470.587 162V101.904H449.371V93.84H500.059V101.904H478.843V162H470.587ZM507.113 162V93.84H549.929V101.904H515.369V123.888H548.777V131.76H515.369V153.936H550.697V162H507.113ZM564.02 162V93.84H589.844C597.012 93.84 602.676 95.696 606.836 99.408C610.996 103.12 613.076 108.144 613.076 114.48C613.076 117.104 612.532 119.504 611.444 121.68C610.356 123.792 608.948 125.584 607.22 127.056C605.492 128.528 603.604 129.552 601.556 130.128C604.564 130.64 606.932 131.856 608.66 133.776C610.452 135.696 611.508 138.416 611.828 141.936L613.748 162H605.396L603.667 142.8C603.412 139.984 602.388 137.904 600.596 136.56C598.868 135.216 596.02 134.544 592.052 134.544H572.276V162H564.02ZM572.276 126.48H590.9C595.06 126.48 598.356 125.424 600.788 123.312C603.22 121.2 604.436 118.192 604.436 114.288C604.436 110.32 603.188 107.28 600.692 105.168C598.196 102.992 594.58 101.904 589.844 101.904H572.276V126.48ZM623.912 137.808V130.224H655.688V137.808H623.912ZM661.826 162L686.402 93.84H697.538L722.114 162H713.09L706.274 142.608H677.666L670.85 162H661.826ZM680.45 134.544H703.49L691.97 101.04L680.45 134.544ZM755.651 163.536C750.403 163.536 745.827 162.512 741.923 160.464C738.083 158.416 735.107 155.504 732.995 151.728C730.947 147.888 729.923 143.376 729.923 138.192V93.744H738.179V138.192C738.179 143.696 739.683 147.952 742.691 150.96C745.763 153.968 750.083 155.472 755.651 155.472C761.155 155.472 765.411 153.968 768.419 150.96C771.491 147.952 773.027 143.696 773.027 138.192V93.744H781.283V138.192C781.283 143.376 780.227 147.888 778.115 151.728C776.067 155.504 773.123 158.416 769.283 160.464C765.443 162.512 760.899 163.536 755.651 163.536ZM811.087 162V101.904H789.871V93.84H840.559V101.904H819.343V162H811.087ZM847.613 162V93.84H855.869V123.696H890.141V93.84H898.397V162H890.141V131.76H855.869V162H847.613ZM911.443 162V151.152H922.291V162H911.443Z" fill="white"/> </svg> `, whiteWordmark: ` <svg width="1024" height="256" viewBox="0 0 1024 256" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="1024" height="256" fill="#FFEAEA"/> <rect x="96" y="79" width="34.6988" height="97.5904" fill="black"/> <rect x="203.133" y="79" width="36.8675" height="97.5904" fill="black"/> <rect x="238.916" y="79" width="31.4458" height="69.6144" transform="rotate(90 238.916 79)" fill="black"/> <rect x="240" y="145.145" width="31.4458" height="70.6988" transform="rotate(90 240 145.145)" fill="black"/> <rect x="169.301" y="110.446" width="34.6988" height="38.6024" transform="rotate(90 169.301 110.446)" fill="black"/> <path d="M281.832 162V93.84H305.256C313.32 93.84 319.368 95.312 323.4 98.256C327.432 101.2 329.448 105.84 329.448 112.176C329.448 116.016 328.36 119.248 326.184 121.872C324.072 124.432 321.128 126.064 317.352 126.768C322.024 127.408 325.672 129.232 328.296 132.24C330.984 135.184 332.328 138.864 332.328 143.28C332.328 149.488 330.312 154.16 326.28 157.296C322.248 160.432 316.52 162 309.096 162H281.832ZM290.088 123.312H305.256C310.248 123.312 314.088 122.384 316.776 120.528C319.464 118.608 320.808 115.952 320.808 112.56C320.808 105.456 315.624 101.904 305.256 101.904H290.088V123.312ZM290.088 153.936H309.096C313.768 153.936 317.352 152.976 319.848 151.056C322.408 149.136 323.688 146.384 323.688 142.8C323.688 139.216 322.408 136.432 319.848 134.448C317.352 132.4 313.768 131.376 309.096 131.376H290.088V153.936ZM345.301 162V93.84H388.117V101.904H353.557V123.888H386.965V131.76H353.557V153.936H388.885V162H345.301ZM416.681 162V101.904H395.465V93.84H446.153V101.904H424.937V162H416.681ZM470.587 162V101.904H449.371V93.84H500.059V101.904H478.843V162H470.587ZM507.113 162V93.84H549.929V101.904H515.369V123.888H548.777V131.76H515.369V153.936H550.697V162H507.113ZM564.02 162V93.84H589.844C597.012 93.84 602.676 95.696 606.836 99.408C610.996 103.12 613.076 108.144 613.076 114.48C613.076 117.104 612.532 119.504 611.444 121.68C610.356 123.792 608.948 125.584 607.22 127.056C605.492 128.528 603.604 129.552 601.556 130.128C604.564 130.64 606.932 131.856 608.66 133.776C610.452 135.696 611.508 138.416 611.828 141.936L613.748 162H605.396L603.667 142.8C603.412 139.984 602.388 137.904 600.596 136.56C598.868 135.216 596.02 134.544 592.052 134.544H572.276V162H564.02ZM572.276 126.48H590.9C595.06 126.48 598.356 125.424 600.788 123.312C603.22 121.2 604.436 118.192 604.436 114.288C604.436 110.32 603.188 107.28 600.692 105.168C598.196 102.992 594.58 101.904 589.844 101.904H572.276V126.48ZM623.912 137.808V130.224H655.688V137.808H623.912ZM661.826 162L686.402 93.84H697.538L722.114 162H713.09L706.274 142.608H677.666L670.85 162H661.826ZM680.45 134.544H703.49L691.97 101.04L680.45 134.544ZM755.651 163.536C750.403 163.536 745.827 162.512 741.923 160.464C738.083 158.416 735.107 155.504 732.995 151.728C730.947 147.888 729.923 143.376 729.923 138.192V93.744H738.179V138.192C738.179 143.696 739.683 147.952 742.691 150.96C745.763 153.968 750.083 155.472 755.651 155.472C761.155 155.472 765.411 153.968 768.419 150.96C771.491 147.952 773.027 143.696 773.027 138.192V93.744H781.283V138.192C781.283 143.376 780.227 147.888 778.115 151.728C776.067 155.504 773.123 158.416 769.283 160.464C765.443 162.512 760.899 163.536 755.651 163.536ZM811.087 162V101.904H789.871V93.84H840.559V101.904H819.343V162H811.087ZM847.613 162V93.84H855.869V123.696H890.141V93.84H898.397V162H890.141V131.76H855.869V162H847.613ZM911.443 162V151.152H922.291V162H911.443Z" fill="black"/> </svg> `, darkPng: DarkPng, whitePng: WhitePng, }; return ( <div className="flex flex-col sticky top-0 bg-background backdrop-blur-md z-30"> <nav className="md:grid grid-cols-12 md:border-b top-0 flex items-center justify-between"> <Link href="/" className="md:border-r md:px-5 px-2.5 py-4 text-foreground md:col-span-2 shrink-0 transition-colors md:w-[268px] lg:w-[286px]" > <div className="flex flex-col gap-2 w-full"> <LogoContextMenu logo={ <div className="flex items-center gap-2"> <Logo /> <p className="select-none">BETTER-AUTH.</p> </div> } logoAssets={logoAssets} /> </div> </Link> <div className="md:col-span-10 flex items-center justify-end relative"> <ul className="md:flex items-center divide-x w-max hidden shrink-0"> {navMenu.map((menu, i) => ( <NavLink key={menu.name} href={menu.path}> {menu.name} </NavLink> ))} <NavLink href="https://github.com/better-auth/better-auth" className=" bg-muted/20" external > <svg xmlns="http://www.w3.org/2000/svg" width="1.4em" height="1.4em" viewBox="0 0 496 512" > <path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6c-3.3.3-5.6-1.3-5.6-3.6c0-2 2.3-3.6 5.2-3.6c3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9c2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9c.3 2 2.9 3.3 5.9 2.6c2.9-.7 4.9-2.6 4.6-4.6c-.3-1.9-3-3.2-5.9-2.9M244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2c12.8 2.3 17.3-5.6 17.3-12.1c0-6.2-.3-40.4-.3-61.4c0 0-70 15-84.7-29.8c0 0-11.4-29.1-27.8-36.6c0 0-22.9-15.7 1.6-15.4c0 0 24.9 2 38.6 25.8c21.9 38.6 58.6 27.5 72.9 20.9c2.3-16 8.8-27.1 16-33.7c-55.9-6.2-112.3-14.3-112.3-110.5c0-27.5 7.6-41.3 23.6-58.9c-2.6-6.5-11.1-33.3 2.6-67.9c20.9-6.5 69 27 69 27c20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27c13.7 34.7 5.2 61.4 2.6 67.9c16 17.7 25.8 31.5 25.8 58.9c0 96.5-58.9 104.2-114.8 110.5c9.2 7.9 17 22.9 17 46.4c0 33.7-.3 75.4-.3 83.6c0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252C496 113.3 383.5 8 244.8 8M97.2 352.9c-1.3 1-1 3.3.7 5.2c1.6 1.6 3.9 2.3 5.2 1c1.3-1 1-3.3-.7-5.2c-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9c1.6 1 3.6.7 4.3-.7c.7-1.3-.3-2.9-2.3-3.9c-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2c2.3 2.3 5.2 2.6 6.5 1c1.3-1.3.7-4.3-1.3-6.2c-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2c-1.4-2.3-4-3.3-5.6-2" ></path> </svg> </NavLink> </ul> <MobileSearchIcon /> <ThemeToggle /> <NavbarMobileBtn /> </div> </nav> <NavbarMobile /> </div> ); }; export const navMenu = [ { name: "helo_", path: "/", }, { name: "docs", path: "/docs", }, { name: "changelogs", path: "/changelogs", }, { name: "blogs", path: "/blog", }, { name: "community", path: "/community", }, ]; ``` -------------------------------------------------------------------------------- /docs/content/docs/plugins/passkey.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Passkey description: Passkey --- Passkeys are a secure, passwordless authentication method using cryptographic key pairs, supported by WebAuthn and FIDO2 standards in web browsers. They replace passwords with unique key pairs: a private key stored on the user's device and a public key shared with the website. Users can log in using biometrics, PINs, or security keys, providing strong, phishing-resistant authentication without traditional passwords. The passkey plugin implementation is powered by [SimpleWebAuthn](https://simplewebauthn.dev/) behind the scenes. ## Installation <Steps> <Step> ### Add the plugin to your auth config To add the passkey plugin to your auth config, you need to import the plugin and pass it to the `plugins` option of the auth instance. **Options** `rpID`: A unique identifier for your website based on your auth server origin. 'localhost' is okay for local dev. RP ID can be formed by discarding zero or more labels from the left of its effective domain until it hits an effective TLD. So `www.example.com` can use the RP IDs `www.example.com` or `example.com`. But not `com`, because that's an eTLD. `rpName`: Human-readable title for your website `origin`: The origin URL at which your better-auth server is hosted. `http://localhost` and `http://localhost:PORT` are also valid. Do **NOT** include any trailing / `authenticatorSelection`: Allows customization of WebAuthn authenticator selection criteria. Leave unspecified for default settings. - `authenticatorAttachment`: Specifies the type of authenticator - `platform`: Authenticator is attached to the platform (e.g., fingerprint reader) - `cross-platform`: Authenticator is not attached to the platform (e.g., security key) - Default: `not set` (both platform and cross-platform allowed, with platform preferred) - `residentKey`: Determines credential storage behavior. - `required`: User MUST store credentials on the authenticator (highest security) - `preferred`: Encourages credential storage but not mandatory - `discouraged`: No credential storage required (fastest experience) - Default: `preferred` - `userVerification`: Controls biometric/PIN verification during authentication: - `required`: User MUST verify identity (highest security) - `preferred`: Verification encouraged but not mandatory - `discouraged`: No verification required (fastest experience) - Default: `preferred` ```ts title="auth.ts" import { betterAuth } from "better-auth" import { passkey } from "better-auth/plugins/passkey" // [!code highlight] export const auth = betterAuth({ plugins: [ // [!code highlight] passkey(), // [!code highlight] ], // [!code highlight] }) ``` </Step> <Step> ### Migrate the database Run the migration or generate the schema to add the necessary fields and tables to the database. <Tabs items={["migrate", "generate"]}> <Tab value="migrate"> ```bash npx @better-auth/cli migrate ``` </Tab> <Tab value="generate"> ```bash npx @better-auth/cli generate ``` </Tab> </Tabs> See the [Schema](#schema) section to add the fields manually. </Step> <Step> ### Add the client plugin ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" import { passkeyClient } from "better-auth/client/plugins" export const authClient = createAuthClient({ plugins: [ // [!code highlight] passkeyClient() // [!code highlight] ] // [!code highlight] }) ``` </Step> </Steps> ## Usage ### Add/Register a passkey To add or register a passkey make sure a user is authenticated and then call the `passkey.addPasskey` function provided by the client. <APIMethod path="/passkey/add-passkey" method="POST" isClientOnly> ```ts type addPasskey = { /** * An optional name to label the authenticator account being registered. If not provided, it will default to the user's email address or user ID */ name?: string = "example-passkey-name" /** * You can also specify the type of authenticator you want to register. Default behavior allows both platform and cross-platform passkeys */ authenticatorAttachment?: "platform" | "cross-platform" = "cross-platform" } ``` </APIMethod> <Callout> Setting `throw: true` in the fetch options has no effect for the register and sign-in passkey responses — they will always return a data object containing the error object. </Callout> ### Sign in with a passkey To sign in with a passkey you can use the `signIn.passkey` method. This will prompt the user to sign in with their passkey. <APIMethod path="/sign-in/passkey" method="POST" isClientOnly> ```ts type signInPasskey = { /** * Browser autofill, a.k.a. Conditional UI. Read more: https://simplewebauthn.dev/docs/packages/browser#browser-autofill-aka-conditional-ui */ autoFill?: boolean = true } ``` </APIMethod> #### Example Usage ```ts // With post authentication redirect await authClient.signIn.passkey({ autoFill: true, fetchOptions: { onSuccess(context) { // Redirect to dashboard after successful authentication window.location.href = "/dashboard"; }, onError(context) { // Handle authentication errors console.error("Authentication failed:", context.error.message); } } }); ``` ### List passkeys You can list all of the passkeys for the authenticated user by calling `passkey.listUserPasskeys`: <APIMethod path="/passkey/list-user-passkeys" method="GET" requireSession resultVariable="passkeys" > ```ts type listPasskeys = { } ``` </APIMethod> ### Deleting passkeys You can delete a passkey by calling `passkey.delete` and providing the passkey ID. <APIMethod path="/passkey/delete-passkey" method="POST" requireSession > ```ts type deletePasskey = { /** * The ID of the passkey to delete. */ id: string = "some-passkey-id" } ``` </APIMethod> ### Updating passkey names <APIMethod path="/passkey/update-passkey" method="POST" requireSession > ```ts type updatePasskey = { /** * The ID of the passkey which you want to update. */ id: string = "id of passkey" /** * The new name which the passkey will be updated to. */ name: string = "my-new-passkey-name" } ``` </APIMethod> ### Conditional UI The plugin supports conditional UI, which allows the browser to autofill the passkey if the user has already registered a passkey. There are two requirements for conditional UI to work: <Steps> <Step> #### Update input fields Add the `autocomplete` attribute with the value `webauthn` to your input fields. You can add this attribute to multiple input fields, but at least one is required for conditional UI to work. The `webauthn` value should also be the last entry of the `autocomplete` attribute. ```html <label for="name">Username:</label> <input type="text" name="name" autocomplete="username webauthn"> <label for="password">Password:</label> <input type="password" name="password" autocomplete="current-password webauthn"> ``` </Step> <Step> #### Preload the passkeys When your component mounts, you can preload the user's passkeys by calling the `authClient.signIn.passkey` method with the `autoFill` option set to `true`. To prevent unnecessary calls, we will also add a check to see if the browser supports conditional UI. <Tabs items={["React"]}> <Tab value="React"> ```ts useEffect(() => { if (!PublicKeyCredential.isConditionalMediationAvailable || !PublicKeyCredential.isConditionalMediationAvailable()) { return; } void authClient.signIn.passkey({ autoFill: true }) }, []) ``` </Tab> </Tabs> </Step> </Steps> Depending on the browser, a prompt will appear to autofill the passkey. If the user has multiple passkeys, they can select the one they want to use. Some browsers also require the user to first interact with the input field before the autofill prompt appears. ### Debugging To test your passkey implementation you can use [emulated authenticators](https://developer.chrome.com/docs/devtools/webauthn). This way you can test the registration and sign-in process without even owning a physical device. ## Schema The plugin require a new table in the database to store passkey data. Table Name: `passkey` <DatabaseTable fields={[ { name: "id", type: "string", description: "Unique identifier for each passkey", isPrimaryKey: true }, { name: "name", type: "string", description: "The name of the passkey", isOptional: true }, { name: "publicKey", type: "string", description: "The public key of the passkey", }, { name: "userId", type: "string", description: "The ID of the user", isForeignKey: true }, { name: "credentialID", type: "string", description: "The unique identifier of the registered credential", }, { name: "counter", type: "number", description: "The counter of the passkey", }, { name: "deviceType", type: "string", description: "The type of device used to register the passkey", }, { name: "backedUp", type: "boolean", description: "Whether the passkey is backed up", }, { name: "transports", type: "string", description: "The transports used to register the passkey", isOptional: true }, { name: "createdAt", type: "Date", description: "The time when the passkey was created", isOptional: true }, { name: "aaguid", type: "string", description: "Authenticator's Attestation GUID indicating the type of the authenticator", isOptional: true }, ]} /> ## Options **rpID**: A unique identifier for your website based on your auth server origin. `'localhost'` is okay for local dev. RP ID can be formed by discarding zero or more labels from the left of its effective domain until it hits an effective TLD. So `www.example.com` can use the RP IDs `www.example.com` or `example.com`. But not `com`, because that's an eTLD. **rpName**: Human-readable title for your website. **origin**: The origin URL at which your better-auth server is hosted. `http://localhost` and `http://localhost:PORT` are also valid. Do NOT include any trailing /. **authenticatorSelection**: Allows customization of WebAuthn authenticator selection criteria. When unspecified, both platform and cross-platform authenticators are allowed with `preferred` settings for `residentKey` and `userVerification`. **aaguid**: (optional) Authenticator Attestation GUID. This is a unique identifier for the passkey provider (device or authenticator type) and can be used to identify the type of passkey device used during registration or authentication. ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/update-api-key.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { APIError, getSessionFromCtx } from "../../../api"; import { createAuthEndpoint } from "@better-auth/core/api"; import { ERROR_CODES } from ".."; import type { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; import { getDate } from "../../../utils/date"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; import { API_KEY_TABLE_NAME } from ".."; import type { AuthContext } from "@better-auth/core"; export function updateApiKey({ opts, schema, deleteAllExpiredApiKeys, }: { opts: PredefinedApiKeyOptions; schema: ReturnType<typeof apiKeySchema>; deleteAllExpiredApiKeys( ctx: AuthContext, byPassLastCheckTime?: boolean, ): void; }) { return createAuthEndpoint( "/api-key/update", { method: "POST", body: z.object({ keyId: z.string().meta({ description: "The id of the Api Key", }), userId: z.coerce .string() .meta({ description: 'The id of the user which the api key belongs to. server-only. Eg: "some-user-id"', }) .optional(), name: z .string() .meta({ description: "The name of the key", }) .optional(), enabled: z .boolean() .meta({ description: "Whether the Api Key is enabled or not", }) .optional(), remaining: z .number() .meta({ description: "The number of remaining requests", }) .min(1) .optional(), refillAmount: z .number() .meta({ description: "The refill amount", }) .optional(), refillInterval: z .number() .meta({ description: "The refill interval", }) .optional(), metadata: z.any().optional(), expiresIn: z .number() .meta({ description: "Expiration time of the Api Key in seconds", }) .min(1) .optional() .nullable(), rateLimitEnabled: z .boolean() .meta({ description: "Whether the key has rate limiting enabled.", }) .optional(), rateLimitTimeWindow: z .number() .meta({ description: "The duration in milliseconds where each request is counted. server-only. Eg: 1000", }) .optional(), rateLimitMax: z .number() .meta({ description: "Maximum amount of requests allowed within a window. Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. server-only. Eg: 100", }) .optional(), permissions: z .record(z.string(), z.array(z.string())) .meta({ description: "Update the permissions on the API Key. server-only.", }) .optional() .nullable(), }), metadata: { openapi: { description: "Update an existing API key by ID", responses: { "200": { description: "API key updated successfully", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string", description: "ID", }, name: { type: "string", nullable: true, description: "The name of the key", }, start: { type: "string", nullable: true, description: "Shows the first few characters of the API key, including the prefix. This allows you to show those few characters in the UI to make it easier for users to identify the API key.", }, prefix: { type: "string", nullable: true, description: "The API Key prefix. Stored as plain text.", }, userId: { type: "string", description: "The owner of the user id", }, refillInterval: { type: "number", nullable: true, description: "The interval in milliseconds between refills of the `remaining` count. Example: 3600000 // refill every hour (3600000ms = 1h)", }, refillAmount: { type: "number", nullable: true, description: "The amount to refill", }, lastRefillAt: { type: "string", format: "date-time", nullable: true, description: "The last refill date", }, enabled: { type: "boolean", description: "Sets if key is enabled or disabled", default: true, }, rateLimitEnabled: { type: "boolean", description: "Whether the key has rate limiting enabled", }, rateLimitTimeWindow: { type: "number", nullable: true, description: "The duration in milliseconds", }, rateLimitMax: { type: "number", nullable: true, description: "Maximum amount of requests allowed within a window", }, requestCount: { type: "number", description: "The number of requests made within the rate limit time window", }, remaining: { type: "number", nullable: true, description: "Remaining requests (every time api key is used this should updated and should be updated on refill as well)", }, lastRequest: { type: "string", format: "date-time", nullable: true, description: "When last request occurred", }, expiresAt: { type: "string", format: "date-time", nullable: true, description: "Expiry date of a key", }, createdAt: { type: "string", format: "date-time", description: "created at", }, updatedAt: { type: "string", format: "date-time", description: "updated at", }, metadata: { type: "object", nullable: true, additionalProperties: true, description: "Extra metadata about the apiKey", }, permissions: { type: "string", nullable: true, description: "Permissions for the api key (stored as JSON string)", }, }, required: [ "id", "userId", "enabled", "rateLimitEnabled", "requestCount", "createdAt", "updatedAt", ], }, }, }, }, }, }, }, }, async (ctx) => { const { keyId, expiresIn, enabled, metadata, refillAmount, refillInterval, remaining, name, permissions, rateLimitEnabled, rateLimitTimeWindow, rateLimitMax, } = ctx.body; const session = await getSessionFromCtx(ctx); const authRequired = ctx.request || ctx.headers; const user = authRequired && !session ? null : session?.user || { id: ctx.body.userId }; if (!user?.id) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.UNAUTHORIZED_SESSION, }); } if (session && ctx.body.userId && session?.user.id !== ctx.body.userId) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.UNAUTHORIZED_SESSION, }); } if (authRequired) { // if this endpoint was being called from the client, // we must make sure they can't use server-only properties. if ( refillAmount !== undefined || refillInterval !== undefined || rateLimitMax !== undefined || rateLimitTimeWindow !== undefined || rateLimitEnabled !== undefined || remaining !== undefined || permissions !== undefined ) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.SERVER_ONLY_PROPERTY, }); } } const apiKey = await ctx.context.adapter.findOne<ApiKey>({ model: API_KEY_TABLE_NAME, where: [ { field: "id", value: keyId, }, { field: "userId", value: user.id, }, ], }); if (!apiKey) { throw new APIError("NOT_FOUND", { message: ERROR_CODES.KEY_NOT_FOUND, }); } let newValues: Partial<ApiKey> = {}; if (name !== undefined) { if (name.length < opts.minimumNameLength) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_NAME_LENGTH, }); } else if (name.length > opts.maximumNameLength) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_NAME_LENGTH, }); } newValues.name = name; } if (enabled !== undefined) { newValues.enabled = enabled; } if (expiresIn !== undefined) { if (opts.keyExpiration.disableCustomExpiresTime === true) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.KEY_DISABLED_EXPIRATION, }); } if (expiresIn !== null) { // if expires is not null, check if it's under the valid range // if it IS null, this means the user wants to disable expiration time on the key const expiresIn_in_days = expiresIn / (60 * 60 * 24); if (expiresIn_in_days < opts.keyExpiration.minExpiresIn) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.EXPIRES_IN_IS_TOO_SMALL, }); } else if (expiresIn_in_days > opts.keyExpiration.maxExpiresIn) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.EXPIRES_IN_IS_TOO_LARGE, }); } } newValues.expiresAt = expiresIn ? getDate(expiresIn, "sec") : null; } if (metadata !== undefined) { if (typeof metadata !== "object") { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_METADATA_TYPE, }); } //@ts-expect-error - we need this to be a string to save into DB. newValues.metadata = schema.apikey.fields.metadata.transform.input(metadata); } if (remaining !== undefined) { newValues.remaining = remaining; } if (refillAmount !== undefined || refillInterval !== undefined) { if (refillAmount !== undefined && refillInterval === undefined) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.REFILL_AMOUNT_AND_INTERVAL_REQUIRED, }); } else if (refillInterval !== undefined && refillAmount === undefined) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.REFILL_INTERVAL_AND_AMOUNT_REQUIRED, }); } newValues.refillAmount = refillAmount; newValues.refillInterval = refillInterval; } if (rateLimitEnabled !== undefined) { newValues.rateLimitEnabled = rateLimitEnabled; } if (rateLimitTimeWindow !== undefined) { newValues.rateLimitTimeWindow = rateLimitTimeWindow; } if (rateLimitMax !== undefined) { newValues.rateLimitMax = rateLimitMax; } if (permissions !== undefined) { //@ts-expect-error - we need this to be a string to save into DB. newValues.permissions = JSON.stringify(permissions); } if (Object.keys(newValues).length === 0) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.NO_VALUES_TO_UPDATE, }); } let newApiKey: ApiKey = apiKey; try { let result = await ctx.context.adapter.update<ApiKey>({ model: API_KEY_TABLE_NAME, where: [ { field: "id", value: apiKey.id, }, ], update: { ...newValues, }, }); if (result) newApiKey = result; } catch (error: any) { throw new APIError("INTERNAL_SERVER_ERROR", { message: error?.message, }); } deleteAllExpiredApiKeys(ctx.context); // transform metadata from string back to object newApiKey.metadata = schema.apikey.fields.metadata.transform.output( newApiKey.metadata as never as string, ); const { key, ...returningApiKey } = newApiKey; return ctx.json({ ...returningApiKey, permissions: returningApiKey.permissions ? safeJSONParse<{ [key: string]: string[]; }>(returningApiKey.permissions) : null, }); }, ); } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/open-api/generator.ts: -------------------------------------------------------------------------------- ```typescript import type { Endpoint, EndpointOptions, OpenAPIParameter, OpenAPISchemaType, } from "better-call"; import * as z from "zod"; import { getEndpoints } from "../../api"; import { getAuthTables } from "../../db"; import type { BetterAuthOptions } from "@better-auth/core"; import type { DBFieldAttribute, DBFieldAttributeConfig, DBFieldType, } from "@better-auth/core/db"; import type { AuthContext } from "@better-auth/core"; export interface Path { get?: { tags?: string[]; operationId?: string; description?: string; security?: [{ bearerAuth: string[] }]; parameters?: OpenAPIParameter[]; responses?: { [key in string]: { description?: string; content: { "application/json": { schema: { type?: OpenAPISchemaType; properties?: Record<string, any>; required?: string[]; $ref?: string; }; }; }; }; }; }; post?: { tags?: string[]; operationId?: string; description?: string; security?: [{ bearerAuth: string[] }]; parameters?: OpenAPIParameter[]; requestBody?: { content: { "application/json": { schema: { type?: OpenAPISchemaType; properties?: Record<string, any>; required?: string[]; $ref?: string; }; }; }; }; responses?: { [key in string]: { description?: string; content: { "application/json": { schema: { type?: OpenAPISchemaType; properties?: Record<string, any>; required?: string[]; $ref?: string; }; }; }; }; }; }; } type AllowedType = "string" | "number" | "boolean" | "array" | "object"; const allowedType = new Set(["string", "number", "boolean", "array", "object"]); function getTypeFromZodType(zodType: z.ZodType<any>) { const type = zodType.type; return allowedType.has(type) ? (type as AllowedType) : "string"; } export type FieldSchema = { type: DBFieldType; default?: DBFieldAttributeConfig["defaultValue"] | "Generated at runtime"; readOnly?: boolean; }; export type OpenAPIModelSchema = { type: "object"; properties: Record<string, FieldSchema>; required?: string[]; }; function getFieldSchema(field: DBFieldAttribute) { const schema: FieldSchema = { type: field.type === "date" ? "string" : field.type, }; if (field.defaultValue !== undefined) { schema.default = typeof field.defaultValue === "function" ? "Generated at runtime" : field.defaultValue; } if (field.input === false) { schema.readOnly = true; } return schema; } function getParameters(options: EndpointOptions) { const parameters: OpenAPIParameter[] = []; if (options.metadata?.openapi?.parameters) { parameters.push(...options.metadata.openapi.parameters); return parameters; } if (options.query instanceof z.ZodObject) { Object.entries(options.query.shape).forEach(([key, value]) => { if (value instanceof z.ZodType) { parameters.push({ name: key, in: "query", schema: { ...processZodType(value as z.ZodType<any>), ...("minLength" in value && (value as any).minLength ? { minLength: (value as any).minLength as number, } : {}), }, }); } }); } return parameters; } function getRequestBody(options: EndpointOptions): any { if (options.metadata?.openapi?.requestBody) { return options.metadata.openapi.requestBody; } if (!options.body) return undefined; if ( options.body instanceof z.ZodObject || options.body instanceof z.ZodOptional ) { // @ts-expect-error const shape = options.body.shape; if (!shape) return undefined; const properties: Record<string, any> = {}; const required: string[] = []; Object.entries(shape).forEach(([key, value]) => { if (value instanceof z.ZodType) { properties[key] = processZodType(value as z.ZodType<any>); if (!(value instanceof z.ZodOptional)) { required.push(key); } } }); return { required: options.body instanceof z.ZodOptional ? false : options.body ? true : false, content: { "application/json": { schema: { type: "object", properties, required, }, }, }, }; } return undefined; } function processZodType(zodType: z.ZodType<any>): any { // optional unwrapping if (zodType instanceof z.ZodOptional) { const innerType = (zodType as any)._def.innerType; const innerSchema = processZodType(innerType); return { ...innerSchema, nullable: true, }; } // object unwrapping if (zodType instanceof z.ZodObject) { const shape = (zodType as any).shape; if (shape) { const properties: Record<string, any> = {}; const required: string[] = []; Object.entries(shape).forEach(([key, value]) => { if (value instanceof z.ZodType) { properties[key] = processZodType(value as z.ZodType<any>); if (!(value instanceof z.ZodOptional)) { required.push(key); } } }); return { type: "object", properties, ...(required.length > 0 ? { required } : {}), description: (zodType as any).description, }; } } // For primitive types, get the correct type from the unwrapped ZodType const baseSchema = { type: getTypeFromZodType(zodType), description: (zodType as any).description, }; return baseSchema; } function getResponse(responses?: Record<string, any>) { return { "400": { content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", }, }, required: ["message"], }, }, }, description: "Bad Request. Usually due to missing parameters, or invalid parameters.", }, "401": { content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", }, }, required: ["message"], }, }, }, description: "Unauthorized. Due to missing or invalid authentication.", }, "403": { content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", }, }, }, }, }, description: "Forbidden. You do not have permission to access this resource or to perform this action.", }, "404": { content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", }, }, }, }, }, description: "Not Found. The requested resource was not found.", }, "429": { content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", }, }, }, }, }, description: "Too Many Requests. You have exceeded the rate limit. Try again later.", }, "500": { content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", }, }, }, }, }, description: "Internal Server Error. This is a problem with the server that you cannot fix.", }, ...responses, } as any; } function toOpenApiPath(path: string) { // /reset-password/:token -> /reset-password/{token} // replace all : with {} return path .split("/") .map((part) => (part.startsWith(":") ? `{${part.slice(1)}}` : part)) .join("/"); } export async function generator(ctx: AuthContext, options: BetterAuthOptions) { const baseEndpoints = getEndpoints(ctx, { ...options, plugins: [], }); const tables = getAuthTables(options); const models = Object.entries(tables).reduce< Record<string, OpenAPIModelSchema> >((acc, [key, value]) => { const modelName = key.charAt(0).toUpperCase() + key.slice(1); const fields = value.fields; const required: string[] = []; const properties: Record<string, FieldSchema> = { id: { type: "string" }, }; Object.entries(fields).forEach(([fieldKey, fieldValue]) => { if (!fieldValue) return; properties[fieldKey] = getFieldSchema(fieldValue); if (fieldValue.required && fieldValue.input !== false) { required.push(fieldKey); } }); acc[modelName] = { type: "object", properties, ...(required.length > 0 ? { required } : {}), }; return acc; }, {}); const components = { schemas: { ...models, }, }; const paths: Record<string, Path> = {}; Object.entries(baseEndpoints.api).forEach(([_, value]) => { if (ctx.options.disabledPaths?.includes(value.path)) return; const options = value.options as EndpointOptions; if (options.metadata?.SERVER_ONLY) return; const path = toOpenApiPath(value.path); if (options.method === "GET") { paths[path] = { get: { tags: ["Default", ...(options.metadata?.openapi?.tags || [])], description: options.metadata?.openapi?.description, operationId: options.metadata?.openapi?.operationId, security: [ { bearerAuth: [], }, ], parameters: getParameters(options), responses: getResponse(options.metadata?.openapi?.responses), }, }; } if (options.method === "POST") { const body = getRequestBody(options); paths[path] = { post: { tags: ["Default", ...(options.metadata?.openapi?.tags || [])], description: options.metadata?.openapi?.description, operationId: options.metadata?.openapi?.operationId, security: [ { bearerAuth: [], }, ], parameters: getParameters(options), ...(body ? { requestBody: body } : { requestBody: { //set body none content: { "application/json": { schema: { type: "object", properties: {}, }, }, }, }, }), responses: getResponse(options.metadata?.openapi?.responses), }, }; } }); for (const plugin of options.plugins || []) { if (plugin.id === "open-api") { continue; } const pluginEndpoints = getEndpoints(ctx, { ...options, plugins: [plugin], }); const api = Object.keys(pluginEndpoints.api) .map((key) => { if ( baseEndpoints.api[key as keyof typeof baseEndpoints.api] === undefined ) { return pluginEndpoints.api[key as keyof typeof pluginEndpoints.api]; } return null; }) .filter((x) => x !== null) as Endpoint[]; Object.entries(api).forEach(([key, value]) => { if (ctx.options.disabledPaths?.includes(value.path)) return; const options = value.options as EndpointOptions; if (options.metadata?.SERVER_ONLY) return; const path = toOpenApiPath(value.path); if (options.method === "GET") { paths[path] = { get: { tags: options.metadata?.openapi?.tags || [ plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1), ], description: options.metadata?.openapi?.description, operationId: options.metadata?.openapi?.operationId, security: [ { bearerAuth: [], }, ], parameters: getParameters(options), responses: getResponse(options.metadata?.openapi?.responses), }, }; } if (options.method === "POST") { paths[path] = { post: { tags: options.metadata?.openapi?.tags || [ plugin.id.charAt(0).toUpperCase() + plugin.id.slice(1), ], description: options.metadata?.openapi?.description, operationId: options.metadata?.openapi?.operationId, security: [ { bearerAuth: [], }, ], parameters: getParameters(options), requestBody: getRequestBody(options), responses: getResponse(options.metadata?.openapi?.responses), }, }; } }); } const res = { openapi: "3.1.1", info: { title: "Better Auth", description: "API Reference for your Better Auth Instance", version: "1.1.0", }, components: { ...components, securitySchemes: { apiKeyCookie: { type: "apiKey", in: "cookie", name: "apiKeyCookie", description: "API Key authentication via cookie", }, bearerAuth: { type: "http", scheme: "bearer", description: "Bearer token authentication", }, }, }, security: [ { apiKeyCookie: [], bearerAuth: [], }, ], servers: [ { url: ctx.baseURL, }, ], tags: [ { name: "Default", description: "Default endpoints that are included with Better Auth by default. These endpoints are not part of any plugin.", }, ], paths, }; return res; } ``` -------------------------------------------------------------------------------- /demo/nextjs/app/admin/page.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { toast, Toaster } from "sonner"; import { client } from "@/lib/auth-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Loader2, Plus, Trash, RefreshCw, UserCircle, Calendar as CalendarIcon, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { format } from "date-fns"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; type User = { id: string; email: string; name: string; role: "admin" | "user"; }; export default function AdminDashboard() { const queryClient = useQueryClient(); const router = useRouter(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [newUser, setNewUser] = useState({ email: "", password: "", name: "", role: "user" as const, }); const [isLoading, setIsLoading] = useState<string | undefined>(); const [isBanDialogOpen, setIsBanDialogOpen] = useState(false); const [banForm, setBanForm] = useState({ userId: "", reason: "", expirationDate: undefined as Date | undefined, }); const { data: users, isLoading: isUsersLoading } = useQuery({ queryKey: ["users"], queryFn: async () => { const data = await client.admin.listUsers( { query: { limit: 10, sortBy: "createdAt", sortDirection: "desc", }, }, { throw: true, }, ); return data?.users || []; }, }); const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading("create"); try { await client.admin.createUser({ email: newUser.email, password: newUser.password, name: newUser.name, role: newUser.role, }); toast.success("User created successfully"); setNewUser({ email: "", password: "", name: "", role: "user" }); setIsDialogOpen(false); queryClient.invalidateQueries({ queryKey: ["users"], }); } catch (error: any) { toast.error(error.message || "Failed to create user"); } finally { setIsLoading(undefined); } }; const handleDeleteUser = async (id: string) => { setIsLoading(`delete-${id}`); try { await client.admin.removeUser({ userId: id }); toast.success("User deleted successfully"); queryClient.invalidateQueries({ queryKey: ["users"], }); } catch (error: any) { toast.error(error.message || "Failed to delete user"); } finally { setIsLoading(undefined); } }; const handleRevokeSessions = async (id: string) => { setIsLoading(`revoke-${id}`); try { await client.admin.revokeUserSessions({ userId: id }); toast.success("Sessions revoked for user"); } catch (error: any) { toast.error(error.message || "Failed to revoke sessions"); } finally { setIsLoading(undefined); } }; const handleImpersonateUser = async (id: string) => { setIsLoading(`impersonate-${id}`); try { await client.admin.impersonateUser({ userId: id }); toast.success("Impersonated user"); router.push("/dashboard"); } catch (error: any) { toast.error(error.message || "Failed to impersonate user"); } finally { setIsLoading(undefined); } }; const handleBanUser = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(`ban-${banForm.userId}`); try { if (!banForm.expirationDate) { throw new Error("Expiration date is required"); } await client.admin.banUser({ userId: banForm.userId, banReason: banForm.reason, banExpiresIn: banForm.expirationDate.getTime() - new Date().getTime(), }); toast.success("User banned successfully"); setIsBanDialogOpen(false); queryClient.invalidateQueries({ queryKey: ["users"], }); } catch (error: any) { toast.error(error.message || "Failed to ban user"); } finally { setIsLoading(undefined); } }; return ( <div className="container mx-auto p-4 space-y-8"> <Toaster richColors /> <Card> <CardHeader className="flex flex-row items-center justify-between"> <CardTitle className="text-2xl">Admin Dashboard</CardTitle> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <DialogTrigger asChild> <Button> <Plus className="mr-2 h-4 w-4" /> Create User </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create New User</DialogTitle> </DialogHeader> <form onSubmit={handleCreateUser} className="space-y-4"> <div> <Label htmlFor="email">Email</Label> <Input id="email" type="email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value }) } required /> </div> <div> <Label htmlFor="password">Password</Label> <Input id="password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value }) } required /> </div> <div> <Label htmlFor="name">Name</Label> <Input id="name" value={newUser.name} onChange={(e) => setNewUser({ ...newUser, name: e.target.value }) } required /> </div> <div> <Label htmlFor="role">Role</Label> <Select value={newUser.role} onValueChange={(value: "admin" | "user") => setNewUser({ ...newUser, role: value as "user" }) } > <SelectTrigger> <SelectValue placeholder="Select role" /> </SelectTrigger> <SelectContent> <SelectItem value="admin">Admin</SelectItem> <SelectItem value="user">User</SelectItem> </SelectContent> </Select> </div> <Button type="submit" className="w-full" disabled={isLoading === "create"} > {isLoading === "create" ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Creating... </> ) : ( "Create User" )} </Button> </form> </DialogContent> </Dialog> <Dialog open={isBanDialogOpen} onOpenChange={setIsBanDialogOpen}> <DialogContent> <DialogHeader> <DialogTitle>Ban User</DialogTitle> </DialogHeader> <form onSubmit={handleBanUser} className="space-y-4"> <div> <Label htmlFor="reason">Reason</Label> <Input id="reason" value={banForm.reason} onChange={(e) => setBanForm({ ...banForm, reason: e.target.value }) } required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="expirationDate">Expiration Date</Label> <Popover> <PopoverTrigger asChild> <Button id="expirationDate" variant={"outline"} className={cn( "w-full justify-start text-left font-normal", !banForm.expirationDate && "text-muted-foreground", )} > <CalendarIcon className="mr-2 h-4 w-4" /> {banForm.expirationDate ? ( format(banForm.expirationDate, "PPP") ) : ( <span>Pick a date</span> )} </Button> </PopoverTrigger> <PopoverContent className="w-auto p-0"> <Calendar mode="single" selected={banForm.expirationDate} onSelect={(date) => setBanForm({ ...banForm, expirationDate: date }) } initialFocus /> </PopoverContent> </Popover> </div> <Button type="submit" className="w-full" disabled={isLoading === `ban-${banForm.userId}`} > {isLoading === `ban-${banForm.userId}` ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Banning... </> ) : ( "Ban User" )} </Button> </form> </DialogContent> </Dialog> </CardHeader> <CardContent> {isUsersLoading ? ( <div className="flex justify-center items-center h-64"> <Loader2 className="h-8 w-8 animate-spin" /> </div> ) : ( <Table> <TableHeader> <TableRow> <TableHead>Email</TableHead> <TableHead>Name</TableHead> <TableHead>Role</TableHead> <TableHead>Banned</TableHead> <TableHead>Actions</TableHead> </TableRow> </TableHeader> <TableBody> {users?.map((user) => ( <TableRow key={user.id}> <TableCell>{user.email}</TableCell> <TableCell>{user.name}</TableCell> <TableCell>{user.role || "user"}</TableCell> <TableCell> {user.banned ? ( <Badge variant="destructive">Yes</Badge> ) : ( <Badge variant="outline">No</Badge> )} </TableCell> <TableCell> <div className="flex space-x-2"> <Button variant="destructive" size="sm" onClick={() => handleDeleteUser(user.id)} disabled={isLoading?.startsWith("delete")} > {isLoading === `delete-${user.id}` ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( <Trash className="h-4 w-4" /> )} </Button> <Button variant="outline" size="sm" onClick={() => handleRevokeSessions(user.id)} disabled={isLoading?.startsWith("revoke")} > {isLoading === `revoke-${user.id}` ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( <RefreshCw className="h-4 w-4" /> )} </Button> <Button variant="secondary" size="sm" onClick={() => handleImpersonateUser(user.id)} disabled={isLoading?.startsWith("impersonate")} > {isLoading === `impersonate-${user.id}` ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( <> <UserCircle className="h-4 w-4 mr-2" /> Impersonate </> )} </Button> <Button variant="outline" size="sm" onClick={async () => { setBanForm({ userId: user.id, reason: "", expirationDate: undefined, }); if (user.banned) { setIsLoading(`ban-${user.id}`); await client.admin.unbanUser( { userId: user.id, }, { onError(context) { toast.error( context.error.message || "Failed to unban user", ); setIsLoading(undefined); }, onSuccess() { queryClient.invalidateQueries({ queryKey: ["users"], }); toast.success("User unbanned successfully"); }, }, ); queryClient.invalidateQueries({ queryKey: ["users"], }); } else { setIsBanDialogOpen(true); } }} disabled={isLoading?.startsWith("ban")} > {isLoading === `ban-${user.id}` ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : user.banned ? ( "Unban" ) : ( "Ban" )} </Button> </div> </TableCell> </TableRow> ))} </TableBody> </Table> )} </CardContent> </Card> </div> ); } ``` -------------------------------------------------------------------------------- /docs/scripts/endpoint-to-doc/index.ts: -------------------------------------------------------------------------------- ```typescript import type { createAuthEndpoint as BAcreateAuthEndpoint } from "better-auth/api"; import { z } from "zod"; import fs from "fs"; import path from "path"; playSound("Hero"); let isUsingSessionMiddleware = false; export const { orgMiddleware, orgSessionMiddleware, requestOnlySessionMiddleware, sessionMiddleware, originCheck, adminMiddleware, referenceMiddleware, } = { orgMiddleware: () => {}, referenceMiddleware: (cb: (x: any) => void) => () => {}, orgSessionMiddleware: () => {}, requestOnlySessionMiddleware: () => {}, sessionMiddleware: () => { isUsingSessionMiddleware = true; }, originCheck: (cb: (x: any) => void) => () => {}, adminMiddleware: () => { isUsingSessionMiddleware = true; }, }; const file = path.join(process.cwd(), "./scripts/endpoint-to-doc/input.ts"); function clearImportCache() { const resolved = new URL(file, import.meta.url).pathname; delete (globalThis as any).__dynamicImportCache?.[resolved]; delete require.cache[require.resolve(resolved)]; } console.log(`Watching: ${file}`); fs.watch(file, async () => { isUsingSessionMiddleware = false; playSound(); console.log(`Detected file change. Regenerating mdx.`); const inputCode = fs.readFileSync(file, "utf-8"); if (inputCode.includes(".coerce")) fs.writeFileSync(file, inputCode.replaceAll(".coerce", ""), "utf-8"); await generateMDX(); playSound("Hero"); }); async function generateMDX() { const exports = await import("./input"); clearImportCache(); if (Object.keys(exports).length !== 1) return console.error(`Please provide at least 1 export.`); const start = Date.now(); const functionName = Object.keys(exports)[0]! as string; const [path, options]: [string, Options] = //@ts-expect-error await exports[Object.keys(exports)[0]!]; if (!path || !options) return console.error(`No path or options.`); if (options.use) { options.use.forEach((fn) => fn()); } console.log(`function name:`, functionName); let jsdoc = generateJSDoc({ path, functionName, options, isServerOnly: options.metadata?.SERVER_ONLY ?? false, }); let mdx = `<APIMethod${parseParams(path, options)}>\n\`\`\`ts\n${parseType( functionName, options, )}\n\`\`\`\n</APIMethod>`; console.log(`Generated in ${(Date.now() - start).toFixed(2)}ms!`); fs.writeFileSync( "./scripts/endpoint-to-doc/output.mdx", `${APIMethodsHeader}\n\n${mdx}\n\n${JSDocHeader}\n\n${jsdoc}`, "utf-8", ); console.log(`Successfully updated \`output.mdx\`!`); } type CreateAuthEndpointProps = Parameters<typeof BAcreateAuthEndpoint>; type Options = CreateAuthEndpointProps[1]; const APIMethodsHeader = `{/* -------------------------------------------------------- */} {/* APIMethod component */} {/* -------------------------------------------------------- */}`; const JSDocHeader = `{/* -------------------------------------------------------- */} {/* JSDOC For the endpoint */} {/* -------------------------------------------------------- */}`; export const createAuthEndpoint = async ( ...params: Partial<CreateAuthEndpointProps> ) => { const [path, options] = params; if (!path || !options) return console.error(`No path or options.`); return [path, options]; }; type Body = { propName: string; type: string[]; isOptional: boolean; isServerOnly: boolean; jsDocComment: string | null; path: string[]; example: string | undefined; }; function parseType(functionName: string, options: Options) { const body: z.ZodAny = (options.query ?? options.body) as any; const parsedBody: Body[] = parseZodShape(body, []); // console.log(parsedBody); let strBody: string = convertBodyToString(parsedBody); return `type ${functionName} = {\n${strBody}}`; } function convertBodyToString(parsedBody: Body[]) { let strBody: string = ``; const indentationSpaces = ` `; let i = -1; for (const body of parsedBody) { i++; if (body.jsDocComment || body.isServerOnly) { strBody += `${indentationSpaces.repeat( 1 + body.path.length, )}/**\n${indentationSpaces.repeat(1 + body.path.length)} * ${ body.jsDocComment } ${ body.isServerOnly ? `\n${indentationSpaces.repeat(1 + body.path.length)} * @serverOnly` : "" }\n${indentationSpaces.repeat(1 + body.path.length)} */\n`; } if (body.type[0] === "Object") { strBody += `${indentationSpaces.repeat(1 + body.path.length)}${ body.propName }${body.isOptional ? "?" : ""}: {\n`; } else { strBody += `${indentationSpaces.repeat(1 + body.path.length)}${ body.propName }${body.isOptional ? "?" : ""}: ${body.type.join(" | ")}${ typeof body.example !== "undefined" ? ` = ${body.example}` : "" }\n`; } if ( !parsedBody[i + 1] || parsedBody[i + 1].path.length < body.path.length ) { let diff = body.path.length - (parsedBody[i + 1]?.path?.length || 0); for (const index of Array(diff) .fill(0) .map((_, i) => i) .reverse()) { strBody += `${indentationSpaces.repeat(index + 1)}}\n`; } } } return strBody; } function parseZodShape(zod: z.ZodAny, path: string[]) { const parsedBody: Body[] = []; if (!zod || !zod._def) { return parsedBody; } let isRootOptional = undefined; let shape = z.object( { test: z.string({ description: "" }) }, { description: "some descriptiom" }, ).shape; //@ts-expect-error if (zod._def.typeName === "ZodOptional") { isRootOptional = true; const eg = z.optional(z.object({})); const x = zod as never as typeof eg; //@ts-expect-error shape = x._def.innerType.shape; } else { const eg = z.object({}); const x = zod as never as typeof eg; //@ts-expect-error shape = x.shape; } for (const [key, value] of Object.entries(shape)) { if (!value) continue; let description = value.description; let { type, isOptional, defaultValue } = getType(value as any, { forceOptional: isRootOptional, }); let example = description ? description.split(" Eg: ")[1] : undefined; if (example) description = description?.replace(" Eg: " + example, ""); let isServerOnly = description ? description.includes("server-only.") : false; if (isServerOnly) description = description?.replace(" server-only. ", ""); if (!description?.trim().length) description = undefined; parsedBody.push({ propName: key, isOptional: isOptional, jsDocComment: description ?? null, path, isServerOnly, type, example: example ?? defaultValue ?? undefined, }); if (type[0] === "Object") { const v = value as never as z.ZodAny; parsedBody.push(...parseZodShape(v, [...path, key])); } } return parsedBody; } function getType( value: z.ZodAny, { forceNullable, forceOptional, forceDefaultValue, }: { forceOptional?: boolean; forceNullable?: boolean; forceDefaultValue?: string; } = {}, ): { type: string[]; isOptional: boolean; defaultValue?: string } { if (!value._def) { console.error( `Something went wrong during "getType". value._def isn't defined.`, ); console.error(`value:`); console.log(value); process.exit(1); } const _null: string[] = value?.isNullable() ? ["null"] : []; switch (value._def.typeName as string) { case "ZodString": { return { type: ["string", ..._null], isOptional: forceOptional ?? value.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodObject": { return { type: ["Object", ..._null], isOptional: forceOptional ?? value.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodBoolean": { return { type: ["boolean", ..._null], isOptional: forceOptional ?? value.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodDate": { return { type: ["date", ..._null], isOptional: forceOptional ?? value.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodEnum": { const v = value as never as z.ZodEnum<["hello", "world"]>; const types: string[] = []; for (const value of v._def.values) { types.push(JSON.stringify(value)); } return { type: types, isOptional: forceOptional ?? v.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodOptional": { const v = value as never as z.ZodOptional<z.ZodAny>; const r = getType(v._def.innerType, { forceOptional: true, forceNullable: forceNullable, }); return { type: r.type, isOptional: forceOptional ?? r.isOptional, defaultValue: forceDefaultValue, }; } case "ZodDefault": { const v = value as never as z.ZodDefault<z.ZodAny>; const r = getType(v._def.innerType, { forceOptional: forceOptional, forceDefaultValue: JSON.stringify(v._def.defaultValue()), forceNullable: forceNullable, }); return { type: r.type, isOptional: forceOptional ?? r.isOptional, defaultValue: forceDefaultValue ?? r.defaultValue, }; } case "ZodAny": { return { type: ["any", ..._null], isOptional: forceOptional ?? value.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodRecord": { const v = value as never as z.ZodRecord; const keys: string[] = getType(v._def.keyType as any).type; const values: string[] = getType(v._def.valueType as any).type; return { type: keys.map((key, i) => `Record<${key}, ${values[i]}>`), isOptional: forceOptional ?? v.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodNumber": { return { type: ["number", ..._null], isOptional: forceOptional ?? value.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodUnion": { const v = value as never as z.ZodUnion<[z.ZodAny]>; const types: string[] = []; for (const option of v.options) { const t = getType(option as any).type; types.push(t.length === 0 ? t[0] : `${t.join(" | ")}`); } return { type: types, isOptional: forceOptional ?? v.isOptional(), defaultValue: forceDefaultValue, }; } case "ZodNullable": { const v = value as never as z.ZodNullable<z.ZodAny>; const r = getType(v._def.innerType, { forceOptional: true }); return { type: r.type, isOptional: forceOptional ?? r.isOptional, defaultValue: forceDefaultValue, }; } case "ZodArray": { const v = value as never as z.ZodArray<z.ZodAny>; const types = getType(v._def.type as any); return { type: [ `${ types.type.length === 1 ? types.type[0] : `(${types.type.join(" | ")})` }[]`, ..._null, ], isOptional: forceOptional ?? v.isOptional(), defaultValue: forceDefaultValue, }; } default: { console.error(`Unknown Zod type: ${value._def.typeName}`); console.log(value._def); process.exit(1); } } } function parseParams(path: string, options: Options): string { let params: string[] = []; params.push(`path="${path}"`); params.push(`method="${options.method}"`); if (options.requireHeaders || isUsingSessionMiddleware) params.push("requireSession"); if (options.metadata?.SERVER_ONLY) params.push("isServerOnly"); if (options.method === "GET" && options.body) params.push("forceAsBody"); if (options.method === "POST" && options.query) params.push("forceAsQuery"); if (params.length === 2) return " " + params.join(" "); return "\n " + params.join("\n ") + "\n"; } function generateJSDoc({ path, options, functionName, isServerOnly, }: { path: string; options: Options; functionName: string; isServerOnly: boolean; }) { /** * ### Endpoint * * POST `/organization/set-active` * * ### API Methods * * **server:** * `auth.api.setActiveOrganization` * * **client:** * `authClient.organization.setActive` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-set-active) */ let jsdoc: string[] = []; jsdoc.push(`### Endpoint`); jsdoc.push(``); jsdoc.push(`${options.method} \`${path}\``); jsdoc.push(``); jsdoc.push(`### API Methods`); jsdoc.push(``); jsdoc.push(`**server:**`); jsdoc.push(`\`auth.api.${functionName}\``); jsdoc.push(``); if (!isServerOnly) { jsdoc.push(`**client:**`); jsdoc.push(`\`authClient.${pathToDotNotation(path)}\``); jsdoc.push(``); } jsdoc.push( `@see [Read our docs to learn more.](https://better-auth.com/docs/plugins/${ path.split("/")[1] }#api-method${path.replaceAll("/", "-")})`, ); return `/**\n * ${jsdoc.join("\n * ")}\n */`; } function pathToDotNotation(input: string): string { return input .split("/") // split into segments .filter(Boolean) // remove empty strings (from leading '/') .map((segment) => segment .split("-") // split kebab-case .map((word, i) => i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1), ) .join(""), ) .join("."); } function playSound(name: string = "Ping") { const path = `/System/Library/Sounds/${name}.aiff`; void Bun.$`afplay ${path}`; } ```