This is page 23 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 -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/mongodb-adapter/mongodb-adapter.ts: -------------------------------------------------------------------------------- ```typescript import { ClientSession, ObjectId, type Db, type MongoClient } from "mongodb"; import type { BetterAuthOptions } from "@better-auth/core"; import { createAdapterFactory, type AdapterFactoryOptions, type AdapterFactoryCustomizeAdapterCreator, } from "../adapter-factory"; import type { DBAdapterDebugLogOption, DBAdapter, Where, } from "@better-auth/core/db/adapter"; export interface MongoDBAdapterConfig { /** * MongoDB client instance * If not provided, Database transactions won't be enabled. */ client?: MongoClient; /** * Enable debug logs for the adapter * * @default false */ debugLogs?: DBAdapterDebugLogOption; /** * Use plural table names * * @default false */ usePlural?: boolean; /** * Whether to execute multiple operations in a transaction. * * If the database doesn't support transactions, * set this to `false` and operations will be executed sequentially. * @default false */ transaction?: boolean; } export const mongodbAdapter = (db: Db, config?: MongoDBAdapterConfig) => { let lazyOptions: BetterAuthOptions | null; const createCustomAdapter = (db: Db, session?: ClientSession): AdapterFactoryCustomizeAdapterCreator => ({ options, getFieldName, schema, getDefaultModelName }) => { function serializeID({ field, value, model, }: { field: string; value: any; model: string; }) { model = getDefaultModelName(model); if ( field === "id" || field === "_id" || schema[model]!.fields[field]?.references?.field === "id" ) { if (typeof value !== "string") { if (value instanceof ObjectId) { return value; } if (Array.isArray(value)) { return value.map((v) => { if (typeof v === "string") { try { return new ObjectId(v); } catch (e) { return v; } } if (v instanceof ObjectId) { return v; } throw new Error("Invalid id value"); }); } throw new Error("Invalid id value"); } try { return new ObjectId(value); } catch (e) { return value; } } return value; } function convertWhereClause({ where, model, }: { where: Where[]; model: string; }) { if (!where.length) return {}; const conditions = where.map((w) => { const { field: field_, value, operator = "eq", connector = "AND", } = w; let condition: any; let field = getFieldName({ model, field: field_ }); if (field === "id") field = "_id"; switch (operator.toLowerCase()) { case "eq": condition = { [field]: serializeID({ field, value, model, }), }; break; case "in": condition = { [field]: { $in: Array.isArray(value) ? value.map((v) => serializeID({ field, value: v, model })) : [serializeID({ field, value, model })], }, }; break; case "not_in": condition = { [field]: { $nin: Array.isArray(value) ? value.map((v) => serializeID({ field, value: v, model })) : [serializeID({ field, value, model })], }, }; break; case "gt": condition = { [field]: { $gt: value } }; break; case "gte": condition = { [field]: { $gte: value } }; break; case "lt": condition = { [field]: { $lt: value } }; break; case "lte": condition = { [field]: { $lte: value } }; break; case "ne": condition = { [field]: { $ne: value } }; break; case "contains": condition = { [field]: { $regex: `.*${escapeForMongoRegex(value as string)}.*`, }, }; break; case "starts_with": condition = { [field]: { $regex: `^${escapeForMongoRegex(value as string)}` }, }; break; case "ends_with": condition = { [field]: { $regex: `${escapeForMongoRegex(value as string)}$` }, }; break; default: throw new Error(`Unsupported operator: ${operator}`); } return { condition, connector }; }); if (conditions.length === 1) { return conditions[0]!.condition; } const andConditions = conditions .filter((c) => c.connector === "AND") .map((c) => c.condition); const orConditions = conditions .filter((c) => c.connector === "OR") .map((c) => c.condition); let clause = {}; if (andConditions.length) { clause = { ...clause, $and: andConditions }; } if (orConditions.length) { clause = { ...clause, $or: orConditions }; } return clause; } return { async create({ model, data: values }) { const res = await db.collection(model).insertOne(values, { session }); const insertedData = { _id: res.insertedId.toString(), ...values }; return insertedData as any; }, async findOne({ model, where, select }) { const clause = convertWhereClause({ where, model }); const projection = select ? Object.fromEntries( select.map((field) => [getFieldName({ field, model }), 1]), ) : undefined; const res = await db .collection(model) .findOne(clause, { session, projection }); if (!res) return null; return res as any; }, async findMany({ model, where, limit, offset, sortBy }) { const clause = where ? convertWhereClause({ where, model }) : {}; const cursor = db.collection(model).find(clause, { session }); if (limit) cursor.limit(limit); if (offset) cursor.skip(offset); if (sortBy) cursor.sort( getFieldName({ field: sortBy.field, model }), sortBy.direction === "desc" ? -1 : 1, ); const res = await cursor.toArray(); return res as any; }, async count({ model, where }) { const clause = where ? convertWhereClause({ where, model }) : {}; const res = await db .collection(model) .countDocuments(clause, { session }); return res; }, async update({ model, where, update: values }) { const clause = convertWhereClause({ where, model }); const res = await db.collection(model).findOneAndUpdate( clause, { $set: values as any }, { session, returnDocument: "after", }, ); if (!res) return null; return res as any; }, async updateMany({ model, where, update: values }) { const clause = convertWhereClause({ where, model }); const res = await db.collection(model).updateMany( clause, { $set: values as any, }, { session }, ); return res.modifiedCount; }, async delete({ model, where }) { const clause = convertWhereClause({ where, model }); await db.collection(model).deleteOne(clause, { session }); }, async deleteMany({ model, where }) { const clause = convertWhereClause({ where, model }); const res = await db .collection(model) .deleteMany(clause, { session }); return res.deletedCount; }, }; }; let lazyAdapter: | ((options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>) | null = null; let adapterOptions: AdapterFactoryOptions | null = null; adapterOptions = { config: { adapterId: "mongodb-adapter", adapterName: "MongoDB Adapter", usePlural: config?.usePlural ?? false, debugLogs: config?.debugLogs ?? false, mapKeysTransformInput: { id: "_id", }, mapKeysTransformOutput: { _id: "id", }, supportsNumericIds: false, transaction: config?.client && (config?.transaction ?? true) ? async (cb) => { if (!config.client) { return cb(lazyAdapter!(lazyOptions!)); } const session = config.client.startSession(); try { session.startTransaction(); const adapter = createAdapterFactory({ config: adapterOptions!.config, adapter: createCustomAdapter(db, session), })(lazyOptions!); const result = await cb(adapter); await session.commitTransaction(); return result; } catch (err) { await session.abortTransaction(); throw err; } finally { await session.endSession(); } } : false, customTransformInput({ action, data, field, fieldAttributes, schema, model, options, }) { if (field === "_id" || fieldAttributes.references?.field === "id") { if (action === "update") { if (typeof data === "string") { try { return new ObjectId(data); } catch (error) { return data; } } return data; } if (Array.isArray(data)) { return data.map((v) => { if (typeof v === "string") { try { return new ObjectId(v); } catch (error) { return v; } } return v; }); } if (typeof data === "string") { try { return new ObjectId(data); } catch (error) { return new ObjectId(); } } if (data === null && fieldAttributes.references?.field === "id") { return null; } return new ObjectId(); } return data; }, customTransformOutput({ data, field, fieldAttributes }) { if (field === "id" || fieldAttributes.references?.field === "id") { if (data instanceof ObjectId) { return data.toHexString(); } if (Array.isArray(data)) { return data.map((v) => { if (v instanceof ObjectId) { return v.toHexString(); } return v; }); } return data; } return data; }, customIdGenerator(props) { return new ObjectId().toString(); }, }, adapter: createCustomAdapter(db), }; lazyAdapter = createAdapterFactory(adapterOptions); return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => { lazyOptions = options; return lazyAdapter(options); }; }; /** * Safely escape user input for use in a MongoDB regex. * This ensures the resulting pattern is treated as literal text, * and not as a regex with special syntax. * * @param input - The input string to escape. Any type that isn't a string will be converted to an empty string. * @param maxLength - The maximum length of the input string to escape. Defaults to 256. This is to prevent DOS attacks. * @returns The escaped string. */ function escapeForMongoRegex(input: string, maxLength = 256): string { if (typeof input !== "string") return ""; // Escape all PCRE special characters // Source: PCRE docs — https://www.pcre.org/original/doc/html/pcrepattern.html return input.slice(0, maxLength).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } ``` -------------------------------------------------------------------------------- /e2e/smoke/test/fixtures/tsconfig-declaration/src/demo.ts: -------------------------------------------------------------------------------- ```typescript import { betterAuth } from "better-auth"; import { bearer, admin, multiSession, organization, twoFactor, oneTap, oAuthProxy, openAPI, customSession, deviceAuthorization, lastLoginMethod, } from "better-auth/plugins"; import { nextCookies } from "better-auth/next-js"; import { passkey } from "better-auth/plugins/passkey"; import { stripe } from "@better-auth/stripe"; import { sso } from "@better-auth/sso"; import { Stripe } from "stripe"; export const auth = betterAuth({ appName: "Better Auth Demo", plugins: [ organization({}), twoFactor({}), passkey(), openAPI(), bearer(), admin({ adminUserIds: ["EXD5zjob2SD6CBWcEQ6OpLRHcyoUbnaB"] }), multiSession(), oAuthProxy(), nextCookies(), oneTap(), customSession(async (session) => { return { ...session, user: { ...session.user, dd: "test", }, }; }), stripe({ stripeClient: new Stripe(process.env.STRIPE_KEY || "sk_test_"), stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, subscription: { enabled: true, allowReTrialsForDifferentPlans: true, plans: () => { const PRO_PRICE_ID = { default: process.env.STRIPE_PRO_PRICE_ID ?? "price_1RoxnRHmTADgihIt4y8c0lVE", annual: process.env.STRIPE_PRO_ANNUAL_PRICE_ID ?? "price_1RoxnoHmTADgihItzFvVP8KT", }; const PLUS_PRICE_ID = { default: process.env.STRIPE_PLUS_PRICE_ID ?? "price_1RoxnJHmTADgihIthZTLmrPn", annual: process.env.STRIPE_PLUS_ANNUAL_PRICE_ID ?? "price_1Roxo5HmTADgihItEbJu5llL", }; return [ { name: "Plus", priceId: PLUS_PRICE_ID.default, annualDiscountPriceId: PLUS_PRICE_ID.annual, freeTrial: { days: 7, }, }, { name: "Pro", priceId: PRO_PRICE_ID.default, annualDiscountPriceId: PRO_PRICE_ID.annual, freeTrial: { days: 7, }, }, ]; }, }, }), sso({ defaultSSO: [ { domain: "http://localhost:3000", providerId: "sso", samlConfig: { issuer: "http://localhost:3000/api/auth/sso/saml2/sp/metadata", entryPoint: "https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435", cert: `-----BEGIN CERTIFICATE----- MIIDBzCCAe+gAwIBAgIUCLBK4f75EXEe4gyroYnVaqLoSp4wDQYJKoZIhvcNAQEL BQAwEzERMA8GA1UEAwwIZHVtbXlpZHAwHhcNMjQwNTEzMjE1NDE2WhcNMzQwNTEx MjE1NDE2WjATMREwDwYDVQQDDAhkdW1teWlkcDCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBAKhmgQmWb8NvGhz952XY4SlJlpWIK72RilhOZS9frDYhqWVJ HsGH9Z7sSzrM/0+YvCyEWuZV9gpMeIaHZxEPDqW3RJ7KG51fn/s/qFvwctf+CZDj yfGDzYs+XIgf7p56U48EmYeWpB/aUW64gSbnPqrtWmVFBisOfIx5aY3NubtTsn+g 0XbdX0L57+NgSvPQHXh/GPXA7xCIWm54G5kqjozxbKEFA0DS3yb6oHRQWHqIAM/7 mJMdUVZNIV1q7c2JIgAl23uDWq+2KTE2R5liP/KjvjwKonVKtTqGqX6ei25rsTHO aDpBH/LdQK2txgsm7R7+IThWNvUI0TttrmwBqyMCAwEAAaNTMFEwHQYDVR0OBBYE FD142gxIAJMhpgMkgpzmRNoW9XbEMB8GA1UdIwQYMBaAFD142gxIAJMhpgMkgpzm RNoW9XbEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADQd6k6z FIc20GfGHY5C2MFwyGOmP5/UG/JiTq7Zky28G6D0NA0je+GztzXx7VYDfCfHxLcm 2k5t9nYhb9kVawiLUUDVF6s+yZUXA4gUA3KoTWh1/oRxR3ggW7dKYm9fsNOdQAbx UUkzp7HLZ45ZlpKUS0hO7es+fPyF5KVw0g0SrtQWwWucnQMAQE9m+B0aOf+92y7J QkdgdR8Gd/XZ4NZfoOnKV7A1utT4rWxYCgICeRTHx9tly5OhPW4hQr5qOpngcsJ9 vhr86IjznQXhfj3hql5lA3VbHW04ro37ROIkh2bShDq5dwJJHpYCGrF3MQv8S3m+ jzGhYL6m9gFTm/8= -----END CERTIFICATE-----`, spMetadata: { metadata: ` <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3000/api/auth/sso/saml2/sp/metadata"> <md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> <md:KeyDescriptor use="signing"> <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> <ds:X509Data> <ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate> </ds:X509Data> </ds:KeyInfo> </md:KeyDescriptor> <md:KeyDescriptor use="encryption"> <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"> <ds:X509Data> <ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate> </ds:X509Data> </ds:KeyInfo> </md:KeyDescriptor> <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3000/api/auth/sso/saml2/sp/sls"/> <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat> <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3000/api/auth/sso/saml2/sp/acs/sso" index="1"/> <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3000/api/auth/sso/saml2/sp/acs/sso" index="1"/> </md:SPSSODescriptor> <md:Organization> <md:OrganizationName xml:lang="en-US">Organization Name</md:OrganizationName> <md:OrganizationDisplayName xml:lang="en-US">Organization DisplayName</md:OrganizationDisplayName> <md:OrganizationURL xml:lang="en-US">http://localhost:3000/</md:OrganizationURL> </md:Organization> <md:ContactPerson contactType="technical"> <md:GivenName>Technical Contact Name</md:GivenName> <md:EmailAddress>[email protected]</md:EmailAddress> </md:ContactPerson> <md:ContactPerson contactType="support"> <md:GivenName>Support Contact Name</md:GivenName> <md:EmailAddress>[email protected]</md:EmailAddress> </md:ContactPerson> </md:EntityDescriptor> `, }, idpMetadata: { entityURL: "https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/metadata", entityID: "https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435", redirectURL: "https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/sso", singleSignOnService: [ { Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", Location: "https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/sso", }, ], cert: `-----BEGIN CERTIFICATE----- MIIDBzCCAe+gAwIBAgIUCLBK4f75EXEe4gyroYnVaqLoSp4wDQYJKoZIhvcNAQEL BQAwEzERMA8GA1UEAwwIZHVtbXlpZHAwHhcNMjQwNTEzMjE1NDE2WhcNMzQwNTEx MjE1NDE2WjATMREwDwYDVQQDDAhkdW1teWlkcDCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBAKhmgQmWb8NvGhz952XY4SlJlpWIK72RilhOZS9frDYhqWVJ HsGH9Z7sSzrM/0+YvCyEWuZV9gpMeIaHZxEPDqW3RJ7KG51fn/s/qFvwctf+CZDj yfGDzYs+XIgf7p56U48EmYeWpB/aUW64gSbnPqrtWmVFBisOfIx5aY3NubtTsn+g 0XbdX0L57+NgSvPQHXh/GPXA7xCIWm54G5kqjozxbKEFA0DS3yb6oHRQWHqIAM/7 mJMdUVZNIV1q7c2JIgAl23uDWq+2KTE2R5liP/KjvjwKonVKtTqGqX6ei25rsTHO aDpBH/LdQK2txgsm7R7+IThWNvUI0TttrmwBqyMCAwEAAaNTMFEwHQYDVR0OBBYE FD142gxIAJMhpgMkgpzmRNoW9XbEMB8GA1UdIwQYMBaAFD142gxIAJMhpgMkgpzm RNoW9XbEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADQd6k6z FIc20GfGHY5C2MFwyGOmP5/UG/JiTq7Zky28G6D0NA0je+GztzXx7VYDfCfHxLcm 2k5t9nYhb9kVawiLUUDVF6s+yZUXA4gUA3KoTWh1/oRxR3ggW7dKYm9fsNOdQAbx UUkzp7HLZ45ZlpKUS0hO7es+fPyF5KVw0g0SrtQWwWucnQMAQE9m+B0aOf+92y7J QkdgdR8Gd/XZ4NZfoOnKV7A1utT4rWxYCgICeRTHx9tly5OhPW4hQr5qOpngcsJ9 vhr86IjznQXhfj3hql5lA3VbHW04ro37ROIkh2bShDq5dwJJHpYCGrF3MQv8S3m+ jzGhYL6m9gFTm/8= -----END CERTIFICATE-----`, }, callbackUrl: "/dashboard", }, }, ], }), deviceAuthorization({ expiresIn: "3min", interval: "5s", }), lastLoginMethod(), ], }); auth.api .createOrganization({ body: { name: "My Org", slug: "my-org", }, }) .catch(); ``` -------------------------------------------------------------------------------- /docs/content/docs/concepts/client.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Client description: Better Auth client library for authentication. --- Better Auth offers a client library compatible with popular frontend frameworks like React, Vue, Svelte, and more. This client library includes a set of functions for interacting with the Better Auth server. Each framework's client library is built on top of a core client library that is framework-agnostic, so that all methods and hooks are consistently available across all client libraries. ## Installation If you haven't already, install better-auth. ```package-install npm i better-auth ``` ## Create Client Instance Import `createAuthClient` from the package for your framework (e.g., "better-auth/react" for React). Call the function to create your client. Pass the base URL of your auth server. If the auth server is running on the same domain as your client, you can skip this step. <Callout type="info"> If you're using a different base path other than `/api/auth`, make sure to pass the whole URL, including the path. (e.g., `http://localhost:3000/custom-path/auth`) </Callout> <Tabs items={["react", "vue", "svelte", "solid", "vanilla"]} defaultValue="react"> <Tab value="vanilla"> ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/client" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" // The base URL of your auth server // [!code highlight] }) ``` </Tab> <Tab value="react" title="lib/auth-client.ts"> ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/react" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" // The base URL of your auth server // [!code highlight] }) ``` </Tab> <Tab value="vue" title="lib/auth-client.ts"> ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/vue" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" // The base URL of your auth server // [!code highlight] }) ``` </Tab> <Tab value="svelte" title="lib/auth-client.ts"> ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/svelte" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" // The base URL of your auth server // [!code highlight] }) ``` </Tab> <Tab value="solid" title="lib/auth-client.ts"> ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/solid" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" // The base URL of your auth server // [!code highlight] }) ``` </Tab> </Tabs> ## Usage Once you've created your client instance, you can use the client to interact with the Better Auth server. The client provides a set of functions by default and they can be extended with plugins. **Example: Sign In** ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" const authClient = createAuthClient() await authClient.signIn.email({ email: "[email protected]", password: "password1234" }) ``` ### Hooks In addition to the standard methods, the client provides hooks to easily access different reactive data. Every hook is available in the root object of the client and they all start with `use`. **Example: useSession** <Tabs items={["React", "Vue","Svelte", "Solid"]} defaultValue="react"> <Tab value="React"> ```tsx title="user.tsx" //make sure you're using the react client import { createAuthClient } from "better-auth/react" const { useSession } = createAuthClient() // [!code highlight] export function User() { const { data: session, isPending, //loading state error, //error object refetch //refetch the session } = useSession() return ( //... ) } ``` </Tab> <Tab value="Vue"> ```vue title="user.vue" <script lang="ts" setup> import { authClient } from '@/lib/auth-client' const session = authClient.useSession() </script> <template> <div> <button v-if="!session.data" @click="() => authClient.signIn.social({ provider: 'github' })"> Continue with GitHub </button> <div> <pre>{{ session.data }}</pre> <button v-if="session.data" @click="authClient.signOut()"> Sign out </button> </div> </div> </template> ``` </Tab> <Tab value="Svelte"> ```svelte title="user.svelte" <script lang="ts"> import { client } from "$lib/client"; const session = client.useSession(); </script> <div style="display: flex; flex-direction: column; gap: 10px; border-radius: 10px; border: 1px solid #4B453F; padding: 20px; margin-top: 10px;" > <div> {#if $session.data} <div> <p> {$session.data.user.name} </p> <p> {$session.data.user.email} </p> <button onclick={async () => { await authClient.signOut(); }} > Signout </button> </div> {:else} <button onclick={async () => { await authClient.signIn.social({ provider: "github", }); }} > Continue with GitHub </button> {/if} </div> </div> ``` </Tab> <Tab value="Solid"> ```tsx title="user.tsx" import { client } from "~/lib/client"; import { Show } from 'solid-js'; export default function Home() { const session = client.useSession() return ( <Show when={session()} fallback={<button onClick={toggle}>Log in</button>} > <button onClick={toggle}>Log out</button> </Show> ); } ``` </Tab> </Tabs> ### Fetch Options The client uses a library called [better fetch](https://better-fetch.vercel.app) to make requests to the server. Better fetch is a wrapper around the native fetch API that provides a more convenient way to make requests. It's created by the same team behind Better Auth and is designed to work seamlessly with it. You can pass any default fetch options to the client by passing `fetchOptions` object to the `createAuthClient`. ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" const authClient = createAuthClient({ fetchOptions: { //any better-fetch options }, }) ``` You can also pass fetch options to most of the client functions. Either as the second argument or as a property in the object. ```ts title="auth-client.ts" await authClient.signIn.email({ email: "[email protected]", password: "password1234", }, { onSuccess(ctx) { // } }) //or await authClient.signIn.email({ email: "[email protected]", password: "password1234", fetchOptions: { onSuccess(ctx) { // } }, }) ``` ### Handling Errors Most of the client functions return a response object with the following properties: - `data`: The response data. - `error`: The error object if there was an error. The error object contains the following properties: - `message`: The error message. (e.g., "Invalid email or password") - `status`: The HTTP status code. - `statusText`: The HTTP status text. ```ts title="auth-client.ts" const { data, error } = await authClient.signIn.email({ email: "[email protected]", password: "password1234" }) if (error) { //handle error } ``` If the action accepts a `fetchOptions` option, you can pass an `onError` callback to handle errors. ```ts title="auth-client.ts" await authClient.signIn.email({ email: "[email protected]", password: "password1234", }, { onError(ctx) { //handle error } }) //or await authClient.signIn.email({ email: "[email protected]", password: "password1234", fetchOptions: { onError(ctx) { //handle error } } }) ``` Hooks like `useSession` also return an error object if there was an error fetching the session. On top of that, they also return an `isPending` property to indicate if the request is still pending. ```ts title="auth-client.ts" const { data, error, isPending } = useSession() if (error) { //handle error } ``` #### Error Codes The client instance contains $ERROR_CODES object that contains all the error codes returned by the server. You can use this to handle error translations or custom error messages. ```ts title="auth-client.ts" const authClient = createAuthClient(); type ErrorTypes = Partial< Record< keyof typeof authClient.$ERROR_CODES, { en: string; es: string; } > >; const errorCodes = { USER_ALREADY_EXISTS: { en: "user already registered", es: "usuario ya registrada", }, } satisfies ErrorTypes; const getErrorMessage = (code: string, lang: "en" | "es") => { if (code in errorCodes) { return errorCodes[code as keyof typeof errorCodes][lang]; } return ""; }; const { error } = await authClient.signUp.email({ email: "[email protected]", password: "password", name: "User", }); if(error?.code){ alert(getErrorMessage(error.code, "en")); } ``` ### Plugins You can extend the client with plugins to add more functionality. Plugins can add new functions to the client or modify existing ones. **Example: Magic Link Plugin** ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" import { magicLinkClient } from "better-auth/client/plugins" const authClient = createAuthClient({ plugins: [ magicLinkClient() ] }) ``` once you've added the plugin, you can use the new functions provided by the plugin. ```ts title="auth-client.ts" await authClient.signIn.magicLink({ email: "[email protected]" }) ``` ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/reset-password.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import { APIError } from "better-call"; import { getDate } from "../../utils/date"; import { generateId } from "../../utils"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { originCheck } from "../middlewares"; import type { AuthContext } from "@better-auth/core"; function redirectError( ctx: AuthContext, callbackURL: string | undefined, query?: Record<string, string>, ): string { const url = callbackURL ? new URL(callbackURL, ctx.baseURL) : new URL(`${ctx.baseURL}/error`); if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); return url.href; } function redirectCallback( ctx: AuthContext, callbackURL: string, query?: Record<string, string>, ): string { const url = new URL(callbackURL, ctx.baseURL); if (query) Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v)); return url.href; } export const requestPasswordReset = createAuthEndpoint( "/request-password-reset", { method: "POST", body: z.object({ /** * The email address of the user to send a password reset email to. */ email: z.email().meta({ description: "The email address of the user to send a password reset email to", }), /** * 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: z .string() .meta({ description: "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", }) .optional(), }), metadata: { openapi: { description: "Send a password reset email to the user", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", }, message: { type: "string", }, }, }, }, }, }, }, }, }, }, async (ctx) => { if (!ctx.context.options.emailAndPassword?.sendResetPassword) { ctx.context.logger.error( "Reset password isn't enabled.Please pass an emailAndPassword.sendResetPassword function in your auth config!", ); throw new APIError("BAD_REQUEST", { message: "Reset password isn't enabled", }); } const { email, redirectTo } = ctx.body; const user = await ctx.context.internalAdapter.findUserByEmail(email, { includeAccounts: true, }); if (!user) { ctx.context.logger.error("Reset Password: User not found", { email }); return ctx.json({ status: true, message: "If this email exists in our system, check your email for the reset link", }); } const defaultExpiresIn = 60 * 60 * 1; const expiresAt = getDate( ctx.context.options.emailAndPassword.resetPasswordTokenExpiresIn || defaultExpiresIn, "sec", ); const verificationToken = generateId(24); await ctx.context.internalAdapter.createVerificationValue({ value: user.user.id, identifier: `reset-password:${verificationToken}`, expiresAt, }); const callbackURL = redirectTo ? encodeURIComponent(redirectTo) : ""; const url = `${ctx.context.baseURL}/reset-password/${verificationToken}?callbackURL=${callbackURL}`; await ctx.context.options.emailAndPassword.sendResetPassword( { user: user.user, url, token: verificationToken, }, ctx.request, ); return ctx.json({ status: true, message: "If this email exists in our system, check your email for the reset link", }); }, ); /** * @deprecated Use requestPasswordReset instead. This endpoint will be removed in the next major * version. */ export const forgetPassword = createAuthEndpoint( "/forget-password", { method: "POST", body: z.object({ /** * The email address of the user to send a password reset email to. */ email: z.string().email().meta({ description: "The email address of the user to send a password reset email to", }), /** * 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: z .string() .meta({ description: "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", }) .optional(), }), metadata: { openapi: { description: "Send a password reset email to the user", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", }, message: { type: "string", }, }, }, }, }, }, }, }, }, }, async (ctx) => { if (!ctx.context.options.emailAndPassword?.sendResetPassword) { ctx.context.logger.error( "Reset password isn't enabled.Please pass an emailAndPassword.sendResetPassword function in your auth config!", ); throw new APIError("BAD_REQUEST", { message: "Reset password isn't enabled", }); } const { email, redirectTo } = ctx.body; const user = await ctx.context.internalAdapter.findUserByEmail(email, { includeAccounts: true, }); if (!user) { ctx.context.logger.error("Reset Password: User not found", { email }); return ctx.json({ status: true, message: "If this email exists in our system, check your email for the reset link", }); } const defaultExpiresIn = 60 * 60 * 1; const expiresAt = getDate( ctx.context.options.emailAndPassword.resetPasswordTokenExpiresIn || defaultExpiresIn, "sec", ); const verificationToken = generateId(24); await ctx.context.internalAdapter.createVerificationValue({ value: user.user.id, identifier: `reset-password:${verificationToken}`, expiresAt, }); const callbackURL = redirectTo ? encodeURIComponent(redirectTo) : ""; const url = `${ctx.context.baseURL}/reset-password/${verificationToken}?callbackURL=${callbackURL}`; await ctx.context.options.emailAndPassword.sendResetPassword( { user: user.user, url, token: verificationToken, }, ctx.request, ); return ctx.json({ status: true, }); }, ); export const requestPasswordResetCallback = createAuthEndpoint( "/reset-password/:token", { method: "GET", query: z.object({ callbackURL: z.string().meta({ description: "The URL to redirect the user to reset their password", }), }), use: [originCheck((ctx) => ctx.query.callbackURL)], metadata: { openapi: { description: "Redirects the user to the callback URL with the token", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const { token } = ctx.params; const { callbackURL } = ctx.query; if (!token || !callbackURL) { throw ctx.redirect( redirectError(ctx.context, callbackURL, { error: "INVALID_TOKEN" }), ); } const verification = await ctx.context.internalAdapter.findVerificationValue( `reset-password:${token}`, ); if (!verification || verification.expiresAt < new Date()) { throw ctx.redirect( redirectError(ctx.context, callbackURL, { error: "INVALID_TOKEN" }), ); } throw ctx.redirect(redirectCallback(ctx.context, callbackURL, { token })); }, ); /** * @deprecated Use requestPasswordResetCallback instead */ export const forgetPasswordCallback = requestPasswordResetCallback; export const resetPassword = createAuthEndpoint( "/reset-password", { method: "POST", query: z .object({ token: z.string().optional(), }) .optional(), body: z.object({ newPassword: z.string().meta({ description: "The new password to set", }), token: z .string() .meta({ description: "The token to reset the password", }) .optional(), }), metadata: { openapi: { description: "Reset the password for a user", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const token = ctx.body.token || ctx.query?.token; if (!token) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.INVALID_TOKEN, }); } const { newPassword } = ctx.body; const minLength = ctx.context.password?.config.minPasswordLength; const maxLength = ctx.context.password?.config.maxPasswordLength; if (newPassword.length < minLength) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.PASSWORD_TOO_SHORT, }); } if (newPassword.length > maxLength) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.PASSWORD_TOO_LONG, }); } const id = `reset-password:${token}`; const verification = await ctx.context.internalAdapter.findVerificationValue(id); if (!verification || verification.expiresAt < new Date()) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.INVALID_TOKEN, }); } const userId = verification.value; const hashedPassword = await ctx.context.password.hash(newPassword); const accounts = await ctx.context.internalAdapter.findAccounts(userId); const account = accounts.find((ac) => ac.providerId === "credential"); if (!account) { await ctx.context.internalAdapter.createAccount({ userId, providerId: "credential", password: hashedPassword, accountId: userId, }); } else { await ctx.context.internalAdapter.updatePassword(userId, hashedPassword); } await ctx.context.internalAdapter.deleteVerificationValue(verification.id); if (ctx.context.options.emailAndPassword?.onPasswordReset) { const user = await ctx.context.internalAdapter.findUserById(userId); if (user) { await ctx.context.options.emailAndPassword.onPasswordReset( { user, }, ctx.request, ); } } if (ctx.context.options.emailAndPassword?.revokeSessionsOnPasswordReset) { await ctx.context.internalAdapter.deleteSessions(userId); } return ctx.json({ status: true, }); }, ); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/email-verification.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import { APIError } from "better-call"; import { getSessionFromCtx } from "./session"; import { setSessionCookie } from "../../cookies"; import type { User } from "../../types"; import { jwtVerify, type JWTPayload, type JWTVerifyResult } from "jose"; import { signJWT } from "../../crypto/jwt"; import { originCheck } from "../middlewares"; import { JWTExpired } from "jose/errors"; import type { GenericEndpointContext } from "@better-auth/core"; export async function createEmailVerificationToken( secret: string, email: string, /** * The email to update from */ updateTo?: string, /** * The time in seconds for the token to expire */ expiresIn: number = 3600, ) { const token = await signJWT( { email: email.toLowerCase(), updateTo, }, secret, expiresIn, ); return token; } /** * A function to send a verification email to the user */ export async function sendVerificationEmailFn( ctx: GenericEndpointContext, user: User, ) { if (!ctx.context.options.emailVerification?.sendVerificationEmail) { ctx.context.logger.error("Verification email isn't enabled."); throw new APIError("BAD_REQUEST", { message: "Verification email isn't enabled", }); } const token = await createEmailVerificationToken( ctx.context.secret, user.email, undefined, ctx.context.options.emailVerification?.expiresIn, ); const callbackURL = ctx.body.callbackURL ? encodeURIComponent(ctx.body.callbackURL) : encodeURIComponent("/"); const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`; await ctx.context.options.emailVerification.sendVerificationEmail( { user: user, url, token, }, ctx.request, ); } export const sendVerificationEmail = createAuthEndpoint( "/send-verification-email", { method: "POST", body: z.object({ email: z.email().meta({ description: "The email to send the verification email to", }), callbackURL: z .string() .meta({ description: "The URL to use for email verification callback", }) .optional(), }), metadata: { openapi: { description: "Send a verification email to the user", requestBody: { content: { "application/json": { schema: { type: "object", properties: { email: { type: "string", description: "The email to send the verification email to", example: "[email protected]", }, callbackURL: { type: "string", description: "The URL to use for email verification callback", example: "https://example.com/callback", nullable: true, }, }, required: ["email"], }, }, }, }, responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", description: "Indicates if the email was sent successfully", example: true, }, }, }, }, }, }, "400": { description: "Bad Request", content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", description: "Error message", example: "Verification email isn't enabled", }, }, }, }, }, }, }, }, }, }, async (ctx) => { if (!ctx.context.options.emailVerification?.sendVerificationEmail) { ctx.context.logger.error("Verification email isn't enabled."); throw new APIError("BAD_REQUEST", { message: "Verification email isn't enabled", }); } const { email } = ctx.body; const session = await getSessionFromCtx(ctx); if (!session) { const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { //we're returning true to avoid leaking information about the user return ctx.json({ status: true, }); } await sendVerificationEmailFn(ctx, user.user); return ctx.json({ status: true, }); } if (session?.user.emailVerified) { throw new APIError("BAD_REQUEST", { message: "You can only send a verification email to an unverified email", }); } if (session?.user.email !== email) { throw new APIError("BAD_REQUEST", { message: "You can only send a verification email to your own email", }); } await sendVerificationEmailFn(ctx, session.user); return ctx.json({ status: true, }); }, ); export const verifyEmail = createAuthEndpoint( "/verify-email", { method: "GET", query: z.object({ token: z.string().meta({ description: "The token to verify the email", }), callbackURL: z .string() .meta({ description: "The URL to redirect to after email verification", }) .optional(), }), use: [originCheck((ctx) => ctx.query.callbackURL)], metadata: { openapi: { description: "Verify the email of the user", parameters: [ { name: "token", in: "query", description: "The token to verify the email", required: true, schema: { type: "string", }, }, { name: "callbackURL", in: "query", description: "The URL to redirect to after email verification", required: false, schema: { type: "string", }, }, ], responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", properties: { id: { type: "string", description: "User ID", }, email: { type: "string", description: "User email", }, name: { type: "string", description: "User name", }, image: { type: "string", description: "User image URL", }, emailVerified: { type: "boolean", description: "Indicates if the user email is verified", }, createdAt: { type: "string", description: "User creation date", }, updatedAt: { type: "string", description: "User update date", }, }, required: [ "id", "email", "name", "image", "emailVerified", "createdAt", "updatedAt", ], }, status: { type: "boolean", description: "Indicates if the email was verified successfully", }, }, required: ["user", "status"], }, }, }, }, }, }, }, }, async (ctx) => { function redirectOnError(error: string) { if (ctx.query.callbackURL) { if (ctx.query.callbackURL.includes("?")) { throw ctx.redirect(`${ctx.query.callbackURL}&error=${error}`); } throw ctx.redirect(`${ctx.query.callbackURL}?error=${error}`); } throw new APIError("UNAUTHORIZED", { message: error, }); } const { token } = ctx.query; let jwt: JWTVerifyResult<JWTPayload>; try { jwt = await jwtVerify( token, new TextEncoder().encode(ctx.context.secret), { algorithms: ["HS256"], }, ); } catch (e) { if (e instanceof JWTExpired) { return redirectOnError("token_expired"); } return redirectOnError("invalid_token"); } const schema = z.object({ email: z.string().email(), updateTo: z.string().optional(), }); const parsed = schema.parse(jwt.payload); const user = await ctx.context.internalAdapter.findUserByEmail( parsed.email, ); if (!user) { return redirectOnError("user_not_found"); } if (parsed.updateTo) { const session = await getSessionFromCtx(ctx); if (!session) { if (ctx.query.callbackURL) { throw ctx.redirect(`${ctx.query.callbackURL}?error=unauthorized`); } return redirectOnError("unauthorized"); } if (session.user.email !== parsed.email) { if (ctx.query.callbackURL) { throw ctx.redirect(`${ctx.query.callbackURL}?error=unauthorized`); } return redirectOnError("unauthorized"); } const updatedUser = await ctx.context.internalAdapter.updateUserByEmail( parsed.email, { email: parsed.updateTo, emailVerified: false, }, ); const newToken = await createEmailVerificationToken( ctx.context.secret, parsed.updateTo, ); //send verification email to the new email const updateCallbackURL = ctx.query.callbackURL ? encodeURIComponent(ctx.query.callbackURL) : encodeURIComponent("/"); await ctx.context.options.emailVerification?.sendVerificationEmail?.( { user: updatedUser, url: `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`, token: newToken, }, ctx.request, ); await setSessionCookie(ctx, { session: session.session, user: { ...session.user, email: parsed.updateTo, emailVerified: false, }, }); if (ctx.query.callbackURL) { throw ctx.redirect(ctx.query.callbackURL); } return ctx.json({ status: true, user: { id: updatedUser.id, email: updatedUser.email, name: updatedUser.name, image: updatedUser.image, emailVerified: updatedUser.emailVerified, createdAt: updatedUser.createdAt, updatedAt: updatedUser.updatedAt, }, }); } if (ctx.context.options.emailVerification?.onEmailVerification) { await ctx.context.options.emailVerification.onEmailVerification( user.user, ctx.request, ); } const updatedUser = await ctx.context.internalAdapter.updateUserByEmail( parsed.email, { emailVerified: true, }, ); if (ctx.context.options.emailVerification?.afterEmailVerification) { await ctx.context.options.emailVerification.afterEmailVerification( updatedUser, ctx.request, ); } if (ctx.context.options.emailVerification?.autoSignInAfterVerification) { const currentSession = await getSessionFromCtx(ctx); if (!currentSession || currentSession.user.email !== parsed.email) { const session = await ctx.context.internalAdapter.createSession( user.user.id, ); if (!session) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Failed to create session", }); } await setSessionCookie(ctx, { session, user: { ...user.user, emailVerified: true, }, }); } else { await setSessionCookie(ctx, { session: currentSession.session, user: { ...currentSession.user, emailVerified: true, }, }); } } if (ctx.query.callbackURL) { throw ctx.redirect(ctx.query.callbackURL); } return ctx.json({ status: true, user: null, }); }, ); ``` -------------------------------------------------------------------------------- /packages/cli/test/generate.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it } from "vitest"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { generatePrismaSchema } from "../src/generators/prisma"; import { organization, twoFactor, username } from "better-auth/plugins"; import { generateDrizzleSchema } from "../src/generators/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { generateMigrations } from "../src/generators/kysely"; import Database from "better-sqlite3"; import type { BetterAuthOptions } from "better-auth"; import { generateAuthConfig } from "../src/generators/auth-config"; import type { SupportedPlugin } from "../src/commands/init"; describe("generate", async () => { it("should generate prisma schema", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", adapter: prismaAdapter( {}, { provider: "postgresql", }, )({} as BetterAuthOptions), options: { database: prismaAdapter( {}, { provider: "postgresql", }, ), plugins: [twoFactor(), username()], }, }); expect(schema.code).toMatchFileSnapshot("./__snapshots__/schema.prisma"); }); it("should generate prisma schema with number id", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", adapter: prismaAdapter( {}, { provider: "postgresql", }, )({} as BetterAuthOptions), options: { database: prismaAdapter( {}, { provider: "postgresql", }, ), plugins: [twoFactor(), username()], advanced: { database: { useNumberId: true, }, }, }, }); expect(schema.code).toMatchFileSnapshot( "./__snapshots__/schema-numberid.prisma", ); }); it("should generate prisma schema for mongodb", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", adapter: prismaAdapter( {}, { provider: "mongodb", }, )({} as BetterAuthOptions), options: { database: prismaAdapter( {}, { provider: "mongodb", }, ), plugins: [twoFactor(), username()], }, }); expect(schema.code).toMatchFileSnapshot( "./__snapshots__/schema-mongodb.prisma", ); }); it("should generate prisma schema for mysql", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", adapter: prismaAdapter( {}, { provider: "mysql", }, )({} as BetterAuthOptions), options: { database: prismaAdapter( {}, { provider: "mongodb", }, ), plugins: [twoFactor(), username()], }, }); expect(schema.code).toMatchFileSnapshot( "./__snapshots__/schema-mysql.prisma", ); }); it("should generate prisma schema for mysql with custom model names", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", adapter: prismaAdapter( {}, { provider: "mysql", }, )({} as BetterAuthOptions), options: { database: prismaAdapter( {}, { provider: "mongodb", }, ), plugins: [ twoFactor(), username(), organization({ schema: { organization: { modelName: "workspace", }, invitation: { modelName: "workspaceInvitation", }, }, }), ], }, }); expect(schema.code).toMatchFileSnapshot( "./__snapshots__/schema-mysql-custom.prisma", ); }); it("should generate drizzle schema", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: drizzleAdapter( {}, { provider: "pg", schema: {}, }, )({} as BetterAuthOptions), options: { database: drizzleAdapter( {}, { provider: "pg", schema: {}, }, ), plugins: [twoFactor(), username()], user: { modelName: "custom_user", }, account: { modelName: "custom_account", }, session: { modelName: "custom_session", }, verification: { modelName: "custom_verification", }, }, }); expect(schema.code).toMatchFileSnapshot("./__snapshots__/auth-schema.txt"); }); it("should generate drizzle schema with number id", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: drizzleAdapter( {}, { provider: "pg", schema: {}, }, )({} as BetterAuthOptions), options: { database: drizzleAdapter( {}, { provider: "pg", schema: {}, }, ), plugins: [twoFactor(), username()], advanced: { database: { useNumberId: true, }, }, user: { modelName: "custom_user", }, account: { modelName: "custom_account", }, session: { modelName: "custom_session", }, verification: { modelName: "custom_verification", }, }, }); expect(schema.code).toMatchFileSnapshot( "./__snapshots__/auth-schema-number-id.txt", ); }); it("should generate kysely schema", async () => { const schema = await generateMigrations({ file: "test.sql", options: { database: new Database(":memory:"), }, adapter: {} as any, }); expect(schema.code).toMatchFileSnapshot("./__snapshots__/migrations.sql"); }); it("should add plugin to empty plugins array without leading comma", async () => { const initialConfig = `export const auth = betterAuth({ plugins: [] });`; const mockFormat = (code: string) => Promise.resolve(code); const mockSpinner = { stop: () => {} }; const plugins: SupportedPlugin[] = [ { id: "next-cookies", name: "nextCookies", path: "better-auth/next-js", clientName: undefined, clientPath: undefined, }, ]; const result = await generateAuthConfig({ format: mockFormat, current_user_config: initialConfig, spinner: mockSpinner as any, plugins, database: null, }); expect(result.generatedCode).toContain(`plugins: [nextCookies()]`); expect(result.generatedCode).not.toContain(`plugins: [, nextCookies()]`); }); }); describe("JSON field support in CLI generators", () => { it("should generate Drizzle schema with JSON fields for PostgreSQL", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "pg", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { preferences: { type: "json", }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain("preferences: jsonb("); }); it("should generate Drizzle schema with JSON fields for MySQL", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "mysql", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { preferences: { type: "json", }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain("preferences: json("); }); it("should generate Drizzle schema with JSON fields for SQLite", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "sqlite", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { preferences: { type: "json", }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain("preferences: text("); }); it("should generate Prisma schema with JSON fields", async () => { const schema = await generatePrismaSchema({ file: "test.prisma", adapter: { id: "prisma", options: {}, } as any, options: { database: {} as any, user: { additionalFields: { preferences: { type: "json", }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain("preferences Json?"); }); }); describe("Enum field support in Drizzle schemas", () => { it("should generate Drizzle schema with enum fields for PostgreSQL", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "pg", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { role: { type: ["admin", "user", "guest"], required: true, }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain( 'role: text("role", { enum: ["admin", "user", "guest"] })', ); await expect(schema.code).toMatchFileSnapshot( "./__snapshots__/auth-schema-pg-enum.txt", ); }); it("should generate Drizzle schema with enum fields for MySQL", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "mysql", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { status: { type: ["active", "inactive", "pending"], required: false, }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain("mysqlEnum"); expect(schema.code).toContain( 'status: mysqlEnum(["active", "inactive", "pending"])', ); await expect(schema.code).toMatchFileSnapshot( "./__snapshots__/auth-schema-mysql-enum.txt", ); }); it("should generate Drizzle schema with enum fields for SQLite", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "sqlite", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { priority: { type: ["high", "medium", "low"], }, }, }, } as BetterAuthOptions, }); expect(schema.code).toContain("text({ enum: ["); expect(schema.code).toContain( 'priority: text({ enum: ["high", "medium", "low"] })', ); await expect(schema.code).toMatchFileSnapshot( "./__snapshots__/auth-schema-sqlite-enum.txt", ); }); it("should include correct imports for enum fields in MySQL", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "mysql", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { status: { type: ["active", "inactive"], }, }, }, } as BetterAuthOptions, }); expect(schema.code).toMatch( /import.*mysqlEnum.*from.*drizzle-orm\/mysql-core/s, ); }); it("should not include enum imports when no enum fields are present", async () => { const schema = await generateDrizzleSchema({ file: "test.drizzle", adapter: { id: "drizzle", options: { provider: "pg", schema: {}, }, } as any, options: { database: {} as any, user: { additionalFields: { name: { type: "string", }, }, }, } as BetterAuthOptions, }); expect(schema.code).not.toContain("enum"); }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/magic-link/index.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import type { BetterAuthPlugin } from "@better-auth/core"; import { APIError } from "better-call"; import { setSessionCookie } from "../../cookies"; import { generateRandomString } from "../../crypto"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { originCheck } from "../../api"; import { defaultKeyHasher } from "./utils"; import type { GenericEndpointContext } from "@better-auth/core"; interface MagicLinkopts { /** * Time in seconds until the magic link expires. * @default (60 * 5) // 5 minutes */ expiresIn?: number; /** * Send magic link implementation. */ sendMagicLink: ( data: { email: string; url: string; token: string; }, request?: Request, ) => Promise<void> | void; /** * Disable sign up if user is not found. * * @default false */ disableSignUp?: boolean; /** * Rate limit configuration. * * @default { * window: 60, * max: 5, * } */ rateLimit?: { window: number; max: number; }; /** * Custom function to generate a token */ generateToken?: (email: string) => Promise<string> | string; /** * This option allows you to configure how the token is stored in your database. * Note: This will not affect the token that's sent, it will only affect the token stored in your database. * * @default "plain" */ storeToken?: | "plain" | "hashed" | { type: "custom-hasher"; hash: (token: string) => Promise<string> }; } export const magicLink = (options: MagicLinkopts) => { const opts = { storeToken: "plain", ...options, } satisfies MagicLinkopts; async function storeToken(ctx: GenericEndpointContext, token: string) { if (opts.storeToken === "hashed") { return await defaultKeyHasher(token); } if ( typeof opts.storeToken === "object" && "type" in opts.storeToken && opts.storeToken.type === "custom-hasher" ) { return await opts.storeToken.hash(token); } return token; } return { id: "magic-link", endpoints: { /** * ### Endpoint * * POST `/sign-in/magic-link` * * ### API Methods * * **server:** * `auth.api.signInMagicLink` * * **client:** * `authClient.signIn.magicLink` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/sign-in#api-method-sign-in-magic-link) */ signInMagicLink: createAuthEndpoint( "/sign-in/magic-link", { method: "POST", requireHeaders: true, body: z.object({ email: z .string() .meta({ description: "Email address to send the magic link", }) .email(), name: z .string() .meta({ description: 'User display name. Only used if the user is registering for the first time. Eg: "my-name"', }) .optional(), callbackURL: z .string() .meta({ description: "URL to redirect after magic link verification", }) .optional(), newUserCallbackURL: z .string() .meta({ description: "URL to redirect after new user signup. Only used if the user is registering for the first time.", }) .optional(), errorCallbackURL: z .string() .meta({ description: "URL to redirect after error.", }) .optional(), }), metadata: { openapi: { description: "Sign in with magic link", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const { email } = ctx.body; if (opts.disableSignUp) { const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.USER_NOT_FOUND, }); } } const verificationToken = opts?.generateToken ? await opts.generateToken(email) : generateRandomString(32, "a-z", "A-Z"); const storedToken = await storeToken(ctx, verificationToken); await ctx.context.internalAdapter.createVerificationValue({ identifier: storedToken, value: JSON.stringify({ email, name: ctx.body.name }), expiresAt: new Date(Date.now() + (opts.expiresIn || 60 * 5) * 1000), }); const realBaseURL = new URL(ctx.context.baseURL); const pathname = realBaseURL.pathname === "/" ? "" : realBaseURL.pathname; const basePath = pathname ? "" : ctx.context.options.basePath || ""; const url = new URL( `${pathname}${basePath}/magic-link/verify`, realBaseURL.origin, ); url.searchParams.set("token", verificationToken); url.searchParams.set("callbackURL", ctx.body.callbackURL || "/"); if (ctx.body.newUserCallbackURL) { url.searchParams.set( "newUserCallbackURL", ctx.body.newUserCallbackURL, ); } if (ctx.body.errorCallbackURL) { url.searchParams.set("errorCallbackURL", ctx.body.errorCallbackURL); } await options.sendMagicLink( { email, url: url.toString(), token: verificationToken, }, ctx.request, ); return ctx.json({ status: true, }); }, ), /** * ### Endpoint * * GET `/magic-link/verify` * * ### API Methods * * **server:** * `auth.api.magicLinkVerify` * * **client:** * `authClient.magicLink.verify` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/magic-link#api-method-magic-link-verify) */ magicLinkVerify: createAuthEndpoint( "/magic-link/verify", { method: "GET", query: z.object({ token: z.string().meta({ description: "Verification token", }), callbackURL: z .string() .meta({ description: 'URL to redirect after magic link verification, if not provided the user will be redirected to the root URL. Eg: "/dashboard"', }) .optional(), errorCallbackURL: z .string() .meta({ description: "URL to redirect after error.", }) .optional(), newUserCallbackURL: z .string() .meta({ description: "URL to redirect after new user signup. Only used if the user is registering for the first time.", }) .optional(), }), use: [ originCheck((ctx) => { return ctx.query.callbackURL ? decodeURIComponent(ctx.query.callbackURL) : "/"; }), originCheck((ctx) => { return ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : "/"; }), originCheck((ctx) => { return ctx.query.errorCallbackURL ? decodeURIComponent(ctx.query.errorCallbackURL) : "/"; }), ], requireHeaders: true, metadata: { openapi: { description: "Verify magic link", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { session: { $ref: "#/components/schemas/Session", }, user: { $ref: "#/components/schemas/User", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const token = ctx.query.token; // If the first argument provides the origin, it will ignore the second argument of `new URL`. // new URL("http://localhost:3001/hello", "http://localhost:3000").toString() // Returns http://localhost:3001/hello const callbackURL = new URL( ctx.query.callbackURL ? decodeURIComponent(ctx.query.callbackURL) : "/", ctx.context.baseURL, ).toString(); const errorCallbackURL = new URL( ctx.query.errorCallbackURL ? decodeURIComponent(ctx.query.errorCallbackURL) : callbackURL, ctx.context.baseURL, ).toString(); const newUserCallbackURL = new URL( ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : callbackURL, ctx.context.baseURL, ).toString(); const toRedirectTo = callbackURL?.startsWith("http") ? callbackURL : callbackURL ? `${ctx.context.options.baseURL}${callbackURL}` : ctx.context.options.baseURL; const storedToken = await storeToken(ctx, token); const tokenValue = await ctx.context.internalAdapter.findVerificationValue( storedToken, ); if (!tokenValue) { throw ctx.redirect(`${errorCallbackURL}?error=INVALID_TOKEN`); } if (tokenValue.expiresAt < new Date()) { await ctx.context.internalAdapter.deleteVerificationValue( tokenValue.id, ); throw ctx.redirect(`${errorCallbackURL}?error=EXPIRED_TOKEN`); } await ctx.context.internalAdapter.deleteVerificationValue( tokenValue.id, ); const { email, name } = JSON.parse(tokenValue.value) as { email: string; name?: string; }; let isNewUser = false; let user = await ctx.context.internalAdapter .findUserByEmail(email) .then((res) => res?.user); if (!user) { if (!opts.disableSignUp) { const newUser = await ctx.context.internalAdapter.createUser({ email: email, emailVerified: true, name: name || "", }); isNewUser = true; user = newUser; if (!user) { throw ctx.redirect( `${errorCallbackURL}?error=failed_to_create_user`, ); } } else { throw ctx.redirect( `${errorCallbackURL}?error=new_user_signup_disabled`, ); } } if (!user.emailVerified) { await ctx.context.internalAdapter.updateUser(user.id, { emailVerified: true, }); } const session = await ctx.context.internalAdapter.createSession( user.id, ); if (!session) { throw ctx.redirect( `${errorCallbackURL}?error=failed_to_create_session`, ); } await setSessionCookie(ctx, { session, user, }); if (!ctx.query.callbackURL) { return ctx.json({ token: session.token, user: { id: user.id, email: user.email, emailVerified: user.emailVerified, name: user.name, image: user.image, createdAt: user.createdAt, updatedAt: user.updatedAt, }, }); } if (isNewUser) { throw ctx.redirect(newUserCallbackURL); } throw ctx.redirect(callbackURL); }, ), }, rateLimit: [ { pathMatcher(path) { return ( path.startsWith("/sign-in/magic-link") || path.startsWith("/magic-link/verify") ); }, window: opts.rateLimit?.window || 60, max: opts.rateLimit?.max || 5, }, ], } satisfies BetterAuthPlugin; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/email-verification.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; describe("Email Verification", async () => { const mockSendEmail = vi.fn(); let token: string; const { auth, testUser, client, signInWithUser } = await getTestInstance({ emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { token = _token; mockSendEmail(user.email, url); }, }, }); it("should send a verification email when enabled", async () => { await auth.api.sendVerificationEmail({ body: { email: testUser.email, }, }); expect(mockSendEmail).toHaveBeenCalledWith( testUser.email, expect.any(String), ); }); it("should send a verification email if verification is required and user is not verified", async () => { await signInWithUser(testUser.email, testUser.password); expect(mockSendEmail).toHaveBeenCalledWith( testUser.email, expect.any(String), ); }); it("should verify email", async () => { const res = await client.verifyEmail({ query: { token, }, }); expect(res.data?.status).toBe(true); }); it("should redirect to callback", async () => { await client.verifyEmail( { query: { token, callbackURL: "/callback", }, }, { onError: (ctx) => { const location = ctx.response.headers.get("location"); expect(location).toBe("/callback"); }, }, ); }); it("should sign after verification", async () => { const { testUser, client, sessionSetter, runWithUser } = await getTestInstance({ emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { token = _token; mockSendEmail(user.email, url); }, autoSignInAfterVerification: true, }, }); // Attempt to update user info (should fail before verification) await runWithUser(testUser.email, testUser.password, async () => { const updateRes = await client.updateUser({ name: "New Name", image: "https://example.com/image.jpg", }); expect(updateRes.data).toBeNull(); expect(updateRes.error!.status).toBe(401); expect(updateRes.error!.statusText).toBe("UNAUTHORIZED"); }); let sessionToken = ""; let verifyHeaders = new Headers(); const res = await client.verifyEmail({ query: { token, }, fetchOptions: { onSuccess(context) { sessionToken = context.response.headers.get("set-auth-token") || ""; sessionSetter(verifyHeaders)(context); }, }, }); expect(sessionToken.length).toBeGreaterThan(10); const session = await client.getSession({ fetchOptions: { headers: verifyHeaders, throw: true, }, }); expect(session!.user.emailVerified).toBe(true); }); it("should use custom expiresIn", async () => { const { auth, client } = await getTestInstance({ emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { token = _token; mockSendEmail(user.email, url); }, expiresIn: 10, }, }); await auth.api.sendVerificationEmail({ body: { email: testUser.email, }, }); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(10 * 1000); const res = await client.verifyEmail({ query: { token, }, }); expect(res.error?.code).toBe("TOKEN_EXPIRED"); }); it("should call onEmailVerification callback when email is verified", async () => { const onEmailVerificationMock = vi.fn(); const { auth, client } = await getTestInstance({ emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { token = _token; mockSendEmail(user.email, url); }, onEmailVerification: onEmailVerificationMock, }, }); await auth.api.sendVerificationEmail({ body: { email: testUser.email, }, }); const res = await client.verifyEmail({ query: { token, }, }); expect(res.data?.status).toBe(true); expect(onEmailVerificationMock).toHaveBeenCalledWith( expect.objectContaining({ email: testUser.email }), expect.any(Object), ); }); it("should call afterEmailVerification callback when email is verified", async () => { const afterEmailVerificationMock = vi.fn(); const { auth, client, testUser } = await getTestInstance({ emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { token = _token; mockSendEmail(user.email, url); }, afterEmailVerification: afterEmailVerificationMock, }, }); await auth.api.sendVerificationEmail({ body: { email: testUser.email, }, }); const res = await client.verifyEmail({ query: { token, }, }); expect(res.data?.status).toBe(true); expect(afterEmailVerificationMock).toHaveBeenCalledWith( expect.objectContaining({ email: testUser.email, emailVerified: true }), expect.any(Object), ); }); it("should preserve encoded characters in callback URL", async () => { const testEmail = "[email protected]"; const encodedEmail = encodeURIComponent(testEmail); const callbackURL = `/sign-in?verifiedEmail=${encodedEmail}`; await client.verifyEmail( { query: { token, callbackURL, }, }, { onError: (ctx) => { const location = ctx.response.headers.get("location"); expect(location).toBe(`/sign-in?verifiedEmail=${encodedEmail}`); const url = new URL(location!, "http://localhost:3000"); expect(url.searchParams.get("verifiedEmail")).toBe(testEmail); }, }, ); }); it("should properly encode callbackURL with query parameters when sending verification email", async () => { const mockSendEmailLocal = vi.fn(); let capturedUrl = ""; const { auth, testUser } = await getTestInstance({ emailAndPassword: { enabled: true, requireEmailVerification: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { capturedUrl = url; mockSendEmailLocal(user.email, url); }, }, }); const callbackURL = "https://example.com/app?redirect=/dashboard&tab=settings"; await auth.api.sendVerificationEmail({ body: { email: testUser.email, callbackURL, }, }); expect(mockSendEmailLocal).toHaveBeenCalled(); const emailUrl = new URL(capturedUrl); const callbackURLParam = emailUrl.searchParams.get("callbackURL"); expect(callbackURLParam).toBe(callbackURL); expect(callbackURLParam).toContain("?redirect=/dashboard&tab=settings"); }); }); describe("Email Verification Secondary Storage", async () => { let store = new Map<string, string>(); let token: string; const { client, signInWithTestUser, db, auth, testUser, cookieSetter } = await getTestInstance({ secondaryStorage: { set(key, value, ttl) { store.set(key, value); }, get(key) { return store.get(key) || null; }, delete(key) { store.delete(key); }, }, rateLimit: { enabled: false, }, emailAndPassword: { enabled: true, }, emailVerification: { async sendVerificationEmail({ user, url, token: _token }) { token = _token; }, autoSignInAfterVerification: true, }, user: { changeEmail: { enabled: true, async sendChangeEmailVerification(data, request) { token = data.token; }, }, }, }); it("should verify email", async () => { await auth.api.sendVerificationEmail({ body: { email: testUser.email, }, }); const headers = new Headers(); await client.verifyEmail({ query: { token, }, fetchOptions: { onSuccess: cookieSetter(headers), }, }); const session = await client.getSession({ fetchOptions: { headers, }, }); expect(session.data?.user.email).toBe(testUser.email); expect(session.data?.user.emailVerified).toBe(true); }); it("should change email", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async (headers) => { await auth.api.changeEmail({ body: { newEmail: "[email protected]", }, headers, }); const newHeaders = new Headers(); await client.verifyEmail({ query: { token, }, fetchOptions: { onSuccess: cookieSetter(newHeaders), headers, }, }); const session = await client.getSession({ fetchOptions: { headers: newHeaders, }, }); expect(session.data?.user.email).toBe("[email protected]"); expect(session.data?.user.emailVerified).toBe(false); }); }); it("should set emailVerified on all sessions", async () => { const sampleUser = { name: "sampler", email: "[email protected]", password: "samplesssss", }; await client.signUp.email({ name: sampleUser.name, email: sampleUser.email, password: sampleUser.password, }); const secondSignInHeaders = new Headers(); await client.signIn.email( { email: sampleUser.email, password: sampleUser.password, }, { onSuccess: cookieSetter(secondSignInHeaders), }, ); await auth.api.sendVerificationEmail({ body: { email: sampleUser.email, }, }); const headers = new Headers(); await client.verifyEmail({ query: { token, }, fetchOptions: { onSuccess: cookieSetter(headers), }, }); const session = await client.getSession({ fetchOptions: { headers, }, }); expect(session.data?.user.email).toBe(sampleUser.email); expect(session.data?.user.emailVerified).toBe(true); const secondSignInSession = await client.getSession({ fetchOptions: { headers: secondSignInHeaders, }, }); expect(secondSignInSession.data?.user.email).toBe(sampleUser.email); expect(secondSignInSession.data?.user.emailVerified).toBe(true); }); it("should set emailVerified on all sessions", async () => { const sampleUser = { name: "sampler", email: "[email protected]", password: "samplesssss", }; await client.signUp.email({ name: sampleUser.name, email: sampleUser.email, password: sampleUser.password, }); const secondSignInHeaders = new Headers(); await client.signIn.email( { email: sampleUser.email, password: sampleUser.password, }, { onSuccess: cookieSetter(secondSignInHeaders), }, ); await auth.api.sendVerificationEmail({ body: { email: sampleUser.email, }, }); const headers = new Headers(); await client.verifyEmail({ query: { token, }, fetchOptions: { onSuccess: cookieSetter(headers), }, }); const session = await client.getSession({ fetchOptions: { headers, }, }); expect(session.data?.user.email).toBe(sampleUser.email); expect(session.data?.user.emailVerified).toBe(true); const secondSignInSession = await client.getSession({ fetchOptions: { headers: secondSignInHeaders, }, }); expect(secondSignInSession.data?.user.email).toBe(sampleUser.email); expect(secondSignInSession.data?.user.emailVerified).toBe(true); }); }); ``` -------------------------------------------------------------------------------- /docs/content/docs/plugins/jwt.mdx: -------------------------------------------------------------------------------- ```markdown --- title: JWT description: Authenticate users with JWT tokens in services that can't use the session --- The JWT plugin provides endpoints to retrieve a JWT token and a JWKS endpoint to verify the token. <Callout type="info"> This plugin is not meant as a replacement for the session. It's meant to be used for services that require JWT tokens. If you're looking to use JWT tokens for authentication, check out the [Bearer Plugin](/docs/plugins/bearer). </Callout> ## Installation <Steps> <Step> ### Add the plugin to your **auth** config ```ts title="auth.ts" import { betterAuth } from "better-auth" import { jwt } from "better-auth/plugins" export const auth = betterAuth({ plugins: [ // [!code highlight] jwt(), // [!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> </Steps> ## Usage Once you've installed the plugin, you can start using the JWT & JWKS plugin to get the token and the JWKS through their respective endpoints. ## JWT ### Retrieve the token There are multiple ways to retrieve JWT tokens: 1. **Using the client plugin (recommended)** Add the `jwtClient` plugin to your auth client configuration: ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" import { jwtClient } from "better-auth/client/plugins" // [!code highlight] export const authClient = createAuthClient({ plugins: [ jwtClient() // [!code highlight] ] }) ``` Then use the client to get JWT tokens: ```ts const { data, error } = await authClient.token() if (error) { // handle error } if (data) { const jwtToken = data.token // Use this token for authenticated requests to external services } ``` This is the recommended approach for client applications that need JWT tokens for external API authentication. 2. **Using your session token** To get the token, call the `/token` endpoint. This will return the following: ```json { "token": "ey..." } ``` Make sure to include the token in the `Authorization` header of your requests if the `bearer` plugin is added in your auth configuration. ```ts await fetch("/api/auth/token", { headers: { "Authorization": `Bearer ${token}` }, }) ``` 3. **From `set-auth-jwt` header** When you call `getSession` method, a JWT is returned in the `set-auth-jwt` header, which you can use to send to your services directly. ```ts await authClient.getSession({ fetchOptions: { onSuccess: (ctx)=>{ const jwt = ctx.response.headers.get("set-auth-jwt") } } }) ``` ### Verifying the token The token can be verified in your own service, without the need for an additional verify call or database check. For this JWKS is used. The public key can be fetched from the `/api/auth/jwks` endpoint. Since this key is not subject to frequent changes, it can be cached indefinitely. The key ID (`kid`) that was used to sign a JWT is included in the header of the token. In case a JWT with a different `kid` is received, it is recommended to fetch the JWKS again. ```json { "keys": [ { "crv": "Ed25519", "x": "bDHiLTt7u-VIU7rfmcltcFhaHKLVvWFy-_csKZARUEU", "kty": "OKP", "kid": "c5c7995d-0037-4553-8aee-b5b620b89b23" } ] } ``` ### OAuth Provider Mode If you are making your system oAuth compliant (such as when utilizing the OIDC or MCP plugins), you **MUST** disable the `/token` endpoint (oAuth equivalent `/oauth2/token`) and disable setting the jwt header (oAuth equivalent `/oauth2/userinfo`). ```ts title="auth.ts" betterAuth({ disabledPaths: [ "/token", ], plugins: [jwt({ disableSettingJwtHeader: true, })] }) ``` #### Example using jose with remote JWKS ```ts import { jwtVerify, createRemoteJWKSet } from 'jose' async function validateToken(token: string) { try { const JWKS = createRemoteJWKSet( new URL('http://localhost:3000/api/auth/jwks') ) const { payload } = await jwtVerify(token, JWKS, { issuer: 'http://localhost:3000', // Should match your JWT issuer, which is the BASE_URL audience: 'http://localhost:3000', // Should match your JWT audience, which is the BASE_URL by default }) return payload } catch (error) { console.error('Token validation failed:', error) throw error } } // Usage example const token = 'your.jwt.token' // this is the token you get from the /api/auth/token endpoint const payload = await validateToken(token) ``` #### Example with local JWKS ```ts import { jwtVerify, createLocalJWKSet } from 'jose' async function validateToken(token: string) { try { /** * This is the JWKS that you get from the /api/auth/ * jwks endpoint */ const storedJWKS = { keys: [{ //... }] }; const JWKS = createLocalJWKSet({ keys: storedJWKS.data?.keys!, }) const { payload } = await jwtVerify(token, JWKS, { issuer: 'http://localhost:3000', // Should match your JWT issuer, which is the BASE_URL audience: 'http://localhost:3000', // Should match your JWT audience, which is the BASE_URL by default }) return payload } catch (error) { console.error('Token validation failed:', error) throw error } } // Usage example const token = 'your.jwt.token' // this is the token you get from the /api/auth/token endpoint const payload = await validateToken(token) ``` ### Remote JWKS Url Disables the `/jwks` endpoint and uses this endpoint in any discovery such as OIDC. Useful if your JWKS are not managed at `/jwks` or if your jwks are signed with a certificate and placed on your CDN. NOTE: you **MUST** specify which asymmetric algorithm is used for signing. ```ts title="auth.ts" jwt({ jwks: { remoteUrl: "https://example.com/.well-known/jwks.json", keyPairConfig: { alg: 'ES256', }, } }) ``` ### Custom Signing This is an advanced feature. Configuration outside of this plugin **MUST** be provided. Implementers: - `remoteUrl` must be defined if using the `sign` function. This shall store all active keys, not just the current one. - If using localized approach, ensure server uses the latest private key when rotated. Depending on deployment, the server may need to be restarted. - When using remote approach, verify the payload is unchanged after transit. Use integrity validation like CRC32 or SHA256 checks if available. #### Localized Signing ```ts title="auth.ts" jwt({ jwks: { remoteUrl: "https://example.com/.well-known/jwks.json", keyPairConfig: { alg: 'EdDSA', }, }, jwt: { sign: async (jwtPayload: JWTPayload) => { // this is pseudocode return await new SignJWT(jwtPayload) .setProtectedHeader({ alg: "EdDSA", kid: process.env.currentKid, typ: "JWT", }) .sign(process.env.clientPrivateKey); }, }, }) ``` #### Remote Signing Useful if you are using a remote Key Management Service such as [Google KMS](https://cloud.google.com/kms/docs/encrypt-decrypt-rsa#kms-encrypt-asymmetric-nodejs), [Amazon KMS](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html), or [Azure Key Vault](https://learn.microsoft.com/en-us/rest/api/keyvault/keys/sign/sign?view=rest-keyvault-keys-7.4&tabs=HTTP). ```ts title="auth.ts" jwt({ jwks: { remoteUrl: "https://example.com/.well-known/jwks.json", keyPairConfig: { alg: 'ES256', }, }, jwt: { sign: async (jwtPayload: JWTPayload) => { // this is pseudocode const headers = JSON.stringify({ kid: '123', alg: 'ES256', typ: 'JWT' }) const payload = JSON.stringify(jwtPayload) const encodedHeaders = Buffer.from(headers).toString('base64url') const encodedPayload = Buffer.from(payload).toString('base64url') const hash = createHash('sha256') const data = `${encodedHeaders}.${encodedPayload}` hash.update(Buffer.from(data)) const digest = hash.digest() const sig = await remoteSign(digest) // integrityCheck(sig) const jwt = `${data}.${sig}` // verifyJwt(jwt) return jwt }, }, }) ``` ## Schema The JWT plugin adds the following tables to the database: ### JWKS Table Name: `jwks` <DatabaseTable fields={[ { name: "id", type: "string", description: "Unique identifier for each web key", isPrimaryKey: true }, { name: "publicKey", type: "string", description: "The public part of the web key" }, { name: "privateKey", type: "string", description: "The private part of the web key" }, { name: "createdAt", type: "Date", description: "Timestamp of when the web key was created" }, ]} /> <Callout> You can customize the table name and fields for the `jwks` table. See the [Database concept documentation](/docs/concepts/database#custom-table-names) for more information on how to customize plugin schema. </Callout> ## Options ### Algorithm of the Key Pair The algorithm used for the generation of the key pair. The default is **EdDSA** with the **Ed25519** curve. Below are the available options: ```ts title="auth.ts" jwt({ jwks: { keyPairConfig: { alg: "EdDSA", crv: "Ed25519" } } }) ``` #### EdDSA - **Default Curve**: `Ed25519` - **Optional Property**: `crv` - Available options: `Ed25519`, `Ed448` - Default: `Ed25519` #### ES256 - No additional properties #### RSA256 - **Optional Property**: `modulusLength` - Expects a number - Default: `2048` #### PS256 - **Optional Property**: `modulusLength` - Expects a number - Default: `2048` #### ECDH-ES - **Optional Property**: `crv` - Available options: `P-256`, `P-384`, `P-521` - Default: `P-256` #### ES512 - No additional properties ### Disable private key encryption By default, the private key is encrypted using AES256 GCM. You can disable this by setting the `disablePrivateKeyEncryption` option to `true`. For security reasons, it's recommended to keep the private key encrypted. ```ts title="auth.ts" jwt({ jwks: { disablePrivateKeyEncryption: true } }) ``` ### Modify JWT payload By default the entire user object is added to the JWT payload. You can modify the payload by providing a function to the `definePayload` option. ```ts title="auth.ts" jwt({ jwt: { definePayload: ({user}) => { return { id: user.id, email: user.email, role: user.role } } } }) ``` ### Modify Issuer, Audience, Subject or Expiration time If none is given, the `BASE_URL` is used as the issuer and the audience is set to the `BASE_URL`. The expiration time is set to 15 minutes. ```ts title="auth.ts" jwt({ jwt: { issuer: "https://example.com", audience: "https://example.com", expirationTime: "1h", getSubject: (session) => { // by default the subject is the user id return session.user.email } } }) ``` ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/account.test.ts: -------------------------------------------------------------------------------- ```typescript import { afterAll, afterEach, beforeAll, describe, expect, it, vi, type MockInstance, } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { getTestInstance } from "../../test-utils/test-instance"; import { parseSetCookieHeader } from "../../cookies"; import type { GoogleProfile } from "@better-auth/core/social-providers"; import { DEFAULT_SECRET } from "../../utils/constants"; import { signJWT } from "../../crypto"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import type { Account } from "../../types"; let email = ""; let handlers: ReturnType<typeof http.post>[]; const server = setupServer(); beforeAll(async () => { handlers = [ http.post("https://oauth2.googleapis.com/token", async () => { const data: GoogleProfile = { email, email_verified: true, name: "First Last", picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", exp: 1234567890, sub: "1234567890", iat: 1234567890, aud: "test", azp: "test", nbf: 1234567890, iss: "test", locale: "en", jti: "test", given_name: "First", family_name: "Last", }; const testIdToken = await signJWT(data, DEFAULT_SECRET); return HttpResponse.json({ access_token: "test", refresh_token: "test", id_token: testIdToken, }); }), ]; server.listen({ onUnhandledRequest: "bypass" }); server.use(...handlers); }); afterEach(() => { server.resetHandlers(); server.use(...handlers); }); afterAll(() => server.close()); describe("account", async () => { const { auth, signInWithTestUser, client } = await getTestInstance({ socialProviders: { google: { clientId: "test", clientSecret: "test", enabled: true, }, }, account: { accountLinking: { allowDifferentEmails: true, }, encryptOAuthTokens: true, }, }); const ctx = await auth.$context; let googleVerifyIdTokenMock: MockInstance; let googleGetUserInfoMock: MockInstance; beforeAll(() => { const googleProvider = ctx.socialProviders.find((v) => v.id === "google")!; expect(googleProvider).toBeTruthy(); googleVerifyIdTokenMock = vi.spyOn(googleProvider, "verifyIdToken"); googleGetUserInfoMock = vi.spyOn(googleProvider, "getUserInfo"); }); afterEach(() => { googleVerifyIdTokenMock.mockClear(); googleGetUserInfoMock.mockClear(); }); const { runWithUser } = await signInWithTestUser(); it("should list all accounts", async () => { await runWithUser(async () => { const accounts = await client.listAccounts(); expect(accounts.data?.length).toBe(1); }); }); it("should link first account", async () => { await runWithUser(async (headers) => { const linkAccountRes = await client.linkSocial( { provider: "google", callbackURL: "/callback", }, { onSuccess(context) { const cookies = parseSetCookieHeader( context.response.headers.get("set-cookie") || "", ); headers.set( "cookie", `better-auth.state=${cookies.get("better-auth.state")?.value}`, ); }, }, ); expect(linkAccountRes.data).toMatchObject({ url: expect.stringContaining("google.com"), redirect: true, }); const state = linkAccountRes.data && "url" in linkAccountRes.data ? new URL(linkAccountRes.data.url).searchParams.get("state") || "" : ""; email = "[email protected]"; await client.$fetch("/callback/google", { query: { state, code: "test", }, method: "GET", onError(context) { expect(context.response.status).toBe(302); const location = context.response.headers.get("location"); expect(location).toBeDefined(); expect(location).toContain("/callback"); }, }); }); const { runWithUser: runWithClient2 } = await signInWithTestUser(); await runWithClient2(async () => { const accounts = await client.listAccounts(); expect(accounts.data?.length).toBe(2); }); }); it("should encrypt access token and refresh token", async () => { const { runWithUser: runWithClient2 } = await signInWithTestUser(); const account = await ctx.adapter.findOne<Account>({ model: "account", where: [{ field: "providerId", value: "google" }], }); expect(account).toBeTruthy(); expect(account?.accessToken).not.toBe("test"); await runWithClient2(async () => { const accessToken = await client.getAccessToken({ providerId: "google", }); expect(accessToken.data?.accessToken).toBe("test"); }); }); it("should pass custom scopes to authorization URL", async () => { const { runWithUser: runWithClient2 } = await signInWithTestUser(); await runWithClient2(async () => { const customScope = "https://www.googleapis.com/auth/drive.readonly"; const linkAccountRes = await client.linkSocial({ provider: "google", callbackURL: "/callback", scopes: [customScope], }); expect(linkAccountRes.data).toMatchObject({ url: expect.stringContaining("google.com"), redirect: true, }); const url = linkAccountRes.data && "url" in linkAccountRes.data ? new URL(linkAccountRes.data.url) : new URL(""); const scopesParam = url.searchParams.get("scope"); expect(scopesParam).toContain(customScope); }); }); it("should link second account from the same provider", async () => { const { runWithUser: runWithClient2 } = await signInWithTestUser(); await runWithClient2(async (headers) => { const linkAccountRes = await client.linkSocial( { provider: "google", callbackURL: "/callback", }, { onSuccess(context) { const cookies = parseSetCookieHeader( context.response.headers.get("set-cookie") || "", ); headers.set( "cookie", `better-auth.state=${cookies.get("better-auth.state")?.value}`, ); }, }, ); expect(linkAccountRes.data).toMatchObject({ url: expect.stringContaining("google.com"), redirect: true, }); const state = linkAccountRes.data && "url" in linkAccountRes.data ? new URL(linkAccountRes.data.url).searchParams.get("state") || "" : ""; email = "[email protected]"; await client.$fetch("/callback/google", { query: { state, code: "test", }, method: "GET", onError(context) { expect(context.response.status).toBe(302); const location = context.response.headers.get("location"); expect(location).toBeDefined(); expect(location).toContain("/callback"); }, }); }); const { runWithUser: runWithClient3 } = await signInWithTestUser(); await runWithClient3(async () => { const accounts = await client.listAccounts(); expect(accounts.data?.length).toBe(2); }); }); it("should link third account with idToken", async () => { googleVerifyIdTokenMock.mockResolvedValueOnce(true); const user = { id: "0987654321", name: "test2", email: "[email protected]", sub: "test2", emailVerified: true, }; const userInfo = { user, data: user, }; googleGetUserInfoMock.mockResolvedValueOnce(userInfo); const { runWithUser: runWithClient2 } = await signInWithTestUser(); await runWithClient2(async (headers) => { await client.linkSocial( { provider: "google", callbackURL: "/callback", idToken: { token: "test" }, }, { onSuccess(context) { const cookies = parseSetCookieHeader( context.response.headers.get("set-cookie") || "", ); headers.set( "cookie", `better-auth.state=${cookies.get("better-auth.state")?.value}`, ); }, }, ); }); expect(googleVerifyIdTokenMock).toHaveBeenCalledOnce(); expect(googleGetUserInfoMock).toHaveBeenCalledOnce(); const { runWithUser: runWithClient3 } = await signInWithTestUser(); await runWithClient3(async () => { const accounts = await client.listAccounts(); expect(accounts.data?.length).toBe(3); }); }); it("should unlink account", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const previousAccounts = await client.listAccounts(); expect(previousAccounts.data?.length).toBe(3); const unlinkAccountId = previousAccounts.data![1]!.accountId; const unlinkRes = await client.unlinkAccount({ providerId: "google", accountId: unlinkAccountId!, }); expect(unlinkRes.data?.status).toBe(true); const accounts = await client.listAccounts(); expect(accounts.data?.length).toBe(2); }); }); it("should fail to unlink the last account of a provider", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const previousAccounts = await client.listAccounts(); await ctx.adapter.delete({ model: "account", where: [ { field: "providerId", value: "google", }, ], }); const unlinkAccountId = previousAccounts.data![0]!.accountId; const unlinkRes = await client.unlinkAccount({ providerId: "credential", accountId: unlinkAccountId, }); expect(unlinkRes.error?.message).toBe( BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT, ); }); }); it("should unlink account with specific accountId", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const previousAccounts = await client.listAccounts(); expect(previousAccounts.data?.length).toBeGreaterThan(0); const accountToUnlink = previousAccounts.data![0]!; const unlinkAccountId = accountToUnlink.accountId; const providerId = accountToUnlink.providerId; const accountsWithSameProvider = previousAccounts.data!.filter( (account) => account.providerId === providerId, ); if (accountsWithSameProvider.length <= 1) { return; } const unlinkRes = await client.unlinkAccount({ providerId, accountId: unlinkAccountId!, }); expect(unlinkRes.data?.status).toBe(true); const accountsAfterUnlink = await client.listAccounts(); expect(accountsAfterUnlink.data?.length).toBe( previousAccounts.data!.length - 1, ); expect( accountsAfterUnlink.data?.find((a) => a.accountId === unlinkAccountId), ).toBeUndefined(); }); }); it("should unlink all accounts with specific providerId", async () => { const { runWithUser, user } = await signInWithTestUser(); await ctx.adapter.create({ model: "account", data: { providerId: "google", accountId: "123", userId: user.id, createdAt: new Date(), updatedAt: new Date(), }, }); await ctx.adapter.create({ model: "account", data: { providerId: "google", accountId: "345", userId: user.id, createdAt: new Date(), updatedAt: new Date(), }, }); await runWithUser(async () => { const previousAccounts = await client.listAccounts(); const googleAccounts = previousAccounts.data!.filter( (account) => account.providerId === "google", ); expect(googleAccounts.length).toBeGreaterThan(1); for (let i = 0; i < googleAccounts.length - 1; i++) { const unlinkRes = await client.unlinkAccount({ providerId: "google", accountId: googleAccounts[i]!.accountId!, }); expect(unlinkRes.data?.status).toBe(true); } const accountsAfterUnlink = await client.listAccounts(); const remainingGoogleAccounts = accountsAfterUnlink.data!.filter( (account) => account.providerId === "google", ); expect(remainingGoogleAccounts.length).toBe(1); }); }); }); ```