This is page 33 of 52. Use http://codebase.md/better-auth/better-auth?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ ├── CODEOWNERS
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── e2e.yml
│ ├── preview.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode
│ └── settings.json
├── banner-dark.png
├── banner.png
├── biome.json
├── bump.config.ts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── demo
│ ├── expo-example
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app.config.ts
│ │ ├── assets
│ │ │ ├── bg-image.jpeg
│ │ │ ├── fonts
│ │ │ │ └── SpaceMono-Regular.ttf
│ │ │ ├── icon.png
│ │ │ └── images
│ │ │ ├── adaptive-icon.png
│ │ │ ├── favicon.png
│ │ │ ├── logo.png
│ │ │ ├── partial-react-logo.png
│ │ │ ├── react-logo.png
│ │ │ ├── [email protected]
│ │ │ ├── [email protected]
│ │ │ └── splash.png
│ │ ├── babel.config.js
│ │ ├── components.json
│ │ ├── expo-env.d.ts
│ │ ├── index.ts
│ │ ├── metro.config.js
│ │ ├── nativewind-env.d.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...route]+api.ts
│ │ │ │ ├── dashboard.tsx
│ │ │ │ ├── forget-password.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── sign-up.tsx
│ │ │ ├── components
│ │ │ │ ├── icons
│ │ │ │ │ └── google.tsx
│ │ │ │ └── ui
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ └── text.tsx
│ │ │ ├── global.css
│ │ │ └── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth.ts
│ │ │ ├── icons
│ │ │ │ ├── iconWithClassName.ts
│ │ │ │ └── X.tsx
│ │ │ └── utils.ts
│ │ ├── tailwind.config.js
│ │ └── tsconfig.json
│ ├── nextjs
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── (auth)
│ │ │ │ ├── forget-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── sign-in
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── two-factor
│ │ │ │ ├── otp
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── accept-invitation
│ │ │ │ └── [id]
│ │ │ │ ├── invitation-error.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── admin
│ │ │ │ └── page.tsx
│ │ │ ├── api
│ │ │ │ └── auth
│ │ │ │ └── [...all]
│ │ │ │ └── route.ts
│ │ │ ├── apps
│ │ │ │ └── register
│ │ │ │ └── page.tsx
│ │ │ ├── client-test
│ │ │ │ └── page.tsx
│ │ │ ├── dashboard
│ │ │ │ ├── change-plan.tsx
│ │ │ │ ├── client.tsx
│ │ │ │ ├── organization-card.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── upgrade-button.tsx
│ │ │ │ └── user-card.tsx
│ │ │ ├── device
│ │ │ │ ├── approve
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── denied
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── success
│ │ │ │ └── page.tsx
│ │ │ ├── favicon.ico
│ │ │ ├── features.tsx
│ │ │ ├── fonts
│ │ │ │ ├── GeistMonoVF.woff
│ │ │ │ └── GeistVF.woff
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── oauth
│ │ │ │ └── authorize
│ │ │ │ ├── concet-buttons.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── pricing
│ │ │ └── page.tsx
│ │ ├── components
│ │ │ ├── account-switch.tsx
│ │ │ ├── blocks
│ │ │ │ └── pricing.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── one-tap.tsx
│ │ │ ├── sign-in-btn.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── tier-labels.tsx
│ │ │ ├── ui
│ │ │ │ ├── accordion.tsx
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── aspect-ratio.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── calendar.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── carousel.tsx
│ │ │ │ ├── chart.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── context-menu.tsx
│ │ │ │ ├── copy-button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ ├── hover-card.tsx
│ │ │ │ ├── input-otp.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── menubar.tsx
│ │ │ │ ├── navigation-menu.tsx
│ │ │ │ ├── pagination.tsx
│ │ │ │ ├── password-input.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── resizable.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ ├── sonner.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ ├── tabs2.tsx
│ │ │ │ ├── textarea.tsx
│ │ │ │ ├── toast.tsx
│ │ │ │ ├── toaster.tsx
│ │ │ │ ├── toggle-group.tsx
│ │ │ │ ├── toggle.tsx
│ │ │ │ └── tooltip.tsx
│ │ │ └── wrapper.tsx
│ │ ├── components.json
│ │ ├── hooks
│ │ │ └── use-toast.ts
│ │ ├── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth-types.ts
│ │ │ ├── auth.ts
│ │ │ ├── email
│ │ │ │ ├── invitation.tsx
│ │ │ │ ├── resend.ts
│ │ │ │ └── reset-password.tsx
│ │ │ ├── metadata.ts
│ │ │ ├── shared.ts
│ │ │ └── utils.ts
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── proxy.ts
│ │ ├── public
│ │ │ ├── __og.png
│ │ │ ├── _og.png
│ │ │ ├── favicon
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── light
│ │ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ │ ├── apple-touch-icon.png
│ │ │ │ │ ├── favicon-16x16.png
│ │ │ │ │ ├── favicon-32x32.png
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ └── site.webmanifest
│ │ │ │ └── site.webmanifest
│ │ │ ├── logo.svg
│ │ │ └── og.png
│ │ ├── README.md
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── turbo.json
│ └── stateless
│ ├── .env.example
│ ├── .gitignore
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src
│ │ ├── app
│ │ │ ├── api
│ │ │ │ ├── auth
│ │ │ │ │ └── [...all]
│ │ │ │ │ └── route.ts
│ │ │ │ └── user
│ │ │ │ └── route.ts
│ │ │ ├── dashboard
│ │ │ │ └── page.tsx
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ └── lib
│ │ ├── auth-client.ts
│ │ └── auth.ts
│ ├── tailwind.config.ts
│ └── tsconfig.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
│ │ │ ├── polar.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
│ ├── tsconfig.json
│ └── turbo.json
├── e2e
│ ├── integration
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── solid-vinxi
│ │ │ ├── .gitignore
│ │ │ ├── app.config.ts
│ │ │ ├── e2e
│ │ │ │ ├── test.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── package.json
│ │ │ ├── public
│ │ │ │ └── favicon.ico
│ │ │ ├── src
│ │ │ │ ├── app.tsx
│ │ │ │ ├── entry-client.tsx
│ │ │ │ ├── entry-server.tsx
│ │ │ │ ├── global.d.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── auth-client.ts
│ │ │ │ │ └── auth.ts
│ │ │ │ └── routes
│ │ │ │ ├── [...404].tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...all].ts
│ │ │ │ └── index.tsx
│ │ │ └── tsconfig.json
│ │ ├── test-utils
│ │ │ ├── package.json
│ │ │ └── src
│ │ │ └── playwright.ts
│ │ └── vanilla-node
│ │ ├── e2e
│ │ │ ├── app.ts
│ │ │ ├── domain.spec.ts
│ │ │ ├── postgres-js.spec.ts
│ │ │ ├── test.spec.ts
│ │ │ └── utils.ts
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── main.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── smoke
│ ├── package.json
│ ├── test
│ │ ├── bun.spec.ts
│ │ ├── cloudflare.spec.ts
│ │ ├── deno.spec.ts
│ │ ├── fixtures
│ │ │ ├── bun-simple.ts
│ │ │ ├── cloudflare
│ │ │ │ ├── .gitignore
│ │ │ │ ├── drizzle
│ │ │ │ │ ├── 0000_clean_vector.sql
│ │ │ │ │ └── meta
│ │ │ │ │ ├── _journal.json
│ │ │ │ │ └── 0000_snapshot.json
│ │ │ │ ├── drizzle.config.ts
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── auth-schema.ts
│ │ │ │ │ ├── db.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test
│ │ │ │ │ ├── apply-migrations.ts
│ │ │ │ │ ├── env.d.ts
│ │ │ │ │ └── index.test.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.ts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ └── wrangler.json
│ │ │ ├── deno-simple.ts
│ │ │ ├── tsconfig-declaration
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── demo.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-exact-optional-property-types
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── user-additional-fields.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-isolated-module-bundler
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-verbatim-module-syntax-node10
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── vite
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── client.ts
│ │ │ │ └── server.ts
│ │ │ ├── tsconfig.json
│ │ │ └── vite.config.ts
│ │ ├── ssr.ts
│ │ ├── typecheck.spec.ts
│ │ └── vite.spec.ts
│ └── tsconfig.json
├── LICENSE.md
├── package.json
├── packages
│ ├── better-auth
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── __snapshots__
│ │ │ │ └── init.test.ts.snap
│ │ │ ├── adapters
│ │ │ │ ├── adapter-factory
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ │ └── adapter-factory.test.ts.snap
│ │ │ │ │ │ └── adapter-factory.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── create-test-suite.ts
│ │ │ │ ├── drizzle-adapter
│ │ │ │ │ ├── drizzle-adapter.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── adapter.drizzle.mysql.test.ts
│ │ │ │ │ ├── adapter.drizzle.pg.test.ts
│ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts
│ │ │ │ │ └── generate-schema.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely-adapter
│ │ │ │ │ ├── bun-sqlite-dialect.ts
│ │ │ │ │ ├── dialect.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── kysely-adapter.ts
│ │ │ │ │ ├── node-sqlite-dialect.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg-custom-schema.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-schema.test.ts
│ │ │ │ ├── get-migration.ts
│ │ │ │ ├── get-schema.ts
│ │ │ │ ├── get-tables.test.ts
│ │ │ │ ├── get-tables.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── internal-adapter.test.ts
│ │ │ │ ├── internal-adapter.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── secondary-storage.test.ts
│ │ │ │ ├── to-zod.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── with-hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── init.test.ts
│ │ │ ├── init.ts
│ │ │ ├── integrations
│ │ │ │ ├── next-js.ts
│ │ │ │ ├── node.ts
│ │ │ │ ├── react-start.ts
│ │ │ │ ├── solid-start.ts
│ │ │ │ └── svelte-kit.ts
│ │ │ ├── oauth2
│ │ │ │ ├── index.ts
│ │ │ │ ├── link-account.test.ts
│ │ │ │ ├── link-account.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── utils.ts
│ │ │ ├── plugins
│ │ │ │ ├── access
│ │ │ │ │ ├── access.test.ts
│ │ │ │ │ ├── access.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── additional-fields
│ │ │ │ │ ├── additional-fields.test.ts
│ │ │ │ │ └── client.ts
│ │ │ │ ├── admin
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── admin.test.ts
│ │ │ │ │ ├── admin.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── anonymous
│ │ │ │ │ ├── anon.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── api-key
│ │ │ │ │ ├── api-key.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── create-api-key.ts
│ │ │ │ │ │ ├── delete-all-expired-api-keys.ts
│ │ │ │ │ │ ├── delete-api-key.ts
│ │ │ │ │ │ ├── get-api-key.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── list-api-keys.ts
│ │ │ │ │ │ ├── update-api-key.ts
│ │ │ │ │ │ └── verify-api-key.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── bearer
│ │ │ │ │ ├── bearer.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── captcha
│ │ │ │ │ ├── captcha.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-handlers
│ │ │ │ │ ├── captchafox.ts
│ │ │ │ │ ├── cloudflare-turnstile.ts
│ │ │ │ │ ├── google-recaptcha.ts
│ │ │ │ │ ├── h-captcha.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── custom-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-session.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── device-authorization
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── device-authorization.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── email-otp
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── email-otp.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── generic-oauth
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── generic-oauth.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── haveibeenpwned
│ │ │ │ │ ├── haveibeenpwned.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── jwt.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── sign.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── last-login-method
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-prefix.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── last-login-method.test.ts
│ │ │ │ ├── magic-link
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── magic-link.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── mcp
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mcp.test.ts
│ │ │ │ ├── multi-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── multi-session.test.ts
│ │ │ │ ├── oauth-proxy
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── oauth-proxy.test.ts
│ │ │ │ ├── oidc-provider
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── oidc.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── ui.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── one-tap
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── one-time-token
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── one-time-token.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── open-api
│ │ │ │ │ ├── generator.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── logo.ts
│ │ │ │ │ └── open-api.test.ts
│ │ │ │ ├── organization
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── call.ts
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization-hook.test.ts
│ │ │ │ │ ├── organization.test.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── permission.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── crud-access-control.test.ts
│ │ │ │ │ │ ├── crud-access-control.ts
│ │ │ │ │ │ ├── crud-invites.ts
│ │ │ │ │ │ ├── crud-members.test.ts
│ │ │ │ │ │ ├── crud-members.ts
│ │ │ │ │ │ ├── crud-org.test.ts
│ │ │ │ │ │ ├── crud-org.ts
│ │ │ │ │ │ └── crud-team.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── team.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── passkey
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── passkey.test.ts
│ │ │ │ ├── phone-number
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── phone-number-error.ts
│ │ │ │ │ └── phone-number.test.ts
│ │ │ │ ├── siwe
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── siwe.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── two-factor
│ │ │ │ │ ├── backup-codes
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── constant.ts
│ │ │ │ │ ├── error-code.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── otp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── totp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── two-factor.test.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-two-factor.ts
│ │ │ │ └── username
│ │ │ │ ├── client.ts
│ │ │ │ ├── error-codes.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── username.test.ts
│ │ │ ├── social-providers
│ │ │ │ └── index.ts
│ │ │ ├── social.test.ts
│ │ │ ├── test-utils
│ │ │ │ ├── headers.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── test-instance.ts
│ │ │ ├── types
│ │ │ │ ├── adapter.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models.ts
│ │ │ │ ├── plugins.ts
│ │ │ │ └── types.test.ts
│ │ │ └── utils
│ │ │ ├── await-object.ts
│ │ │ ├── boolean.ts
│ │ │ ├── clone.ts
│ │ │ ├── constants.ts
│ │ │ ├── date.ts
│ │ │ ├── ensure-utc.ts
│ │ │ ├── get-request-ip.ts
│ │ │ ├── hashing.ts
│ │ │ ├── hide-metadata.ts
│ │ │ ├── id.ts
│ │ │ ├── import-util.ts
│ │ │ ├── index.ts
│ │ │ ├── is-atom.ts
│ │ │ ├── is-promise.ts
│ │ │ ├── json.ts
│ │ │ ├── merger.ts
│ │ │ ├── middleware-response.ts
│ │ │ ├── misc.ts
│ │ │ ├── password.ts
│ │ │ ├── plugin-helper.ts
│ │ │ ├── shim.ts
│ │ │ ├── time.ts
│ │ │ ├── url.ts
│ │ │ └── wildcard.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ ├── vitest.config.ts
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── commands
│ │ │ │ ├── generate.ts
│ │ │ │ ├── info.ts
│ │ │ │ ├── init.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── mcp.ts
│ │ │ │ ├── migrate.ts
│ │ │ │ └── secret.ts
│ │ │ ├── generators
│ │ │ │ ├── auth-config.ts
│ │ │ │ ├── drizzle.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── types.ts
│ │ │ ├── index.ts
│ │ │ └── utils
│ │ │ ├── add-svelte-kit-env-modules.ts
│ │ │ ├── check-package-managers.ts
│ │ │ ├── format-ms.ts
│ │ │ ├── get-config.ts
│ │ │ ├── get-package-info.ts
│ │ │ ├── get-tsconfig-info.ts
│ │ │ └── install-dependencies.ts
│ │ ├── test
│ │ │ ├── __snapshots__
│ │ │ │ ├── auth-schema-mysql-enum.txt
│ │ │ │ ├── auth-schema-mysql-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey.txt
│ │ │ │ ├── auth-schema-mysql.txt
│ │ │ │ ├── auth-schema-number-id.txt
│ │ │ │ ├── auth-schema-pg-enum.txt
│ │ │ │ ├── auth-schema-pg-passkey.txt
│ │ │ │ ├── auth-schema-sqlite-enum.txt
│ │ │ │ ├── auth-schema-sqlite-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey.txt
│ │ │ │ ├── auth-schema-sqlite.txt
│ │ │ │ ├── auth-schema.txt
│ │ │ │ ├── migrations.sql
│ │ │ │ ├── schema-mongodb.prisma
│ │ │ │ ├── schema-mysql-custom.prisma
│ │ │ │ ├── schema-mysql.prisma
│ │ │ │ ├── schema-numberid.prisma
│ │ │ │ └── schema.prisma
│ │ │ ├── generate-all-db.test.ts
│ │ │ ├── generate.test.ts
│ │ │ ├── get-config.test.ts
│ │ │ ├── info.test.ts
│ │ │ └── migrate.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── core
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── api
│ │ │ │ └── index.ts
│ │ │ ├── async_hooks
│ │ │ │ └── index.ts
│ │ │ ├── context
│ │ │ │ ├── endpoint-context.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── transaction.ts
│ │ │ ├── db
│ │ │ │ ├── adapter
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── plugin.ts
│ │ │ │ ├── schema
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── user.ts
│ │ │ │ │ └── verification.ts
│ │ │ │ └── type.ts
│ │ │ ├── env
│ │ │ │ ├── color-depth.ts
│ │ │ │ ├── env-impl.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.test.ts
│ │ │ │ └── logger.ts
│ │ │ ├── error
│ │ │ │ ├── codes.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── oauth2
│ │ │ │ ├── client-credentials-token.ts
│ │ │ │ ├── create-authorization-url.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── oauth-provider.ts
│ │ │ │ ├── refresh-access-token.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── validate-authorization-code.ts
│ │ │ ├── social-providers
│ │ │ │ ├── apple.ts
│ │ │ │ ├── atlassian.ts
│ │ │ │ ├── cognito.ts
│ │ │ │ ├── discord.ts
│ │ │ │ ├── dropbox.ts
│ │ │ │ ├── facebook.ts
│ │ │ │ ├── figma.ts
│ │ │ │ ├── github.ts
│ │ │ │ ├── gitlab.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── huggingface.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kakao.ts
│ │ │ │ ├── kick.ts
│ │ │ │ ├── line.ts
│ │ │ │ ├── linear.ts
│ │ │ │ ├── linkedin.ts
│ │ │ │ ├── microsoft-entra-id.ts
│ │ │ │ ├── naver.ts
│ │ │ │ ├── notion.ts
│ │ │ │ ├── paypal.ts
│ │ │ │ ├── polar.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
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── expo.test.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsdown.config.ts
│ ├── sso
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── index.ts
│ │ │ ├── oidc.test.ts
│ │ │ └── saml.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── stripe
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── stripe.test.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── telemetry
│ ├── package.json
│ ├── src
│ │ ├── detectors
│ │ │ ├── detect-auth-config.ts
│ │ │ ├── detect-database.ts
│ │ │ ├── detect-framework.ts
│ │ │ ├── detect-project-info.ts
│ │ │ ├── detect-runtime.ts
│ │ │ └── detect-system-info.ts
│ │ ├── index.ts
│ │ ├── project-id.ts
│ │ ├── telemetry.test.ts
│ │ ├── types.ts
│ │ └── utils
│ │ ├── hash.ts
│ │ ├── id.ts
│ │ ├── import-util.ts
│ │ └── package-json.ts
│ ├── tsconfig.json
│ └── tsdown.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── SECURITY.md
├── tsconfig.base.json
├── tsconfig.json
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/docs/components/api-method.tsx:
--------------------------------------------------------------------------------
```typescript
import { Endpoint } from "./endpoint";
import { DynamicCodeBlock } from "./ui/dynamic-code-block";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./ui/table";
import {
ApiMethodTabs,
ApiMethodTabsContent,
ApiMethodTabsList,
ApiMethodTabsTrigger,
} from "./api-method-tabs";
import { JSX, ReactNode } from "react";
import { Link } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
type Property = {
isOptional: boolean;
description: string | null;
propName: string;
type: string;
exampleValue: string | null;
comments: string | null;
isServerOnly: boolean;
path: string[];
isNullable: boolean;
isClientOnly: boolean;
};
const placeholderProperty: Property = {
isOptional: false,
comments: null,
description: null,
exampleValue: null,
propName: "",
type: "",
isServerOnly: false,
path: [],
isNullable: false,
isClientOnly: false,
};
const indentationSpace = ` `;
export const APIMethod = ({
path,
isServerOnly,
isClientOnly,
method,
children,
noResult,
requireSession,
note,
clientOnlyNote,
serverOnlyNote,
resultVariable = "data",
forceAsBody,
forceAsQuery,
}: {
/**
* Endpoint path
*/
path: string;
/**
* If enabled, we will add `headers` to the fetch options, indicating the given API method requires auth headers.
*
* @default false
*/
requireSession?: boolean;
/**
* The HTTP method to the endpoint
*
* @default "GET"
*/
method?: "POST" | "GET" | "DELETE" | "PUT";
/**
* Wether the endpoint is server only or not.
*
* @default false
*/
isServerOnly?: boolean;
/**
* Wether the code example is client-only, thus maening it's an endpoint.
*
* @default false
*/
isClientOnly?: boolean;
/**
* The `ts` codeblock which describes the API method.
* I recommend checking other parts of the Better-Auth docs which is using this component to get an idea of how to
* write out the children.
*/
children: JSX.Element;
/**
* If enabled, will remove the `const data = ` part, since this implies there will be no return data from the API method.
*/
noResult?: boolean;
/**
* A small note to display above the client-auth example code-block.
*/
clientOnlyNote?: string;
/**
* A small note to display above the server-auth example code-block.
*/
serverOnlyNote?: string;
/**
* A small note to display above both the client & server auth example code-blocks.
*/
note?: string;
/**
* The result output variable name.
*
* @default "data"
*/
resultVariable?: string;
/**
* Force the server auth API to use `body`, rather than auto choosing
*/
forceAsBody?: boolean;
/**
* Force the server auth API to use `query`, rather than auto choosing
*/
forceAsQuery?: boolean;
}) => {
let { props, functionName, code_prefix, code_suffix } = parseCode(children);
const authClientMethodPath = pathToDotNotation(path);
const clientBody = createClientBody({
props,
method: method ?? "GET",
forceAsBody,
forceAsQuery,
});
const serverBody = createServerBody({
props,
method: method ?? "GET",
requireSession: requireSession ?? false,
forceAsQuery,
forceAsBody,
});
const serverCodeBlock = (
<DynamicCodeBlock
code={`${code_prefix}${
noResult ? "" : `const ${resultVariable} = `
}await auth.api.${functionName}(${serverBody});${code_suffix}`}
lang="ts"
allowCopy={!isClientOnly}
/>
);
let pathId = path.replaceAll("/", "-");
return (
<>
<div className="relative">
<div
id={`api-method${pathId}`}
aria-hidden
className="absolute invisible -top-[100px]"
/>
</div>
<ApiMethodTabs
defaultValue={isServerOnly ? "server" : "client"}
className="gap-0 w-full"
>
<ApiMethodTabsList className="relative flex justify-start w-full p-0 bg-transparent hover:[&>div>a>button]:opacity-100">
<ApiMethodTabsTrigger
value="client"
className="transition-all duration-150 ease-in-out max-w-[100px] data-[state=active]:bg-border hover:bg-border/50 bg-border/50 border hover:border-primary/15 cursor-pointer data-[state=active]:border-primary/10 rounded-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
>
<path
fill="currentColor"
d="M23.81 26c-.35.9-.94 1.5-1.61 1.5h-8.46c-.68 0-1.26-.6-1.61-1.5H1v1.75A2.45 2.45 0 0 0 3.6 30h28.8a2.45 2.45 0 0 0 2.6-2.25V26Z"
/>
<path
fill="currentColor"
d="M7 10h22v14h3V7.57A1.54 1.54 0 0 0 30.5 6h-25A1.54 1.54 0 0 0 4 7.57V24h3Z"
/>
<path fill="none" d="M0 0h36v36H0z" />
</svg>
<span>Client</span>
</ApiMethodTabsTrigger>
<ApiMethodTabsTrigger
value="server"
className="transition-all duration-150 ease-in-out max-w-[100px] data-[state=active]:bg-border hover:bg-border/50 bg-border/50 border hover:border-primary/15 cursor-pointer data-[state=active]:border-primary/10 rounded-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M3 3h18v18H3zm2 2v6h14V5zm14 8H5v6h14zM7 7h2v2H7zm2 8H7v2h2z"
/>
</svg>
<span>Server</span>
</ApiMethodTabsTrigger>
<div className="absolute right-0">
<a href={`#api-method${pathId}`}>
<Button
variant="ghost"
className="opacity-100 transition-all duration-150 ease-in-out scale-90 md:opacity-0"
size={"icon"}
>
<Link className="size-4" />
</Button>
</a>
</div>
</ApiMethodTabsList>
<ApiMethodTabsContent value="client">
{isServerOnly ? null : (
<Endpoint
method={method || "GET"}
path={path}
isServerOnly={isServerOnly ?? false}
/>
)}
{clientOnlyNote || note ? (
<Note>
{note && tsxifyBackticks(note)}
{clientOnlyNote ? (
<>
{note ? <br /> : null}
{tsxifyBackticks(clientOnlyNote)}
</>
) : null}
</Note>
) : null}
<div className={cn("relative w-full")}>
<DynamicCodeBlock
code={`${code_prefix}${
noResult
? ""
: `const { data${
resultVariable === "data" ? "" : `: ${resultVariable}`
}, error } = `
}await authClient.${authClientMethodPath}(${clientBody});${code_suffix}`}
lang="ts"
allowCopy={!isServerOnly}
/>
{isServerOnly ? (
<div className="flex absolute inset-0 justify-center items-center w-full h-full rounded-lg border backdrop-brightness-50 backdrop-blur-xs border-border">
<span>This is a server-only endpoint</span>
</div>
) : null}
</div>
{!isServerOnly ? <TypeTable props={props} isServer={false} /> : null}
</ApiMethodTabsContent>
<ApiMethodTabsContent value="server">
{isClientOnly ? null : (
<Endpoint
method={method || "GET"}
path={path}
isServerOnly={isServerOnly ?? false}
className=""
/>
)}
{serverOnlyNote || note ? (
<Note>
{note && tsxifyBackticks(note)}
{serverOnlyNote ? (
<>
{note ? <br /> : null}
{tsxifyBackticks(serverOnlyNote)}
</>
) : null}
</Note>
) : null}
<div className={cn("relative w-full")}>
{serverCodeBlock}
{isClientOnly ? (
<div className="flex absolute inset-0 justify-center items-center w-full h-full rounded-lg border backdrop-brightness-50 backdrop-blur-xs border-border">
<span>This is a client-only endpoint</span>
</div>
) : null}
</div>
{!isClientOnly ? <TypeTable props={props} isServer /> : null}
</ApiMethodTabsContent>
</ApiMethodTabs>
</>
);
};
function pathToDotNotation(input: string): string {
return input
.split("/") // split into segments
.filter(Boolean) // remove empty strings (from leading '/')
.map((segment) =>
segment
.split("-") // split kebab-case
.map((word, i) =>
i === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(""),
)
.join(".");
}
function getChildren(
x:
| ({ props: { children: string } } | string)
| ({ props: { children: string } } | string)[],
): string[] {
if (Array.isArray(x)) {
const res = [];
for (const item of x) {
res.push(getChildren(item));
}
return res.flat();
} else {
if (typeof x === "string") return [x];
return [x.props.children];
}
}
function TypeTable({
props,
isServer,
}: {
props: Property[];
isServer: boolean;
}) {
if (!isServer && !props.filter((x) => !x.isServerOnly).length) return null;
if (isServer && !props.filter((x) => !x.isClientOnly).length) return null;
if (!props.length) return null;
return (
<Table className="overflow-hidden mt-2 mb-0">
<TableHeader>
<TableRow>
<TableHead className="text-primary w-[100px]">Prop</TableHead>
<TableHead className="text-primary">Description</TableHead>
<TableHead className="text-primary w-[100px]">Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.map((prop, i) =>
(prop.isServerOnly && isServer === false) ||
(prop.isClientOnly && isServer === true) ? null : (
<TableRow key={i}>
<TableCell>
<code>
{prop.path.join(".") + (prop.path.length ? "." : "")}
{prop.propName}
{prop.isOptional ? "?" : ""}
</code>
{prop.isServerOnly ? (
<span className="mx-2 text-xs text-muted-foreground">
(server-only)
</span>
) : null}
</TableCell>
<TableCell className="max-w-[500px] overflow-hidden">
<div className="w-full break-words h-fit text-wrap">
{tsxifyBackticks(prop.description ?? "")}
</div>
</TableCell>
<TableCell className="max-w-[200px] overflow-auto">
<code>
{prop.type}
{prop.isNullable ? " | null" : ""}
</code>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
);
}
function tsxifyBackticks(input: string): JSX.Element {
const parts = input.split(/(`[^`]+`)/g); // Split by backtick sections
return (
<>
{parts.map((part, index) => {
if (part.startsWith("`") && part.endsWith("`")) {
const content = part.slice(1, -1); // remove backticks
return <code key={index}>{content}</code>;
} else {
return <span key={index}>{part}</span>;
}
})}
</>
);
}
function parseCode(children: JSX.Element) {
// These two variables are essentially taking the `children` JSX shiki code, and converting them to
// an array string purely of it's code content.
const arrayOfJSXCode = children?.props.children.props.children.props.children
.map((x: any) =>
x === "\n" ? { props: { children: { props: { children: "\n" } } } } : x,
)
.map((x: any) => x.props.children)
.filter((x: any) => x != null);
const arrayOfCode: string[] = arrayOfJSXCode
.flatMap(
(
x: { props: { children: string } } | { props: { children: string } }[],
) => {
return getChildren(x);
},
)
.join("")
.split("\n");
let props: Property[] = [];
let functionName: string = "";
let currentJSDocDescription: string = "";
let withinApiMethodType = false;
let hasAlreadyDefinedApiMethodType = false;
let isServerOnly_ = false;
let isClientOnly_ = false;
let nestPath: string[] = []; // str arr segmented-path, eg: ["data", "metadata", "something"]
let serverOnlyPaths: string[] = []; // str arr full-path, eg: ["data.metadata.something"]
let clientOnlyPaths: string[] = []; // str arr full-path, eg: ["data.metadata.something"]
let isNullable = false;
let code_prefix = "";
let code_suffix = "";
for (let line of arrayOfCode) {
const originalLine = line;
line = line.trim();
if (line === "}" && withinApiMethodType && !nestPath.length) {
withinApiMethodType = false;
hasAlreadyDefinedApiMethodType = true;
continue;
} else {
if (line === "}" && withinApiMethodType && nestPath.length) {
nestPath.pop();
continue;
}
}
if (
line.toLowerCase().startsWith("type") &&
!hasAlreadyDefinedApiMethodType &&
!withinApiMethodType
) {
withinApiMethodType = true;
// Will grab the name of the API method function name from:
// type createOrganization = {
// ^^^^^^^^^^^^^^^^^^
functionName = line.replace("type ", "").split("=")[0].trim();
continue;
}
if (!withinApiMethodType) {
if (!hasAlreadyDefinedApiMethodType) {
code_prefix += originalLine + "\n";
} else {
code_suffix += "\n" + originalLine + "";
}
continue;
}
if (
line.startsWith("/*") ||
line.startsWith("*") ||
line.startsWith("*/")
) {
if (line.startsWith("/*")) {
continue;
} else if (line.startsWith("*/")) {
continue;
} else {
if (line === "*" || line === "* ") continue;
line = line.replace("* ", "");
if (line.trim() === "@serverOnly") {
isServerOnly_ = true;
continue;
} else if (line.trim() === "@nullable") {
isNullable = true;
continue;
} else if (line.trim() === "@clientOnly") {
isClientOnly_ = true;
continue;
}
currentJSDocDescription += line + " ";
}
} else {
// New property field
// Example:
// name: string = "My Organization",
let propName = line.split(":")[0].trim();
const isOptional = propName.endsWith("?") ? true : false;
if (isOptional) propName = propName.slice(0, -1); // Remove `?` in propname.
let propType = line
.replace(propName, "")
.replace("?", "")
.replace(":", "")
.split("=")[0]
.trim()!;
let isTheStartOfNest = false;
if (propType === "{") {
// This means that it's a nested object.
propType = `Object`;
isTheStartOfNest = true;
nestPath.push(propName);
if (isServerOnly_) {
serverOnlyPaths.push(nestPath.join("."));
}
if (isClientOnly_) {
clientOnlyPaths.push(nestPath.join("."));
}
}
if (clientOnlyPaths.includes(nestPath.join("."))) {
isClientOnly_ = true;
}
if (serverOnlyPaths.includes(nestPath.join("."))) {
isServerOnly_ = true;
}
let exampleValue = !line.includes("=")
? null
: line
.replace(propName, "")
.replace("?", "")
.replace(":", "")
.replace(propType, "")
.replace("=", "")
.trim();
if (exampleValue?.endsWith(",")) exampleValue = exampleValue.slice(0, -1);
// const comments =
// line
// .replace(propName, "")
// .replace("?", "")
// .replace(":", "")
// .replace(propType, "")
// .replace("=", "")
// .replace(exampleValue || "IMPOSSIBLE_TO_REPLACE_!!!!", "")
// .split("//")[1] ?? null;
const comments = null;
const description =
currentJSDocDescription.length > 0 ? currentJSDocDescription : null;
if (description) {
currentJSDocDescription = "";
}
const property: Property = {
...placeholderProperty,
description,
comments,
exampleValue,
isOptional,
propName,
type: propType,
isServerOnly: isServerOnly_,
isClientOnly: isClientOnly_,
path: isTheStartOfNest
? nestPath.slice(0, nestPath.length - 1)
: nestPath.slice(),
isNullable: isNullable,
};
isServerOnly_ = false;
isClientOnly_ = false;
isNullable = false;
// console.log(property);
props.push(property);
}
}
return {
functionName,
props,
code_prefix,
code_suffix,
};
}
/**
* Builds a property line with proper formatting and comments
*/
function buildPropertyLine(
prop: Property,
indentLevel: number,
additionalComments: string[] = [],
): string {
const comments: string[] = [...additionalComments];
if (!prop.isOptional) comments.push("required");
if (prop.comments) comments.push(prop.comments);
const addComment = comments.length > 0;
const indent = indentationSpace.repeat(indentLevel);
const propValue = prop.exampleValue ? `: ${prop.exampleValue}` : "";
const commentText = addComment ? ` // ${comments.join(", ")}` : "";
if (prop.type === "Object") {
// For object types, put comment after the opening brace
return `${indent}${prop.propName}${propValue}: {${commentText}\n`;
} else {
// For non-object types, put comment after the comma
return `${indent}${prop.propName}${propValue},${commentText}\n`;
}
}
/**
* Determines if the client request should use query parameters
*
* - GET requests use query params by default, unless `forceAsBody` is true
* - Any request can be forced to use query params with `forceAsQuery`
*/
function shouldClientUseQueryParams(
method: string | undefined,
forceAsBody: boolean | undefined,
forceAsQuery: boolean | undefined,
): boolean {
if (forceAsQuery) return true;
if (forceAsBody) return false;
return method === "GET";
}
function createClientBody({
props,
method,
forceAsBody,
forceAsQuery,
}: {
props: Property[];
method?: string;
forceAsBody?: boolean;
forceAsQuery?: boolean;
}) {
const isQueryParam = shouldClientUseQueryParams(
method,
forceAsBody,
forceAsQuery,
);
const baseIndentLevel = isQueryParam ? 2 : 1;
let params = ``;
let i = -1;
for (const prop of props) {
i++;
if (prop.isServerOnly) continue;
if (params === "") params += "{\n";
params += buildPropertyLine(prop, prop.path.length + baseIndentLevel);
if ((props[i + 1]?.path?.length || 0) < prop.path.length) {
const diff = prop.path.length - (props[i + 1]?.path?.length || 0);
for (const index of Array(diff)
.fill(0)
.map((_, i) => i)
.reverse()) {
params += `${indentationSpace.repeat(index + baseIndentLevel)}},\n`;
}
}
}
if (params !== "") {
if (isQueryParam) {
// Wrap in query object for GET requests and when forceAsQuery is true
params = `{\n query: ${params} },\n}`;
} else {
params += "}";
}
}
return params;
}
/**
* Determines if the server request should use query parameters
*
* - GET requests use query params by default, unless `forceAsBody` is true
* - Other methods (POST, PUT, DELETE) use body by default, unless `forceAsQuery` is true
*/
function shouldServerUseQueryParams(
method: string,
forceAsBody: boolean | undefined,
forceAsQuery: boolean | undefined,
): boolean {
if (forceAsQuery) return true;
if (forceAsBody) return false;
return method === "GET";
}
function createServerBody({
props,
requireSession,
method,
forceAsBody,
forceAsQuery,
}: {
props: Property[];
requireSession: boolean;
method: string;
forceAsQuery: boolean | undefined;
forceAsBody: boolean | undefined;
}) {
const isQueryParam = shouldServerUseQueryParams(
method,
forceAsBody,
forceAsQuery,
);
const clientOnlyProps = props.filter((x) => !x.isClientOnly);
// Build properties content
let propertiesContent = ``;
let i = -1;
for (const prop of props) {
i++;
if (prop.isClientOnly) continue;
if (propertiesContent === "") propertiesContent += "{\n";
// Check if this is a server-only nested property
const isNestedServerOnlyProp =
prop.isServerOnly &&
!(
prop.path.length &&
props.find(
(x) =>
x.path.join(".") ===
prop.path.slice(0, prop.path.length - 2).join(".") &&
x.propName === prop.path[prop.path.length - 1],
)
);
const additionalComments: string[] = [];
if (isNestedServerOnlyProp) additionalComments.push("server-only");
propertiesContent += buildPropertyLine(
prop,
prop.path.length + 2,
additionalComments,
);
if ((props[i + 1]?.path?.length || 0) < prop.path.length) {
const diff = prop.path.length - (props[i + 1]?.path?.length || 0);
for (const index of Array(diff)
.fill(0)
.map((_, i) => i)
.reverse()) {
propertiesContent += `${indentationSpace.repeat(index + 2)}},\n`;
}
}
}
if (propertiesContent !== "") propertiesContent += " },";
// Build fetch options
let fetchOptions = "";
if (requireSession) {
fetchOptions +=
"\n // This endpoint requires session cookies.\n headers: await headers(),";
}
// Assemble final result
let result = "";
if (clientOnlyProps.length > 0) {
result += "{\n";
const paramType = isQueryParam ? "query" : "body";
result += ` ${paramType}: ${propertiesContent}${fetchOptions}\n}`;
} else if (fetchOptions.length) {
result += `{${fetchOptions}\n}`;
}
return result;
}
function Note({ children }: { children: ReactNode }) {
return (
<div className="flex relative flex-col gap-2 p-3 mb-2 w-full break-words rounded-md border text-md text-wrap border-border bg-fd-secondary/50">
<span className="-mb-1 w-full text-xs select-none text-muted-foreground">
Notes
</span>
<p className="mt-0 mb-0 text-sm">{children as any}</p>
</div>
);
}
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/mcp/mcp.test.ts:
--------------------------------------------------------------------------------
```typescript
import { afterAll, describe, it } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { mcp, withMcpAuth } from ".";
import { genericOAuth } from "../generic-oauth";
import type { Client } from "../oidc-provider/types";
import { createAuthClient } from "../../client";
import { genericOAuthClient } from "../generic-oauth/client";
import { listen } from "listhen";
import { toNodeHandler } from "../../integrations/node";
import { jwt } from "../jwt";
describe("mcp", async () => {
// Start server on ephemeral port first to get available port
const tempServer = await listen(
toNodeHandler(async () => new Response("temp")),
{
port: 0,
},
);
const port = tempServer.address?.port || 3001;
const baseURL = `http://localhost:${port}`;
await tempServer.close();
const { auth, signInWithTestUser, customFetchImpl, testUser, cookieSetter } =
await getTestInstance({
baseURL,
plugins: [
mcp({
loginPage: "/login",
oidcConfig: {
loginPage: "/login",
consentPage: "/oauth/consent",
requirePKCE: true,
getAdditionalUserInfoClaim(user, scopes, client) {
return {
custom: "custom value",
userId: user.id,
};
},
},
}),
jwt(),
],
});
const signInResult = await signInWithTestUser();
const headers = signInResult.headers;
const serverClient = createAuthClient({
baseURL,
fetchOptions: {
customFetchImpl,
headers,
},
});
const server = await listen(toNodeHandler(auth.handler), {
port,
});
afterAll(async () => {
await server.close();
});
let publicClient: Client;
let confidentialClient: Client;
it("should register public client with token_endpoint_auth_method: none", async ({
expect,
}) => {
const createdClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-public-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-public",
],
logo_uri: "",
token_endpoint_auth_method: "none",
},
onResponse(context) {
expect(context.response.status).toBe(201);
expect(context.response.headers.get("Content-Type")).toBe(
"application/json",
);
},
});
expect(createdClient.data).toMatchObject({
client_id: expect.any(String),
client_name: "test-public-client",
logo_uri: "",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-public",
],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
client_id_issued_at: expect.any(Number),
});
// Public clients should NOT receive client_secret or client_secret_expires_at
expect(createdClient.data).not.toHaveProperty("client_secret");
expect(createdClient.data).not.toHaveProperty("client_secret_expires_at");
publicClient = {
clientId: (createdClient.data as any).client_id,
clientSecret: "", // Public clients don't have secrets, but our type expects a string
redirectURLs: (createdClient.data as any).redirect_uris,
metadata: {},
icon: (createdClient.data as any).logo_uri || "",
type: "public",
disabled: false,
name: (createdClient.data as any).client_name || "",
};
});
it("should register confidential client with client_secret_basic", async ({
expect,
}) => {
const createdClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-confidential-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-confidential",
],
logo_uri: "",
token_endpoint_auth_method: "client_secret_basic",
},
});
expect(createdClient.data).toMatchObject({
client_id: expect.any(String),
client_secret: expect.any(String),
client_name: "test-confidential-client",
logo_uri: "",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-confidential",
],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_basic",
client_id_issued_at: expect.any(Number),
client_secret_expires_at: 0,
});
// Confidential clients should receive client_secret and client_secret_expires_at
expect(createdClient.data).toHaveProperty("client_secret");
expect(createdClient.data).toHaveProperty("client_secret_expires_at");
confidentialClient = {
clientId: (createdClient.data as any).client_id,
clientSecret: (createdClient.data as any).client_secret,
redirectURLs: (createdClient.data as any).redirect_uris,
metadata: {},
icon: (createdClient.data as any).logo_uri || "",
type: "web",
disabled: false,
name: (createdClient.data as any).client_name || "",
};
});
it("should authenticate public client with PKCE only", async ({ expect }) => {
const { customFetchImpl: customFetchImplRP, cookieSetter } =
await getTestInstance({
account: {
accountLinking: {
trustedProviders: ["test-public"],
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "test-public",
clientId: publicClient.clientId,
clientSecret: "", // Public client has no secret
authorizationUrl: `${baseURL}/api/auth/mcp/authorize`,
tokenUrl: `${baseURL}/api/auth/mcp/token`,
scopes: ["openid", "profile", "email"],
pkce: true,
},
],
}),
],
});
const client = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:5001",
fetchOptions: {
customFetchImpl: customFetchImplRP,
},
});
const oAuthHeaders = new Headers();
const data = await client.signIn.oauth2(
{
providerId: "test-public",
callbackURL: "/dashboard",
},
{
throw: true,
onSuccess: cookieSetter(oAuthHeaders),
},
);
expect(data.url).toContain(`${baseURL}/api/auth/mcp/authorize`);
expect(data.url).toContain(`client_id=${publicClient.clientId}`);
expect(data.url).toContain("code_challenge=");
expect(data.url).toContain("code_challenge_method=S256");
let redirectURI = "";
await serverClient.$fetch(data.url, {
method: "GET",
onError(context: any) {
redirectURI = context.response.headers.get("Location") || "";
},
});
expect(redirectURI).toContain(
"http://localhost:3000/api/auth/oauth2/callback/test-public?code=",
);
let callbackURL = "";
await client.$fetch(redirectURI, {
headers: oAuthHeaders,
onError(context: any) {
callbackURL = context.response.headers.get("Location") || "";
},
});
expect(callbackURL).toContain("/dashboard");
});
it("should reject public client without code_verifier", async ({
expect,
}) => {
// Create a mock token request without code_verifier
const authCode = "test-auth-code";
const result = await serverClient.$fetch("/mcp/token", {
method: "POST",
body: {
grant_type: "authorization_code",
client_id: publicClient.clientId,
code: authCode,
redirect_uri: publicClient.redirectURLs[0],
// Missing code_verifier for public client
},
});
expect(result.error).toBeTruthy();
expect((result.error as any).error).toBe("invalid_request");
expect((result.error as any).error_description).toContain(
"code verifier is missing",
);
});
it("should still support confidential clients in MCP context", async ({
expect,
}) => {
const { customFetchImpl: customFetchImplRP, cookieSetter } =
await getTestInstance({
account: {
accountLinking: {
trustedProviders: ["test-confidential"],
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "test-confidential",
clientId: confidentialClient.clientId,
clientSecret: confidentialClient.clientSecret || "",
authorizationUrl: `${baseURL}/api/auth/mcp/authorize`,
tokenUrl: `${baseURL}/api/auth/mcp/token`,
scopes: ["openid", "profile", "email"],
pkce: true,
},
],
}),
],
});
const oAuthHeaders = new Headers();
const client = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:5001",
fetchOptions: {
customFetchImpl: customFetchImplRP,
},
});
const data = await client.signIn.oauth2(
{
providerId: "test-confidential",
callbackURL: "/dashboard",
},
{
throw: true,
onSuccess: cookieSetter(oAuthHeaders),
},
);
expect(data.url).toContain(`${baseURL}/api/auth/mcp/authorize`);
expect(data.url).toContain(`client_id=${confidentialClient.clientId}`);
let redirectURI = "";
await serverClient.$fetch(data.url, {
method: "GET",
onError(context: any) {
redirectURI = context.response.headers.get("Location") || "";
},
});
expect(redirectURI).toContain(
"http://localhost:3000/api/auth/oauth2/callback/test-confidential?code=",
);
let callbackURL = "";
await client.$fetch(redirectURI, {
headers: oAuthHeaders,
onError(context: any) {
callbackURL = context.response.headers.get("Location") || "";
},
});
expect(callbackURL).toContain("/dashboard");
});
it("should expose OAuth discovery metadata", async ({ expect }) => {
const metadata = await serverClient.$fetch(
"/.well-known/oauth-authorization-server",
);
expect(metadata.data).toMatchObject({
issuer: baseURL,
authorization_endpoint: `${baseURL}/api/auth/mcp/authorize`,
token_endpoint: `${baseURL}/api/auth/mcp/token`,
userinfo_endpoint: `${baseURL}/api/auth/mcp/userinfo`,
jwks_uri: `${baseURL}/api/auth/mcp/jwks`,
registration_endpoint: `${baseURL}/api/auth/mcp/register`,
scopes_supported: ["openid", "profile", "email", "offline_access"],
response_types_supported: ["code"],
response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256", "none"],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"client_secret_post",
"none",
],
code_challenge_methods_supported: ["S256"],
claims_supported: [
"sub",
"iss",
"aud",
"exp",
"nbf",
"iat",
"jti",
"email",
"email_verified",
"name",
],
});
});
it("should expose OAuth protected resource metadata", async ({ expect }) => {
const metadata = await serverClient.$fetch(
"/.well-known/oauth-protected-resource",
);
expect(metadata.data).toMatchObject({
resource: baseURL,
authorization_servers: [`${baseURL}/api/auth`],
jwks_uri: `${baseURL}/api/auth/mcp/jwks`,
scopes_supported: ["openid", "profile", "email", "offline_access"],
bearer_methods_supported: ["header"],
resource_signing_alg_values_supported: ["RS256", "none"],
});
});
it("should handle token refresh flow", async ({ expect }) => {
// Create a confidential client for easier testing (avoids PKCE complexity)
const createdClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-refresh-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-refresh",
],
logo_uri: "",
token_endpoint_auth_method: "client_secret_basic",
},
});
// Create a mock access token in the database to test refresh functionality
// We'll simulate an existing token with refresh capabilities
const clientId = (createdClient.data as any).client_id;
const clientSecret = (createdClient.data as any).client_secret;
// Test the refresh token flow by creating a refresh token request
// For this test, we'll verify the endpoint handles refresh_token grant_type
const refreshTokenRequest = await serverClient.$fetch("/mcp/token", {
method: "POST",
body: {
grant_type: "refresh_token",
client_id: clientId,
client_secret: clientSecret,
refresh_token: "invalid-refresh-token", // This should fail but test the flow
},
});
// Should fail with invalid_grant error for invalid refresh token
expect(refreshTokenRequest.error).toBeTruthy();
expect((refreshTokenRequest.error as any).error).toBe("invalid_grant");
expect((refreshTokenRequest.error as any).error_description).toContain(
"invalid refresh token",
);
});
it("should return user info from userinfo endpoint", async ({ expect }) => {
// First get an access token through the OAuth flow
const createdClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-userinfo-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-userinfo",
],
logo_uri: "",
token_endpoint_auth_method: "none",
},
});
const userinfoClient = {
clientId: (createdClient.data as any).client_id,
clientSecret: (createdClient.data as any).client_secret,
redirectURLs: (createdClient.data as any).redirect_uris,
};
// Set up OAuth flow
const { customFetchImpl: customFetchImplRP } = await getTestInstance({
account: {
accountLinking: {
trustedProviders: ["test-userinfo"],
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "test-userinfo",
clientId: userinfoClient.clientId,
clientSecret: "",
authorizationUrl: `${baseURL}/api/auth/mcp/authorize`,
tokenUrl: `${baseURL}/api/auth/mcp/token`,
scopes: ["openid", "profile", "email"],
pkce: true,
},
],
}),
],
});
const client = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:5003",
fetchOptions: {
customFetchImpl: customFetchImplRP,
},
});
// Perform OAuth flow
const data = await client.signIn.oauth2(
{
providerId: "test-userinfo",
callbackURL: "/dashboard",
},
{
throw: true,
},
);
// Follow OAuth flow to get access token (simplified version)
// In a real test, we'd complete the full flow, but for this test we'll
// use the getMcpSession endpoint which validates bearer tokens
// For now, let's test the userinfo endpoint structure by calling it directly
// This will fail auth but we can check the endpoint exists and returns proper errors
const userinfoResponse = await serverClient.$fetch("/mcp/userinfo", {
method: "GET",
headers: {
Authorization: "Bearer invalid-token",
},
});
// Should return null for invalid token
expect(userinfoResponse.data).toBeNull();
});
it("should handle ID token requests", async ({ expect }) => {
// Create a confidential client to test ID token flow
const createdClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-idtoken-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-idtoken",
],
logo_uri: "",
token_endpoint_auth_method: "client_secret_basic",
},
});
const clientId = (createdClient.data as any).client_id;
const clientSecret = (createdClient.data as any).client_secret;
// Test that token endpoint handles openid scope properly
// We'll test with invalid code but valid structure to verify ID token logic
const tokenRequest = await serverClient.$fetch("/mcp/token", {
method: "POST",
body: {
grant_type: "authorization_code",
client_id: clientId,
client_secret: clientSecret,
code: "invalid-auth-code",
redirect_uri: (createdClient.data as any).redirect_uris[0],
// Missing code_verifier but that's OK for confidential clients
},
});
// Should fail due to missing code verifier, but this tests the ID token flow exists
expect(tokenRequest.error).toBeTruthy();
expect((tokenRequest.error as any).error).toBe("invalid_request");
expect((tokenRequest.error as any).error_description).toContain(
"code verifier is missing",
);
});
it("should handle consent flow with prompt=consent", async ({ expect }) => {
// Register a client for consent flow testing
const consentClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-consent-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-consent",
],
logo_uri: "",
token_endpoint_auth_method: "none",
},
});
const clientId = (consentClient.data as any).client_id;
const redirectUri = (consentClient.data as any).redirect_uris[0];
// Construct authorization URL with prompt=consent
const authURL = new URL(`${baseURL}/api/auth/mcp/authorize`);
authURL.searchParams.set("client_id", clientId);
authURL.searchParams.set("redirect_uri", redirectUri);
authURL.searchParams.set("response_type", "code");
authURL.searchParams.set("scope", "openid profile email");
authURL.searchParams.set("state", "test-state");
authURL.searchParams.set("prompt", "consent");
authURL.searchParams.set("code_challenge", "test-challenge");
authURL.searchParams.set("code_challenge_method", "S256");
// Make authorization request with authenticated session
let redirectLocation = "";
const consentHeaders = new Headers();
await serverClient.$fetch(authURL.toString(), {
method: "GET",
onError(context: any) {
redirectLocation = context.response.headers.get("Location") || "";
// Capture consent cookies (oidc_consent_prompt)
const setCookieHeaders =
context.response.headers.getSetCookie?.() || [];
for (const cookie of setCookieHeaders) {
if (cookie.includes("oidc_consent_prompt=")) {
const existingCookies = consentHeaders.get("Cookie") || "";
const cookieValue = cookie.split(";")[0]; // Extract just the name=value part
consentHeaders.set(
"Cookie",
existingCookies
? `${existingCookies}; ${cookieValue}`
: cookieValue,
);
}
}
},
});
// Verify redirect to consent page (not direct code callback)
expect(redirectLocation).toContain("/oauth/consent");
expect(redirectLocation).toContain("consent_code=");
expect(redirectLocation).toContain(`client_id=${clientId}`);
expect(redirectLocation).toContain("scope=");
expect(redirectLocation).not.toContain("?code="); // Should NOT have authorization code yet
// Extract consent_code from redirect URL
const consentURL = new URL(redirectLocation, baseURL);
const consentCode = consentURL.searchParams.get("consent_code");
expect(consentCode).toBeTruthy();
// Merge session headers with consent cookies
const authHeaders = new Headers(headers);
consentHeaders.forEach((value, key) => {
if (key.toLowerCase() === "cookie") {
const existing = authHeaders.get("Cookie") || "";
authHeaders.set("Cookie", existing ? `${existing}; ${value}` : value);
}
});
// Accept consent
let finalRedirect = "";
try {
const consentResponse = await serverClient.$fetch("/oauth2/consent", {
method: "POST",
headers: authHeaders,
body: {
accept: true,
consent_code: consentCode,
},
});
// The response should contain redirectURI
if (consentResponse.data) {
finalRedirect = (consentResponse.data as any).redirectURI;
}
} catch (error) {
// In case of error, log it for debugging
console.error("Consent request failed:", error);
throw error;
}
// Verify we get the final redirect with authorization code
expect(finalRedirect).toBeTruthy();
expect(finalRedirect).toContain(redirectUri);
expect(finalRedirect).toContain("code=");
expect(finalRedirect).toContain("state=test-state");
});
it("should skip consent flow when prompt is not consent", async ({
expect,
}) => {
// Register a client for non-consent flow testing
const noConsentClient = await serverClient.$fetch("/mcp/register", {
method: "POST",
body: {
client_name: "test-no-consent-client",
redirect_uris: [
"http://localhost:3000/api/auth/oauth2/callback/test-no-consent",
],
logo_uri: "",
token_endpoint_auth_method: "none",
},
});
const clientId = (noConsentClient.data as any).client_id;
const redirectUri = (noConsentClient.data as any).redirect_uris[0];
// Construct authorization URL WITHOUT prompt=consent
const authURL = new URL(`${baseURL}/api/auth/mcp/authorize`);
authURL.searchParams.set("client_id", clientId);
authURL.searchParams.set("redirect_uri", redirectUri);
authURL.searchParams.set("response_type", "code");
authURL.searchParams.set("scope", "openid profile email");
authURL.searchParams.set("state", "test-state-2");
authURL.searchParams.set("code_challenge", "test-challenge-2");
authURL.searchParams.set("code_challenge_method", "S256");
// Make authorization request with authenticated session
let redirectLocation = "";
await serverClient.$fetch(authURL.toString(), {
method: "GET",
onError(context: any) {
redirectLocation = context.response.headers.get("Location") || "";
},
});
// Verify redirect directly to callback with code (skip consent)
expect(redirectLocation).toContain(redirectUri);
expect(redirectLocation).toContain("code=");
expect(redirectLocation).toContain("state=test-state-2");
expect(redirectLocation).not.toContain("consent_code="); // Should NOT redirect to consent page
});
describe("withMCPAuth", () => {
it("should return 401 if the request is not authenticated returning the right WWW-Authenticate header", async ({
expect,
}) => {
// Test the handler using a newly instantiated Request instead of the server, since this route isn't handled by the server
const response = await withMcpAuth(auth, async () => {
// it will never be reached since the request is not authenticated
return new Response("unnecessary");
})(new Request(`${baseURL}/mcp`));
expect(response.status).toBe(401);
expect(response.headers.get("WWW-Authenticate")).toBe(
`Bearer resource_metadata="${baseURL}/api/auth/.well-known/oauth-protected-resource"`,
);
expect(response.headers.get("Access-Control-Expose-Headers")).toBe(
"WWW-Authenticate",
);
});
});
});
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/organization/adapter.ts:
--------------------------------------------------------------------------------
```typescript
import type { Session, User } from "../../types";
import { getDate } from "../../utils/date";
import type { OrganizationOptions } from "./types";
import type {
InferInvitation,
InferMember,
InferOrganization,
InferTeam,
InvitationInput,
Member,
MemberInput,
OrganizationInput,
Team,
TeamInput,
TeamMember,
} from "./schema";
import { BetterAuthError } from "@better-auth/core/error";
import parseJSON from "../../client/parser";
import { type InferAdditionalFieldsFromPluginOptions } from "../../db";
import { getCurrentAdapter } from "@better-auth/core/context";
import type { AuthContext, GenericEndpointContext } from "@better-auth/core";
export const getOrgAdapter = <O extends OrganizationOptions>(
context: AuthContext,
options?: O,
) => {
const baseAdapter = context.adapter;
return {
findOrganizationBySlug: async (slug: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.findOne<InferOrganization<O>>({
model: "organization",
where: [
{
field: "slug",
value: slug,
},
],
});
return organization;
},
createOrganization: async (data: {
organization: OrganizationInput &
// This represents the additional fields from the plugin options
Record<string, any>;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.create<
OrganizationInput,
InferOrganization<O, false>
>({
model: "organization",
data: {
...data.organization,
metadata: data.organization.metadata
? JSON.stringify(data.organization.metadata)
: undefined,
},
forceAllowId: true,
});
return {
...organization,
metadata:
organization.metadata && typeof organization.metadata === "string"
? JSON.parse(organization.metadata)
: undefined,
} as typeof organization;
},
findMemberByEmail: async (data: {
email: string;
organizationId: string;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const user = await adapter.findOne<User>({
model: "user",
where: [
{
field: "email",
value: data.email.toLowerCase(),
},
],
});
if (!user) {
return null;
}
const member = await adapter.findOne<Member>({
model: "member",
where: [
{
field: "organizationId",
value: data.organizationId,
},
{
field: "userId",
value: user.id,
},
],
});
if (!member) {
return null;
}
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
},
};
},
listMembers: async (data: {
organizationId?: string;
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: "asc" | "desc";
filter?: {
field: string;
operator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains";
value: any;
};
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const members = await Promise.all([
adapter.findMany<Member>({
model: "member",
where: [
{ field: "organizationId", value: data.organizationId },
...(data.filter?.field
? [
{
field: data.filter?.field,
value: data.filter?.value,
},
]
: []),
],
limit: data.limit || options?.membershipLimit || 100,
offset: data.offset || 0,
sortBy: data.sortBy
? { field: data.sortBy, direction: data.sortOrder || "asc" }
: undefined,
}),
adapter.count({
model: "member",
where: [
{ field: "organizationId", value: data.organizationId },
...(data.filter?.field
? [
{
field: data.filter?.field,
value: data.filter?.value,
},
]
: []),
],
}),
]);
const users = await adapter.findMany<User>({
model: "user",
where: [
{
field: "id",
value: members[0].map((member) => member.userId),
operator: "in",
},
],
});
return {
members: members[0].map((member) => {
const user = users.find((user) => user.id === member.userId);
if (!user) {
throw new BetterAuthError(
"Unexpected error: User not found for member",
);
}
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
},
};
}),
total: members[1],
};
},
findMemberByOrgId: async (data: {
userId: string;
organizationId: string;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const [member, user] = await Promise.all([
await adapter.findOne<Member>({
model: "member",
where: [
{
field: "userId",
value: data.userId,
},
{
field: "organizationId",
value: data.organizationId,
},
],
}),
await adapter.findOne<User>({
model: "user",
where: [
{
field: "id",
value: data.userId,
},
],
}),
]);
if (!user || !member) {
return null;
}
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
},
};
},
findMemberById: async (memberId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.findOne<Member>({
model: "member",
where: [
{
field: "id",
value: memberId,
},
],
});
if (!member) {
return null;
}
const user = await adapter.findOne<User>({
model: "user",
where: [
{
field: "id",
value: member.userId,
},
],
});
if (!user) {
return null;
}
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
},
};
},
createMember: async (
data: Omit<MemberInput, "id"> &
// Additional fields from the plugin options
Record<string, any>,
) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.create<
typeof data,
Member & InferAdditionalFieldsFromPluginOptions<"member", O, false>
>({
model: "member",
data: {
...data,
createdAt: new Date(),
},
});
return member;
},
updateMember: async (memberId: string, role: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.update<InferMember<O>>({
model: "member",
where: [
{
field: "id",
value: memberId,
},
],
update: {
role,
},
});
return member;
},
deleteMember: async (memberId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.delete<InferMember<O>>({
model: "member",
where: [
{
field: "id",
value: memberId,
},
],
});
return member;
},
updateOrganization: async (
organizationId: string,
data: Partial<OrganizationInput>,
) => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.update<InferOrganization<O>>({
model: "organization",
where: [
{
field: "id",
value: organizationId,
},
],
update: {
...data,
metadata:
typeof data.metadata === "object"
? JSON.stringify(data.metadata)
: data.metadata,
},
});
if (!organization) {
return null;
}
return {
...organization,
metadata: organization.metadata
? parseJSON<Record<string, any>>(organization.metadata)
: undefined,
};
},
deleteOrganization: async (organizationId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
await adapter.delete({
model: "member",
where: [
{
field: "organizationId",
value: organizationId,
},
],
});
await adapter.delete({
model: "invitation",
where: [
{
field: "organizationId",
value: organizationId,
},
],
});
await adapter.delete<InferOrganization<O>>({
model: "organization",
where: [
{
field: "id",
value: organizationId,
},
],
});
return organizationId;
},
setActiveOrganization: async (
sessionToken: string,
organizationId: string | null,
ctx: GenericEndpointContext,
) => {
const session = await context.internalAdapter.updateSession(
sessionToken,
{
activeOrganizationId: organizationId,
},
);
return session as Session;
},
findOrganizationById: async (organizationId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const organization = await adapter.findOne<InferOrganization<O>>({
model: "organization",
where: [
{
field: "id",
value: organizationId,
},
],
});
return organization;
},
checkMembership: async ({
userId,
organizationId,
}: {
userId: string;
organizationId: string;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.findOne<InferMember<O>>({
model: "member",
where: [
{
field: "userId",
value: userId,
},
{
field: "organizationId",
value: organizationId,
},
],
});
return member;
},
/**
* @requires db
*/
findFullOrganization: async ({
organizationId,
isSlug,
includeTeams,
membersLimit,
}: {
organizationId: string;
isSlug?: boolean;
includeTeams?: boolean;
membersLimit?: number;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const org = await adapter.findOne<InferOrganization<O>>({
model: "organization",
where: [{ field: isSlug ? "slug" : "id", value: organizationId }],
});
if (!org) {
return null;
}
const [invitations, members, teams] = await Promise.all([
adapter.findMany<InferInvitation<O>>({
model: "invitation",
where: [{ field: "organizationId", value: org.id }],
}),
adapter.findMany<InferMember<O>>({
model: "member",
where: [{ field: "organizationId", value: org.id }],
limit: membersLimit ?? options?.membershipLimit ?? 100,
}),
includeTeams
? adapter.findMany<InferTeam<O>>({
model: "team",
where: [{ field: "organizationId", value: org.id }],
})
: null,
]);
if (!org) return null;
const userIds = members.map((member) => member.userId);
const users =
userIds.length > 0
? await adapter.findMany<User>({
model: "user",
where: [{ field: "id", value: userIds, operator: "in" }],
limit: options?.membershipLimit || 100,
})
: [];
const userMap = new Map(users.map((user) => [user.id, user]));
const membersWithUsers = members.map((member) => {
const user = userMap.get(member.userId);
if (!user) {
throw new BetterAuthError(
"Unexpected error: User not found for member",
);
}
return {
...member,
user: {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
},
};
});
return {
...org,
invitations,
members: membersWithUsers,
teams,
};
},
listOrganizations: async (userId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const members = await adapter.findMany<InferMember<O>>({
model: "member",
where: [
{
field: "userId",
value: userId,
},
],
});
if (!members || members.length === 0) {
return [];
}
const organizationIds = members.map((member) => member.organizationId);
const organizations = await adapter.findMany<InferOrganization<O>>({
model: "organization",
where: [
{
field: "id",
value: organizationIds,
operator: "in",
},
],
});
return organizations;
},
createTeam: async (data: Omit<TeamInput, "id">) => {
const adapter = await getCurrentAdapter(baseAdapter);
const team = await adapter.create<Omit<TeamInput, "id">, InferTeam<O>>({
model: "team",
data,
});
return team;
},
findTeamById: async <IncludeMembers extends boolean>({
teamId,
organizationId,
includeTeamMembers,
}: {
teamId: string;
organizationId?: string;
includeTeamMembers?: IncludeMembers;
}): Promise<
| (InferTeam<O> &
(IncludeMembers extends true ? { members: TeamMember[] } : {}))
| null
> => {
const adapter = await getCurrentAdapter(baseAdapter);
const team = await adapter.findOne<InferTeam<O>>({
model: "team",
where: [
{
field: "id",
value: teamId,
},
...(organizationId
? [
{
field: "organizationId",
value: organizationId,
},
]
: []),
],
});
if (!team) {
return null;
}
let members: TeamMember[] = [];
if (includeTeamMembers) {
members = await adapter.findMany<TeamMember>({
model: "teamMember",
where: [
{
field: "teamId",
value: teamId,
},
],
limit: options?.membershipLimit || 100,
});
return {
...team,
members,
};
}
return team as InferTeam<O> &
(IncludeMembers extends true ? { members: TeamMember[] } : {});
},
updateTeam: async (
teamId: string,
data: { name?: string; description?: string; status?: string },
) => {
const adapter = await getCurrentAdapter(baseAdapter);
if ("id" in data) data.id = undefined;
const team = await adapter.update<
Team & InferAdditionalFieldsFromPluginOptions<"team", O>
>({
model: "team",
where: [
{
field: "id",
value: teamId,
},
],
update: {
...data,
},
});
return team;
},
deleteTeam: async (teamId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
await adapter.deleteMany({
model: "teamMember",
where: [
{
field: "teamId",
value: teamId,
},
],
});
const team = await adapter.delete<Team>({
model: "team",
where: [
{
field: "id",
value: teamId,
},
],
});
return team;
},
listTeams: async (organizationId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const teams = await adapter.findMany<Team>({
model: "team",
where: [
{
field: "organizationId",
value: organizationId,
},
],
});
return teams;
},
createTeamInvitation: async ({
email,
role,
teamId,
organizationId,
inviterId,
expiresIn = 1000 * 60 * 60 * 48, // Default expiration: 48 hours
}: {
email: string;
role: string;
teamId: string;
organizationId: string;
inviterId: string;
expiresIn?: number;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const expiresAt = getDate(expiresIn); // Get expiration date
const invitation = await adapter.create<
InvitationInput,
InferInvitation<O>
>({
model: "invitation",
data: {
email,
role,
organizationId,
teamId,
inviterId,
status: "pending",
expiresAt,
},
});
return invitation;
},
setActiveTeam: async (
sessionToken: string,
teamId: string | null,
ctx: GenericEndpointContext,
) => {
const session = await context.internalAdapter.updateSession(
sessionToken,
{
activeTeamId: teamId,
},
);
return session as Session;
},
listTeamMembers: async (data: { teamId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const members = await adapter.findMany<TeamMember>({
model: "teamMember",
where: [
{
field: "teamId",
value: data.teamId,
},
],
});
return members;
},
countTeamMembers: async (data: { teamId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const count = await adapter.count({
model: "teamMember",
where: [{ field: "teamId", value: data.teamId }],
});
return count;
},
countMembers: async (data: { organizationId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const count = await adapter.count({
model: "member",
where: [{ field: "organizationId", value: data.organizationId }],
});
return count;
},
listTeamsByUser: async (data: { userId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const members = await adapter.findMany<TeamMember>({
model: "teamMember",
where: [
{
field: "userId",
value: data.userId,
},
],
});
const teams = await adapter.findMany<Team>({
model: "team",
where: [
{
field: "id",
operator: "in",
value: members.map((m) => m.teamId),
},
],
});
return teams;
},
findTeamMember: async (data: { teamId: string; userId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.findOne<TeamMember>({
model: "teamMember",
where: [
{
field: "teamId",
value: data.teamId,
},
{
field: "userId",
value: data.userId,
},
],
});
return member;
},
findOrCreateTeamMember: async (data: {
teamId: string;
userId: string;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const member = await adapter.findOne<TeamMember>({
model: "teamMember",
where: [
{
field: "teamId",
value: data.teamId,
},
{
field: "userId",
value: data.userId,
},
],
});
if (member) return member;
return await adapter.create<Omit<TeamMember, "id">, TeamMember>({
model: "teamMember",
data: {
teamId: data.teamId,
userId: data.userId,
createdAt: new Date(),
},
});
},
removeTeamMember: async (data: { teamId: string; userId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
// use `deleteMany` instead of `delete` since Prisma requires 1 unique field for normal `delete` operations
// FKs do not count thus breaking the operation. As a solution, we'll use `deleteMany` instead.
await adapter.deleteMany({
model: "teamMember",
where: [
{
field: "teamId",
value: data.teamId,
},
{
field: "userId",
value: data.userId,
},
],
});
},
findInvitationsByTeamId: async (teamId: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitations = await adapter.findMany<InferInvitation<O>>({
model: "invitation",
where: [
{
field: "teamId",
value: teamId,
},
],
});
return invitations;
},
listUserInvitations: async (email: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitations = await adapter.findMany<InferInvitation<O>>({
model: "invitation",
where: [{ field: "email", value: email.toLowerCase() }],
});
return invitations;
},
createInvitation: async ({
invitation,
user,
}: {
invitation: {
email: string;
role: string;
organizationId: string;
teamIds: string[];
} & Record<string, any>; // This represents the additionalFields for the invitation
user: User;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const defaultExpiration = 60 * 60 * 48;
const expiresAt = getDate(
options?.invitationExpiresIn || defaultExpiration,
"sec",
);
const invite = await adapter.create<
Omit<InvitationInput, "id">,
InferInvitation<O>
>({
model: "invitation",
data: {
status: "pending",
expiresAt,
createdAt: new Date(),
inviterId: user.id,
...invitation,
teamId:
invitation.teamIds.length > 0 ? invitation.teamIds.join(",") : null,
},
});
return invite;
},
findInvitationById: async (id: string) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitation = await adapter.findOne<InferInvitation<O>>({
model: "invitation",
where: [
{
field: "id",
value: id,
},
],
});
return invitation;
},
findPendingInvitation: async (data: {
email: string;
organizationId: string;
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitation = await adapter.findMany<InferInvitation<O>>({
model: "invitation",
where: [
{
field: "email",
value: data.email.toLowerCase(),
},
{
field: "organizationId",
value: data.organizationId,
},
{
field: "status",
value: "pending",
},
],
});
return invitation.filter(
(invite) => new Date(invite.expiresAt) > new Date(),
);
},
findPendingInvitations: async (data: { organizationId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitations = await adapter.findMany<InferInvitation<O>>({
model: "invitation",
where: [
{
field: "organizationId",
value: data.organizationId,
},
{
field: "status",
value: "pending",
},
],
});
return invitations.filter(
(invite) => new Date(invite.expiresAt) > new Date(),
);
},
listInvitations: async (data: { organizationId: string }) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitations = await adapter.findMany<InferInvitation<O>>({
model: "invitation",
where: [
{
field: "organizationId",
value: data.organizationId,
},
],
});
return invitations;
},
updateInvitation: async (data: {
invitationId: string;
status: "accepted" | "canceled" | "rejected";
}) => {
const adapter = await getCurrentAdapter(baseAdapter);
const invitation = await adapter.update<InferInvitation<O>>({
model: "invitation",
where: [
{
field: "id",
value: data.invitationId,
},
],
update: {
status: data.status,
},
});
return invitation;
},
};
};
```
--------------------------------------------------------------------------------
/docs/content/docs/concepts/database.mdx:
--------------------------------------------------------------------------------
```markdown
---
title: Database
description: Learn how to use a database with Better Auth.
---
## Adapters
Better Auth requires a database connection to store data. The database will be used to store data such as users, sessions, and more. Plugins can also define their own database tables to store data.
You can pass a database connection to Better Auth by passing a supported database instance in the database options. You can learn more about supported database adapters in the [Other relational databases](/docs/adapters/other-relational-databases) documentation.
## CLI
Better Auth comes with a CLI tool to manage database migrations and generate schema.
### Running Migrations
The cli checks your database and prompts you to add missing tables or update existing ones with new columns. This is only supported for the built-in Kysely adapter. For other adapters, you can use the `generate` command to create the schema and handle the migration through your ORM.
```bash
npx @better-auth/cli migrate
```
<Callout type="info">
For PostgreSQL users: The migrate command supports non-default schemas. It automatically detects your `search_path` configuration and creates tables in the correct schema. See [PostgreSQL adapter](/docs/adapters/postgresql#use-a-non-default-schema) for details.
</Callout>
### Generating Schema
Better Auth also provides a `generate` command to generate the schema required by Better Auth. The `generate` command creates the schema required by Better Auth. If you're using a database adapter like Prisma or Drizzle, this command will generate the right schema for your ORM. If you're using the built-in Kysely adapter, it will generate an SQL file you can run directly on your database.
```bash
npx @better-auth/cli generate
```
See the [CLI](/docs/concepts/cli) documentation for more information on the CLI.
<Callout>
If you prefer adding tables manually, you can do that as well. The core schema
required by Better Auth is described below and you can find additional schema
required by plugins in the plugin documentation.
</Callout>
## Secondary Storage
Secondary storage in Better Auth allows you to use key-value stores for managing session data, rate limiting counters, etc. This can be useful when you want to offload the storage of this intensive records to a high performance storage or even RAM.
### Implementation
To use secondary storage, implement the `SecondaryStorage` interface:
```typescript
interface SecondaryStorage {
get: (key: string) => Promise<unknown>;
set: (key: string, value: string, ttl?: number) => Promise<void>;
delete: (key: string) => Promise<void>;
}
```
Then, provide your implementation to the `betterAuth` function:
```typescript
betterAuth({
// ... other options
secondaryStorage: {
// Your implementation here
},
});
```
**Example: Redis Implementation**
Here's a basic example using Redis:
```typescript
import { createClient } from "redis";
import { betterAuth } from "better-auth";
const redis = createClient();
await redis.connect();
export const auth = betterAuth({
// ... other options
secondaryStorage: {
get: async (key) => {
return await redis.get(key);
},
set: async (key, value, ttl) => {
if (ttl) await redis.set(key, value, { EX: ttl });
// or for ioredis:
// if (ttl) await redis.set(key, value, 'EX', ttl)
else await redis.set(key, value);
},
delete: async (key) => {
await redis.del(key);
}
}
});
```
This implementation allows Better Auth to use Redis for storing session data and rate limiting counters. You can also add prefixes to the keys names.
## Core Schema
Better Auth requires the following tables to be present in the database. The types are in `typescript` format. You can use corresponding types in your database.
### User
Table Name: `user`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for each user",
isPrimaryKey: true,
},
{
name: "name",
type: "string",
description: "User's chosen display name",
},
{
name: "email",
type: "string",
description: "User's email address for communication and login",
},
{
name: "emailVerified",
type: "boolean",
description: "Whether the user's email is verified",
},
{
name: "image",
type: "string",
description: "User's image url",
isOptional: true,
},
{
name: "createdAt",
type: "Date",
description: "Timestamp of when the user account was created",
},
{
name: "updatedAt",
type: "Date",
description: "Timestamp of the last update to the user's information",
},
]}
/>
### Session
Table Name: `session`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for each session",
isPrimaryKey: true,
},
{
name: "userId",
type: "string",
description: "The ID of the user",
isForeignKey: true,
},
{
name: "token",
type: "string",
description: "The unique session token",
isUnique: true,
},
{
name: "expiresAt",
type: "Date",
description: "The time when the session expires",
},
{
name: "ipAddress",
type: "string",
description: "The IP address of the device",
isOptional: true,
},
{
name: "userAgent",
type: "string",
description: "The user agent information of the device",
isOptional: true,
},
{
name: "createdAt",
type: "Date",
description: "Timestamp of when the session was created",
},
{
name: "updatedAt",
type: "Date",
description: "Timestamp of when the session was updated",
},
]}
/>
### Account
Table Name: `account`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for each account",
isPrimaryKey: true,
},
{
name: "userId",
type: "string",
description: "The ID of the user",
isForeignKey: true,
},
{
name: "accountId",
type: "string",
description:
"The ID of the account as provided by the SSO or equal to userId for credential accounts",
},
{
name: "providerId",
type: "string",
description: "The ID of the provider",
},
{
name: "accessToken",
type: "string",
description: "The access token of the account. Returned by the provider",
isOptional: true,
},
{
name: "refreshToken",
type: "string",
description: "The refresh token of the account. Returned by the provider",
isOptional: true,
},
{
name: "accessTokenExpiresAt",
type: "Date",
description: "The time when the access token expires",
isOptional: true,
},
{
name: "refreshTokenExpiresAt",
type: "Date",
description: "The time when the refresh token expires",
isOptional: true,
},
{
name: "scope",
type: "string",
description: "The scope of the account. Returned by the provider",
isOptional: true,
},
{
name: "idToken",
type: "string",
description: "The ID token returned from the provider",
isOptional: true,
},
{
name: "password",
type: "string",
description:
"The password of the account. Mainly used for email and password authentication",
isOptional: true,
},
{
name: "createdAt",
type: "Date",
description: "Timestamp of when the account was created",
},
{
name: "updatedAt",
type: "Date",
description: "Timestamp of when the account was updated",
},
]}
/>
### Verification
Table Name: `verification`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for each verification",
isPrimaryKey: true,
},
{
name: "identifier",
type: "string",
description: "The identifier for the verification request",
},
{
name: "value",
type: "string",
description: "The value to be verified",
},
{
name: "expiresAt",
type: "Date",
description: "The time when the verification request expires",
},
{
name: "createdAt",
type: "Date",
description: "Timestamp of when the verification request was created",
},
{
name: "updatedAt",
type: "Date",
description: "Timestamp of when the verification request was updated",
},
]}
/>
## Custom Tables
Better Auth allows you to customize the table names and column names for the core schema. You can also extend the core schema by adding additional fields to the user and session tables.
### Custom Table Names
You can customize the table names and column names for the core schema by using the `modelName` and `fields` properties in your auth config:
```ts title="auth.ts"
export const auth = betterAuth({
user: {
modelName: "users",
fields: {
name: "full_name",
email: "email_address",
},
},
session: {
modelName: "user_sessions",
fields: {
userId: "user_id",
},
},
});
```
<Callout>
Type inference in your code will still use the original field names (e.g.,
`user.name`, not `user.full_name`).
</Callout>
To customize table names and column name for plugins, you can use the `schema` property in the plugin config:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
twoFactor({
schema: {
user: {
fields: {
twoFactorEnabled: "two_factor_enabled",
secret: "two_factor_secret",
},
},
},
}),
],
});
```
### Extending Core Schema
Better Auth provides a type-safe way to extend the `user` and `session` schemas. You can add custom fields to your auth config, and the CLI will automatically update the database schema. These additional fields will be properly inferred in functions like `useSession`, `signUp.email`, and other endpoints that work with user or session objects.
To add custom fields, use the `additionalFields` property in the `user` or `session` object of your auth config. The `additionalFields` object uses field names as keys, with each value being a `FieldAttributes` object containing:
- `type`: The data type of the field (e.g., "string", "number", "boolean").
- `required`: A boolean indicating if the field is mandatory.
- `defaultValue`: The default value for the field (note: this only applies in the JavaScript layer; in the database, the field will be optional).
- `input`: This determines whether a value can be provided when creating a new record (default: `true`). If there are additional fields, like `role`, that should not be provided by the user during signup, you can set this to `false`.
Here's an example of how to extend the user schema with additional fields:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
user: {
additionalFields: {
role: {
type: "string",
required: false,
defaultValue: "user",
input: false, // don't allow user to set role
},
lang: {
type: "string",
required: false,
defaultValue: "en",
},
},
},
});
```
Now you can access the additional fields in your application logic.
```ts
//on signup
const res = await auth.api.signUpEmail({
email: "[email protected]",
password: "password",
name: "John Doe",
lang: "fr",
});
//user object
res.user.role; // > "admin"
res.user.lang; // > "fr"
```
<Callout>
See the
[TypeScript](/docs/concepts/typescript#inferring-additional-fields-on-client)
documentation for more information on how to infer additional fields on the
client side.
</Callout>
If you're using social / OAuth providers, you may want to provide `mapProfileToUser` to map the profile data to the user object. So, you can populate additional fields from the provider's profile.
**Example: Mapping Profile to User For `firstName` and `lastName`**
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
socialProviders: {
github: {
clientId: "YOUR_GITHUB_CLIENT_ID",
clientSecret: "YOUR_GITHUB_CLIENT_SECRET",
mapProfileToUser: (profile) => {
return {
firstName: profile.name.split(" ")[0],
lastName: profile.name.split(" ")[1],
};
},
},
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
mapProfileToUser: (profile) => {
return {
firstName: profile.given_name,
lastName: profile.family_name,
};
},
},
},
});
```
### ID Generation
Better Auth by default will generate unique IDs for users, sessions, and other entities. You can customize ID generation behavior using the `advanced.database.generateId` option or `advanced.database.useNumberId`.
#### Option 1: Let Database Generate IDs
Setting `generateId` to `false` disables ID generation globally, letting your database handle all ID generation:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { db } from "./db";
export const auth = betterAuth({
database: db,
advanced: {
database: {
generateId: false, // Database generates all IDs
},
},
});
```
<Callout type="info">
**Note**: For auto-incrementing numeric IDs (serial, auto_increment), use `useNumberId: true` instead (see the Numeric IDs section below).
</Callout>
#### Option 2: Custom ID Generation Function
Use a function to generate IDs. You can return `false` or `undefined` from the function to let the database generate the ID for specific models:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { db } from "./db";
export const auth = betterAuth({
database: db,
advanced: {
database: {
generateId: (options) => {
// Let database auto-generate for specific models
if (options.model === "user" || options.model === "users") {
return false; // Let database generate ID
}
// Generate UUIDs for other tables
return crypto.randomUUID();
},
},
},
});
```
<Callout type="info">
**Important**: Returning `false` or `undefined` from the `generateId` function lets the database handle ID generation for that specific model. Setting `generateId: false` (without a function) disables ID generation for **all** tables.
</Callout>
#### Option 3: Consistent Custom ID Generator
Generate the same type of ID for all tables:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { db } from "./db";
export const auth = betterAuth({
database: db,
advanced: {
database: {
generateId: () => crypto.randomUUID(),
},
},
});
```
### Numeric IDs
If you prefer auto-incrementing numeric IDs, you can set the `advanced.database.useNumberId` option to `true`.
Doing this will disable Better-Auth from generating IDs for any table, and will assume your
database will generate the numeric ID automatically.
When enabled, the Better-Auth CLI will generate or migrate the schema with the `id` field as a numeric type for your database
with auto-incrementing attributes associated with it.
```ts
import { betterAuth } from "better-auth";
import { db } from "./db";
export const auth = betterAuth({
database: db,
advanced: {
database: {
useNumberId: true,
},
},
});
```
<Callout type="info">
Better-Auth will continue to infer the type of the `id` field as a `string` for the database, but will
automatically convert it to a numeric type when fetching or inserting data from the database.
It's likely when grabbing `id` values returned from Better-Auth that you'll receive a string version of a number,
this is normal. It's also expected that all id values passed to Better-Auth (eg via an endpoint body) is expected to be a string.
</Callout>
### Mixed ID Types
If you need different ID types across tables (e.g., integer IDs for users, UUID strings for sessions/accounts/verification), use a `generateId` function instead of `useNumberId`.
<Callout type="warn">
**Do NOT use `useNumberId` when mixing ID types**. The `useNumberId` option is global and affects all tables. It will disable internal ID generation and expect all IDs to be numbers.
</Callout>
**Example: Integer IDs for Users, UUIDs for Other Tables**
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { db } from "./db";
export const auth = betterAuth({
database: db,
user: {
modelName: "users", // PostgreSQL: id serial primary key
},
session: {
modelName: "session", // PostgreSQL: id text primary key
},
advanced: {
database: {
// Do NOT set useNumberId - it's global and affects all tables
generateId: (options) => {
if (options.model === "user" || options.model === "users") {
return false; // Let PostgreSQL serial generate it
}
return crypto.randomUUID(); // UUIDs for session, account, verification
},
},
},
});
```
This configuration allows you to:
- Use database auto-increment (serial, auto_increment, etc.) for the users table
- Generate UUIDs for all other tables (session, account, verification)
- Maintain compatibility with existing schemas that use different ID types
<Callout type="info">
**Use Case**: This is particularly useful when migrating from other authentication providers (like Clerk) where you have existing users with integer IDs but want UUID strings for new tables.
</Callout>
### Database Hooks
Database hooks allow you to define custom logic that can be executed during the lifecycle of core database operations in Better Auth. You can create hooks for the following models: **user**, **session**, and **account**.
<Callout type="warn">
Additional fields are supported, however full type inference for these fields isn't yet supported.
Improved type support is planned.
</Callout>
There are two types of hooks you can define:
#### 1. Before Hook
- **Purpose**: This hook is called before the respective entity (user, session, or account) is created, updated, or deleted.
- **Behavior**: If the hook returns `false`, the operation will be aborted. And If it returns a data object, it'll replace the original payload.
#### 2. After Hook
- **Purpose**: This hook is called after the respective entity is created or updated.
- **Behavior**: You can perform additional actions or modifications after the entity has been successfully created or updated.
**Example Usage**
```typescript title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
// Modify the user object before it is created
return {
data: {
// Ensure to return Better-Auth named fields, not the original field names in your database.
...user,
firstName: user.name.split(" ")[0],
lastName: user.name.split(" ")[1],
},
};
},
after: async (user) => {
//perform additional actions, like creating a stripe customer
},
},
delete: {
before: async (user, ctx) => {
console.log(`User ${user.email} is being deleted`);
if (user.email.includes("admin")) {
return false; // Abort deletion
}
return true; // Allow deletion
},
after: async (user) => {
console.log(`User ${user.email} has been deleted`);
},
},
},
session: {
delete: {
before: async (session, ctx) => {
console.log(`Session ${session.token} is being deleted`);
if (session.userId === "admin-user-id") {
return false; // Abort deletion
}
return true; // Allow deletion
},
after: async (session) => {
console.log(`Session ${session.token} has been deleted`);
},
},
},
},
});
```
#### Throwing Errors
If you want to stop the database hook from proceeding, you can throw errors using the `APIError` class imported from `better-auth/api`.
```typescript title="auth.ts"
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
if (user.isAgreedToTerms === false) {
// Your special condition.
// Send the API error.
throw new APIError("BAD_REQUEST", {
message: "User must agree to the TOS before signing up.",
});
}
return {
data: user,
};
},
},
},
},
});
```
#### Using the Context Object
The context object (`ctx`), passed as the second argument to the hook, contains useful information. For `update` hooks, this includes the current `session`, which you can use to access the logged-in user's details.
```typescript title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
databaseHooks: {
user: {
update: {
before: async (data, ctx) => {
// You can access the session from the context object.
if (ctx.context.session) {
console.log("User update initiated by:", ctx.context.session.userId);
}
return { data };
},
},
},
},
});
```
Much like standard hooks, database hooks also provide a `ctx` object that offers a variety of useful properties. Learn more in the [Hooks Documentation](/docs/concepts/hooks#ctx).
## Plugins Schema
Plugins can define their own tables in the database to store additional data. They can also add columns to the core tables to store additional data. For example, the two factor authentication plugin adds the following columns to the `user` table:
- `twoFactorEnabled`: Whether two factor authentication is enabled for the user.
- `twoFactorSecret`: The secret key used to generate TOTP codes.
- `twoFactorBackupCodes`: Encrypted backup codes for account recovery.
To add new tables and columns to your database, you have two options:
`CLI`: Use the migrate or generate command. These commands will scan your database and guide you through adding any missing tables or columns.
`Manual Method`: Follow the instructions in the plugin documentation to manually add tables and columns.
Both methods ensure your database schema stays up to date with your plugins' requirements.
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/routes/session-api.test.ts:
--------------------------------------------------------------------------------
```typescript
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { parseSetCookieHeader } from "../../cookies";
import { getDate } from "../../utils/date";
import { memoryAdapter, type MemoryDB } from "../../adapters/memory-adapter";
import { runWithEndpointContext } from "@better-auth/core/context";
import type { GenericEndpointContext } from "@better-auth/core";
describe("session", async () => {
const { client, testUser, sessionSetter, cookieSetter, auth } =
await getTestInstance();
it("should set cookies correctly on sign in", async () => {
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess(context) {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
cookieSetter(headers)(context);
const cookie = cookies.get("better-auth.session_token");
expect(cookie).toMatchObject({
value: expect.any(String),
"max-age": 60 * 60 * 24 * 7,
path: "/",
samesite: "lax",
httponly: true,
});
},
},
);
const { data } = await client.getSession({
fetchOptions: {
headers,
},
});
const expiresAt = new Date(data?.session.expiresAt || "");
const now = new Date();
expect(expiresAt.getTime()).toBeGreaterThan(
now.getTime() + 6 * 24 * 60 * 60 * 1000,
);
});
it("should return null when not authenticated", async () => {
const response = await client.getSession();
expect(response.data).toBeNull();
});
it("should update session when update age is reached", async () => {
const { client, testUser } = await getTestInstance({
session: {
updateAge: 60,
expiresIn: 60 * 2,
},
});
let headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
const data = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
if (!data) {
throw new Error("No session found");
}
expect(new Date(data?.session.expiresAt).getTime()).toBeGreaterThan(
new Date(Date.now() + 1000 * 2 * 59).getTime(),
);
expect(new Date(data?.session.expiresAt).getTime()).toBeLessThan(
new Date(Date.now() + 1000 * 2 * 60).getTime(),
);
for (const t of [60, 80, 100, 121]) {
const span = new Date();
span.setSeconds(span.getSeconds() + t);
vi.setSystemTime(span);
const response = await client.getSession({
fetchOptions: {
headers,
onSuccess(context) {
const parsed = parseSetCookieHeader(
context.response.headers.get("set-cookie") || "",
);
const maxAge = parsed.get("better-auth.session_token")?.["max-age"];
expect(maxAge).toBe(t === 121 ? 0 : 60 * 2);
},
},
});
if (t === 121) {
//expired
expect(response.data).toBeNull();
} else {
expect(
new Date(response.data?.session.expiresAt!).getTime(),
).toBeGreaterThan(new Date(Date.now() + 1000 * 2 * 59).getTime());
}
}
vi.useRealTimers();
});
it("should update the session every time when set to 0", async () => {
const { client, signInWithTestUser } = await getTestInstance({
session: {
updateAge: 0,
},
});
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const session = await client.getSession();
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 5);
const session2 = await client.getSession();
expect(session2.data?.session.expiresAt).not.toBe(
session.data?.session.expiresAt,
);
expect(
new Date(session2.data!.session.expiresAt).getTime(),
).toBeGreaterThan(new Date(session.data!.session.expiresAt).getTime());
});
});
it("should handle 'don't remember me' option", async () => {
let headers = new Headers();
const res = await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
rememberMe: false,
},
{
onSuccess: cookieSetter(headers),
},
);
const data = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
if (!data) {
throw new Error("No session found");
}
const expiresAt = data.session.expiresAt;
expect(new Date(expiresAt).valueOf()).toBeLessThanOrEqual(
getDate(1000 * 60 * 60 * 24).valueOf(),
);
const response = await client.getSession({
fetchOptions: {
headers,
},
});
if (!response.data?.session) {
throw new Error("No session found");
}
// Check that the session wasn't update
expect(
new Date(response.data.session.expiresAt).valueOf(),
).toBeLessThanOrEqual(getDate(1000 * 60 * 60 * 24).valueOf());
});
it("should set cookies correctly on sign in after changing config", async () => {
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess(context) {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
expect(cookies.get("better-auth.session_token")).toMatchObject({
value: expect.any(String),
"max-age": 60 * 60 * 24 * 7,
path: "/",
httponly: true,
samesite: "lax",
});
cookieSetter(headers)(context);
},
},
);
const data = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
if (!data) {
throw new Error("No session found");
}
const expiresAt = new Date(data?.session?.expiresAt || "");
const now = new Date();
expect(expiresAt.getTime()).toBeGreaterThan(
now.getTime() + 6 * 24 * 60 * 60 * 1000,
);
});
it("should clear session on sign out", async () => {
let headers = new Headers();
const res = await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
const data = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(data).not.toBeNull();
await client.signOut({
fetchOptions: {
headers,
},
});
const response = await client.getSession({
fetchOptions: {
headers,
},
});
expect(response.data);
});
it("should list sessions", async () => {
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: sessionSetter(headers),
},
);
const response = await client.listSessions({
fetchOptions: {
headers,
},
});
expect(response.data?.length).toBeGreaterThan(1);
});
it("should revoke session", async () => {
const headers = new Headers();
const headers2 = new Headers();
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
onSuccess: sessionSetter(headers),
},
});
await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
onSuccess: sessionSetter(headers2),
},
});
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
await client.revokeSession({
fetchOptions: {
headers,
},
token: session?.session?.token || "",
});
const newSession = await client.getSession({
fetchOptions: {
headers,
},
});
expect(newSession.data).toBeNull();
const revokeRes = await client.revokeSessions({
fetchOptions: {
headers: headers2,
},
});
expect(revokeRes.data?.status).toBe(true);
});
it("should return session headers", async () => {
const context = await auth.$context;
await runWithEndpointContext(
{
context,
} as unknown as GenericEndpointContext,
async () => {
const signInRes = await auth.api.signInEmail({
body: {
email: testUser.email,
password: testUser.password,
},
returnHeaders: true,
});
const signInHeaders = new Headers();
signInHeaders.set("cookie", signInRes.headers.getSetCookie()[0]!);
const sessionResWithoutHeaders = await auth.api.getSession({
headers: signInHeaders,
});
const sessionResWithHeaders = await auth.api.getSession({
headers: signInHeaders,
returnHeaders: true,
});
expect(sessionResWithHeaders.headers).toBeDefined();
expect(sessionResWithHeaders.response?.user).toBeDefined();
expect(sessionResWithHeaders.response?.session).toBeDefined();
expectTypeOf({
headers: sessionResWithHeaders.headers,
}).toMatchObjectType<{
headers: Headers;
}>();
// @ts-expect-error: headers should not exist on sessionResWithoutHeaders
expect(sessionResWithoutHeaders.headers).toBeUndefined();
const sessionResWithHeadersAndAsResponse = await auth.api.getSession({
headers: signInHeaders,
returnHeaders: true,
asResponse: true,
});
expectTypeOf({
res: sessionResWithHeadersAndAsResponse,
}).toMatchObjectType<{ res: Response }>();
expect(sessionResWithHeadersAndAsResponse.ok).toBe(true);
expect(sessionResWithHeadersAndAsResponse.status).toBe(200);
},
);
});
});
describe("session storage", async () => {
let store = new Map<string, string>();
const { client, signInWithTestUser, db } = await getTestInstance({
secondaryStorage: {
set(key, value, ttl) {
store.set(key, value);
},
get(key) {
return store.get(key) || null;
},
delete(key) {
store.delete(key);
},
},
rateLimit: {
enabled: false,
},
});
beforeEach(() => {
store.clear();
});
it("should store session in secondary storage", async () => {
//since the instance creates a session on init, we expect the store to have 2 item (1 for session and 1 for active sessions record for the user)
expect(store.size).toBe(0);
const { runWithUser } = await signInWithTestUser();
expect(store.size).toBe(2);
await runWithUser(async () => {
const session = await client.getSession();
expect(session.data).toMatchObject({
session: {
userId: expect.any(String),
token: expect.any(String),
expiresAt: expect.any(Date),
ipAddress: expect.any(String),
userAgent: expect.any(String),
},
user: {
id: expect.any(String),
name: "test user",
email: "[email protected]",
emailVerified: false,
image: null,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
},
});
});
});
it("should list sessions", async () => {
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const response = await client.listSessions();
expect(response.data?.length).toBe(1);
});
});
it("revoke session and list sessions", async () => {
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const session = await client.getSession();
expect(session.data).not.toBeNull();
expect(session.data?.session?.token).toBeDefined();
const userId = session.data!.session.userId;
const sessions = JSON.parse(store.get(`active-sessions-${userId}`)!);
expect(sessions.length).toBe(1);
const res = await client.revokeSession({
token: session.data?.session?.token!,
});
expect(res.data?.status).toBe(true);
const response = await client.listSessions();
expect(response.data).toBe(null);
expect(store.size).toBe(0);
});
});
it("should revoke session", async () => {
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const session = await client.getSession();
expect(session.data).not.toBeNull();
const res = await client.revokeSession({
token: session.data?.session?.token || "",
});
const revokedSession = await client.getSession();
expect(revokedSession.data).toBeNull();
});
});
});
describe("cookie cache", async () => {
const database: MemoryDB = {
user: [],
account: [],
session: [],
verification: [],
};
const adapter = memoryAdapter(database);
const { client, testUser, auth, cookieSetter } = await getTestInstance({
database: adapter,
session: {
additionalFields: {
sensitiveData: {
type: "string",
returned: false,
defaultValue: "sensitive-data",
},
},
cookieCache: {
enabled: true,
strategy: "base64-hmac",
freshCache: false,
},
},
});
const ctx = await auth.$context;
it("should cache cookies", async () => {});
const fn = vi.spyOn(ctx.adapter, "findOne");
const headers = new Headers();
it("should cache cookies", async () => {
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
expect(fn).toHaveBeenCalledTimes(1);
const session = await client.getSession({
fetchOptions: {
headers,
},
});
expect(session.data?.session).not.toHaveProperty("sensitiveData");
expect(session.data).not.toBeNull();
expect(fn).toHaveBeenCalledTimes(1);
});
it("should disable cookie cache", async () => {
const ctx = await auth.$context;
const s = await client.getSession({
fetchOptions: {
headers,
},
});
expect(s.data?.user.emailVerified).toBe(false);
await runWithEndpointContext(
{
context: ctx,
} as unknown as GenericEndpointContext,
async () => {
await ctx.internalAdapter.updateUser(s.data?.user.id || "", {
emailVerified: true,
});
},
);
expect(fn).toHaveBeenCalledTimes(1);
const session = await client.getSession({
query: {
disableCookieCache: true,
},
fetchOptions: {
headers,
},
});
expect(session.data?.user.emailVerified).toBe(true);
expect(session.data).not.toBeNull();
expect(fn).toHaveBeenCalledTimes(3);
});
it("should reset cache when expires", async () => {
expect(fn).toHaveBeenCalledTimes(3);
await client.getSession({
fetchOptions: {
headers,
},
});
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 10); // 10 minutes
await client.getSession({
fetchOptions: {
headers,
onSuccess(context) {
cookieSetter(headers)(context);
},
},
});
expect(fn).toHaveBeenCalledTimes(5);
await client.getSession({
fetchOptions: {
headers,
onSuccess(context) {
cookieSetter(headers)(context);
},
},
});
expect(fn).toHaveBeenCalledTimes(5);
});
});
describe("cookie cache with JWT strategy", async () => {
const { auth, client, testUser, cookieSetter } = await getTestInstance({
session: {
additionalFields: {
sensitiveData: {
type: "string",
returned: false,
defaultValue: "sensitive-data",
},
},
cookieCache: {
enabled: true,
strategy: "jwt",
freshCache: false,
},
},
});
const ctx = await auth.$context;
const fn = vi.spyOn(ctx.adapter, "findOne");
const headers = new Headers();
it("should cache cookies with JWT strategy", async () => {
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
expect(fn).toHaveBeenCalledTimes(1);
const session = await client.getSession({
fetchOptions: {
headers,
},
});
expect(session.data?.session).not.toHaveProperty("sensitiveData");
expect(session.data).not.toBeNull();
expect(fn).toHaveBeenCalledTimes(1); // Should still be 1 (cache hit)
});
it("should disable cookie cache with JWT strategy", async () => {
const ctx = await auth.$context;
const s = await client.getSession({
fetchOptions: {
headers,
},
});
expect(s.data?.user.emailVerified).toBe(false);
await runWithEndpointContext(
{
context: ctx,
} as unknown as GenericEndpointContext,
async () => {
await ctx.internalAdapter.updateUser(s.data?.user.id || "", {
emailVerified: true,
});
},
);
expect(fn).toHaveBeenCalledTimes(1);
const session = await client.getSession({
query: {
disableCookieCache: true,
},
fetchOptions: {
headers,
},
});
expect(session.data?.user.emailVerified).toBe(true);
expect(session.data).not.toBeNull();
expect(fn).toHaveBeenCalledTimes(3); // Database hit when cache disabled
});
it("should reset JWT cache when expires", async () => {
expect(fn).toHaveBeenCalledTimes(3);
await client.getSession({
fetchOptions: {
headers,
},
});
expect(fn).toHaveBeenCalledTimes(3);
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 60 * 10);
await client.getSession({
fetchOptions: {
headers,
onSuccess(context) {
cookieSetter(headers)(context);
},
},
});
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(3);
vi.useRealTimers();
});
it("should handle multiple concurrent requests with JWT cache", async () => {
vi.useRealTimers();
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
// Make multiple concurrent requests
const promises = Array(5)
.fill(0)
.map(() =>
client.getSession({
fetchOptions: {
headers,
},
}),
);
const results = await Promise.all(promises);
// All should return valid sessions
results.forEach((result) => {
expect(result.data).not.toBeNull();
expect(result.data?.user.email).toBe(testUser.email);
});
});
});
describe("cookie cache freshCache", async () => {
const { auth, client, testUser, cookieSetter } = await getTestInstance({
session: {
cookieCache: {
enabled: true,
strategy: "jwt",
freshCache: 60, // 60 seconds
},
},
});
const ctx = await auth.$context;
const fn = vi.spyOn(ctx.adapter, "findOne");
const headers = new Headers();
it("should use cached data when freshCache threshold has not been reached", async () => {
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
expect(fn).toHaveBeenCalledTimes(1);
const session1 = await client.getSession({
fetchOptions: {
headers,
},
});
expect(session1.data).not.toBeNull();
expect(fn).toHaveBeenCalledTimes(1);
const session2 = await client.getSession({
fetchOptions: {
headers,
},
});
expect(session2.data).not.toBeNull();
expect(fn).toHaveBeenCalledTimes(1);
});
it("should refresh cache when freshCache threshold is exceeded", async () => {
const callsBefore = fn.mock.calls.length;
vi.useFakeTimers();
// Advance time by 61 seconds (beyond the 60 second freshCache threshold)
await vi.advanceTimersByTimeAsync(1000 * 61);
const session = await client.getSession({
fetchOptions: {
headers,
onSuccess(context) {
cookieSetter(headers)(context);
},
},
});
expect(session.data).not.toBeNull();
const callsAfterRefresh = fn.mock.calls.length;
expect(callsAfterRefresh).toBeGreaterThan(callsBefore);
await client.getSession({
fetchOptions: {
headers,
},
});
expect(fn).toHaveBeenCalledTimes(callsAfterRefresh);
vi.useRealTimers();
});
it("should not refresh cache when freshCache is disabled (false)", async () => {
const {
client,
testUser: testUser0,
cookieSetter,
auth,
} = await getTestInstance({
session: {
cookieCache: {
enabled: true,
strategy: "jwt",
freshCache: false, // Disabled
},
},
});
const ctx = await auth.$context;
const fn = vi.spyOn(ctx.adapter, "findOne");
const headers = new Headers();
await client.signIn.email(
{
email: testUser0.email,
password: testUser0.password,
},
{
onSuccess: cookieSetter(headers),
},
);
const callsAfterSignIn = fn.mock.calls.length;
// Even after advancing time, cache should still be used (freshCache disabled)
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 120); // 2 minutes
await client.getSession({
fetchOptions: {
headers: headers,
},
});
const callsAfterFirst = fn.mock.calls.length;
expect(callsAfterFirst).toBe(callsAfterSignIn); // No DB call, cache used
await client.getSession({
fetchOptions: {
headers: headers,
},
});
const callsAfterSecond = fn.mock.calls.length;
expect(callsAfterSecond).toBe(callsAfterSignIn); // Still no DB call, cache used
vi.useRealTimers();
});
it("should work without database (session stored in cookie only)", async () => {
// Create instance with cookieCache enabled and freshCache disabled
const {
client,
testUser: testUser0,
cookieSetter,
auth,
} = await getTestInstance({
session: {
cookieCache: {
enabled: true,
strategy: "jwt",
freshCache: false, // Don't refresh, use cookie only
},
},
});
const headers = new Headers();
// Sign in to create session cookie
await client.signIn.email(
{
email: testUser0.email,
password: testUser0.password,
},
{
onSuccess: cookieSetter(headers),
},
);
// Get the session to ensure it's in the cookie
const firstSession = await client.getSession({
fetchOptions: {
headers,
},
});
expect(firstSession.data).not.toBeNull();
const sessionToken = firstSession.data?.session?.token;
// Clear the database session (simulating no database scenario)
if (sessionToken) {
const ctx = await auth.$context;
await ctx.internalAdapter.deleteSession(sessionToken);
}
// getSession should still work using cookie cache only (no database lookup)
const sessionFromCache = await client.getSession({
fetchOptions: {
headers,
},
});
expect(sessionFromCache.data).not.toBeNull();
expect(sessionFromCache.data?.user.email).toBe(testUser0.email);
expect(sessionFromCache.data?.session).toBeDefined();
expect(sessionFromCache.data?.session?.token).toBe(sessionToken);
});
it("should work without database when freshCache threshold is reached", async () => {
const { client, testUser, cookieSetter, auth } = await getTestInstance({
session: {
cookieCache: {
enabled: true,
strategy: "jwt",
freshCache: 30, // 30s freshness threshold
},
},
});
const headers = new Headers();
await client.signIn.email(
{
email: testUser.email,
password: testUser.password,
},
{
onSuccess: cookieSetter(headers),
},
);
const firstSession = await client.getSession({
fetchOptions: {
headers,
onSuccess: cookieSetter(headers),
},
});
expect(firstSession.data).not.toBeNull();
const sessionToken = firstSession.data?.session?.token;
const ctx = await auth.$context;
await ctx.internalAdapter.deleteSession(sessionToken!);
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000 * 31);
const sessionFromCache = await client.getSession({
fetchOptions: {
headers,
},
});
expect(sessionFromCache.data).not.toBeNull();
expect(sessionFromCache.data?.user.email).toBe(testUser.email);
expect(sessionFromCache.data?.session).toBeDefined();
expect(sessionFromCache.data?.session?.token).toBe(sessionToken);
vi.useRealTimers();
});
});
```