This is page 36 of 71. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ ├── CODEOWNERS
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── e2e.yml
│ ├── preview.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode
│ └── settings.json
├── banner-dark.png
├── banner.png
├── biome.json
├── bump.config.ts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── demo
│ ├── expo-example
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app.config.ts
│ │ ├── assets
│ │ │ ├── bg-image.jpeg
│ │ │ ├── fonts
│ │ │ │ └── SpaceMono-Regular.ttf
│ │ │ ├── icon.png
│ │ │ └── images
│ │ │ ├── adaptive-icon.png
│ │ │ ├── favicon.png
│ │ │ ├── logo.png
│ │ │ ├── partial-react-logo.png
│ │ │ ├── react-logo.png
│ │ │ ├── [email protected]
│ │ │ ├── [email protected]
│ │ │ └── splash.png
│ │ ├── babel.config.js
│ │ ├── components.json
│ │ ├── expo-env.d.ts
│ │ ├── index.ts
│ │ ├── metro.config.js
│ │ ├── nativewind-env.d.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...route]+api.ts
│ │ │ │ ├── dashboard.tsx
│ │ │ │ ├── forget-password.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── sign-up.tsx
│ │ │ ├── components
│ │ │ │ ├── icons
│ │ │ │ │ └── google.tsx
│ │ │ │ └── ui
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ └── text.tsx
│ │ │ ├── global.css
│ │ │ └── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth.ts
│ │ │ ├── icons
│ │ │ │ ├── iconWithClassName.ts
│ │ │ │ └── X.tsx
│ │ │ └── utils.ts
│ │ ├── tailwind.config.js
│ │ └── tsconfig.json
│ ├── nextjs
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── (auth)
│ │ │ │ ├── forget-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── sign-in
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── two-factor
│ │ │ │ ├── otp
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── accept-invitation
│ │ │ │ └── [id]
│ │ │ │ ├── invitation-error.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── admin
│ │ │ │ └── page.tsx
│ │ │ ├── api
│ │ │ │ └── auth
│ │ │ │ └── [...all]
│ │ │ │ └── route.ts
│ │ │ ├── apps
│ │ │ │ └── register
│ │ │ │ └── page.tsx
│ │ │ ├── client-test
│ │ │ │ └── page.tsx
│ │ │ ├── dashboard
│ │ │ │ ├── change-plan.tsx
│ │ │ │ ├── client.tsx
│ │ │ │ ├── organization-card.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── upgrade-button.tsx
│ │ │ │ └── user-card.tsx
│ │ │ ├── device
│ │ │ │ ├── approve
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── denied
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── success
│ │ │ │ └── page.tsx
│ │ │ ├── favicon.ico
│ │ │ ├── features.tsx
│ │ │ ├── fonts
│ │ │ │ ├── GeistMonoVF.woff
│ │ │ │ └── GeistVF.woff
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── oauth
│ │ │ │ └── authorize
│ │ │ │ ├── concet-buttons.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── pricing
│ │ │ └── page.tsx
│ │ ├── components
│ │ │ ├── account-switch.tsx
│ │ │ ├── blocks
│ │ │ │ └── pricing.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── one-tap.tsx
│ │ │ ├── sign-in-btn.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── tier-labels.tsx
│ │ │ ├── ui
│ │ │ │ ├── accordion.tsx
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── aspect-ratio.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── calendar.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── carousel.tsx
│ │ │ │ ├── chart.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── context-menu.tsx
│ │ │ │ ├── copy-button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ ├── hover-card.tsx
│ │ │ │ ├── input-otp.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── menubar.tsx
│ │ │ │ ├── navigation-menu.tsx
│ │ │ │ ├── pagination.tsx
│ │ │ │ ├── password-input.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── resizable.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ ├── sonner.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ ├── tabs2.tsx
│ │ │ │ ├── textarea.tsx
│ │ │ │ ├── toast.tsx
│ │ │ │ ├── toaster.tsx
│ │ │ │ ├── toggle-group.tsx
│ │ │ │ ├── toggle.tsx
│ │ │ │ └── tooltip.tsx
│ │ │ └── wrapper.tsx
│ │ ├── components.json
│ │ ├── hooks
│ │ │ └── use-toast.ts
│ │ ├── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth-types.ts
│ │ │ ├── auth.ts
│ │ │ ├── email
│ │ │ │ ├── invitation.tsx
│ │ │ │ ├── resend.ts
│ │ │ │ └── reset-password.tsx
│ │ │ ├── metadata.ts
│ │ │ ├── shared.ts
│ │ │ └── utils.ts
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── proxy.ts
│ │ ├── public
│ │ │ ├── __og.png
│ │ │ ├── _og.png
│ │ │ ├── favicon
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── light
│ │ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ │ ├── apple-touch-icon.png
│ │ │ │ │ ├── favicon-16x16.png
│ │ │ │ │ ├── favicon-32x32.png
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ └── site.webmanifest
│ │ │ │ └── site.webmanifest
│ │ │ ├── logo.svg
│ │ │ └── og.png
│ │ ├── README.md
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── turbo.json
│ └── stateless
│ ├── .env.example
│ ├── .gitignore
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src
│ │ ├── app
│ │ │ ├── api
│ │ │ │ ├── auth
│ │ │ │ │ └── [...all]
│ │ │ │ │ └── route.ts
│ │ │ │ └── user
│ │ │ │ └── route.ts
│ │ │ ├── dashboard
│ │ │ │ └── page.tsx
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ └── lib
│ │ ├── auth-client.ts
│ │ └── auth.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── docker-compose.yml
├── docs
│ ├── .env.example
│ ├── .gitignore
│ ├── app
│ │ ├── api
│ │ │ ├── ai-chat
│ │ │ │ └── route.ts
│ │ │ ├── analytics
│ │ │ │ ├── conversation
│ │ │ │ │ └── route.ts
│ │ │ │ ├── event
│ │ │ │ │ └── route.ts
│ │ │ │ └── feedback
│ │ │ │ └── route.ts
│ │ │ ├── chat
│ │ │ │ └── route.ts
│ │ │ ├── og
│ │ │ │ └── route.tsx
│ │ │ ├── og-release
│ │ │ │ └── route.tsx
│ │ │ ├── search
│ │ │ │ └── route.ts
│ │ │ └── support
│ │ │ └── route.ts
│ │ ├── blog
│ │ │ ├── _components
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── blog-list.tsx
│ │ │ │ ├── changelog-layout.tsx
│ │ │ │ ├── default-changelog.tsx
│ │ │ │ ├── fmt-dates.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ ├── stat-field.tsx
│ │ │ │ └── support.tsx
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── changelogs
│ │ │ ├── _components
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── changelog-layout.tsx
│ │ │ │ ├── default-changelog.tsx
│ │ │ │ ├── fmt-dates.tsx
│ │ │ │ ├── grid-pattern.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ └── stat-field.tsx
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── community
│ │ │ ├── _components
│ │ │ │ ├── header.tsx
│ │ │ │ └── stats.tsx
│ │ │ └── page.tsx
│ │ ├── docs
│ │ │ ├── [[...slug]]
│ │ │ │ ├── page.client.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── lib
│ │ │ └── get-llm-text.ts
│ │ ├── global.css
│ │ ├── layout.config.tsx
│ │ ├── layout.tsx
│ │ ├── llms.txt
│ │ │ ├── [...slug]
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ ├── reference
│ │ │ └── route.ts
│ │ ├── sitemap.xml
│ │ ├── static.json
│ │ │ └── route.ts
│ │ └── v1
│ │ ├── _components
│ │ │ └── v1-text.tsx
│ │ ├── bg-line.tsx
│ │ └── page.tsx
│ ├── assets
│ │ ├── Geist.ttf
│ │ └── GeistMono.ttf
│ ├── components
│ │ ├── ai-chat-modal.tsx
│ │ ├── anchor-scroll-fix.tsx
│ │ ├── api-method-tabs.tsx
│ │ ├── api-method.tsx
│ │ ├── banner.tsx
│ │ ├── blocks
│ │ │ └── features.tsx
│ │ ├── builder
│ │ │ ├── beam.tsx
│ │ │ ├── code-tabs
│ │ │ │ ├── code-editor.tsx
│ │ │ │ ├── code-tabs.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── tab-bar.tsx
│ │ │ │ └── theme.ts
│ │ │ ├── index.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── social-provider.tsx
│ │ │ ├── store.ts
│ │ │ └── tabs.tsx
│ │ ├── display-techstack.tsx
│ │ ├── divider-text.tsx
│ │ ├── docs
│ │ │ ├── docs.client.tsx
│ │ │ ├── docs.tsx
│ │ │ ├── layout
│ │ │ │ ├── nav.tsx
│ │ │ │ ├── theme-toggle.tsx
│ │ │ │ ├── toc-thumb.tsx
│ │ │ │ └── toc.tsx
│ │ │ ├── page.client.tsx
│ │ │ ├── page.tsx
│ │ │ ├── shared.tsx
│ │ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── popover.tsx
│ │ │ └── scroll-area.tsx
│ │ ├── endpoint.tsx
│ │ ├── features.tsx
│ │ ├── floating-ai-search.tsx
│ │ ├── fork-button.tsx
│ │ ├── generate-apple-jwt.tsx
│ │ ├── generate-secret.tsx
│ │ ├── github-stat.tsx
│ │ ├── icons.tsx
│ │ ├── landing
│ │ │ ├── gradient-bg.tsx
│ │ │ ├── grid-pattern.tsx
│ │ │ ├── hero.tsx
│ │ │ ├── section-svg.tsx
│ │ │ ├── section.tsx
│ │ │ ├── spotlight.tsx
│ │ │ └── testimonials.tsx
│ │ ├── logo-context-menu.tsx
│ │ ├── logo.tsx
│ │ ├── markdown-renderer.tsx
│ │ ├── markdown.tsx
│ │ ├── mdx
│ │ │ ├── add-to-cursor.tsx
│ │ │ └── database-tables.tsx
│ │ ├── message-feedback.tsx
│ │ ├── mobile-search-icon.tsx
│ │ ├── nav-bar.tsx
│ │ ├── nav-link.tsx
│ │ ├── nav-mobile.tsx
│ │ ├── promo-card.tsx
│ │ ├── resource-card.tsx
│ │ ├── resource-grid.tsx
│ │ ├── resource-section.tsx
│ │ ├── ripple.tsx
│ │ ├── search-dialog.tsx
│ │ ├── side-bar.tsx
│ │ ├── sidebar-content.tsx
│ │ ├── techstack-icons.tsx
│ │ ├── theme-provider.tsx
│ │ ├── theme-toggler.tsx
│ │ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── aside-link.tsx
│ │ ├── aspect-ratio.tsx
│ │ ├── avatar.tsx
│ │ ├── background-beams.tsx
│ │ ├── background-boxes.tsx
│ │ ├── badge.tsx
│ │ ├── breadcrumb.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── callout.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── chart.tsx
│ │ ├── checkbox.tsx
│ │ ├── code-block.tsx
│ │ ├── collapsible.tsx
│ │ ├── command.tsx
│ │ ├── context-menu.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── dynamic-code-block.tsx
│ │ ├── fade-in.tsx
│ │ ├── form.tsx
│ │ ├── hover-card.tsx
│ │ ├── input-otp.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── menubar.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── pagination.tsx
│ │ ├── popover.tsx
│ │ ├── progress.tsx
│ │ ├── radio-group.tsx
│ │ ├── resizable.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── sidebar.tsx
│ │ ├── skeleton.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── sparkles.tsx
│ │ ├── switch.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toggle-group.tsx
│ │ ├── toggle.tsx
│ │ ├── tooltip-docs.tsx
│ │ ├── tooltip.tsx
│ │ └── use-copy-button.tsx
│ ├── components.json
│ ├── content
│ │ ├── blogs
│ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx
│ │ │ ├── 1-3.mdx
│ │ │ ├── authjs-joins-better-auth.mdx
│ │ │ └── seed-round.mdx
│ │ ├── changelogs
│ │ │ ├── 1-2.mdx
│ │ │ └── 1.0.mdx
│ │ └── docs
│ │ ├── adapters
│ │ │ ├── community-adapters.mdx
│ │ │ ├── drizzle.mdx
│ │ │ ├── mongo.mdx
│ │ │ ├── mssql.mdx
│ │ │ ├── mysql.mdx
│ │ │ ├── other-relational-databases.mdx
│ │ │ ├── postgresql.mdx
│ │ │ ├── prisma.mdx
│ │ │ └── sqlite.mdx
│ │ ├── authentication
│ │ │ ├── apple.mdx
│ │ │ ├── atlassian.mdx
│ │ │ ├── cognito.mdx
│ │ │ ├── discord.mdx
│ │ │ ├── dropbox.mdx
│ │ │ ├── email-password.mdx
│ │ │ ├── facebook.mdx
│ │ │ ├── figma.mdx
│ │ │ ├── github.mdx
│ │ │ ├── gitlab.mdx
│ │ │ ├── google.mdx
│ │ │ ├── huggingface.mdx
│ │ │ ├── kakao.mdx
│ │ │ ├── kick.mdx
│ │ │ ├── line.mdx
│ │ │ ├── linear.mdx
│ │ │ ├── linkedin.mdx
│ │ │ ├── microsoft.mdx
│ │ │ ├── naver.mdx
│ │ │ ├── notion.mdx
│ │ │ ├── other-social-providers.mdx
│ │ │ ├── paypal.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── reddit.mdx
│ │ │ ├── roblox.mdx
│ │ │ ├── salesforce.mdx
│ │ │ ├── slack.mdx
│ │ │ ├── spotify.mdx
│ │ │ ├── tiktok.mdx
│ │ │ ├── twitch.mdx
│ │ │ ├── twitter.mdx
│ │ │ ├── vk.mdx
│ │ │ └── zoom.mdx
│ │ ├── basic-usage.mdx
│ │ ├── comparison.mdx
│ │ ├── concepts
│ │ │ ├── api.mdx
│ │ │ ├── cli.mdx
│ │ │ ├── client.mdx
│ │ │ ├── cookies.mdx
│ │ │ ├── database.mdx
│ │ │ ├── email.mdx
│ │ │ ├── hooks.mdx
│ │ │ ├── oauth.mdx
│ │ │ ├── plugins.mdx
│ │ │ ├── rate-limit.mdx
│ │ │ ├── session-management.mdx
│ │ │ ├── typescript.mdx
│ │ │ └── users-accounts.mdx
│ │ ├── examples
│ │ │ ├── astro.mdx
│ │ │ ├── next-js.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ └── svelte-kit.mdx
│ │ ├── guides
│ │ │ ├── auth0-migration-guide.mdx
│ │ │ ├── browser-extension-guide.mdx
│ │ │ ├── clerk-migration-guide.mdx
│ │ │ ├── create-a-db-adapter.mdx
│ │ │ ├── next-auth-migration-guide.mdx
│ │ │ ├── optimizing-for-performance.mdx
│ │ │ ├── saml-sso-with-okta.mdx
│ │ │ ├── supabase-migration-guide.mdx
│ │ │ └── your-first-plugin.mdx
│ │ ├── installation.mdx
│ │ ├── integrations
│ │ │ ├── astro.mdx
│ │ │ ├── convex.mdx
│ │ │ ├── elysia.mdx
│ │ │ ├── expo.mdx
│ │ │ ├── express.mdx
│ │ │ ├── fastify.mdx
│ │ │ ├── hono.mdx
│ │ │ ├── lynx.mdx
│ │ │ ├── nestjs.mdx
│ │ │ ├── next.mdx
│ │ │ ├── nitro.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ ├── solid-start.mdx
│ │ │ ├── svelte-kit.mdx
│ │ │ ├── tanstack.mdx
│ │ │ └── waku.mdx
│ │ ├── introduction.mdx
│ │ ├── meta.json
│ │ ├── plugins
│ │ │ ├── 2fa.mdx
│ │ │ ├── admin.mdx
│ │ │ ├── anonymous.mdx
│ │ │ ├── api-key.mdx
│ │ │ ├── autumn.mdx
│ │ │ ├── bearer.mdx
│ │ │ ├── captcha.mdx
│ │ │ ├── community-plugins.mdx
│ │ │ ├── device-authorization.mdx
│ │ │ ├── dodopayments.mdx
│ │ │ ├── dub.mdx
│ │ │ ├── email-otp.mdx
│ │ │ ├── generic-oauth.mdx
│ │ │ ├── have-i-been-pwned.mdx
│ │ │ ├── jwt.mdx
│ │ │ ├── last-login-method.mdx
│ │ │ ├── magic-link.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── multi-session.mdx
│ │ │ ├── oauth-proxy.mdx
│ │ │ ├── oidc-provider.mdx
│ │ │ ├── one-tap.mdx
│ │ │ ├── one-time-token.mdx
│ │ │ ├── open-api.mdx
│ │ │ ├── organization.mdx
│ │ │ ├── passkey.mdx
│ │ │ ├── phone-number.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── siwe.mdx
│ │ │ ├── sso.mdx
│ │ │ ├── stripe.mdx
│ │ │ └── username.mdx
│ │ └── reference
│ │ ├── contributing.mdx
│ │ ├── faq.mdx
│ │ ├── options.mdx
│ │ ├── resources.mdx
│ │ ├── security.mdx
│ │ └── telemetry.mdx
│ ├── hooks
│ │ └── use-mobile.ts
│ ├── ignore-build.sh
│ ├── lib
│ │ ├── blog.ts
│ │ ├── chat
│ │ │ └── inkeep-qa-schema.ts
│ │ ├── constants.ts
│ │ ├── export-search-indexes.ts
│ │ ├── inkeep-analytics.ts
│ │ ├── is-active.ts
│ │ ├── metadata.ts
│ │ ├── source.ts
│ │ └── utils.ts
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── proxy.ts
│ ├── public
│ │ ├── avatars
│ │ │ └── beka.jpg
│ │ ├── blogs
│ │ │ ├── authjs-joins.png
│ │ │ ├── seed-round.png
│ │ │ └── supabase-ps.png
│ │ ├── branding
│ │ │ ├── better-auth-brand-assets.zip
│ │ │ ├── better-auth-logo-dark.png
│ │ │ ├── better-auth-logo-dark.svg
│ │ │ ├── better-auth-logo-light.png
│ │ │ ├── better-auth-logo-light.svg
│ │ │ ├── better-auth-logo-wordmark-dark.png
│ │ │ ├── better-auth-logo-wordmark-dark.svg
│ │ │ ├── better-auth-logo-wordmark-light.png
│ │ │ └── better-auth-logo-wordmark-light.svg
│ │ ├── extension-id.png
│ │ ├── favicon
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── favicon.ico
│ │ │ ├── light
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ └── site.webmanifest
│ │ │ └── site.webmanifest
│ │ ├── images
│ │ │ └── blogs
│ │ │ └── better auth (1).png
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ ├── LogoDark.webp
│ │ ├── LogoLight.webp
│ │ ├── og.png
│ │ ├── open-api-reference.png
│ │ ├── people-say
│ │ │ ├── code-with-antonio.jpg
│ │ │ ├── dagmawi-babi.png
│ │ │ ├── dax.png
│ │ │ ├── dev-ed.png
│ │ │ ├── egoist.png
│ │ │ ├── guillermo-rauch.png
│ │ │ ├── jonathan-wilke.png
│ │ │ ├── josh-tried-coding.jpg
│ │ │ ├── kitze.jpg
│ │ │ ├── lazar-nikolov.png
│ │ │ ├── nizzy.png
│ │ │ ├── omar-mcadam.png
│ │ │ ├── ryan-vogel.jpg
│ │ │ ├── saltyatom.jpg
│ │ │ ├── sebastien-chopin.png
│ │ │ ├── shreyas-mididoddi.png
│ │ │ ├── tech-nerd.png
│ │ │ ├── theo.png
│ │ │ ├── vybhav-bhargav.png
│ │ │ └── xavier-pladevall.jpg
│ │ ├── plus.svg
│ │ ├── release-og
│ │ │ ├── 1-2.png
│ │ │ ├── 1-3.png
│ │ │ └── changelog-og.png
│ │ └── v1-og.png
│ ├── README.md
│ ├── scripts
│ │ ├── endpoint-to-doc
│ │ │ ├── index.ts
│ │ │ ├── input.ts
│ │ │ ├── output.mdx
│ │ │ └── readme.md
│ │ └── sync-orama.ts
│ ├── source.config.ts
│ ├── tsconfig.json
│ └── turbo.json
├── e2e
│ ├── integration
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── solid-vinxi
│ │ │ ├── .gitignore
│ │ │ ├── app.config.ts
│ │ │ ├── e2e
│ │ │ │ ├── test.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── package.json
│ │ │ ├── public
│ │ │ │ └── favicon.ico
│ │ │ ├── src
│ │ │ │ ├── app.tsx
│ │ │ │ ├── entry-client.tsx
│ │ │ │ ├── entry-server.tsx
│ │ │ │ ├── global.d.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── auth-client.ts
│ │ │ │ │ └── auth.ts
│ │ │ │ └── routes
│ │ │ │ ├── [...404].tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...all].ts
│ │ │ │ └── index.tsx
│ │ │ └── tsconfig.json
│ │ ├── test-utils
│ │ │ ├── package.json
│ │ │ └── src
│ │ │ └── playwright.ts
│ │ └── vanilla-node
│ │ ├── e2e
│ │ │ ├── app.ts
│ │ │ ├── domain.spec.ts
│ │ │ ├── postgres-js.spec.ts
│ │ │ ├── test.spec.ts
│ │ │ └── utils.ts
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── main.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── smoke
│ ├── package.json
│ ├── test
│ │ ├── bun.spec.ts
│ │ ├── cloudflare.spec.ts
│ │ ├── deno.spec.ts
│ │ ├── fixtures
│ │ │ ├── bun-simple.ts
│ │ │ ├── cloudflare
│ │ │ │ ├── .gitignore
│ │ │ │ ├── drizzle
│ │ │ │ │ ├── 0000_clean_vector.sql
│ │ │ │ │ └── meta
│ │ │ │ │ ├── _journal.json
│ │ │ │ │ └── 0000_snapshot.json
│ │ │ │ ├── drizzle.config.ts
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── auth-schema.ts
│ │ │ │ │ ├── db.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test
│ │ │ │ │ ├── apply-migrations.ts
│ │ │ │ │ ├── env.d.ts
│ │ │ │ │ └── index.test.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.ts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ └── wrangler.json
│ │ │ ├── deno-simple.ts
│ │ │ ├── tsconfig-declaration
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── demo.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-exact-optional-property-types
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── user-additional-fields.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-isolated-module-bundler
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-verbatim-module-syntax-node10
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── vite
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── client.ts
│ │ │ │ └── server.ts
│ │ │ ├── tsconfig.json
│ │ │ └── vite.config.ts
│ │ ├── ssr.ts
│ │ ├── typecheck.spec.ts
│ │ └── vite.spec.ts
│ └── tsconfig.json
├── LICENSE.md
├── package.json
├── packages
│ ├── better-auth
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── __snapshots__
│ │ │ │ └── init.test.ts.snap
│ │ │ ├── adapters
│ │ │ │ ├── adapter-factory
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ │ └── adapter-factory.test.ts.snap
│ │ │ │ │ │ └── adapter-factory.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── create-test-suite.ts
│ │ │ │ ├── drizzle-adapter
│ │ │ │ │ ├── drizzle-adapter.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── adapter.drizzle.mysql.test.ts
│ │ │ │ │ ├── adapter.drizzle.pg.test.ts
│ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts
│ │ │ │ │ └── generate-schema.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely-adapter
│ │ │ │ │ ├── bun-sqlite-dialect.ts
│ │ │ │ │ ├── dialect.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── kysely-adapter.ts
│ │ │ │ │ ├── node-sqlite-dialect.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg-custom-schema.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg.test.ts
│ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts
│ │ │ │ │ │ └── node-sqlite-dialect.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── memory-adapter
│ │ │ │ │ ├── adapter.memory.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── memory-adapter.ts
│ │ │ │ ├── mongodb-adapter
│ │ │ │ │ ├── adapter.mongo-db.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mongodb-adapter.ts
│ │ │ │ ├── prisma-adapter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prisma-adapter.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── base.prisma
│ │ │ │ │ ├── generate-auth-config.ts
│ │ │ │ │ ├── generate-prisma-schema.ts
│ │ │ │ │ ├── get-prisma-client.ts
│ │ │ │ │ ├── prisma.mysql.test.ts
│ │ │ │ │ ├── prisma.pg.test.ts
│ │ │ │ │ ├── prisma.sqlite.test.ts
│ │ │ │ │ └── push-prisma-schema.ts
│ │ │ │ ├── test-adapter.ts
│ │ │ │ ├── test.ts
│ │ │ │ ├── tests
│ │ │ │ │ ├── auth-flow.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── normal.ts
│ │ │ │ │ ├── number-id.ts
│ │ │ │ │ ├── performance.ts
│ │ │ │ │ └── transactions.ts
│ │ │ │ └── utils.ts
│ │ │ ├── api
│ │ │ │ ├── check-endpoint-conflicts.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── middlewares
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── origin-check.test.ts
│ │ │ │ │ └── origin-check.ts
│ │ │ │ ├── rate-limiter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── rate-limiter.test.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── account.test.ts
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── callback.ts
│ │ │ │ │ ├── email-verification.test.ts
│ │ │ │ │ ├── email-verification.ts
│ │ │ │ │ ├── error.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── ok.ts
│ │ │ │ │ ├── reset-password.test.ts
│ │ │ │ │ ├── reset-password.ts
│ │ │ │ │ ├── session-api.test.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── sign-in.test.ts
│ │ │ │ │ ├── sign-in.ts
│ │ │ │ │ ├── sign-out.test.ts
│ │ │ │ │ ├── sign-out.ts
│ │ │ │ │ ├── sign-up.test.ts
│ │ │ │ │ ├── sign-up.ts
│ │ │ │ │ ├── update-user.test.ts
│ │ │ │ │ └── update-user.ts
│ │ │ │ ├── to-auth-endpoints.test.ts
│ │ │ │ └── to-auth-endpoints.ts
│ │ │ ├── auth.test.ts
│ │ │ ├── auth.ts
│ │ │ ├── call.test.ts
│ │ │ ├── client
│ │ │ │ ├── client-ssr.test.ts
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── fetch-plugins.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lynx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lynx-store.ts
│ │ │ │ ├── parser.ts
│ │ │ │ ├── path-to-object.ts
│ │ │ │ ├── plugins
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── infer-plugin.ts
│ │ │ │ ├── proxy.ts
│ │ │ │ ├── query.ts
│ │ │ │ ├── react
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── react-store.ts
│ │ │ │ ├── session-atom.ts
│ │ │ │ ├── solid
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── solid-store.ts
│ │ │ │ ├── svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test-plugin.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── url.test.ts
│ │ │ │ ├── vanilla.ts
│ │ │ │ └── vue
│ │ │ │ ├── index.ts
│ │ │ │ └── vue-store.ts
│ │ │ ├── cookies
│ │ │ │ ├── check-cookies.ts
│ │ │ │ ├── cookie-utils.ts
│ │ │ │ ├── cookies.test.ts
│ │ │ │ └── index.ts
│ │ │ ├── crypto
│ │ │ │ ├── buffer.ts
│ │ │ │ ├── hash.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt.ts
│ │ │ │ ├── password.test.ts
│ │ │ │ ├── password.ts
│ │ │ │ └── random.ts
│ │ │ ├── db
│ │ │ │ ├── db.test.ts
│ │ │ │ ├── field.ts
│ │ │ │ ├── get-migration-schema.test.ts
│ │ │ │ ├── get-migration.ts
│ │ │ │ ├── get-schema.ts
│ │ │ │ ├── get-tables.test.ts
│ │ │ │ ├── get-tables.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── internal-adapter.test.ts
│ │ │ │ ├── internal-adapter.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── secondary-storage.test.ts
│ │ │ │ ├── to-zod.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── with-hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── init.test.ts
│ │ │ ├── init.ts
│ │ │ ├── integrations
│ │ │ │ ├── next-js.ts
│ │ │ │ ├── node.ts
│ │ │ │ ├── react-start.ts
│ │ │ │ ├── solid-start.ts
│ │ │ │ └── svelte-kit.ts
│ │ │ ├── oauth2
│ │ │ │ ├── index.ts
│ │ │ │ ├── link-account.test.ts
│ │ │ │ ├── link-account.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── utils.ts
│ │ │ ├── plugins
│ │ │ │ ├── access
│ │ │ │ │ ├── access.test.ts
│ │ │ │ │ ├── access.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── additional-fields
│ │ │ │ │ ├── additional-fields.test.ts
│ │ │ │ │ └── client.ts
│ │ │ │ ├── admin
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── admin.test.ts
│ │ │ │ │ ├── admin.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── anonymous
│ │ │ │ │ ├── anon.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── api-key
│ │ │ │ │ ├── api-key.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── create-api-key.ts
│ │ │ │ │ │ ├── delete-all-expired-api-keys.ts
│ │ │ │ │ │ ├── delete-api-key.ts
│ │ │ │ │ │ ├── get-api-key.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── list-api-keys.ts
│ │ │ │ │ │ ├── update-api-key.ts
│ │ │ │ │ │ └── verify-api-key.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── bearer
│ │ │ │ │ ├── bearer.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── captcha
│ │ │ │ │ ├── captcha.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-handlers
│ │ │ │ │ ├── captchafox.ts
│ │ │ │ │ ├── cloudflare-turnstile.ts
│ │ │ │ │ ├── google-recaptcha.ts
│ │ │ │ │ ├── h-captcha.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── custom-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-session.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── device-authorization
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── device-authorization.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── email-otp
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── email-otp.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── generic-oauth
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── generic-oauth.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── haveibeenpwned
│ │ │ │ │ ├── haveibeenpwned.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── jwt.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── sign.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── last-login-method
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-prefix.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── last-login-method.test.ts
│ │ │ │ ├── magic-link
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── magic-link.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── mcp
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mcp.test.ts
│ │ │ │ ├── multi-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── multi-session.test.ts
│ │ │ │ ├── oauth-proxy
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── oauth-proxy.test.ts
│ │ │ │ ├── oidc-provider
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── oidc.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── ui.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── one-tap
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── one-time-token
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── one-time-token.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── open-api
│ │ │ │ │ ├── generator.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── logo.ts
│ │ │ │ │ └── open-api.test.ts
│ │ │ │ ├── organization
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── call.ts
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization-hook.test.ts
│ │ │ │ │ ├── organization.test.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── permission.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── crud-access-control.test.ts
│ │ │ │ │ │ ├── crud-access-control.ts
│ │ │ │ │ │ ├── crud-invites.ts
│ │ │ │ │ │ ├── crud-members.test.ts
│ │ │ │ │ │ ├── crud-members.ts
│ │ │ │ │ │ ├── crud-org.test.ts
│ │ │ │ │ │ ├── crud-org.ts
│ │ │ │ │ │ └── crud-team.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── team.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── passkey
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── passkey.test.ts
│ │ │ │ ├── phone-number
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── phone-number-error.ts
│ │ │ │ │ └── phone-number.test.ts
│ │ │ │ ├── siwe
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── siwe.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── two-factor
│ │ │ │ │ ├── backup-codes
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── constant.ts
│ │ │ │ │ ├── error-code.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── otp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── totp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── two-factor.test.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-two-factor.ts
│ │ │ │ └── username
│ │ │ │ ├── client.ts
│ │ │ │ ├── error-codes.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── username.test.ts
│ │ │ ├── social-providers
│ │ │ │ └── index.ts
│ │ │ ├── social.test.ts
│ │ │ ├── test-utils
│ │ │ │ ├── headers.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── test-instance.ts
│ │ │ ├── types
│ │ │ │ ├── adapter.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models.ts
│ │ │ │ ├── plugins.ts
│ │ │ │ └── types.test.ts
│ │ │ └── utils
│ │ │ ├── await-object.ts
│ │ │ ├── boolean.ts
│ │ │ ├── clone.ts
│ │ │ ├── constants.ts
│ │ │ ├── date.ts
│ │ │ ├── ensure-utc.ts
│ │ │ ├── get-request-ip.ts
│ │ │ ├── hashing.ts
│ │ │ ├── hide-metadata.ts
│ │ │ ├── id.ts
│ │ │ ├── import-util.ts
│ │ │ ├── index.ts
│ │ │ ├── is-atom.ts
│ │ │ ├── is-promise.ts
│ │ │ ├── json.ts
│ │ │ ├── merger.ts
│ │ │ ├── middleware-response.ts
│ │ │ ├── misc.ts
│ │ │ ├── password.ts
│ │ │ ├── plugin-helper.ts
│ │ │ ├── shim.ts
│ │ │ ├── time.ts
│ │ │ ├── url.ts
│ │ │ └── wildcard.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ ├── vitest.config.ts
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── commands
│ │ │ │ ├── generate.ts
│ │ │ │ ├── info.ts
│ │ │ │ ├── init.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── mcp.ts
│ │ │ │ ├── migrate.ts
│ │ │ │ └── secret.ts
│ │ │ ├── generators
│ │ │ │ ├── auth-config.ts
│ │ │ │ ├── drizzle.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── types.ts
│ │ │ ├── index.ts
│ │ │ └── utils
│ │ │ ├── add-svelte-kit-env-modules.ts
│ │ │ ├── check-package-managers.ts
│ │ │ ├── format-ms.ts
│ │ │ ├── get-config.ts
│ │ │ ├── get-package-info.ts
│ │ │ ├── get-tsconfig-info.ts
│ │ │ └── install-dependencies.ts
│ │ ├── test
│ │ │ ├── __snapshots__
│ │ │ │ ├── auth-schema-mysql-enum.txt
│ │ │ │ ├── auth-schema-mysql-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey.txt
│ │ │ │ ├── auth-schema-mysql.txt
│ │ │ │ ├── auth-schema-number-id.txt
│ │ │ │ ├── auth-schema-pg-enum.txt
│ │ │ │ ├── auth-schema-pg-passkey.txt
│ │ │ │ ├── auth-schema-sqlite-enum.txt
│ │ │ │ ├── auth-schema-sqlite-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey.txt
│ │ │ │ ├── auth-schema-sqlite.txt
│ │ │ │ ├── auth-schema.txt
│ │ │ │ ├── migrations.sql
│ │ │ │ ├── schema-mongodb.prisma
│ │ │ │ ├── schema-mysql-custom.prisma
│ │ │ │ ├── schema-mysql.prisma
│ │ │ │ ├── schema-numberid.prisma
│ │ │ │ └── schema.prisma
│ │ │ ├── generate-all-db.test.ts
│ │ │ ├── generate.test.ts
│ │ │ ├── get-config.test.ts
│ │ │ ├── info.test.ts
│ │ │ └── migrate.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── core
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── api
│ │ │ │ └── index.ts
│ │ │ ├── async_hooks
│ │ │ │ └── index.ts
│ │ │ ├── context
│ │ │ │ ├── endpoint-context.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── transaction.ts
│ │ │ ├── db
│ │ │ │ ├── adapter
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── plugin.ts
│ │ │ │ ├── schema
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── user.ts
│ │ │ │ │ └── verification.ts
│ │ │ │ └── type.ts
│ │ │ ├── env
│ │ │ │ ├── color-depth.ts
│ │ │ │ ├── env-impl.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.test.ts
│ │ │ │ └── logger.ts
│ │ │ ├── error
│ │ │ │ ├── codes.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── oauth2
│ │ │ │ ├── client-credentials-token.ts
│ │ │ │ ├── create-authorization-url.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── oauth-provider.ts
│ │ │ │ ├── refresh-access-token.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── validate-authorization-code.ts
│ │ │ ├── social-providers
│ │ │ │ ├── apple.ts
│ │ │ │ ├── atlassian.ts
│ │ │ │ ├── cognito.ts
│ │ │ │ ├── discord.ts
│ │ │ │ ├── dropbox.ts
│ │ │ │ ├── facebook.ts
│ │ │ │ ├── figma.ts
│ │ │ │ ├── github.ts
│ │ │ │ ├── gitlab.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── huggingface.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kakao.ts
│ │ │ │ ├── kick.ts
│ │ │ │ ├── line.ts
│ │ │ │ ├── linear.ts
│ │ │ │ ├── linkedin.ts
│ │ │ │ ├── microsoft-entra-id.ts
│ │ │ │ ├── naver.ts
│ │ │ │ ├── notion.ts
│ │ │ │ ├── paypal.ts
│ │ │ │ ├── polar.ts
│ │ │ │ ├── reddit.ts
│ │ │ │ ├── roblox.ts
│ │ │ │ ├── salesforce.ts
│ │ │ │ ├── slack.ts
│ │ │ │ ├── spotify.ts
│ │ │ │ ├── tiktok.ts
│ │ │ │ ├── twitch.ts
│ │ │ │ ├── twitter.ts
│ │ │ │ ├── vk.ts
│ │ │ │ └── zoom.ts
│ │ │ ├── types
│ │ │ │ ├── context.ts
│ │ │ │ ├── cookie.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── init-options.ts
│ │ │ │ ├── plugin-client.ts
│ │ │ │ └── plugin.ts
│ │ │ └── utils
│ │ │ ├── error-codes.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── expo
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── expo.test.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsdown.config.ts
│ ├── sso
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── index.ts
│ │ │ ├── oidc.test.ts
│ │ │ └── saml.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── stripe
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── stripe.test.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── telemetry
│ ├── package.json
│ ├── src
│ │ ├── detectors
│ │ │ ├── detect-auth-config.ts
│ │ │ ├── detect-database.ts
│ │ │ ├── detect-framework.ts
│ │ │ ├── detect-project-info.ts
│ │ │ ├── detect-runtime.ts
│ │ │ └── detect-system-info.ts
│ │ ├── index.ts
│ │ ├── project-id.ts
│ │ ├── telemetry.test.ts
│ │ ├── types.ts
│ │ └── utils
│ │ ├── hash.ts
│ │ ├── id.ts
│ │ ├── import-util.ts
│ │ └── package-json.ts
│ ├── tsconfig.json
│ └── tsdown.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── SECURITY.md
├── tsconfig.base.json
├── tsconfig.json
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it, vi } from "vitest";
2 | import { getTestInstance } from "../../test-utils/test-instance";
3 | import { $deviceAuthorizationOptionsSchema, deviceAuthorization } from ".";
4 | import { deviceAuthorizationClient } from "./client";
5 | import type { DeviceCode } from "./schema";
6 |
7 | describe("device authorization plugin input validation", () => {
8 | it("basic validation", async () => {
9 | const options = $deviceAuthorizationOptionsSchema.parse({});
10 | expect(options).toMatchInlineSnapshot(`
11 | {
12 | "deviceCodeLength": 40,
13 | "expiresIn": "30m",
14 | "interval": "5s",
15 | "userCodeLength": 8,
16 | }
17 | `);
18 | });
19 |
20 | it("should validate custom options", async () => {
21 | const options = $deviceAuthorizationOptionsSchema.parse({
22 | expiresIn: 60 * 1000,
23 | interval: 2 * 1000,
24 | deviceCodeLength: 50,
25 | userCodeLength: 10,
26 | });
27 | expect(options).toMatchInlineSnapshot(`
28 | {
29 | "deviceCodeLength": 50,
30 | "expiresIn": 60000,
31 | "interval": 2000,
32 | "userCodeLength": 10,
33 | }
34 | `);
35 | });
36 | });
37 |
38 | describe("client validation", async () => {
39 | const validClients = ["valid-client-1", "valid-client-2"];
40 |
41 | const { auth } = await getTestInstance({
42 | plugins: [
43 | deviceAuthorization({
44 | validateClient: async (clientId) => {
45 | return validClients.includes(clientId);
46 | },
47 | }),
48 | ],
49 | });
50 |
51 | it("should reject invalid client in device code request", async () => {
52 | await expect(
53 | auth.api.deviceCode({
54 | body: {
55 | client_id: "invalid-client",
56 | },
57 | }),
58 | ).rejects.toMatchObject({
59 | body: {
60 | error: "invalid_client",
61 | error_description: "Invalid client ID",
62 | },
63 | });
64 | });
65 |
66 | it("should accept valid client in device code request", async () => {
67 | const response = await auth.api.deviceCode({
68 | body: {
69 | client_id: "valid-client-1",
70 | },
71 | });
72 | expect(response.device_code).toBeDefined();
73 | });
74 |
75 | it("should reject invalid client in token request", async () => {
76 | const { device_code } = await auth.api.deviceCode({
77 | body: {
78 | client_id: "valid-client-1",
79 | },
80 | });
81 |
82 | await expect(
83 | auth.api.deviceToken({
84 | body: {
85 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
86 | device_code,
87 | client_id: "invalid-client",
88 | },
89 | }),
90 | ).rejects.toMatchObject({
91 | body: {
92 | error: "invalid_grant",
93 | error_description: "Invalid client ID",
94 | },
95 | });
96 | });
97 |
98 | it("should reject mismatched client_id in token request", async () => {
99 | const { device_code } = await auth.api.deviceCode({
100 | body: {
101 | client_id: "valid-client-1",
102 | },
103 | });
104 |
105 | await expect(
106 | auth.api.deviceToken({
107 | body: {
108 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
109 | device_code,
110 | client_id: "valid-client-2",
111 | },
112 | }),
113 | ).rejects.toMatchObject({
114 | body: {
115 | error: "invalid_grant",
116 | error_description: "Client ID mismatch",
117 | },
118 | });
119 | });
120 | });
121 |
122 | describe("device authorization flow", async () => {
123 | const { auth, client, sessionSetter, signInWithTestUser } =
124 | await getTestInstance(
125 | {
126 | plugins: [
127 | deviceAuthorization({
128 | expiresIn: "5min",
129 | interval: "2s",
130 | }),
131 | ],
132 | },
133 | {
134 | clientOptions: {
135 | plugins: [deviceAuthorizationClient()],
136 | },
137 | },
138 | );
139 |
140 | describe("device code request", () => {
141 | it("should generate device and user codes", async () => {
142 | const response = await auth.api.deviceCode({
143 | body: {
144 | client_id: "test-client",
145 | },
146 | });
147 |
148 | expect(response.device_code).toBeDefined();
149 | expect(response.user_code).toBeDefined();
150 | expect(response.verification_uri).toBeDefined();
151 | expect(response.verification_uri_complete).toBeDefined();
152 | expect(response.expires_in).toBe(300);
153 | expect(response.interval).toBe(2);
154 | expect(response.user_code).toMatch(/^[A-Z0-9]{8}$/);
155 | expect(response.verification_uri_complete).toContain(response.user_code);
156 | });
157 |
158 | it("should support custom client ID and scope", async () => {
159 | const response = await auth.api.deviceCode({
160 | body: {
161 | client_id: "test-client",
162 | scope: "read write",
163 | },
164 | });
165 |
166 | expect(response.device_code).toBeDefined();
167 | expect(response.user_code).toBeDefined();
168 | });
169 | });
170 |
171 | describe("device token polling", () => {
172 | it("should return authorization_pending when not approved", async () => {
173 | const { device_code } = await auth.api.deviceCode({
174 | body: {
175 | client_id: "test-client",
176 | },
177 | });
178 |
179 | await expect(
180 | auth.api.deviceToken({
181 | body: {
182 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
183 | device_code: device_code,
184 | client_id: "test-client",
185 | },
186 | }),
187 | ).rejects.toMatchObject({
188 | body: {
189 | error: "authorization_pending",
190 | error_description: "Authorization pending",
191 | },
192 | });
193 | });
194 |
195 | it("should return expired_token for expired device codes", async () => {
196 | const { device_code } = await auth.api.deviceCode({
197 | body: {
198 | client_id: "test-client",
199 | },
200 | });
201 |
202 | // Advance time past expiration
203 | vi.useFakeTimers();
204 | await vi.advanceTimersByTimeAsync(301 * 1000); // 301 seconds
205 |
206 | await expect(
207 | auth.api.deviceToken({
208 | body: {
209 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
210 | device_code: device_code,
211 | client_id: "test-client",
212 | },
213 | }),
214 | ).rejects.toMatchObject({
215 | body: {
216 | error: "expired_token",
217 | error_description: "Device code has expired",
218 | },
219 | });
220 |
221 | vi.useRealTimers();
222 | });
223 |
224 | it("should return error for invalid device code", async () => {
225 | await expect(
226 | auth.api.deviceToken({
227 | body: {
228 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
229 | device_code: "invalid-code",
230 | client_id: "test-client",
231 | },
232 | }),
233 | ).rejects.toMatchObject({
234 | body: {
235 | error: "invalid_grant",
236 | error_description: "Invalid device code",
237 | },
238 | });
239 | });
240 | });
241 |
242 | describe("device verification", () => {
243 | it("should verify valid user code", async () => {
244 | const { user_code } = await auth.api.deviceCode({
245 | body: {
246 | client_id: "test-client",
247 | },
248 | });
249 |
250 | const response = await auth.api.deviceVerify({
251 | query: { user_code },
252 | });
253 | expect("error" in response).toBe(false);
254 | if (!("error" in response)) {
255 | expect(response.user_code).toBe(user_code);
256 | expect(response.status).toBe("pending");
257 | }
258 | });
259 |
260 | it("should handle invalid user code", async () => {
261 | await expect(
262 | auth.api.deviceVerify({
263 | query: { user_code: "INVALID" },
264 | }),
265 | ).rejects.toMatchObject({
266 | body: {
267 | error: "invalid_request",
268 | error_description: "Invalid user code",
269 | },
270 | });
271 | });
272 | });
273 |
274 | describe("device approval flow", () => {
275 | it("should approve device and create session", async () => {
276 | // First, sign in as a user
277 | const { headers } = await signInWithTestUser();
278 |
279 | // Request device code
280 | const { device_code, user_code } = await auth.api.deviceCode({
281 | body: {
282 | client_id: "test-client",
283 | },
284 | });
285 |
286 | // Approve the device
287 | const approveResponse = await auth.api.deviceApprove({
288 | body: { userCode: user_code },
289 | headers,
290 | });
291 | expect("success" in approveResponse && approveResponse.success).toBe(
292 | true,
293 | );
294 |
295 | // Poll for token should now succeed
296 | const tokenResponse = await auth.api.deviceToken({
297 | body: {
298 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
299 | device_code: device_code,
300 | client_id: "test-client",
301 | },
302 | });
303 | // Check OAuth 2.0 compliant response
304 | expect("access_token" in tokenResponse).toBe(true);
305 | if ("access_token" in tokenResponse) {
306 | expect(tokenResponse.access_token).toBeDefined();
307 | expect(tokenResponse.token_type).toBe("Bearer");
308 | expect(tokenResponse.expires_in).toBeGreaterThan(0);
309 | expect(tokenResponse.scope).toBeDefined();
310 | }
311 | });
312 |
313 | it("should deny device authorization", async () => {
314 | const { device_code, user_code } = await auth.api.deviceCode({
315 | body: {
316 | client_id: "test-client",
317 | },
318 | });
319 |
320 | // Deny the device
321 | const denyResponse = await auth.api.deviceDeny({
322 | body: { userCode: user_code },
323 | headers: new Headers(),
324 | });
325 | expect("success" in denyResponse && denyResponse.success).toBe(true);
326 |
327 | // Poll for token should return access_denied
328 | await expect(
329 | auth.api.deviceToken({
330 | body: {
331 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
332 | device_code: device_code,
333 | client_id: "test-client",
334 | },
335 | }),
336 | ).rejects.toMatchObject({
337 | body: {
338 | error: "access_denied",
339 | error_description: "Access denied",
340 | },
341 | });
342 | });
343 |
344 | it("should require authentication for approval", async () => {
345 | const { user_code } = await auth.api.deviceCode({
346 | body: {
347 | client_id: "test-client",
348 | },
349 | });
350 |
351 | await expect(
352 | auth.api.deviceApprove({
353 | body: { userCode: user_code },
354 | headers: new Headers(),
355 | }),
356 | ).rejects.toMatchObject({
357 | body: {
358 | error: "unauthorized",
359 | error_description: "Authentication required",
360 | },
361 | });
362 | });
363 |
364 | it("should enforce rate limiting with slow_down error", async () => {
365 | const { device_code } = await auth.api.deviceCode({
366 | body: {
367 | client_id: "test-client",
368 | },
369 | });
370 |
371 | await auth.api
372 | .deviceToken({
373 | body: {
374 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
375 | device_code: device_code,
376 | client_id: "test-client",
377 | },
378 | })
379 | .catch(
380 | // ignore the error
381 | () => {},
382 | );
383 |
384 | await expect(
385 | auth.api.deviceToken({
386 | body: {
387 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
388 | device_code: device_code,
389 | client_id: "test-client",
390 | },
391 | }),
392 | ).rejects.toMatchObject({
393 | body: {
394 | error: "slow_down",
395 | error_description: "Polling too frequently",
396 | },
397 | });
398 | });
399 | });
400 |
401 | describe("edge cases", () => {
402 | it("should not allow approving already processed device code", async () => {
403 | // Sign in as a user
404 | const { headers } = await signInWithTestUser();
405 |
406 | // Request and approve device
407 | const { user_code: userCode } = await auth.api.deviceCode({
408 | body: {
409 | client_id: "test-client",
410 | },
411 | });
412 | await auth.api.deviceApprove({
413 | body: { userCode },
414 | headers,
415 | });
416 |
417 | await expect(
418 | auth.api.deviceApprove({
419 | body: { userCode },
420 | headers,
421 | }),
422 | ).rejects.toMatchObject({
423 | body: {
424 | error: "invalid_request",
425 | error_description: "Device code already processed",
426 | },
427 | });
428 | });
429 |
430 | it("should handle user code without dashes", async () => {
431 | const { user_code } = await auth.api.deviceCode({
432 | body: {
433 | client_id: "test-client",
434 | },
435 | });
436 | const cleanUserCode = user_code.replace(/-/g, "");
437 |
438 | const response = await auth.api.deviceVerify({
439 | query: { user_code: cleanUserCode },
440 | });
441 | expect("status" in response && response.status).toBe("pending");
442 | });
443 |
444 | it("should store and use scope from device code request", async () => {
445 | const { headers } = await signInWithTestUser();
446 |
447 | const { device_code, user_code } = await auth.api.deviceCode({
448 | body: {
449 | client_id: "test-client",
450 | scope: "read write profile",
451 | },
452 | });
453 |
454 | await auth.api.deviceApprove({
455 | body: { userCode: user_code },
456 | headers,
457 | });
458 |
459 | const tokenResponse = await auth.api.deviceToken({
460 | body: {
461 | grant_type: "urn:ietf:params:oauth:grant-type:device_code",
462 | device_code: device_code,
463 | client_id: "test-client",
464 | },
465 | });
466 | expect("scope" in tokenResponse && tokenResponse.scope).toBe(
467 | "read write profile",
468 | );
469 | });
470 | });
471 | });
472 |
473 | describe("device authorization with custom options", async () => {
474 | it("should correctly store interval as milliseconds in database", async () => {
475 | const { auth, client, db } = await getTestInstance({
476 | plugins: [
477 | deviceAuthorization({
478 | interval: "5s",
479 | }),
480 | ],
481 | });
482 |
483 | const response = await auth.api.deviceCode({
484 | body: {
485 | client_id: "test-client",
486 | },
487 | });
488 |
489 | // Response should return interval in seconds
490 | expect(response.interval).toBe(5);
491 |
492 | // Check that the interval is stored as milliseconds in the database
493 | const deviceCodeRecord: DeviceCode | null = await db.findOne({
494 | model: "deviceCode",
495 | where: [
496 | {
497 | field: "deviceCode",
498 | value: response.device_code,
499 | },
500 | ],
501 | });
502 |
503 | // Should be stored as 5000 milliseconds, not "5s" string
504 | expect(deviceCodeRecord?.pollingInterval).toBe(5000);
505 | expect(typeof deviceCodeRecord?.pollingInterval).toBe("number");
506 | });
507 |
508 | it("should use custom code generators", async () => {
509 | const customDeviceCode = "custom-device-code-12345";
510 | const customUserCode = "CUSTOM12";
511 |
512 | const { auth } = await getTestInstance({
513 | plugins: [
514 | deviceAuthorization({
515 | generateDeviceCode: () => customDeviceCode,
516 | generateUserCode: () => customUserCode,
517 | }),
518 | ],
519 | });
520 |
521 | const response = await auth.api.deviceCode({
522 | body: {
523 | client_id: "test-client",
524 | },
525 | });
526 | expect(response.device_code).toBe(customDeviceCode);
527 | expect(response.user_code).toBe(customUserCode);
528 | });
529 |
530 | it("should respect custom expiration time", async () => {
531 | const { auth } = await getTestInstance({
532 | plugins: [
533 | deviceAuthorization({
534 | expiresIn: "1min",
535 | }),
536 | ],
537 | });
538 |
539 | const response = await auth.api.deviceCode({
540 | body: {
541 | client_id: "test-client",
542 | },
543 | });
544 | expect(response.expires_in).toBe(60);
545 | });
546 | });
547 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/two-factor/backup-codes/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createAuthEndpoint } from "@better-auth/core/api";
2 | import { APIError } from "better-call";
3 | import * as z from "zod";
4 | import { sessionMiddleware } from "../../../api";
5 | import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto";
6 | import { generateRandomString } from "../../../crypto/random";
7 | import { safeJSONParse } from "../../../utils/json";
8 | import { TWO_FACTOR_ERROR_CODES } from "../error-code";
9 | import type {
10 | TwoFactorProvider,
11 | TwoFactorTable,
12 | UserWithTwoFactor,
13 | } from "../types";
14 | import { verifyTwoFactor } from "../verify-two-factor";
15 |
16 | export interface BackupCodeOptions {
17 | /**
18 | * The amount of backup codes to generate
19 | *
20 | * @default 10
21 | */
22 | amount?: number;
23 | /**
24 | * The length of the backup codes
25 | *
26 | * @default 10
27 | */
28 | length?: number;
29 | /**
30 | * An optional custom function to generate backup codes
31 | */
32 | customBackupCodesGenerate?: () => string[];
33 | /**
34 | * How to store the backup codes in the database, whether encrypted or plain.
35 | */
36 | storeBackupCodes?:
37 | | "plain"
38 | | "encrypted"
39 | | {
40 | encrypt: (token: string) => Promise<string>;
41 | decrypt: (token: string) => Promise<string>;
42 | };
43 | }
44 |
45 | function generateBackupCodesFn(options?: BackupCodeOptions) {
46 | return Array.from({ length: options?.amount ?? 10 })
47 | .fill(null)
48 | .map(() => generateRandomString(options?.length ?? 10, "a-z", "0-9", "A-Z"))
49 | .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
50 | }
51 |
52 | export async function generateBackupCodes(
53 | secret: string,
54 | options?: BackupCodeOptions,
55 | ) {
56 | const backupCodes = options?.customBackupCodesGenerate
57 | ? options.customBackupCodesGenerate()
58 | : generateBackupCodesFn(options);
59 | if (options?.storeBackupCodes === "encrypted") {
60 | const encCodes = await symmetricEncrypt({
61 | data: JSON.stringify(backupCodes),
62 | key: secret,
63 | });
64 | return {
65 | backupCodes,
66 | encryptedBackupCodes: encCodes,
67 | };
68 | }
69 | if (
70 | typeof options?.storeBackupCodes === "object" &&
71 | "encrypt" in options?.storeBackupCodes
72 | ) {
73 | return {
74 | backupCodes,
75 | encryptedBackupCodes: await options?.storeBackupCodes.encrypt(
76 | JSON.stringify(backupCodes),
77 | ),
78 | };
79 | }
80 | return {
81 | backupCodes,
82 | encryptedBackupCodes: JSON.stringify(backupCodes),
83 | };
84 | }
85 |
86 | export async function verifyBackupCode(
87 | data: {
88 | backupCodes: string;
89 | code: string;
90 | },
91 | key: string,
92 | options?: BackupCodeOptions,
93 | ) {
94 | const codes = await getBackupCodes(data.backupCodes, key, options);
95 | if (!codes) {
96 | return {
97 | status: false,
98 | updated: null,
99 | };
100 | }
101 | return {
102 | status: codes.includes(data.code),
103 | updated: codes.filter((code) => code !== data.code),
104 | };
105 | }
106 |
107 | export async function getBackupCodes(
108 | backupCodes: string,
109 | key: string,
110 | options?: BackupCodeOptions,
111 | ) {
112 | if (options?.storeBackupCodes === "encrypted") {
113 | const decrypted = await symmetricDecrypt({ key, data: backupCodes });
114 | return safeJSONParse<string[]>(decrypted);
115 | }
116 | if (
117 | typeof options?.storeBackupCodes === "object" &&
118 | "decrypt" in options?.storeBackupCodes
119 | ) {
120 | const decrypted = await options?.storeBackupCodes.decrypt(backupCodes);
121 | return safeJSONParse<string[]>(decrypted);
122 | }
123 |
124 | return safeJSONParse<string[]>(backupCodes);
125 | }
126 |
127 | export const backupCode2fa = (opts: BackupCodeOptions) => {
128 | const twoFactorTable = "twoFactor";
129 |
130 | return {
131 | id: "backup_code",
132 | endpoints: {
133 | /**
134 | * ### Endpoint
135 | *
136 | * POST `/two-factor/verify-backup-code`
137 | *
138 | * ### API Methods
139 | *
140 | * **server:**
141 | * `auth.api.verifyBackupCode`
142 | *
143 | * **client:**
144 | * `authClient.twoFactor.verifyBackupCode`
145 | *
146 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-verify-backup-code)
147 | */
148 | verifyBackupCode: createAuthEndpoint(
149 | "/two-factor/verify-backup-code",
150 |
151 | {
152 | method: "POST",
153 | body: z.object({
154 | code: z.string().meta({
155 | description: `A backup code to verify. Eg: "123456"`,
156 | }),
157 | /**
158 | * Disable setting the session cookie
159 | */
160 | disableSession: z
161 | .boolean()
162 | .meta({
163 | description: "If true, the session cookie will not be set.",
164 | })
165 | .optional(),
166 | /**
167 | * if true, the device will be trusted
168 | * for 30 days. It'll be refreshed on
169 | * every sign in request within this time.
170 | */
171 | trustDevice: z
172 | .boolean()
173 | .meta({
174 | description:
175 | "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true",
176 | })
177 | .optional(),
178 | }),
179 | metadata: {
180 | openapi: {
181 | description: "Verify a backup code for two-factor authentication",
182 | responses: {
183 | "200": {
184 | description: "Backup code verified successfully",
185 | content: {
186 | "application/json": {
187 | schema: {
188 | type: "object",
189 | properties: {
190 | user: {
191 | type: "object",
192 | properties: {
193 | id: {
194 | type: "string",
195 | description: "Unique identifier of the user",
196 | },
197 | email: {
198 | type: "string",
199 | format: "email",
200 | nullable: true,
201 | description: "User's email address",
202 | },
203 | emailVerified: {
204 | type: "boolean",
205 | nullable: true,
206 | description: "Whether the email is verified",
207 | },
208 | name: {
209 | type: "string",
210 | nullable: true,
211 | description: "User's name",
212 | },
213 | image: {
214 | type: "string",
215 | format: "uri",
216 | nullable: true,
217 | description: "User's profile image URL",
218 | },
219 | twoFactorEnabled: {
220 | type: "boolean",
221 | description:
222 | "Whether two-factor authentication is enabled for the user",
223 | },
224 | createdAt: {
225 | type: "string",
226 | format: "date-time",
227 | description:
228 | "Timestamp when the user was created",
229 | },
230 | updatedAt: {
231 | type: "string",
232 | format: "date-time",
233 | description:
234 | "Timestamp when the user was last updated",
235 | },
236 | },
237 | required: [
238 | "id",
239 | "twoFactorEnabled",
240 | "createdAt",
241 | "updatedAt",
242 | ],
243 | description:
244 | "The authenticated user object with two-factor details",
245 | },
246 | session: {
247 | type: "object",
248 | properties: {
249 | token: {
250 | type: "string",
251 | description: "Session token",
252 | },
253 | userId: {
254 | type: "string",
255 | description:
256 | "ID of the user associated with the session",
257 | },
258 | createdAt: {
259 | type: "string",
260 | format: "date-time",
261 | description:
262 | "Timestamp when the session was created",
263 | },
264 | expiresAt: {
265 | type: "string",
266 | format: "date-time",
267 | description:
268 | "Timestamp when the session expires",
269 | },
270 | },
271 | required: [
272 | "token",
273 | "userId",
274 | "createdAt",
275 | "expiresAt",
276 | ],
277 | description:
278 | "The current session object, included unless disableSession is true",
279 | },
280 | },
281 | required: ["user", "session"],
282 | },
283 | },
284 | },
285 | },
286 | },
287 | },
288 | },
289 | },
290 | async (ctx) => {
291 | const { session, valid } = await verifyTwoFactor(ctx);
292 | const user = session.user as UserWithTwoFactor;
293 | const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
294 | model: twoFactorTable,
295 | where: [
296 | {
297 | field: "userId",
298 | value: user.id,
299 | },
300 | ],
301 | });
302 | if (!twoFactor) {
303 | throw new APIError("BAD_REQUEST", {
304 | message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
305 | });
306 | }
307 | const validate = await verifyBackupCode(
308 | {
309 | backupCodes: twoFactor.backupCodes,
310 | code: ctx.body.code,
311 | },
312 | ctx.context.secret,
313 | opts,
314 | );
315 | if (!validate.status) {
316 | throw new APIError("UNAUTHORIZED", {
317 | message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE,
318 | });
319 | }
320 | const updatedBackupCodes = await symmetricEncrypt({
321 | key: ctx.context.secret,
322 | data: JSON.stringify(validate.updated),
323 | });
324 |
325 | await ctx.context.adapter.updateMany({
326 | model: twoFactorTable,
327 | update: {
328 | backupCodes: updatedBackupCodes,
329 | },
330 | where: [
331 | {
332 | field: "userId",
333 | value: user.id,
334 | },
335 | ],
336 | });
337 |
338 | if (!ctx.body.disableSession) {
339 | return valid(ctx);
340 | }
341 | return ctx.json({
342 | token: session.session?.token,
343 | user: {
344 | id: session.user?.id,
345 | email: session.user.email,
346 | emailVerified: session.user.emailVerified,
347 | name: session.user.name,
348 | image: session.user.image,
349 | createdAt: session.user.createdAt,
350 | updatedAt: session.user.updatedAt,
351 | },
352 | });
353 | },
354 | ),
355 | /**
356 | * ### Endpoint
357 | *
358 | * POST `/two-factor/generate-backup-codes`
359 | *
360 | * ### API Methods
361 | *
362 | * **server:**
363 | * `auth.api.generateBackupCodes`
364 | *
365 | * **client:**
366 | * `authClient.twoFactor.generateBackupCodes`
367 | *
368 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-generate-backup-codes)
369 | */
370 | generateBackupCodes: createAuthEndpoint(
371 | "/two-factor/generate-backup-codes",
372 | {
373 | method: "POST",
374 | body: z.object({
375 | password: z.string().meta({
376 | description: "The users password.",
377 | }),
378 | }),
379 | use: [sessionMiddleware],
380 | metadata: {
381 | openapi: {
382 | description:
383 | "Generate new backup codes for two-factor authentication",
384 | responses: {
385 | "200": {
386 | description: "Backup codes generated successfully",
387 | content: {
388 | "application/json": {
389 | schema: {
390 | type: "object",
391 | properties: {
392 | status: {
393 | type: "boolean",
394 | description:
395 | "Indicates if the backup codes were generated successfully",
396 | enum: [true],
397 | },
398 | backupCodes: {
399 | type: "array",
400 | items: { type: "string" },
401 | description:
402 | "Array of generated backup codes in plain text",
403 | },
404 | },
405 | required: ["status", "backupCodes"],
406 | },
407 | },
408 | },
409 | },
410 | },
411 | },
412 | },
413 | },
414 | async (ctx) => {
415 | const user = ctx.context.session.user as UserWithTwoFactor;
416 | if (!user.twoFactorEnabled) {
417 | throw new APIError("BAD_REQUEST", {
418 | message: TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED,
419 | });
420 | }
421 | await ctx.context.password.checkPassword(user.id, ctx);
422 | const backupCodes = await generateBackupCodes(
423 | ctx.context.secret,
424 | opts,
425 | );
426 | await ctx.context.adapter.updateMany({
427 | model: twoFactorTable,
428 | update: {
429 | backupCodes: backupCodes.encryptedBackupCodes,
430 | },
431 | where: [
432 | {
433 | field: "userId",
434 | value: ctx.context.session.user.id,
435 | },
436 | ],
437 | });
438 | return ctx.json({
439 | status: true,
440 | backupCodes: backupCodes.backupCodes,
441 | });
442 | },
443 | ),
444 | /**
445 | * ### Endpoint
446 | *
447 | * GET `/two-factor/view-backup-codes`
448 | *
449 | * ### API Methods
450 | *
451 | * **server:**
452 | * `auth.api.viewBackupCodes`
453 | *
454 | * **client:**
455 | * `authClient.twoFactor.viewBackupCodes`
456 | *
457 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-view-backup-codes)
458 | */
459 | viewBackupCodes: createAuthEndpoint(
460 | "/two-factor/view-backup-codes",
461 | {
462 | method: "GET",
463 | body: z.object({
464 | userId: z.coerce.string().meta({
465 | description: `The user ID to view all backup codes. Eg: "user-id"`,
466 | }),
467 | }),
468 | metadata: {
469 | SERVER_ONLY: true,
470 | },
471 | },
472 | async (ctx) => {
473 | const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
474 | model: twoFactorTable,
475 | where: [
476 | {
477 | field: "userId",
478 | value: ctx.body.userId,
479 | },
480 | ],
481 | });
482 | if (!twoFactor) {
483 | throw new APIError("BAD_REQUEST", {
484 | message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED,
485 | });
486 | }
487 | const decryptedBackupCodes = await getBackupCodes(
488 | twoFactor.backupCodes,
489 | ctx.context.secret,
490 | opts,
491 | );
492 |
493 | if (!decryptedBackupCodes) {
494 | throw new APIError("BAD_REQUEST", {
495 | message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE,
496 | });
497 | }
498 | return ctx.json({
499 | status: true,
500 | backupCodes: decryptedBackupCodes,
501 | });
502 | },
503 | ),
504 | },
505 | } satisfies TwoFactorProvider;
506 | };
507 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { BetterAuthOptions } from "@better-auth/core";
2 | import type {
3 | DBAdapter,
4 | DBAdapterDebugLogOption,
5 | Where,
6 | } from "@better-auth/core/db/adapter";
7 | import { BetterAuthError } from "@better-auth/core/error";
8 | import {
9 | and,
10 | asc,
11 | count,
12 | desc,
13 | eq,
14 | gt,
15 | gte,
16 | inArray,
17 | like,
18 | lt,
19 | lte,
20 | ne,
21 | notInArray,
22 | or,
23 | SQL,
24 | sql,
25 | } from "drizzle-orm";
26 | import {
27 | type AdapterFactoryCustomizeAdapterCreator,
28 | type AdapterFactoryOptions,
29 | createAdapterFactory,
30 | } from "../adapter-factory";
31 |
32 | export interface DB {
33 | [key: string]: any;
34 | }
35 |
36 | export interface DrizzleAdapterConfig {
37 | /**
38 | * The schema object that defines the tables and fields
39 | */
40 | schema?: Record<string, any>;
41 | /**
42 | * The database provider
43 | */
44 | provider: "pg" | "mysql" | "sqlite";
45 | /**
46 | * If the table names in the schema are plural
47 | * set this to true. For example, if the schema
48 | * has an object with a key "users" instead of "user"
49 | */
50 | usePlural?: boolean;
51 | /**
52 | * Enable debug logs for the adapter
53 | *
54 | * @default false
55 | */
56 | debugLogs?: DBAdapterDebugLogOption;
57 | /**
58 | * By default snake case is used for table and field names
59 | * when the CLI is used to generate the schema. If you want
60 | * to use camel case, set this to true.
61 | * @default false
62 | */
63 | camelCase?: boolean;
64 | /**
65 | * Whether to execute multiple operations in a transaction.
66 | *
67 | * If the database doesn't support transactions,
68 | * set this to `false` and operations will be executed sequentially.
69 | * @default false
70 | */
71 | transaction?: boolean;
72 | }
73 |
74 | export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
75 | let lazyOptions: BetterAuthOptions | null = null;
76 | const createCustomAdapter =
77 | (db: DB): AdapterFactoryCustomizeAdapterCreator =>
78 | ({ getFieldName, debugLog }) => {
79 | function getSchema(model: string) {
80 | const schema = config.schema || db._.fullSchema;
81 | if (!schema) {
82 | throw new BetterAuthError(
83 | "Drizzle adapter failed to initialize. Schema not found. Please provide a schema object in the adapter options object.",
84 | );
85 | }
86 | const schemaModel = schema[model];
87 | if (!schemaModel) {
88 | throw new BetterAuthError(
89 | `[# Drizzle Adapter]: The model "${model}" was not found in the schema object. Please pass the schema directly to the adapter options.`,
90 | );
91 | }
92 | return schemaModel;
93 | }
94 | const withReturning = async (
95 | model: string,
96 | builder: any,
97 | data: Record<string, any>,
98 | where?: Where[],
99 | ) => {
100 | if (config.provider !== "mysql") {
101 | const c = await builder.returning();
102 | return c[0];
103 | }
104 | await builder.execute();
105 | const schemaModel = getSchema(model);
106 | const builderVal = builder.config?.values;
107 | if (where?.length) {
108 | // If we're updating a field that's in the where clause, use the new value
109 | const updatedWhere = where.map((w) => {
110 | // If this field was updated, use the new value for lookup
111 | if (data[w.field] !== undefined) {
112 | return { ...w, value: data[w.field] };
113 | }
114 | return w;
115 | });
116 |
117 | const clause = convertWhereClause(updatedWhere, model);
118 | const res = await db
119 | .select()
120 | .from(schemaModel)
121 | .where(...clause);
122 | return res[0];
123 | } else if (builderVal && builderVal[0]?.id?.value) {
124 | let tId = builderVal[0]?.id?.value;
125 | if (!tId) {
126 | //get last inserted id
127 | const lastInsertId = await db
128 | .select({ id: sql`LAST_INSERT_ID()` })
129 | .from(schemaModel)
130 | .orderBy(desc(schemaModel.id))
131 | .limit(1);
132 | tId = lastInsertId[0].id;
133 | }
134 | const res = await db
135 | .select()
136 | .from(schemaModel)
137 | .where(eq(schemaModel.id, tId))
138 | .limit(1)
139 | .execute();
140 | return res[0];
141 | } else if (data.id) {
142 | const res = await db
143 | .select()
144 | .from(schemaModel)
145 | .where(eq(schemaModel.id, data.id))
146 | .limit(1)
147 | .execute();
148 | return res[0];
149 | } else {
150 | // If the user doesn't have `id` as a field, then this will fail.
151 | // We expect that they defined `id` in all of their models.
152 | if (!("id" in schemaModel)) {
153 | throw new BetterAuthError(
154 | `The model "${model}" does not have an "id" field. Please use the "id" field as your primary key.`,
155 | );
156 | }
157 | const res = await db
158 | .select()
159 | .from(schemaModel)
160 | .orderBy(desc(schemaModel.id))
161 | .limit(1)
162 | .execute();
163 | return res[0];
164 | }
165 | };
166 | function convertWhereClause(where: Where[], model: string) {
167 | const schemaModel = getSchema(model);
168 | if (!where) return [];
169 | if (where.length === 1) {
170 | const w = where[0];
171 | if (!w) {
172 | return [];
173 | }
174 | const field = getFieldName({ model, field: w.field });
175 | if (!schemaModel[field]) {
176 | throw new BetterAuthError(
177 | `The field "${w.field}" does not exist in the schema for the model "${model}". Please update your schema.`,
178 | );
179 | }
180 | if (w.operator === "in") {
181 | if (!Array.isArray(w.value)) {
182 | throw new BetterAuthError(
183 | `The value for the field "${w.field}" must be an array when using the "in" operator.`,
184 | );
185 | }
186 | return [inArray(schemaModel[field], w.value)];
187 | }
188 |
189 | if (w.operator === "not_in") {
190 | if (!Array.isArray(w.value)) {
191 | throw new BetterAuthError(
192 | `The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
193 | );
194 | }
195 | return [notInArray(schemaModel[field], w.value)];
196 | }
197 |
198 | if (w.operator === "contains") {
199 | return [like(schemaModel[field], `%${w.value}%`)];
200 | }
201 |
202 | if (w.operator === "starts_with") {
203 | return [like(schemaModel[field], `${w.value}%`)];
204 | }
205 |
206 | if (w.operator === "ends_with") {
207 | return [like(schemaModel[field], `%${w.value}`)];
208 | }
209 |
210 | if (w.operator === "lt") {
211 | return [lt(schemaModel[field], w.value)];
212 | }
213 |
214 | if (w.operator === "lte") {
215 | return [lte(schemaModel[field], w.value)];
216 | }
217 |
218 | if (w.operator === "ne") {
219 | return [ne(schemaModel[field], w.value)];
220 | }
221 |
222 | if (w.operator === "gt") {
223 | return [gt(schemaModel[field], w.value)];
224 | }
225 |
226 | if (w.operator === "gte") {
227 | return [gte(schemaModel[field], w.value)];
228 | }
229 |
230 | return [eq(schemaModel[field], w.value)];
231 | }
232 | const andGroup = where.filter(
233 | (w) => w.connector === "AND" || !w.connector,
234 | );
235 | const orGroup = where.filter((w) => w.connector === "OR");
236 |
237 | const andClause = and(
238 | ...andGroup.map((w) => {
239 | const field = getFieldName({ model, field: w.field });
240 | if (w.operator === "in") {
241 | if (!Array.isArray(w.value)) {
242 | throw new BetterAuthError(
243 | `The value for the field "${w.field}" must be an array when using the "in" operator.`,
244 | );
245 | }
246 | return inArray(schemaModel[field], w.value);
247 | }
248 | if (w.operator === "not_in") {
249 | if (!Array.isArray(w.value)) {
250 | throw new BetterAuthError(
251 | `The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
252 | );
253 | }
254 | return notInArray(schemaModel[field], w.value);
255 | }
256 | if (w.operator === "contains") {
257 | return like(schemaModel[field], `%${w.value}%`);
258 | }
259 | if (w.operator === "starts_with") {
260 | return like(schemaModel[field], `${w.value}%`);
261 | }
262 | if (w.operator === "ends_with") {
263 | return like(schemaModel[field], `%${w.value}`);
264 | }
265 | if (w.operator === "lt") {
266 | return lt(schemaModel[field], w.value);
267 | }
268 | if (w.operator === "lte") {
269 | return lte(schemaModel[field], w.value);
270 | }
271 | if (w.operator === "gt") {
272 | return gt(schemaModel[field], w.value);
273 | }
274 | if (w.operator === "gte") {
275 | return gte(schemaModel[field], w.value);
276 | }
277 | if (w.operator === "ne") {
278 | return ne(schemaModel[field], w.value);
279 | }
280 | return eq(schemaModel[field], w.value);
281 | }),
282 | );
283 | const orClause = or(
284 | ...orGroup.map((w) => {
285 | const field = getFieldName({ model, field: w.field });
286 | if (w.operator === "in") {
287 | if (!Array.isArray(w.value)) {
288 | throw new BetterAuthError(
289 | `The value for the field "${w.field}" must be an array when using the "in" operator.`,
290 | );
291 | }
292 | return inArray(schemaModel[field], w.value);
293 | }
294 | if (w.operator === "not_in") {
295 | if (!Array.isArray(w.value)) {
296 | throw new BetterAuthError(
297 | `The value for the field "${w.field}" must be an array when using the "not_in" operator.`,
298 | );
299 | }
300 | return notInArray(schemaModel[field], w.value);
301 | }
302 | if (w.operator === "contains") {
303 | return like(schemaModel[field], `%${w.value}%`);
304 | }
305 | if (w.operator === "starts_with") {
306 | return like(schemaModel[field], `${w.value}%`);
307 | }
308 | if (w.operator === "ends_with") {
309 | return like(schemaModel[field], `%${w.value}`);
310 | }
311 | if (w.operator === "lt") {
312 | return lt(schemaModel[field], w.value);
313 | }
314 | if (w.operator === "lte") {
315 | return lte(schemaModel[field], w.value);
316 | }
317 | if (w.operator === "gt") {
318 | return gt(schemaModel[field], w.value);
319 | }
320 | if (w.operator === "gte") {
321 | return gte(schemaModel[field], w.value);
322 | }
323 | if (w.operator === "ne") {
324 | return ne(schemaModel[field], w.value);
325 | }
326 | return eq(schemaModel[field], w.value);
327 | }),
328 | );
329 |
330 | const clause: SQL<unknown>[] = [];
331 |
332 | if (andGroup.length) clause.push(andClause!);
333 | if (orGroup.length) clause.push(orClause!);
334 | return clause;
335 | }
336 | function checkMissingFields(
337 | schema: Record<string, any>,
338 | model: string,
339 | values: Record<string, any>,
340 | ) {
341 | if (!schema) {
342 | throw new BetterAuthError(
343 | "Drizzle adapter failed to initialize. Schema not found. Please provide a schema object in the adapter options object.",
344 | );
345 | }
346 | for (const key in values) {
347 | if (!schema[key]) {
348 | throw new BetterAuthError(
349 | `The field "${key}" does not exist in the "${model}" schema. Please update your drizzle schema or re-generate using "npx @better-auth/cli generate".`,
350 | );
351 | }
352 | }
353 | }
354 | return {
355 | async create({ model, data: values }) {
356 | const schemaModel = getSchema(model);
357 | checkMissingFields(schemaModel, model, values);
358 | const builder = db.insert(schemaModel).values(values);
359 | const returned = await withReturning(model, builder, values);
360 | return returned;
361 | },
362 | async findOne({ model, where }) {
363 | const schemaModel = getSchema(model);
364 | const clause = convertWhereClause(where, model);
365 | const res = await db
366 | .select()
367 | .from(schemaModel)
368 | .where(...clause);
369 | if (!res.length) return null;
370 | return res[0];
371 | },
372 | async findMany({ model, where, sortBy, limit, offset }) {
373 | const schemaModel = getSchema(model);
374 | const clause = where ? convertWhereClause(where, model) : [];
375 |
376 | const sortFn = sortBy?.direction === "desc" ? desc : asc;
377 | const builder = db
378 | .select()
379 | .from(schemaModel)
380 | .limit(limit || 100)
381 | .offset(offset || 0);
382 | if (sortBy?.field) {
383 | builder.orderBy(
384 | sortFn(
385 | schemaModel[getFieldName({ model, field: sortBy?.field })],
386 | ),
387 | );
388 | }
389 | return (await builder.where(...clause)) as any[];
390 | },
391 | async count({ model, where }) {
392 | const schemaModel = getSchema(model);
393 | const clause = where ? convertWhereClause(where, model) : [];
394 | const res = await db
395 | .select({ count: count() })
396 | .from(schemaModel)
397 | .where(...clause);
398 | return res[0].count;
399 | },
400 | async update({ model, where, update: values }) {
401 | const schemaModel = getSchema(model);
402 | const clause = convertWhereClause(where, model);
403 | const builder = db
404 | .update(schemaModel)
405 | .set(values)
406 | .where(...clause);
407 | return await withReturning(model, builder, values as any, where);
408 | },
409 | async updateMany({ model, where, update: values }) {
410 | const schemaModel = getSchema(model);
411 | const clause = convertWhereClause(where, model);
412 | const builder = db
413 | .update(schemaModel)
414 | .set(values)
415 | .where(...clause);
416 | return await builder;
417 | },
418 | async delete({ model, where }) {
419 | const schemaModel = getSchema(model);
420 | const clause = convertWhereClause(where, model);
421 | const builder = db.delete(schemaModel).where(...clause);
422 | return await builder;
423 | },
424 | async deleteMany({ model, where }) {
425 | const schemaModel = getSchema(model);
426 | const clause = convertWhereClause(where, model);
427 | const builder = db.delete(schemaModel).where(...clause);
428 | return await builder;
429 | },
430 | options: config,
431 | };
432 | };
433 | let adapterOptions: AdapterFactoryOptions | null = null;
434 | adapterOptions = {
435 | config: {
436 | adapterId: "drizzle",
437 | adapterName: "Drizzle Adapter",
438 | usePlural: config.usePlural ?? false,
439 | debugLogs: config.debugLogs ?? false,
440 | transaction:
441 | (config.transaction ?? false)
442 | ? (cb) =>
443 | db.transaction((tx: DB) => {
444 | const adapter = createAdapterFactory({
445 | config: adapterOptions!.config,
446 | adapter: createCustomAdapter(tx),
447 | })(lazyOptions!);
448 | return cb(adapter);
449 | })
450 | : false,
451 | },
452 | adapter: createCustomAdapter(db),
453 | };
454 | const adapter = createAdapterFactory(adapterOptions);
455 | return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
456 | lazyOptions = options;
457 | return adapter(options);
458 | };
459 | };
460 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/middlewares/origin-check.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createAuthEndpoint } from "@better-auth/core/api";
2 | import { describe, expect } from "vitest";
3 | import * as z from "zod";
4 | import { createAuthClient } from "../../client";
5 | import { getTestInstance } from "../../test-utils/test-instance";
6 | import { isSimpleRequest, originCheck } from "./origin-check";
7 |
8 | describe("Origin Check", async (it) => {
9 | const { customFetchImpl, testUser } = await getTestInstance({
10 | trustedOrigins: [
11 | "http://localhost:5000",
12 | "https://trusted.com",
13 | "*.my-site.com",
14 | "https://*.protocol-site.com",
15 | ],
16 | emailAndPassword: {
17 | enabled: true,
18 | async sendResetPassword(url, user) {},
19 | },
20 | advanced: {
21 | disableCSRFCheck: false,
22 | disableOriginCheck: false,
23 | },
24 | });
25 |
26 | it("should allow trusted origins", async (ctx) => {
27 | const client = createAuthClient({
28 | baseURL: "http://localhost:3000",
29 | fetchOptions: {
30 | customFetchImpl,
31 | headers: {
32 | origin: "http://localhost:3000",
33 | },
34 | },
35 | });
36 | const res = await client.signIn.email({
37 | email: testUser.email,
38 | password: testUser.password,
39 | callbackURL: "http://localhost:3000/callback",
40 | });
41 | expect(res.data?.user).toBeDefined();
42 | });
43 |
44 | it("should not allow untrusted origins", async (ctx) => {
45 | const client = createAuthClient({
46 | baseURL: "http://localhost:3000",
47 | fetchOptions: {
48 | customFetchImpl,
49 | },
50 | });
51 | const res = await client.signIn.email({
52 | email: "[email protected]",
53 | password: "password",
54 | callbackURL: "http://malicious.com",
55 | });
56 | expect(res.error?.status).toBe(403);
57 | expect(res.error?.message).toBe("Invalid callbackURL");
58 | });
59 |
60 | it("should allow query params in callback url", async (ctx) => {
61 | const client = createAuthClient({
62 | baseURL: "http://localhost:3000",
63 | fetchOptions: {
64 | customFetchImpl,
65 | headers: {
66 | origin: "https://localhost:3000",
67 | },
68 | },
69 | });
70 | const res = await client.signIn.email({
71 | email: testUser.email,
72 | password: testUser.password,
73 | callbackURL: "/dashboard?test=123",
74 | });
75 | expect(res.data?.user).toBeDefined();
76 | });
77 |
78 | it("should allow plus signs in the callback url", async (ctx) => {
79 | const client = createAuthClient({
80 | baseURL: "http://localhost:3000",
81 | fetchOptions: {
82 | customFetchImpl,
83 | headers: {
84 | origin: "https://localhost:3000",
85 | },
86 | },
87 | });
88 | const res = await client.signIn.email({
89 | email: testUser.email,
90 | password: testUser.password,
91 | callbackURL: "/dashboard+page?test=123+456",
92 | });
93 | expect(res.data?.user).toBeDefined();
94 | });
95 |
96 | it("should reject callback url with double slash", async (ctx) => {
97 | const client = createAuthClient({
98 | baseURL: "http://localhost:3000",
99 | fetchOptions: {
100 | customFetchImpl,
101 | headers: {
102 | origin: "https://localhost:3000",
103 | },
104 | },
105 | });
106 | const res = await client.signIn.email({
107 | email: testUser.email,
108 | password: testUser.password,
109 | callbackURL: "//evil.com",
110 | });
111 | expect(res.error?.status).toBe(403);
112 | });
113 |
114 | it("should reject callback urls with encoded malicious content", async (ctx) => {
115 | const client = createAuthClient({
116 | baseURL: "http://localhost:3000",
117 | fetchOptions: {
118 | customFetchImpl,
119 | headers: {
120 | origin: "https://localhost:3000",
121 | },
122 | },
123 | });
124 |
125 | const maliciousPatterns = [
126 | "/%5C/evil.com",
127 | `/\\/\\/evil.com`,
128 | "/%5C/evil.com",
129 | "/..%2F..%2Fevil.com",
130 | "javascript:alert('xss')",
131 | "data:text/html,<script>alert('xss')</script>",
132 | ];
133 |
134 | for (const pattern of maliciousPatterns) {
135 | const res = await client.signIn.email({
136 | email: testUser.email,
137 | password: testUser.password,
138 | callbackURL: pattern,
139 | });
140 | expect(res.error?.status).toBe(403);
141 | }
142 | });
143 |
144 | it("should reject untrusted origin headers", async (ctx) => {
145 | const client = createAuthClient({
146 | baseURL: "http://localhost:3000",
147 | fetchOptions: {
148 | customFetchImpl,
149 | headers: {
150 | origin: "malicious.com",
151 | cookie: "session=123",
152 | },
153 | },
154 | });
155 | const res = await client.signIn.email({
156 | email: testUser.email,
157 | password: testUser.password,
158 | });
159 | expect(res.error?.status).toBe(403);
160 | });
161 |
162 | it("should reject untrusted origin headers which start with trusted origin", async (ctx) => {
163 | const client = createAuthClient({
164 | baseURL: "http://localhost:3000",
165 | fetchOptions: {
166 | customFetchImpl,
167 | headers: {
168 | origin: "https://trusted.com.malicious.com",
169 | cookie: "session=123",
170 | },
171 | },
172 | });
173 | const res = await client.signIn.email({
174 | email: testUser.email,
175 | password: testUser.password,
176 | });
177 | expect(res.error?.status).toBe(403);
178 | });
179 |
180 | it("should reject untrusted origin subdomains", async (ctx) => {
181 | const client = createAuthClient({
182 | baseURL: "http://localhost:3000",
183 | fetchOptions: {
184 | customFetchImpl,
185 | headers: {
186 | origin: "http://sub-domain.trusted.com",
187 | cookie: "session=123",
188 | },
189 | },
190 | });
191 | const res = await client.signIn.email({
192 | email: testUser.email,
193 | password: testUser.password,
194 | });
195 | expect(res.error?.status).toBe(403);
196 | });
197 |
198 | it("should allow untrusted origin if they don't contain cookies", async (ctx) => {
199 | const client = createAuthClient({
200 | baseURL: "http://localhost:3000",
201 | fetchOptions: {
202 | customFetchImpl,
203 | headers: {
204 | origin: "http://sub-domain.trusted.com",
205 | },
206 | },
207 | });
208 | const res = await client.signIn.email({
209 | email: testUser.email,
210 | password: testUser.password,
211 | });
212 | expect(res.data?.user).toBeDefined();
213 | });
214 |
215 | it("should reject untrusted redirectTo", async (ctx) => {
216 | const client = createAuthClient({
217 | baseURL: "http://localhost:3000",
218 | fetchOptions: {
219 | customFetchImpl,
220 | },
221 | });
222 | const res = await client.requestPasswordReset({
223 | email: testUser.email,
224 | redirectTo: "http://malicious.com",
225 | });
226 | expect(res.error?.status).toBe(403);
227 | expect(res.error?.message).toBe("Invalid redirectURL");
228 | });
229 |
230 | it("should work with list of trusted origins", async (ctx) => {
231 | const client = createAuthClient({
232 | baseURL: "http://localhost:3000",
233 | fetchOptions: {
234 | customFetchImpl,
235 | headers: {
236 | origin: "https://trusted.com",
237 | },
238 | },
239 | });
240 | const res = await client.requestPasswordReset({
241 | email: testUser.email,
242 | redirectTo: "http://localhost:5000/reset-password",
243 | });
244 | expect(res.data?.status).toBeTruthy();
245 |
246 | const res2 = await client.signIn.email({
247 | email: testUser.email,
248 | password: testUser.password,
249 | fetchOptions: {
250 | query: {
251 | currentURL: "http://localhost:5000",
252 | },
253 | },
254 | });
255 | expect(res2.data?.user).toBeDefined();
256 | });
257 |
258 | it("should work with wildcard trusted origins", async (ctx) => {
259 | const client = createAuthClient({
260 | baseURL: "https://sub-domain.my-site.com",
261 | fetchOptions: {
262 | customFetchImpl,
263 | headers: {
264 | origin: "https://sub-domain.my-site.com",
265 | },
266 | },
267 | });
268 | const res = await client.signIn.email({
269 | email: testUser.email,
270 | password: testUser.password,
271 | callbackURL: "https://sub-domain.my-site.com/callback",
272 | });
273 | expect(res.data?.user).toBeDefined();
274 |
275 | // Test another subdomain with the wildcard pattern
276 | const client2 = createAuthClient({
277 | baseURL: "https://another-sub.my-site.com",
278 | fetchOptions: {
279 | customFetchImpl,
280 | headers: {
281 | origin: "https://another-sub.my-site.com",
282 | },
283 | },
284 | });
285 | const res2 = await client2.signIn.email({
286 | email: testUser.email,
287 | password: testUser.password,
288 | callbackURL: "https://another-sub.my-site.com/callback",
289 | });
290 | expect(res2.data?.user).toBeDefined();
291 | });
292 |
293 | it("should work with GET requests", async (ctx) => {
294 | const client = createAuthClient({
295 | baseURL: "https://sub-domain.my-site.com",
296 | fetchOptions: {
297 | customFetchImpl,
298 | headers: {
299 | origin: "https://google.com",
300 | cookie: "value",
301 | },
302 | },
303 | });
304 | const res = await client.$fetch("/ok");
305 | expect(res.data).toMatchObject({ ok: true });
306 | });
307 |
308 | it("should handle POST requests with proper origin validation", async (ctx) => {
309 | // Test with valid origin
310 | const validClient = createAuthClient({
311 | baseURL: "http://localhost:3000",
312 | fetchOptions: {
313 | customFetchImpl,
314 | headers: {
315 | origin: "http://localhost:5000",
316 | cookie: "session=123",
317 | },
318 | },
319 | });
320 | const validRes = await validClient.signIn.email({
321 | email: testUser.email,
322 | password: testUser.password,
323 | });
324 | expect(validRes.data?.user).toBeDefined();
325 |
326 | // Test with invalid origin
327 | const invalidClient = createAuthClient({
328 | baseURL: "http://localhost:3000",
329 | fetchOptions: {
330 | customFetchImpl,
331 | headers: {
332 | origin: "http://untrusted-domain.com",
333 | cookie: "session=123",
334 | },
335 | },
336 | });
337 | const invalidRes = await invalidClient.signIn.email({
338 | email: testUser.email,
339 | password: testUser.password,
340 | });
341 | expect(invalidRes.error?.status).toBe(403);
342 | });
343 |
344 | it("should work with relative callbackURL with query params", async (ctx) => {
345 | const client = createAuthClient({
346 | baseURL: "http://localhost:3000",
347 | fetchOptions: {
348 | customFetchImpl,
349 | },
350 | });
351 | const res = await client.signIn.email({
352 | email: testUser.email,
353 | password: testUser.password,
354 | callbackURL: "/[email protected]",
355 | });
356 | expect(res.data?.user).toBeDefined();
357 | });
358 |
359 | it("should work with protocol specific wildcard trusted origins", async () => {
360 | // Test HTTPS protocol specific wildcard - should work
361 | const httpsClient = createAuthClient({
362 | baseURL: "http://localhost:3000",
363 | fetchOptions: {
364 | customFetchImpl,
365 | headers: {
366 | origin: "https://api.protocol-site.com",
367 | cookie: "session=123",
368 | },
369 | },
370 | });
371 | const httpsRes = await httpsClient.signIn.email({
372 | email: testUser.email,
373 | password: testUser.password,
374 | callbackURL: "https://app.protocol-site.com/dashboard",
375 | });
376 | expect(httpsRes.data?.user).toBeDefined();
377 |
378 | // Test HTTP with HTTPS protocol wildcard - should fail
379 | const httpClient = createAuthClient({
380 | baseURL: "http://localhost:3000",
381 | fetchOptions: {
382 | customFetchImpl,
383 | headers: {
384 | origin: "http://api.protocol-site.com",
385 | cookie: "session=123",
386 | },
387 | },
388 | });
389 | const httpRes = await httpClient.signIn.email({
390 | email: testUser.email,
391 | password: testUser.password,
392 | });
393 | expect(httpRes.error?.status).toBe(403);
394 | });
395 |
396 | it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => {
397 | const { customFetchImpl, testUser } = await getTestInstance({
398 | trustedOrigins: [
399 | "exp://10.0.0.*:*/*",
400 | "exp://192.168.*.*:*/*",
401 | "exp://172.*.*.*:*/*",
402 | ],
403 | emailAndPassword: {
404 | enabled: true,
405 | async sendResetPassword(url, user) {},
406 | },
407 | });
408 |
409 | // Test custom scheme with wildcard - should work
410 | const expoClient = createAuthClient({
411 | baseURL: "http://localhost:3000",
412 | fetchOptions: {
413 | customFetchImpl,
414 | },
415 | });
416 |
417 | // Test with IP matching the wildcard pattern
418 | const resWithIP = await expoClient.signIn.email({
419 | email: testUser.email,
420 | password: testUser.password,
421 | callbackURL: "exp://10.0.0.29:8081/--/",
422 | });
423 | expect(resWithIP.data?.user).toBeDefined();
424 |
425 | // Test with different IP range that matches
426 | const resWithIP2 = await expoClient.signIn.email({
427 | email: testUser.email,
428 | password: testUser.password,
429 | callbackURL: "exp://192.168.1.100:8081/--/",
430 | });
431 | expect(resWithIP2.data?.user).toBeDefined();
432 |
433 | // Test with different IP range that matches
434 | const resWithIP3 = await expoClient.signIn.email({
435 | email: testUser.email,
436 | password: testUser.password,
437 | callbackURL: "exp://172.16.0.1:8081/--/",
438 | });
439 | expect(resWithIP3.data?.user).toBeDefined();
440 |
441 | // Test with IP that doesn't match any pattern - should fail
442 | const resWithUnmatchedIP = await expoClient.signIn.email({
443 | email: testUser.email,
444 | password: testUser.password,
445 | callbackURL: "exp://203.0.113.0:8081/--/",
446 | });
447 | expect(resWithUnmatchedIP.error?.status).toBe(403);
448 | });
449 | });
450 |
451 | describe("origin check middleware", async (it) => {
452 | it("should return invalid origin", async () => {
453 | const { client } = await getTestInstance({
454 | trustedOrigins: ["https://trusted-site.com"],
455 | plugins: [
456 | {
457 | id: "test",
458 | endpoints: {
459 | test: createAuthEndpoint(
460 | "/test",
461 | {
462 | method: "GET",
463 | query: z.object({
464 | callbackURL: z.string(),
465 | }),
466 | use: [originCheck((c) => c.query.callbackURL)],
467 | },
468 | async (c) => {
469 | return c.query.callbackURL;
470 | },
471 | ),
472 | },
473 | },
474 | ],
475 | });
476 | const invalid = await client.$fetch(
477 | "/test?callbackURL=https://malicious-site.com",
478 | );
479 | expect(invalid.error?.status).toBe(403);
480 | const valid = await client.$fetch("/test?callbackURL=/dashboard");
481 | expect(valid.data).toBe("/dashboard");
482 | const validTrusted = await client.$fetch(
483 | "/test?callbackURL=https://trusted-site.com/path",
484 | );
485 | expect(validTrusted.data).toBe("https://trusted-site.com/path");
486 |
487 | const sampleInternalEndpointInvalid = await client.$fetch(
488 | "/verify-email?callbackURL=https://malicious-site.com&token=xyz",
489 | );
490 | expect(sampleInternalEndpointInvalid.error?.status).toBe(403);
491 | });
492 | });
493 |
494 | describe("is simple request", async (it) => {
495 | it("should return true for simple requests", async () => {
496 | const request = new Request("http://localhost:3000/test", {
497 | method: "GET",
498 | });
499 | const isSimple = isSimpleRequest(request.headers);
500 | expect(isSimple).toBe(true);
501 | });
502 |
503 | it("should return false for non-simple requests", async () => {
504 | const request = new Request("http://localhost:3000/test", {
505 | method: "POST",
506 | headers: {
507 | "custom-header": "value",
508 | },
509 | });
510 | const isSimple = isSimpleRequest(request.headers);
511 | expect(isSimple).toBe(false);
512 | });
513 |
514 | it("should return false for requests with a content type that is not simple", async () => {
515 | const request = new Request("http://localhost:3000/test", {
516 | method: "POST",
517 | headers: {
518 | "content-type": "application/json",
519 | },
520 | });
521 | const isSimple = isSimpleRequest(request.headers);
522 | expect(isSimple).toBe(false);
523 | });
524 |
525 | it;
526 | });
527 |
```
--------------------------------------------------------------------------------
/docs/content/docs/integrations/waku.mdx:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: Waku Integration
3 | description: Integrate Better Auth with Waku.
4 | ---
5 |
6 | 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).
7 |
8 | ## Create auth instance
9 |
10 | Create a file named `auth.ts` in your application. Import Better Auth and create your instance.
11 |
12 | <Callout type="warn">
13 | Make sure to export the auth instance with the variable name `auth` or as a `default` export.
14 | </Callout>
15 |
16 | ```ts title="src/auth.ts"
17 | import { betterAuth } from "better-auth"
18 |
19 | export const auth = betterAuth({
20 | database: {
21 | provider: "postgres", //change this to your database provider
22 | url: process.env.DATABASE_URL, // path to your database or connection string
23 | }
24 | })
25 | ```
26 |
27 | ## Create API Route
28 |
29 | 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:
30 |
31 | ```ts title="src/pages/api/auth/[...route].ts"
32 | import { auth } from "../../../auth" // Adjust the path as necessary
33 |
34 | export const GET = async (request: Request): Promise<Response> => {
35 | return auth.handler(request)
36 | }
37 |
38 | export const POST = async (request: Request): Promise<Response> => {
39 | return auth.handler(request)
40 | }
41 | ```
42 |
43 | <Callout type="info">
44 | You can change the path on your better-auth configuration but it's recommended to keep it as `src/pages/api/auth/[...route].ts`
45 | </Callout>
46 |
47 | ## Create a client
48 |
49 | Create a client instance. Here we are creating `auth-client.ts` file inside the `lib/` directory.
50 |
51 | ```ts title="src/lib/auth-client.ts"
52 | import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react
53 |
54 | export const authClient = createAuthClient({
55 | //you can pass client configuration here
56 | })
57 |
58 | export type Session = typeof authClient.$Infer.Session // you can infer typescript types from the authClient
59 | ```
60 |
61 | Once you have created the client, you can use it to sign up, sign in, and perform other actions.
62 | 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.
63 |
64 | 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.
65 |
66 | ## RSC and Server actions
67 |
68 | 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.
69 |
70 | **Example: Getting Session on a server action**
71 |
72 | ```tsx title="server.ts"
73 | "use server" // Waku currently only supports file-level "use server"
74 |
75 | import { auth } from "./auth"
76 | import { getContext } from "waku/middleware/context"
77 |
78 | export const someAuthenticatedAction = async () => {
79 | "use server"
80 | const session = await auth.api.getSession({
81 | headers: new Headers(getContext().req.headers),
82 | })
83 | };
84 | ```
85 |
86 | **Example: Getting Session on a RSC**
87 |
88 |
89 | ```tsx
90 | import { auth } from "../auth"
91 | import { getContext } from "waku/middleware/context"
92 |
93 | export async function ServerComponent() {
94 | const session = await auth.api.getSession({
95 | headers: new Headers(getContext().req.headers),
96 | })
97 | if(!session) {
98 | return <div>Not authenticated</div>
99 | }
100 | return (
101 | <div>
102 | <h1>Welcome {session.user.name}</h1>
103 | </div>
104 | )
105 | }
106 | ```
107 |
108 | <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>
109 |
110 | ### Server Action Cookies
111 |
112 | When you call a function that needs to set cookies, like `signInEmail` or `signUpEmail` in a server action, cookies won’t be set.
113 |
114 | We can create a plugin that works together with our middleware to set cookies.
115 |
116 | ```ts title="auth.ts"
117 | import { betterAuth } from "better-auth";
118 | import { wakuCookies } from "better-auth/waku";
119 | import { getContextData } from "waku/middleware/context";
120 |
121 | export const auth = betterAuth({
122 | //...your config
123 | plugins: [wakuCookies()] // make sure this is the last plugin in the array // [!code highlight]
124 | })
125 |
126 | function wakuCookies() {
127 | return {
128 | id: "waku-cookies",
129 | hooks: {
130 | after: [
131 | {
132 | matcher(ctx) {
133 | return true;
134 | },
135 | handler: createAuthMiddleware(async (ctx) => {
136 | const returned = ctx.context.responseHeaders;
137 | if ("_flag" in ctx && ctx._flag === "router") {
138 | return;
139 | }
140 | if (returned instanceof Headers) {
141 | const setCookieHeader = returned?.get("set-cookie");
142 | if (!setCookieHeader) return;
143 | const contextData = getContextData();
144 | contextData.betterAuthSetCookie = setCookieHeader;
145 | }
146 | }),
147 | },
148 | ],
149 | },
150 | } satisfies BetterAuthPlugin;
151 | }
152 | ```
153 |
154 | See below for the middleware to create to add the `contextData.betterAuthSetCookie` cookies to the response.
155 | Now, when you call functions that set cookies, they will be automatically set.
156 |
157 | ```ts
158 | "use server";
159 | import { auth } from "../auth"
160 |
161 | const signIn = async () => {
162 | await auth.api.signInEmail({
163 | body: {
164 | email: "[email protected]",
165 | password: "password",
166 | }
167 | })
168 | }
169 | ```
170 |
171 | ### Middleware
172 |
173 | 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.
174 |
175 | You can use the `getSessionCookie` helper from Better Auth for this purpose:
176 |
177 | <Callout type="warn">
178 | 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>.
179 | </Callout>
180 |
181 | ```ts title="src/middleware/auth.ts"
182 | import type { Middleware } from "waku/config"
183 | import { getSession } from "../auth"
184 | import { getSessionCookie } from "better-auth/cookies"
185 |
186 | const authMiddleware: Middleware = () => {
187 | return async (ctx, next) => {
188 | const sessionCookie = getSessionCookie(
189 | new Request(ctx.req.url, {
190 | body: ctx.req.body,
191 | headers: ctx.req.headers,
192 | method: ctx.req.method,
193 | })
194 | )
195 | // THIS IS NOT SECURE!
196 | // This is the recommended approach to optimistically redirect users
197 | // We recommend handling auth checks in each page/route
198 | if (!sessionCookie && ctx.req.url.pathname !== "/") {
199 | if (!ctx.req.url.pathname.endsWith(".txt")) {
200 | // Currently RSC requests end in .txt and don't handle redirect responses
201 | // The redirect needs to be encoded in the React flight stream somehow
202 | // There is some functionality in Waku to do this from a server component
203 | // but not from middleware.
204 | ctx.res.status = 302;
205 | ctx.res.headers = {
206 | Location: new URL("/", ctx.req.url).toString(),
207 | };
208 | }
209 | }
210 |
211 | // TODO possible to inspect ctx.req.url and not do this on every request
212 | // Or skip starting the promise here and just invoke from server components and functions
213 | getSession()
214 | await next()
215 | if (ctx.data.betterAuthSetCookie) {
216 | ctx.res.headers ||= {}
217 | let origSetCookie = ctx.res.headers["set-cookie"] || ([] as string[])
218 | if (typeof origSetCookie === "string") {
219 | origSetCookie = [origSetCookie]
220 | }
221 | ctx.res.headers["set-cookie"] = [
222 | ...origSetCookie,
223 | ctx.data.betterAuthSetCookie as string,
224 | ]
225 | }
226 | }
227 | };
228 |
229 | export default authMiddleware;
230 | ```
231 |
232 | <Callout type="warn">
233 | **Security Warning:** The `getSessionCookie` function only checks for the
234 | existence of a session cookie; it does **not** validate it. Relying solely
235 | on this check for security is dangerous, as anyone can manually create a
236 | cookie to bypass it. You must always validate the session on your server for
237 | any protected actions or pages.
238 | </Callout>
239 |
240 | <Callout type="info">
241 | If you have a custom cookie name or prefix, you can pass it to the `getSessionCookie` function.
242 | ```ts
243 | const sessionCookie = getSessionCookie(request, {
244 | cookieName: "my_session_cookie",
245 | cookiePrefix: "my_prefix"
246 | })
247 | ```
248 | </Callout>
249 |
250 | Alternatively, you can use the `getCookieCache` helper to get the session object from the cookie cache.
251 |
252 | ```ts
253 | import { getCookieCache } from "better-auth/cookies"
254 |
255 | const authMiddleware: Middleware = () => {
256 | return async (ctx, next) => {
257 | const session = await getCookieCache(ctx.req)
258 | if (!session && ctx.req.url.pathname !== "/") {
259 | if (!ctx.req.url.pathname.endsWith(".txt")) {
260 | ctx.res.status = 302
261 | ctx.res.headers = {
262 | Location: new URL("/", ctx.req.url).toString(),
263 | }
264 | }
265 | }
266 | }
267 | await next();
268 | }
269 | }
270 |
271 | export default authMiddleware;
272 | ```
273 |
274 | 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):
275 |
276 | ```ts title="waku.config.ts"
277 | import { defineConfig } from "waku/config";
278 |
279 | export default defineConfig({
280 | middleware: [
281 | "waku/middleware/context",
282 | "waku/middleware/dev-server",
283 | "./src/middleware/auth.ts",
284 | "waku/middleware/handler",
285 | ],
286 | });
287 | ```
288 |
289 | ### How to handle auth checks in each page/route
290 |
291 | In this example, we are using the `auth.api.getSession` function within a server component to get the session object,
292 | then we are checking if the session is valid. If it's not, we are redirecting the user to the sign-in page.
293 | Waku has `getContext` to get the request headers and `getContextData()` to store data per request. We can use this
294 | to avoid fetching the session more than once per request.
295 |
296 | ```ts title="auth.ts"
297 | import { getContext, getContextData } from "waku/middleware/context";
298 |
299 | // Code from above to create the server auth config
300 | // export const auth = ...
301 |
302 | export function getSession(): Promise<Session | null> {
303 | const contextData = getContextData();
304 | const ctx = getContext();
305 | const existingSessionPromise = contextData.sessionPromise as
306 | | Promise<Session | null>
307 | | undefined;
308 | if (existingSessionPromise) {
309 | return existingSessionPromise;
310 | }
311 | const sessionPromise = auth.api.getSession({
312 | headers: new Headers(ctx.req.headers),
313 | });
314 | contextData.sessionPromise = sessionPromise;
315 | return sessionPromise;
316 | }
317 | ```
318 |
319 |
320 | ```tsx title="src/pages/dashboard.tsx"
321 | import { getSession } from "../auth";
322 | import { unstable_redirect as redirect } from 'waku/router/server';
323 |
324 | export default async function DashboardPage() {
325 | const session = await getSession()
326 |
327 | if (!session) {
328 | redirect("/sign-in")
329 | }
330 |
331 | return (
332 | <div>
333 | <h1>Welcome {session.user.name}</h1>
334 | </div>
335 | )
336 | }
337 | ```
338 |
339 | ### Example usage
340 |
341 | #### Sign Up
342 |
343 | ```ts title="src/components/signup.tsx"
344 | "use client"
345 |
346 | import { useState } from "react"
347 | import { authClient } from "../lib/auth-client"
348 |
349 | export default function SignUp() {
350 | const [email, setEmail] = useState("")
351 | const [name, setName] = useState("")
352 | const [password, setPassword] = useState("")
353 |
354 | const signUp = async () => {
355 | await authClient.signUp.email(
356 | {
357 | email,
358 | password,
359 | name,
360 | },
361 | {
362 | onRequest: (ctx) => {
363 | // show loading state
364 | },
365 | onSuccess: (ctx) => {
366 | // redirect to home
367 | },
368 | onError: (ctx) => {
369 | alert(ctx.error)
370 | },
371 | },
372 | )
373 | }
374 |
375 | return (
376 | <div>
377 | <h2>
378 | Sign Up
379 | </h2>
380 | <form
381 | onSubmit={signUp}
382 | >
383 | <input
384 | type="text"
385 | value={name}
386 | onChange={(e) => setName(e.target.value)}
387 | placeholder="Name"
388 | />
389 | <input
390 | type="email"
391 | value={email}
392 | onChange={(e) => setEmail(e.target.value)}
393 | placeholder="Email"
394 | />
395 | <input
396 | type="password"
397 | value={password}
398 | onChange={(e) => setPassword(e.target.value)}
399 | placeholder="Password"
400 | />
401 | <button
402 | type="submit"
403 | >
404 | Sign Up
405 | </button>
406 | </form>
407 | </div>
408 | )
409 | }
410 |
411 | ```
412 |
413 | #### Sign In
414 |
415 | ```ts title="src/components/signin.tsx"
416 | "use client"
417 |
418 | import { useState } from "react"
419 | import { authClient } from "../lib/auth-client"
420 |
421 | export default function SignIn() {
422 | const [email, setEmail] = useState("")
423 | const [password, setPassword] = useState("")
424 |
425 | const signIn = async () => {
426 | await authClient.signIn.email(
427 | {
428 | email,
429 | password,
430 | },
431 | {
432 | onRequest: (ctx) => {
433 | // show loading state
434 | },
435 | onSuccess: (ctx) => {
436 | // redirect to home
437 | },
438 | onError: (ctx) => {
439 | alert(ctx.error)
440 | },
441 | },
442 | )
443 | }
444 |
445 | return (
446 | <div>
447 | <h2>
448 | Sign In
449 | </h2>
450 | <form onSubmit={signIn}>
451 | <input
452 | type="email"
453 | value={email}
454 | onChange={(e) => setEmail(e.target.value)}
455 | />
456 | <input
457 | type="password"
458 | value={password}
459 | onChange={(e) => setPassword(e.target.value)}
460 | />
461 | <button
462 | type="submit"
463 | >
464 | Sign In
465 | </button>
466 | </form>
467 | </div>
468 | )
469 | }
470 | ```
471 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/username/username.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect } from "vitest";
2 | import { getTestInstance } from "../../test-utils/test-instance";
3 | import { username } from ".";
4 | import { usernameClient } from "./client";
5 |
6 | describe("username", async (it) => {
7 | const { client, sessionSetter, signInWithTestUser } = await getTestInstance(
8 | {
9 | plugins: [
10 | username({
11 | minUsernameLength: 4,
12 | }),
13 | ],
14 | },
15 | {
16 | clientOptions: {
17 | plugins: [usernameClient()],
18 | },
19 | },
20 | );
21 |
22 | it("should sign up with username", async () => {
23 | const headers = new Headers();
24 | await client.signUp.email(
25 | {
26 | email: "[email protected]",
27 | username: "new_username",
28 | password: "new-password",
29 | name: "new-name",
30 | },
31 | {
32 | onSuccess: sessionSetter(headers),
33 | },
34 | );
35 | const session = await client.getSession({
36 | fetchOptions: {
37 | headers,
38 | throw: true,
39 | },
40 | });
41 | expect(session?.user.username).toBe("new_username");
42 | });
43 | const headers = new Headers();
44 | it("should sign-in with username", async () => {
45 | const res = await client.signIn.username(
46 | {
47 | username: "new_username",
48 | password: "new-password",
49 | },
50 | {
51 | onSuccess: sessionSetter(headers),
52 | },
53 | );
54 | expect(res.data?.token).toBeDefined();
55 | });
56 | it("should update username", async () => {
57 | const res = await client.updateUser({
58 | username: "new_username_2.1",
59 | fetchOptions: {
60 | headers,
61 | },
62 | });
63 |
64 | const session = await client.getSession({
65 | fetchOptions: {
66 | headers,
67 | throw: true,
68 | },
69 | });
70 | expect(session?.user.username).toBe("new_username_2.1");
71 | });
72 |
73 | it("should fail on duplicate username in sign-up", async () => {
74 | const res = await client.signUp.email({
75 | email: "[email protected]",
76 | username: "New_username_2.1",
77 | password: "new_password",
78 | name: "new-name",
79 | });
80 | expect(res.error?.status).toBe(422);
81 | });
82 |
83 | it("should fail on duplicate username in update-user if user is different", async () => {
84 | const newHeaders = new Headers();
85 | await client.signUp.email({
86 | email: "[email protected]",
87 | username: "duplicate-username",
88 | password: "new_password",
89 | name: "new-name",
90 | fetchOptions: {
91 | headers: newHeaders,
92 | },
93 | });
94 |
95 | const { headers: testUserHeaders } = await signInWithTestUser();
96 | const res = await client.updateUser({
97 | username: "duplicate-username",
98 | fetchOptions: {
99 | headers: testUserHeaders,
100 | },
101 | });
102 | expect(res.error?.status).toBe(400);
103 | });
104 |
105 | it("should succeed on duplicate username in update-user if user is the same", async () => {
106 | await client.updateUser({
107 | username: "New_username_2.1",
108 | fetchOptions: {
109 | headers,
110 | },
111 | });
112 |
113 | const session = await client.getSession({
114 | fetchOptions: {
115 | headers,
116 | throw: true,
117 | },
118 | });
119 | expect(session?.user.username).toBe("new_username_2.1");
120 | });
121 |
122 | it("should preserve both username and displayUsername when updating both", async () => {
123 | const updateRes = await client.updateUser({
124 | username: "priority_user",
125 | displayUsername: "Priority Display Name",
126 | fetchOptions: {
127 | headers,
128 | },
129 | });
130 |
131 | expect(updateRes.error).toBeNull();
132 |
133 | const session = await client.getSession({
134 | fetchOptions: {
135 | headers,
136 | throw: true,
137 | },
138 | });
139 |
140 | expect(session?.user.username).toBe("priority_user");
141 | expect(session?.user.displayUsername).toBe("Priority Display Name");
142 | });
143 |
144 | it("should fail on invalid username", async () => {
145 | const res = await client.signUp.email({
146 | email: "[email protected]",
147 | username: "new username",
148 | password: "new_password",
149 | name: "new-name",
150 | });
151 | expect(res.error?.status).toBe(400);
152 | expect(res.error?.code).toBe("USERNAME_IS_INVALID");
153 | });
154 |
155 | it("should fail on too short username", async () => {
156 | const res = await client.signUp.email({
157 | email: "[email protected]",
158 | username: "new",
159 | password: "new_password",
160 | name: "new-name",
161 | });
162 | expect(res.error?.status).toBe(400);
163 | expect(res.error?.code).toBe("USERNAME_IS_TOO_SHORT");
164 | });
165 |
166 | it("should fail on empty username", async () => {
167 | const res = await client.signUp.email({
168 | email: "[email protected]",
169 | username: "",
170 | password: "new_password",
171 | name: "new-name",
172 | });
173 | expect(res.error?.status).toBe(400);
174 | });
175 |
176 | it("should check if username is unavailable", async () => {
177 | const res = await client.isUsernameAvailable({
178 | username: "priority_user",
179 | });
180 | expect(res.data?.available).toEqual(false);
181 | });
182 |
183 | it("should check if username is unavailable with different case (normalization)", async () => {
184 | const res = await client.isUsernameAvailable({
185 | username: "PRIORITY_USER",
186 | });
187 | expect(res.data?.available).toEqual(false);
188 | });
189 |
190 | it("should check if username is available", async () => {
191 | const res = await client.isUsernameAvailable({
192 | username: "new_username_2.2",
193 | });
194 | expect(res.data?.available).toEqual(true);
195 | });
196 |
197 | it("should reject invalid username format in isUsernameAvailable", async () => {
198 | const res = await client.isUsernameAvailable({
199 | username: "invalid username!",
200 | });
201 | expect(res.error?.status).toBe(422);
202 | expect(res.error?.code).toBe("USERNAME_IS_INVALID");
203 | });
204 |
205 | it("should reject too short username in isUsernameAvailable", async () => {
206 | const res = await client.isUsernameAvailable({
207 | username: "abc",
208 | });
209 | expect(res.error?.status).toBe(422);
210 | expect(res.error?.code).toBe("USERNAME_IS_TOO_SHORT");
211 | });
212 |
213 | it("should reject too long username in isUsernameAvailable", async () => {
214 | const longUsername = "a".repeat(31);
215 | const res = await client.isUsernameAvailable({
216 | username: longUsername,
217 | });
218 | expect(res.error?.status).toBe(422);
219 | expect(res.error?.code).toBe("USERNAME_IS_TOO_LONG");
220 | });
221 |
222 | it("should not normalize displayUsername", async () => {
223 | const headers = new Headers();
224 | await client.signUp.email(
225 | {
226 | email: "[email protected]",
227 | displayUsername: "Test Username",
228 | password: "test-password",
229 | name: "test-name",
230 | },
231 | {
232 | onSuccess: sessionSetter(headers),
233 | },
234 | );
235 |
236 | const session = await client.getSession({
237 | fetchOptions: {
238 | headers,
239 | throw: true,
240 | },
241 | });
242 |
243 | expect(session?.user.username).toBe("test username");
244 | expect(session?.user.displayUsername).toBe("Test Username");
245 | });
246 |
247 | it("should preserve both username and displayUsername when both are provided", async () => {
248 | const headers = new Headers();
249 | await client.signUp.email(
250 | {
251 | email: "[email protected]",
252 | username: "custom_user",
253 | displayUsername: "Fancy Display Name",
254 | password: "test-password",
255 | name: "test-name",
256 | },
257 | {
258 | onSuccess: sessionSetter(headers),
259 | },
260 | );
261 |
262 | const session = await client.getSession({
263 | fetchOptions: {
264 | headers,
265 | throw: true,
266 | },
267 | });
268 |
269 | expect(session?.user.username).toBe("custom_user");
270 | expect(session?.user.displayUsername).toBe("Fancy Display Name");
271 | });
272 |
273 | it("should sign in with normalized username", async () => {
274 | const { client } = await getTestInstance(
275 | {
276 | plugins: [username()],
277 | },
278 | {
279 | clientOptions: {
280 | plugins: [usernameClient()],
281 | },
282 | },
283 | );
284 | await client.signUp.email({
285 | email: "[email protected]",
286 | username: "Custom_User",
287 | password: "test-password",
288 | name: "test-name",
289 | });
290 | const res2 = await client.signIn.username({
291 | username: "Custom_User",
292 | password: "test-password",
293 | });
294 | expect(res2.data?.user.username).toBe("custom_user");
295 | expect(res2.data?.user.displayUsername).toBe("Custom_User");
296 | });
297 | });
298 |
299 | describe("username custom normalization", async (it) => {
300 | const { client } = await getTestInstance(
301 | {
302 | plugins: [
303 | username({
304 | minUsernameLength: 4,
305 | usernameNormalization: (username) =>
306 | username.replaceAll("0", "o").replaceAll("4", "a").toLowerCase(),
307 | }),
308 | ],
309 | },
310 | {
311 | clientOptions: {
312 | plugins: [usernameClient()],
313 | },
314 | },
315 | );
316 |
317 | it("should sign up with username", async () => {
318 | const res = await client.signUp.email({
319 | email: "[email protected]",
320 | username: "H4XX0R",
321 | password: "new-password",
322 | name: "new-name",
323 | });
324 | expect(res.error).toBeNull();
325 | });
326 |
327 | it("should fail on duplicate username", async () => {
328 | const res = await client.signUp.email({
329 | email: "[email protected]",
330 | username: "haxxor",
331 | password: "new-password",
332 | name: "new-name",
333 | });
334 | expect(res.error?.status).toBe(400);
335 | });
336 |
337 | it("should normalize displayUsername", async () => {
338 | const { auth } = await getTestInstance({
339 | plugins: [
340 | username({
341 | displayUsernameNormalization: (displayUsername) =>
342 | displayUsername.toLowerCase(),
343 | }),
344 | ],
345 | });
346 | const res = await auth.api.signUpEmail({
347 | body: {
348 | email: "[email protected]",
349 | password: "new-password",
350 | name: "new-name",
351 | username: "test_username",
352 | displayUsername: "Test Username",
353 | },
354 | });
355 | const session = await auth.api.getSession({
356 | headers: new Headers({
357 | authorization: `Bearer ${res.token}`,
358 | }),
359 | });
360 | expect(session?.user.username).toBe("test_username");
361 | expect(session?.user.displayUsername).toBe("test username");
362 | });
363 | });
364 |
365 | describe("username with displayUsername validation", async (it) => {
366 | const { client, sessionSetter } = await getTestInstance(
367 | {
368 | plugins: [
369 | username({
370 | displayUsernameValidator: (displayUsername) =>
371 | /^[a-zA-Z0-9_-]+$/.test(displayUsername),
372 | }),
373 | ],
374 | },
375 | {
376 | clientOptions: {
377 | plugins: [usernameClient()],
378 | },
379 | },
380 | );
381 |
382 | it("should accept valid displayUsername", async () => {
383 | const res = await client.signUp.email({
384 | email: "[email protected]",
385 | displayUsername: "Valid_Display-123",
386 | password: "test-password",
387 | name: "test-name",
388 | });
389 | expect(res.error).toBeNull();
390 | });
391 |
392 | it("should reject invalid displayUsername", async () => {
393 | const res = await client.signUp.email({
394 | email: "[email protected]",
395 | displayUsername: "Invalid Display!",
396 | password: "test-password",
397 | name: "test-name",
398 | });
399 | expect(res.error?.status).toBe(400);
400 | expect(res.error?.code).toBe("DISPLAY_USERNAME_IS_INVALID");
401 | });
402 |
403 | it("should update displayUsername with valid value", async () => {
404 | const headers = new Headers();
405 | await client.signUp.email(
406 | {
407 | email: "[email protected]",
408 | displayUsername: "Initial_Name",
409 | password: "test-password",
410 | name: "test-name",
411 | },
412 | {
413 | onSuccess: sessionSetter(headers),
414 | },
415 | );
416 |
417 | const sessionBefore = await client.getSession({
418 | fetchOptions: {
419 | headers,
420 | throw: true,
421 | },
422 | });
423 | expect(sessionBefore?.user.displayUsername).toBe("Initial_Name");
424 | expect(sessionBefore?.user.username).toBe("initial_name");
425 |
426 | const res = await client.updateUser({
427 | displayUsername: "Updated_Name-123",
428 | fetchOptions: {
429 | headers,
430 | },
431 | });
432 |
433 | expect(res.error).toBeNull();
434 | const sessionAfter = await client.getSession({
435 | fetchOptions: {
436 | headers,
437 | throw: true,
438 | },
439 | });
440 | expect(sessionAfter?.user.displayUsername).toBe("Updated_Name-123");
441 | expect(sessionAfter?.user.username).toBe("updated_name-123");
442 | });
443 |
444 | it("should reject invalid displayUsername on update", async () => {
445 | const headers = new Headers();
446 | await client.signUp.email(
447 | {
448 | email: "[email protected]",
449 | displayUsername: "Valid_Name",
450 | password: "test-password",
451 | name: "test-name",
452 | },
453 | {
454 | onSuccess: sessionSetter(headers),
455 | },
456 | );
457 |
458 | const res = await client.updateUser({
459 | displayUsername: "Invalid Display!",
460 | fetchOptions: {
461 | headers,
462 | },
463 | });
464 |
465 | expect(res.error?.status).toBe(400);
466 | expect(res.error?.code).toBe("DISPLAY_USERNAME_IS_INVALID");
467 | });
468 | });
469 |
470 | describe("isUsernameAvailable with custom validator", async (it) => {
471 | const { client } = await getTestInstance(
472 | {
473 | plugins: [
474 | username({
475 | usernameValidator: async (username) => {
476 | return username.startsWith("user_");
477 | },
478 | }),
479 | ],
480 | },
481 | {
482 | clientOptions: {
483 | plugins: [usernameClient()],
484 | },
485 | },
486 | );
487 |
488 | it("should accept username with custom validator", async () => {
489 | const res = await client.isUsernameAvailable({
490 | username: "user_valid123",
491 | });
492 | expect(res.data?.available).toEqual(true);
493 | });
494 |
495 | it("should reject username that doesn't match custom validator", async () => {
496 | const res = await client.isUsernameAvailable({
497 | username: "invalid_user",
498 | });
499 | expect(res.error?.status).toBe(422);
500 | expect(res.error?.code).toBe("USERNAME_IS_INVALID");
501 | });
502 | });
503 |
504 | describe("post normalization flow", async (it) => {
505 | it("should set displayUsername to username if only username is provided", async () => {
506 | const { auth } = await getTestInstance({
507 | plugins: [
508 | username({
509 | validationOrder: {
510 | username: "post-normalization",
511 | displayUsername: "post-normalization",
512 | },
513 | usernameNormalization: (username) => {
514 | return username.split(" ").join("_").toLowerCase();
515 | },
516 | }),
517 | ],
518 | });
519 | const res = await auth.api.signUpEmail({
520 | body: {
521 | email: "[email protected]",
522 | username: "Test Username",
523 | password: "test-password",
524 | name: "test-name",
525 | },
526 | });
527 | const session = await auth.api.getSession({
528 | headers: new Headers({
529 | authorization: `Bearer ${res.token}`,
530 | }),
531 | });
532 | expect(session?.user.username).toBe("test_username");
533 | expect(session?.user.displayUsername).toBe("Test Username");
534 | });
535 | });
536 |
537 | describe("username email verification flow (no info leak)", async (it) => {
538 | const { client } = await getTestInstance(
539 | {
540 | emailAndPassword: { enabled: true, requireEmailVerification: true },
541 | plugins: [username()],
542 | },
543 | {
544 | clientOptions: {
545 | plugins: [usernameClient()],
546 | },
547 | },
548 | );
549 |
550 | it("returns INVALID_USERNAME_OR_PASSWORD for wrong password even if email is unverified", async () => {
551 | await client.signUp.email({
552 | email: "[email protected]",
553 | username: "unverified_user",
554 | password: "correct-password",
555 | name: "Unverified User",
556 | });
557 |
558 | const res = await client.signIn.username({
559 | username: "unverified_user",
560 | password: "wrong-password",
561 | });
562 |
563 | expect(res.error?.status).toBe(401);
564 | expect(res.error?.code).toBe("INVALID_USERNAME_OR_PASSWORD");
565 | });
566 |
567 | it("returns EMAIL_NOT_VERIFIED only after a correct password for an unverified user", async () => {
568 | const res = await client.signIn.username({
569 | username: "unverified_user",
570 | password: "correct-password",
571 | });
572 |
573 | expect(res.error?.status).toBe(403);
574 | expect(res.error?.code).toBe("EMAIL_NOT_VERIFIED");
575 | });
576 | });
577 |
```