This is page 18 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-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── 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 │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/api/rate-limiter/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { RateLimit } from "../../types"; 2 | import { safeJSONParse } from "../../utils/json"; 3 | import { getIp } from "../../utils/get-request-ip"; 4 | import { wildcardMatch } from "../../utils/wildcard"; 5 | import type { AuthContext } from "@better-auth/core"; 6 | 7 | function shouldRateLimit( 8 | max: number, 9 | window: number, 10 | rateLimitData: RateLimit, 11 | ) { 12 | const now = Date.now(); 13 | const windowInMs = window * 1000; 14 | const timeSinceLastRequest = now - rateLimitData.lastRequest; 15 | return timeSinceLastRequest < windowInMs && rateLimitData.count >= max; 16 | } 17 | 18 | function rateLimitResponse(retryAfter: number) { 19 | return new Response( 20 | JSON.stringify({ 21 | message: "Too many requests. Please try again later.", 22 | }), 23 | { 24 | status: 429, 25 | statusText: "Too Many Requests", 26 | headers: { 27 | "X-Retry-After": retryAfter.toString(), 28 | }, 29 | }, 30 | ); 31 | } 32 | 33 | function getRetryAfter(lastRequest: number, window: number) { 34 | const now = Date.now(); 35 | const windowInMs = window * 1000; 36 | return Math.ceil((lastRequest + windowInMs - now) / 1000); 37 | } 38 | 39 | function createDBStorage(ctx: AuthContext) { 40 | const model = "rateLimit"; 41 | const db = ctx.adapter; 42 | return { 43 | get: async (key: string) => { 44 | const res = await db.findMany<RateLimit>({ 45 | model, 46 | where: [{ field: "key", value: key }], 47 | }); 48 | const data = res[0]; 49 | 50 | if (typeof data?.lastRequest === "bigint") { 51 | data.lastRequest = Number(data.lastRequest); 52 | } 53 | 54 | return data; 55 | }, 56 | set: async (key: string, value: RateLimit, _update?: boolean) => { 57 | try { 58 | if (_update) { 59 | await db.updateMany({ 60 | model, 61 | where: [{ field: "key", value: key }], 62 | update: { 63 | count: value.count, 64 | lastRequest: value.lastRequest, 65 | }, 66 | }); 67 | } else { 68 | await db.create({ 69 | model, 70 | data: { 71 | key, 72 | count: value.count, 73 | lastRequest: value.lastRequest, 74 | }, 75 | }); 76 | } 77 | } catch (e) { 78 | ctx.logger.error("Error setting rate limit", e); 79 | } 80 | }, 81 | }; 82 | } 83 | 84 | const memory = new Map<string, RateLimit>(); 85 | export function getRateLimitStorage( 86 | ctx: AuthContext, 87 | rateLimitSettings?: { 88 | window?: number; 89 | }, 90 | ) { 91 | if (ctx.options.rateLimit?.customStorage) { 92 | return ctx.options.rateLimit.customStorage; 93 | } 94 | const storage = ctx.rateLimit.storage; 95 | if (storage === "secondary-storage") { 96 | return { 97 | get: async (key: string) => { 98 | const data = await ctx.options.secondaryStorage?.get(key); 99 | return data ? safeJSONParse<RateLimit>(data) : undefined; 100 | }, 101 | set: async (key: string, value: RateLimit, _update?: boolean) => { 102 | const ttl = 103 | rateLimitSettings?.window ?? ctx.options.rateLimit?.window ?? 10; 104 | await ctx.options.secondaryStorage?.set?.( 105 | key, 106 | JSON.stringify(value), 107 | ttl, 108 | ); 109 | }, 110 | }; 111 | } else if (storage === "memory") { 112 | return { 113 | async get(key: string) { 114 | return memory.get(key); 115 | }, 116 | async set(key: string, value: RateLimit, _update?: boolean) { 117 | memory.set(key, value); 118 | }, 119 | }; 120 | } 121 | return createDBStorage(ctx); 122 | } 123 | 124 | export async function onRequestRateLimit(req: Request, ctx: AuthContext) { 125 | if (!ctx.rateLimit.enabled) { 126 | return; 127 | } 128 | const path = new URL(req.url).pathname.replace( 129 | ctx.options.basePath || "/api/auth", 130 | "", 131 | ); 132 | let window = ctx.rateLimit.window; 133 | let max = ctx.rateLimit.max; 134 | const ip = getIp(req, ctx.options); 135 | if (!ip) { 136 | return; 137 | } 138 | const key = ip + path; 139 | const specialRules = getDefaultSpecialRules(); 140 | const specialRule = specialRules.find((rule) => rule.pathMatcher(path)); 141 | 142 | if (specialRule) { 143 | window = specialRule.window; 144 | max = specialRule.max; 145 | } 146 | 147 | for (const plugin of ctx.options.plugins || []) { 148 | if (plugin.rateLimit) { 149 | const matchedRule = plugin.rateLimit.find((rule) => 150 | rule.pathMatcher(path), 151 | ); 152 | if (matchedRule) { 153 | window = matchedRule.window; 154 | max = matchedRule.max; 155 | break; 156 | } 157 | } 158 | } 159 | 160 | if (ctx.rateLimit.customRules) { 161 | const _path = Object.keys(ctx.rateLimit.customRules).find((p) => { 162 | if (p.includes("*")) { 163 | const isMatch = wildcardMatch(p)(path); 164 | return isMatch; 165 | } 166 | return p === path; 167 | }); 168 | if (_path) { 169 | const customRule = ctx.rateLimit.customRules[_path]; 170 | const resolved = 171 | typeof customRule === "function" ? await customRule(req) : customRule; 172 | if (resolved) { 173 | window = resolved.window; 174 | max = resolved.max; 175 | } 176 | 177 | if (resolved === false) { 178 | return; 179 | } 180 | } 181 | } 182 | 183 | const storage = getRateLimitStorage(ctx, { 184 | window, 185 | }); 186 | const data = await storage.get(key); 187 | const now = Date.now(); 188 | 189 | if (!data) { 190 | await storage.set(key, { 191 | key, 192 | count: 1, 193 | lastRequest: now, 194 | }); 195 | } else { 196 | const timeSinceLastRequest = now - data.lastRequest; 197 | 198 | if (shouldRateLimit(max, window, data)) { 199 | const retryAfter = getRetryAfter(data.lastRequest, window); 200 | return rateLimitResponse(retryAfter); 201 | } else if (timeSinceLastRequest > window * 1000) { 202 | // Reset the count if the window has passed since the last request 203 | await storage.set( 204 | key, 205 | { 206 | ...data, 207 | count: 1, 208 | lastRequest: now, 209 | }, 210 | true, 211 | ); 212 | } else { 213 | await storage.set( 214 | key, 215 | { 216 | ...data, 217 | count: data.count + 1, 218 | lastRequest: now, 219 | }, 220 | true, 221 | ); 222 | } 223 | } 224 | } 225 | 226 | function getDefaultSpecialRules() { 227 | const specialRules = [ 228 | { 229 | pathMatcher(path: string) { 230 | return ( 231 | path.startsWith("/sign-in") || 232 | path.startsWith("/sign-up") || 233 | path.startsWith("/change-password") || 234 | path.startsWith("/change-email") 235 | ); 236 | }, 237 | window: 10, 238 | max: 3, 239 | }, 240 | ]; 241 | return specialRules; 242 | } 243 | ``` -------------------------------------------------------------------------------- /docs/app/changelogs/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { changelogs } 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 ChangelogPage, { Glow } from "../_components/default-changelog"; 19 | import { IconLink } from "../_components/changelog-layout"; 20 | import { XIcon } from "../_components/icons"; 21 | import { StarField } from "../_components/stat-field"; 22 | import { GridPatterns } from "../_components/grid-pattern"; 23 | import { Callout } from "@/components/ui/callout"; 24 | 25 | const metaTitle = "Changelogs"; 26 | const metaDescription = "Latest changes , fixes and updates."; 27 | const ogImage = "https://better-auth.com/release-og/changelog-og.png"; 28 | 29 | export default async function Page({ 30 | params, 31 | }: { 32 | params: Promise<{ slug?: string[] }>; 33 | }) { 34 | const { slug } = await params; 35 | const page = changelogs.getPage(slug); 36 | if (!slug) { 37 | return <ChangelogPage />; 38 | } 39 | if (!page) { 40 | notFound(); 41 | } 42 | const MDX = page.data?.body; 43 | const toc = page.data?.toc; 44 | const { title, description, date } = page.data; 45 | return ( 46 | <div className="md:grid md:grid-cols-2 items-start"> 47 | <div className="bg-gradient-to-tr hidden md:block overflow-hidden px-12 py-24 md:py-0 -mt-[100px] md:h-dvh relative md:sticky top-0 from-transparent dark:via-stone-950/5 via-stone-100/30 to-stone-200/20 dark:to-transparent/10"> 48 | <StarField className="top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2" /> 49 | <Glow /> 50 | <GridPatterns /> 51 | <div className="z-20 flex flex-col md:justify-center max-w-xl mx-auto h-full"> 52 | <div className="mt-14 mb-2 text-gray-600 dark:text-gray-300 flex items-center gap-x-1"> 53 | <p className="text-[12px] uppercase font-mono"> 54 | {formatDate(date)} 55 | </p> 56 | </div> 57 | <h1 className=" font-sans mb-2 font-semibold tracking-tighter text-5xl"> 58 | {title}{" "} 59 | </h1> 60 | <p className="text-sm text-gray-600 mb-2 dark:text-gray-300"> 61 | {description} 62 | </p> 63 | <hr className="mt-4" /> 64 | <p className="absolute bottom-10 text-[0.8125rem]/6 text-gray-500"> 65 | <IconLink href="https://x.com/better_auth" icon={XIcon} compact> 66 | BETTER-AUTH. 67 | </IconLink> 68 | </p> 69 | </div> 70 | </div> 71 | <div className="px-4 relative md:px-8 pb-12 md:py-12"> 72 | <div className="absolute top-0 left-0 h-full -translate-x-full w-px bg-gradient-to-b from-black/5 dark:from-white/10 via-black/3 dark:via-white/5 to-transparent"></div> 73 | <div className="prose pt-8 md:pt-0"> 74 | <MDX 75 | components={{ 76 | ...defaultMdxComponents, 77 | Link: ({ 78 | className, 79 | ...props 80 | }: React.ComponentProps<typeof Link>) => ( 81 | <Link 82 | className={cn( 83 | "font-medium underline underline-offset-4", 84 | className, 85 | )} 86 | {...props} 87 | /> 88 | ), 89 | Step, 90 | Steps, 91 | File, 92 | Folder, 93 | Files, 94 | Tab, 95 | Tabs, 96 | Pre: Pre, 97 | GenerateSecret, 98 | AnimatePresence, 99 | TypeTable, 100 | Features, 101 | ForkButton, 102 | DatabaseTable, 103 | Accordion, 104 | Accordions, 105 | Callout: ({ 106 | children, 107 | type, 108 | ...props 109 | }: { 110 | children: React.ReactNode; 111 | type?: "info" | "warn" | "error" | "success" | "warning"; 112 | [key: string]: any; 113 | }) => ( 114 | <Callout type={type} {...props}> 115 | {children} 116 | </Callout> 117 | ), 118 | }} 119 | /> 120 | </div> 121 | </div> 122 | </div> 123 | ); 124 | } 125 | 126 | export async function generateMetadata({ 127 | params, 128 | }: { 129 | params: Promise<{ slug?: string[] }>; 130 | }) { 131 | const { slug } = await params; 132 | if (!slug) { 133 | return { 134 | metadataBase: new URL("https://better-auth.com/changelogs"), 135 | title: metaTitle, 136 | description: metaDescription, 137 | openGraph: { 138 | title: metaTitle, 139 | description: metaDescription, 140 | images: [ 141 | { 142 | url: ogImage, 143 | }, 144 | ], 145 | url: "https://better-auth.com/changelogs", 146 | }, 147 | twitter: { 148 | card: "summary_large_image", 149 | title: metaTitle, 150 | description: metaDescription, 151 | images: [ogImage], 152 | }, 153 | }; 154 | } 155 | const page = changelogs.getPage(slug); 156 | if (page == null) notFound(); 157 | const baseUrl = process.env.NEXT_PUBLIC_URL || process.env.VERCEL_URL; 158 | const url = new URL(`${baseUrl}/release-og/${slug.join("")}.png`); 159 | const { title, description } = page.data; 160 | 161 | return { 162 | title, 163 | description, 164 | openGraph: { 165 | title, 166 | description, 167 | type: "website", 168 | url: absoluteUrl(`changelogs/${slug.join("")}`), 169 | images: [ 170 | { 171 | url: url.toString(), 172 | width: 1200, 173 | height: 630, 174 | alt: title, 175 | }, 176 | ], 177 | }, 178 | twitter: { 179 | card: "summary_large_image", 180 | title, 181 | description, 182 | images: [url.toString()], 183 | }, 184 | }; 185 | } 186 | 187 | export function generateStaticParams() { 188 | return changelogs.generateParams(); 189 | } 190 | ``` -------------------------------------------------------------------------------- /docs/app/docs/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { source } from "@/lib/source"; 2 | import { DocsPage, DocsBody, DocsTitle } from "@/components/docs/page"; 3 | import { notFound } from "next/navigation"; 4 | import { absoluteUrl } from "@/lib/utils"; 5 | import DatabaseTable from "@/components/mdx/database-tables"; 6 | import { cn } from "@/lib/utils"; 7 | import { Step, Steps } from "fumadocs-ui/components/steps"; 8 | import { Tab, Tabs } from "fumadocs-ui/components/tabs"; 9 | import { GenerateSecret } from "@/components/generate-secret"; 10 | import { AnimatePresence } from "@/components/ui/fade-in"; 11 | import { TypeTable } from "fumadocs-ui/components/type-table"; 12 | import { Features } from "@/components/blocks/features"; 13 | import { ForkButton } from "@/components/fork-button"; 14 | import Link from "next/link"; 15 | import defaultMdxComponents from "fumadocs-ui/mdx"; 16 | import { 17 | CodeBlock, 18 | Pre, 19 | CodeBlockTab, 20 | CodeBlockTabsList, 21 | CodeBlockTabs, 22 | } from "@/components/ui/code-block"; 23 | import { File, Folder, Files } from "fumadocs-ui/components/files"; 24 | import { AutoTypeTable } from "fumadocs-typescript/ui"; 25 | import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; 26 | import { Endpoint } from "@/components/endpoint"; 27 | import { DividerText } from "@/components/divider-text"; 28 | import { APIMethod } from "@/components/api-method"; 29 | import { LLMCopyButton, ViewOptions } from "./page.client"; 30 | import { GenerateAppleJwt } from "@/components/generate-apple-jwt"; 31 | import { Callout } from "@/components/ui/callout"; 32 | import { AddToCursor } from "@/components/mdx/add-to-cursor"; 33 | export default async function Page({ 34 | params, 35 | }: { 36 | params: Promise<{ slug?: string[] }>; 37 | }) { 38 | const { slug } = await params; 39 | const page = source.getPage(slug); 40 | 41 | if (!page) { 42 | notFound(); 43 | } 44 | 45 | const MDX = page.data.body; 46 | const avoidLLMHeader = ["Introduction", "Comparison"]; 47 | return ( 48 | <DocsPage 49 | toc={page.data.toc} 50 | full={page.data.full} 51 | editOnGithub={{ 52 | owner: "better-auth", 53 | repo: "better-auth", 54 | sha: process.env.VERCEL_GIT_COMMIT_SHA || "main", 55 | path: `/docs/content/docs/${page.path}`, 56 | }} 57 | tableOfContent={{ 58 | header: <div className="w-10 h-4"></div>, 59 | }} 60 | > 61 | <DocsTitle>{page.data.title}</DocsTitle> 62 | {!avoidLLMHeader.includes(page.data.title) && ( 63 | <div className="flex flex-row gap-2 items-center pb-3 border-b"> 64 | <LLMCopyButton /> 65 | <ViewOptions 66 | markdownUrl={`${page.url}.mdx`} 67 | githubUrl={`https://github.com/better-auth/better-auth/blob/main/docs/content/docs/${page.file.path}`} 68 | /> 69 | </div> 70 | )} 71 | <DocsBody> 72 | <MDX 73 | components={{ 74 | ...defaultMdxComponents, 75 | CodeBlockTabs: (props) => { 76 | return ( 77 | <CodeBlockTabs 78 | {...props} 79 | className="p-0 border-0 rounded-lg bg-fd-secondary" 80 | > 81 | <div {...props}>{props.children}</div> 82 | </CodeBlockTabs> 83 | ); 84 | }, 85 | CodeBlockTabsList: (props) => { 86 | return ( 87 | <CodeBlockTabsList 88 | {...props} 89 | className="pb-0 my-0 rounded-lg bg-fd-secondary" 90 | /> 91 | ); 92 | }, 93 | CodeBlockTab: (props) => { 94 | return <CodeBlockTab {...props} className="p-0 m-0 rounded-lg" />; 95 | }, 96 | pre: (props) => { 97 | return ( 98 | <CodeBlock className="rounded-xl bg-fd-muted" {...props}> 99 | <div style={{ minWidth: "100%", display: "table" }}> 100 | <Pre className="px-0 py-3 bg-fd-muted focus-visible:outline-none"> 101 | {props.children} 102 | </Pre> 103 | </div> 104 | </CodeBlock> 105 | ); 106 | }, 107 | Link: ({ 108 | className, 109 | ...props 110 | }: React.ComponentProps<typeof Link>) => ( 111 | <Link 112 | className={cn( 113 | "font-medium underline underline-offset-4", 114 | className, 115 | )} 116 | {...props} 117 | /> 118 | ), 119 | Step, 120 | Steps, 121 | File, 122 | Folder, 123 | Files, 124 | Tab, 125 | Tabs, 126 | AutoTypeTable, 127 | GenerateSecret, 128 | GenerateAppleJwt, 129 | AnimatePresence, 130 | TypeTable, 131 | Features, 132 | ForkButton, 133 | AddToCursor, 134 | DatabaseTable, 135 | Accordion, 136 | Accordions, 137 | Endpoint, 138 | APIMethod, 139 | Callout: ({ 140 | children, 141 | type, 142 | ...props 143 | }: { 144 | children: React.ReactNode; 145 | type?: "info" | "warn" | "error" | "success" | "warning"; 146 | [key: string]: any; 147 | }) => ( 148 | <Callout type={type} {...props}> 149 | {children} 150 | </Callout> 151 | ), 152 | DividerText, 153 | iframe: (props) => ( 154 | <iframe {...props} className="w-full h-[500px]" /> 155 | ), 156 | }} 157 | /> 158 | </DocsBody> 159 | </DocsPage> 160 | ); 161 | } 162 | 163 | export async function generateStaticParams() { 164 | return source.generateParams(); 165 | } 166 | 167 | export async function generateMetadata({ 168 | params, 169 | }: { 170 | params: Promise<{ slug?: string[] }>; 171 | }) { 172 | const { slug } = await params; 173 | const page = source.getPage(slug); 174 | if (page == null) notFound(); 175 | const baseUrl = process.env.NEXT_PUBLIC_URL || process.env.VERCEL_URL; 176 | const url = new URL(`${baseUrl}/api/og`); 177 | const { title, description } = page.data; 178 | const pageSlug = page.file.path; 179 | url.searchParams.set("type", "Documentation"); 180 | url.searchParams.set("mode", "dark"); 181 | url.searchParams.set("heading", `${title}`); 182 | 183 | return { 184 | title, 185 | description, 186 | openGraph: { 187 | title, 188 | description, 189 | type: "website", 190 | url: absoluteUrl(`docs/${pageSlug}`), 191 | images: [ 192 | { 193 | url: url.toString(), 194 | width: 1200, 195 | height: 630, 196 | alt: title, 197 | }, 198 | ], 199 | }, 200 | twitter: { 201 | card: "summary_large_image", 202 | title, 203 | description, 204 | images: [url.toString()], 205 | }, 206 | }; 207 | } 208 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/passkey/client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { BetterFetch, BetterFetchOption } from "@better-fetch/fetch"; 2 | import { 3 | WebAuthnError, 4 | startAuthentication, 5 | startRegistration, 6 | } from "@simplewebauthn/browser"; 7 | import type { 8 | PublicKeyCredentialCreationOptionsJSON, 9 | PublicKeyCredentialRequestOptionsJSON, 10 | } from "@simplewebauthn/browser"; 11 | import type { User, Session } from "../../types"; 12 | import type { passkey as passkeyPl, Passkey } from "."; 13 | import type { BetterAuthClientPlugin, ClientStore } from "@better-auth/core"; 14 | import { useAuthQuery } from "../../client"; 15 | import { atom } from "nanostores"; 16 | 17 | export const getPasskeyActions = ( 18 | $fetch: BetterFetch, 19 | { 20 | $listPasskeys, 21 | $store, 22 | }: { 23 | $listPasskeys: ReturnType<typeof atom<any>>; 24 | $store: ClientStore; 25 | }, 26 | ) => { 27 | const signInPasskey = async ( 28 | opts?: { 29 | autoFill?: boolean; 30 | fetchOptions?: BetterFetchOption; 31 | }, 32 | options?: BetterFetchOption, 33 | ) => { 34 | const response = await $fetch<PublicKeyCredentialRequestOptionsJSON>( 35 | "/passkey/generate-authenticate-options", 36 | { 37 | method: "POST", 38 | throw: false, 39 | }, 40 | ); 41 | if (!response.data) { 42 | return response; 43 | } 44 | try { 45 | const res = await startAuthentication({ 46 | optionsJSON: response.data, 47 | useBrowserAutofill: opts?.autoFill, 48 | }); 49 | const verified = await $fetch<{ 50 | session: Session; 51 | user: User; 52 | }>("/passkey/verify-authentication", { 53 | body: { 54 | response: res, 55 | }, 56 | ...opts?.fetchOptions, 57 | ...options, 58 | method: "POST", 59 | throw: false, 60 | }); 61 | $listPasskeys.set(Math.random()); 62 | $store.notify("$sessionSignal"); 63 | 64 | return verified; 65 | } catch (e) { 66 | return { 67 | data: null, 68 | error: { 69 | code: "AUTH_CANCELLED", 70 | message: "auth cancelled", 71 | status: 400, 72 | statusText: "BAD_REQUEST", 73 | }, 74 | }; 75 | } 76 | }; 77 | 78 | const registerPasskey = async ( 79 | opts?: { 80 | fetchOptions?: BetterFetchOption; 81 | /** 82 | * The name of the passkey. This is used to 83 | * identify the passkey in the UI. 84 | */ 85 | name?: string; 86 | 87 | /** 88 | * The type of attachment for the passkey. Defaults to both 89 | * platform and cross-platform allowed, with platform preferred. 90 | */ 91 | authenticatorAttachment?: "platform" | "cross-platform"; 92 | 93 | /** 94 | * Try to silently create a passkey with the password manager that the user just signed 95 | * in with. 96 | * @default false 97 | */ 98 | useAutoRegister?: boolean; 99 | }, 100 | fetchOpts?: BetterFetchOption, 101 | ) => { 102 | const options = await $fetch<PublicKeyCredentialCreationOptionsJSON>( 103 | "/passkey/generate-register-options", 104 | { 105 | method: "GET", 106 | query: { 107 | ...(opts?.authenticatorAttachment && { 108 | authenticatorAttachment: opts.authenticatorAttachment, 109 | }), 110 | ...(opts?.name && { 111 | name: opts.name, 112 | }), 113 | }, 114 | throw: false, 115 | }, 116 | ); 117 | 118 | if (!options.data) { 119 | return options; 120 | } 121 | try { 122 | const res = await startRegistration({ 123 | optionsJSON: options.data, 124 | useAutoRegister: opts?.useAutoRegister, 125 | }); 126 | const verified = await $fetch<{ 127 | passkey: Passkey; 128 | }>("/passkey/verify-registration", { 129 | ...opts?.fetchOptions, 130 | ...fetchOpts, 131 | body: { 132 | response: res, 133 | name: opts?.name, 134 | }, 135 | method: "POST", 136 | throw: false, 137 | }); 138 | 139 | if (!verified.data) { 140 | return verified; 141 | } 142 | $listPasskeys.set(Math.random()); 143 | } catch (e) { 144 | if (e instanceof WebAuthnError) { 145 | if (e.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") { 146 | return { 147 | data: null, 148 | error: { 149 | code: e.code, 150 | message: "previously registered", 151 | status: 400, 152 | statusText: "BAD_REQUEST", 153 | }, 154 | }; 155 | } 156 | if (e.code === "ERROR_CEREMONY_ABORTED") { 157 | return { 158 | data: null, 159 | error: { 160 | code: e.code, 161 | message: "registration cancelled", 162 | status: 400, 163 | statusText: "BAD_REQUEST", 164 | }, 165 | }; 166 | } 167 | return { 168 | data: null, 169 | error: { 170 | code: e.code, 171 | message: e.message, 172 | status: 400, 173 | statusText: "BAD_REQUEST", 174 | }, 175 | }; 176 | } 177 | return { 178 | data: null, 179 | error: { 180 | code: "UNKNOWN_ERROR", 181 | message: e instanceof Error ? e.message : "unknown error", 182 | status: 500, 183 | statusText: "INTERNAL_SERVER_ERROR", 184 | }, 185 | }; 186 | } 187 | }; 188 | 189 | return { 190 | signIn: { 191 | /** 192 | * Sign in with a registered passkey 193 | */ 194 | passkey: signInPasskey, 195 | }, 196 | passkey: { 197 | /** 198 | * Add a passkey to the user account 199 | */ 200 | addPasskey: registerPasskey, 201 | }, 202 | /** 203 | * Inferred Internal Types 204 | */ 205 | $Infer: {} as { 206 | Passkey: Passkey; 207 | }, 208 | }; 209 | }; 210 | 211 | export const passkeyClient = () => { 212 | const $listPasskeys = atom<any>(); 213 | return { 214 | id: "passkey", 215 | $InferServerPlugin: {} as ReturnType<typeof passkeyPl>, 216 | getActions: ($fetch, $store) => 217 | getPasskeyActions($fetch, { 218 | $listPasskeys, 219 | $store, 220 | }), 221 | getAtoms($fetch) { 222 | const listPasskeys = useAuthQuery<Passkey[]>( 223 | $listPasskeys, 224 | "/passkey/list-user-passkeys", 225 | $fetch, 226 | { 227 | method: "GET", 228 | }, 229 | ); 230 | return { 231 | listPasskeys, 232 | $listPasskeys, 233 | }; 234 | }, 235 | pathMethods: { 236 | "/passkey/register": "POST", 237 | "/passkey/authenticate": "POST", 238 | }, 239 | atomListeners: [ 240 | { 241 | matcher(path) { 242 | return ( 243 | path === "/passkey/verify-registration" || 244 | path === "/passkey/delete-passkey" || 245 | path === "/passkey/update-passkey" || 246 | path === "/sign-out" 247 | ); 248 | }, 249 | signal: "$listPasskeys", 250 | }, 251 | { 252 | matcher: (path) => path === "/passkey/verify-authentication", 253 | signal: "$sessionSignal", 254 | }, 255 | ], 256 | } satisfies BetterAuthClientPlugin; 257 | }; 258 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/one-tap/client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { BetterFetchOption } from "@better-fetch/fetch"; 2 | import type { BetterAuthClientPlugin } from "@better-auth/core"; 3 | 4 | declare global { 5 | interface Window { 6 | google?: { 7 | accounts: { 8 | id: { 9 | initialize: (config: any) => void; 10 | prompt: (callback?: (notification: any) => void) => void; 11 | }; 12 | }; 13 | }; 14 | googleScriptInitialized?: boolean; 15 | } 16 | } 17 | 18 | export interface GoogleOneTapOptions { 19 | /** 20 | * Google client ID 21 | */ 22 | clientId: string; 23 | /** 24 | * Auto select the account if the user is already signed in 25 | */ 26 | autoSelect?: boolean; 27 | /** 28 | * Cancel the flow when the user taps outside the prompt 29 | */ 30 | cancelOnTapOutside?: boolean; 31 | /** 32 | * The mode to use for the Google One Tap flow 33 | * 34 | * popup: Use a popup window 35 | * redirect: Redirect the user to the Google One Tap flow 36 | * 37 | * @default "popup" 38 | */ 39 | uxMode?: "popup" | "redirect"; 40 | /** 41 | * The context to use for the Google One Tap flow. See https://developers.google.com/identity/gsi/web/reference/js-reference 42 | * 43 | * @default "signin" 44 | */ 45 | context?: "signin" | "signup" | "use"; 46 | /** 47 | * Additional configuration options to pass to the Google One Tap API. 48 | */ 49 | additionalOptions?: Record<string, any>; 50 | /** 51 | * Configuration options for the prompt and exponential backoff behavior. 52 | */ 53 | promptOptions?: { 54 | /** 55 | * Base delay (in milliseconds) for exponential backoff. 56 | * @default 1000 57 | */ 58 | baseDelay?: number; 59 | /** 60 | * Maximum number of prompt attempts before calling onPromptNotification. 61 | * @default 5 62 | */ 63 | maxAttempts?: number; 64 | }; 65 | } 66 | 67 | export interface GoogleOneTapActionOptions 68 | extends Omit<GoogleOneTapOptions, "clientId" | "promptOptions"> { 69 | fetchOptions?: BetterFetchOption; 70 | /** 71 | * Callback URL. 72 | */ 73 | callbackURL?: string; 74 | /** 75 | * Optional callback that receives the prompt notification if (or when) the prompt is dismissed or skipped. 76 | * This lets you render an alternative UI (e.g. a Google Sign-In button) to restart the process. 77 | */ 78 | onPromptNotification?: (notification: any) => void; 79 | } 80 | 81 | let isRequestInProgress = false; 82 | 83 | export const oneTapClient = (options: GoogleOneTapOptions) => { 84 | return { 85 | id: "one-tap", 86 | getActions: ($fetch, _) => ({ 87 | oneTap: async ( 88 | opts?: GoogleOneTapActionOptions, 89 | fetchOptions?: BetterFetchOption, 90 | ) => { 91 | if (isRequestInProgress) { 92 | console.warn( 93 | "A Google One Tap request is already in progress. Please wait.", 94 | ); 95 | return; 96 | } 97 | 98 | isRequestInProgress = true; 99 | 100 | try { 101 | if (typeof window === "undefined" || !window.document) { 102 | console.warn( 103 | "Google One Tap is only available in browser environments", 104 | ); 105 | return; 106 | } 107 | 108 | const { autoSelect, cancelOnTapOutside, context } = opts ?? {}; 109 | const contextValue = context ?? options.context ?? "signin"; 110 | 111 | await loadGoogleScript(); 112 | 113 | await new Promise<void>((resolve, reject) => { 114 | let isResolved = false; 115 | const baseDelay = options.promptOptions?.baseDelay ?? 1000; 116 | const maxAttempts = options.promptOptions?.maxAttempts ?? 5; 117 | 118 | window.google?.accounts.id.initialize({ 119 | client_id: options.clientId, 120 | callback: async (response: { credential: string }) => { 121 | isResolved = true; 122 | try { 123 | await $fetch("/one-tap/callback", { 124 | method: "POST", 125 | body: { idToken: response.credential }, 126 | ...opts?.fetchOptions, 127 | ...fetchOptions, 128 | }); 129 | 130 | if ( 131 | (!opts?.fetchOptions && !fetchOptions) || 132 | opts?.callbackURL 133 | ) { 134 | window.location.href = opts?.callbackURL ?? "/"; 135 | } 136 | resolve(); 137 | } catch (error) { 138 | console.error("Error during One Tap callback:", error); 139 | reject(error); 140 | } 141 | }, 142 | auto_select: autoSelect, 143 | cancel_on_tap_outside: cancelOnTapOutside, 144 | context: contextValue, 145 | 146 | ...options.additionalOptions, 147 | }); 148 | 149 | const handlePrompt = (attempt: number) => { 150 | if (isResolved) return; 151 | 152 | window.google?.accounts.id.prompt((notification: any) => { 153 | if (isResolved) return; 154 | 155 | if ( 156 | notification.isDismissedMoment && 157 | notification.isDismissedMoment() 158 | ) { 159 | if (attempt < maxAttempts) { 160 | const delay = Math.pow(2, attempt) * baseDelay; 161 | setTimeout(() => handlePrompt(attempt + 1), delay); 162 | } else { 163 | opts?.onPromptNotification?.(notification); 164 | } 165 | } else if ( 166 | notification.isSkippedMoment && 167 | notification.isSkippedMoment() 168 | ) { 169 | if (attempt < maxAttempts) { 170 | const delay = Math.pow(2, attempt) * baseDelay; 171 | setTimeout(() => handlePrompt(attempt + 1), delay); 172 | } else { 173 | opts?.onPromptNotification?.(notification); 174 | } 175 | } 176 | }); 177 | }; 178 | 179 | handlePrompt(0); 180 | }); 181 | } catch (error) { 182 | console.error("Error during Google One Tap flow:", error); 183 | throw error; 184 | } finally { 185 | isRequestInProgress = false; 186 | } 187 | }, 188 | }), 189 | getAtoms($fetch) { 190 | return {}; 191 | }, 192 | } satisfies BetterAuthClientPlugin; 193 | }; 194 | 195 | const loadGoogleScript = (): Promise<void> => { 196 | return new Promise((resolve) => { 197 | if (window.googleScriptInitialized) { 198 | resolve(); 199 | return; 200 | } 201 | 202 | const script = document.createElement("script"); 203 | script.src = "https://accounts.google.com/gsi/client"; 204 | script.async = true; 205 | script.defer = true; 206 | script.onload = () => { 207 | window.googleScriptInitialized = true; 208 | resolve(); 209 | }; 210 | document.head.appendChild(script); 211 | }); 212 | }; 213 | ``` -------------------------------------------------------------------------------- /docs/app/blog/_components/support.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from "@/components/ui/dialog"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Label } from "@/components/ui/label"; 14 | import { 15 | Select, 16 | SelectContent, 17 | SelectItem, 18 | SelectTrigger, 19 | SelectValue, 20 | } from "@/components/ui/select"; 21 | import { Textarea } from "@/components/ui/textarea"; 22 | import * as React from "react"; 23 | import { 24 | Card, 25 | CardDescription, 26 | CardFooter, 27 | CardHeader, 28 | CardTitle, 29 | } from "@/components/ui/card"; 30 | 31 | export function Support() { 32 | const [open, setOpen] = React.useState(false); 33 | const [submitting, setSubmitting] = React.useState(false); 34 | const formRef = React.useRef<HTMLFormElement | null>(null); 35 | 36 | async function onSubmit(event: React.FormEvent<HTMLFormElement>) { 37 | event.preventDefault(); 38 | if (submitting) return; 39 | setSubmitting(true); 40 | const form = new FormData(event.currentTarget); 41 | const payload = { 42 | name: String(form.get("name") || ""), 43 | email: String(form.get("email") || ""), 44 | company: String(form.get("company") || ""), 45 | website: String(form.get("website") || ""), 46 | userCount: String(form.get("userCount") || ""), 47 | interest: String(form.get("interest") || ""), 48 | features: String(form.get("features") || ""), 49 | additional: String(form.get("additional") || ""), 50 | }; 51 | try { 52 | const res = await fetch("/api/support", { 53 | method: "POST", 54 | headers: { "Content-Type": "application/json" }, 55 | body: JSON.stringify(payload), 56 | }); 57 | if (!res.ok) throw new Error("Failed to submit"); 58 | setOpen(false); 59 | formRef.current?.reset(); 60 | // optionally add a toast later 61 | } catch (e) { 62 | console.error(e); 63 | // optionally add error toast 64 | } finally { 65 | setSubmitting(false); 66 | } 67 | } 68 | 69 | return ( 70 | <Card className="flex flex-col gap-3 rounded-none"> 71 | <CardHeader> 72 | <CardTitle>Dedicated Support</CardTitle> 73 | <CardDescription> 74 | We're now offering on demand support for Better Auth and Auth.js. 75 | Including help out migrations, consultations, premium dedicated 76 | support and more. If you're interested, please get in touch. 77 | </CardDescription> 78 | </CardHeader> 79 | <CardFooter> 80 | <Dialog open={open} onOpenChange={setOpen}> 81 | <div> 82 | <DialogTrigger asChild> 83 | <Button 84 | type="button" 85 | className="bg-blue-500 text-white hover:bg-blue-600 transition-colors cursor-pointer" 86 | > 87 | Request support 88 | </Button> 89 | </DialogTrigger> 90 | </div> 91 | <DialogContent> 92 | <DialogHeader> 93 | <DialogTitle>Request dedicated support</DialogTitle> 94 | <DialogDescription> 95 | Tell us about your team and what you're looking for. 96 | </DialogDescription> 97 | </DialogHeader> 98 | <form ref={formRef} className="grid gap-4" onSubmit={onSubmit}> 99 | <div className="grid gap-2"> 100 | <Label htmlFor="name">Your name</Label> 101 | <Input id="name" name="name" placeholder="Jane Doe" required /> 102 | </div> 103 | <div className="grid gap-2"> 104 | <Label htmlFor="email">Work email</Label> 105 | <Input 106 | id="email" 107 | name="email" 108 | type="email" 109 | placeholder="[email protected]" 110 | required 111 | /> 112 | </div> 113 | <div className="grid gap-2"> 114 | <Label htmlFor="company">Company</Label> 115 | <Input id="company" name="company" placeholder="Acme Inc." /> 116 | </div> 117 | <div className="grid gap-2"> 118 | <Label htmlFor="website">Website</Label> 119 | <Input 120 | id="website" 121 | name="website" 122 | placeholder="https://acme.com" 123 | /> 124 | </div> 125 | <div className="grid gap-2"> 126 | <Label htmlFor="userCount">Users</Label> 127 | <Select name="userCount"> 128 | <SelectTrigger id="userCount"> 129 | <SelectValue placeholder="Select users" /> 130 | </SelectTrigger> 131 | <SelectContent> 132 | <SelectItem value="<1k">Less than 1k</SelectItem> 133 | <SelectItem value="1k-10k">1k - 10k</SelectItem> 134 | <SelectItem value=">10k">More than 10k</SelectItem> 135 | </SelectContent> 136 | </Select> 137 | </div> 138 | <div className="grid gap-2"> 139 | <Label htmlFor="interest">What are you interested in?</Label> 140 | <Select name="interest"> 141 | <SelectTrigger id="interest"> 142 | <SelectValue placeholder="Choose a package" /> 143 | </SelectTrigger> 144 | <SelectContent> 145 | <SelectItem value="migration">Migration help</SelectItem> 146 | <SelectItem value="consultation">Consultation</SelectItem> 147 | <SelectItem value="support">Premium support</SelectItem> 148 | <SelectItem value="custom">Custom</SelectItem> 149 | </SelectContent> 150 | </Select> 151 | </div> 152 | <div className="grid gap-2"> 153 | <Label htmlFor="features"> 154 | Features or plugins of interest 155 | </Label> 156 | <Input 157 | id="features" 158 | name="features" 159 | placeholder="SAML, SIWE, WebAuthn, Organizations, ..." 160 | /> 161 | </div> 162 | <div className="grid gap-2"> 163 | <Label htmlFor="additional">Anything else?</Label> 164 | <Textarea 165 | id="additional" 166 | name="additional" 167 | placeholder="Share more context, timelines, and expectations." 168 | /> 169 | </div> 170 | <DialogFooter> 171 | <Button type="submit" disabled={submitting}> 172 | {submitting ? "Submitting..." : "Submit"} 173 | </Button> 174 | </DialogFooter> 175 | </form> 176 | </DialogContent> 177 | </Dialog> 178 | </CardFooter> 179 | </Card> 180 | ); 181 | } 182 | ``` -------------------------------------------------------------------------------- /docs/content/docs/guides/next-auth-migration-guide.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Migrating from NextAuth.js to Better Auth 3 | description: A step-by-step guide to transitioning from NextAuth.js to Better Auth. 4 | --- 5 | 6 | In this guide, we’ll walk through the steps to migrate a project from [NextAuth.js](https://authjs.dev/) to Better Auth, ensuring no loss of data or functionality. While this guide focuses on Next.js, it can be adapted for other frameworks as well. 7 | 8 | --- 9 | 10 | ## Before You Begin 11 | 12 | Before starting the migration process, set up Better Auth in your project. Follow the [installation guide](/docs/installation) to get started. 13 | 14 | --- 15 | 16 | <Steps> 17 | <Step> 18 | ### Mapping Existing Columns 19 | 20 | Instead of altering your existing database column names, you can map them to match Better Auth's expected structure. This allows you to retain your current database schema. 21 | 22 | #### User Schema 23 | 24 | Map the following fields in the user schema: 25 | 26 | - (next-auth v4) `emailVerified`: datetime → boolean 27 | 28 | #### Session Schema 29 | 30 | Map the following fields in the session schema: 31 | 32 | - `expires` → `expiresAt` 33 | - `sessionToken` → `token` 34 | - (next-auth v4) add `createdAt` with datetime type 35 | - (next-auth v4) add `updatedAt` with datetime type 36 | 37 | ```typescript title="auth.ts" 38 | export const auth = betterAuth({ 39 | // Other configs 40 | session: { 41 | fields: { 42 | expiresAt: "expires", // Map your existing `expires` field to Better Auth's `expiresAt` 43 | token: "sessionToken" // Map your existing `sessionToken` field to Better Auth's `token` 44 | } 45 | }, 46 | }); 47 | ``` 48 | 49 | Make sure to have `createdAt` and `updatedAt` fields on your session schema. 50 | 51 | #### Account Schema 52 | 53 | Map these fields in the account schema: 54 | 55 | - (next-auth v4) `provider` → `providerId` 56 | - `providerAccountId` → `accountId` 57 | - `refresh_token` → `refreshToken` 58 | - `access_token` → `accessToken` 59 | - (next-auth v3) `access_token_expires` → `accessTokenExpiresAt` and int → datetime 60 | - (next-auth v4) `expires_at` → `accessTokenExpiresAt` and int → datetime 61 | - `id_token` → `idToken` 62 | - (next-auth v4) add `createdAt` with datetime type 63 | - (next-auth v4) add `updatedAt` with datetime type 64 | 65 | Remove the `session_state`, `type`, and `token_type` fields, as they are not required by Better Auth. 66 | 67 | ```typescript title="auth.ts" 68 | export const auth = betterAuth({ 69 | // Other configs 70 | account: { 71 | fields: { 72 | accountId: "providerAccountId", 73 | refreshToken: "refresh_token", 74 | accessToken: "access_token", 75 | accessTokenExpiresAt: "access_token_expires", 76 | idToken: "id_token", 77 | } 78 | }, 79 | }); 80 | ``` 81 | 82 | **Note:** If you use ORM adapters, you can map these fields in your schema file. 83 | 84 | **Example with Prisma:** 85 | 86 | ```prisma title="schema.prisma" 87 | model Session { 88 | id String @id @default(cuid()) 89 | expiresAt DateTime @map("expires") // Map your existing `expires` field to Better Auth's `expiresAt` 90 | token String @map("sessionToken") // Map your existing `sessionToken` field to Better Auth's `token` 91 | userId String 92 | user User @relation(fields: [userId], references: [id]) 93 | } 94 | ``` 95 | 96 | Make sure to have `createdAt` and `updatedAt` fields on your account schema. 97 | </Step> 98 | <Step> 99 | 100 | ### Update the Route Handler 101 | 102 | In the `app/api/auth` folder, rename the `[...nextauth]` file to `[...all]` to avoid confusion. Then, update the `route.ts` file as follows: 103 | 104 | ```typescript title="app/api/auth/[...all]/route.ts" 105 | import { toNextJsHandler } from "better-auth/next-js"; 106 | import { auth } from "~/server/auth"; 107 | 108 | export const { POST, GET } = toNextJsHandler(auth); 109 | ``` 110 | </Step> 111 | 112 | <Step> 113 | ### Update the Client 114 | 115 | Create a file named `auth-client.ts` in the `lib` folder. Add the following code: 116 | 117 | ```typescript title="auth-client.ts" 118 | import { createAuthClient } from "better-auth/react"; 119 | 120 | export const authClient = createAuthClient({ 121 | baseURL: process.env.BASE_URL! // Optional if the API base URL matches the frontend 122 | }); 123 | 124 | export const { signIn, signOut, useSession } = authClient; 125 | ``` 126 | 127 | #### Social Login Functions 128 | 129 | Update your social login functions to use Better Auth. For example, for Discord: 130 | 131 | ```typescript 132 | import { signIn } from "~/lib/auth-client"; 133 | 134 | export const signInDiscord = async () => { 135 | const data = await signIn.social({ 136 | provider: "discord" 137 | }); 138 | return data; 139 | }; 140 | ``` 141 | 142 | #### Update `useSession` Calls 143 | 144 | Replace `useSession` calls with Better Auth’s version. Example: 145 | 146 | ```typescript title="Profile.tsx" 147 | import { useSession } from "~/lib/auth-client"; 148 | 149 | export const Profile = () => { 150 | const { data } = useSession(); 151 | return ( 152 | <div> 153 | <pre> 154 | {JSON.stringify(data, null, 2)} 155 | </pre> 156 | </div> 157 | ); 158 | }; 159 | ``` 160 | </Step> 161 | 162 | <Step> 163 | 164 | ### Server-Side Session Handling 165 | 166 | Use the `auth` instance to get session data on the server: 167 | 168 | ```typescript title="actions.ts" 169 | "use server"; 170 | 171 | import { auth } from "~/server/auth"; 172 | import { headers } from "next/headers"; 173 | 174 | export const protectedAction = async () => { 175 | const session = await auth.api.getSession({ 176 | headers: await headers(), 177 | }); 178 | }; 179 | ``` 180 | </Step> 181 | 182 | <Step> 183 | ### Middleware 184 | 185 | To protect routes with middleware, refer to the [Next.js middleware guide](/docs/integrations/next#middleware). 186 | </Step> 187 | </Steps> 188 | 189 | 190 | ## Wrapping Up 191 | 192 | Congratulations! You’ve successfully migrated from NextAuth.js to Better Auth. For a complete implementation with multiple authentication methods, check out the [demo repository](https://github.com/Bekacru/t3-app-better-auth). 193 | 194 | Better Auth offers greater flexibility and more features—be sure to explore the [documentation](/docs) to unlock its full potential. 195 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/init.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { init } from "./init"; 3 | import Database from "better-sqlite3"; 4 | import { betterAuth } from "./auth"; 5 | import { createAuthClient } from "./client"; 6 | import { getTestInstance } from "./test-utils/test-instance"; 7 | 8 | describe("init", async () => { 9 | const database = new Database(":memory:"); 10 | 11 | it("should match config", async () => { 12 | const res = await init({ 13 | baseURL: "http://localhost:3000", 14 | database, 15 | }); 16 | expect(res).toMatchSnapshot(); 17 | }); 18 | 19 | it("should infer BASE_URL from env", async () => { 20 | vi.stubEnv("BETTER_AUTH_URL", "http://localhost:5147"); 21 | const res = await init({ 22 | database, 23 | }); 24 | expect(res.options.baseURL).toBe("http://localhost:5147"); 25 | expect(res.baseURL).toBe("http://localhost:5147/api/auth"); 26 | vi.unstubAllEnvs(); 27 | }); 28 | 29 | it("should respect base path", async () => { 30 | const res = await init({ 31 | database, 32 | basePath: "/custom-path", 33 | baseURL: "http://localhost:5147", 34 | }); 35 | expect(res.baseURL).toBe("http://localhost:5147/custom-path"); 36 | }); 37 | 38 | it("should work with base path", async () => { 39 | const { client } = await getTestInstance({ 40 | basePath: "/custom-path", 41 | }); 42 | 43 | await client.$fetch("/ok", { 44 | onSuccess: (ctx) => { 45 | expect(ctx.data).toMatchObject({ 46 | ok: true, 47 | }); 48 | }, 49 | }); 50 | }); 51 | 52 | it("should execute plugins init", async () => { 53 | const newBaseURL = "http://test.test"; 54 | const res = await init({ 55 | baseURL: "http://localhost:3000", 56 | database, 57 | plugins: [ 58 | { 59 | id: "test", 60 | init: () => { 61 | return { 62 | context: { 63 | baseURL: newBaseURL, 64 | }, 65 | }; 66 | }, 67 | }, 68 | ], 69 | }); 70 | expect(res.baseURL).toBe(newBaseURL); 71 | }); 72 | 73 | it("should work with custom path", async () => { 74 | const customPath = "/custom-path"; 75 | const ctx = await init({ 76 | database, 77 | basePath: customPath, 78 | baseURL: "http://localhost:3000", 79 | }); 80 | expect(ctx.baseURL).toBe(`http://localhost:3000${customPath}`); 81 | 82 | const res = betterAuth({ 83 | baseURL: "http://localhost:3000", 84 | database, 85 | basePath: customPath, 86 | }); 87 | 88 | const client = createAuthClient({ 89 | baseURL: `http://localhost:3000/custom-path`, 90 | fetchOptions: { 91 | customFetchImpl: async (url, init) => { 92 | return res.handler(new Request(url, init)); 93 | }, 94 | }, 95 | }); 96 | const ok = await client.$fetch("/ok"); 97 | expect(ok.data).toMatchObject({ 98 | ok: true, 99 | }); 100 | }); 101 | 102 | it("should allow plugins to set config values", async () => { 103 | const ctx = await init({ 104 | database, 105 | baseURL: "http://localhost:3000", 106 | plugins: [ 107 | { 108 | id: "test-plugin", 109 | init(ctx) { 110 | return { 111 | context: ctx, 112 | options: { 113 | emailAndPassword: { 114 | enabled: true, 115 | }, 116 | }, 117 | }; 118 | }, 119 | }, 120 | ], 121 | }); 122 | expect(ctx.options.emailAndPassword?.enabled).toBe(true); 123 | }); 124 | 125 | it("should not allow plugins to set config values if they are set in the main config", async () => { 126 | const ctx = await init({ 127 | database, 128 | baseURL: "http://localhost:3000", 129 | emailAndPassword: { 130 | enabled: false, 131 | }, 132 | plugins: [ 133 | { 134 | id: "test-plugin", 135 | init(ctx) { 136 | return { 137 | context: ctx, 138 | options: { 139 | emailAndPassword: { 140 | enabled: true, 141 | }, 142 | }, 143 | }; 144 | }, 145 | }, 146 | ], 147 | }); 148 | expect(ctx.options.emailAndPassword?.enabled).toBe(false); 149 | }); 150 | 151 | it("should properly pass modfied context from one plugin to another", async () => { 152 | const mockProvider = { 153 | id: "test-oauth-provider", 154 | name: "Test OAuth Provider", 155 | createAuthorizationURL: vi.fn(), 156 | validateAuthorizationCode: vi.fn(), 157 | refreshAccessToken: vi.fn(), 158 | getUserInfo: vi.fn(), 159 | }; 160 | 161 | const ctx = await init({ 162 | database, 163 | baseURL: "http://localhost:3000", 164 | socialProviders: { 165 | github: { 166 | clientId: "test-github-id", 167 | clientSecret: "test-github-secret", 168 | }, 169 | }, 170 | plugins: [ 171 | { 172 | id: "test-oauth-plugin", 173 | init(ctx) { 174 | return { 175 | context: { 176 | socialProviders: [mockProvider, ...ctx.socialProviders], 177 | }, 178 | }; 179 | }, 180 | }, 181 | { 182 | id: "test-oauth-plugin-2", 183 | init(ctx) { 184 | return { 185 | context: ctx, 186 | }; 187 | }, 188 | }, 189 | ], 190 | }); 191 | expect(ctx.socialProviders).toHaveLength(2); 192 | const testProvider = ctx.socialProviders.find( 193 | (p) => p.id === "test-oauth-provider", 194 | ); 195 | expect(testProvider).toBeDefined(); 196 | expect(testProvider?.refreshAccessToken).toBeDefined(); 197 | const githubProvider = ctx.socialProviders.find((p) => p.id === "github"); 198 | expect(githubProvider).toBeDefined(); 199 | }); 200 | 201 | it("should init async plugin", async () => { 202 | const initFn = vi.fn(async () => { 203 | await new Promise((r) => setTimeout(r, 100)); 204 | return { 205 | context: { 206 | baseURL: "http://async.test", 207 | }, 208 | }; 209 | }); 210 | await init({ 211 | baseURL: "http://localhost:3000", 212 | database, 213 | plugins: [ 214 | { 215 | id: "test-async", 216 | init: initFn, 217 | }, 218 | ], 219 | }); 220 | expect(initFn).toHaveBeenCalled(); 221 | }); 222 | 223 | it("handles empty basePath", async () => { 224 | const res = await init({ 225 | database, 226 | baseURL: "http://localhost:5147/", 227 | basePath: "", 228 | }); 229 | expect(res.baseURL).toBe("http://localhost:5147"); 230 | }); 231 | 232 | it("handles root basePath", async () => { 233 | const res = await init({ 234 | database, 235 | baseURL: "http://localhost:5147/", 236 | basePath: "/", 237 | }); 238 | expect(res.baseURL).toBe("http://localhost:5147"); 239 | }); 240 | 241 | it("normalizes trailing slashes with default path", async () => { 242 | const res = await init({ 243 | database, 244 | baseURL: "http://localhost:5147////", 245 | }); 246 | expect(res.baseURL).toBe("http://localhost:5147/api/auth"); 247 | }); 248 | }); 249 | ``` -------------------------------------------------------------------------------- /demo/nextjs/components/ui/carousel.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; 5 | import useEmblaCarousel, { 6 | type UseEmblaCarouselType, 7 | } from "embla-carousel-react"; 8 | 9 | import { cn } from "@/lib/utils"; 10 | import { Button } from "@/components/ui/button"; 11 | 12 | type CarouselApi = UseEmblaCarouselType[1]; 13 | type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; 14 | type CarouselOptions = UseCarouselParameters[0]; 15 | type CarouselPlugin = UseCarouselParameters[1]; 16 | 17 | type CarouselProps = { 18 | opts?: CarouselOptions; 19 | plugins?: CarouselPlugin; 20 | orientation?: "horizontal" | "vertical"; 21 | setApi?: (api: CarouselApi) => void; 22 | }; 23 | 24 | type CarouselContextProps = { 25 | carouselRef: ReturnType<typeof useEmblaCarousel>[0]; 26 | api: ReturnType<typeof useEmblaCarousel>[1]; 27 | scrollPrev: () => void; 28 | scrollNext: () => void; 29 | canScrollPrev: boolean; 30 | canScrollNext: boolean; 31 | } & CarouselProps; 32 | 33 | const CarouselContext = React.createContext<CarouselContextProps | null>(null); 34 | 35 | function useCarousel() { 36 | const context = React.useContext(CarouselContext); 37 | 38 | if (!context) { 39 | throw new Error("useCarousel must be used within a <Carousel />"); 40 | } 41 | 42 | return context; 43 | } 44 | 45 | const Carousel = ({ 46 | ref, 47 | orientation = "horizontal", 48 | opts, 49 | setApi, 50 | plugins, 51 | className, 52 | children, 53 | ...props 54 | }) => { 55 | const [carouselRef, api] = useEmblaCarousel( 56 | { 57 | ...opts, 58 | axis: orientation === "horizontal" ? "x" : "y", 59 | }, 60 | plugins, 61 | ); 62 | const [canScrollPrev, setCanScrollPrev] = React.useState(false); 63 | const [canScrollNext, setCanScrollNext] = React.useState(false); 64 | 65 | const onSelect = React.useCallback((api: CarouselApi) => { 66 | if (!api) { 67 | return; 68 | } 69 | 70 | setCanScrollPrev(api.canScrollPrev()); 71 | setCanScrollNext(api.canScrollNext()); 72 | }, []); 73 | 74 | const scrollPrev = React.useCallback(() => { 75 | api?.scrollPrev(); 76 | }, [api]); 77 | 78 | const scrollNext = React.useCallback(() => { 79 | api?.scrollNext(); 80 | }, [api]); 81 | 82 | const handleKeyDown = React.useCallback( 83 | (event: React.KeyboardEvent<HTMLDivElement>) => { 84 | if (event.key === "ArrowLeft") { 85 | event.preventDefault(); 86 | scrollPrev(); 87 | } else if (event.key === "ArrowRight") { 88 | event.preventDefault(); 89 | scrollNext(); 90 | } 91 | }, 92 | [scrollPrev, scrollNext], 93 | ); 94 | 95 | React.useEffect(() => { 96 | if (!api || !setApi) { 97 | return; 98 | } 99 | 100 | setApi(api); 101 | }, [api, setApi]); 102 | 103 | React.useEffect(() => { 104 | if (!api) { 105 | return; 106 | } 107 | 108 | onSelect(api); 109 | api.on("reInit", onSelect); 110 | api.on("select", onSelect); 111 | 112 | return () => { 113 | api?.off("select", onSelect); 114 | }; 115 | }, [api, onSelect]); 116 | 117 | return ( 118 | <CarouselContext.Provider 119 | value={{ 120 | carouselRef, 121 | api: api, 122 | opts, 123 | orientation: 124 | orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), 125 | scrollPrev, 126 | scrollNext, 127 | canScrollPrev, 128 | canScrollNext, 129 | }} 130 | > 131 | <div 132 | ref={ref} 133 | onKeyDownCapture={handleKeyDown} 134 | className={cn("relative", className)} 135 | role="region" 136 | aria-roledescription="carousel" 137 | {...props} 138 | > 139 | {children} 140 | </div> 141 | </CarouselContext.Provider> 142 | ); 143 | }; 144 | Carousel.displayName = "Carousel"; 145 | 146 | const CarouselContent = ({ 147 | ref, 148 | className, 149 | ...props 150 | }: React.HTMLAttributes<HTMLDivElement> & { 151 | ref: React.RefObject<HTMLDivElement>; 152 | }) => { 153 | const { carouselRef, orientation } = useCarousel(); 154 | 155 | return ( 156 | <div ref={carouselRef} className="overflow-hidden"> 157 | <div 158 | ref={ref} 159 | className={cn( 160 | "flex", 161 | orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", 162 | className, 163 | )} 164 | {...props} 165 | /> 166 | </div> 167 | ); 168 | }; 169 | CarouselContent.displayName = "CarouselContent"; 170 | 171 | const CarouselItem = ({ 172 | ref, 173 | className, 174 | ...props 175 | }: React.HTMLAttributes<HTMLDivElement> & { 176 | ref: React.RefObject<HTMLDivElement>; 177 | }) => { 178 | const { orientation } = useCarousel(); 179 | 180 | return ( 181 | <div 182 | ref={ref} 183 | role="group" 184 | aria-roledescription="slide" 185 | className={cn( 186 | "min-w-0 shrink-0 grow-0 basis-full", 187 | orientation === "horizontal" ? "pl-4" : "pt-4", 188 | className, 189 | )} 190 | {...props} 191 | /> 192 | ); 193 | }; 194 | CarouselItem.displayName = "CarouselItem"; 195 | 196 | const CarouselPrevious = ({ 197 | ref, 198 | className, 199 | variant = "outline", 200 | size = "icon", 201 | ...props 202 | }: React.ComponentProps<typeof Button> & { 203 | ref: React.RefObject<HTMLButtonElement>; 204 | }) => { 205 | const { orientation, scrollPrev, canScrollPrev } = useCarousel(); 206 | 207 | return ( 208 | <Button 209 | ref={ref} 210 | variant={variant} 211 | size={size} 212 | className={cn( 213 | "absolute h-8 w-8 rounded-full", 214 | orientation === "horizontal" 215 | ? "-left-12 top-1/2 -translate-y-1/2" 216 | : "-top-12 left-1/2 -translate-x-1/2 rotate-90", 217 | className, 218 | )} 219 | disabled={!canScrollPrev} 220 | onClick={scrollPrev} 221 | {...props} 222 | > 223 | <ArrowLeftIcon className="h-4 w-4" /> 224 | <span className="sr-only">Previous slide</span> 225 | </Button> 226 | ); 227 | }; 228 | CarouselPrevious.displayName = "CarouselPrevious"; 229 | 230 | const CarouselNext = ({ 231 | ref, 232 | className, 233 | variant = "outline", 234 | size = "icon", 235 | ...props 236 | }: React.ComponentProps<typeof Button> & { 237 | ref: React.RefObject<HTMLButtonElement>; 238 | }) => { 239 | const { orientation, scrollNext, canScrollNext } = useCarousel(); 240 | 241 | return ( 242 | <Button 243 | ref={ref} 244 | variant={variant} 245 | size={size} 246 | className={cn( 247 | "absolute h-8 w-8 rounded-full", 248 | orientation === "horizontal" 249 | ? "-right-12 top-1/2 -translate-y-1/2" 250 | : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", 251 | className, 252 | )} 253 | disabled={!canScrollNext} 254 | onClick={scrollNext} 255 | {...props} 256 | > 257 | <ArrowRightIcon className="h-4 w-4" /> 258 | <span className="sr-only">Next slide</span> 259 | </Button> 260 | ); 261 | }; 262 | CarouselNext.displayName = "CarouselNext"; 263 | 264 | export { 265 | type CarouselApi, 266 | Carousel, 267 | CarouselContent, 268 | CarouselItem, 269 | CarouselPrevious, 270 | CarouselNext, 271 | }; 272 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/mcp/authorize.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "better-call"; 2 | import { getSessionFromCtx } from "../../api"; 3 | import type { 4 | AuthorizationQuery, 5 | Client, 6 | OIDCOptions, 7 | } from "../oidc-provider/types"; 8 | import { generateRandomString } from "../../crypto"; 9 | import type { GenericEndpointContext } from "@better-auth/core"; 10 | 11 | function redirectErrorURL(url: string, error: string, description: string) { 12 | return `${ 13 | url.includes("?") ? "&" : "?" 14 | }error=${error}&error_description=${description}`; 15 | } 16 | 17 | export async function authorizeMCPOAuth( 18 | ctx: GenericEndpointContext, 19 | options: OIDCOptions, 20 | ) { 21 | ctx.setHeader("Access-Control-Allow-Origin", "*"); 22 | ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); 23 | ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); 24 | ctx.setHeader("Access-Control-Max-Age", "86400"); 25 | const opts = { 26 | codeExpiresIn: 600, 27 | defaultScope: "openid", 28 | ...options, 29 | scopes: [ 30 | "openid", 31 | "profile", 32 | "email", 33 | "offline_access", 34 | ...(options?.scopes || []), 35 | ], 36 | }; 37 | if (!ctx.request) { 38 | throw new APIError("UNAUTHORIZED", { 39 | error_description: "request not found", 40 | error: "invalid_request", 41 | }); 42 | } 43 | const session = await getSessionFromCtx(ctx); 44 | if (!session) { 45 | /** 46 | * If the user is not logged in, we need to redirect them to the 47 | * login page. 48 | */ 49 | await ctx.setSignedCookie( 50 | "oidc_login_prompt", 51 | JSON.stringify(ctx.query), 52 | ctx.context.secret, 53 | { 54 | maxAge: 600, 55 | path: "/", 56 | sameSite: "lax", 57 | }, 58 | ); 59 | const queryFromURL = ctx.request.url?.split("?")[1]!; 60 | throw ctx.redirect(`${options.loginPage}?${queryFromURL}`); 61 | } 62 | 63 | const query = ctx.query as AuthorizationQuery; 64 | console.log(query); 65 | if (!query.client_id) { 66 | throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`); 67 | } 68 | 69 | if (!query.response_type) { 70 | throw ctx.redirect( 71 | redirectErrorURL( 72 | `${ctx.context.baseURL}/error`, 73 | "invalid_request", 74 | "response_type is required", 75 | ), 76 | ); 77 | } 78 | 79 | const client = await ctx.context.adapter 80 | .findOne<Record<string, any>>({ 81 | model: "oauthApplication", 82 | where: [ 83 | { 84 | field: "clientId", 85 | value: ctx.query.client_id, 86 | }, 87 | ], 88 | }) 89 | .then((res) => { 90 | if (!res) { 91 | return null; 92 | } 93 | return { 94 | ...res, 95 | redirectURLs: res.redirectURLs.split(","), 96 | metadata: res.metadata ? JSON.parse(res.metadata) : {}, 97 | } as Client; 98 | }); 99 | console.log(client); 100 | if (!client) { 101 | throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`); 102 | } 103 | const redirectURI = client.redirectURLs.find( 104 | (url) => url === ctx.query.redirect_uri, 105 | ); 106 | 107 | if (!redirectURI || !query.redirect_uri) { 108 | /** 109 | * show UI error here warning the user that the redirect URI is invalid 110 | */ 111 | throw new APIError("BAD_REQUEST", { 112 | message: "Invalid redirect URI", 113 | }); 114 | } 115 | if (client.disabled) { 116 | throw ctx.redirect(`${ctx.context.baseURL}/error?error=client_disabled`); 117 | } 118 | 119 | if (query.response_type !== "code") { 120 | throw ctx.redirect( 121 | `${ctx.context.baseURL}/error?error=unsupported_response_type`, 122 | ); 123 | } 124 | 125 | const requestScope = 126 | query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" "); 127 | const invalidScopes = requestScope.filter((scope) => { 128 | return !opts.scopes.includes(scope); 129 | }); 130 | if (invalidScopes.length) { 131 | throw ctx.redirect( 132 | redirectErrorURL( 133 | query.redirect_uri, 134 | "invalid_scope", 135 | `The following scopes are invalid: ${invalidScopes.join(", ")}`, 136 | ), 137 | ); 138 | } 139 | 140 | if ( 141 | (!query.code_challenge || !query.code_challenge_method) && 142 | options.requirePKCE 143 | ) { 144 | throw ctx.redirect( 145 | redirectErrorURL( 146 | query.redirect_uri, 147 | "invalid_request", 148 | "pkce is required", 149 | ), 150 | ); 151 | } 152 | 153 | if (!query.code_challenge_method) { 154 | query.code_challenge_method = "plain"; 155 | } 156 | 157 | if ( 158 | ![ 159 | "s256", 160 | options.allowPlainCodeChallengeMethod ? "plain" : "s256", 161 | ].includes(query.code_challenge_method?.toLowerCase() || "") 162 | ) { 163 | throw ctx.redirect( 164 | redirectErrorURL( 165 | query.redirect_uri, 166 | "invalid_request", 167 | "invalid code_challenge method", 168 | ), 169 | ); 170 | } 171 | 172 | const code = generateRandomString(32, "a-z", "A-Z", "0-9"); 173 | const codeExpiresInMs = opts.codeExpiresIn * 1000; 174 | const expiresAt = new Date(Date.now() + codeExpiresInMs); 175 | try { 176 | /** 177 | * Save the code in the database 178 | */ 179 | await ctx.context.internalAdapter.createVerificationValue({ 180 | value: JSON.stringify({ 181 | clientId: client.clientId, 182 | redirectURI: query.redirect_uri, 183 | scope: requestScope, 184 | userId: session.user.id, 185 | authTime: new Date(session.session.createdAt).getTime(), 186 | /** 187 | * If the prompt is set to `consent`, then we need 188 | * to require the user to consent to the scopes. 189 | * 190 | * This means the code now needs to be treated as a 191 | * consent request. 192 | * 193 | * once the user consents, the code will be updated 194 | * with the actual code. This is to prevent the 195 | * client from using the code before the user 196 | * consents. 197 | */ 198 | requireConsent: query.prompt === "consent", 199 | state: query.prompt === "consent" ? query.state : null, 200 | codeChallenge: query.code_challenge, 201 | codeChallengeMethod: query.code_challenge_method, 202 | nonce: query.nonce, 203 | }), 204 | identifier: code, 205 | expiresAt, 206 | }); 207 | } catch (e) { 208 | throw ctx.redirect( 209 | redirectErrorURL( 210 | query.redirect_uri, 211 | "server_error", 212 | "An error occurred while processing the request", 213 | ), 214 | ); 215 | } 216 | 217 | const redirectURIWithCode = new URL(redirectURI); 218 | redirectURIWithCode.searchParams.set("code", code); 219 | redirectURIWithCode.searchParams.set("state", ctx.query.state); 220 | 221 | if (query.prompt !== "consent") { 222 | throw ctx.redirect(redirectURIWithCode.toString()); 223 | } 224 | 225 | throw ctx.redirect(redirectURIWithCode.toString()); 226 | } 227 | ``` -------------------------------------------------------------------------------- /demo/nextjs/components/sign-up.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Label } from "@/components/ui/label"; 14 | import { useState, useTransition } from "react"; 15 | import { Loader2, X } from "lucide-react"; 16 | import { signUp } from "@/lib/auth-client"; 17 | import { toast } from "sonner"; 18 | import { useSearchParams, useRouter } from "next/navigation"; 19 | import Link from "next/link"; 20 | import { getCallbackURL } from "@/lib/shared"; 21 | 22 | export function SignUp() { 23 | const [firstName, setFirstName] = useState(""); 24 | const [lastName, setLastName] = useState(""); 25 | const [email, setEmail] = useState(""); 26 | const [password, setPassword] = useState(""); 27 | const [passwordConfirmation, setPasswordConfirmation] = useState(""); 28 | const [image, setImage] = useState<File | null>(null); 29 | const [imagePreview, setImagePreview] = useState<string | null>(null); 30 | const router = useRouter(); 31 | const params = useSearchParams(); 32 | const [loading, startTransition] = useTransition(); 33 | 34 | const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { 35 | const file = e.target.files?.[0]; 36 | if (file) { 37 | setImage(file); 38 | setImagePreview((preview) => { 39 | if (preview) { 40 | URL.revokeObjectURL(preview); 41 | } 42 | return URL.createObjectURL(file); 43 | }); 44 | } 45 | }; 46 | 47 | return ( 48 | <Card className="z-50 rounded-md rounded-t-none max-w-md"> 49 | <CardHeader> 50 | <CardTitle className="text-lg md:text-xl">Sign Up</CardTitle> 51 | <CardDescription className="text-xs md:text-sm"> 52 | Enter your information to create an account 53 | </CardDescription> 54 | </CardHeader> 55 | <CardContent> 56 | <div className="grid gap-4"> 57 | <div className="grid grid-cols-2 gap-4"> 58 | <div className="grid gap-2"> 59 | <Label htmlFor="first-name">First name</Label> 60 | <Input 61 | id="first-name" 62 | placeholder="Max" 63 | required 64 | onChange={(e) => { 65 | setFirstName(e.target.value); 66 | }} 67 | value={firstName} 68 | /> 69 | </div> 70 | <div className="grid gap-2"> 71 | <Label htmlFor="last-name">Last name</Label> 72 | <Input 73 | id="last-name" 74 | placeholder="Robinson" 75 | required 76 | onChange={(e) => { 77 | setLastName(e.target.value); 78 | }} 79 | value={lastName} 80 | /> 81 | </div> 82 | </div> 83 | <div className="grid gap-2"> 84 | <Label htmlFor="email">Email</Label> 85 | <Input 86 | id="email" 87 | type="email" 88 | placeholder="[email protected]" 89 | required 90 | onChange={(e) => { 91 | setEmail(e.target.value); 92 | }} 93 | value={email} 94 | /> 95 | </div> 96 | <div className="grid gap-2"> 97 | <Label htmlFor="password">Password</Label> 98 | <Input 99 | id="password" 100 | type="password" 101 | value={password} 102 | onChange={(e) => setPassword(e.target.value)} 103 | autoComplete="new-password" 104 | placeholder="Password" 105 | /> 106 | </div> 107 | <div className="grid gap-2"> 108 | <Label htmlFor="password">Confirm Password</Label> 109 | <Input 110 | id="password_confirmation" 111 | type="password" 112 | value={passwordConfirmation} 113 | onChange={(e) => setPasswordConfirmation(e.target.value)} 114 | autoComplete="new-password" 115 | placeholder="Confirm Password" 116 | /> 117 | </div> 118 | <div className="grid gap-2"> 119 | <Label htmlFor="image">Profile Image (optional)</Label> 120 | <div className="flex items-end gap-4"> 121 | {imagePreview && ( 122 | <div className="relative w-16 h-16 rounded-sm overflow-hidden"> 123 | <img 124 | src={imagePreview} 125 | alt="Profile preview" 126 | className="object-cover w-full h-full" 127 | /> 128 | </div> 129 | )} 130 | <div className="flex items-center gap-2 w-full"> 131 | <Input 132 | id="image" 133 | type="file" 134 | accept="image/*" 135 | onChange={handleImageChange} 136 | className="w-full" 137 | /> 138 | {imagePreview && ( 139 | <X 140 | className="cursor-pointer" 141 | onClick={() => { 142 | setImage(null); 143 | setImagePreview(null); 144 | }} 145 | /> 146 | )} 147 | </div> 148 | </div> 149 | </div> 150 | <Button 151 | type="submit" 152 | className="w-full" 153 | disabled={loading} 154 | onClick={async () => { 155 | startTransition(async () => { 156 | await signUp.email({ 157 | email, 158 | password, 159 | name: `${firstName} ${lastName}`, 160 | image: image ? await convertImageToBase64(image) : "", 161 | callbackURL: "/dashboard", 162 | fetchOptions: { 163 | onError: (ctx) => { 164 | toast.error(ctx.error.message); 165 | }, 166 | onSuccess: async () => { 167 | toast.success("Successfully signed up"); 168 | router.push(getCallbackURL(params)); 169 | }, 170 | }, 171 | }); 172 | }); 173 | }} 174 | > 175 | {loading ? ( 176 | <Loader2 size={16} className="animate-spin" /> 177 | ) : ( 178 | "Create an account" 179 | )} 180 | </Button> 181 | </div> 182 | </CardContent> 183 | <CardFooter> 184 | <div className="flex justify-center w-full border-t pt-4"> 185 | <p className="text-center text-xs text-neutral-500"> 186 | built with{" "} 187 | <Link 188 | href="https://better-auth.com" 189 | className="underline" 190 | target="_blank" 191 | > 192 | <span className="dark:text-white/70 cursor-pointer"> 193 | better-auth. 194 | </span> 195 | </Link> 196 | </p> 197 | </div> 198 | </CardFooter> 199 | </Card> 200 | ); 201 | } 202 | 203 | async function convertImageToBase64(file: File): Promise<string> { 204 | return new Promise((resolve, reject) => { 205 | const reader = new FileReader(); 206 | reader.onloadend = () => resolve(reader.result as string); 207 | reader.onerror = reject; 208 | reader.readAsDataURL(file); 209 | }); 210 | } 211 | ``` -------------------------------------------------------------------------------- /demo/nextjs/app/dashboard/change-plan.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogDescription, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "@/components/ui/dialog"; 10 | import { Label } from "@/components/ui/label"; 11 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 12 | import { client } from "@/lib/auth-client"; 13 | import { cn } from "@/lib/utils"; 14 | import { ArrowUpFromLine, CreditCard, RefreshCcw } from "lucide-react"; 15 | import { useId, useState } from "react"; 16 | import { toast } from "sonner"; 17 | 18 | function Component(props: { currentPlan?: string; isTrial?: boolean }) { 19 | const [selectedPlan, setSelectedPlan] = useState("plus"); 20 | const id = useId(); 21 | return ( 22 | <Dialog> 23 | <DialogTrigger asChild> 24 | <Button 25 | variant={!props.currentPlan ? "default" : "outline"} 26 | size="sm" 27 | className={cn( 28 | "gap-2", 29 | !props.currentPlan && 30 | " bg-linear-to-br from-purple-100 to-stone-300", 31 | )} 32 | > 33 | {props.currentPlan ? ( 34 | <RefreshCcw className="opacity-80" size={14} strokeWidth={2} /> 35 | ) : ( 36 | <ArrowUpFromLine className="opacity-80" size={14} strokeWidth={2} /> 37 | )} 38 | {props.currentPlan ? "Change Plan" : "Upgrade Plan"} 39 | </Button> 40 | </DialogTrigger> 41 | <DialogContent> 42 | <div className="mb-2 flex flex-col gap-2"> 43 | <div 44 | className="flex size-11 shrink-0 items-center justify-center rounded-full border border-border" 45 | aria-hidden="true" 46 | > 47 | {props.currentPlan ? ( 48 | <RefreshCcw className="opacity-80" size={16} strokeWidth={2} /> 49 | ) : ( 50 | <CreditCard className="opacity-80" size={16} strokeWidth={2} /> 51 | )} 52 | </div> 53 | <DialogHeader> 54 | <DialogTitle className="text-left"> 55 | {!props.currentPlan ? "Upgrade" : "Change"} your plan 56 | </DialogTitle> 57 | <DialogDescription className="text-left"> 58 | Pick one of the following plans. 59 | </DialogDescription> 60 | </DialogHeader> 61 | </div> 62 | 63 | <form className="space-y-5"> 64 | <RadioGroup 65 | className="gap-2" 66 | defaultValue="2" 67 | value={selectedPlan} 68 | onValueChange={(value) => setSelectedPlan(value)} 69 | > 70 | <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-data-[state=checked]:border-ring has-data-[state=checked]:bg-accent"> 71 | <RadioGroupItem 72 | value="plus" 73 | id={`${id}-1`} 74 | aria-describedby={`${id}-1-description`} 75 | className="order-1 after:absolute after:inset-0" 76 | /> 77 | <div className="grid grow gap-1"> 78 | <Label htmlFor={`${id}-1`}>Plus</Label> 79 | <p 80 | id={`${id}-1-description`} 81 | className="text-xs text-muted-foreground" 82 | > 83 | $20/month 84 | </p> 85 | </div> 86 | </div> 87 | <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-data-[state=checked]:border-ring has-data-[state=checked]:bg-accent"> 88 | <RadioGroupItem 89 | value="pro" 90 | id={`${id}-2`} 91 | aria-describedby={`${id}-2-description`} 92 | className="order-1 after:absolute after:inset-0" 93 | /> 94 | <div className="grid grow gap-1"> 95 | <Label htmlFor={`${id}-2`}>Pro</Label> 96 | <p 97 | id={`${id}-2-description`} 98 | className="text-xs text-muted-foreground" 99 | > 100 | $200/month 101 | </p> 102 | </div> 103 | </div> 104 | <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-data-[state=checked]:border-ring has-data-[state=checked]:bg-accent"> 105 | <RadioGroupItem 106 | value="enterprise" 107 | id={`${id}-3`} 108 | aria-describedby={`${id}-3-description`} 109 | className="order-1 after:absolute after:inset-0" 110 | /> 111 | <div className="grid grow gap-1"> 112 | <Label htmlFor={`${id}-3`}>Enterprise</Label> 113 | <p 114 | id={`${id}-3-description`} 115 | className="text-xs text-muted-foreground" 116 | > 117 | Contact our sales team 118 | </p> 119 | </div> 120 | </div> 121 | </RadioGroup> 122 | 123 | <div className="space-y-3"> 124 | <p className="text-xs text-white/70 text-center"> 125 | note: all upgrades takes effect immediately and you'll be charged 126 | the new amount on your next billing cycle. 127 | </p> 128 | </div> 129 | 130 | <div className="grid gap-2"> 131 | <Button 132 | type="button" 133 | className="w-full" 134 | disabled={ 135 | selectedPlan === props.currentPlan?.toLowerCase() && 136 | !props.isTrial 137 | } 138 | onClick={async () => { 139 | if (selectedPlan === "enterprise") { 140 | return; 141 | } 142 | await client.subscription.upgrade( 143 | { 144 | plan: selectedPlan, 145 | }, 146 | { 147 | onError: (ctx) => { 148 | toast.error(ctx.error.message); 149 | }, 150 | }, 151 | ); 152 | }} 153 | > 154 | {selectedPlan === props.currentPlan?.toLowerCase() 155 | ? props.isTrial 156 | ? "Upgrade" 157 | : "Current Plan" 158 | : selectedPlan === "plus" 159 | ? !props.currentPlan 160 | ? "Upgrade" 161 | : "Downgrade" 162 | : selectedPlan === "pro" 163 | ? "Upgrade" 164 | : "Contact us"} 165 | </Button> 166 | {props.currentPlan && ( 167 | <Button 168 | type="button" 169 | variant="destructive" 170 | className="w-full" 171 | onClick={async () => { 172 | await client.subscription.cancel( 173 | { 174 | returnUrl: "/dashboard", 175 | }, 176 | { 177 | onError: (ctx) => { 178 | toast.error(ctx.error.message); 179 | }, 180 | }, 181 | ); 182 | }} 183 | > 184 | Cancel Plan 185 | </Button> 186 | )} 187 | </div> 188 | </form> 189 | </DialogContent> 190 | </Dialog> 191 | ); 192 | } 193 | 194 | export { Component }; 195 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/oauth2/link-account.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; 2 | import { setupServer } from "msw/node"; 3 | import { http, HttpResponse } from "msw"; 4 | import { getTestInstance } from "../test-utils/test-instance"; 5 | import type { GoogleProfile } from "@better-auth/core/social-providers"; 6 | import { DEFAULT_SECRET } from "../utils/constants"; 7 | import { signJWT } from "../crypto"; 8 | import type { User } from "../types"; 9 | 10 | let mockEmail = ""; 11 | let mockEmailVerified = true; 12 | 13 | const server = setupServer(); 14 | 15 | beforeAll(() => { 16 | server.listen({ onUnhandledRequest: "bypass" }); 17 | }); 18 | 19 | afterEach(() => { 20 | server.resetHandlers(); 21 | }); 22 | 23 | afterAll(() => server.close()); 24 | 25 | describe("oauth2 - email verification on link", async () => { 26 | const { auth, client, cookieSetter } = await getTestInstance({ 27 | socialProviders: { 28 | google: { 29 | clientId: "test", 30 | clientSecret: "test", 31 | enabled: true, 32 | }, 33 | }, 34 | emailAndPassword: { 35 | enabled: true, 36 | requireEmailVerification: true, 37 | }, 38 | account: { 39 | accountLinking: { 40 | enabled: true, 41 | trustedProviders: ["google"], 42 | }, 43 | }, 44 | }); 45 | 46 | const ctx = await auth.$context; 47 | 48 | async function linkGoogleAccount() { 49 | server.use( 50 | http.post("https://oauth2.googleapis.com/token", async () => { 51 | const profile: GoogleProfile = { 52 | email: mockEmail, 53 | email_verified: mockEmailVerified, 54 | name: "Test User", 55 | picture: "https://example.com/photo.jpg", 56 | exp: 1234567890, 57 | sub: "google_oauth_sub_1234567890", 58 | iat: 1234567890, 59 | aud: "test", 60 | azp: "test", 61 | nbf: 1234567890, 62 | iss: "test", 63 | locale: "en", 64 | jti: "test", 65 | given_name: "Test", 66 | family_name: "User", 67 | }; 68 | const idToken = await signJWT(profile, DEFAULT_SECRET); 69 | return HttpResponse.json({ 70 | access_token: "test_access_token", 71 | refresh_token: "test_refresh_token", 72 | id_token: idToken, 73 | }); 74 | }), 75 | ); 76 | 77 | const oAuthHeaders = new Headers(); 78 | const signInRes = await client.signIn.social({ 79 | provider: "google", 80 | callbackURL: "/", 81 | fetchOptions: { 82 | onSuccess: cookieSetter(oAuthHeaders), 83 | }, 84 | }); 85 | const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; 86 | await client.$fetch("/callback/google", { 87 | query: { state, code: "test_code" }, 88 | method: "GET", 89 | headers: oAuthHeaders, 90 | onError(context) { 91 | expect(context.response.status).toBe(302); 92 | }, 93 | }); 94 | } 95 | 96 | it("should update emailVerified when linking account with verified email", async () => { 97 | const testEmail = "[email protected]"; 98 | 99 | // Create user with unverified email 100 | mockEmail = testEmail; 101 | mockEmailVerified = false; 102 | 103 | const signUpRes = await client.signUp.email({ 104 | email: testEmail, 105 | password: "password123", 106 | name: "Test User", 107 | }); 108 | 109 | const userId = signUpRes.data!.user.id; 110 | 111 | // Verify initial state 112 | let user = await ctx.adapter.findOne<User>({ 113 | model: "user", 114 | where: [{ field: "id", value: userId }], 115 | }); 116 | expect(user?.emailVerified).toBe(false); 117 | 118 | // Link with Google account that has verified email 119 | mockEmailVerified = true; 120 | await linkGoogleAccount(); 121 | 122 | // Verify email is now verified 123 | user = await ctx.adapter.findOne<User>({ 124 | model: "user", 125 | where: [{ field: "id", value: userId }], 126 | }); 127 | expect(user?.emailVerified).toBe(true); 128 | }); 129 | 130 | it("should not update emailVerified when provider reports unverified", async () => { 131 | const testEmail = "[email protected]"; 132 | 133 | // Create user with unverified email 134 | mockEmail = testEmail; 135 | mockEmailVerified = false; 136 | 137 | const signUpRes = await client.signUp.email({ 138 | email: testEmail, 139 | password: "password123", 140 | name: "Unverified User", 141 | }); 142 | 143 | const userId = signUpRes.data!.user.id; 144 | 145 | // Link Google account with unverified email from provider 146 | await linkGoogleAccount(); 147 | 148 | // Verify email remains unverified 149 | const user = await ctx.adapter.findOne<User>({ 150 | model: "user", 151 | where: [{ field: "id", value: userId }], 152 | }); 153 | expect(user?.emailVerified).toBe(false); 154 | }); 155 | 156 | it("should not update emailVerified when email addresses don't match", async () => { 157 | const userEmail = "[email protected]"; 158 | const googleEmail = "[email protected]"; 159 | 160 | // Create user with one email 161 | mockEmail = userEmail; 162 | mockEmailVerified = false; 163 | 164 | const signUpRes = await client.signUp.email({ 165 | email: userEmail, 166 | password: "password123", 167 | name: "Test User", 168 | }); 169 | 170 | const userId = signUpRes.data!.user.id; 171 | 172 | // Verify initial state 173 | let user = await ctx.adapter.findOne<User>({ 174 | model: "user", 175 | where: [{ field: "id", value: userId }], 176 | }); 177 | expect(user?.emailVerified).toBe(false); 178 | 179 | // Try to link with Google using different email (verified) 180 | mockEmail = googleEmail; 181 | mockEmailVerified = true; 182 | await linkGoogleAccount(); 183 | 184 | // Verify emailVerified remains false (emails don't match) 185 | user = await ctx.adapter.findOne<User>({ 186 | model: "user", 187 | where: [{ field: "id", value: userId }], 188 | }); 189 | expect(user?.emailVerified).toBe(false); 190 | }); 191 | 192 | it("should handle already verified emails gracefully", async () => { 193 | const testEmail = "[email protected]"; 194 | 195 | // Create user with verified email 196 | mockEmail = testEmail; 197 | mockEmailVerified = true; 198 | 199 | const signUpRes = await client.signUp.email({ 200 | email: testEmail, 201 | password: "password123", 202 | name: "Verified User", 203 | }); 204 | 205 | const userId = signUpRes.data!.user.id; 206 | 207 | // Manually set emailVerified to true 208 | await ctx.adapter.update({ 209 | model: "user", 210 | where: [{ field: "id", value: userId }], 211 | update: { emailVerified: true }, 212 | }); 213 | 214 | // Link with Google account (also verified) 215 | await linkGoogleAccount(); 216 | 217 | // Verify email remains verified 218 | const user = await ctx.adapter.findOne<User>({ 219 | model: "user", 220 | where: [{ field: "id", value: userId }], 221 | }); 222 | expect(user?.emailVerified).toBe(true); 223 | }); 224 | }); 225 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/middlewares/origin-check.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "better-call"; 2 | import { createAuthMiddleware } from "@better-auth/core/api"; 3 | import { wildcardMatch } from "../../utils/wildcard"; 4 | import { getHost, getOrigin, getProtocol } from "../../utils/url"; 5 | import type { GenericEndpointContext } from "@better-auth/core"; 6 | 7 | /** 8 | * A middleware to validate callbackURL and origin against 9 | * trustedOrigins. 10 | */ 11 | export const originCheckMiddleware = createAuthMiddleware(async (ctx) => { 12 | if (ctx.request?.method !== "POST" || !ctx.request) { 13 | return; 14 | } 15 | const headers = ctx.request?.headers; 16 | const request = ctx.request; 17 | const { body, query, context } = ctx; 18 | /** 19 | * We only allow requests with the x-auth-request header set to 20 | * true or application/json content type. This is to prevent 21 | * simple requests from being processed 22 | */ 23 | if (isSimpleRequest(headers) && !ctx.context.skipCSRFCheck) { 24 | throw new APIError("FORBIDDEN", { message: "Invalid request" }); 25 | } 26 | const originHeader = headers?.get("origin") || headers?.get("referer") || ""; 27 | const callbackURL = body?.callbackURL || query?.callbackURL; 28 | const redirectURL = body?.redirectTo; 29 | const errorCallbackURL = body?.errorCallbackURL; 30 | const newUserCallbackURL = body?.newUserCallbackURL; 31 | 32 | const trustedOrigins: string[] = Array.isArray(context.options.trustedOrigins) 33 | ? context.trustedOrigins 34 | : [ 35 | ...context.trustedOrigins, 36 | ...((await context.options.trustedOrigins?.(request)) || []), 37 | ]; 38 | const useCookies = headers?.has("cookie"); 39 | 40 | const matchesPattern = (url: string, pattern: string): boolean => { 41 | if (url.startsWith("/")) { 42 | return false; 43 | } 44 | if (pattern.includes("*")) { 45 | // For protocol-specific wildcards, match the full origin 46 | if (pattern.includes("://")) { 47 | return wildcardMatch(pattern)(getOrigin(url) || url); 48 | } 49 | // For host-only wildcards, match just the host 50 | return wildcardMatch(pattern)(getHost(url)); 51 | } 52 | 53 | const protocol = getProtocol(url); 54 | return protocol === "http:" || protocol === "https:" || !protocol 55 | ? pattern === getOrigin(url) 56 | : url.startsWith(pattern); 57 | }; 58 | const validateURL = (url: string | undefined, label: string) => { 59 | if (!url) { 60 | return; 61 | } 62 | const isTrustedOrigin = trustedOrigins.some( 63 | (origin) => 64 | matchesPattern(url, origin) || 65 | (url?.startsWith("/") && 66 | label !== "origin" && 67 | /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test(url)), 68 | ); 69 | if (!isTrustedOrigin) { 70 | ctx.context.logger.error(`Invalid ${label}: ${url}`); 71 | ctx.context.logger.info( 72 | `If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`, 73 | `Current list of trustedOrigins: ${trustedOrigins}`, 74 | ); 75 | throw new APIError("FORBIDDEN", { message: `Invalid ${label}` }); 76 | } 77 | }; 78 | if ( 79 | useCookies && 80 | !ctx.context.skipCSRFCheck && 81 | !ctx.context.skipOriginCheck 82 | ) { 83 | if (!originHeader || originHeader === "null") { 84 | throw new APIError("FORBIDDEN", { message: "Missing or null Origin" }); 85 | } 86 | validateURL(originHeader, "origin"); 87 | } 88 | callbackURL && validateURL(callbackURL, "callbackURL"); 89 | redirectURL && validateURL(redirectURL, "redirectURL"); 90 | errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL"); 91 | newUserCallbackURL && validateURL(newUserCallbackURL, "newUserCallbackURL"); 92 | }); 93 | 94 | export const originCheck = ( 95 | getValue: (ctx: GenericEndpointContext) => string | string[], 96 | ) => 97 | createAuthMiddleware(async (ctx) => { 98 | if (!ctx.request) { 99 | return; 100 | } 101 | const { context } = ctx; 102 | const callbackURL = getValue(ctx); 103 | const trustedOrigins: string[] = Array.isArray( 104 | context.options.trustedOrigins, 105 | ) 106 | ? context.trustedOrigins 107 | : [ 108 | ...context.trustedOrigins, 109 | ...((await context.options.trustedOrigins?.(ctx.request)) || []), 110 | ]; 111 | 112 | const matchesPattern = (url: string, pattern: string): boolean => { 113 | if (url.startsWith("/")) { 114 | return false; 115 | } 116 | if (pattern.includes("*")) { 117 | // For protocol-specific wildcards, match the full origin 118 | if (pattern.includes("://")) { 119 | return wildcardMatch(pattern)(getOrigin(url) || url); 120 | } 121 | // For host-only wildcards, match just the host 122 | return wildcardMatch(pattern)(getHost(url)); 123 | } 124 | const protocol = getProtocol(url); 125 | return protocol === "http:" || protocol === "https:" || !protocol 126 | ? pattern === getOrigin(url) 127 | : url.startsWith(pattern); 128 | }; 129 | 130 | const validateURL = (url: string | undefined, label: string) => { 131 | if (!url) { 132 | return; 133 | } 134 | const isTrustedOrigin = trustedOrigins.some( 135 | (origin) => 136 | matchesPattern(url, origin) || 137 | (url?.startsWith("/") && 138 | label !== "origin" && 139 | /^\/(?!\/|\\|%2f|%5c)[\w\-.\+/@]*(?:\?[\w\-.\+/=&%@]*)?$/.test( 140 | url, 141 | )), 142 | ); 143 | if (!isTrustedOrigin) { 144 | ctx.context.logger.error(`Invalid ${label}: ${url}`); 145 | ctx.context.logger.info( 146 | `If it's a valid URL, please add ${url} to trustedOrigins in your auth config\n`, 147 | `Current list of trustedOrigins: ${trustedOrigins}`, 148 | ); 149 | throw new APIError("FORBIDDEN", { message: `Invalid ${label}` }); 150 | } 151 | }; 152 | const callbacks = Array.isArray(callbackURL) ? callbackURL : [callbackURL]; 153 | for (const url of callbacks) { 154 | validateURL(url, "callbackURL"); 155 | } 156 | }); 157 | 158 | export function isSimpleRequest(headers: Headers) { 159 | const SIMPLE_HEADERS = [ 160 | "accept", 161 | "accept-language", 162 | "content-language", 163 | "content-type", 164 | ]; 165 | const SIMPLE_CONTENT_TYPES = [ 166 | "application/x-www-form-urlencoded", 167 | "multipart/form-data", 168 | "text/plain", 169 | ]; 170 | for (const [key, value] of headers.entries()) { 171 | if (!SIMPLE_HEADERS.includes(key.toLowerCase())) { 172 | return false; // has non-simple header 173 | } 174 | if ( 175 | key.toLowerCase() === "content-type" && 176 | !SIMPLE_CONTENT_TYPES.includes( 177 | value?.split(";")[0]?.trim()?.toLowerCase() || "", 178 | ) 179 | ) { 180 | return false; 181 | } 182 | } 183 | return true; 184 | } 185 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/list-api-keys.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { sessionMiddleware } from "../../../api"; 2 | import { createAuthEndpoint } from "@better-auth/core/api"; 3 | import type { apiKeySchema } from "../schema"; 4 | import type { ApiKey } from "../types"; 5 | import type { PredefinedApiKeyOptions } from "."; 6 | import { safeJSONParse } from "../../../utils/json"; 7 | import { API_KEY_TABLE_NAME } from ".."; 8 | import type { AuthContext } from "@better-auth/core"; 9 | export function listApiKeys({ 10 | opts, 11 | schema, 12 | deleteAllExpiredApiKeys, 13 | }: { 14 | opts: PredefinedApiKeyOptions; 15 | schema: ReturnType<typeof apiKeySchema>; 16 | deleteAllExpiredApiKeys( 17 | ctx: AuthContext, 18 | byPassLastCheckTime?: boolean, 19 | ): void; 20 | }) { 21 | return createAuthEndpoint( 22 | "/api-key/list", 23 | { 24 | method: "GET", 25 | use: [sessionMiddleware], 26 | metadata: { 27 | openapi: { 28 | description: "List all API keys for the authenticated user", 29 | responses: { 30 | "200": { 31 | description: "API keys retrieved successfully", 32 | content: { 33 | "application/json": { 34 | schema: { 35 | type: "array", 36 | items: { 37 | type: "object", 38 | properties: { 39 | id: { 40 | type: "string", 41 | description: "ID", 42 | }, 43 | name: { 44 | type: "string", 45 | nullable: true, 46 | description: "The name of the key", 47 | }, 48 | start: { 49 | type: "string", 50 | nullable: true, 51 | description: 52 | "Shows the first few characters of the API key, including the prefix. This allows you to show those few characters in the UI to make it easier for users to identify the API key.", 53 | }, 54 | prefix: { 55 | type: "string", 56 | nullable: true, 57 | description: 58 | "The API Key prefix. Stored as plain text.", 59 | }, 60 | userId: { 61 | type: "string", 62 | description: "The owner of the user id", 63 | }, 64 | refillInterval: { 65 | type: "number", 66 | nullable: true, 67 | description: 68 | "The interval in milliseconds between refills of the `remaining` count. Example: 3600000 // refill every hour (3600000ms = 1h)", 69 | }, 70 | refillAmount: { 71 | type: "number", 72 | nullable: true, 73 | description: "The amount to refill", 74 | }, 75 | lastRefillAt: { 76 | type: "string", 77 | format: "date-time", 78 | nullable: true, 79 | description: "The last refill date", 80 | }, 81 | enabled: { 82 | type: "boolean", 83 | description: "Sets if key is enabled or disabled", 84 | default: true, 85 | }, 86 | rateLimitEnabled: { 87 | type: "boolean", 88 | description: 89 | "Whether the key has rate limiting enabled", 90 | }, 91 | rateLimitTimeWindow: { 92 | type: "number", 93 | nullable: true, 94 | description: "The duration in milliseconds", 95 | }, 96 | rateLimitMax: { 97 | type: "number", 98 | nullable: true, 99 | description: 100 | "Maximum amount of requests allowed within a window", 101 | }, 102 | requestCount: { 103 | type: "number", 104 | description: 105 | "The number of requests made within the rate limit time window", 106 | }, 107 | remaining: { 108 | type: "number", 109 | nullable: true, 110 | description: 111 | "Remaining requests (every time api key is used this should updated and should be updated on refill as well)", 112 | }, 113 | lastRequest: { 114 | type: "string", 115 | format: "date-time", 116 | nullable: true, 117 | description: "When last request occurred", 118 | }, 119 | expiresAt: { 120 | type: "string", 121 | format: "date-time", 122 | nullable: true, 123 | description: "Expiry date of a key", 124 | }, 125 | createdAt: { 126 | type: "string", 127 | format: "date-time", 128 | description: "created at", 129 | }, 130 | updatedAt: { 131 | type: "string", 132 | format: "date-time", 133 | description: "updated at", 134 | }, 135 | metadata: { 136 | type: "object", 137 | nullable: true, 138 | additionalProperties: true, 139 | description: "Extra metadata about the apiKey", 140 | }, 141 | permissions: { 142 | type: "string", 143 | nullable: true, 144 | description: 145 | "Permissions for the api key (stored as JSON string)", 146 | }, 147 | }, 148 | required: [ 149 | "id", 150 | "userId", 151 | "enabled", 152 | "rateLimitEnabled", 153 | "requestCount", 154 | "createdAt", 155 | "updatedAt", 156 | ], 157 | }, 158 | }, 159 | }, 160 | }, 161 | }, 162 | }, 163 | }, 164 | }, 165 | }, 166 | async (ctx) => { 167 | const session = ctx.context.session; 168 | let apiKeys = await ctx.context.adapter.findMany<ApiKey>({ 169 | model: API_KEY_TABLE_NAME, 170 | where: [ 171 | { 172 | field: "userId", 173 | value: session.user.id, 174 | }, 175 | ], 176 | }); 177 | 178 | deleteAllExpiredApiKeys(ctx.context); 179 | apiKeys = apiKeys.map((apiKey) => { 180 | return { 181 | ...apiKey, 182 | metadata: schema.apikey.fields.metadata.transform.output( 183 | apiKey.metadata as never as string, 184 | ), 185 | }; 186 | }); 187 | 188 | let returningApiKey = apiKeys.map((x) => { 189 | const { key, ...returningApiKey } = x; 190 | return { 191 | ...returningApiKey, 192 | permissions: returningApiKey.permissions 193 | ? safeJSONParse<{ 194 | [key: string]: string[]; 195 | }>(returningApiKey.permissions) 196 | : null, 197 | }; 198 | }); 199 | 200 | return ctx.json(returningApiKey); 201 | }, 202 | ); 203 | } 204 | ```