This is page 23 of 69. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── 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/plugins/magic-link/magic-link.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { getTestInstance } from "../../test-utils/test-instance"; 3 | import { magicLink } from "."; 4 | import { createAuthClient } from "../../client"; 5 | import { magicLinkClient } from "./client"; 6 | import { defaultKeyHasher } from "./utils"; 7 | 8 | type VerificationEmail = { 9 | email: string; 10 | token: string; 11 | url: string; 12 | }; 13 | 14 | describe("magic link", async () => { 15 | let verificationEmail: VerificationEmail = { 16 | email: "", 17 | token: "", 18 | url: "", 19 | }; 20 | const { customFetchImpl, testUser, sessionSetter } = await getTestInstance({ 21 | plugins: [ 22 | magicLink({ 23 | async sendMagicLink(data) { 24 | verificationEmail = data; 25 | }, 26 | }), 27 | ], 28 | }); 29 | 30 | const client = createAuthClient({ 31 | plugins: [magicLinkClient()], 32 | fetchOptions: { 33 | customFetchImpl, 34 | }, 35 | baseURL: "http://localhost:3000", 36 | basePath: "/api/auth", 37 | }); 38 | 39 | it("should send magic link", async () => { 40 | await client.signIn.magicLink({ 41 | email: testUser.email, 42 | }); 43 | expect(verificationEmail).toMatchObject({ 44 | email: testUser.email, 45 | url: expect.stringContaining( 46 | "http://localhost:3000/api/auth/magic-link/verify", 47 | ), 48 | }); 49 | }); 50 | it("should verify magic link", async () => { 51 | const headers = new Headers(); 52 | const response = await client.magicLink.verify({ 53 | query: { 54 | token: new URL(verificationEmail.url).searchParams.get("token") || "", 55 | }, 56 | fetchOptions: { 57 | onSuccess: sessionSetter(headers), 58 | }, 59 | }); 60 | expect(response.data?.token).toBeDefined(); 61 | const betterAuthCookie = headers.get("set-cookie"); 62 | expect(betterAuthCookie).toBeDefined(); 63 | }); 64 | 65 | it("shouldn't verify magic link with the same token", async () => { 66 | await client.magicLink.verify( 67 | { 68 | query: { 69 | token: new URL(verificationEmail.url).searchParams.get("token") || "", 70 | }, 71 | }, 72 | { 73 | onError(context) { 74 | expect(context.response.status).toBe(302); 75 | const location = context.response.headers.get("location"); 76 | expect(location).toContain("?error=INVALID_TOKEN"); 77 | }, 78 | }, 79 | ); 80 | }); 81 | 82 | it("shouldn't verify magic link with an expired token", async () => { 83 | await client.signIn.magicLink({ 84 | email: testUser.email, 85 | }); 86 | const token = verificationEmail.token; 87 | vi.useFakeTimers(); 88 | await vi.advanceTimersByTimeAsync(1000 * 60 * 5 + 1); 89 | await client.magicLink.verify( 90 | { 91 | query: { 92 | token, 93 | callbackURL: "/callback", 94 | }, 95 | }, 96 | { 97 | onError(context) { 98 | expect(context.response.status).toBe(302); 99 | const location = context.response.headers.get("location"); 100 | expect(location).toContain("?error=EXPIRED_TOKEN"); 101 | }, 102 | }, 103 | ); 104 | }); 105 | 106 | it("should sign up with magic link", async () => { 107 | const email = "[email protected]"; 108 | await client.signIn.magicLink({ 109 | email, 110 | name: "test", 111 | }); 112 | expect(verificationEmail).toMatchObject({ 113 | email, 114 | url: expect.stringContaining( 115 | "http://localhost:3000/api/auth/magic-link/verify", 116 | ), 117 | }); 118 | const headers = new Headers(); 119 | const response = await client.magicLink.verify({ 120 | query: { 121 | token: new URL(verificationEmail.url).searchParams.get("token") || "", 122 | }, 123 | fetchOptions: { 124 | onSuccess: sessionSetter(headers), 125 | }, 126 | }); 127 | const session = await client.getSession({ 128 | fetchOptions: { 129 | headers, 130 | }, 131 | }); 132 | expect(session.data?.user).toMatchObject({ 133 | name: "test", 134 | email: "[email protected]", 135 | emailVerified: true, 136 | }); 137 | }); 138 | 139 | it("should use custom generateToken function", async () => { 140 | const customGenerateToken = vi.fn(() => "custom_token"); 141 | 142 | const { customFetchImpl } = await getTestInstance({ 143 | plugins: [ 144 | magicLink({ 145 | async sendMagicLink(data) { 146 | verificationEmail = data; 147 | }, 148 | generateToken: customGenerateToken, 149 | }), 150 | ], 151 | }); 152 | 153 | const customClient = createAuthClient({ 154 | plugins: [magicLinkClient()], 155 | fetchOptions: { 156 | customFetchImpl, 157 | }, 158 | baseURL: "http://localhost:3000/api/auth", 159 | }); 160 | 161 | await customClient.signIn.magicLink({ 162 | email: testUser.email, 163 | }); 164 | 165 | expect(customGenerateToken).toHaveBeenCalled(); 166 | expect(verificationEmail.token).toBe("custom_token"); 167 | }); 168 | }); 169 | 170 | describe("magic link verify", async () => { 171 | const verificationEmail: VerificationEmail[] = [ 172 | { 173 | email: "", 174 | token: "", 175 | url: "", 176 | }, 177 | ]; 178 | const { customFetchImpl, testUser, sessionSetter } = await getTestInstance({ 179 | plugins: [ 180 | magicLink({ 181 | async sendMagicLink(data) { 182 | verificationEmail.push(data); 183 | }, 184 | }), 185 | ], 186 | }); 187 | 188 | const client = createAuthClient({ 189 | plugins: [magicLinkClient()], 190 | fetchOptions: { 191 | customFetchImpl, 192 | }, 193 | baseURL: "http://localhost:3000/api/auth", 194 | }); 195 | 196 | it("should verify last magic link", async () => { 197 | await client.signIn.magicLink({ 198 | email: testUser.email, 199 | }); 200 | await client.signIn.magicLink({ 201 | email: testUser.email, 202 | }); 203 | await client.signIn.magicLink({ 204 | email: testUser.email, 205 | }); 206 | const headers = new Headers(); 207 | const lastEmail = verificationEmail.pop() as VerificationEmail; 208 | const response = await client.magicLink.verify({ 209 | query: { 210 | token: new URL(lastEmail.url).searchParams.get("token") || "", 211 | }, 212 | fetchOptions: { 213 | onSuccess: sessionSetter(headers), 214 | }, 215 | }); 216 | expect(response.data?.token).toBeDefined(); 217 | const betterAuthCookie = headers.get("set-cookie"); 218 | expect(betterAuthCookie).toBeDefined(); 219 | }); 220 | }); 221 | 222 | describe("magic link storeToken", async () => { 223 | it("should store token in hashed", async () => { 224 | let verificationEmail: VerificationEmail = { 225 | email: "", 226 | token: "", 227 | url: "", 228 | }; 229 | const { auth, signInWithTestUser, client, testUser } = 230 | await getTestInstance({ 231 | plugins: [ 232 | magicLink({ 233 | storeToken: "hashed", 234 | sendMagicLink(data, request) { 235 | verificationEmail = data; 236 | }, 237 | }), 238 | ], 239 | }); 240 | 241 | const internalAdapter = (await auth.$context).internalAdapter; 242 | const { headers } = await signInWithTestUser(); 243 | const response = await auth.api.signInMagicLink({ 244 | body: { 245 | email: testUser.email, 246 | }, 247 | headers, 248 | }); 249 | const hashedToken = await defaultKeyHasher(verificationEmail.token); 250 | const storedToken = 251 | await internalAdapter.findVerificationValue(hashedToken); 252 | expect(storedToken).toBeDefined(); 253 | const response2 = await auth.api.signInMagicLink({ 254 | body: { 255 | email: testUser.email, 256 | }, 257 | headers, 258 | }); 259 | expect(response2.status).toBe(true); 260 | }); 261 | 262 | it("should store token with custom hasher", async () => { 263 | let verificationEmail: VerificationEmail = { 264 | email: "", 265 | token: "", 266 | url: "", 267 | }; 268 | const { auth, signInWithTestUser, client, testUser } = 269 | await getTestInstance({ 270 | plugins: [ 271 | magicLink({ 272 | storeToken: { 273 | type: "custom-hasher", 274 | async hash(token) { 275 | return token + "hashed"; 276 | }, 277 | }, 278 | sendMagicLink(data, request) { 279 | verificationEmail = data; 280 | }, 281 | }), 282 | ], 283 | }); 284 | 285 | const internalAdapter = (await auth.$context).internalAdapter; 286 | const { headers } = await signInWithTestUser(); 287 | await auth.api.signInMagicLink({ 288 | body: { 289 | email: testUser.email, 290 | }, 291 | headers, 292 | }); 293 | const hashedToken = `${verificationEmail.token}hashed`; 294 | const storedToken = 295 | await internalAdapter.findVerificationValue(hashedToken); 296 | expect(storedToken).toBeDefined(); 297 | const response2 = await auth.api.signInMagicLink({ 298 | body: { 299 | email: testUser.email, 300 | }, 301 | headers, 302 | }); 303 | expect(response2.status).toBe(true); 304 | }); 305 | }); 306 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/microsoft-entra-id.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | validateAuthorizationCode, 3 | createAuthorizationURL, 4 | refreshAccessToken, 5 | } from "../oauth2"; 6 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 7 | import { betterFetch } from "@better-fetch/fetch"; 8 | import { logger } from "../env"; 9 | import { decodeJwt } from "jose"; 10 | import { base64 } from "@better-auth/utils/base64"; 11 | 12 | /** 13 | * @see [Microsoft Identity Platform - Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference) 14 | */ 15 | export interface MicrosoftEntraIDProfile extends Record<string, any> { 16 | /** Identifies the intended recipient of the token */ 17 | aud: string; 18 | /** Identifies the issuer, or "authorization server" that constructs and returns the token */ 19 | iss: string; 20 | /** Indicates when the authentication for the token occurred */ 21 | iat: Date; 22 | /** Records the identity provider that authenticated the subject of the token */ 23 | idp: string; 24 | /** Identifies the time before which the JWT can't be accepted for processing */ 25 | nbf: Date; 26 | /** Identifies the expiration time on or after which the JWT can't be accepted for processing */ 27 | exp: Date; 28 | /** Code hash included in ID tokens when issued with an OAuth 2.0 authorization code */ 29 | c_hash: string; 30 | /** Access token hash included in ID tokens when issued with an OAuth 2.0 access token */ 31 | at_hash: string; 32 | /** Internal claim used to record data for token reuse */ 33 | aio: string; 34 | /** The primary username that represents the user */ 35 | preferred_username: string; 36 | /** User's email address */ 37 | email: string; 38 | /** Human-readable value that identifies the subject of the token */ 39 | name: string; 40 | /** Matches the parameter included in the original authorize request */ 41 | nonce: string; 42 | /** User's profile picture */ 43 | picture: string; 44 | /** Immutable identifier for the user account */ 45 | oid: string; 46 | /** Set of roles assigned to the user */ 47 | roles: string[]; 48 | /** Internal claim used to revalidate tokens */ 49 | rh: string; 50 | /** Subject identifier - unique to application ID */ 51 | sub: string; 52 | /** Tenant ID the user is signing in to */ 53 | tid: string; 54 | /** Unique identifier for a session */ 55 | sid: string; 56 | /** Token identifier claim */ 57 | uti: string; 58 | /** Indicates if user is in at least one group */ 59 | hasgroups: boolean; 60 | /** User account status in tenant (0 = member, 1 = guest) */ 61 | acct: 0 | 1; 62 | /** Auth Context IDs */ 63 | acrs: string; 64 | /** Time when the user last authenticated */ 65 | auth_time: Date; 66 | /** User's country/region */ 67 | ctry: string; 68 | /** IP address of requesting client when inside VNET */ 69 | fwd: string; 70 | /** Group claims */ 71 | groups: string; 72 | /** Login hint for SSO */ 73 | login_hint: string; 74 | /** Resource tenant's country/region */ 75 | tenant_ctry: string; 76 | /** Region of the resource tenant */ 77 | tenant_region_scope: string; 78 | /** UserPrincipalName */ 79 | upn: string; 80 | /** User's verified primary email addresses */ 81 | verified_primary_email: string[]; 82 | /** User's verified secondary email addresses */ 83 | verified_secondary_email: string[]; 84 | /** VNET specifier information */ 85 | vnet: string; 86 | /** Client Capabilities */ 87 | xms_cc: string; 88 | /** Whether user's email domain is verified */ 89 | xms_edov: boolean; 90 | /** Preferred data location for Multi-Geo tenants */ 91 | xms_pdl: string; 92 | /** User preferred language */ 93 | xms_pl: string; 94 | /** Tenant preferred language */ 95 | xms_tpl: string; 96 | /** Zero-touch Deployment ID */ 97 | ztdid: string; 98 | /** IP Address */ 99 | ipaddr: string; 100 | /** On-premises Security Identifier */ 101 | onprem_sid: string; 102 | /** Password Expiration Time */ 103 | pwd_exp: number; 104 | /** Change Password URL */ 105 | pwd_url: string; 106 | /** Inside Corporate Network flag */ 107 | in_corp: string; 108 | /** User's family name/surname */ 109 | family_name: string; 110 | /** User's given/first name */ 111 | given_name: string; 112 | } 113 | 114 | export interface MicrosoftOptions 115 | extends ProviderOptions<MicrosoftEntraIDProfile> { 116 | clientId: string; 117 | /** 118 | * The tenant ID of the Microsoft account 119 | * @default "common" 120 | */ 121 | tenantId?: string; 122 | /** 123 | * The authentication authority URL. Use the default "https://login.microsoftonline.com" for standard Entra ID or "https://<tenant-id>.ciamlogin.com" for CIAM scenarios. 124 | * @default "https://login.microsoftonline.com" 125 | */ 126 | authority?: string; 127 | /** 128 | * The size of the profile photo 129 | * @default 48 130 | */ 131 | profilePhotoSize?: 48 | 64 | 96 | 120 | 240 | 360 | 432 | 504 | 648; 132 | /** 133 | * Disable profile photo 134 | */ 135 | disableProfilePhoto?: boolean; 136 | } 137 | 138 | export const microsoft = (options: MicrosoftOptions) => { 139 | const tenant = options.tenantId || "common"; 140 | const authority = options.authority || "https://login.microsoftonline.com"; 141 | const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`; 142 | const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`; 143 | return { 144 | id: "microsoft", 145 | name: "Microsoft EntraID", 146 | createAuthorizationURL(data) { 147 | const scopes = options.disableDefaultScope 148 | ? [] 149 | : ["openid", "profile", "email", "User.Read", "offline_access"]; 150 | options.scope && scopes.push(...options.scope); 151 | data.scopes && scopes.push(...data.scopes); 152 | return createAuthorizationURL({ 153 | id: "microsoft", 154 | options, 155 | authorizationEndpoint, 156 | state: data.state, 157 | codeVerifier: data.codeVerifier, 158 | scopes, 159 | redirectURI: data.redirectURI, 160 | prompt: options.prompt, 161 | loginHint: data.loginHint, 162 | }); 163 | }, 164 | validateAuthorizationCode({ code, codeVerifier, redirectURI }) { 165 | return validateAuthorizationCode({ 166 | code, 167 | codeVerifier, 168 | redirectURI, 169 | options, 170 | tokenEndpoint, 171 | }); 172 | }, 173 | async getUserInfo(token) { 174 | if (options.getUserInfo) { 175 | return options.getUserInfo(token); 176 | } 177 | if (!token.idToken) { 178 | return null; 179 | } 180 | const user = decodeJwt(token.idToken) as MicrosoftEntraIDProfile; 181 | const profilePhotoSize = options.profilePhotoSize || 48; 182 | await betterFetch<ArrayBuffer>( 183 | `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, 184 | { 185 | headers: { 186 | Authorization: `Bearer ${token.accessToken}`, 187 | }, 188 | async onResponse(context) { 189 | if (options.disableProfilePhoto || !context.response.ok) { 190 | return; 191 | } 192 | try { 193 | const response = context.response.clone(); 194 | const pictureBuffer = await response.arrayBuffer(); 195 | const pictureBase64 = base64.encode(pictureBuffer); 196 | user.picture = `data:image/jpeg;base64, ${pictureBase64}`; 197 | } catch (e) { 198 | logger.error( 199 | e && typeof e === "object" && "name" in e 200 | ? (e.name as string) 201 | : "", 202 | e, 203 | ); 204 | } 205 | }, 206 | }, 207 | ); 208 | const userMap = await options.mapProfileToUser?.(user); 209 | return { 210 | user: { 211 | id: user.sub, 212 | name: user.name, 213 | email: user.email, 214 | image: user.picture, 215 | emailVerified: true, 216 | ...userMap, 217 | }, 218 | data: user, 219 | }; 220 | }, 221 | refreshAccessToken: options.refreshAccessToken 222 | ? options.refreshAccessToken 223 | : async (refreshToken) => { 224 | const scopes = options.disableDefaultScope 225 | ? [] 226 | : ["openid", "profile", "email", "User.Read", "offline_access"]; 227 | options.scope && scopes.push(...options.scope); 228 | 229 | return refreshAccessToken({ 230 | refreshToken, 231 | options: { 232 | clientId: options.clientId, 233 | clientSecret: options.clientSecret, 234 | }, 235 | extraParams: { 236 | scope: scopes.join(" "), // Include the scopes in request to microsoft 237 | }, 238 | tokenEndpoint, 239 | }); 240 | }, 241 | options, 242 | } satisfies OAuthProvider; 243 | }; 244 | ``` -------------------------------------------------------------------------------- /packages/telemetry/src/detectors/detect-auth-config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { TelemetryContext } from "../types"; 2 | import type { BetterAuthOptions } from "@better-auth/core"; 3 | 4 | export function getTelemetryAuthConfig( 5 | options: BetterAuthOptions, 6 | context?: TelemetryContext, 7 | ) { 8 | return { 9 | database: context?.database, 10 | adapter: context?.adapter, 11 | emailVerification: { 12 | sendVerificationEmail: !!options.emailVerification?.sendVerificationEmail, 13 | sendOnSignUp: !!options.emailVerification?.sendOnSignUp, 14 | sendOnSignIn: !!options.emailVerification?.sendOnSignIn, 15 | autoSignInAfterVerification: 16 | !!options.emailVerification?.autoSignInAfterVerification, 17 | expiresIn: options.emailVerification?.expiresIn, 18 | onEmailVerification: !!options.emailVerification?.onEmailVerification, 19 | afterEmailVerification: 20 | !!options.emailVerification?.afterEmailVerification, 21 | }, 22 | emailAndPassword: { 23 | enabled: !!options.emailAndPassword?.enabled, 24 | disableSignUp: !!options.emailAndPassword?.disableSignUp, 25 | requireEmailVerification: 26 | !!options.emailAndPassword?.requireEmailVerification, 27 | maxPasswordLength: options.emailAndPassword?.maxPasswordLength, 28 | minPasswordLength: options.emailAndPassword?.minPasswordLength, 29 | sendResetPassword: !!options.emailAndPassword?.sendResetPassword, 30 | resetPasswordTokenExpiresIn: 31 | options.emailAndPassword?.resetPasswordTokenExpiresIn, 32 | onPasswordReset: !!options.emailAndPassword?.onPasswordReset, 33 | password: { 34 | hash: !!options.emailAndPassword?.password?.hash, 35 | verify: !!options.emailAndPassword?.password?.verify, 36 | }, 37 | autoSignIn: !!options.emailAndPassword?.autoSignIn, 38 | revokeSessionsOnPasswordReset: 39 | !!options.emailAndPassword?.revokeSessionsOnPasswordReset, 40 | }, 41 | socialProviders: Object.keys(options.socialProviders || {}).map((p) => { 42 | const provider = 43 | options.socialProviders?.[p as keyof typeof options.socialProviders]; 44 | if (!provider) return {}; 45 | return { 46 | id: p, 47 | mapProfileToUser: !!provider.mapProfileToUser, 48 | disableDefaultScope: !!provider.disableDefaultScope, 49 | disableIdTokenSignIn: !!provider.disableIdTokenSignIn, 50 | disableImplicitSignUp: provider.disableImplicitSignUp, 51 | disableSignUp: provider.disableSignUp, 52 | getUserInfo: !!provider.getUserInfo, 53 | overrideUserInfoOnSignIn: !!provider.overrideUserInfoOnSignIn, 54 | prompt: provider.prompt, 55 | verifyIdToken: !!provider.verifyIdToken, 56 | scope: provider.scope, 57 | refreshAccessToken: !!provider.refreshAccessToken, 58 | }; 59 | }), 60 | plugins: options.plugins?.map((p) => p.id.toString()), 61 | user: { 62 | modelName: options.user?.modelName, 63 | fields: options.user?.fields, 64 | additionalFields: options.user?.additionalFields, 65 | changeEmail: { 66 | enabled: options.user?.changeEmail?.enabled, 67 | sendChangeEmailVerification: 68 | !!options.user?.changeEmail?.sendChangeEmailVerification, 69 | }, 70 | }, 71 | verification: { 72 | modelName: options.verification?.modelName, 73 | disableCleanup: options.verification?.disableCleanup, 74 | fields: options.verification?.fields, 75 | }, 76 | session: { 77 | modelName: options.session?.modelName, 78 | additionalFields: options.session?.additionalFields, 79 | cookieCache: { 80 | enabled: options.session?.cookieCache?.enabled, 81 | maxAge: options.session?.cookieCache?.maxAge, 82 | }, 83 | disableSessionRefresh: options.session?.disableSessionRefresh, 84 | expiresIn: options.session?.expiresIn, 85 | fields: options.session?.fields, 86 | freshAge: options.session?.freshAge, 87 | preserveSessionInDatabase: options.session?.preserveSessionInDatabase, 88 | storeSessionInDatabase: options.session?.storeSessionInDatabase, 89 | updateAge: options.session?.updateAge, 90 | }, 91 | account: { 92 | modelName: options.account?.modelName, 93 | fields: options.account?.fields, 94 | encryptOAuthTokens: options.account?.encryptOAuthTokens, 95 | updateAccountOnSignIn: options.account?.updateAccountOnSignIn, 96 | accountLinking: { 97 | enabled: options.account?.accountLinking?.enabled, 98 | trustedProviders: options.account?.accountLinking?.trustedProviders, 99 | updateUserInfoOnLink: 100 | options.account?.accountLinking?.updateUserInfoOnLink, 101 | allowUnlinkingAll: options.account?.accountLinking?.allowUnlinkingAll, 102 | }, 103 | }, 104 | hooks: { 105 | after: !!options.hooks?.after, 106 | before: !!options.hooks?.before, 107 | }, 108 | secondaryStorage: !!options.secondaryStorage, 109 | advanced: { 110 | cookiePrefix: !!options.advanced?.cookiePrefix, //this shouldn't be tracked 111 | cookies: !!options.advanced?.cookies, 112 | crossSubDomainCookies: { 113 | domain: !!options.advanced?.crossSubDomainCookies?.domain, 114 | enabled: options.advanced?.crossSubDomainCookies?.enabled, 115 | additionalCookies: 116 | options.advanced?.crossSubDomainCookies?.additionalCookies, 117 | }, 118 | database: { 119 | useNumberId: !!options.advanced?.database?.useNumberId, 120 | generateId: options.advanced?.database?.generateId, 121 | defaultFindManyLimit: options.advanced?.database?.defaultFindManyLimit, 122 | }, 123 | useSecureCookies: options.advanced?.useSecureCookies, 124 | ipAddress: { 125 | disableIpTracking: options.advanced?.ipAddress?.disableIpTracking, 126 | ipAddressHeaders: options.advanced?.ipAddress?.ipAddressHeaders, 127 | }, 128 | disableCSRFCheck: options.advanced?.disableCSRFCheck, 129 | cookieAttributes: { 130 | expires: options.advanced?.defaultCookieAttributes?.expires, 131 | secure: options.advanced?.defaultCookieAttributes?.secure, 132 | sameSite: options.advanced?.defaultCookieAttributes?.sameSite, 133 | domain: !!options.advanced?.defaultCookieAttributes?.domain, 134 | path: options.advanced?.defaultCookieAttributes?.path, 135 | httpOnly: options.advanced?.defaultCookieAttributes?.httpOnly, 136 | }, 137 | }, 138 | trustedOrigins: options.trustedOrigins?.length, 139 | rateLimit: { 140 | storage: options.rateLimit?.storage, 141 | modelName: options.rateLimit?.modelName, 142 | window: options.rateLimit?.window, 143 | customStorage: !!options.rateLimit?.customStorage, 144 | enabled: options.rateLimit?.enabled, 145 | max: options.rateLimit?.max, 146 | }, 147 | onAPIError: { 148 | errorURL: options.onAPIError?.errorURL, 149 | onError: !!options.onAPIError?.onError, 150 | throw: options.onAPIError?.throw, 151 | }, 152 | logger: { 153 | disabled: options.logger?.disabled, 154 | level: options.logger?.level, 155 | log: !!options.logger?.log, 156 | }, 157 | databaseHooks: { 158 | user: { 159 | create: { 160 | after: !!options.databaseHooks?.user?.create?.after, 161 | before: !!options.databaseHooks?.user?.create?.before, 162 | }, 163 | update: { 164 | after: !!options.databaseHooks?.user?.update?.after, 165 | before: !!options.databaseHooks?.user?.update?.before, 166 | }, 167 | }, 168 | session: { 169 | create: { 170 | after: !!options.databaseHooks?.session?.create?.after, 171 | before: !!options.databaseHooks?.session?.create?.before, 172 | }, 173 | update: { 174 | after: !!options.databaseHooks?.session?.update?.after, 175 | before: !!options.databaseHooks?.session?.update?.before, 176 | }, 177 | }, 178 | account: { 179 | create: { 180 | after: !!options.databaseHooks?.account?.create?.after, 181 | before: !!options.databaseHooks?.account?.create?.before, 182 | }, 183 | update: { 184 | after: !!options.databaseHooks?.account?.update?.after, 185 | before: !!options.databaseHooks?.account?.update?.before, 186 | }, 187 | }, 188 | verification: { 189 | create: { 190 | after: !!options.databaseHooks?.verification?.create?.after, 191 | before: !!options.databaseHooks?.verification?.create?.before, 192 | }, 193 | update: { 194 | after: !!options.databaseHooks?.verification?.update?.after, 195 | before: !!options.databaseHooks?.verification?.update?.before, 196 | }, 197 | }, 198 | }, 199 | }; 200 | } 201 | ``` -------------------------------------------------------------------------------- /demo/nextjs/components/sign-in.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardHeader, 8 | CardTitle, 9 | CardDescription, 10 | CardFooter, 11 | } from "@/components/ui/card"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Label } from "@/components/ui/label"; 14 | import { Checkbox } from "@/components/ui/checkbox"; 15 | import { useState, useTransition } from "react"; 16 | import { Loader2 } from "lucide-react"; 17 | import { client, signIn } from "@/lib/auth-client"; 18 | import Link from "next/link"; 19 | import { cn } from "@/lib/utils"; 20 | import { useRouter, useSearchParams } from "next/navigation"; 21 | import { toast } from "sonner"; 22 | import { getCallbackURL } from "@/lib/shared"; 23 | 24 | export default function SignIn() { 25 | const [email, setEmail] = useState(""); 26 | const [password, setPassword] = useState(""); 27 | const [loading, startTransition] = useTransition(); 28 | const [rememberMe, setRememberMe] = useState(false); 29 | const router = useRouter(); 30 | const params = useSearchParams(); 31 | 32 | const LastUsedIndicator = () => ( 33 | <span className="ml-auto absolute top-0 right-0 px-2 py-1 text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-md font-medium"> 34 | Last Used 35 | </span> 36 | ); 37 | 38 | return ( 39 | <Card className="max-w-md rounded-none"> 40 | <CardHeader> 41 | <CardTitle className="text-lg md:text-xl">Sign In</CardTitle> 42 | <CardDescription className="text-xs md:text-sm"> 43 | Enter your email below to login to your account 44 | </CardDescription> 45 | </CardHeader> 46 | <CardContent> 47 | <div className="grid gap-4"> 48 | <div className="grid gap-2"> 49 | <Label htmlFor="email">Email</Label> 50 | <Input 51 | id="email" 52 | type="email" 53 | placeholder="[email protected]" 54 | required 55 | onChange={(e) => { 56 | setEmail(e.target.value); 57 | }} 58 | value={email} 59 | /> 60 | </div> 61 | 62 | <div className="grid gap-2"> 63 | <div className="flex items-center"> 64 | <Label htmlFor="password">Password</Label> 65 | <Link 66 | href="/forget-password" 67 | className="ml-auto inline-block text-sm underline" 68 | > 69 | Forgot your password? 70 | </Link> 71 | </div> 72 | 73 | <Input 74 | id="password" 75 | type="password" 76 | placeholder="password" 77 | autoComplete="password" 78 | value={password} 79 | onChange={(e) => setPassword(e.target.value)} 80 | /> 81 | </div> 82 | 83 | <div className="flex items-center gap-2"> 84 | <Checkbox 85 | id="remember" 86 | onClick={() => { 87 | setRememberMe(!rememberMe); 88 | }} 89 | /> 90 | <Label htmlFor="remember">Remember me</Label> 91 | </div> 92 | 93 | <Button 94 | type="submit" 95 | className="w-full flex items-center justify-center" 96 | disabled={loading} 97 | onClick={async () => { 98 | startTransition(async () => { 99 | await signIn.email( 100 | { email, password, rememberMe }, 101 | { 102 | onSuccess(context) { 103 | toast.success("Successfully signed in"); 104 | router.push(getCallbackURL(params)); 105 | }, 106 | onError(context) { 107 | toast.error(context.error.message); 108 | }, 109 | }, 110 | ); 111 | }); 112 | }} 113 | > 114 | <div className="flex items-center justify-center w-full relative"> 115 | {loading ? ( 116 | <Loader2 size={16} className="animate-spin" /> 117 | ) : ( 118 | "Login" 119 | )} 120 | {client.isLastUsedLoginMethod("email") && <LastUsedIndicator />} 121 | </div> 122 | </Button> 123 | 124 | <div 125 | className={cn( 126 | "w-full gap-2 flex items-center", 127 | "justify-between flex-col", 128 | )} 129 | > 130 | <Button 131 | variant="outline" 132 | className={cn("w-full gap-2 flex relative")} 133 | onClick={async () => { 134 | await signIn.social({ 135 | provider: "google", 136 | callbackURL: "/dashboard", 137 | }); 138 | }} 139 | > 140 | <svg 141 | xmlns="http://www.w3.org/2000/svg" 142 | width="0.98em" 143 | height="1em" 144 | viewBox="0 0 256 262" 145 | > 146 | <path 147 | fill="#4285F4" 148 | d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" 149 | ></path> 150 | <path 151 | fill="#34A853" 152 | d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" 153 | ></path> 154 | <path 155 | fill="#FBBC05" 156 | d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z" 157 | ></path> 158 | <path 159 | fill="#EB4335" 160 | d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" 161 | ></path> 162 | </svg> 163 | <span>Sign in with Google</span> 164 | {client.isLastUsedLoginMethod("google") && <LastUsedIndicator />} 165 | </Button> 166 | <Button 167 | variant="outline" 168 | className={cn("w-full gap-2 flex items-center relative")} 169 | onClick={async () => { 170 | await signIn.social({ 171 | provider: "github", 172 | callbackURL: "/dashboard", 173 | }); 174 | }} 175 | > 176 | <svg 177 | xmlns="http://www.w3.org/2000/svg" 178 | width="1em" 179 | height="1em" 180 | viewBox="0 0 24 24" 181 | > 182 | <path 183 | fill="currentColor" 184 | d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" 185 | ></path> 186 | </svg> 187 | <span>Sign in with GitHub</span> 188 | {client.isLastUsedLoginMethod("github") && <LastUsedIndicator />} 189 | </Button> 190 | <Button 191 | variant="outline" 192 | className={cn("w-full gap-2 flex items-center relative")} 193 | onClick={async () => { 194 | await signIn.social({ 195 | provider: "microsoft", 196 | callbackURL: "/dashboard", 197 | }); 198 | }} 199 | > 200 | <svg 201 | xmlns="http://www.w3.org/2000/svg" 202 | width="1em" 203 | height="1em" 204 | viewBox="0 0 24 24" 205 | > 206 | <path 207 | fill="currentColor" 208 | d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z" 209 | ></path> 210 | </svg> 211 | <span>Sign in with Microsoft</span> 212 | {client.isLastUsedLoginMethod("microsoft") && ( 213 | <LastUsedIndicator /> 214 | )} 215 | </Button> 216 | </div> 217 | </div> 218 | </CardContent> 219 | <CardFooter> 220 | <div className="flex justify-center w-full border-t pt-4"> 221 | <p className="text-center text-xs text-neutral-500"> 222 | built with{" "} 223 | <Link 224 | href="https://better-auth.com" 225 | className="underline" 226 | target="_blank" 227 | > 228 | <span className="dark:text-white/70 cursor-pointer"> 229 | better-auth. 230 | </span> 231 | </Link> 232 | </p> 233 | </div> 234 | </CardFooter> 235 | </Card> 236 | ); 237 | } 238 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/anonymous/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError, getSessionFromCtx } from "../../api"; 2 | import { 3 | createAuthEndpoint, 4 | createAuthMiddleware, 5 | } from "@better-auth/core/api"; 6 | import type { 7 | BetterAuthPlugin, 8 | GenericEndpointContext, 9 | } from "@better-auth/core"; 10 | import type { InferOptionSchema, Session, User } from "../../types"; 11 | import { parseSetCookieHeader, setSessionCookie } from "../../cookies"; 12 | import { getOrigin } from "../../utils/url"; 13 | import { mergeSchema } from "../../db/schema"; 14 | import type { EndpointContext } from "better-call"; 15 | import { generateId } from "../../utils/id"; 16 | import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; 17 | import type { AuthContext } from "@better-auth/core"; 18 | import { defineErrorCodes } from "@better-auth/core/utils"; 19 | 20 | export interface UserWithAnonymous extends User { 21 | isAnonymous: boolean; 22 | } 23 | export interface AnonymousOptions { 24 | /** 25 | * Configure the domain name of the temporary email 26 | * address for anonymous users in the database. 27 | * @default "baseURL" 28 | */ 29 | emailDomainName?: string; 30 | /** 31 | * A useful hook to run after an anonymous user 32 | * is about to link their account. 33 | */ 34 | onLinkAccount?: (data: { 35 | anonymousUser: { 36 | user: UserWithAnonymous & Record<string, any>; 37 | session: Session & Record<string, any>; 38 | }; 39 | newUser: { 40 | user: User & Record<string, any>; 41 | session: Session & Record<string, any>; 42 | }; 43 | ctx: GenericEndpointContext; 44 | }) => Promise<void> | void; 45 | /** 46 | * Disable deleting the anonymous user after linking 47 | */ 48 | disableDeleteAnonymousUser?: boolean; 49 | /** 50 | * A hook to generate a name for the anonymous user. 51 | * Useful if you want to have random names for anonymous users, or if `name` is unique in your database. 52 | * @returns The name for the anonymous user. 53 | */ 54 | generateName?: ( 55 | ctx: EndpointContext< 56 | "/sign-in/anonymous", 57 | { 58 | method: "POST"; 59 | }, 60 | AuthContext 61 | >, 62 | ) => Promise<string> | string; 63 | /** 64 | * Custom schema for the anonymous plugin 65 | */ 66 | schema?: InferOptionSchema<typeof schema>; 67 | } 68 | 69 | const schema = { 70 | user: { 71 | fields: { 72 | isAnonymous: { 73 | type: "boolean", 74 | required: false, 75 | }, 76 | }, 77 | }, 78 | } satisfies BetterAuthPluginDBSchema; 79 | 80 | const ERROR_CODES = defineErrorCodes({ 81 | FAILED_TO_CREATE_USER: "Failed to create user", 82 | COULD_NOT_CREATE_SESSION: "Could not create session", 83 | ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY: 84 | "Anonymous users cannot sign in again anonymously", 85 | }); 86 | 87 | export const anonymous = (options?: AnonymousOptions) => { 88 | return { 89 | id: "anonymous", 90 | endpoints: { 91 | signInAnonymous: createAuthEndpoint( 92 | "/sign-in/anonymous", 93 | { 94 | method: "POST", 95 | metadata: { 96 | openapi: { 97 | description: "Sign in anonymously", 98 | responses: { 99 | 200: { 100 | description: "Sign in anonymously", 101 | content: { 102 | "application/json": { 103 | schema: { 104 | type: "object", 105 | properties: { 106 | user: { 107 | $ref: "#/components/schemas/User", 108 | }, 109 | session: { 110 | $ref: "#/components/schemas/Session", 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | }, 118 | }, 119 | }, 120 | }, 121 | async (ctx) => { 122 | // If the current request already has a valid anonymous session, we should 123 | // reject any further attempts to create another anonymous user. This 124 | // prevents an anonymous user from signing in anonymously again while they 125 | // are already authenticated. 126 | const existingSession = await getSessionFromCtx<{ 127 | isAnonymous: boolean; 128 | }>(ctx, { disableRefresh: true }); 129 | if (existingSession?.user.isAnonymous) { 130 | throw new APIError("BAD_REQUEST", { 131 | message: 132 | ERROR_CODES.ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY, 133 | }); 134 | } 135 | 136 | const { emailDomainName = getOrigin(ctx.context.baseURL) } = 137 | options || {}; 138 | const id = generateId(); 139 | const email = `temp-${id}@${emailDomainName}`; 140 | const name = (await options?.generateName?.(ctx)) || "Anonymous"; 141 | const newUser = await ctx.context.internalAdapter.createUser({ 142 | email, 143 | emailVerified: false, 144 | isAnonymous: true, 145 | name, 146 | createdAt: new Date(), 147 | updatedAt: new Date(), 148 | }); 149 | if (!newUser) { 150 | throw ctx.error("INTERNAL_SERVER_ERROR", { 151 | message: ERROR_CODES.FAILED_TO_CREATE_USER, 152 | }); 153 | } 154 | const session = await ctx.context.internalAdapter.createSession( 155 | newUser.id, 156 | ); 157 | if (!session) { 158 | return ctx.json(null, { 159 | status: 400, 160 | body: { 161 | message: ERROR_CODES.COULD_NOT_CREATE_SESSION, 162 | }, 163 | }); 164 | } 165 | await setSessionCookie(ctx, { 166 | session, 167 | user: newUser, 168 | }); 169 | return ctx.json({ 170 | token: session.token, 171 | user: { 172 | id: newUser.id, 173 | email: newUser.email, 174 | emailVerified: newUser.emailVerified, 175 | name: newUser.name, 176 | createdAt: newUser.createdAt, 177 | updatedAt: newUser.updatedAt, 178 | }, 179 | }); 180 | }, 181 | ), 182 | }, 183 | hooks: { 184 | after: [ 185 | { 186 | matcher(ctx) { 187 | return ( 188 | ctx.path.startsWith("/sign-in") || 189 | ctx.path.startsWith("/sign-up") || 190 | ctx.path.startsWith("/callback") || 191 | ctx.path.startsWith("/oauth2/callback") || 192 | ctx.path.startsWith("/magic-link/verify") || 193 | ctx.path.startsWith("/email-otp/verify-email") || 194 | ctx.path.startsWith("/one-tap/callback") || 195 | ctx.path.startsWith("/passkey/verify-authentication") || 196 | ctx.path.startsWith("/phone-number/verify") 197 | ); 198 | }, 199 | handler: createAuthMiddleware(async (ctx) => { 200 | const setCookie = ctx.context.responseHeaders?.get("set-cookie"); 201 | 202 | /** 203 | * We can consider the user is about to sign in or sign up 204 | * if the response contains a session token. 205 | */ 206 | const sessionTokenName = ctx.context.authCookies.sessionToken.name; 207 | /** 208 | * The user is about to link their account. 209 | */ 210 | const sessionCookie = parseSetCookieHeader(setCookie || "") 211 | .get(sessionTokenName) 212 | ?.value.split(".")[0]!; 213 | 214 | if (!sessionCookie) { 215 | return; 216 | } 217 | /** 218 | * Make sure the user had an anonymous session. 219 | */ 220 | const session = await getSessionFromCtx<{ isAnonymous: boolean }>( 221 | ctx, 222 | { 223 | disableRefresh: true, 224 | }, 225 | ); 226 | 227 | if (!session || !session.user.isAnonymous) { 228 | return; 229 | } 230 | 231 | if (ctx.path === "/sign-in/anonymous" && !ctx.context.newSession) { 232 | throw new APIError("BAD_REQUEST", { 233 | message: 234 | ERROR_CODES.ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY, 235 | }); 236 | } 237 | const newSession = ctx.context.newSession; 238 | if (!newSession) { 239 | return; 240 | } 241 | // At this point the user is linking their previous anonymous account with a 242 | // new credential (email / social). Invoke the provided callback so that the 243 | // integrator can perform any additional logic such as transferring data 244 | // from the anonymous user to the new user. 245 | if (options?.onLinkAccount) { 246 | await options?.onLinkAccount?.({ 247 | anonymousUser: session, 248 | newUser: newSession, 249 | ctx, 250 | }); 251 | } 252 | if (!options?.disableDeleteAnonymousUser) { 253 | await ctx.context.internalAdapter.deleteUser(session.user.id); 254 | } 255 | }), 256 | }, 257 | ], 258 | }, 259 | schema: mergeSchema(schema, options?.schema), 260 | $ERROR_CODES: ERROR_CODES, 261 | } satisfies BetterAuthPlugin; 262 | }; 263 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/two-factor/totp/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "better-call"; 2 | import * as z from "zod"; 3 | import { createAuthEndpoint } from "@better-auth/core/api"; 4 | import { sessionMiddleware } from "../../../api"; 5 | import { symmetricDecrypt } from "../../../crypto"; 6 | import type { BackupCodeOptions } from "../backup-codes"; 7 | import { verifyTwoFactor } from "../verify-two-factor"; 8 | import type { 9 | TwoFactorProvider, 10 | TwoFactorTable, 11 | UserWithTwoFactor, 12 | } from "../types"; 13 | import { setSessionCookie } from "../../../cookies"; 14 | import { TWO_FACTOR_ERROR_CODES } from "../error-code"; 15 | import { createOTP } from "@better-auth/utils/otp"; 16 | import { BASE_ERROR_CODES } from "@better-auth/core/error"; 17 | 18 | export type TOTPOptions = { 19 | /** 20 | * Issuer 21 | */ 22 | issuer?: string; 23 | /** 24 | * How many digits the otp to be 25 | * 26 | * @default 6 27 | */ 28 | digits?: 6 | 8; 29 | /** 30 | * Period for otp in seconds. 31 | * @default 30 32 | */ 33 | period?: number; 34 | /** 35 | * Backup codes configuration 36 | */ 37 | backupCodes?: BackupCodeOptions; 38 | /** 39 | * Disable totp 40 | */ 41 | disable?: boolean; 42 | }; 43 | 44 | export const totp2fa = (options?: TOTPOptions) => { 45 | const opts = { 46 | ...options, 47 | digits: options?.digits || 6, 48 | period: options?.period || 30, 49 | }; 50 | 51 | const twoFactorTable = "twoFactor"; 52 | 53 | const generateTOTP = createAuthEndpoint( 54 | "/totp/generate", 55 | { 56 | method: "POST", 57 | body: z.object({ 58 | secret: z.string().meta({ 59 | description: "The secret to generate the TOTP code", 60 | }), 61 | }), 62 | metadata: { 63 | openapi: { 64 | summary: "Generate TOTP code", 65 | description: "Use this endpoint to generate a TOTP code", 66 | responses: { 67 | 200: { 68 | description: "Successful response", 69 | content: { 70 | "application/json": { 71 | schema: { 72 | type: "object", 73 | properties: { 74 | code: { 75 | type: "string", 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | SERVER_ONLY: true, 85 | }, 86 | }, 87 | async (ctx) => { 88 | if (options?.disable) { 89 | ctx.context.logger.error( 90 | "totp isn't configured. please pass totp option on two factor plugin to enable totp", 91 | ); 92 | throw new APIError("BAD_REQUEST", { 93 | message: "totp isn't configured", 94 | }); 95 | } 96 | const code = await createOTP(ctx.body.secret, { 97 | period: opts.period, 98 | digits: opts.digits, 99 | }).totp(); 100 | return { code }; 101 | }, 102 | ); 103 | 104 | const getTOTPURI = createAuthEndpoint( 105 | "/two-factor/get-totp-uri", 106 | { 107 | method: "POST", 108 | use: [sessionMiddleware], 109 | body: z.object({ 110 | password: z.string().meta({ 111 | description: "User password", 112 | }), 113 | }), 114 | metadata: { 115 | openapi: { 116 | summary: "Get TOTP URI", 117 | description: "Use this endpoint to get the TOTP URI", 118 | responses: { 119 | 200: { 120 | description: "Successful response", 121 | content: { 122 | "application/json": { 123 | schema: { 124 | type: "object", 125 | properties: { 126 | totpURI: { 127 | type: "string", 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | }, 138 | async (ctx) => { 139 | if (options?.disable) { 140 | ctx.context.logger.error( 141 | "totp isn't configured. please pass totp option on two factor plugin to enable totp", 142 | ); 143 | throw new APIError("BAD_REQUEST", { 144 | message: "totp isn't configured", 145 | }); 146 | } 147 | const user = ctx.context.session.user as UserWithTwoFactor; 148 | const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({ 149 | model: twoFactorTable, 150 | where: [ 151 | { 152 | field: "userId", 153 | value: user.id, 154 | }, 155 | ], 156 | }); 157 | if (!twoFactor) { 158 | throw new APIError("BAD_REQUEST", { 159 | message: TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED, 160 | }); 161 | } 162 | const secret = await symmetricDecrypt({ 163 | key: ctx.context.secret, 164 | data: twoFactor.secret, 165 | }); 166 | await ctx.context.password.checkPassword(user.id, ctx); 167 | const totpURI = createOTP(secret, { 168 | digits: opts.digits, 169 | period: opts.period, 170 | }).url(options?.issuer || ctx.context.appName, user.email); 171 | return { 172 | totpURI, 173 | }; 174 | }, 175 | ); 176 | 177 | const verifyTOTP = createAuthEndpoint( 178 | "/two-factor/verify-totp", 179 | { 180 | method: "POST", 181 | body: z.object({ 182 | code: z.string().meta({ 183 | description: 'The otp code to verify. Eg: "012345"', 184 | }), 185 | /** 186 | * if true, the device will be trusted 187 | * for 30 days. It'll be refreshed on 188 | * every sign in request within this time. 189 | */ 190 | trustDevice: z 191 | .boolean() 192 | .meta({ 193 | description: 194 | "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true", 195 | }) 196 | .optional(), 197 | }), 198 | metadata: { 199 | openapi: { 200 | summary: "Verify two factor TOTP", 201 | description: "Verify two factor TOTP", 202 | responses: { 203 | 200: { 204 | description: "Successful response", 205 | content: { 206 | "application/json": { 207 | schema: { 208 | type: "object", 209 | properties: { 210 | status: { 211 | type: "boolean", 212 | }, 213 | }, 214 | }, 215 | }, 216 | }, 217 | }, 218 | }, 219 | }, 220 | }, 221 | }, 222 | async (ctx) => { 223 | if (options?.disable) { 224 | ctx.context.logger.error( 225 | "totp isn't configured. please pass totp option on two factor plugin to enable totp", 226 | ); 227 | throw new APIError("BAD_REQUEST", { 228 | message: "totp isn't configured", 229 | }); 230 | } 231 | const { session, valid, invalid } = await verifyTwoFactor(ctx); 232 | const user = session.user as UserWithTwoFactor; 233 | const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({ 234 | model: twoFactorTable, 235 | where: [ 236 | { 237 | field: "userId", 238 | value: user.id, 239 | }, 240 | ], 241 | }); 242 | 243 | if (!twoFactor) { 244 | throw new APIError("BAD_REQUEST", { 245 | message: TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED, 246 | }); 247 | } 248 | const decrypted = await symmetricDecrypt({ 249 | key: ctx.context.secret, 250 | data: twoFactor.secret, 251 | }); 252 | const status = await createOTP(decrypted, { 253 | period: opts.period, 254 | digits: opts.digits, 255 | }).verify(ctx.body.code); 256 | if (!status) { 257 | return invalid("INVALID_CODE"); 258 | } 259 | 260 | if (!user.twoFactorEnabled) { 261 | if (!session.session) { 262 | throw new APIError("BAD_REQUEST", { 263 | message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION, 264 | }); 265 | } 266 | const updatedUser = await ctx.context.internalAdapter.updateUser( 267 | user.id, 268 | { 269 | twoFactorEnabled: true, 270 | }, 271 | ); 272 | const newSession = await ctx.context.internalAdapter 273 | .createSession(user.id, false, session.session) 274 | .catch((e) => { 275 | throw e; 276 | }); 277 | 278 | await ctx.context.internalAdapter.deleteSession(session.session.token); 279 | await setSessionCookie(ctx, { 280 | session: newSession, 281 | user: updatedUser, 282 | }); 283 | } 284 | return valid(ctx); 285 | }, 286 | ); 287 | 288 | return { 289 | id: "totp", 290 | endpoints: { 291 | /** 292 | * ### Endpoint 293 | * 294 | * POST `/totp/generate` 295 | * 296 | * ### API Methods 297 | * 298 | * **server:** 299 | * `auth.api.generateTOTP` 300 | * 301 | * **client:** 302 | * `authClient.totp.generate` 303 | * 304 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/totp#api-method-totp-generate) 305 | */ 306 | generateTOTP: generateTOTP, 307 | /** 308 | * ### Endpoint 309 | * 310 | * POST `/two-factor/get-totp-uri` 311 | * 312 | * ### API Methods 313 | * 314 | * **server:** 315 | * `auth.api.getTOTPURI` 316 | * 317 | * **client:** 318 | * `authClient.twoFactor.getTotpUri` 319 | * 320 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/two-factor#api-method-two-factor-get-totp-uri) 321 | */ 322 | getTOTPURI: getTOTPURI, 323 | verifyTOTP, 324 | }, 325 | } satisfies TwoFactorProvider; 326 | }; 327 | ``` -------------------------------------------------------------------------------- /docs/app/blog/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { blogs } from "@/lib/source"; 2 | import { notFound } from "next/navigation"; 3 | import { absoluteUrl, formatDate } from "@/lib/utils"; 4 | import DatabaseTable from "@/components/mdx/database-tables"; 5 | import { cn } from "@/lib/utils"; 6 | import { Step, Steps } from "fumadocs-ui/components/steps"; 7 | import { Tab, Tabs } from "fumadocs-ui/components/tabs"; 8 | import { GenerateSecret } from "@/components/generate-secret"; 9 | import { AnimatePresence } from "@/components/ui/fade-in"; 10 | import { TypeTable } from "fumadocs-ui/components/type-table"; 11 | import { Features } from "@/components/blocks/features"; 12 | import { ForkButton } from "@/components/fork-button"; 13 | import Link from "next/link"; 14 | import defaultMdxComponents from "fumadocs-ui/mdx"; 15 | import { File, Folder, Files } from "fumadocs-ui/components/files"; 16 | import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; 17 | import { Pre } from "fumadocs-ui/components/codeblock"; 18 | import { Glow } from "../_components/default-changelog"; 19 | import { XIcon } from "../_components/icons"; 20 | import { StarField } from "../_components/stat-field"; 21 | import { BlogPage } from "../_components/blog-list"; 22 | import { Callout } from "@/components/ui/callout"; 23 | import { ArrowLeftIcon, ExternalLink } from "lucide-react"; 24 | import { Support } from "../_components/support"; 25 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 26 | 27 | const metaTitle = "Blogs"; 28 | const metaDescription = "Latest changes , fixes and updates."; 29 | const ogImage = "https://better-auth.com/release-og/changelog-og.png"; 30 | 31 | export default async function Page({ 32 | params, 33 | }: { 34 | params: Promise<{ slug?: string[] }>; 35 | }) { 36 | const { slug } = await params; 37 | if (!slug) { 38 | return <BlogPage />; 39 | } 40 | const page = blogs.getPage(slug); 41 | if (!page) { 42 | notFound(); 43 | } 44 | const MDX = page.data?.body; 45 | const { title, description, date } = page.data; 46 | return ( 47 | <div className="relative min-h-screen"> 48 | <div className="pointer-events-none absolute inset-0 -z-10"> 49 | <StarField className="top-1/3 left-1/2 -translate-x-1/2" /> 50 | <Glow /> 51 | </div> 52 | <div className="relative mx-auto max-w-3xl px-4 md:px-0 pb-24 pt-12"> 53 | <h1 className="text-center text-3xl md:text-5xl font-semibold tracking-tighter"> 54 | {title} 55 | </h1> 56 | {description && ( 57 | <p className="mt-3 text-center text-muted-foreground"> 58 | {description} 59 | </p> 60 | )} 61 | <div className="my-2 flex items-center justify-center gap-3"> 62 | <div> 63 | <Avatar> 64 | <AvatarImage 65 | src={page.data?.author?.avatar} 66 | alt={page.data?.author?.name ?? "Author"} 67 | /> 68 | <AvatarFallback> 69 | {page.data?.author?.name?.charAt(0)?.toUpperCase() ?? ""} 70 | </AvatarFallback> 71 | </Avatar> 72 | </div> 73 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> 74 | {page.data?.author?.name && ( 75 | <span className="font-medium text-foreground"> 76 | {page.data.author.name} 77 | </span> 78 | )} 79 | {page.data?.author?.twitter && ( 80 | <> 81 | <span>·</span> 82 | <a 83 | href={`https://x.com/${page.data.author.twitter}`} 84 | target="_blank" 85 | rel="noreferrer noopener" 86 | className="inline-flex items-center gap-1 underline decoration-dashed" 87 | > 88 | <XIcon className="size-3" />@{page.data.author.twitter} 89 | </a> 90 | </> 91 | )} 92 | {date && ( 93 | <> 94 | <span>·</span> 95 | <time dateTime={String(date)}>{formatDate(date)}</time> 96 | </> 97 | )} 98 | </div> 99 | </div> 100 | <div className="w-full flex items-center gap-2 my-4 mb-8"> 101 | <div className="flex items-center gap-2 opacity-80"> 102 | <ArrowLeftIcon className="size-4" /> 103 | <Link href="/blog" className=""> 104 | Blogs 105 | </Link> 106 | </div> 107 | <hr className="h-1 w-full opacity-80" /> 108 | </div> 109 | 110 | <article className="prose prose-neutral dark:prose-invert mx-auto max-w-3xl px-4 md:px-0"> 111 | <MDX 112 | components={{ 113 | ...defaultMdxComponents, 114 | a: ({ className, href, children, ...props }: any) => { 115 | const isExternal = 116 | typeof href === "string" && /^(https?:)?\/\//.test(href); 117 | const classes = cn( 118 | "inline-flex items-center gap-1 font-medium underline decoration-dashed", 119 | className, 120 | ); 121 | if (isExternal) { 122 | return ( 123 | <a 124 | className={classes} 125 | href={href} 126 | target="_blank" 127 | rel="noreferrer noopener" 128 | {...props} 129 | > 130 | {children} 131 | <ExternalLink className="ms-0.5 inline size-[0.9em] text-fd-muted-foreground" /> 132 | </a> 133 | ); 134 | } 135 | return ( 136 | <Link className={classes} href={href} {...(props as any)}> 137 | {children} 138 | </Link> 139 | ); 140 | }, 141 | Link: ({ className, href, children, ...props }: any) => { 142 | const isExternal = 143 | typeof href === "string" && /^(https?:)?\/\//.test(href); 144 | const classes = cn( 145 | "inline-flex items-center gap-1 font-medium underline decoration-dashed", 146 | className, 147 | ); 148 | if (isExternal) { 149 | return ( 150 | <a 151 | className={classes} 152 | href={href} 153 | target="_blank" 154 | rel="noreferrer noopener" 155 | {...props} 156 | > 157 | {children} 158 | <ExternalLink className="ms-0.5 inline size-[0.9em] text-fd-muted-foreground" /> 159 | </a> 160 | ); 161 | } 162 | return ( 163 | <Link className={classes} href={href} {...(props as any)}> 164 | {children} 165 | </Link> 166 | ); 167 | }, 168 | Step, 169 | Steps, 170 | File, 171 | Folder, 172 | Files, 173 | Tab, 174 | Tabs, 175 | Pre: Pre, 176 | GenerateSecret, 177 | AnimatePresence, 178 | TypeTable, 179 | Features, 180 | ForkButton, 181 | DatabaseTable, 182 | Accordion, 183 | Accordions, 184 | Callout: ({ 185 | children, 186 | type, 187 | ...props 188 | }: { 189 | children: React.ReactNode; 190 | type?: "info" | "warn" | "error" | "success" | "warning"; 191 | [key: string]: any; 192 | }) => ( 193 | <Callout type={type} {...props}> 194 | {children} 195 | </Callout> 196 | ), 197 | Support, 198 | }} 199 | /> 200 | </article> 201 | </div> 202 | </div> 203 | ); 204 | } 205 | 206 | export async function generateMetadata({ 207 | params, 208 | }: { 209 | params: Promise<{ slug?: string[] }>; 210 | }) { 211 | const { slug } = await params; 212 | if (!slug) { 213 | return { 214 | metadataBase: new URL("https://better-auth.com/blogs"), 215 | title: metaTitle, 216 | description: metaDescription, 217 | openGraph: { 218 | title: metaTitle, 219 | description: metaDescription, 220 | images: [ 221 | { 222 | url: ogImage, 223 | }, 224 | ], 225 | url: "https://better-auth.com/blogs", 226 | }, 227 | twitter: { 228 | card: "summary_large_image", 229 | title: metaTitle, 230 | description: metaDescription, 231 | images: [ogImage], 232 | }, 233 | }; 234 | } 235 | const page = blogs.getPage(slug); 236 | if (page == null) notFound(); 237 | const baseUrl = process.env.NEXT_PUBLIC_URL || process.env.VERCEL_URL; 238 | const url = new URL( 239 | `${baseUrl?.startsWith("http") ? baseUrl : `https://${baseUrl}`}${ 240 | page.data?.image 241 | }`, 242 | ); 243 | const { title, description } = page.data; 244 | 245 | return { 246 | title, 247 | description, 248 | openGraph: { 249 | title, 250 | description, 251 | type: "website", 252 | url: absoluteUrl(`blog/${slug.join("/")}`), 253 | images: [ 254 | { 255 | url: url.toString(), 256 | width: 1200, 257 | height: 630, 258 | alt: title, 259 | }, 260 | ], 261 | }, 262 | twitter: { 263 | card: "summary_large_image", 264 | title, 265 | description, 266 | images: [url.toString()], 267 | }, 268 | }; 269 | } 270 | 271 | export function generateStaticParams() { 272 | return blogs.generateParams(); 273 | } 274 | ``` -------------------------------------------------------------------------------- /docs/app/v1/page.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { ArrowRight } from "lucide-react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { BackgroundLines } from "./bg-line"; 4 | import Link from "next/link"; 5 | import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; 6 | import { Metadata } from "next"; 7 | 8 | export const metadata: Metadata = { 9 | title: "V1.0 Release", 10 | description: "Better Auth V1.0 release notes", 11 | openGraph: { 12 | images: "https://better-auth.com/v1-og.png", 13 | title: "V1.0 Release", 14 | description: "Better Auth V1.0 release notes", 15 | url: "https://better-auth.com/v1", 16 | type: "article", 17 | siteName: "Better Auth", 18 | }, 19 | twitter: { 20 | images: "https://better-auth.com/v1-og.png", 21 | card: "summary_large_image", 22 | site: "@better_auth", 23 | creator: "@better_auth", 24 | title: "V1.0 Release", 25 | description: "Better Auth V1.0 release notes", 26 | }, 27 | }; 28 | 29 | export default function V1Ship() { 30 | return ( 31 | <div className="min-h-screen bg-transparent overflow-hidden"> 32 | <div className="h-[50vh] bg-transparent/10 relative"> 33 | <BackgroundLines> 34 | <div className="absolute bottom-1/3 left-1/2 transform -translate-x-1/2 text-center"> 35 | <h1 className="text-5xl mb-4">V1.0 - nov.22</h1> 36 | <p className="text-lg text-gray-400 max-w-xl mx-auto"> 37 | We are excited to announce the Better Auth V1.0 release. 38 | </p> 39 | </div> 40 | </BackgroundLines> 41 | </div> 42 | 43 | <div className="relative py-24"> 44 | <div className="absolute inset-0 z-0"> 45 | <div className="grid grid-cols-12 h-full"> 46 | {Array(12) 47 | .fill(null) 48 | .map((_, i) => ( 49 | <div 50 | key={i} 51 | className="border-l border-dashed border-stone-100 dark:border-white/10 h-full" 52 | /> 53 | ))} 54 | </div> 55 | <div className="grid grid-rows-12 w-full absolute top-0"> 56 | {Array(12) 57 | .fill(null) 58 | .map((_, i) => ( 59 | <div 60 | key={i} 61 | className="border-t border-dashed border-stone-100 dark:border-stone-900/60 w-full" 62 | /> 63 | ))} 64 | </div> 65 | </div> 66 | <div className="max-w-6xl mx-auto px-6 relative z-10"> 67 | <h2 className="text-3xl font-bold mb-12 font-geist text-center"> 68 | What does V1 means? 69 | </h2> 70 | <p> 71 | Since introducing Better Auth, the community's excitement has been 72 | incredibly motivating—thank you! <br /> <br /> 73 | V1 is an important milestone, but it simply means we believe you can 74 | use it in production and that we'll strive to keep the APIs stable 75 | until the next major version. However, we'll continue improving, 76 | adding new features, and fixing bugs at the same pace as before. 77 | <br /> <br /> 78 | If you were using Better Auth for production, we recommend updating 79 | to V1 as soon as possible. There are some breaking changes, feel 80 | free to join us on{" "} 81 | <Link href="https://discord.gg/better-auth">Discord</Link>, and 82 | we'll gladly assist. 83 | </p> 84 | </div> 85 | </div> 86 | 87 | <ReleaseRelated /> 88 | 89 | <div className="border-t border-white/10"> 90 | <div className="max-w-4xl mx-auto px-6 py-24"> 91 | <h2 className="text-3xl font-bold mb-12 font-geist">Changelog</h2> 92 | <div className="space-y-8"> 93 | <ChangelogItem 94 | version="1.0.0" 95 | date="2024" 96 | changes={[ 97 | "feat: Open API Docs", 98 | "docs: Sign In Box Builder", 99 | "feat: default memory adapter. If no database is provided, it will use memory adapter", 100 | "feat: New server only endpoints for Organization and Two Factor plugins", 101 | "refactor: all core tables now have `createdAt` and `updatedAt` fields", 102 | "refactor: accounts now store `expiresAt` for both refresh and access tokens", 103 | "feat: Email OTP forget password flow", 104 | "docs: NextAuth.js migration guide", 105 | "feat: sensitive endpoints now check for fresh tokens", 106 | "feat: two-factor now have different interface for redirect and callback", 107 | "and a lot more bug fixes and improvements...", 108 | ]} 109 | /> 110 | </div> 111 | </div> 112 | </div> 113 | </div> 114 | ); 115 | } 116 | 117 | function ReleaseRelated() { 118 | return ( 119 | <div className="relative dark:bg-transparent/10 bg-zinc-100 border-b-2 border-white/10 rounded-none py-24"> 120 | <div className="absolute inset-0 z-0"> 121 | <div className="grid grid-rows-12 w-full absolute top-0"> 122 | {Array(12) 123 | .fill(null) 124 | .map((_, i) => ( 125 | <div 126 | key={i} 127 | className="border-t border-dashed border-white/10 w-full" 128 | /> 129 | ))} 130 | </div> 131 | </div> 132 | <div className="max-w-6xl mx-auto px-6 relative z-10"> 133 | <div className="grid grid-cols-1 md:grid-cols-3 gap-8"> 134 | <div> 135 | <h3 className="text-xl font-semibold mb-4">Install Latest</h3> 136 | <div className="dark:bg-white/5 bg-black/10 rounded-lg p-4 mb-2"> 137 | <code className="text-sm font-mono"> 138 | npm i better-auth@latest 139 | </code> 140 | </div> 141 | <p className="text-sm text-gray-400"> 142 | Get the latest{" "} 143 | <a href="#" className="underline"> 144 | Node.js and npm 145 | </a> 146 | . 147 | </p> 148 | </div> 149 | <div> 150 | <h3 className="text-xl font-semibold mb-4">Adopt the new Schema</h3> 151 | <div className="dark:bg-white/5 bg-black/10 rounded-lg p-4 mb-2"> 152 | <code className="text-sm font-mono "> 153 | pnpx @better-auth/cli migrate 154 | <br /> 155 | </code> 156 | </div> 157 | <p className="text-sm text-gray-400"> 158 | Ensure you have the latest{" "} 159 | <code className="text-xs dark:bg-white/5 bg-black/10 px-1 py-0.5 rounded"> 160 | schema required 161 | </code>{" "} 162 | by Better Auth. 163 | <code className="text-xs dark:bg-white/5 bg-black/10 px-1 py-0.5 rounded"> 164 | You can also 165 | </code>{" "} 166 | add them manually. Read the{" "} 167 | <a 168 | href="/docs/concepts/database#core-schema" 169 | className="underline" 170 | > 171 | Core Schema 172 | </a>{" "} 173 | for full instructions. 174 | </p> 175 | </div> 176 | <div> 177 | <h3 className="text-xl font-semibold mb-4"> 178 | Check out the change log, the new UI Builder, OpenAPI Docs, and 179 | more 180 | </h3> 181 | <p className="text-sm text-gray-400 mb-4"> 182 | We have some exciting new features and updates that you should 183 | check out. 184 | </p> 185 | <Link 186 | className="w-full" 187 | href="https://github.com/better-auth/better-auth" 188 | > 189 | <Button variant="outline" className="w-full justify-between"> 190 | <div className="flex items-center gap-2"> 191 | <GitHubLogoIcon fontSize={10} /> 192 | Star on GitHub 193 | </div> 194 | <ArrowRight className="w-4 h-4" /> 195 | </Button> 196 | </Link> 197 | <Link className="w-full" href="https://discord.gg/better-auth"> 198 | <Button 199 | variant="outline" 200 | className="w-full justify-between border-t-0" 201 | > 202 | <div className="flex items-center gap-2"> 203 | <DiscordLogoIcon /> 204 | Join Discord 205 | </div> 206 | <ArrowRight className="w-4 h-4" /> 207 | </Button> 208 | </Link> 209 | </div> 210 | </div> 211 | </div> 212 | </div> 213 | ); 214 | } 215 | 216 | function ChangelogItem({ 217 | version, 218 | date, 219 | changes, 220 | }: { 221 | version: string; 222 | date: string; 223 | changes: string[]; 224 | }) { 225 | return ( 226 | <div className="border-l-2 border-white/10 pl-6 relative"> 227 | <div className="absolute w-3 h-3 bg-white rounded-full -left-[7px] top-2" /> 228 | <div className="flex items-center gap-4 mb-4"> 229 | <h3 className="text-xl font-bold font-geist">{version}</h3> 230 | <span className="text-sm text-gray-400">{date}</span> 231 | </div> 232 | <ul className="space-y-3"> 233 | {changes.map((change, i) => ( 234 | <li key={i} className="text-gray-400"> 235 | {change} 236 | </li> 237 | ))} 238 | </ul> 239 | </div> 240 | ); 241 | } 242 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/oidc-provider/authorize.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "better-call"; 2 | import { getSessionFromCtx } from "../../api"; 3 | import type { AuthorizationQuery, OIDCOptions } from "./types"; 4 | import { generateRandomString } from "../../crypto"; 5 | import { getClient } from "./index"; 6 | import type { GenericEndpointContext } from "@better-auth/core"; 7 | 8 | function formatErrorURL(url: string, error: string, description: string) { 9 | return `${ 10 | url.includes("?") ? "&" : "?" 11 | }error=${error}&error_description=${description}`; 12 | } 13 | 14 | function getErrorURL( 15 | ctx: GenericEndpointContext, 16 | error: string, 17 | description: string, 18 | ) { 19 | const baseURL = 20 | ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`; 21 | const formattedURL = formatErrorURL(baseURL, error, description); 22 | return formattedURL; 23 | } 24 | 25 | export async function authorize( 26 | ctx: GenericEndpointContext, 27 | options: OIDCOptions, 28 | ) { 29 | const handleRedirect = (url: string) => { 30 | const fromFetch = ctx.request?.headers.get("sec-fetch-mode") === "cors"; 31 | if (fromFetch) { 32 | return ctx.json({ 33 | redirect: true, 34 | url, 35 | }); 36 | } else { 37 | throw ctx.redirect(url); 38 | } 39 | }; 40 | 41 | const opts = { 42 | codeExpiresIn: 600, 43 | defaultScope: "openid", 44 | ...options, 45 | scopes: [ 46 | "openid", 47 | "profile", 48 | "email", 49 | "offline_access", 50 | ...(options?.scopes || []), 51 | ], 52 | }; 53 | if (!ctx.request) { 54 | throw new APIError("UNAUTHORIZED", { 55 | error_description: "request not found", 56 | error: "invalid_request", 57 | }); 58 | } 59 | const session = await getSessionFromCtx(ctx); 60 | if (!session) { 61 | /** 62 | * If the user is not logged in, we need to redirect them to the 63 | * login page. 64 | */ 65 | await ctx.setSignedCookie( 66 | "oidc_login_prompt", 67 | JSON.stringify(ctx.query), 68 | ctx.context.secret, 69 | { 70 | maxAge: 600, 71 | path: "/", 72 | sameSite: "lax", 73 | }, 74 | ); 75 | const queryFromURL = ctx.request.url?.split("?")[1]!; 76 | return handleRedirect(`${options.loginPage}?${queryFromURL}`); 77 | } 78 | 79 | const query = ctx.query as AuthorizationQuery; 80 | if (!query.client_id) { 81 | const errorURL = getErrorURL( 82 | ctx, 83 | "invalid_client", 84 | "client_id is required", 85 | ); 86 | throw ctx.redirect(errorURL); 87 | } 88 | 89 | if (!query.response_type) { 90 | const errorURL = getErrorURL( 91 | ctx, 92 | "invalid_request", 93 | "response_type is required", 94 | ); 95 | throw ctx.redirect( 96 | getErrorURL(ctx, "invalid_request", "response_type is required"), 97 | ); 98 | } 99 | 100 | const client = await getClient( 101 | ctx.query.client_id, 102 | ctx.context.adapter, 103 | options.trustedClients || [], 104 | ); 105 | if (!client) { 106 | const errorURL = getErrorURL( 107 | ctx, 108 | "invalid_client", 109 | "client_id is required", 110 | ); 111 | throw ctx.redirect(errorURL); 112 | } 113 | const redirectURI = client.redirectURLs.find( 114 | (url) => url === ctx.query.redirect_uri, 115 | ); 116 | 117 | if (!redirectURI || !query.redirect_uri) { 118 | /** 119 | * show UI error here warning the user that the redirect URI is invalid 120 | */ 121 | throw new APIError("BAD_REQUEST", { 122 | message: "Invalid redirect URI", 123 | }); 124 | } 125 | if (client.disabled) { 126 | const errorURL = getErrorURL(ctx, "client_disabled", "client is disabled"); 127 | throw ctx.redirect(errorURL); 128 | } 129 | 130 | if (query.response_type !== "code") { 131 | const errorURL = getErrorURL( 132 | ctx, 133 | "unsupported_response_type", 134 | "unsupported response type", 135 | ); 136 | throw ctx.redirect(errorURL); 137 | } 138 | 139 | const requestScope = 140 | query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" "); 141 | const invalidScopes = requestScope.filter((scope) => { 142 | return !opts.scopes.includes(scope); 143 | }); 144 | if (invalidScopes.length) { 145 | return handleRedirect( 146 | formatErrorURL( 147 | query.redirect_uri, 148 | "invalid_scope", 149 | `The following scopes are invalid: ${invalidScopes.join(", ")}`, 150 | ), 151 | ); 152 | } 153 | 154 | if ( 155 | (!query.code_challenge || !query.code_challenge_method) && 156 | options.requirePKCE 157 | ) { 158 | return handleRedirect( 159 | formatErrorURL(query.redirect_uri, "invalid_request", "pkce is required"), 160 | ); 161 | } 162 | 163 | if (!query.code_challenge_method) { 164 | query.code_challenge_method = "plain"; 165 | } 166 | 167 | if ( 168 | ![ 169 | "s256", 170 | options.allowPlainCodeChallengeMethod ? "plain" : "s256", 171 | ].includes(query.code_challenge_method?.toLowerCase() || "") 172 | ) { 173 | return handleRedirect( 174 | formatErrorURL( 175 | query.redirect_uri, 176 | "invalid_request", 177 | "invalid code_challenge method", 178 | ), 179 | ); 180 | } 181 | 182 | const code = generateRandomString(32, "a-z", "A-Z", "0-9"); 183 | const codeExpiresInMs = opts.codeExpiresIn * 1000; 184 | const expiresAt = new Date(Date.now() + codeExpiresInMs); 185 | 186 | // Determine if consent is required 187 | // Consent is ALWAYS required unless: 188 | // 1. The client is trusted (skipConsent = true) 189 | // 2. The user has already consented and prompt is not "consent" 190 | const skipConsentForTrustedClient = client.skipConsent; 191 | const hasAlreadyConsented = await ctx.context.adapter 192 | .findOne<{ 193 | consentGiven: boolean; 194 | }>({ 195 | model: "oauthConsent", 196 | where: [ 197 | { 198 | field: "clientId", 199 | value: client.clientId, 200 | }, 201 | { 202 | field: "userId", 203 | value: session.user.id, 204 | }, 205 | ], 206 | }) 207 | .then((res) => !!res?.consentGiven); 208 | 209 | const requireConsent = 210 | !skipConsentForTrustedClient && 211 | (!hasAlreadyConsented || query.prompt === "consent"); 212 | 213 | try { 214 | /** 215 | * Save the code in the database 216 | */ 217 | await ctx.context.internalAdapter.createVerificationValue({ 218 | value: JSON.stringify({ 219 | clientId: client.clientId, 220 | redirectURI: query.redirect_uri, 221 | scope: requestScope, 222 | userId: session.user.id, 223 | authTime: new Date(session.session.createdAt).getTime(), 224 | /** 225 | * Consent is required per OIDC spec unless: 226 | * 1. Client is trusted (skipConsent = true) 227 | * 2. User has already consented (and prompt is not "consent") 228 | * 229 | * When consent is required, the code needs to be treated as a 230 | * consent request. Once the user consents, the code will be 231 | * updated with the actual authorization code. 232 | */ 233 | requireConsent, 234 | state: requireConsent ? query.state : null, 235 | codeChallenge: query.code_challenge, 236 | codeChallengeMethod: query.code_challenge_method, 237 | nonce: query.nonce, 238 | }), 239 | identifier: code, 240 | expiresAt, 241 | }); 242 | } catch (e) { 243 | return handleRedirect( 244 | formatErrorURL( 245 | query.redirect_uri, 246 | "server_error", 247 | "An error occurred while processing the request", 248 | ), 249 | ); 250 | } 251 | 252 | // If consent is not required, redirect with the code immediately 253 | if (!requireConsent) { 254 | const redirectURIWithCode = new URL(redirectURI); 255 | redirectURIWithCode.searchParams.set("code", code); 256 | redirectURIWithCode.searchParams.set("state", ctx.query.state); 257 | return handleRedirect(redirectURIWithCode.toString()); 258 | } 259 | 260 | // Consent is required - redirect to consent page or show consent HTML 261 | 262 | if (options?.consentPage) { 263 | // Set cookie to support cookie-based consent flows 264 | await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, { 265 | maxAge: 600, 266 | path: "/", 267 | sameSite: "lax", 268 | }); 269 | 270 | // Pass the consent code as a URL parameter to support URL-based consent flows 271 | const urlParams = new URLSearchParams(); 272 | urlParams.set("consent_code", code); 273 | urlParams.set("client_id", client.clientId); 274 | urlParams.set("scope", requestScope.join(" ")); 275 | const consentURI = `${options.consentPage}?${urlParams.toString()}`; 276 | 277 | return handleRedirect(consentURI); 278 | } 279 | const htmlFn = options?.getConsentHTML; 280 | 281 | if (!htmlFn) { 282 | throw new APIError("INTERNAL_SERVER_ERROR", { 283 | message: "No consent page provided", 284 | }); 285 | } 286 | 287 | return new Response( 288 | htmlFn({ 289 | scopes: requestScope, 290 | clientMetadata: client.metadata, 291 | clientIcon: client?.icon, 292 | clientId: client.clientId, 293 | clientName: client.name, 294 | code, 295 | }), 296 | { 297 | headers: { 298 | "content-type": "text/html", 299 | }, 300 | }, 301 | ); 302 | } 303 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { BetterAuthError } from "@better-auth/core/error"; 2 | import type { BetterAuthOptions } from "@better-auth/core"; 3 | import { 4 | createAdapterFactory, 5 | type AdapterFactoryOptions, 6 | type AdapterFactoryCustomizeAdapterCreator, 7 | } from "../adapter-factory"; 8 | import type { 9 | DBAdapterDebugLogOption, 10 | DBAdapter, 11 | Where, 12 | } from "@better-auth/core/db/adapter"; 13 | 14 | export interface PrismaConfig { 15 | /** 16 | * Database provider. 17 | */ 18 | provider: 19 | | "sqlite" 20 | | "cockroachdb" 21 | | "mysql" 22 | | "postgresql" 23 | | "sqlserver" 24 | | "mongodb"; 25 | 26 | /** 27 | * Enable debug logs for the adapter 28 | * 29 | * @default false 30 | */ 31 | debugLogs?: DBAdapterDebugLogOption; 32 | 33 | /** 34 | * Use plural table names 35 | * 36 | * @default false 37 | */ 38 | usePlural?: boolean; 39 | 40 | /** 41 | * Whether to execute multiple operations in a transaction. 42 | * 43 | * If the database doesn't support transactions, 44 | * set this to `false` and operations will be executed sequentially. 45 | * @default false 46 | */ 47 | transaction?: boolean; 48 | } 49 | 50 | interface PrismaClient {} 51 | 52 | type PrismaClientInternal = { 53 | $transaction: ( 54 | callback: (db: PrismaClient) => Promise<any> | any, 55 | ) => Promise<any>; 56 | } & { 57 | [model: string]: { 58 | create: (data: any) => Promise<any>; 59 | findFirst: (data: any) => Promise<any>; 60 | findMany: (data: any) => Promise<any>; 61 | update: (data: any) => Promise<any>; 62 | updateMany: (data: any) => Promise<any>; 63 | delete: (data: any) => Promise<any>; 64 | [key: string]: any; 65 | }; 66 | }; 67 | 68 | export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { 69 | let lazyOptions: BetterAuthOptions | null = null; 70 | const createCustomAdapter = 71 | (prisma: PrismaClient): AdapterFactoryCustomizeAdapterCreator => 72 | ({ getFieldName }) => { 73 | const db = prisma as PrismaClientInternal; 74 | 75 | const convertSelect = (select?: string[], model?: string) => { 76 | if (!select || !model) return undefined; 77 | return select.reduce((prev, cur) => { 78 | return { 79 | ...prev, 80 | [getFieldName({ model, field: cur })]: true, 81 | }; 82 | }, {}); 83 | }; 84 | function operatorToPrismaOperator(operator: string) { 85 | switch (operator) { 86 | case "starts_with": 87 | return "startsWith"; 88 | case "ends_with": 89 | return "endsWith"; 90 | case "ne": 91 | return "not"; 92 | case "not_in": 93 | return "notIn"; 94 | default: 95 | return operator; 96 | } 97 | } 98 | const convertWhereClause = (model: string, where?: Where[]) => { 99 | if (!where || !where.length) return {}; 100 | if (where.length === 1) { 101 | const w = where[0]!; 102 | if (!w) { 103 | return; 104 | } 105 | return { 106 | [getFieldName({ model, field: w.field })]: 107 | w.operator === "eq" || !w.operator 108 | ? w.value 109 | : { 110 | [operatorToPrismaOperator(w.operator)]: w.value, 111 | }, 112 | }; 113 | } 114 | const and = where.filter((w) => w.connector === "AND" || !w.connector); 115 | const or = where.filter((w) => w.connector === "OR"); 116 | const andClause = and.map((w) => { 117 | return { 118 | [getFieldName({ model, field: w.field })]: 119 | w.operator === "eq" || !w.operator 120 | ? w.value 121 | : { 122 | [operatorToPrismaOperator(w.operator)]: w.value, 123 | }, 124 | }; 125 | }); 126 | const orClause = or.map((w) => { 127 | return { 128 | [getFieldName({ model, field: w.field })]: 129 | w.operator === "eq" || !w.operator 130 | ? w.value 131 | : { 132 | [operatorToPrismaOperator(w.operator)]: w.value, 133 | }, 134 | }; 135 | }); 136 | 137 | return { 138 | ...(andClause.length ? { AND: andClause } : {}), 139 | ...(orClause.length ? { OR: orClause } : {}), 140 | }; 141 | }; 142 | 143 | return { 144 | async create({ model, data: values, select }) { 145 | if (!db[model]) { 146 | throw new BetterAuthError( 147 | `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, 148 | ); 149 | } 150 | return await db[model]!.create({ 151 | data: values, 152 | select: convertSelect(select, model), 153 | }); 154 | }, 155 | async findOne({ model, where, select }) { 156 | const whereClause = convertWhereClause(model, where); 157 | if (!db[model]) { 158 | throw new BetterAuthError( 159 | `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, 160 | ); 161 | } 162 | return await db[model]!.findFirst({ 163 | where: whereClause, 164 | select: convertSelect(select, model), 165 | }); 166 | }, 167 | async findMany({ model, where, limit, offset, sortBy }) { 168 | const whereClause = convertWhereClause(model, where); 169 | if (!db[model]) { 170 | throw new BetterAuthError( 171 | `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, 172 | ); 173 | } 174 | 175 | return (await db[model]!.findMany({ 176 | where: whereClause, 177 | take: limit || 100, 178 | skip: offset || 0, 179 | ...(sortBy?.field 180 | ? { 181 | orderBy: { 182 | [getFieldName({ model, field: sortBy.field })]: 183 | sortBy.direction === "desc" ? "desc" : "asc", 184 | }, 185 | } 186 | : {}), 187 | })) as any[]; 188 | }, 189 | async count({ model, where }) { 190 | const whereClause = convertWhereClause(model, where); 191 | if (!db[model]) { 192 | throw new BetterAuthError( 193 | `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, 194 | ); 195 | } 196 | return await db[model]!.count({ 197 | where: whereClause, 198 | }); 199 | }, 200 | async update({ model, where, update }) { 201 | if (!db[model]) { 202 | throw new BetterAuthError( 203 | `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`, 204 | ); 205 | } 206 | const whereClause = convertWhereClause(model, where); 207 | return await db[model]!.update({ 208 | where: whereClause, 209 | data: update, 210 | }); 211 | }, 212 | async updateMany({ model, where, update }) { 213 | const whereClause = convertWhereClause(model, where); 214 | const result = await db[model]!.updateMany({ 215 | where: whereClause, 216 | data: update, 217 | }); 218 | return result ? (result.count as number) : 0; 219 | }, 220 | async delete({ model, where }) { 221 | const whereClause = convertWhereClause(model, where); 222 | try { 223 | await db[model]!.delete({ 224 | where: whereClause, 225 | }); 226 | } catch (e: any) { 227 | // If the record doesn't exist, we don't want to throw an error 228 | if (e?.meta?.cause === "Record to delete does not exist.") return; 229 | // otherwise if it's an unknown error, we want to just log it for debugging. 230 | console.log(e); 231 | } 232 | }, 233 | async deleteMany({ model, where }) { 234 | const whereClause = convertWhereClause(model, where); 235 | const result = await db[model]!.deleteMany({ 236 | where: whereClause, 237 | }); 238 | return result ? (result.count as number) : 0; 239 | }, 240 | options: config, 241 | }; 242 | }; 243 | 244 | let adapterOptions: AdapterFactoryOptions | null = null; 245 | adapterOptions = { 246 | config: { 247 | adapterId: "prisma", 248 | adapterName: "Prisma Adapter", 249 | usePlural: config.usePlural ?? false, 250 | debugLogs: config.debugLogs ?? false, 251 | transaction: 252 | (config.transaction ?? false) 253 | ? (cb) => 254 | (prisma as PrismaClientInternal).$transaction((tx) => { 255 | const adapter = createAdapterFactory({ 256 | config: adapterOptions!.config, 257 | adapter: createCustomAdapter(tx), 258 | })(lazyOptions!); 259 | return cb(adapter); 260 | }) 261 | : false, 262 | }, 263 | adapter: createCustomAdapter(prisma), 264 | }; 265 | 266 | const adapter = createAdapterFactory(adapterOptions); 267 | return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => { 268 | lazyOptions = options; 269 | return adapter(options); 270 | }; 271 | }; 272 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import { getTestInstance } from "../../test-utils/test-instance"; 3 | import type { RateLimit } from "../../types"; 4 | 5 | describe( 6 | "rate-limiter", 7 | { 8 | timeout: 10000, 9 | }, 10 | async () => { 11 | const { client, testUser } = await getTestInstance({ 12 | rateLimit: { 13 | enabled: true, 14 | window: 10, 15 | max: 20, 16 | }, 17 | }); 18 | 19 | it("should return 429 after 3 request for sign-in", async () => { 20 | for (let i = 0; i < 5; i++) { 21 | const response = await client.signIn.email({ 22 | email: testUser.email, 23 | password: testUser.password, 24 | }); 25 | if (i >= 3) { 26 | expect(response.error?.status).toBe(429); 27 | } else { 28 | expect(response.error).toBeNull(); 29 | } 30 | } 31 | }); 32 | 33 | it("should reset the limit after the window period", async () => { 34 | vi.useFakeTimers(); 35 | vi.advanceTimersByTime(11000); 36 | for (let i = 0; i < 5; i++) { 37 | const res = await client.signIn.email({ 38 | email: testUser.email, 39 | password: testUser.password, 40 | }); 41 | if (i >= 3) { 42 | expect(res.error?.status).toBe(429); 43 | } else { 44 | expect(res.error).toBeNull(); 45 | } 46 | } 47 | }); 48 | 49 | it("should respond the correct retry-after header", async () => { 50 | vi.useFakeTimers(); 51 | vi.advanceTimersByTime(3000); 52 | let retryAfter = ""; 53 | await client.signIn.email( 54 | { 55 | email: testUser.email, 56 | password: testUser.password, 57 | }, 58 | { 59 | onError(context) { 60 | retryAfter = context.response.headers.get("X-Retry-After") ?? ""; 61 | }, 62 | }, 63 | ); 64 | expect(retryAfter).toBe("7"); 65 | }); 66 | 67 | it("should rate limit based on the path", async () => { 68 | const signInRes = await client.signIn.email({ 69 | email: testUser.email, 70 | password: testUser.password, 71 | }); 72 | expect(signInRes.error?.status).toBe(429); 73 | 74 | const signUpRes = await client.signUp.email({ 75 | email: "[email protected]", 76 | password: testUser.password, 77 | name: "test", 78 | }); 79 | expect(signUpRes.error).toBeNull(); 80 | }); 81 | 82 | it("non-special-rules limits", async () => { 83 | for (let i = 0; i < 25; i++) { 84 | const response = await client.getSession(); 85 | expect(response.error?.status).toBe(i >= 20 ? 429 : undefined); 86 | } 87 | }); 88 | 89 | it("query params should be ignored", async () => { 90 | for (let i = 0; i < 25; i++) { 91 | const response = await client.listSessions({ 92 | fetchOptions: { 93 | query: { 94 | "test-query": Math.random().toString(), 95 | }, 96 | }, 97 | }); 98 | 99 | if (i >= 20) { 100 | expect(response.error?.status).toBe(429); 101 | } else { 102 | expect(response.error?.status).toBe(401); 103 | } 104 | } 105 | }); 106 | }, 107 | ); 108 | 109 | describe("custom rate limiting storage", async () => { 110 | let store = new Map<string, string>(); 111 | const expirationMap = new Map<string, number>(); 112 | const { client, testUser } = await getTestInstance({ 113 | rateLimit: { 114 | enabled: true, 115 | }, 116 | secondaryStorage: { 117 | set(key, value, ttl) { 118 | store.set(key, value); 119 | if (ttl) expirationMap.set(key, ttl); 120 | }, 121 | get(key) { 122 | return store.get(key) || null; 123 | }, 124 | delete(key) { 125 | store.delete(key); 126 | expirationMap.delete(key); 127 | }, 128 | }, 129 | }); 130 | 131 | it("should use custom storage", async () => { 132 | await client.getSession(); 133 | expect(store.size).toBe(3); 134 | let lastRequest = Date.now(); 135 | for (let i = 0; i < 4; i++) { 136 | const response = await client.signIn.email({ 137 | email: testUser.email, 138 | password: testUser.password, 139 | }); 140 | const rateLimitData: RateLimit = JSON.parse( 141 | store.get("127.0.0.1/sign-in/email") ?? "{}", 142 | ); 143 | expect(rateLimitData.lastRequest).toBeGreaterThanOrEqual(lastRequest); 144 | lastRequest = rateLimitData.lastRequest; 145 | if (i >= 3) { 146 | expect(response.error?.status).toBe(429); 147 | expect(rateLimitData.count).toBe(3); 148 | } else { 149 | expect(response.error).toBeNull(); 150 | expect(rateLimitData.count).toBe(i + 1); 151 | } 152 | const rateLimitExp = expirationMap.get("127.0.0.1/sign-in/email"); 153 | expect(rateLimitExp).toBe(10); 154 | } 155 | }); 156 | }); 157 | 158 | describe("should work with custom rules", async () => { 159 | const { client, testUser } = await getTestInstance({ 160 | rateLimit: { 161 | enabled: true, 162 | storage: "database", 163 | customRules: { 164 | "/sign-in/*": { 165 | window: 10, 166 | max: 2, 167 | }, 168 | "/sign-up/email": { 169 | window: 10, 170 | max: 3, 171 | }, 172 | "/get-session": false, 173 | }, 174 | }, 175 | }); 176 | 177 | it("should use custom rules", async () => { 178 | for (let i = 0; i < 4; i++) { 179 | const response = await client.signIn.email({ 180 | email: testUser.email, 181 | password: testUser.password, 182 | }); 183 | if (i >= 2) { 184 | expect(response.error?.status).toBe(429); 185 | } else { 186 | expect(response.error).toBeNull(); 187 | } 188 | } 189 | 190 | for (let i = 0; i < 5; i++) { 191 | const response = await client.signUp.email({ 192 | email: `${Math.random()}@test.com`, 193 | password: testUser.password, 194 | name: "test", 195 | }); 196 | if (i >= 3) { 197 | expect(response.error?.status).toBe(429); 198 | } else { 199 | expect(response.error).toBeNull(); 200 | } 201 | } 202 | }); 203 | 204 | it("should use default rules if custom rules are not defined", async () => { 205 | for (let i = 0; i < 5; i++) { 206 | const response = await client.getSession(); 207 | if (i >= 20) { 208 | expect(response.error?.status).toBe(429); 209 | } else { 210 | expect(response.error).toBeNull(); 211 | } 212 | } 213 | }); 214 | 215 | it("should not rate limit if custom rule is false", async () => { 216 | let i = 0; 217 | let response = null; 218 | for (; i < 110; i++) { 219 | response = await client.getSession().then((res) => res.error); 220 | } 221 | expect(response).toBeNull(); 222 | expect(i).toBe(110); 223 | }); 224 | }); 225 | 226 | describe("should work in development/test environment", () => { 227 | const LOCALHOST_IP = "127.0.0.1"; 228 | const REQUEST_PATH = "/sign-in/email"; 229 | 230 | let originalNodeEnv: string | undefined; 231 | beforeEach(() => { 232 | originalNodeEnv = process.env.NODE_ENV; 233 | }); 234 | afterEach(() => { 235 | process.env.NODE_ENV = originalNodeEnv; 236 | vi.unstubAllEnvs(); 237 | }); 238 | 239 | it("should work in development environment", async () => { 240 | vi.stubEnv("NODE_ENV", "development"); 241 | 242 | const store = new Map<string, string>(); 243 | const { client, testUser } = await getTestInstance({ 244 | rateLimit: { 245 | enabled: true, 246 | window: 10, 247 | max: 3, 248 | }, 249 | secondaryStorage: { 250 | set(key, value) { 251 | store.set(key, value); 252 | }, 253 | get(key) { 254 | return store.get(key) || null; 255 | }, 256 | delete(key) { 257 | store.delete(key); 258 | }, 259 | }, 260 | }); 261 | 262 | for (let i = 0; i < 4; i++) { 263 | const response = await client.signIn.email({ 264 | email: testUser.email, 265 | password: testUser.password, 266 | }); 267 | 268 | if (i >= 3) { 269 | expect(response.error?.status).toBe(429); 270 | } else { 271 | expect(response.error).toBeNull(); 272 | } 273 | } 274 | 275 | const signInKeys = Array.from(store.keys()).filter((key) => 276 | key.endsWith(REQUEST_PATH), 277 | ); 278 | 279 | expect(signInKeys.length).toBeGreaterThan(0); 280 | expect(signInKeys[0]).toBe(`${LOCALHOST_IP}${REQUEST_PATH}`); 281 | }); 282 | 283 | it("should work in test environment", async () => { 284 | vi.stubEnv("NODE_ENV", "test"); 285 | 286 | const store = new Map<string, string>(); 287 | const { client, testUser } = await getTestInstance({ 288 | rateLimit: { 289 | enabled: true, 290 | window: 10, 291 | max: 3, 292 | }, 293 | secondaryStorage: { 294 | set(key, value) { 295 | store.set(key, value); 296 | }, 297 | get(key) { 298 | return store.get(key) || null; 299 | }, 300 | delete(key) { 301 | store.delete(key); 302 | }, 303 | }, 304 | }); 305 | 306 | for (let i = 0; i < 4; i++) { 307 | const response = await client.signIn.email({ 308 | email: testUser.email, 309 | password: testUser.password, 310 | }); 311 | 312 | if (i >= 3) { 313 | expect(response.error?.status).toBe(429); 314 | } else { 315 | expect(response.error).toBeNull(); 316 | } 317 | } 318 | 319 | const signInKeys = Array.from(store.keys()).filter((key) => 320 | key.endsWith(REQUEST_PATH), 321 | ); 322 | 323 | expect(signInKeys.length).toBeGreaterThan(0); 324 | expect(signInKeys[0]).toBe(`${LOCALHOST_IP}${REQUEST_PATH}`); 325 | }); 326 | }); 327 | ```