This is page 26 of 51. Use http://codebase.md/better-auth/better-auth?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ ├── CODEOWNERS
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── e2e.yml
│ ├── preview.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode
│ └── settings.json
├── banner-dark.png
├── banner.png
├── biome.json
├── bump.config.ts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── demo
│ ├── expo-example
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app.config.ts
│ │ ├── assets
│ │ │ ├── bg-image.jpeg
│ │ │ ├── fonts
│ │ │ │ └── SpaceMono-Regular.ttf
│ │ │ ├── icon.png
│ │ │ └── images
│ │ │ ├── adaptive-icon.png
│ │ │ ├── favicon.png
│ │ │ ├── logo.png
│ │ │ ├── partial-react-logo.png
│ │ │ ├── react-logo.png
│ │ │ ├── [email protected]
│ │ │ ├── [email protected]
│ │ │ └── splash.png
│ │ ├── babel.config.js
│ │ ├── components.json
│ │ ├── expo-env.d.ts
│ │ ├── index.ts
│ │ ├── metro.config.js
│ │ ├── nativewind-env.d.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...route]+api.ts
│ │ │ │ ├── dashboard.tsx
│ │ │ │ ├── forget-password.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── sign-up.tsx
│ │ │ ├── components
│ │ │ │ ├── icons
│ │ │ │ │ └── google.tsx
│ │ │ │ └── ui
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ └── text.tsx
│ │ │ ├── global.css
│ │ │ └── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth.ts
│ │ │ ├── icons
│ │ │ │ ├── iconWithClassName.ts
│ │ │ │ └── X.tsx
│ │ │ └── utils.ts
│ │ ├── tailwind.config.js
│ │ └── tsconfig.json
│ ├── nextjs
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── (auth)
│ │ │ │ ├── forget-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── sign-in
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── two-factor
│ │ │ │ ├── otp
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── accept-invitation
│ │ │ │ └── [id]
│ │ │ │ ├── invitation-error.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── admin
│ │ │ │ └── page.tsx
│ │ │ ├── api
│ │ │ │ └── auth
│ │ │ │ └── [...all]
│ │ │ │ └── route.ts
│ │ │ ├── apps
│ │ │ │ └── register
│ │ │ │ └── page.tsx
│ │ │ ├── client-test
│ │ │ │ └── page.tsx
│ │ │ ├── dashboard
│ │ │ │ ├── change-plan.tsx
│ │ │ │ ├── client.tsx
│ │ │ │ ├── organization-card.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── upgrade-button.tsx
│ │ │ │ └── user-card.tsx
│ │ │ ├── device
│ │ │ │ ├── approve
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── denied
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── success
│ │ │ │ └── page.tsx
│ │ │ ├── favicon.ico
│ │ │ ├── features.tsx
│ │ │ ├── fonts
│ │ │ │ ├── GeistMonoVF.woff
│ │ │ │ └── GeistVF.woff
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── oauth
│ │ │ │ └── authorize
│ │ │ │ ├── concet-buttons.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── pricing
│ │ │ └── page.tsx
│ │ ├── components
│ │ │ ├── account-switch.tsx
│ │ │ ├── blocks
│ │ │ │ └── pricing.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── one-tap.tsx
│ │ │ ├── sign-in-btn.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── tier-labels.tsx
│ │ │ ├── ui
│ │ │ │ ├── accordion.tsx
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── aspect-ratio.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── calendar.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── carousel.tsx
│ │ │ │ ├── chart.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── context-menu.tsx
│ │ │ │ ├── copy-button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ ├── hover-card.tsx
│ │ │ │ ├── input-otp.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── menubar.tsx
│ │ │ │ ├── navigation-menu.tsx
│ │ │ │ ├── pagination.tsx
│ │ │ │ ├── password-input.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── resizable.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ ├── sonner.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ ├── tabs2.tsx
│ │ │ │ ├── textarea.tsx
│ │ │ │ ├── toast.tsx
│ │ │ │ ├── toaster.tsx
│ │ │ │ ├── toggle-group.tsx
│ │ │ │ ├── toggle.tsx
│ │ │ │ └── tooltip.tsx
│ │ │ └── wrapper.tsx
│ │ ├── components.json
│ │ ├── hooks
│ │ │ └── use-toast.ts
│ │ ├── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth-types.ts
│ │ │ ├── auth.ts
│ │ │ ├── email
│ │ │ │ ├── invitation.tsx
│ │ │ │ ├── resend.ts
│ │ │ │ └── reset-password.tsx
│ │ │ ├── metadata.ts
│ │ │ ├── shared.ts
│ │ │ └── utils.ts
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── proxy.ts
│ │ ├── public
│ │ │ ├── __og.png
│ │ │ ├── _og.png
│ │ │ ├── favicon
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── light
│ │ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ │ ├── apple-touch-icon.png
│ │ │ │ │ ├── favicon-16x16.png
│ │ │ │ │ ├── favicon-32x32.png
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ └── site.webmanifest
│ │ │ │ └── site.webmanifest
│ │ │ ├── logo.svg
│ │ │ └── og.png
│ │ ├── README.md
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── turbo.json
│ └── 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
│ │ │ ├── reddit.mdx
│ │ │ ├── roblox.mdx
│ │ │ ├── salesforce.mdx
│ │ │ ├── slack.mdx
│ │ │ ├── spotify.mdx
│ │ │ ├── tiktok.mdx
│ │ │ ├── twitch.mdx
│ │ │ ├── twitter.mdx
│ │ │ ├── vk.mdx
│ │ │ └── zoom.mdx
│ │ ├── basic-usage.mdx
│ │ ├── comparison.mdx
│ │ ├── concepts
│ │ │ ├── api.mdx
│ │ │ ├── cli.mdx
│ │ │ ├── client.mdx
│ │ │ ├── cookies.mdx
│ │ │ ├── database.mdx
│ │ │ ├── email.mdx
│ │ │ ├── hooks.mdx
│ │ │ ├── oauth.mdx
│ │ │ ├── plugins.mdx
│ │ │ ├── rate-limit.mdx
│ │ │ ├── session-management.mdx
│ │ │ ├── typescript.mdx
│ │ │ └── users-accounts.mdx
│ │ ├── examples
│ │ │ ├── astro.mdx
│ │ │ ├── next-js.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ └── svelte-kit.mdx
│ │ ├── guides
│ │ │ ├── auth0-migration-guide.mdx
│ │ │ ├── browser-extension-guide.mdx
│ │ │ ├── clerk-migration-guide.mdx
│ │ │ ├── create-a-db-adapter.mdx
│ │ │ ├── next-auth-migration-guide.mdx
│ │ │ ├── optimizing-for-performance.mdx
│ │ │ ├── saml-sso-with-okta.mdx
│ │ │ ├── supabase-migration-guide.mdx
│ │ │ └── your-first-plugin.mdx
│ │ ├── installation.mdx
│ │ ├── integrations
│ │ │ ├── astro.mdx
│ │ │ ├── convex.mdx
│ │ │ ├── elysia.mdx
│ │ │ ├── expo.mdx
│ │ │ ├── express.mdx
│ │ │ ├── fastify.mdx
│ │ │ ├── hono.mdx
│ │ │ ├── lynx.mdx
│ │ │ ├── nestjs.mdx
│ │ │ ├── next.mdx
│ │ │ ├── nitro.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ ├── solid-start.mdx
│ │ │ ├── svelte-kit.mdx
│ │ │ ├── tanstack.mdx
│ │ │ └── waku.mdx
│ │ ├── introduction.mdx
│ │ ├── meta.json
│ │ ├── plugins
│ │ │ ├── 2fa.mdx
│ │ │ ├── admin.mdx
│ │ │ ├── anonymous.mdx
│ │ │ ├── api-key.mdx
│ │ │ ├── autumn.mdx
│ │ │ ├── bearer.mdx
│ │ │ ├── captcha.mdx
│ │ │ ├── community-plugins.mdx
│ │ │ ├── device-authorization.mdx
│ │ │ ├── dodopayments.mdx
│ │ │ ├── dub.mdx
│ │ │ ├── email-otp.mdx
│ │ │ ├── generic-oauth.mdx
│ │ │ ├── have-i-been-pwned.mdx
│ │ │ ├── jwt.mdx
│ │ │ ├── last-login-method.mdx
│ │ │ ├── magic-link.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── multi-session.mdx
│ │ │ ├── oauth-proxy.mdx
│ │ │ ├── oidc-provider.mdx
│ │ │ ├── one-tap.mdx
│ │ │ ├── one-time-token.mdx
│ │ │ ├── open-api.mdx
│ │ │ ├── organization.mdx
│ │ │ ├── passkey.mdx
│ │ │ ├── phone-number.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── siwe.mdx
│ │ │ ├── sso.mdx
│ │ │ ├── stripe.mdx
│ │ │ └── username.mdx
│ │ └── reference
│ │ ├── contributing.mdx
│ │ ├── faq.mdx
│ │ ├── options.mdx
│ │ ├── resources.mdx
│ │ ├── security.mdx
│ │ └── telemetry.mdx
│ ├── hooks
│ │ └── use-mobile.ts
│ ├── ignore-build.sh
│ ├── lib
│ │ ├── blog.ts
│ │ ├── chat
│ │ │ └── inkeep-qa-schema.ts
│ │ ├── constants.ts
│ │ ├── export-search-indexes.ts
│ │ ├── inkeep-analytics.ts
│ │ ├── is-active.ts
│ │ ├── metadata.ts
│ │ ├── source.ts
│ │ └── utils.ts
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── proxy.ts
│ ├── public
│ │ ├── avatars
│ │ │ └── beka.jpg
│ │ ├── blogs
│ │ │ ├── authjs-joins.png
│ │ │ ├── seed-round.png
│ │ │ └── supabase-ps.png
│ │ ├── branding
│ │ │ ├── better-auth-brand-assets.zip
│ │ │ ├── better-auth-logo-dark.png
│ │ │ ├── better-auth-logo-dark.svg
│ │ │ ├── better-auth-logo-light.png
│ │ │ ├── better-auth-logo-light.svg
│ │ │ ├── better-auth-logo-wordmark-dark.png
│ │ │ ├── better-auth-logo-wordmark-dark.svg
│ │ │ ├── better-auth-logo-wordmark-light.png
│ │ │ └── better-auth-logo-wordmark-light.svg
│ │ ├── extension-id.png
│ │ ├── favicon
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── favicon.ico
│ │ │ ├── light
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ └── site.webmanifest
│ │ │ └── site.webmanifest
│ │ ├── images
│ │ │ └── blogs
│ │ │ └── better auth (1).png
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ ├── LogoDark.webp
│ │ ├── LogoLight.webp
│ │ ├── og.png
│ │ ├── open-api-reference.png
│ │ ├── people-say
│ │ │ ├── code-with-antonio.jpg
│ │ │ ├── dagmawi-babi.png
│ │ │ ├── dax.png
│ │ │ ├── dev-ed.png
│ │ │ ├── egoist.png
│ │ │ ├── guillermo-rauch.png
│ │ │ ├── jonathan-wilke.png
│ │ │ ├── josh-tried-coding.jpg
│ │ │ ├── kitze.jpg
│ │ │ ├── lazar-nikolov.png
│ │ │ ├── nizzy.png
│ │ │ ├── omar-mcadam.png
│ │ │ ├── ryan-vogel.jpg
│ │ │ ├── saltyatom.jpg
│ │ │ ├── sebastien-chopin.png
│ │ │ ├── shreyas-mididoddi.png
│ │ │ ├── tech-nerd.png
│ │ │ ├── theo.png
│ │ │ ├── vybhav-bhargav.png
│ │ │ └── xavier-pladevall.jpg
│ │ ├── plus.svg
│ │ ├── release-og
│ │ │ ├── 1-2.png
│ │ │ ├── 1-3.png
│ │ │ └── changelog-og.png
│ │ └── v1-og.png
│ ├── README.md
│ ├── scripts
│ │ ├── endpoint-to-doc
│ │ │ ├── index.ts
│ │ │ ├── input.ts
│ │ │ ├── output.mdx
│ │ │ └── readme.md
│ │ └── sync-orama.ts
│ ├── source.config.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── turbo.json
├── e2e
│ ├── integration
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── solid-vinxi
│ │ │ ├── .gitignore
│ │ │ ├── app.config.ts
│ │ │ ├── e2e
│ │ │ │ ├── test.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── package.json
│ │ │ ├── public
│ │ │ │ └── favicon.ico
│ │ │ ├── src
│ │ │ │ ├── app.tsx
│ │ │ │ ├── entry-client.tsx
│ │ │ │ ├── entry-server.tsx
│ │ │ │ ├── global.d.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── auth-client.ts
│ │ │ │ │ └── auth.ts
│ │ │ │ └── routes
│ │ │ │ ├── [...404].tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...all].ts
│ │ │ │ └── index.tsx
│ │ │ └── tsconfig.json
│ │ ├── test-utils
│ │ │ ├── package.json
│ │ │ └── src
│ │ │ └── playwright.ts
│ │ └── vanilla-node
│ │ ├── e2e
│ │ │ ├── app.ts
│ │ │ ├── domain.spec.ts
│ │ │ ├── postgres-js.spec.ts
│ │ │ ├── test.spec.ts
│ │ │ └── utils.ts
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── main.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── smoke
│ ├── package.json
│ ├── test
│ │ ├── bun.spec.ts
│ │ ├── cloudflare.spec.ts
│ │ ├── deno.spec.ts
│ │ ├── fixtures
│ │ │ ├── bun-simple.ts
│ │ │ ├── cloudflare
│ │ │ │ ├── .gitignore
│ │ │ │ ├── drizzle
│ │ │ │ │ ├── 0000_clean_vector.sql
│ │ │ │ │ └── meta
│ │ │ │ │ ├── _journal.json
│ │ │ │ │ └── 0000_snapshot.json
│ │ │ │ ├── drizzle.config.ts
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── auth-schema.ts
│ │ │ │ │ ├── db.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test
│ │ │ │ │ ├── apply-migrations.ts
│ │ │ │ │ ├── env.d.ts
│ │ │ │ │ └── index.test.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.ts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ └── wrangler.json
│ │ │ ├── deno-simple.ts
│ │ │ ├── tsconfig-declaration
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── demo.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-exact-optional-property-types
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── user-additional-fields.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-isolated-module-bundler
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-verbatim-module-syntax-node10
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── vite
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── client.ts
│ │ │ │ └── server.ts
│ │ │ ├── tsconfig.json
│ │ │ └── vite.config.ts
│ │ ├── ssr.ts
│ │ ├── typecheck.spec.ts
│ │ └── vite.spec.ts
│ └── tsconfig.json
├── LICENSE.md
├── package.json
├── packages
│ ├── better-auth
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── __snapshots__
│ │ │ │ └── init.test.ts.snap
│ │ │ ├── adapters
│ │ │ │ ├── adapter-factory
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ │ └── adapter-factory.test.ts.snap
│ │ │ │ │ │ └── adapter-factory.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── create-test-suite.ts
│ │ │ │ ├── drizzle-adapter
│ │ │ │ │ ├── drizzle-adapter.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── adapter.drizzle.mysql.test.ts
│ │ │ │ │ ├── adapter.drizzle.pg.test.ts
│ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts
│ │ │ │ │ └── generate-schema.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely-adapter
│ │ │ │ │ ├── bun-sqlite-dialect.ts
│ │ │ │ │ ├── dialect.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── kysely-adapter.ts
│ │ │ │ │ ├── node-sqlite-dialect.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg.test.ts
│ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts
│ │ │ │ │ │ └── node-sqlite-dialect.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── memory-adapter
│ │ │ │ │ ├── adapter.memory.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── memory-adapter.ts
│ │ │ │ ├── mongodb-adapter
│ │ │ │ │ ├── adapter.mongo-db.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mongodb-adapter.ts
│ │ │ │ ├── prisma-adapter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prisma-adapter.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── base.prisma
│ │ │ │ │ ├── generate-auth-config.ts
│ │ │ │ │ ├── generate-prisma-schema.ts
│ │ │ │ │ ├── get-prisma-client.ts
│ │ │ │ │ ├── prisma.mysql.test.ts
│ │ │ │ │ ├── prisma.pg.test.ts
│ │ │ │ │ ├── prisma.sqlite.test.ts
│ │ │ │ │ └── push-prisma-schema.ts
│ │ │ │ ├── test-adapter.ts
│ │ │ │ ├── test.ts
│ │ │ │ ├── tests
│ │ │ │ │ ├── auth-flow.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── normal.ts
│ │ │ │ │ ├── number-id.ts
│ │ │ │ │ ├── performance.ts
│ │ │ │ │ └── transactions.ts
│ │ │ │ └── utils.ts
│ │ │ ├── api
│ │ │ │ ├── check-endpoint-conflicts.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── middlewares
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── origin-check.test.ts
│ │ │ │ │ └── origin-check.ts
│ │ │ │ ├── rate-limiter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── rate-limiter.test.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── account.test.ts
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── callback.ts
│ │ │ │ │ ├── email-verification.test.ts
│ │ │ │ │ ├── email-verification.ts
│ │ │ │ │ ├── error.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── ok.ts
│ │ │ │ │ ├── reset-password.test.ts
│ │ │ │ │ ├── reset-password.ts
│ │ │ │ │ ├── session-api.test.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── sign-in.test.ts
│ │ │ │ │ ├── sign-in.ts
│ │ │ │ │ ├── sign-out.test.ts
│ │ │ │ │ ├── sign-out.ts
│ │ │ │ │ ├── sign-up.test.ts
│ │ │ │ │ ├── sign-up.ts
│ │ │ │ │ ├── update-user.test.ts
│ │ │ │ │ └── update-user.ts
│ │ │ │ ├── to-auth-endpoints.test.ts
│ │ │ │ └── to-auth-endpoints.ts
│ │ │ ├── auth.test.ts
│ │ │ ├── auth.ts
│ │ │ ├── call.test.ts
│ │ │ ├── client
│ │ │ │ ├── client-ssr.test.ts
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── fetch-plugins.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lynx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lynx-store.ts
│ │ │ │ ├── parser.ts
│ │ │ │ ├── path-to-object.ts
│ │ │ │ ├── plugins
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── infer-plugin.ts
│ │ │ │ ├── proxy.ts
│ │ │ │ ├── query.ts
│ │ │ │ ├── react
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── react-store.ts
│ │ │ │ ├── session-atom.ts
│ │ │ │ ├── solid
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── solid-store.ts
│ │ │ │ ├── svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test-plugin.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── url.test.ts
│ │ │ │ ├── vanilla.ts
│ │ │ │ └── vue
│ │ │ │ ├── index.ts
│ │ │ │ └── vue-store.ts
│ │ │ ├── cookies
│ │ │ │ ├── check-cookies.ts
│ │ │ │ ├── cookie-utils.ts
│ │ │ │ ├── cookies.test.ts
│ │ │ │ └── index.ts
│ │ │ ├── crypto
│ │ │ │ ├── buffer.ts
│ │ │ │ ├── hash.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt.ts
│ │ │ │ ├── password.test.ts
│ │ │ │ ├── password.ts
│ │ │ │ └── random.ts
│ │ │ ├── db
│ │ │ │ ├── db.test.ts
│ │ │ │ ├── field.ts
│ │ │ │ ├── get-migration.ts
│ │ │ │ ├── get-schema.ts
│ │ │ │ ├── get-tables.test.ts
│ │ │ │ ├── get-tables.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── internal-adapter.test.ts
│ │ │ │ ├── internal-adapter.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── secondary-storage.test.ts
│ │ │ │ ├── to-zod.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── with-hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── init.test.ts
│ │ │ ├── init.ts
│ │ │ ├── integrations
│ │ │ │ ├── next-js.ts
│ │ │ │ ├── node.ts
│ │ │ │ ├── react-start.ts
│ │ │ │ ├── solid-start.ts
│ │ │ │ └── svelte-kit.ts
│ │ │ ├── oauth2
│ │ │ │ ├── index.ts
│ │ │ │ ├── link-account.test.ts
│ │ │ │ ├── link-account.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── utils.ts
│ │ │ ├── plugins
│ │ │ │ ├── access
│ │ │ │ │ ├── access.test.ts
│ │ │ │ │ ├── access.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── additional-fields
│ │ │ │ │ ├── additional-fields.test.ts
│ │ │ │ │ └── client.ts
│ │ │ │ ├── admin
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── admin.test.ts
│ │ │ │ │ ├── admin.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── anonymous
│ │ │ │ │ ├── anon.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── api-key
│ │ │ │ │ ├── api-key.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── create-api-key.ts
│ │ │ │ │ │ ├── delete-all-expired-api-keys.ts
│ │ │ │ │ │ ├── delete-api-key.ts
│ │ │ │ │ │ ├── get-api-key.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── list-api-keys.ts
│ │ │ │ │ │ ├── update-api-key.ts
│ │ │ │ │ │ └── verify-api-key.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── bearer
│ │ │ │ │ ├── bearer.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── captcha
│ │ │ │ │ ├── captcha.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-handlers
│ │ │ │ │ ├── captchafox.ts
│ │ │ │ │ ├── cloudflare-turnstile.ts
│ │ │ │ │ ├── google-recaptcha.ts
│ │ │ │ │ ├── h-captcha.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── custom-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-session.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── device-authorization
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── device-authorization.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── email-otp
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── email-otp.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── generic-oauth
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── generic-oauth.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── haveibeenpwned
│ │ │ │ │ ├── haveibeenpwned.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── jwt.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── sign.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── last-login-method
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-prefix.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── last-login-method.test.ts
│ │ │ │ ├── magic-link
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── magic-link.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── mcp
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mcp.test.ts
│ │ │ │ ├── multi-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── multi-session.test.ts
│ │ │ │ ├── oauth-proxy
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── oauth-proxy.test.ts
│ │ │ │ ├── oidc-provider
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── oidc.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── ui.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── one-tap
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── one-time-token
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── one-time-token.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── open-api
│ │ │ │ │ ├── generator.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── logo.ts
│ │ │ │ │ └── open-api.test.ts
│ │ │ │ ├── organization
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── call.ts
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization-hook.test.ts
│ │ │ │ │ ├── organization.test.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── permission.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── crud-access-control.test.ts
│ │ │ │ │ │ ├── crud-access-control.ts
│ │ │ │ │ │ ├── crud-invites.ts
│ │ │ │ │ │ ├── crud-members.test.ts
│ │ │ │ │ │ ├── crud-members.ts
│ │ │ │ │ │ ├── crud-org.test.ts
│ │ │ │ │ │ ├── crud-org.ts
│ │ │ │ │ │ └── crud-team.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── team.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── passkey
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── passkey.test.ts
│ │ │ │ ├── phone-number
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── phone-number-error.ts
│ │ │ │ │ └── phone-number.test.ts
│ │ │ │ ├── siwe
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── siwe.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── two-factor
│ │ │ │ │ ├── backup-codes
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── constant.ts
│ │ │ │ │ ├── error-code.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── otp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── totp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── two-factor.test.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-two-factor.ts
│ │ │ │ └── username
│ │ │ │ ├── client.ts
│ │ │ │ ├── error-codes.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── username.test.ts
│ │ │ ├── social-providers
│ │ │ │ └── index.ts
│ │ │ ├── social.test.ts
│ │ │ ├── test-utils
│ │ │ │ ├── headers.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── test-instance.ts
│ │ │ ├── types
│ │ │ │ ├── adapter.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models.ts
│ │ │ │ ├── plugins.ts
│ │ │ │ └── types.test.ts
│ │ │ └── utils
│ │ │ ├── await-object.ts
│ │ │ ├── boolean.ts
│ │ │ ├── clone.ts
│ │ │ ├── constants.ts
│ │ │ ├── date.ts
│ │ │ ├── ensure-utc.ts
│ │ │ ├── get-request-ip.ts
│ │ │ ├── hashing.ts
│ │ │ ├── hide-metadata.ts
│ │ │ ├── id.ts
│ │ │ ├── import-util.ts
│ │ │ ├── index.ts
│ │ │ ├── is-atom.ts
│ │ │ ├── is-promise.ts
│ │ │ ├── json.ts
│ │ │ ├── merger.ts
│ │ │ ├── middleware-response.ts
│ │ │ ├── misc.ts
│ │ │ ├── password.ts
│ │ │ ├── plugin-helper.ts
│ │ │ ├── shim.ts
│ │ │ ├── time.ts
│ │ │ ├── url.ts
│ │ │ └── wildcard.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ ├── vitest.config.ts
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── commands
│ │ │ │ ├── generate.ts
│ │ │ │ ├── info.ts
│ │ │ │ ├── init.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── mcp.ts
│ │ │ │ ├── migrate.ts
│ │ │ │ └── secret.ts
│ │ │ ├── generators
│ │ │ │ ├── auth-config.ts
│ │ │ │ ├── drizzle.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── types.ts
│ │ │ ├── index.ts
│ │ │ └── utils
│ │ │ ├── add-svelte-kit-env-modules.ts
│ │ │ ├── check-package-managers.ts
│ │ │ ├── format-ms.ts
│ │ │ ├── get-config.ts
│ │ │ ├── get-package-info.ts
│ │ │ ├── get-tsconfig-info.ts
│ │ │ └── install-dependencies.ts
│ │ ├── test
│ │ │ ├── __snapshots__
│ │ │ │ ├── auth-schema-mysql-enum.txt
│ │ │ │ ├── auth-schema-mysql-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey.txt
│ │ │ │ ├── auth-schema-mysql.txt
│ │ │ │ ├── auth-schema-number-id.txt
│ │ │ │ ├── auth-schema-pg-enum.txt
│ │ │ │ ├── auth-schema-pg-passkey.txt
│ │ │ │ ├── auth-schema-sqlite-enum.txt
│ │ │ │ ├── auth-schema-sqlite-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey.txt
│ │ │ │ ├── auth-schema-sqlite.txt
│ │ │ │ ├── auth-schema.txt
│ │ │ │ ├── migrations.sql
│ │ │ │ ├── schema-mongodb.prisma
│ │ │ │ ├── schema-mysql-custom.prisma
│ │ │ │ ├── schema-mysql.prisma
│ │ │ │ ├── schema-numberid.prisma
│ │ │ │ └── schema.prisma
│ │ │ ├── generate-all-db.test.ts
│ │ │ ├── generate.test.ts
│ │ │ ├── get-config.test.ts
│ │ │ ├── info.test.ts
│ │ │ └── migrate.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── core
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── api
│ │ │ │ └── index.ts
│ │ │ ├── async_hooks
│ │ │ │ └── index.ts
│ │ │ ├── context
│ │ │ │ ├── endpoint-context.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── transaction.ts
│ │ │ ├── db
│ │ │ │ ├── adapter
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── plugin.ts
│ │ │ │ ├── schema
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── user.ts
│ │ │ │ │ └── verification.ts
│ │ │ │ └── type.ts
│ │ │ ├── env
│ │ │ │ ├── color-depth.ts
│ │ │ │ ├── env-impl.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.test.ts
│ │ │ │ └── logger.ts
│ │ │ ├── error
│ │ │ │ ├── codes.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── oauth2
│ │ │ │ ├── client-credentials-token.ts
│ │ │ │ ├── create-authorization-url.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── oauth-provider.ts
│ │ │ │ ├── refresh-access-token.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── validate-authorization-code.ts
│ │ │ ├── social-providers
│ │ │ │ ├── apple.ts
│ │ │ │ ├── atlassian.ts
│ │ │ │ ├── cognito.ts
│ │ │ │ ├── discord.ts
│ │ │ │ ├── dropbox.ts
│ │ │ │ ├── facebook.ts
│ │ │ │ ├── figma.ts
│ │ │ │ ├── github.ts
│ │ │ │ ├── gitlab.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── huggingface.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kakao.ts
│ │ │ │ ├── kick.ts
│ │ │ │ ├── line.ts
│ │ │ │ ├── linear.ts
│ │ │ │ ├── linkedin.ts
│ │ │ │ ├── microsoft-entra-id.ts
│ │ │ │ ├── naver.ts
│ │ │ │ ├── notion.ts
│ │ │ │ ├── paypal.ts
│ │ │ │ ├── reddit.ts
│ │ │ │ ├── roblox.ts
│ │ │ │ ├── salesforce.ts
│ │ │ │ ├── slack.ts
│ │ │ │ ├── spotify.ts
│ │ │ │ ├── tiktok.ts
│ │ │ │ ├── twitch.ts
│ │ │ │ ├── twitter.ts
│ │ │ │ ├── vk.ts
│ │ │ │ └── zoom.ts
│ │ │ ├── types
│ │ │ │ ├── context.ts
│ │ │ │ ├── cookie.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── init-options.ts
│ │ │ │ ├── plugin-client.ts
│ │ │ │ └── plugin.ts
│ │ │ └── utils
│ │ │ ├── error-codes.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── expo
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── expo.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── sso
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── index.ts
│ │ │ ├── oidc.test.ts
│ │ │ └── saml.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── stripe
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── stripe.test.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── telemetry
│ ├── package.json
│ ├── src
│ │ ├── detectors
│ │ │ ├── detect-auth-config.ts
│ │ │ ├── detect-database.ts
│ │ │ ├── detect-framework.ts
│ │ │ ├── detect-project-info.ts
│ │ │ ├── detect-runtime.ts
│ │ │ └── detect-system-info.ts
│ │ ├── index.ts
│ │ ├── project-id.ts
│ │ ├── telemetry.test.ts
│ │ ├── types.ts
│ │ └── utils
│ │ ├── hash.ts
│ │ ├── id.ts
│ │ ├── import-util.ts
│ │ └── package-json.ts
│ ├── tsconfig.json
│ └── tsdown.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── SECURITY.md
├── tsconfig.base.json
├── tsconfig.json
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/expo/src/client.ts:
--------------------------------------------------------------------------------
```typescript
import type { BetterAuthClientPlugin, Store } from "better-auth/types";
import * as Linking from "expo-linking";
import { Platform } from "react-native";
import Constants from "expo-constants";
import type { BetterFetchOption } from "@better-fetch/fetch";
interface CookieAttributes {
value: string;
expires?: Date;
"max-age"?: number;
domain?: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: "Strict" | "Lax" | "None";
}
export function parseSetCookieHeader(
header: string,
): Map<string, CookieAttributes> {
const cookieMap = new Map<string, CookieAttributes>();
const cookies = splitSetCookieHeader(header);
cookies.forEach((cookie) => {
const parts = cookie.split(";").map((p) => p.trim());
const [nameValue, ...attributes] = parts;
const [name, ...valueParts] = nameValue!.split("=");
const value = valueParts.join("=");
const cookieObj: CookieAttributes = { value };
attributes.forEach((attr) => {
const [attrName, ...attrValueParts] = attr.split("=");
const attrValue = attrValueParts.join("=");
cookieObj[attrName!.toLowerCase() as "value"] = attrValue;
});
cookieMap.set(name!, cookieObj);
});
return cookieMap;
}
function splitSetCookieHeader(setCookie: string): string[] {
const parts: string[] = [];
let buffer = "";
let i = 0;
while (i < setCookie.length) {
const char = setCookie[i];
if (char === ",") {
const recent = buffer.toLowerCase();
const hasExpires = recent.includes("expires=");
const hasGmt = /gmt/i.test(recent);
if (hasExpires && !hasGmt) {
buffer += char;
i += 1;
continue;
}
if (buffer.trim().length > 0) {
parts.push(buffer.trim());
buffer = "";
}
i += 1;
if (setCookie[i] === " ") i += 1;
continue;
}
buffer += char;
i += 1;
}
if (buffer.trim().length > 0) {
parts.push(buffer.trim());
}
return parts;
}
interface ExpoClientOptions {
scheme?: string;
storage: {
setItem: (key: string, value: string) => any;
getItem: (key: string) => string | null;
};
/**
* Prefix for local storage keys (e.g., "my-app_cookie", "my-app_session_data")
* @default "better-auth"
*/
storagePrefix?: string;
/**
* Prefix for server cookie names to filter (e.g., "better-auth.session_token")
* This is used to identify which cookies belong to better-auth to prevent
* infinite refetching when third-party cookies are set.
* @default "better-auth"
*/
cookiePrefix?: string;
disableCache?: boolean;
}
interface StoredCookie {
value: string;
expires: string | null;
}
export function getSetCookie(header: string, prevCookie?: string) {
const parsed = parseSetCookieHeader(header);
let toSetCookie: Record<string, StoredCookie> = {};
parsed.forEach((cookie, key) => {
const expiresAt = cookie["expires"];
const maxAge = cookie["max-age"];
const expires = maxAge
? new Date(Date.now() + Number(maxAge) * 1000)
: expiresAt
? new Date(String(expiresAt))
: null;
toSetCookie[key] = {
value: cookie["value"],
expires: expires ? expires.toISOString() : null,
};
});
if (prevCookie) {
try {
const prevCookieParsed = JSON.parse(prevCookie);
toSetCookie = {
...prevCookieParsed,
...toSetCookie,
};
} catch {
//
}
}
return JSON.stringify(toSetCookie);
}
export function getCookie(cookie: string) {
let parsed = {} as Record<string, StoredCookie>;
try {
parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
} catch (e) {}
const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
if (value.expires && new Date(value.expires) < new Date()) {
return acc;
}
return `${acc}; ${key}=${value.value}`;
}, "");
return toSend;
}
function getOrigin(scheme: string) {
const schemeURI = Linking.createURL("", { scheme });
return schemeURI;
}
/**
* Compare if session cookies have actually changed by comparing their values.
* Ignores expiry timestamps that naturally change on each request.
*
* @param prevCookie - Previous cookie JSON string
* @param newCookie - New cookie JSON string
* @returns true if session cookies have changed, false otherwise
*/
function hasSessionCookieChanged(
prevCookie: string | null,
newCookie: string,
): boolean {
if (!prevCookie) return true;
try {
const prev = JSON.parse(prevCookie) as Record<string, StoredCookie>;
const next = JSON.parse(newCookie) as Record<string, StoredCookie>;
// Get all session-related cookie keys (session_token, session_data)
const sessionKeys = new Set<string>();
Object.keys(prev).forEach((key) => {
if (key.includes("session_token") || key.includes("session_data")) {
sessionKeys.add(key);
}
});
Object.keys(next).forEach((key) => {
if (key.includes("session_token") || key.includes("session_data")) {
sessionKeys.add(key);
}
});
// Compare the values of session cookies (ignore expires timestamps)
for (const key of sessionKeys) {
const prevValue = prev[key]?.value;
const nextValue = next[key]?.value;
if (prevValue !== nextValue) {
return true;
}
}
return false;
} catch {
// If parsing fails, assume cookie changed
return true;
}
}
/**
* Check if the Set-Cookie header contains session-related better-auth cookies.
* Only triggers session updates when session_token or session_data cookies are present.
* This prevents infinite refetching when non-session cookies (like third-party cookies) change.
*
* Supports multiple cookie naming patterns:
* - Default: "better-auth.session_token", "__Secure-better-auth.session_token"
* - Custom prefix: "myapp.session_token", "__Secure-myapp.session_token"
* - Custom full names: "my_custom_session_token", "custom_session_data"
* - No prefix (cookiePrefix=""): "session_token", "my_session_token", etc.
*
* @param setCookieHeader - The Set-Cookie header value
* @param cookiePrefix - The cookie prefix to check for. Can be empty string for custom cookie names.
* @returns true if the header contains session-related cookies, false otherwise
*/
export function hasBetterAuthCookies(
setCookieHeader: string,
cookiePrefix: string,
): boolean {
const cookies = parseSetCookieHeader(setCookieHeader);
const sessionCookieSuffixes = ["session_token", "session_data"];
// Check if any cookie is a session-related cookie
for (const name of cookies.keys()) {
// Remove __Secure- prefix if present for comparison
const nameWithoutSecure = name.startsWith("__Secure-")
? name.slice(9)
: name;
for (const suffix of sessionCookieSuffixes) {
if (cookiePrefix) {
// When prefix is provided, only match exact pattern: "prefix.suffix"
if (nameWithoutSecure === `${cookiePrefix}.${suffix}`) {
return true;
}
} else {
// When prefix is empty, check for:
// 1. Exact match: "session_token"
// 2. Custom names ending with suffix: "my_custom_session_token"
if (nameWithoutSecure.endsWith(suffix)) {
return true;
}
}
}
}
return false;
}
/**
* Expo secure store does not support colons in the keys.
* This function replaces colons with underscores.
*
* @see https://github.com/better-auth/better-auth/issues/5426
*
* @param name cookie name to be saved in the storage
* @returns normalized cookie name
*/
export function normalizeCookieName(name: string) {
return name.replace(/:/g, "_");
}
export function storageAdapter(storage: {
getItem: (name: string) => string | null;
setItem: (name: string, value: string) => void;
}) {
return {
getItem: (name: string) => {
return storage.getItem(normalizeCookieName(name));
},
setItem: (name: string, value: string) => {
return storage.setItem(normalizeCookieName(name), value);
},
};
}
export const expoClient = (opts: ExpoClientOptions) => {
let store: Store | null = null;
const storagePrefix = opts?.storagePrefix || "better-auth";
const cookieName = `${storagePrefix}_cookie`;
const localCacheName = `${storagePrefix}_session_data`;
const storage = storageAdapter(opts?.storage);
const isWeb = Platform.OS === "web";
const cookiePrefix = opts?.cookiePrefix || "better-auth";
const rawScheme =
opts?.scheme || Constants.expoConfig?.scheme || Constants.platform?.scheme;
const scheme = Array.isArray(rawScheme) ? rawScheme[0] : rawScheme;
if (!scheme && !isWeb) {
throw new Error(
"Scheme not found in app.json. Please provide a scheme in the options.",
);
}
return {
id: "expo",
getActions(_, $store) {
store = $store;
return {
/**
* Get the stored cookie.
*
* You can use this to get the cookie stored in the device and use it in your fetch
* requests.
*
* @example
* ```ts
* const cookie = client.getCookie();
* fetch("https://api.example.com", {
* headers: {
* cookie,
* },
* });
*/
getCookie: () => {
const cookie = storage.getItem(cookieName);
return getCookie(cookie || "{}");
},
};
},
fetchPlugins: [
{
id: "expo",
name: "Expo",
hooks: {
async onSuccess(context) {
if (isWeb) return;
const setCookie = context.response.headers.get("set-cookie");
if (setCookie) {
// Only process and notify if the Set-Cookie header contains better-auth cookies
// This prevents infinite refetching when other cookies (like Cloudflare's __cf_bm) are present
if (hasBetterAuthCookies(setCookie, cookiePrefix)) {
const prevCookie = await storage.getItem(cookieName);
const toSetCookie = getSetCookie(
setCookie || "",
prevCookie ?? undefined,
);
// Only notify $sessionSignal if the session cookie values actually changed
// This prevents infinite refetching when the server sends the same cookie with updated expiry
if (hasSessionCookieChanged(prevCookie, toSetCookie)) {
await storage.setItem(cookieName, toSetCookie);
store?.notify("$sessionSignal");
} else {
// Still update the storage to refresh expiry times, but don't trigger refetch
await storage.setItem(cookieName, toSetCookie);
}
}
}
if (
context.request.url.toString().includes("/get-session") &&
!opts?.disableCache
) {
const data = context.data;
storage.setItem(localCacheName, JSON.stringify(data));
}
if (
context.data?.redirect &&
(context.request.url.toString().includes("/sign-in") ||
context.request.url.toString().includes("/link-social")) &&
!context.request?.body.includes("idToken") // id token is used for silent sign-in
) {
const callbackURL = JSON.parse(context.request.body)?.callbackURL;
const to = callbackURL;
const signInURL = context.data?.url;
let Browser: typeof import("expo-web-browser") | undefined =
undefined;
try {
Browser = await import("expo-web-browser");
} catch (error) {
throw new Error(
'"expo-web-browser" is not installed as a dependency!',
{
cause: error,
},
);
}
const proxyURL = `${context.request.baseURL}/expo-authorization-proxy?authorizationURL=${encodeURIComponent(signInURL)}`;
const result = await Browser.openAuthSessionAsync(proxyURL, to);
if (result.type !== "success") return;
const url = new URL(result.url);
const cookie = String(url.searchParams.get("cookie"));
if (!cookie) return;
storage.setItem(cookieName, getSetCookie(cookie));
store?.notify("$sessionSignal");
}
},
},
async init(url, options) {
if (isWeb) {
return {
url,
options: options as BetterFetchOption,
};
}
options = options || {};
const storedCookie = storage.getItem(cookieName);
const cookie = getCookie(storedCookie || "{}");
options.credentials = "omit";
options.headers = {
...options.headers,
cookie,
"expo-origin": getOrigin(scheme!),
"x-skip-oauth-proxy": "true", // skip oauth proxy for expo
};
if (options.body?.callbackURL) {
if (options.body.callbackURL.startsWith("/")) {
const url = Linking.createURL(options.body.callbackURL, {
scheme,
});
options.body.callbackURL = url;
}
}
if (options.body?.newUserCallbackURL) {
if (options.body.newUserCallbackURL.startsWith("/")) {
const url = Linking.createURL(options.body.newUserCallbackURL, {
scheme,
});
options.body.newUserCallbackURL = url;
}
}
if (options.body?.errorCallbackURL) {
if (options.body.errorCallbackURL.startsWith("/")) {
const url = Linking.createURL(options.body.errorCallbackURL, {
scheme,
});
options.body.errorCallbackURL = url;
}
}
if (url.includes("/sign-out")) {
await storage.setItem(cookieName, "{}");
store?.atoms.session?.set({
...store.atoms.session.get(),
data: null,
error: null,
isPending: false,
});
storage.setItem(localCacheName, "{}");
}
return {
url,
options: options as BetterFetchOption,
};
},
},
],
} satisfies BetterAuthClientPlugin;
};
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { $deviceAuthorizationOptionsSchema, deviceAuthorization } from ".";
import { deviceAuthorizationClient } from "./client";
import type { DeviceCode } from "./schema";
describe("device authorization plugin input validation", () => {
it("basic validation", async () => {
const options = $deviceAuthorizationOptionsSchema.parse({});
expect(options).toMatchInlineSnapshot(`
{
"deviceCodeLength": 40,
"expiresIn": "30m",
"interval": "5s",
"userCodeLength": 8,
}
`);
});
it("should validate custom options", async () => {
const options = $deviceAuthorizationOptionsSchema.parse({
expiresIn: 60 * 1000,
interval: 2 * 1000,
deviceCodeLength: 50,
userCodeLength: 10,
});
expect(options).toMatchInlineSnapshot(`
{
"deviceCodeLength": 50,
"expiresIn": 60000,
"interval": 2000,
"userCodeLength": 10,
}
`);
});
});
describe("client validation", async () => {
const validClients = ["valid-client-1", "valid-client-2"];
const { auth } = await getTestInstance({
plugins: [
deviceAuthorization({
validateClient: async (clientId) => {
return validClients.includes(clientId);
},
}),
],
});
it("should reject invalid client in device code request", async () => {
await expect(
auth.api.deviceCode({
body: {
client_id: "invalid-client",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_client",
error_description: "Invalid client ID",
},
});
});
it("should accept valid client in device code request", async () => {
const response = await auth.api.deviceCode({
body: {
client_id: "valid-client-1",
},
});
expect(response.device_code).toBeDefined();
});
it("should reject invalid client in token request", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "valid-client-1",
},
});
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: "invalid-client",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_grant",
error_description: "Invalid client ID",
},
});
});
it("should reject mismatched client_id in token request", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "valid-client-1",
},
});
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: "valid-client-2",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_grant",
error_description: "Client ID mismatch",
},
});
});
});
describe("device authorization flow", async () => {
const { auth, client, sessionSetter, signInWithTestUser } =
await getTestInstance(
{
plugins: [
deviceAuthorization({
expiresIn: "5min",
interval: "2s",
}),
],
},
{
clientOptions: {
plugins: [deviceAuthorizationClient()],
},
},
);
describe("device code request", () => {
it("should generate device and user codes", async () => {
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
expect(response.device_code).toBeDefined();
expect(response.user_code).toBeDefined();
expect(response.verification_uri).toBeDefined();
expect(response.verification_uri_complete).toBeDefined();
expect(response.expires_in).toBe(300);
expect(response.interval).toBe(2);
expect(response.user_code).toMatch(/^[A-Z0-9]{8}$/);
expect(response.verification_uri_complete).toContain(response.user_code);
});
it("should support custom client ID and scope", async () => {
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
scope: "read write",
},
});
expect(response.device_code).toBeDefined();
expect(response.user_code).toBeDefined();
});
});
describe("device token polling", () => {
it("should return authorization_pending when not approved", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "authorization_pending",
error_description: "Authorization pending",
},
});
});
it("should return expired_token for expired device codes", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Advance time past expiration
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(301 * 1000); // 301 seconds
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "expired_token",
error_description: "Device code has expired",
},
});
vi.useRealTimers();
});
it("should return error for invalid device code", async () => {
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: "invalid-code",
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "invalid_grant",
error_description: "Invalid device code",
},
});
});
});
describe("device verification", () => {
it("should verify valid user code", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
const response = await auth.api.deviceVerify({
query: { user_code },
});
expect("error" in response).toBe(false);
if (!("error" in response)) {
expect(response.user_code).toBe(user_code);
expect(response.status).toBe("pending");
}
});
it("should handle invalid user code", async () => {
await expect(
auth.api.deviceVerify({
query: { user_code: "INVALID" },
}),
).rejects.toMatchObject({
body: {
error: "invalid_request",
error_description: "Invalid user code",
},
});
});
});
describe("device approval flow", () => {
it("should approve device and create session", async () => {
// First, sign in as a user
const { headers } = await signInWithTestUser();
// Request device code
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Approve the device
const approveResponse = await auth.api.deviceApprove({
body: { userCode: user_code },
headers,
});
expect("success" in approveResponse && approveResponse.success).toBe(
true,
);
// Poll for token should now succeed
const tokenResponse = await auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
});
// Check OAuth 2.0 compliant response
expect("access_token" in tokenResponse).toBe(true);
if ("access_token" in tokenResponse) {
expect(tokenResponse.access_token).toBeDefined();
expect(tokenResponse.token_type).toBe("Bearer");
expect(tokenResponse.expires_in).toBeGreaterThan(0);
expect(tokenResponse.scope).toBeDefined();
}
});
it("should deny device authorization", async () => {
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Deny the device
const denyResponse = await auth.api.deviceDeny({
body: { userCode: user_code },
headers: new Headers(),
});
expect("success" in denyResponse && denyResponse.success).toBe(true);
// Poll for token should return access_denied
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "access_denied",
error_description: "Access denied",
},
});
});
it("should require authentication for approval", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await expect(
auth.api.deviceApprove({
body: { userCode: user_code },
headers: new Headers(),
}),
).rejects.toMatchObject({
body: {
error: "unauthorized",
error_description: "Authentication required",
},
});
});
it("should enforce rate limiting with slow_down error", async () => {
const { device_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await auth.api
.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
})
.catch(
// ignore the error
() => {},
);
await expect(
auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
}),
).rejects.toMatchObject({
body: {
error: "slow_down",
error_description: "Polling too frequently",
},
});
});
});
describe("edge cases", () => {
it("should not allow approving already processed device code", async () => {
// Sign in as a user
const { headers } = await signInWithTestUser();
// Request and approve device
const { user_code: userCode } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
await auth.api.deviceApprove({
body: { userCode },
headers,
});
await expect(
auth.api.deviceApprove({
body: { userCode },
headers,
}),
).rejects.toMatchObject({
body: {
error: "invalid_request",
error_description: "Device code already processed",
},
});
});
it("should handle user code without dashes", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
const cleanUserCode = user_code.replace(/-/g, "");
const response = await auth.api.deviceVerify({
query: { user_code: cleanUserCode },
});
expect("status" in response && response.status).toBe("pending");
});
it("should store and use scope from device code request", async () => {
const { headers } = await signInWithTestUser();
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
scope: "read write profile",
},
});
await auth.api.deviceApprove({
body: { userCode: user_code },
headers,
});
const tokenResponse = await auth.api.deviceToken({
body: {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code,
client_id: "test-client",
},
});
expect("scope" in tokenResponse && tokenResponse.scope).toBe(
"read write profile",
);
});
});
});
describe("device authorization with custom options", async () => {
it("should correctly store interval as milliseconds in database", async () => {
const { auth, client, db } = await getTestInstance({
plugins: [
deviceAuthorization({
interval: "5s",
}),
],
});
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
// Response should return interval in seconds
expect(response.interval).toBe(5);
// Check that the interval is stored as milliseconds in the database
const deviceCodeRecord: DeviceCode | null = await db.findOne({
model: "deviceCode",
where: [
{
field: "deviceCode",
value: response.device_code,
},
],
});
// Should be stored as 5000 milliseconds, not "5s" string
expect(deviceCodeRecord?.pollingInterval).toBe(5000);
expect(typeof deviceCodeRecord?.pollingInterval).toBe("number");
});
it("should use custom code generators", async () => {
const customDeviceCode = "custom-device-code-12345";
const customUserCode = "CUSTOM12";
const { auth } = await getTestInstance({
plugins: [
deviceAuthorization({
generateDeviceCode: () => customDeviceCode,
generateUserCode: () => customUserCode,
}),
],
});
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
expect(response.device_code).toBe(customDeviceCode);
expect(response.user_code).toBe(customUserCode);
});
it("should respect custom expiration time", async () => {
const { auth } = await getTestInstance({
plugins: [
deviceAuthorization({
expiresIn: "1min",
}),
],
});
const response = await auth.api.deviceCode({
body: {
client_id: "test-client",
},
});
expect(response.expires_in).toBe(60);
});
});
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/two-factor/backup-codes/index.ts:
--------------------------------------------------------------------------------
```typescript
import { generateRandomString } from "../../../crypto/random";
import * as z from "zod";
import { createAuthEndpoint } from "@better-auth/core/api";
import { sessionMiddleware } from "../../../api";
import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto";
import type {
TwoFactorProvider,
TwoFactorTable,
UserWithTwoFactor,
} from "../types";
import { APIError } from "better-call";
import { TWO_FACTOR_ERROR_CODES } from "../error-code";
import { verifyTwoFactor } from "../verify-two-factor";
import { safeJSONParse } from "../../../utils/json";
export interface BackupCodeOptions {
/**
* The amount of backup codes to generate
*
* @default 10
*/
amount?: number;
/**
* The length of the backup codes
*
* @default 10
*/
length?: number;
/**
* An optional custom function to generate backup codes
*/
customBackupCodesGenerate?: () => string[];
/**
* How to store the backup codes in the database, whether encrypted or plain.
*/
storeBackupCodes?:
| "plain"
| "encrypted"
| {
encrypt: (token: string) => Promise<string>;
decrypt: (token: string) => Promise<string>;
};
}
function generateBackupCodesFn(options?: BackupCodeOptions) {
return Array.from({ length: options?.amount ?? 10 })
.fill(null)
.map(() => generateRandomString(options?.length ?? 10, "a-z", "0-9", "A-Z"))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
}
export async function generateBackupCodes(
secret: string,
options?: BackupCodeOptions,
) {
const backupCodes = options?.customBackupCodesGenerate
? options.customBackupCodesGenerate()
: generateBackupCodesFn(options);
if (options?.storeBackupCodes === "encrypted") {
const encCodes = await symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: secret,
});
return {
backupCodes,
encryptedBackupCodes: encCodes,
};
}
if (
typeof options?.storeBackupCodes === "object" &&
"encrypt" in options?.storeBackupCodes
) {
return {
backupCodes,
encryptedBackupCodes: await options?.storeBackupCodes.encrypt(
JSON.stringify(backupCodes),
),
};
}
return {
backupCodes,
encryptedBackupCodes: JSON.stringify(backupCodes),
};
}
export async function verifyBackupCode(
data: {
backupCodes: string;
code: string;
},
key: string,
options?: BackupCodeOptions,
) {
const codes = await getBackupCodes(data.backupCodes, key, options);
if (!codes) {
return {
status: false,
updated: null,
};
}
return {
status: codes.includes(data.code),
updated: codes.filter((code) => code !== data.code),
};
}
export async function getBackupCodes(
backupCodes: string,
key: string,
options?: BackupCodeOptions,
) {
if (options?.storeBackupCodes === "encrypted") {
const decrypted = await symmetricDecrypt({ key, data: backupCodes });
return safeJSONParse<string[]>(decrypted);
}
if (
typeof options?.storeBackupCodes === "object" &&
"decrypt" in options?.storeBackupCodes
) {
const decrypted = await options?.storeBackupCodes.decrypt(backupCodes);
return safeJSONParse<string[]>(decrypted);
}
return safeJSONParse<string[]>(backupCodes);
}
export const backupCode2fa = (opts: BackupCodeOptions) => {
const twoFactorTable = "twoFactor";
return {
id: "backup_code",
endpoints: {
/**
* ### Endpoint
*
* POST `/two-factor/verify-backup-code`
*
* ### API Methods
*
* **server:**
* `auth.api.verifyBackupCode`
*
* **client:**
* `authClient.twoFactor.verifyBackupCode`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-verify-backup-code)
*/
verifyBackupCode: createAuthEndpoint(
"/two-factor/verify-backup-code",
{
method: "POST",
body: z.object({
code: z.string().meta({
description: `A backup code to verify. Eg: "123456"`,
}),
/**
* Disable setting the session cookie
*/
disableSession: z
.boolean()
.meta({
description: "If true, the session cookie will not be set.",
})
.optional(),
/**
* if true, the device will be trusted
* for 30 days. It'll be refreshed on
* every sign in request within this time.
*/
trustDevice: z
.boolean()
.meta({
description:
"If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true",
})
.optional(),
}),
metadata: {
openapi: {
description: "Verify a backup code for two-factor authentication",
responses: {
"200": {
description: "Backup code verified successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user: {
type: "object",
properties: {
id: {
type: "string",
description: "Unique identifier of the user",
},
email: {
type: "string",
format: "email",
nullable: true,
description: "User's email address",
},
emailVerified: {
type: "boolean",
nullable: true,
description: "Whether the email is verified",
},
name: {
type: "string",
nullable: true,
description: "User's name",
},
image: {
type: "string",
format: "uri",
nullable: true,
description: "User's profile image URL",
},
twoFactorEnabled: {
type: "boolean",
description:
"Whether two-factor authentication is enabled for the user",
},
createdAt: {
type: "string",
format: "date-time",
description:
"Timestamp when the user was created",
},
updatedAt: {
type: "string",
format: "date-time",
description:
"Timestamp when the user was last updated",
},
},
required: [
"id",
"twoFactorEnabled",
"createdAt",
"updatedAt",
],
description:
"The authenticated user object with two-factor details",
},
session: {
type: "object",
properties: {
token: {
type: "string",
description: "Session token",
},
userId: {
type: "string",
description:
"ID of the user associated with the session",
},
createdAt: {
type: "string",
format: "date-time",
description:
"Timestamp when the session was created",
},
expiresAt: {
type: "string",
format: "date-time",
description:
"Timestamp when the session expires",
},
},
required: [
"token",
"userId",
"createdAt",
"expiresAt",
],
description:
"The current session object, included unless disableSession is true",
},
},
required: ["user", "session"],
},
},
},
},
},
},
},
},
async (ctx) => {
const { session, valid } = await verifyTwoFactor(ctx);
const user = session.user as UserWithTwoFactor;
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
{
field: "userId",
value: user.id,
},
],
});
if (!twoFactor) {
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
});
}
const validate = await verifyBackupCode(
{
backupCodes: twoFactor.backupCodes,
code: ctx.body.code,
},
ctx.context.secret,
opts,
);
if (!validate.status) {
throw new APIError("UNAUTHORIZED", {
message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE,
});
}
const updatedBackupCodes = await symmetricEncrypt({
key: ctx.context.secret,
data: JSON.stringify(validate.updated),
});
await ctx.context.adapter.updateMany({
model: twoFactorTable,
update: {
backupCodes: updatedBackupCodes,
},
where: [
{
field: "userId",
value: user.id,
},
],
});
if (!ctx.body.disableSession) {
return valid(ctx);
}
return ctx.json({
token: session.session?.token,
user: {
id: session.user?.id,
email: session.user.email,
emailVerified: session.user.emailVerified,
name: session.user.name,
image: session.user.image,
createdAt: session.user.createdAt,
updatedAt: session.user.updatedAt,
},
});
},
),
/**
* ### Endpoint
*
* POST `/two-factor/generate-backup-codes`
*
* ### API Methods
*
* **server:**
* `auth.api.generateBackupCodes`
*
* **client:**
* `authClient.twoFactor.generateBackupCodes`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-generate-backup-codes)
*/
generateBackupCodes: createAuthEndpoint(
"/two-factor/generate-backup-codes",
{
method: "POST",
body: z.object({
password: z.string().meta({
description: "The users password.",
}),
}),
use: [sessionMiddleware],
metadata: {
openapi: {
description:
"Generate new backup codes for two-factor authentication",
responses: {
"200": {
description: "Backup codes generated successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description:
"Indicates if the backup codes were generated successfully",
enum: [true],
},
backupCodes: {
type: "array",
items: { type: "string" },
description:
"Array of generated backup codes in plain text",
},
},
required: ["status", "backupCodes"],
},
},
},
},
},
},
},
},
async (ctx) => {
const user = ctx.context.session.user as UserWithTwoFactor;
if (!user.twoFactorEnabled) {
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED,
});
}
await ctx.context.password.checkPassword(user.id, ctx);
const backupCodes = await generateBackupCodes(
ctx.context.secret,
opts,
);
await ctx.context.adapter.updateMany({
model: twoFactorTable,
update: {
backupCodes: backupCodes.encryptedBackupCodes,
},
where: [
{
field: "userId",
value: ctx.context.session.user.id,
},
],
});
return ctx.json({
status: true,
backupCodes: backupCodes.backupCodes,
});
},
),
/**
* ### Endpoint
*
* GET `/two-factor/view-backup-codes`
*
* ### API Methods
*
* **server:**
* `auth.api.viewBackupCodes`
*
* **client:**
* `authClient.twoFactor.viewBackupCodes`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-view-backup-codes)
*/
viewBackupCodes: createAuthEndpoint(
"/two-factor/view-backup-codes",
{
method: "GET",
body: z.object({
userId: z.coerce.string().meta({
description: `The user ID to view all backup codes. Eg: "user-id"`,
}),
}),
metadata: {
SERVER_ONLY: true,
},
},
async (ctx) => {
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
{
field: "userId",
value: ctx.body.userId,
},
],
});
if (!twoFactor) {
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
});
}
const decryptedBackupCodes = await getBackupCodes(
twoFactor.backupCodes,
ctx.context.secret,
opts,
);
if (!decryptedBackupCodes) {
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE,
});
}
return ctx.json({
status: true,
backupCodes: decryptedBackupCodes,
});
},
),
},
} satisfies TwoFactorProvider;
};
```
--------------------------------------------------------------------------------
/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts:
--------------------------------------------------------------------------------
```typescript
import {
and,
asc,
count,
desc,
eq,
gt,
gte,
inArray,
notInArray,
like,
lt,
lte,
ne,
or,
sql,
SQL,
} from "drizzle-orm";
import { BetterAuthError } from "@better-auth/core/error";
import type { BetterAuthOptions } from "@better-auth/core";
import {
createAdapterFactory,
type AdapterFactoryOptions,
type AdapterFactoryCustomizeAdapterCreator,
} from "../adapter-factory";
import type {
DBAdapterDebugLogOption,
DBAdapter,
Where,
} from "@better-auth/core/db/adapter";
export interface DB {
[key: string]: any;
}
export interface DrizzleAdapterConfig {
/**
* The schema object that defines the tables and fields
*/
schema?: Record<string, any>;
/**
* The database provider
*/
provider: "pg" | "mysql" | "sqlite";
/**
* If the table names in the schema are plural
* set this to true. For example, if the schema
* has an object with a key "users" instead of "user"
*/
usePlural?: boolean;
/**
* Enable debug logs for the adapter
*
* @default false
*/
debugLogs?: DBAdapterDebugLogOption;
/**
* By default snake case is used for table and field names
* when the CLI is used to generate the schema. If you want
* to use camel case, set this to true.
* @default false
*/
camelCase?: boolean;
/**
* Whether to execute multiple operations in a transaction.
*
* If the database doesn't support transactions,
* set this to `false` and operations will be executed sequentially.
* @default false
*/
transaction?: boolean;
}
export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
let lazyOptions: BetterAuthOptions | null = null;
const createCustomAdapter =
(db: DB): AdapterFactoryCustomizeAdapterCreator =>
({ getFieldName, debugLog }) => {
function getSchema(model: string) {
const schema = config.schema || db._.fullSchema;
if (!schema) {
throw new BetterAuthError(
"Drizzle adapter failed to initialize. Schema not found. Please provide a schema object in the adapter options object.",
);
}
const schemaModel = schema[model];
if (!schemaModel) {
throw new BetterAuthError(
`[# Drizzle Adapter]: The model "${model}" was not found in the schema object. Please pass the schema directly to the adapter options.`,
);
}
return schemaModel;
}
const withReturning = async (
model: string,
builder: any,
data: Record<string, any>,
where?: Where[],
) => {
if (config.provider !== "mysql") {
const c = await builder.returning();
return c[0];
}
await builder.execute();
const schemaModel = getSchema(model);
const builderVal = builder.config?.values;
if (where?.length) {
// If we're updating a field that's in the where clause, use the new value
const updatedWhere = where.map((w) => {
// If this field was updated, use the new value for lookup
if (data[w.field] !== undefined) {
return { ...w, value: data[w.field] };
}
return w;
});
const clause = convertWhereClause(updatedWhere, model);
const res = await db
.select()
.from(schemaModel)
.where(...clause);
return res[0];
} else if (builderVal && builderVal[0]?.id?.value) {
let tId = builderVal[0]?.id?.value;
if (!tId) {
//get last inserted id
const lastInsertId = await db
.select({ id: sql`LAST_INSERT_ID()` })
.from(schemaModel)
.orderBy(desc(schemaModel.id))
.limit(1);
tId = lastInsertId[0].id;
}
const res = await db
.select()
.from(schemaModel)
.where(eq(schemaModel.id, tId))
.limit(1)
.execute();
return res[0];
} else if (data.id) {
const res = await db
.select()
.from(schemaModel)
.where(eq(schemaModel.id, data.id))
.limit(1)
.execute();
return res[0];
} else {
// If the user doesn't have `id` as a field, then this will fail.
// We expect that they defined `id` in all of their models.
if (!("id" in schemaModel)) {
throw new BetterAuthError(
`The model "${model}" does not have an "id" field. Please use the "id" field as your primary key.`,
);
}
const res = await db
.select()
.from(schemaModel)
.orderBy(desc(schemaModel.id))
.limit(1)
.execute();
return res[0];
}
};
function convertWhereClause(where: Where[], model: string) {
const schemaModel = getSchema(model);
if (!where) return [];
if (where.length === 1) {
const w = where[0];
if (!w) {
return [];
}
const field = getFieldName({ model, field: w.field });
if (!schemaModel[field]) {
throw new BetterAuthError(
`The field "${w.field}" does not exist in the schema for the model "${model}". Please update your schema.`,
);
}
if (w.operator === "in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "in" operator.`,
);
}
return [inArray(schemaModel[field], w.value)];
}
if (w.operator === "not_in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
);
}
return [notInArray(schemaModel[field], w.value)];
}
if (w.operator === "contains") {
return [like(schemaModel[field], `%${w.value}%`)];
}
if (w.operator === "starts_with") {
return [like(schemaModel[field], `${w.value}%`)];
}
if (w.operator === "ends_with") {
return [like(schemaModel[field], `%${w.value}`)];
}
if (w.operator === "lt") {
return [lt(schemaModel[field], w.value)];
}
if (w.operator === "lte") {
return [lte(schemaModel[field], w.value)];
}
if (w.operator === "ne") {
return [ne(schemaModel[field], w.value)];
}
if (w.operator === "gt") {
return [gt(schemaModel[field], w.value)];
}
if (w.operator === "gte") {
return [gte(schemaModel[field], w.value)];
}
return [eq(schemaModel[field], w.value)];
}
const andGroup = where.filter(
(w) => w.connector === "AND" || !w.connector,
);
const orGroup = where.filter((w) => w.connector === "OR");
const andClause = and(
...andGroup.map((w) => {
const field = getFieldName({ model, field: w.field });
if (w.operator === "in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "in" operator.`,
);
}
return inArray(schemaModel[field], w.value);
}
if (w.operator === "not_in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
);
}
return notInArray(schemaModel[field], w.value);
}
if (w.operator === "contains") {
return like(schemaModel[field], `%${w.value}%`);
}
if (w.operator === "starts_with") {
return like(schemaModel[field], `${w.value}%`);
}
if (w.operator === "ends_with") {
return like(schemaModel[field], `%${w.value}`);
}
if (w.operator === "lt") {
return lt(schemaModel[field], w.value);
}
if (w.operator === "lte") {
return lte(schemaModel[field], w.value);
}
if (w.operator === "gt") {
return gt(schemaModel[field], w.value);
}
if (w.operator === "gte") {
return gte(schemaModel[field], w.value);
}
if (w.operator === "ne") {
return ne(schemaModel[field], w.value);
}
return eq(schemaModel[field], w.value);
}),
);
const orClause = or(
...orGroup.map((w) => {
const field = getFieldName({ model, field: w.field });
if (w.operator === "in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "in" operator.`,
);
}
return inArray(schemaModel[field], w.value);
}
if (w.operator === "not_in") {
if (!Array.isArray(w.value)) {
throw new BetterAuthError(
`The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
);
}
return notInArray(schemaModel[field], w.value);
}
if (w.operator === "contains") {
return like(schemaModel[field], `%${w.value}%`);
}
if (w.operator === "starts_with") {
return like(schemaModel[field], `${w.value}%`);
}
if (w.operator === "ends_with") {
return like(schemaModel[field], `%${w.value}`);
}
if (w.operator === "lt") {
return lt(schemaModel[field], w.value);
}
if (w.operator === "lte") {
return lte(schemaModel[field], w.value);
}
if (w.operator === "gt") {
return gt(schemaModel[field], w.value);
}
if (w.operator === "gte") {
return gte(schemaModel[field], w.value);
}
if (w.operator === "ne") {
return ne(schemaModel[field], w.value);
}
return eq(schemaModel[field], w.value);
}),
);
const clause: SQL<unknown>[] = [];
if (andGroup.length) clause.push(andClause!);
if (orGroup.length) clause.push(orClause!);
return clause;
}
function checkMissingFields(
schema: Record<string, any>,
model: string,
values: Record<string, any>,
) {
if (!schema) {
throw new BetterAuthError(
"Drizzle adapter failed to initialize. Schema not found. Please provide a schema object in the adapter options object.",
);
}
for (const key in values) {
if (!schema[key]) {
throw new BetterAuthError(
`The field "${key}" does not exist in the "${model}" schema. Please update your drizzle schema or re-generate using "npx @better-auth/cli generate".`,
);
}
}
}
return {
async create({ model, data: values }) {
const schemaModel = getSchema(model);
checkMissingFields(schemaModel, model, values);
const builder = db.insert(schemaModel).values(values);
const returned = await withReturning(model, builder, values);
return returned;
},
async findOne({ model, where }) {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
const res = await db
.select()
.from(schemaModel)
.where(...clause);
if (!res.length) return null;
return res[0];
},
async findMany({ model, where, sortBy, limit, offset }) {
const schemaModel = getSchema(model);
const clause = where ? convertWhereClause(where, model) : [];
const sortFn = sortBy?.direction === "desc" ? desc : asc;
const builder = db
.select()
.from(schemaModel)
.limit(limit || 100)
.offset(offset || 0);
if (sortBy?.field) {
builder.orderBy(
sortFn(
schemaModel[getFieldName({ model, field: sortBy?.field })],
),
);
}
return (await builder.where(...clause)) as any[];
},
async count({ model, where }) {
const schemaModel = getSchema(model);
const clause = where ? convertWhereClause(where, model) : [];
const res = await db
.select({ count: count() })
.from(schemaModel)
.where(...clause);
return res[0].count;
},
async update({ model, where, update: values }) {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
const builder = db
.update(schemaModel)
.set(values)
.where(...clause);
return await withReturning(model, builder, values as any, where);
},
async updateMany({ model, where, update: values }) {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
const builder = db
.update(schemaModel)
.set(values)
.where(...clause);
return await builder;
},
async delete({ model, where }) {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
const builder = db.delete(schemaModel).where(...clause);
return await builder;
},
async deleteMany({ model, where }) {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
const builder = db.delete(schemaModel).where(...clause);
return await builder;
},
options: config,
};
};
let adapterOptions: AdapterFactoryOptions | null = null;
adapterOptions = {
config: {
adapterId: "drizzle",
adapterName: "Drizzle Adapter",
usePlural: config.usePlural ?? false,
debugLogs: config.debugLogs ?? false,
transaction:
(config.transaction ?? false)
? (cb) =>
db.transaction((tx: DB) => {
const adapter = createAdapterFactory({
config: adapterOptions!.config,
adapter: createCustomAdapter(tx),
})(lazyOptions!);
return cb(adapter);
})
: false,
},
adapter: createCustomAdapter(db),
};
const adapter = createAdapterFactory(adapterOptions);
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
lazyOptions = options;
return adapter(options);
};
};
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/middlewares/origin-check.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { createAuthClient } from "../../client";
import { createAuthEndpoint } from "@better-auth/core/api";
import { isSimpleRequest, originCheck } from "./origin-check";
import * as z from "zod";
describe("Origin Check", async (it) => {
const { customFetchImpl, testUser } = await getTestInstance({
trustedOrigins: [
"http://localhost:5000",
"https://trusted.com",
"*.my-site.com",
"https://*.protocol-site.com",
],
emailAndPassword: {
enabled: true,
async sendResetPassword(url, user) {},
},
advanced: {
disableCSRFCheck: false,
disableOriginCheck: false,
},
});
it("should allow trusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "http://localhost:3000",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "http://localhost:3000/callback",
});
expect(res.data?.user).toBeDefined();
});
it("should not allow untrusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
const res = await client.signIn.email({
email: "[email protected]",
password: "password",
callbackURL: "http://malicious.com",
});
expect(res.error?.status).toBe(403);
expect(res.error?.message).toBe("Invalid callbackURL");
});
it("should allow query params in callback url", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://localhost:3000",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "/dashboard?test=123",
});
expect(res.data?.user).toBeDefined();
});
it("should allow plus signs in the callback url", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://localhost:3000",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "/dashboard+page?test=123+456",
});
expect(res.data?.user).toBeDefined();
});
it("should reject callback url with double slash", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://localhost:3000",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "//evil.com",
});
expect(res.error?.status).toBe(403);
});
it("should reject callback urls with encoded malicious content", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://localhost:3000",
},
},
});
const maliciousPatterns = [
"/%5C/evil.com",
`/\\/\\/evil.com`,
"/%5C/evil.com",
"/..%2F..%2Fevil.com",
"javascript:alert('xss')",
"data:text/html,<script>alert('xss')</script>",
];
for (const pattern of maliciousPatterns) {
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: pattern,
});
expect(res.error?.status).toBe(403);
}
});
it("should reject untrusted origin headers", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "malicious.com",
cookie: "session=123",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(res.error?.status).toBe(403);
});
it("should reject untrusted origin headers which start with trusted origin", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://trusted.com.malicious.com",
cookie: "session=123",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(res.error?.status).toBe(403);
});
it("should reject untrusted origin subdomains", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "http://sub-domain.trusted.com",
cookie: "session=123",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(res.error?.status).toBe(403);
});
it("should allow untrusted origin if they don't contain cookies", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "http://sub-domain.trusted.com",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(res.data?.user).toBeDefined();
});
it("should reject untrusted redirectTo", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
const res = await client.requestPasswordReset({
email: testUser.email,
redirectTo: "http://malicious.com",
});
expect(res.error?.status).toBe(403);
expect(res.error?.message).toBe("Invalid redirectURL");
});
it("should work with list of trusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://trusted.com",
},
},
});
const res = await client.requestPasswordReset({
email: testUser.email,
redirectTo: "http://localhost:5000/reset-password",
});
expect(res.data?.status).toBeTruthy();
const res2 = await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
query: {
currentURL: "http://localhost:5000",
},
},
});
expect(res2.data?.user).toBeDefined();
});
it("should work with wildcard trusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "https://sub-domain.my-site.com",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://sub-domain.my-site.com",
},
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "https://sub-domain.my-site.com/callback",
});
expect(res.data?.user).toBeDefined();
// Test another subdomain with the wildcard pattern
const client2 = createAuthClient({
baseURL: "https://another-sub.my-site.com",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://another-sub.my-site.com",
},
},
});
const res2 = await client2.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "https://another-sub.my-site.com/callback",
});
expect(res2.data?.user).toBeDefined();
});
it("should work with GET requests", async (ctx) => {
const client = createAuthClient({
baseURL: "https://sub-domain.my-site.com",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://google.com",
cookie: "value",
},
},
});
const res = await client.$fetch("/ok");
expect(res.data).toMatchObject({ ok: true });
});
it("should handle POST requests with proper origin validation", async (ctx) => {
// Test with valid origin
const validClient = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "http://localhost:5000",
cookie: "session=123",
},
},
});
const validRes = await validClient.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(validRes.data?.user).toBeDefined();
// Test with invalid origin
const invalidClient = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "http://untrusted-domain.com",
cookie: "session=123",
},
},
});
const invalidRes = await invalidClient.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(invalidRes.error?.status).toBe(403);
});
it("should work with relative callbackURL with query params", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
const res = await client.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "/[email protected]",
});
expect(res.data?.user).toBeDefined();
});
it("should work with protocol specific wildcard trusted origins", async () => {
// Test HTTPS protocol specific wildcard - should work
const httpsClient = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://api.protocol-site.com",
cookie: "session=123",
},
},
});
const httpsRes = await httpsClient.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "https://app.protocol-site.com/dashboard",
});
expect(httpsRes.data?.user).toBeDefined();
// Test HTTP with HTTPS protocol wildcard - should fail
const httpClient = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "http://api.protocol-site.com",
cookie: "session=123",
},
},
});
const httpRes = await httpClient.signIn.email({
email: testUser.email,
password: testUser.password,
});
expect(httpRes.error?.status).toBe(403);
});
it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => {
const { customFetchImpl, testUser } = await getTestInstance({
trustedOrigins: [
"exp://10.0.0.*:*/*",
"exp://192.168.*.*:*/*",
"exp://172.*.*.*:*/*",
],
emailAndPassword: {
enabled: true,
async sendResetPassword(url, user) {},
},
});
// Test custom scheme with wildcard - should work
const expoClient = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
// Test with IP matching the wildcard pattern
const resWithIP = await expoClient.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "exp://10.0.0.29:8081/--/",
});
expect(resWithIP.data?.user).toBeDefined();
// Test with different IP range that matches
const resWithIP2 = await expoClient.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "exp://192.168.1.100:8081/--/",
});
expect(resWithIP2.data?.user).toBeDefined();
// Test with different IP range that matches
const resWithIP3 = await expoClient.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "exp://172.16.0.1:8081/--/",
});
expect(resWithIP3.data?.user).toBeDefined();
// Test with IP that doesn't match any pattern - should fail
const resWithUnmatchedIP = await expoClient.signIn.email({
email: testUser.email,
password: testUser.password,
callbackURL: "exp://203.0.113.0:8081/--/",
});
expect(resWithUnmatchedIP.error?.status).toBe(403);
});
});
describe("origin check middleware", async (it) => {
it("should return invalid origin", async () => {
const { client } = await getTestInstance({
trustedOrigins: ["https://trusted-site.com"],
plugins: [
{
id: "test",
endpoints: {
test: createAuthEndpoint(
"/test",
{
method: "GET",
query: z.object({
callbackURL: z.string(),
}),
use: [originCheck((c) => c.query.callbackURL)],
},
async (c) => {
return c.query.callbackURL;
},
),
},
},
],
});
const invalid = await client.$fetch(
"/test?callbackURL=https://malicious-site.com",
);
expect(invalid.error?.status).toBe(403);
const valid = await client.$fetch("/test?callbackURL=/dashboard");
expect(valid.data).toBe("/dashboard");
const validTrusted = await client.$fetch(
"/test?callbackURL=https://trusted-site.com/path",
);
expect(validTrusted.data).toBe("https://trusted-site.com/path");
const sampleInternalEndpointInvalid = await client.$fetch(
"/verify-email?callbackURL=https://malicious-site.com&token=xyz",
);
expect(sampleInternalEndpointInvalid.error?.status).toBe(403);
});
});
describe("is simple request", async (it) => {
it("should return true for simple requests", async () => {
const request = new Request("http://localhost:3000/test", {
method: "GET",
});
const isSimple = isSimpleRequest(request.headers);
expect(isSimple).toBe(true);
});
it("should return false for non-simple requests", async () => {
const request = new Request("http://localhost:3000/test", {
method: "POST",
headers: {
"custom-header": "value",
},
});
const isSimple = isSimpleRequest(request.headers);
expect(isSimple).toBe(false);
});
it("should return false for requests with a content type that is not simple", async () => {
const request = new Request("http://localhost:3000/test", {
method: "POST",
headers: {
"content-type": "application/json",
},
});
const isSimple = isSimpleRequest(request.headers);
expect(isSimple).toBe(false);
});
it;
});
```
--------------------------------------------------------------------------------
/docs/content/docs/integrations/waku.mdx:
--------------------------------------------------------------------------------
```markdown
---
title: Waku Integration
description: Integrate Better Auth with Waku.
---
Better Auth can be easily integrated with Waku. Before you start, make sure you have a Better Auth instance configured. If you haven't done that yet, check out the [installation](/docs/installation).
## Create auth instance
Create a file named `auth.ts` in your application. Import Better Auth and create your instance.
<Callout type="warn">
Make sure to export the auth instance with the variable name `auth` or as a `default` export.
</Callout>
```ts title="src/auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
database: {
provider: "postgres", //change this to your database provider
url: process.env.DATABASE_URL, // path to your database or connection string
}
})
```
## Create API Route
We need to mount the handler to a API route. Create a directory for Waku's file system router at `src/pages/api/auth`. Create a catch-all route file `[...route].ts` inside the `src/pages/api/auth` directory. And add the following code:
```ts title="src/pages/api/auth/[...route].ts"
import { auth } from "../../../auth" // Adjust the path as necessary
export const GET = async (request: Request): Promise<Response> => {
return auth.handler(request)
}
export const POST = async (request: Request): Promise<Response> => {
return auth.handler(request)
}
```
<Callout type="info">
You can change the path on your better-auth configuration but it's recommended to keep it as `src/pages/api/auth/[...route].ts`
</Callout>
## Create a client
Create a client instance. Here we are creating `auth-client.ts` file inside the `lib/` directory.
```ts title="src/lib/auth-client.ts"
import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react
export const authClient = createAuthClient({
//you can pass client configuration here
})
export type Session = typeof authClient.$Infer.Session // you can infer typescript types from the authClient
```
Once you have created the client, you can use it to sign up, sign in, and perform other actions.
Some of the actions are reactive. The client uses [nano-store](https://github.com/nanostores/nanostores) to store the state and re-render the components when the state changes.
The client also uses [better-fetch](https://github.com/bekacru/better-fetch) to make the requests. You can pass the fetch configuration to the client.
## RSC and Server actions
The `api` object exported from the auth instance contains all the actions that you can perform on the server. Every endpoint made inside Better Auth is a invocable as a function. Including plugins endpoints.
**Example: Getting Session on a server action**
```tsx title="server.ts"
"use server" // Waku currently only supports file-level "use server"
import { auth } from "./auth"
import { getContext } from "waku/middleware/context"
export const someAuthenticatedAction = async () => {
"use server"
const session = await auth.api.getSession({
headers: new Headers(getContext().req.headers),
})
};
```
**Example: Getting Session on a RSC**
```tsx
import { auth } from "../auth"
import { getContext } from "waku/middleware/context"
export async function ServerComponent() {
const session = await auth.api.getSession({
headers: new Headers(getContext().req.headers),
})
if(!session) {
return <div>Not authenticated</div>
}
return (
<div>
<h1>Welcome {session.user.name}</h1>
</div>
)
}
```
<Callout type="warn">RSCs that run after the response has started streaming cannot set cookies. The [cookie cache](/docs/concepts/session-management#cookie-cache) will not be refreshed until the server is interacted with from the client via Server Actions or Route Handlers.</Callout>
### Server Action Cookies
When you call a function that needs to set cookies, like `signInEmail` or `signUpEmail` in a server action, cookies won’t be set.
We can create a plugin that works together with our middleware to set cookies.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { wakuCookies } from "better-auth/waku";
import { getContextData } from "waku/middleware/context";
export const auth = betterAuth({
//...your config
plugins: [wakuCookies()] // make sure this is the last plugin in the array // [!code highlight]
})
function wakuCookies() {
return {
id: "waku-cookies",
hooks: {
after: [
{
matcher(ctx) {
return true;
},
handler: createAuthMiddleware(async (ctx) => {
const returned = ctx.context.responseHeaders;
if ("_flag" in ctx && ctx._flag === "router") {
return;
}
if (returned instanceof Headers) {
const setCookieHeader = returned?.get("set-cookie");
if (!setCookieHeader) return;
const contextData = getContextData();
contextData.betterAuthSetCookie = setCookieHeader;
}
}),
},
],
},
} satisfies BetterAuthPlugin;
}
```
See below for the middleware to create to add the `contextData.betterAuthSetCookie` cookies to the response.
Now, when you call functions that set cookies, they will be automatically set.
```ts
"use server";
import { auth } from "../auth"
const signIn = async () => {
await auth.api.signInEmail({
body: {
email: "[email protected]",
password: "password",
}
})
}
```
### Middleware
In Waku middleware, it's recommended to only check for the existence of a session cookie to handle redirection. This avoids blocking requests by making API or database calls.
You can use the `getSessionCookie` helper from Better Auth for this purpose:
<Callout type="warn">
The <code>getSessionCookie()</code> function does not automatically reference the auth config specified in <code>auth.ts</code>. Therefore, if you customized the cookie name or prefix, you need to ensure that the configuration in <code>getSessionCookie()</code> matches the config defined in your <code>auth.ts</code>.
</Callout>
```ts title="src/middleware/auth.ts"
import type { Middleware } from "waku/config"
import { getSession } from "../auth"
import { getSessionCookie } from "better-auth/cookies"
const authMiddleware: Middleware = () => {
return async (ctx, next) => {
const sessionCookie = getSessionCookie(
new Request(ctx.req.url, {
body: ctx.req.body,
headers: ctx.req.headers,
method: ctx.req.method,
})
)
// THIS IS NOT SECURE!
// This is the recommended approach to optimistically redirect users
// We recommend handling auth checks in each page/route
if (!sessionCookie && ctx.req.url.pathname !== "/") {
if (!ctx.req.url.pathname.endsWith(".txt")) {
// Currently RSC requests end in .txt and don't handle redirect responses
// The redirect needs to be encoded in the React flight stream somehow
// There is some functionality in Waku to do this from a server component
// but not from middleware.
ctx.res.status = 302;
ctx.res.headers = {
Location: new URL("/", ctx.req.url).toString(),
};
}
}
// TODO possible to inspect ctx.req.url and not do this on every request
// Or skip starting the promise here and just invoke from server components and functions
getSession()
await next()
if (ctx.data.betterAuthSetCookie) {
ctx.res.headers ||= {}
let origSetCookie = ctx.res.headers["set-cookie"] || ([] as string[])
if (typeof origSetCookie === "string") {
origSetCookie = [origSetCookie]
}
ctx.res.headers["set-cookie"] = [
...origSetCookie,
ctx.data.betterAuthSetCookie as string,
]
}
}
};
export default authMiddleware;
```
<Callout type="warn">
**Security Warning:** The `getSessionCookie` function only checks for the
existence of a session cookie; it does **not** validate it. Relying solely
on this check for security is dangerous, as anyone can manually create a
cookie to bypass it. You must always validate the session on your server for
any protected actions or pages.
</Callout>
<Callout type="info">
If you have a custom cookie name or prefix, you can pass it to the `getSessionCookie` function.
```ts
const sessionCookie = getSessionCookie(request, {
cookieName: "my_session_cookie",
cookiePrefix: "my_prefix"
})
```
</Callout>
Alternatively, you can use the `getCookieCache` helper to get the session object from the cookie cache.
```ts
import { getCookieCache } from "better-auth/cookies"
const authMiddleware: Middleware = () => {
return async (ctx, next) => {
const session = await getCookieCache(ctx.req)
if (!session && ctx.req.url.pathname !== "/") {
if (!ctx.req.url.pathname.endsWith(".txt")) {
ctx.res.status = 302
ctx.res.headers = {
Location: new URL("/", ctx.req.url).toString(),
}
}
}
}
await next();
}
}
export default authMiddleware;
```
Note that your middleware will need to be added to a waku.config.ts file (create this file if it doesn't already exist in your project):
```ts title="waku.config.ts"
import { defineConfig } from "waku/config";
export default defineConfig({
middleware: [
"waku/middleware/context",
"waku/middleware/dev-server",
"./src/middleware/auth.ts",
"waku/middleware/handler",
],
});
```
### How to handle auth checks in each page/route
In this example, we are using the `auth.api.getSession` function within a server component to get the session object,
then we are checking if the session is valid. If it's not, we are redirecting the user to the sign-in page.
Waku has `getContext` to get the request headers and `getContextData()` to store data per request. We can use this
to avoid fetching the session more than once per request.
```ts title="auth.ts"
import { getContext, getContextData } from "waku/middleware/context";
// Code from above to create the server auth config
// export const auth = ...
export function getSession(): Promise<Session | null> {
const contextData = getContextData();
const ctx = getContext();
const existingSessionPromise = contextData.sessionPromise as
| Promise<Session | null>
| undefined;
if (existingSessionPromise) {
return existingSessionPromise;
}
const sessionPromise = auth.api.getSession({
headers: new Headers(ctx.req.headers),
});
contextData.sessionPromise = sessionPromise;
return sessionPromise;
}
```
```tsx title="src/pages/dashboard.tsx"
import { getSession } from "../auth";
import { unstable_redirect as redirect } from 'waku/router/server';
export default async function DashboardPage() {
const session = await getSession()
if (!session) {
redirect("/sign-in")
}
return (
<div>
<h1>Welcome {session.user.name}</h1>
</div>
)
}
```
### Example usage
#### Sign Up
```ts title="src/components/signup.tsx"
"use client"
import { useState } from "react"
import { authClient } from "../lib/auth-client"
export default function SignUp() {
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [password, setPassword] = useState("")
const signUp = async () => {
await authClient.signUp.email(
{
email,
password,
name,
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
// redirect to home
},
onError: (ctx) => {
alert(ctx.error)
},
},
)
}
return (
<div>
<h2>
Sign Up
</h2>
<form
onSubmit={signUp}
>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button
type="submit"
>
Sign Up
</button>
</form>
</div>
)
}
```
#### Sign In
```ts title="src/components/signin.tsx"
"use client"
import { useState } from "react"
import { authClient } from "../lib/auth-client"
export default function SignIn() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const signIn = async () => {
await authClient.signIn.email(
{
email,
password,
},
{
onRequest: (ctx) => {
// show loading state
},
onSuccess: (ctx) => {
// redirect to home
},
onError: (ctx) => {
alert(ctx.error)
},
},
)
}
return (
<div>
<h2>
Sign In
</h2>
<form onSubmit={signIn}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="submit"
>
Sign In
</button>
</form>
</div>
)
}
```
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/username/username.test.ts:
--------------------------------------------------------------------------------
```typescript
import { describe, expect } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { username } from ".";
import { usernameClient } from "./client";
describe("username", async (it) => {
const { client, sessionSetter, signInWithTestUser } = await getTestInstance(
{
plugins: [
username({
minUsernameLength: 4,
}),
],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
it("should sign up with username", async () => {
const headers = new Headers();
await client.signUp.email(
{
email: "[email protected]",
username: "new_username",
password: "new-password",
name: "new-name",
},
{
onSuccess: sessionSetter(headers),
},
);
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBe("new_username");
});
const headers = new Headers();
it("should sign-in with username", async () => {
const res = await client.signIn.username(
{
username: "new_username",
password: "new-password",
},
{
onSuccess: sessionSetter(headers),
},
);
expect(res.data?.token).toBeDefined();
});
it("should update username", async () => {
const res = await client.updateUser({
username: "new_username_2.1",
fetchOptions: {
headers,
},
});
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBe("new_username_2.1");
});
it("should fail on duplicate username in sign-up", async () => {
const res = await client.signUp.email({
email: "[email protected]",
username: "New_username_2.1",
password: "new_password",
name: "new-name",
});
expect(res.error?.status).toBe(422);
});
it("should fail on duplicate username in update-user if user is different", async () => {
const newHeaders = new Headers();
await client.signUp.email({
email: "[email protected]",
username: "duplicate-username",
password: "new_password",
name: "new-name",
fetchOptions: {
headers: newHeaders,
},
});
const { headers: testUserHeaders } = await signInWithTestUser();
const res = await client.updateUser({
username: "duplicate-username",
fetchOptions: {
headers: testUserHeaders,
},
});
expect(res.error?.status).toBe(400);
});
it("should succeed on duplicate username in update-user if user is the same", async () => {
await client.updateUser({
username: "New_username_2.1",
fetchOptions: {
headers,
},
});
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBe("new_username_2.1");
});
it("should preserve both username and displayUsername when updating both", async () => {
const updateRes = await client.updateUser({
username: "priority_user",
displayUsername: "Priority Display Name",
fetchOptions: {
headers,
},
});
expect(updateRes.error).toBeNull();
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBe("priority_user");
expect(session?.user.displayUsername).toBe("Priority Display Name");
});
it("should fail on invalid username", async () => {
const res = await client.signUp.email({
email: "[email protected]",
username: "new username",
password: "new_password",
name: "new-name",
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe("USERNAME_IS_INVALID");
});
it("should fail on too short username", async () => {
const res = await client.signUp.email({
email: "[email protected]",
username: "new",
password: "new_password",
name: "new-name",
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe("USERNAME_IS_TOO_SHORT");
});
it("should fail on empty username", async () => {
const res = await client.signUp.email({
email: "[email protected]",
username: "",
password: "new_password",
name: "new-name",
});
expect(res.error?.status).toBe(400);
});
it("should check if username is unavailable", async () => {
const res = await client.isUsernameAvailable({
username: "priority_user",
});
expect(res.data?.available).toEqual(false);
});
it("should check if username is unavailable with different case (normalization)", async () => {
const res = await client.isUsernameAvailable({
username: "PRIORITY_USER",
});
expect(res.data?.available).toEqual(false);
});
it("should check if username is available", async () => {
const res = await client.isUsernameAvailable({
username: "new_username_2.2",
});
expect(res.data?.available).toEqual(true);
});
it("should reject invalid username format in isUsernameAvailable", async () => {
const res = await client.isUsernameAvailable({
username: "invalid username!",
});
expect(res.error?.status).toBe(422);
expect(res.error?.code).toBe("USERNAME_IS_INVALID");
});
it("should reject too short username in isUsernameAvailable", async () => {
const res = await client.isUsernameAvailable({
username: "abc",
});
expect(res.error?.status).toBe(422);
expect(res.error?.code).toBe("USERNAME_IS_TOO_SHORT");
});
it("should reject too long username in isUsernameAvailable", async () => {
const longUsername = "a".repeat(31);
const res = await client.isUsernameAvailable({
username: longUsername,
});
expect(res.error?.status).toBe(422);
expect(res.error?.code).toBe("USERNAME_IS_TOO_LONG");
});
it("should not normalize displayUsername", async () => {
const headers = new Headers();
await client.signUp.email(
{
email: "[email protected]",
displayUsername: "Test Username",
password: "test-password",
name: "test-name",
},
{
onSuccess: sessionSetter(headers),
},
);
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBe("test username");
expect(session?.user.displayUsername).toBe("Test Username");
});
it("should preserve both username and displayUsername when both are provided", async () => {
const headers = new Headers();
await client.signUp.email(
{
email: "[email protected]",
username: "custom_user",
displayUsername: "Fancy Display Name",
password: "test-password",
name: "test-name",
},
{
onSuccess: sessionSetter(headers),
},
);
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBe("custom_user");
expect(session?.user.displayUsername).toBe("Fancy Display Name");
});
it("should sign in with normalized username", async () => {
const { client } = await getTestInstance(
{
plugins: [username()],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
await client.signUp.email({
email: "[email protected]",
username: "Custom_User",
password: "test-password",
name: "test-name",
});
const res2 = await client.signIn.username({
username: "Custom_User",
password: "test-password",
});
expect(res2.data?.user.username).toBe("custom_user");
expect(res2.data?.user.displayUsername).toBe("Custom_User");
});
});
describe("username custom normalization", async (it) => {
const { client } = await getTestInstance(
{
plugins: [
username({
minUsernameLength: 4,
usernameNormalization: (username) =>
username.replaceAll("0", "o").replaceAll("4", "a").toLowerCase(),
}),
],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
it("should sign up with username", async () => {
const res = await client.signUp.email({
email: "[email protected]",
username: "H4XX0R",
password: "new-password",
name: "new-name",
});
expect(res.error).toBeNull();
});
it("should fail on duplicate username", async () => {
const res = await client.signUp.email({
email: "[email protected]",
username: "haxxor",
password: "new-password",
name: "new-name",
});
expect(res.error?.status).toBe(400);
});
it("should normalize displayUsername", async () => {
const { auth } = await getTestInstance({
plugins: [
username({
displayUsernameNormalization: (displayUsername) =>
displayUsername.toLowerCase(),
}),
],
});
const res = await auth.api.signUpEmail({
body: {
email: "[email protected]",
password: "new-password",
name: "new-name",
username: "test_username",
displayUsername: "Test Username",
},
});
const session = await auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${res.token}`,
}),
});
expect(session?.user.username).toBe("test_username");
expect(session?.user.displayUsername).toBe("test username");
});
});
describe("username with displayUsername validation", async (it) => {
const { client, sessionSetter } = await getTestInstance(
{
plugins: [
username({
displayUsernameValidator: (displayUsername) =>
/^[a-zA-Z0-9_-]+$/.test(displayUsername),
}),
],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
it("should accept valid displayUsername", async () => {
const res = await client.signUp.email({
email: "[email protected]",
displayUsername: "Valid_Display-123",
password: "test-password",
name: "test-name",
});
expect(res.error).toBeNull();
});
it("should reject invalid displayUsername", async () => {
const res = await client.signUp.email({
email: "[email protected]",
displayUsername: "Invalid Display!",
password: "test-password",
name: "test-name",
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe("DISPLAY_USERNAME_IS_INVALID");
});
it("should update displayUsername with valid value", async () => {
const headers = new Headers();
await client.signUp.email(
{
email: "[email protected]",
displayUsername: "Initial_Name",
password: "test-password",
name: "test-name",
},
{
onSuccess: sessionSetter(headers),
},
);
const sessionBefore = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(sessionBefore?.user.displayUsername).toBe("Initial_Name");
expect(sessionBefore?.user.username).toBe("initial_name");
const res = await client.updateUser({
displayUsername: "Updated_Name-123",
fetchOptions: {
headers,
},
});
expect(res.error).toBeNull();
const sessionAfter = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(sessionAfter?.user.displayUsername).toBe("Updated_Name-123");
expect(sessionAfter?.user.username).toBe("updated_name-123");
});
it("should reject invalid displayUsername on update", async () => {
const headers = new Headers();
await client.signUp.email(
{
email: "[email protected]",
displayUsername: "Valid_Name",
password: "test-password",
name: "test-name",
},
{
onSuccess: sessionSetter(headers),
},
);
const res = await client.updateUser({
displayUsername: "Invalid Display!",
fetchOptions: {
headers,
},
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe("DISPLAY_USERNAME_IS_INVALID");
});
});
describe("isUsernameAvailable with custom validator", async (it) => {
const { client } = await getTestInstance(
{
plugins: [
username({
usernameValidator: async (username) => {
return username.startsWith("user_");
},
}),
],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
it("should accept username with custom validator", async () => {
const res = await client.isUsernameAvailable({
username: "user_valid123",
});
expect(res.data?.available).toEqual(true);
});
it("should reject username that doesn't match custom validator", async () => {
const res = await client.isUsernameAvailable({
username: "invalid_user",
});
expect(res.error?.status).toBe(422);
expect(res.error?.code).toBe("USERNAME_IS_INVALID");
});
});
describe("post normalization flow", async (it) => {
it("should set displayUsername to username if only username is provided", async () => {
const { auth } = await getTestInstance({
plugins: [
username({
validationOrder: {
username: "post-normalization",
displayUsername: "post-normalization",
},
usernameNormalization: (username) => {
return username.split(" ").join("_").toLowerCase();
},
}),
],
});
const res = await auth.api.signUpEmail({
body: {
email: "[email protected]",
username: "Test Username",
password: "test-password",
name: "test-name",
},
});
const session = await auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${res.token}`,
}),
});
expect(session?.user.username).toBe("test_username");
expect(session?.user.displayUsername).toBe("Test Username");
});
});
describe("username email verification flow (no info leak)", async (it) => {
const { client } = await getTestInstance(
{
emailAndPassword: { enabled: true, requireEmailVerification: true },
plugins: [username()],
},
{
clientOptions: {
plugins: [usernameClient()],
},
},
);
it("returns INVALID_USERNAME_OR_PASSWORD for wrong password even if email is unverified", async () => {
await client.signUp.email({
email: "[email protected]",
username: "unverified_user",
password: "correct-password",
name: "Unverified User",
});
const res = await client.signIn.username({
username: "unverified_user",
password: "wrong-password",
});
expect(res.error?.status).toBe(401);
expect(res.error?.code).toBe("INVALID_USERNAME_OR_PASSWORD");
});
it("returns EMAIL_NOT_VERIFIED only after a correct password for an unverified user", async () => {
const res = await client.signIn.username({
username: "unverified_user",
password: "correct-password",
});
expect(res.error?.status).toBe(403);
expect(res.error?.code).toBe("EMAIL_NOT_VERIFIED");
});
});
```
--------------------------------------------------------------------------------
/packages/cli/src/commands/info.ts:
--------------------------------------------------------------------------------
```typescript
import { Command } from "commander";
import os from "os";
import { execSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import path from "path";
import chalk from "chalk";
import { getConfig } from "../utils/get-config";
import { getPackageInfo } from "../utils/get-package-info";
function getSystemInfo() {
const platform = os.platform();
const arch = os.arch();
const version = os.version();
const release = os.release();
const cpus = os.cpus();
const memory = os.totalmem();
const freeMemory = os.freemem();
return {
platform,
arch,
version,
release,
cpuCount: cpus.length,
cpuModel: cpus[0]?.model || "Unknown",
totalMemory: `${(memory / 1024 / 1024 / 1024).toFixed(2)} GB`,
freeMemory: `${(freeMemory / 1024 / 1024 / 1024).toFixed(2)} GB`,
};
}
function getNodeInfo() {
return {
version: process.version,
env: process.env.NODE_ENV || "development",
};
}
function getPackageManager() {
const userAgent = process.env.npm_config_user_agent || "";
if (userAgent.includes("yarn")) {
return { name: "yarn", version: getVersion("yarn") };
}
if (userAgent.includes("pnpm")) {
return { name: "pnpm", version: getVersion("pnpm") };
}
if (userAgent.includes("bun")) {
return { name: "bun", version: getVersion("bun") };
}
return { name: "npm", version: getVersion("npm") };
}
function getVersion(command: string): string {
try {
const output = execSync(`${command} --version`, { encoding: "utf8" });
return output.trim();
} catch {
return "Not installed";
}
}
function getFrameworkInfo(projectRoot: string) {
const packageJsonPath = path.join(projectRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const frameworks: Record<string, string | undefined> = {
next: deps["next"],
react: deps["react"],
vue: deps["vue"],
nuxt: deps["nuxt"],
svelte: deps["svelte"],
"@sveltejs/kit": deps["@sveltejs/kit"],
express: deps["express"],
fastify: deps["fastify"],
hono: deps["hono"],
remix: deps["@remix-run/react"],
astro: deps["astro"],
solid: deps["solid-js"],
qwik: deps["@builder.io/qwik"],
};
const installedFrameworks = Object.entries(frameworks)
.filter(([_, version]) => version)
.map(([name, version]) => ({ name, version }));
return installedFrameworks.length > 0 ? installedFrameworks : null;
} catch {
return null;
}
}
function getDatabaseInfo(projectRoot: string) {
const packageJsonPath = path.join(projectRoot, "package.json");
if (!existsSync(packageJsonPath)) {
return null;
}
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const databases: Record<string, string | undefined> = {
"better-sqlite3": deps["better-sqlite3"],
"@libsql/client": deps["@libsql/client"],
"@libsql/kysely-libsql": deps["@libsql/kysely-libsql"],
mysql2: deps["mysql2"],
pg: deps["pg"],
postgres: deps["postgres"],
"@prisma/client": deps["@prisma/client"],
drizzle: deps["drizzle-orm"],
kysely: deps["kysely"],
mongodb: deps["mongodb"],
"@neondatabase/serverless": deps["@neondatabase/serverless"],
"@vercel/postgres": deps["@vercel/postgres"],
"@planetscale/database": deps["@planetscale/database"],
};
const installedDatabases = Object.entries(databases)
.filter(([_, version]) => version)
.map(([name, version]) => ({ name, version }));
return installedDatabases.length > 0 ? installedDatabases : null;
} catch {
return null;
}
}
function sanitizeBetterAuthConfig(config: any): any {
if (!config) return null;
const sanitized = JSON.parse(JSON.stringify(config));
// List of sensitive keys to redact
const sensitiveKeys = [
"secret",
"clientSecret",
"clientId",
"authToken",
"apiKey",
"apiSecret",
"privateKey",
"publicKey",
"password",
"token",
"webhook",
"connectionString",
"databaseUrl",
"databaseURL",
"TURSO_AUTH_TOKEN",
"TURSO_DATABASE_URL",
"MYSQL_DATABASE_URL",
"DATABASE_URL",
"POSTGRES_URL",
"MONGODB_URI",
"stripeKey",
"stripeWebhookSecret",
];
// Keys that should NOT be redacted even if they contain sensitive keywords
const allowedKeys = [
"baseURL",
"callbackURL",
"redirectURL",
"trustedOrigins",
"appName",
];
function redactSensitive(obj: any, parentKey?: string): any {
if (typeof obj !== "object" || obj === null) {
// Check if the parent key is sensitive
if (parentKey && typeof obj === "string" && obj.length > 0) {
// First check if it's in the allowed list
if (
allowedKeys.some(
(allowed) => parentKey.toLowerCase() === allowed.toLowerCase(),
)
) {
return obj;
}
const lowerKey = parentKey.toLowerCase();
if (
sensitiveKeys.some((key) => {
const lowerSensitiveKey = key.toLowerCase();
// Exact match or the key ends with the sensitive key
return (
lowerKey === lowerSensitiveKey ||
lowerKey.endsWith(lowerSensitiveKey)
);
})
) {
return "[REDACTED]";
}
}
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => redactSensitive(item, parentKey));
}
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
// First check if this key is in the allowed list
if (
allowedKeys.some(
(allowed) => key.toLowerCase() === allowed.toLowerCase(),
)
) {
result[key] = value;
continue;
}
const lowerKey = key.toLowerCase();
// Check if this key should be redacted
if (
sensitiveKeys.some((sensitiveKey) => {
const lowerSensitiveKey = sensitiveKey.toLowerCase();
// Exact match or the key ends with the sensitive key
return (
lowerKey === lowerSensitiveKey ||
lowerKey.endsWith(lowerSensitiveKey)
);
})
) {
if (typeof value === "string" && value.length > 0) {
result[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
// Still recurse into objects but mark them as potentially sensitive
result[key] = redactSensitive(value, key);
} else {
result[key] = value;
}
} else {
result[key] = redactSensitive(value, key);
}
}
return result;
}
// Special handling for specific config sections
if (sanitized.database) {
// Redact database connection details
if (typeof sanitized.database === "string") {
sanitized.database = "[REDACTED]";
} else if (sanitized.database.url) {
sanitized.database.url = "[REDACTED]";
}
if (sanitized.database.authToken) {
sanitized.database.authToken = "[REDACTED]";
}
}
if (sanitized.socialProviders) {
// Redact all social provider secrets
for (const provider in sanitized.socialProviders) {
if (sanitized.socialProviders[provider]) {
sanitized.socialProviders[provider] = redactSensitive(
sanitized.socialProviders[provider],
provider,
);
}
}
}
if (sanitized.emailAndPassword?.sendResetPassword) {
sanitized.emailAndPassword.sendResetPassword = "[Function]";
}
if (sanitized.emailVerification?.sendVerificationEmail) {
sanitized.emailVerification.sendVerificationEmail = "[Function]";
}
// Redact plugin configurations
if (sanitized.plugins && Array.isArray(sanitized.plugins)) {
sanitized.plugins = sanitized.plugins.map((plugin: any) => {
if (typeof plugin === "function") {
return "[Plugin Function]";
}
if (plugin && typeof plugin === "object") {
// Get plugin name if available
const pluginName = plugin.id || plugin.name || "unknown";
return {
name: pluginName,
config: redactSensitive(plugin.config || plugin),
};
}
return plugin;
});
}
return redactSensitive(sanitized);
}
async function getBetterAuthInfo(
projectRoot: string,
configPath?: string,
suppressLogs = false,
) {
try {
// Temporarily suppress console output if needed
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
if (suppressLogs) {
console.log = () => {};
console.warn = () => {};
console.error = () => {};
}
try {
const config = await getConfig({
cwd: projectRoot,
configPath,
shouldThrowOnError: false,
});
const packageInfo = await getPackageInfo();
const betterAuthVersion =
packageInfo.dependencies?.["better-auth"] ||
packageInfo.devDependencies?.["better-auth"] ||
packageInfo.peerDependencies?.["better-auth"] ||
packageInfo.optionalDependencies?.["better-auth"] ||
"Unknown";
return {
version: betterAuthVersion,
config: sanitizeBetterAuthConfig(config),
};
} finally {
// Restore console methods
if (suppressLogs) {
console.log = originalLog;
console.warn = originalWarn;
console.error = originalError;
}
}
} catch (error) {
return {
version: "Unknown",
config: null,
error:
error instanceof Error
? error.message
: "Failed to load Better Auth config",
};
}
}
function formatOutput(data: any, indent = 0): string {
const spaces = " ".repeat(indent);
if (data === null || data === undefined) {
return `${spaces}${chalk.gray("N/A")}`;
}
if (
typeof data === "string" ||
typeof data === "number" ||
typeof data === "boolean"
) {
return `${spaces}${data}`;
}
if (Array.isArray(data)) {
if (data.length === 0) {
return `${spaces}${chalk.gray("[]")}`;
}
return data.map((item) => formatOutput(item, indent)).join("\n");
}
if (typeof data === "object") {
const entries = Object.entries(data);
if (entries.length === 0) {
return `${spaces}${chalk.gray("{}")}`;
}
return entries
.map(([key, value]) => {
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
return `${spaces}${chalk.cyan(key)}:\n${formatOutput(value, indent + 2)}`;
}
return `${spaces}${chalk.cyan(key)}: ${formatOutput(value, 0)}`;
})
.join("\n");
}
return `${spaces}${JSON.stringify(data)}`;
}
export const info = new Command("info")
.description("Display system and Better Auth configuration information")
.option("--cwd <cwd>", "The working directory", process.cwd())
.option("--config <config>", "Path to the Better Auth configuration file")
.option("-j, --json", "Output as JSON")
.option("-c, --copy", "Copy output to clipboard (requires pbcopy/xclip)")
.action(async (options) => {
const projectRoot = path.resolve(options.cwd || process.cwd());
// Collect all information
const systemInfo = getSystemInfo();
const nodeInfo = getNodeInfo();
const packageManager = getPackageManager();
const frameworks = getFrameworkInfo(projectRoot);
const databases = getDatabaseInfo(projectRoot);
const betterAuthInfo = await getBetterAuthInfo(
projectRoot,
options.config,
options.json,
);
const fullInfo = {
system: systemInfo,
node: nodeInfo,
packageManager,
frameworks,
databases,
betterAuth: betterAuthInfo,
};
if (options.json) {
const jsonOutput = JSON.stringify(fullInfo, null, 2);
console.log(jsonOutput);
if (options.copy) {
try {
const platform = os.platform();
if (platform === "darwin") {
execSync("pbcopy", { input: jsonOutput });
console.log(chalk.green("\n✓ Copied to clipboard"));
} else if (platform === "linux") {
execSync("xclip -selection clipboard", { input: jsonOutput });
console.log(chalk.green("\n✓ Copied to clipboard"));
} else if (platform === "win32") {
execSync("clip", { input: jsonOutput });
console.log(chalk.green("\n✓ Copied to clipboard"));
}
} catch {
console.log(chalk.yellow("\n⚠ Could not copy to clipboard"));
}
}
return;
}
// Format and display output
console.log(chalk.bold("\n📊 Better Auth System Information\n"));
console.log(chalk.gray("=".repeat(50)));
console.log(chalk.bold.white("\n🖥️ System Information:"));
console.log(formatOutput(systemInfo, 2));
console.log(chalk.bold.white("\n📦 Node.js:"));
console.log(formatOutput(nodeInfo, 2));
console.log(chalk.bold.white("\n📦 Package Manager:"));
console.log(formatOutput(packageManager, 2));
if (frameworks) {
console.log(chalk.bold.white("\n🚀 Frameworks:"));
console.log(formatOutput(frameworks, 2));
}
if (databases) {
console.log(chalk.bold.white("\n💾 Database Clients:"));
console.log(formatOutput(databases, 2));
}
console.log(chalk.bold.white("\n🔐 Better Auth:"));
if (betterAuthInfo.error) {
console.log(` ${chalk.red("Error:")} ${betterAuthInfo.error}`);
} else {
console.log(` ${chalk.cyan("Version")}: ${betterAuthInfo.version}`);
if (betterAuthInfo.config) {
console.log(` ${chalk.cyan("Configuration")}:`);
console.log(formatOutput(betterAuthInfo.config, 4));
}
}
console.log(chalk.gray("\n" + "=".repeat(50)));
console.log(chalk.gray("\n💡 Tip: Use --json flag for JSON output"));
console.log(chalk.gray("💡 Use --copy flag to copy output to clipboard"));
console.log(
chalk.gray("💡 When reporting issues, include this information\n"),
);
if (options.copy) {
const textOutput = `
Better Auth System Information
==============================
System Information:
${JSON.stringify(systemInfo, null, 2)}
Node.js:
${JSON.stringify(nodeInfo, null, 2)}
Package Manager:
${JSON.stringify(packageManager, null, 2)}
Frameworks:
${JSON.stringify(frameworks, null, 2)}
Database Clients:
${JSON.stringify(databases, null, 2)}
Better Auth:
${JSON.stringify(betterAuthInfo, null, 2)}
`;
try {
const platform = os.platform();
if (platform === "darwin") {
execSync("pbcopy", { input: textOutput });
console.log(chalk.green("✓ Copied to clipboard"));
} else if (platform === "linux") {
execSync("xclip -selection clipboard", { input: textOutput });
console.log(chalk.green("✓ Copied to clipboard"));
} else if (platform === "win32") {
execSync("clip", { input: textOutput });
console.log(chalk.green("✓ Copied to clipboard"));
}
} catch {
console.log(chalk.yellow("⚠ Could not copy to clipboard"));
}
}
});
```
--------------------------------------------------------------------------------
/demo/nextjs/app/dashboard/organization-card.tsx:
--------------------------------------------------------------------------------
```typescript
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
organization,
useListOrganizations,
useSession,
} from "@/lib/auth-client";
import { ActiveOrganization, Session } from "@/lib/auth-types";
import { ChevronDownIcon, PlusIcon } from "@radix-ui/react-icons";
import { Loader2, MailPlus } from "lucide-react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { AnimatePresence, motion } from "framer-motion";
import CopyButton from "@/components/ui/copy-button";
import Image from "next/image";
export function OrganizationCard(props: {
session: Session | null;
activeOrganization: ActiveOrganization | null;
}) {
const organizations = useListOrganizations();
const [optimisticOrg, setOptimisticOrg] = useState<ActiveOrganization | null>(
props.activeOrganization,
);
const [isRevoking, setIsRevoking] = useState<string[]>([]);
const inviteVariants = {
hidden: { opacity: 0, height: 0 },
visible: { opacity: 1, height: "auto" },
exit: { opacity: 0, height: 0 },
};
const { data } = useSession();
const session = data || props.session;
const currentMember = optimisticOrg?.members.find(
(member) => member.userId === session?.user.id,
);
return (
<Card>
<CardHeader>
<CardTitle>Organization</CardTitle>
<div className="flex justify-between">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center gap-1 cursor-pointer">
<p className="text-sm">
<span className="font-bold"></span>{" "}
{optimisticOrg?.name || "Personal"}
</p>
<ChevronDownIcon />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
className=" py-1"
onClick={async () => {
organization.setActive({
organizationId: null,
});
setOptimisticOrg(null);
}}
>
<p className="text-sm sm">Personal</p>
</DropdownMenuItem>
{organizations.data?.map((org) => (
<DropdownMenuItem
className=" py-1"
key={org.id}
onClick={async () => {
if (org.id === optimisticOrg?.id) {
return;
}
setOptimisticOrg({
members: [],
invitations: [],
...org,
});
const { data } = await organization.setActive({
organizationId: org.id,
});
setOptimisticOrg(data);
}}
>
<p className="text-sm sm">{org.name}</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div>
<CreateOrganizationDialog />
</div>
</div>
<div className="flex items-center gap-2">
<Avatar className="rounded-none">
<AvatarImage
className="object-cover w-full h-full rounded-none"
src={optimisticOrg?.logo || undefined}
/>
<AvatarFallback className="rounded-none">
{optimisticOrg?.name?.charAt(0) || "P"}
</AvatarFallback>
</Avatar>
<div>
<p>{optimisticOrg?.name || "Personal"}</p>
<p className="text-xs text-muted-foreground">
{optimisticOrg?.members.length || 1} members
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-8 flex-col md:flex-row">
<div className="flex flex-col gap-2 grow">
<p className="font-medium border-b-2 border-b-foreground/10">
Members
</p>
<div className="flex flex-col gap-2">
{optimisticOrg?.members.map((member) => (
<div
key={member.id}
className="flex justify-between items-center"
>
<div className="flex items-center gap-2">
<Avatar className="sm:flex w-9 h-9">
<AvatarImage
src={member.user.image || undefined}
className="object-cover"
/>
<AvatarFallback>
{member.user.name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm">{member.user.name}</p>
<p className="text-xs text-muted-foreground">
{member.role}
</p>
</div>
</div>
{member.role !== "owner" &&
(currentMember?.role === "owner" ||
currentMember?.role === "admin") && (
<Button
size="sm"
variant="destructive"
onClick={() => {
organization.removeMember({
memberIdOrEmail: member.id,
});
}}
>
{currentMember?.id === member.id ? "Leave" : "Remove"}
</Button>
)}
</div>
))}
{!optimisticOrg?.id && (
<div>
<div className="flex items-center gap-2">
<Avatar>
<AvatarImage src={session?.user.image || undefined} />
<AvatarFallback>
{session?.user.name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm">{session?.user.name}</p>
<p className="text-xs text-muted-foreground">Owner</p>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2 grow">
<p className="font-medium border-b-2 border-b-foreground/10">
Invites
</p>
<div className="flex flex-col gap-2">
<AnimatePresence>
{optimisticOrg?.invitations
.filter((invitation) => invitation.status === "pending")
.map((invitation) => (
<motion.div
key={invitation.id}
className="flex items-center justify-between"
variants={inviteVariants}
initial="hidden"
animate="visible"
exit="exit"
layout
>
<div>
<p className="text-sm">{invitation.email}</p>
<p className="text-xs text-muted-foreground">
{invitation.role}
</p>
</div>
<div className="flex items-center gap-2">
<Button
disabled={isRevoking.includes(invitation.id)}
size="sm"
variant="destructive"
onClick={() => {
organization.cancelInvitation(
{
invitationId: invitation.id,
},
{
onRequest: () => {
setIsRevoking([...isRevoking, invitation.id]);
},
onSuccess: () => {
toast.message(
"Invitation revoked successfully",
);
setIsRevoking(
isRevoking.filter(
(id) => id !== invitation.id,
),
);
setOptimisticOrg({
...optimisticOrg,
invitations:
optimisticOrg?.invitations.filter(
(inv) => inv.id !== invitation.id,
),
});
},
onError: (ctx) => {
toast.error(ctx.error.message);
setIsRevoking(
isRevoking.filter(
(id) => id !== invitation.id,
),
);
},
},
);
}}
>
{isRevoking.includes(invitation.id) ? (
<Loader2 className="animate-spin" size={16} />
) : (
"Revoke"
)}
</Button>
<div>
<CopyButton
textToCopy={`${window.location.origin}/accept-invitation/${invitation.id}`}
/>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{optimisticOrg?.invitations.length === 0 && (
<motion.p
className="text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
No Active Invitations
</motion.p>
)}
{!optimisticOrg?.id && (
<Label className="text-xs text-muted-foreground">
You can't invite members to your personal workspace.
</Label>
)}
</div>
</div>
</div>
<div className="flex justify-end w-full mt-4">
<div>
<div>
{optimisticOrg?.id && (
<InviteMemberDialog
setOptimisticOrg={setOptimisticOrg}
optimisticOrg={optimisticOrg}
/>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function CreateOrganizationDialog() {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [isSlugEdited, setIsSlugEdited] = useState(false);
const [logo, setLogo] = useState<string | null>(null);
useEffect(() => {
if (!isSlugEdited) {
const generatedSlug = name.trim().toLowerCase().replace(/\s+/g, "-");
setSlug(generatedSlug);
}
}, [name, isSlugEdited]);
useEffect(() => {
if (open) {
setName("");
setSlug("");
setIsSlugEdited(false);
setLogo(null);
}
}, [open]);
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onloadend = () => {
setLogo(reader.result as string);
};
reader.readAsDataURL(file);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="w-full gap-2" variant="default">
<PlusIcon />
<p>New Organization</p>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-11/12">
<DialogHeader>
<DialogTitle>New Organization</DialogTitle>
<DialogDescription>
Create a new organization to collaborate with your team.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Organization Name</Label>
<Input
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Organization Slug</Label>
<Input
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setIsSlugEdited(true);
}}
placeholder="Slug"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Logo</Label>
<Input type="file" accept="image/*" onChange={handleLogoChange} />
{logo && (
<div className="mt-2">
<Image
src={logo}
alt="Logo preview"
className="w-16 h-16 object-cover"
width={16}
height={16}
/>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
disabled={loading}
onClick={async () => {
setLoading(true);
await organization.create(
{
name: name,
slug: slug,
logo: logo || undefined,
},
{
onResponse: () => {
setLoading(false);
},
onSuccess: () => {
toast.success("Organization created successfully");
setOpen(false);
},
onError: (error) => {
toast.error(error.error.message);
setLoading(false);
},
},
);
}}
>
{loading ? (
<Loader2 className="animate-spin" size={16} />
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function InviteMemberDialog({
setOptimisticOrg,
optimisticOrg,
}: {
setOptimisticOrg: (org: ActiveOrganization | null) => void;
optimisticOrg: ActiveOrganization | null;
}) {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [loading, setLoading] = useState(false);
return (
<Dialog>
<DialogTrigger asChild>
<Button size="sm" className="w-full gap-2" variant="secondary">
<MailPlus size={16} />
<p>Invite Member</p>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-11/12">
<DialogHeader>
<DialogTitle>Invite Member</DialogTitle>
<DialogDescription>
Invite a member to your organization.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label>Email</Label>
<Input
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<DialogClose>
<Button
disabled={loading}
onClick={async () => {
const invite = organization.inviteMember({
email: email,
role: role as "member",
fetchOptions: {
throw: true,
onSuccess: (ctx) => {
if (optimisticOrg) {
setOptimisticOrg({
...optimisticOrg,
invitations: [
...(optimisticOrg?.invitations || []),
ctx.data,
],
});
}
},
},
});
toast.promise(invite, {
loading: "Inviting member...",
success: "Member invited successfully",
error: (error) => error.error.message,
});
}}
>
Invite
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```