This is page 25 of 51. Use http://codebase.md/better-auth/better-auth?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-declaration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.base.json ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /docs/components/ai-chat-modal.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { useState, useRef, useEffect } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { Send, Bot, User, AlertCircle } from "lucide-react"; import { MarkdownRenderer } from "./markdown-renderer"; import { betterFetch } from "@better-fetch/fetch"; import { atom } from "jotai"; interface Message { id: string; role: "user" | "assistant"; content: string; timestamp: Date; isStreaming?: boolean; } export const aiChatModalAtom = atom(false); interface AIChatModalProps { isOpen: boolean; onClose: () => void; } export function AIChatModal({ isOpen, onClose }: AIChatModalProps) { const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null); const [externalUserId] = useState<string>( () => `better-auth-user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, ); const messagesEndRef = useRef<HTMLDivElement>(null); const abortControllerRef = useRef<AbortController | null>(null); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; useEffect(() => { scrollToBottom(); }, [messages]); useEffect(() => { return () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, []); useEffect(() => { if (!isOpen) { setSessionId(null); setMessages([]); setInput(""); setApiError(null); } }, [isOpen]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || isLoading) return; const userMessage: Message = { id: Date.now().toString(), role: "user", content: input.trim(), timestamp: new Date(), }; setMessages((prev) => [...prev, userMessage]); setInput(""); setIsLoading(true); setApiError(null); const thinkingMessage: Message = { id: `thinking-${Date.now()}`, role: "assistant", content: "", timestamp: new Date(), isStreaming: false, }; setMessages((prev) => [...prev, thinkingMessage]); abortControllerRef.current = new AbortController(); try { const payload = { question: userMessage.content, stream: false, // Use non-streaming to get session_id session_id: sessionId, // Use existing session_id if available external_user_id: externalUserId, // Use consistent external_user_id for consistency on getting the context right fetch_existing: false, }; const { data, error } = await betterFetch<{ content?: string; answer?: string; response?: string; session_id?: string; }>("/api/ai-chat", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify(payload), signal: abortControllerRef.current.signal, }); if (error) { console.error("API Error Response:", error); throw new Error(`HTTP ${error.status}: ${error.message}`); } if (data.session_id) { setSessionId(data.session_id); } let answer = ""; if (data.content) { answer = data.content; } else if (data.answer) { answer = data.answer; } else if (data.response) { answer = data.response; } else if (typeof data === "string") { answer = data; } else { console.error("Unexpected response format:", data); throw new Error("Unexpected response format from API"); } await simulateStreamingEffect(answer, thinkingMessage.id); } catch (error) { if (error instanceof Error && error.name === "AbortError") { console.log("Request was aborted"); return; } console.error("Error calling AI API:", error); setMessages((prev) => prev.map((msg) => msg.id.startsWith("thinking-") ? { id: (Date.now() + 1).toString(), role: "assistant" as const, content: `I encountered an error while processing your request. Please try again.`, timestamp: new Date(), isStreaming: false, } : msg, ), ); if (error instanceof Error) { setApiError(error.message); } } finally { setIsLoading(false); abortControllerRef.current = null; } }; const simulateStreamingEffect = async ( fullContent: string, thinkingMessageId: string, ) => { const assistantMessageId = (Date.now() + 1).toString(); let displayedContent = ""; setMessages((prev) => prev.map((msg) => msg.id === thinkingMessageId ? { id: assistantMessageId, role: "assistant" as const, content: "", timestamp: new Date(), isStreaming: true, } : msg, ), ); const words = fullContent.split(" "); for (let i = 0; i < words.length; i++) { displayedContent += (i > 0 ? " " : "") + words[i]; setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId ? { ...msg, content: displayedContent } : msg, ), ); const delay = Math.random() * 50 + 20; await new Promise((resolve) => setTimeout(resolve, delay)); } setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId ? { ...msg, isStreaming: false } : msg, ), ); }; return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent className="max-w-4xl border-b h-[80vh] flex flex-col"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Bot className="h-5 w-5 text-primary" /> Ask AI About Better Auth </DialogTitle> <DialogDescription> Ask questions about Better-Auth and get AI-powered answers {apiError && ( <div className="flex items-center gap-2 mt-2 text-amber-600 dark:text-amber-400"> <AlertCircle className="h-4 w-4" /> <span className="text-xs"> API Error: Something went wrong. Please try again. </span> </div> )} </DialogDescription> </DialogHeader> <div className="flex-1 flex flex-col min-h-0"> <div className={cn( "flex-1 overflow-y-auto space-y-4 p-6", messages.length === 0 ? "overflow-y-hidden" : "overflow-y-auto", )} > {messages.length === 0 ? ( <div className="flex h-full flex-col items-center justify-center text-center"> <div className="mb-6"> <div className="w-16 h-16 mx-auto bg-transparent border border-input/70 border-dashed rounded-none flex items-center justify-center mb-4"> <Bot className="h-8 w-8 text-primary" /> </div> </div> <div className="mb-8 max-w-md"> <h3 className="text-xl font-semibold text-foreground mb-2"> Ask About Better Auth </h3> <p className="text-muted-foreground text-sm leading-relaxed"> I'm here to help you with Better Auth questions, setup guides, and implementation tips. Ask me anything! </p> </div> <div className="w-full max-w-lg"> <p className="text-sm font-medium text-foreground mb-4"> Try asking: </p> <div className="space-y-3"> {[ "How do I set up SSO with Google?", "How to integrate Better Auth with NextJs?", "How to setup Two Factor Authentication?", ].map((question, index) => ( <button key={index} onClick={() => setInput(question)} className="w-full text-left p-3 rounded-none border border-border/50 hover:border-primary/50 hover:bg-primary/5 transition-all duration-200 group" > <div className="flex items-center gap-3"> <div className="w-6 h-6 rounded-none bg-transparent border border-input/70 border-dashed flex items-center justify-center group-hover:bg-primary/20 transition-colors"> <span className="text-xs text-primary font-medium"> {index + 1} </span> </div> <span className="text-sm text-foreground group-hover:text-primary transition-colors"> {question} </span> </div> </button> ))} </div> </div> </div> ) : ( messages.map((message) => ( <div key={message.id} className={cn( "flex gap-3", message.role === "user" ? "justify-end" : "justify-start", )} > {message.role === "assistant" && ( <div className="flex-shrink-0"> <div className="w-8 h-8 rounded-full bg-transparent border border-input/70 border-dashed flex items-center justify-center"> <Bot className="h-4 w-4 text-primary" /> </div> </div> )} <div className={cn( "max-w-[80%] rounded-xl px-4 py-3 shadow-sm", message.role === "user" ? "bg-primary text-primary-foreground" : "bg-background border border-border/50", )} > {message.role === "assistant" ? ( <div className="w-full"> {message.id.startsWith("thinking-") ? ( <div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex space-x-1"> <div className="w-1 h-1 bg-primary rounded-full animate-bounce [animation-delay:-0.3s]"></div> <div className="w-1 h-1 bg-primary rounded-full animate-bounce [animation-delay:-0.15s]"></div> <div className="w-1 h-1 bg-primary rounded-full animate-bounce"></div> </div> <span>Thinking...</span> </div> ) : ( <> <MarkdownRenderer content={message.content} /> {message.isStreaming && ( <div className="inline-block w-2 h-4 bg-primary streaming-cursor ml-1" /> )} </> )} </div> ) : ( <p className="text-sm">{message.content}</p> )} </div> {message.role === "user" && ( <div className="flex-shrink-0"> <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center"> <User className="h-4 w-4" /> </div> </div> )} </div> )) )} <div ref={messagesEndRef} /> </div> <div className="border-t px-0 bg-background/50 backdrop-blur-sm p-4"> <div className="relative max-w-4xl mx-auto"> <div className={cn( "relative flex flex-col border-input rounded-lg transition-all duration-200 w-full text-left", "ring-1 ring-border/20 bg-muted/30 border-input border-1 backdrop-blur-sm", "focus-within:ring-primary/30 focus-within:bg-muted/[35%]", )} > <div className="overflow-y-auto max-h-[200px]"> <Textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask a question about Better-Auth..." className="w-full rounded-none rounded-b-none px-4 py-3 h-[70px] bg-transparent border-none text-foreground placeholder:text-muted-foreground resize-none focus-visible:ring-0 leading-[1.2] min-h-[52px] max-h-32" disabled={isLoading} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit(e); } }} /> </div> <div className="h-12 bg-muted/20 rounded-b-xl flex items-center justify-end px-3"> <button type="submit" onClick={(e) => { e.preventDefault(); void handleSubmit(e); }} disabled={!input.trim() || isLoading} className={cn( "rounded-lg p-2 transition-all duration-200", input.trim() && !isLoading ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-md" : "bg-muted/50 text-muted-foreground cursor-not-allowed", )} > {isLoading ? ( <div className="w-4 h-4 border-2 border-current/30 border-t-current rounded-full animate-spin" /> ) : ( <Send className="h-4 w-4" /> )} </button> </div> </div> </div> <div className="mt-3 text-center"> <p className="text-xs text-muted-foreground"> Press{" "} <kbd className="px-1.5 py-0.5 text-xs bg-muted rounded"> Enter </kbd>{" "} to send,{" "} <kbd className="px-1.5 py-0.5 text-xs bg-muted rounded"> Shift+Enter </kbd>{" "} for new line </p> </div> </div> </div> </DialogContent> </Dialog> ); } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/create-api-key.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { APIError, getSessionFromCtx } from "../../../api"; import { createAuthEndpoint } from "@better-auth/core/api"; import { API_KEY_TABLE_NAME, ERROR_CODES } from ".."; import { getDate } from "../../../utils/date"; import { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; import { defaultKeyHasher } from "../"; import type { AuthContext } from "@better-auth/core"; export function createApiKey({ keyGenerator, opts, schema, deleteAllExpiredApiKeys, }: { keyGenerator: (options: { length: number; prefix: string | undefined; }) => Promise<string> | string; opts: PredefinedApiKeyOptions; schema: ReturnType<typeof apiKeySchema>; deleteAllExpiredApiKeys( ctx: AuthContext, byPassLastCheckTime?: boolean, ): void; }) { return createAuthEndpoint( "/api-key/create", { method: "POST", body: z.object({ name: z .string() .meta({ description: "Name of the Api Key" }) .optional(), expiresIn: z .number() .meta({ description: "Expiration time of the Api Key in seconds", }) .min(1) .optional() .nullable() .default(null), userId: z.coerce .string() .meta({ description: 'User Id of the user that the Api Key belongs to. server-only. Eg: "user-id"', }) .optional(), prefix: z .string() .meta({ description: "Prefix of the Api Key" }) .regex(/^[a-zA-Z0-9_-]+$/, { message: "Invalid prefix format, must be alphanumeric and contain only underscores and hyphens.", }) .optional(), remaining: z .number() .meta({ description: "Remaining number of requests. Server side only", }) .min(0) .optional() .nullable() .default(null), metadata: z.any().optional(), refillAmount: z .number() .meta({ description: "Amount to refill the remaining count of the Api Key. server-only. Eg: 100", }) .min(1) .optional(), refillInterval: z .number() .meta({ description: "Interval to refill the Api Key in milliseconds. server-only. Eg: 1000", }) .optional(), rateLimitTimeWindow: z .number() .meta({ description: "The duration in milliseconds where each request is counted. Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. server-only. Eg: 1000", }) .optional(), rateLimitMax: z .number() .meta({ description: "Maximum amount of requests allowed within a window. Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. server-only. Eg: 100", }) .optional(), rateLimitEnabled: z .boolean() .meta({ description: "Whether the key has rate limiting enabled. server-only. Eg: true", }) .optional(), permissions: z .record(z.string(), z.array(z.string())) .meta({ description: "Permissions of the Api Key.", }) .optional(), }), metadata: { openapi: { description: "Create a new API key for a user", responses: { "200": { description: "API key created successfully", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the API key", }, createdAt: { type: "string", format: "date-time", description: "Creation timestamp", }, updatedAt: { type: "string", format: "date-time", description: "Last update timestamp", }, name: { type: "string", nullable: true, description: "Name of the API key", }, prefix: { type: "string", nullable: true, description: "Prefix of the API key", }, start: { type: "string", nullable: true, description: "Starting characters of the key (if configured)", }, key: { type: "string", description: "The full API key (only returned on creation)", }, enabled: { type: "boolean", description: "Whether the key is enabled", }, expiresAt: { type: "string", format: "date-time", nullable: true, description: "Expiration timestamp", }, userId: { type: "string", description: "ID of the user owning the key", }, lastRefillAt: { type: "string", format: "date-time", nullable: true, description: "Last refill timestamp", }, lastRequest: { type: "string", format: "date-time", nullable: true, description: "Last request timestamp", }, metadata: { type: "object", nullable: true, additionalProperties: true, description: "Metadata associated with the key", }, rateLimitMax: { type: "number", nullable: true, description: "Maximum requests in time window", }, rateLimitTimeWindow: { type: "number", nullable: true, description: "Rate limit time window in milliseconds", }, remaining: { type: "number", nullable: true, description: "Remaining requests", }, refillAmount: { type: "number", nullable: true, description: "Amount to refill", }, refillInterval: { type: "number", nullable: true, description: "Refill interval in milliseconds", }, rateLimitEnabled: { type: "boolean", description: "Whether rate limiting is enabled", }, requestCount: { type: "number", description: "Current request count in window", }, permissions: { type: "object", nullable: true, additionalProperties: { type: "array", items: { type: "string" }, }, description: "Permissions associated with the key", }, }, required: [ "id", "createdAt", "updatedAt", "key", "enabled", "userId", "rateLimitEnabled", "requestCount", ], }, }, }, }, }, }, }, }, async (ctx) => { const { name, expiresIn, prefix, remaining, metadata, refillAmount, refillInterval, permissions, rateLimitMax, rateLimitTimeWindow, rateLimitEnabled, } = ctx.body; const session = await getSessionFromCtx(ctx); const authRequired = ctx.request || ctx.headers; const user = authRequired && !session ? null : session?.user || { id: ctx.body.userId }; if (!user?.id) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.UNAUTHORIZED_SESSION, }); } if (session && ctx.body.userId && session?.user.id !== ctx.body.userId) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.UNAUTHORIZED_SESSION, }); } if (authRequired) { // if this endpoint was being called from the client, // we must make sure they can't use server-only properties. if ( refillAmount !== undefined || refillInterval !== undefined || rateLimitMax !== undefined || rateLimitTimeWindow !== undefined || rateLimitEnabled !== undefined || permissions !== undefined || remaining !== null ) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.SERVER_ONLY_PROPERTY, }); } } // if metadata is defined, than check that it's an object. if (metadata) { if (opts.enableMetadata === false) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.METADATA_DISABLED, }); } if (typeof metadata !== "object") { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_METADATA_TYPE, }); } } // make sure that if they pass a refill amount, they also pass a refill interval if (refillAmount && !refillInterval) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.REFILL_AMOUNT_AND_INTERVAL_REQUIRED, }); } // make sure that if they pass a refill interval, they also pass a refill amount if (refillInterval && !refillAmount) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.REFILL_INTERVAL_AND_AMOUNT_REQUIRED, }); } if (expiresIn) { if (opts.keyExpiration.disableCustomExpiresTime === true) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.KEY_DISABLED_EXPIRATION, }); } const expiresIn_in_days = expiresIn / (60 * 60 * 24); if (opts.keyExpiration.minExpiresIn > expiresIn_in_days) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.EXPIRES_IN_IS_TOO_SMALL, }); } else if (opts.keyExpiration.maxExpiresIn < expiresIn_in_days) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.EXPIRES_IN_IS_TOO_LARGE, }); } } if (prefix) { if (prefix.length < opts.minimumPrefixLength) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_PREFIX_LENGTH, }); } if (prefix.length > opts.maximumPrefixLength) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_PREFIX_LENGTH, }); } } if (name) { if (name.length < opts.minimumNameLength) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_NAME_LENGTH, }); } if (name.length > opts.maximumNameLength) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_NAME_LENGTH, }); } } else if (opts.requireName) { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.NAME_REQUIRED, }); } deleteAllExpiredApiKeys(ctx.context); const key = await keyGenerator({ length: opts.defaultKeyLength, prefix: prefix || opts.defaultPrefix, }); const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key); let start: string | null = null; if (opts.startingCharactersConfig.shouldStore) { start = key.substring( 0, opts.startingCharactersConfig.charactersLength, ); } const defaultPermissions = opts.permissions?.defaultPermissions ? typeof opts.permissions.defaultPermissions === "function" ? await opts.permissions.defaultPermissions(user.id, ctx) : opts.permissions.defaultPermissions : undefined; const permissionsToApply = permissions ? JSON.stringify(permissions) : defaultPermissions ? JSON.stringify(defaultPermissions) : undefined; let data: Omit<ApiKey, "id"> = { createdAt: new Date(), updatedAt: new Date(), name: name ?? null, prefix: prefix ?? opts.defaultPrefix ?? null, start: start, key: hashed, enabled: true, expiresAt: expiresIn ? getDate(expiresIn, "sec") : opts.keyExpiration.defaultExpiresIn ? getDate(opts.keyExpiration.defaultExpiresIn, "sec") : null, userId: user.id, lastRefillAt: null, lastRequest: null, metadata: null, rateLimitMax: rateLimitMax ?? opts.rateLimit.maxRequests ?? null, rateLimitTimeWindow: rateLimitTimeWindow ?? opts.rateLimit.timeWindow ?? null, remaining: remaining === null ? remaining : (remaining ?? refillAmount ?? null), refillAmount: refillAmount ?? null, refillInterval: refillInterval ?? null, rateLimitEnabled: rateLimitEnabled === undefined ? (opts.rateLimit.enabled ?? true) : rateLimitEnabled, requestCount: 0, //@ts-expect-error - we intentionally save the permissions as string on DB. permissions: permissionsToApply, }; if (metadata) { //@ts-expect-error - we intentionally save the metadata as string on DB. data.metadata = schema.apikey.fields.metadata.transform.input(metadata); } const apiKey = await ctx.context.adapter.create< Omit<ApiKey, "id">, ApiKey >({ model: API_KEY_TABLE_NAME, data: data, }); return ctx.json({ ...(apiKey as ApiKey), key: key, metadata: metadata ?? null, permissions: apiKey.permissions ? safeJSONParse(apiKey.permissions) : null, }); }, ); } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/to-auth-endpoints.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it } from "vitest"; import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; import { toAuthEndpoints } from "./to-auth-endpoints"; import { init } from "../init"; import * as z from "zod"; import { APIError } from "better-call"; import { getTestInstance } from "../test-utils/test-instance"; describe("before hook", async () => { describe("context", async () => { const endpoints = { query: createAuthEndpoint( "/query", { method: "GET", }, async (c) => { return c.query; }, ), body: createAuthEndpoint( "/body", { method: "POST", }, async (c) => { return c.body; }, ), params: createAuthEndpoint( "/params", { method: "GET", }, async (c) => { return c.params; }, ), headers: createAuthEndpoint( "/headers", { method: "GET", requireHeaders: true, }, async (c) => { return Object.fromEntries(c.headers.entries()); }, ), }; const authContext = init({ hooks: { before: createAuthMiddleware(async (c) => { switch (c.path) { case "/body": return { context: { body: { name: "body", }, }, }; case "/params": return { context: { params: { name: "params", }, }, }; case "/headers": return { context: { headers: new Headers({ name: "headers", }), }, }; } return { context: { query: { name: "query", }, }, }; }), }, }); const authEndpoints = toAuthEndpoints(endpoints, authContext); it("should return hook set query", async () => { const res = await authEndpoints.query(); expect(res?.name).toBe("query"); const res2 = await authEndpoints.query({ query: { key: "value", }, }); expect(res2).toMatchObject({ name: "query", key: "value", }); }); it("should return hook set body", async () => { const res = await authEndpoints.body(); expect(res?.name).toBe("body"); const res2 = await authEndpoints.body({ //@ts-expect-error body: { key: "value", }, }); expect(res2).toMatchObject({ name: "body", key: "value", }); }); it("should return hook set param", async () => { const res = await authEndpoints.params(); expect(res?.name).toBe("params"); const res2 = await authEndpoints.params({ params: { key: "value", }, }); expect(res2).toMatchObject({ name: "params", key: "value", }); }); it("should return hook set headers", async () => { const res = await authEndpoints.headers({ headers: new Headers({ key: "value", }), }); expect(res).toMatchObject({ key: "value", name: "headers" }); }); it("should replace existing array when hook provides another array", async () => { const endpoint = { body: createAuthEndpoint( "/body-array-replace", { method: "POST", body: z.object({ tags: z.array(z.string()) }) }, async (c) => c.body, ), }; const authContext = init({ hooks: { before: createAuthMiddleware(async (c) => { if (c.path === "/body-array-replace") { return { context: { body: { tags: ["a"], }, }, }; } }), }, }); const api = toAuthEndpoints(endpoint, authContext); const res = await api.body({ body: { tags: ["b", "c"], }, }); expect(res.tags).toEqual(["a"]); }); }); describe("response", async () => { const endpoints = { response: createAuthEndpoint( "/response", { method: "GET", }, async (c) => { return { response: true }; }, ), json: createAuthEndpoint( "/json", { method: "GET", }, async (c) => { return { response: true }; }, ), }; const authContext = init({ hooks: { before: createAuthMiddleware(async (c) => { if (c.path === "/json") { return { before: true }; } return new Response(JSON.stringify({ before: true })); }), }, }); const authEndpoints = toAuthEndpoints(endpoints, authContext); it("should return Response object", async () => { const response = await authEndpoints.response(); expect(response).toBeInstanceOf(Response); }); it("should return the hook response", async () => { const response = await authEndpoints.json(); expect(response).toMatchObject({ before: true }); }); }); }); describe("after hook", async () => { describe("response", async () => { const endpoints = { changeResponse: createAuthEndpoint( "/change-response", { method: "GET", }, async (c) => { return { hello: "world", }; }, ), throwError: createAuthEndpoint( "/throw-error", { method: "POST", query: z .object({ throwHook: z.boolean(), }) .optional(), }, async (c) => { throw c.error("BAD_REQUEST"); }, ), multipleHooks: createAuthEndpoint( "/multi-hooks", { method: "GET", }, async (c) => { return { return: "1", }; }, ), }; const authContext = init({ plugins: [ { id: "test", hooks: { after: [ { matcher() { return true; }, handler: createAuthMiddleware(async (c) => { if (c.path === "/multi-hooks") { return { return: "3", }; } }), }, ], }, }, ], hooks: { after: createAuthMiddleware(async (c) => { if (c.path === "/change-response") { return { hello: "auth", }; } if (c.path === "/multi-hooks") { return { return: "2", }; } if (c.query?.throwHook) { throw c.error("BAD_REQUEST", { message: "from after hook", }); } }), }, }); const api = toAuthEndpoints(endpoints, authContext); it("should change the response object from `hello:world` to `hello:auth`", async () => { const response = await api.changeResponse(); expect(response).toMatchObject({ hello: "auth" }); }); it("should return the last hook returned response", async () => { const response = await api.multipleHooks(); expect(response).toMatchObject({ return: "3", }); }); it("should return error as response", async () => { const response = await api.throwError({ asResponse: true, }); expect(response.status).toBe(400); }); it("should throw the last error", async () => { await api .throwError({ query: { throwHook: true, }, }) .catch((e) => { expect(e).toBeInstanceOf(APIError); expect(e?.message).toBe("from after hook"); }); }); }); describe("cookies", async () => { const endpoints = { cookies: createAuthEndpoint( "/cookies", { method: "POST", }, async (c) => { c.setCookie("session", "value"); return { hello: "world" }; }, ), cookieOverride: createAuthEndpoint( "/cookie", { method: "GET", }, async (c) => { c.setCookie("data", "1"); }, ), noCookie: createAuthEndpoint( "/no-cookie", { method: "GET", }, async (c) => {}, ), }; const authContext = init({ hooks: { after: createAuthMiddleware(async (c) => { c.setHeader("key", "value"); c.setCookie("data", "2"); }), }, }); const authEndpoints = toAuthEndpoints(endpoints, authContext); it("set cookies from both hook", async () => { const result = await authEndpoints.cookies({ asResponse: true, }); expect(result.headers.get("set-cookie")).toContain("session=value"); expect(result.headers.get("set-cookie")).toContain("data=2"); }); it("should override cookie", async () => { const result = await authEndpoints.cookieOverride({ asResponse: true, }); expect(result.headers.get("set-cookie")).toContain("data=2"); }); it("should only set the hook cookie", async () => { const result = await authEndpoints.noCookie({ asResponse: true, }); expect(result.headers.get("set-cookie")).toContain("data=2"); }); it("should return cookies from return headers", async () => { const result = await authEndpoints.noCookie({ returnHeaders: true, }); expect(result.headers.get("set-cookie")).toContain("data=2"); const result2 = await authEndpoints.cookies({ asResponse: true, }); expect(result2.headers.get("set-cookie")).toContain("session=value"); expect(result2.headers.get("set-cookie")).toContain("data=2"); }); }); }); describe("disabled paths", async () => { const { client } = await getTestInstance({ disabledPaths: ["/sign-in/email"], }); it("should return 404 for disabled paths", async () => { const response = await client.$fetch("/ok"); expect(response.data).toEqual({ ok: true }); const { error } = await client.signIn.email({ email: "[email protected]", password: "test", }); expect(error?.status).toBe(404); }); }); describe("debug mode stack trace", () => { it("should preserve stack trace when logger is in debug mode and APIError is thrown", async () => { const endpoints = { testEndpoint: createAuthEndpoint( "/test-error", { method: "GET" }, async () => { throw new APIError("BAD_REQUEST", { message: "Test error" }); }, ), }; const authContext = init({ logger: { level: "debug", }, }); const api = toAuthEndpoints(endpoints, authContext); try { await api.testEndpoint({}); } catch (error: any) { expect(error).toBeInstanceOf(APIError); expect(error.stack).toBeDefined(); expect(error.stack).toMatch(/ErrorWithStack:|Error:|APIError:/); expect(error.stack).toMatch(/at\s+/); } }); it("should not modify stack trace when logger is not in debug mode", async () => { const endpoints = { testEndpoint: createAuthEndpoint( "/test-error", { method: "GET" }, async () => { throw new APIError("BAD_REQUEST", { message: "Test error" }); }, ), }; const authContext = init({ logger: { level: "error", // Not debug mode }, }); const api = toAuthEndpoints(endpoints, authContext); try { await api.testEndpoint({}); } catch (error: any) { expect(error).toBeInstanceOf(APIError); // Stack should exist but may be minimal when not in debug mode expect(error.stack).toBeDefined(); } }); it("should have detailed stack trace in debug mode", async () => { const endpoints = { testEndpoint: createAuthEndpoint( "/test-error", { method: "GET" }, async () => { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Internal error occurred", }); }, ), }; const authContext = init({ logger: { level: "debug", }, }); const api = toAuthEndpoints(endpoints, authContext); try { await api.testEndpoint({}); } catch (error: any) { expect(error).toBeInstanceOf(APIError); expect(error.stack).toBeDefined(); // Check for stack trace format expect(error.stack).toMatch(/at\s+.*\(.*\)/); // Match "at functionName (file:line:col)" expect(error.stack).toMatch(/\.ts:\d+:\d+/); // Match TypeScript file with line:column } }); it("should handle APIError in hooks with debug mode", async () => { const endpoints = { testEndpoint: createAuthEndpoint( "/test-hook-error", { method: "GET" }, async () => { return { data: "success" }; }, ), }; const authContext = init({ logger: { level: "debug", }, hooks: { before: createAuthMiddleware(async () => { throw new APIError("FORBIDDEN", { message: "Forbidden action" }); }), }, }); const api = toAuthEndpoints(endpoints, authContext); try { await api.testEndpoint({}); } catch (error: any) { expect(error).toBeInstanceOf(APIError); expect(error.stack).toBeDefined(); expect(error.stack).toMatch(/ErrorWithStack:|Error:|APIError:/); expect(error.stack).toMatch(/at\s+/); } }); it("should handle Response containing APIError in debug mode", async () => { const endpoints = { testEndpoint: createAuthEndpoint( "/test-response-error", { method: "GET" }, async () => { throw new APIError("UNAUTHORIZED", { message: "Unauthorized access", }); }, ), }; const authContext = init({ logger: { level: "debug", }, }); const api = toAuthEndpoints(endpoints, authContext); // Test with asResponse = true to get Response object const response = await api.testEndpoint({ asResponse: true }); expect(response).toBeInstanceOf(Response); expect(response.status).toBe(401); // Test with asResponse = false to get thrown error try { await api.testEndpoint({ asResponse: false }); } catch (error: any) { expect(error).toBeInstanceOf(APIError); expect(error.stack).toBeDefined(); expect(error.stack).toMatch(/ErrorWithStack:|Error:|APIError:/); } }); }); ``` -------------------------------------------------------------------------------- /packages/expo/src/client.ts: -------------------------------------------------------------------------------- ```typescript import type { BetterAuthClientPlugin, Store } from "better-auth/types"; import * as Linking from "expo-linking"; import { Platform } from "react-native"; import Constants from "expo-constants"; import type { BetterFetchOption } from "@better-fetch/fetch"; interface CookieAttributes { value: string; expires?: Date; "max-age"?: number; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; sameSite?: "Strict" | "Lax" | "None"; } export function parseSetCookieHeader( header: string, ): Map<string, CookieAttributes> { const cookieMap = new Map<string, CookieAttributes>(); const cookies = splitSetCookieHeader(header); cookies.forEach((cookie) => { const parts = cookie.split(";").map((p) => p.trim()); const [nameValue, ...attributes] = parts; const [name, ...valueParts] = nameValue!.split("="); const value = valueParts.join("="); const cookieObj: CookieAttributes = { value }; attributes.forEach((attr) => { const [attrName, ...attrValueParts] = attr.split("="); const attrValue = attrValueParts.join("="); cookieObj[attrName!.toLowerCase() as "value"] = attrValue; }); cookieMap.set(name!, cookieObj); }); return cookieMap; } function splitSetCookieHeader(setCookie: string): string[] { const parts: string[] = []; let buffer = ""; let i = 0; while (i < setCookie.length) { const char = setCookie[i]; if (char === ",") { const recent = buffer.toLowerCase(); const hasExpires = recent.includes("expires="); const hasGmt = /gmt/i.test(recent); if (hasExpires && !hasGmt) { buffer += char; i += 1; continue; } if (buffer.trim().length > 0) { parts.push(buffer.trim()); buffer = ""; } i += 1; if (setCookie[i] === " ") i += 1; continue; } buffer += char; i += 1; } if (buffer.trim().length > 0) { parts.push(buffer.trim()); } return parts; } interface ExpoClientOptions { scheme?: string; storage: { setItem: (key: string, value: string) => any; getItem: (key: string) => string | null; }; /** * Prefix for local storage keys (e.g., "my-app_cookie", "my-app_session_data") * @default "better-auth" */ storagePrefix?: string; /** * Prefix for server cookie names to filter (e.g., "better-auth.session_token") * This is used to identify which cookies belong to better-auth to prevent * infinite refetching when third-party cookies are set. * @default "better-auth" */ cookiePrefix?: string; disableCache?: boolean; } interface StoredCookie { value: string; expires: string | null; } export function getSetCookie(header: string, prevCookie?: string) { const parsed = parseSetCookieHeader(header); let toSetCookie: Record<string, StoredCookie> = {}; parsed.forEach((cookie, key) => { const expiresAt = cookie["expires"]; const maxAge = cookie["max-age"]; const expires = maxAge ? new Date(Date.now() + Number(maxAge) * 1000) : expiresAt ? new Date(String(expiresAt)) : null; toSetCookie[key] = { value: cookie["value"], expires: expires ? expires.toISOString() : null, }; }); if (prevCookie) { try { const prevCookieParsed = JSON.parse(prevCookie); toSetCookie = { ...prevCookieParsed, ...toSetCookie, }; } catch { // } } return JSON.stringify(toSetCookie); } export function getCookie(cookie: string) { let parsed = {} as Record<string, StoredCookie>; try { parsed = JSON.parse(cookie) as Record<string, StoredCookie>; } catch (e) {} const toSend = Object.entries(parsed).reduce((acc, [key, value]) => { if (value.expires && new Date(value.expires) < new Date()) { return acc; } return `${acc}; ${key}=${value.value}`; }, ""); return toSend; } function getOrigin(scheme: string) { const schemeURI = Linking.createURL("", { scheme }); return schemeURI; } /** * Compare if session cookies have actually changed by comparing their values. * Ignores expiry timestamps that naturally change on each request. * * @param prevCookie - Previous cookie JSON string * @param newCookie - New cookie JSON string * @returns true if session cookies have changed, false otherwise */ function hasSessionCookieChanged( prevCookie: string | null, newCookie: string, ): boolean { if (!prevCookie) return true; try { const prev = JSON.parse(prevCookie) as Record<string, StoredCookie>; const next = JSON.parse(newCookie) as Record<string, StoredCookie>; // Get all session-related cookie keys (session_token, session_data) const sessionKeys = new Set<string>(); Object.keys(prev).forEach((key) => { if (key.includes("session_token") || key.includes("session_data")) { sessionKeys.add(key); } }); Object.keys(next).forEach((key) => { if (key.includes("session_token") || key.includes("session_data")) { sessionKeys.add(key); } }); // Compare the values of session cookies (ignore expires timestamps) for (const key of sessionKeys) { const prevValue = prev[key]?.value; const nextValue = next[key]?.value; if (prevValue !== nextValue) { return true; } } return false; } catch { // If parsing fails, assume cookie changed return true; } } /** * Check if the Set-Cookie header contains session-related better-auth cookies. * Only triggers session updates when session_token or session_data cookies are present. * This prevents infinite refetching when non-session cookies (like third-party cookies) change. * * Supports multiple cookie naming patterns: * - Default: "better-auth.session_token", "__Secure-better-auth.session_token" * - Custom prefix: "myapp.session_token", "__Secure-myapp.session_token" * - Custom full names: "my_custom_session_token", "custom_session_data" * - No prefix (cookiePrefix=""): "session_token", "my_session_token", etc. * * @param setCookieHeader - The Set-Cookie header value * @param cookiePrefix - The cookie prefix to check for. Can be empty string for custom cookie names. * @returns true if the header contains session-related cookies, false otherwise */ export function hasBetterAuthCookies( setCookieHeader: string, cookiePrefix: string, ): boolean { const cookies = parseSetCookieHeader(setCookieHeader); const sessionCookieSuffixes = ["session_token", "session_data"]; // Check if any cookie is a session-related cookie for (const name of cookies.keys()) { // Remove __Secure- prefix if present for comparison const nameWithoutSecure = name.startsWith("__Secure-") ? name.slice(9) : name; for (const suffix of sessionCookieSuffixes) { if (cookiePrefix) { // When prefix is provided, only match exact pattern: "prefix.suffix" if (nameWithoutSecure === `${cookiePrefix}.${suffix}`) { return true; } } else { // When prefix is empty, check for: // 1. Exact match: "session_token" // 2. Custom names ending with suffix: "my_custom_session_token" if (nameWithoutSecure.endsWith(suffix)) { return true; } } } } return false; } /** * Expo secure store does not support colons in the keys. * This function replaces colons with underscores. * * @see https://github.com/better-auth/better-auth/issues/5426 * * @param name cookie name to be saved in the storage * @returns normalized cookie name */ export function normalizeCookieName(name: string) { return name.replace(/:/g, "_"); } export function storageAdapter(storage: { getItem: (name: string) => string | null; setItem: (name: string, value: string) => void; }) { return { getItem: (name: string) => { return storage.getItem(normalizeCookieName(name)); }, setItem: (name: string, value: string) => { return storage.setItem(normalizeCookieName(name), value); }, }; } export const expoClient = (opts: ExpoClientOptions) => { let store: Store | null = null; const storagePrefix = opts?.storagePrefix || "better-auth"; const cookieName = `${storagePrefix}_cookie`; const localCacheName = `${storagePrefix}_session_data`; const storage = storageAdapter(opts?.storage); const isWeb = Platform.OS === "web"; const cookiePrefix = opts?.cookiePrefix || "better-auth"; const rawScheme = opts?.scheme || Constants.expoConfig?.scheme || Constants.platform?.scheme; const scheme = Array.isArray(rawScheme) ? rawScheme[0] : rawScheme; if (!scheme && !isWeb) { throw new Error( "Scheme not found in app.json. Please provide a scheme in the options.", ); } return { id: "expo", getActions(_, $store) { store = $store; return { /** * Get the stored cookie. * * You can use this to get the cookie stored in the device and use it in your fetch * requests. * * @example * ```ts * const cookie = client.getCookie(); * fetch("https://api.example.com", { * headers: { * cookie, * }, * }); */ getCookie: () => { const cookie = storage.getItem(cookieName); return getCookie(cookie || "{}"); }, }; }, fetchPlugins: [ { id: "expo", name: "Expo", hooks: { async onSuccess(context) { if (isWeb) return; const setCookie = context.response.headers.get("set-cookie"); if (setCookie) { // Only process and notify if the Set-Cookie header contains better-auth cookies // This prevents infinite refetching when other cookies (like Cloudflare's __cf_bm) are present if (hasBetterAuthCookies(setCookie, cookiePrefix)) { const prevCookie = await storage.getItem(cookieName); const toSetCookie = getSetCookie( setCookie || "", prevCookie ?? undefined, ); // Only notify $sessionSignal if the session cookie values actually changed // This prevents infinite refetching when the server sends the same cookie with updated expiry if (hasSessionCookieChanged(prevCookie, toSetCookie)) { await storage.setItem(cookieName, toSetCookie); store?.notify("$sessionSignal"); } else { // Still update the storage to refresh expiry times, but don't trigger refetch await storage.setItem(cookieName, toSetCookie); } } } if ( context.request.url.toString().includes("/get-session") && !opts?.disableCache ) { const data = context.data; storage.setItem(localCacheName, JSON.stringify(data)); } if ( context.data?.redirect && (context.request.url.toString().includes("/sign-in") || context.request.url.toString().includes("/link-social")) && !context.request?.body.includes("idToken") // id token is used for silent sign-in ) { const callbackURL = JSON.parse(context.request.body)?.callbackURL; const to = callbackURL; const signInURL = context.data?.url; let Browser: typeof import("expo-web-browser") | undefined = undefined; try { Browser = await import("expo-web-browser"); } catch (error) { throw new Error( '"expo-web-browser" is not installed as a dependency!', { cause: error, }, ); } const proxyURL = `${context.request.baseURL}/expo-authorization-proxy?authorizationURL=${encodeURIComponent(signInURL)}`; const result = await Browser.openAuthSessionAsync(proxyURL, to); if (result.type !== "success") return; const url = new URL(result.url); const cookie = String(url.searchParams.get("cookie")); if (!cookie) return; storage.setItem(cookieName, getSetCookie(cookie)); store?.notify("$sessionSignal"); } }, }, async init(url, options) { if (isWeb) { return { url, options: options as BetterFetchOption, }; } options = options || {}; const storedCookie = storage.getItem(cookieName); const cookie = getCookie(storedCookie || "{}"); options.credentials = "omit"; options.headers = { ...options.headers, cookie, "expo-origin": getOrigin(scheme!), "x-skip-oauth-proxy": "true", // skip oauth proxy for expo }; if (options.body?.callbackURL) { if (options.body.callbackURL.startsWith("/")) { const url = Linking.createURL(options.body.callbackURL, { scheme, }); options.body.callbackURL = url; } } if (options.body?.newUserCallbackURL) { if (options.body.newUserCallbackURL.startsWith("/")) { const url = Linking.createURL(options.body.newUserCallbackURL, { scheme, }); options.body.newUserCallbackURL = url; } } if (options.body?.errorCallbackURL) { if (options.body.errorCallbackURL.startsWith("/")) { const url = Linking.createURL(options.body.errorCallbackURL, { scheme, }); options.body.errorCallbackURL = url; } } if (url.includes("/sign-out")) { await storage.setItem(cookieName, "{}"); store?.atoms.session?.set({ ...store.atoms.session.get(), data: null, error: null, isPending: false, }); storage.setItem(localCacheName, "{}"); } return { url, options: options as BetterFetchOption, }; }, }, ], } satisfies BetterAuthClientPlugin; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { $deviceAuthorizationOptionsSchema, deviceAuthorization } from "."; import { deviceAuthorizationClient } from "./client"; import type { DeviceCode } from "./schema"; describe("device authorization plugin input validation", () => { it("basic validation", async () => { const options = $deviceAuthorizationOptionsSchema.parse({}); expect(options).toMatchInlineSnapshot(` { "deviceCodeLength": 40, "expiresIn": "30m", "interval": "5s", "userCodeLength": 8, } `); }); it("should validate custom options", async () => { const options = $deviceAuthorizationOptionsSchema.parse({ expiresIn: 60 * 1000, interval: 2 * 1000, deviceCodeLength: 50, userCodeLength: 10, }); expect(options).toMatchInlineSnapshot(` { "deviceCodeLength": 50, "expiresIn": 60000, "interval": 2000, "userCodeLength": 10, } `); }); }); describe("client validation", async () => { const validClients = ["valid-client-1", "valid-client-2"]; const { auth } = await getTestInstance({ plugins: [ deviceAuthorization({ validateClient: async (clientId) => { return validClients.includes(clientId); }, }), ], }); it("should reject invalid client in device code request", async () => { await expect( auth.api.deviceCode({ body: { client_id: "invalid-client", }, }), ).rejects.toMatchObject({ body: { error: "invalid_client", error_description: "Invalid client ID", }, }); }); it("should accept valid client in device code request", async () => { const response = await auth.api.deviceCode({ body: { client_id: "valid-client-1", }, }); expect(response.device_code).toBeDefined(); }); it("should reject invalid client in token request", async () => { const { device_code } = await auth.api.deviceCode({ body: { client_id: "valid-client-1", }, }); await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id: "invalid-client", }, }), ).rejects.toMatchObject({ body: { error: "invalid_grant", error_description: "Invalid client ID", }, }); }); it("should reject mismatched client_id in token request", async () => { const { device_code } = await auth.api.deviceCode({ body: { client_id: "valid-client-1", }, }); await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id: "valid-client-2", }, }), ).rejects.toMatchObject({ body: { error: "invalid_grant", error_description: "Client ID mismatch", }, }); }); }); describe("device authorization flow", async () => { const { auth, client, sessionSetter, signInWithTestUser } = await getTestInstance( { plugins: [ deviceAuthorization({ expiresIn: "5min", interval: "2s", }), ], }, { clientOptions: { plugins: [deviceAuthorizationClient()], }, }, ); describe("device code request", () => { it("should generate device and user codes", async () => { const response = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); expect(response.device_code).toBeDefined(); expect(response.user_code).toBeDefined(); expect(response.verification_uri).toBeDefined(); expect(response.verification_uri_complete).toBeDefined(); expect(response.expires_in).toBe(300); expect(response.interval).toBe(2); expect(response.user_code).toMatch(/^[A-Z0-9]{8}$/); expect(response.verification_uri_complete).toContain(response.user_code); }); it("should support custom client ID and scope", async () => { const response = await auth.api.deviceCode({ body: { client_id: "test-client", scope: "read write", }, }); expect(response.device_code).toBeDefined(); expect(response.user_code).toBeDefined(); }); }); describe("device token polling", () => { it("should return authorization_pending when not approved", async () => { const { device_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }), ).rejects.toMatchObject({ body: { error: "authorization_pending", error_description: "Authorization pending", }, }); }); it("should return expired_token for expired device codes", async () => { const { device_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); // Advance time past expiration vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(301 * 1000); // 301 seconds await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }), ).rejects.toMatchObject({ body: { error: "expired_token", error_description: "Device code has expired", }, }); vi.useRealTimers(); }); it("should return error for invalid device code", async () => { await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: "invalid-code", client_id: "test-client", }, }), ).rejects.toMatchObject({ body: { error: "invalid_grant", error_description: "Invalid device code", }, }); }); }); describe("device verification", () => { it("should verify valid user code", async () => { const { user_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); const response = await auth.api.deviceVerify({ query: { user_code }, }); expect("error" in response).toBe(false); if (!("error" in response)) { expect(response.user_code).toBe(user_code); expect(response.status).toBe("pending"); } }); it("should handle invalid user code", async () => { await expect( auth.api.deviceVerify({ query: { user_code: "INVALID" }, }), ).rejects.toMatchObject({ body: { error: "invalid_request", error_description: "Invalid user code", }, }); }); }); describe("device approval flow", () => { it("should approve device and create session", async () => { // First, sign in as a user const { headers } = await signInWithTestUser(); // Request device code const { device_code, user_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); // Approve the device const approveResponse = await auth.api.deviceApprove({ body: { userCode: user_code }, headers, }); expect("success" in approveResponse && approveResponse.success).toBe( true, ); // Poll for token should now succeed const tokenResponse = await auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }); // Check OAuth 2.0 compliant response expect("access_token" in tokenResponse).toBe(true); if ("access_token" in tokenResponse) { expect(tokenResponse.access_token).toBeDefined(); expect(tokenResponse.token_type).toBe("Bearer"); expect(tokenResponse.expires_in).toBeGreaterThan(0); expect(tokenResponse.scope).toBeDefined(); } }); it("should deny device authorization", async () => { const { device_code, user_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); // Deny the device const denyResponse = await auth.api.deviceDeny({ body: { userCode: user_code }, headers: new Headers(), }); expect("success" in denyResponse && denyResponse.success).toBe(true); // Poll for token should return access_denied await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }), ).rejects.toMatchObject({ body: { error: "access_denied", error_description: "Access denied", }, }); }); it("should require authentication for approval", async () => { const { user_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); await expect( auth.api.deviceApprove({ body: { userCode: user_code }, headers: new Headers(), }), ).rejects.toMatchObject({ body: { error: "unauthorized", error_description: "Authentication required", }, }); }); it("should enforce rate limiting with slow_down error", async () => { const { device_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); await auth.api .deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }) .catch( // ignore the error () => {}, ); await expect( auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }), ).rejects.toMatchObject({ body: { error: "slow_down", error_description: "Polling too frequently", }, }); }); }); describe("edge cases", () => { it("should not allow approving already processed device code", async () => { // Sign in as a user const { headers } = await signInWithTestUser(); // Request and approve device const { user_code: userCode } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); await auth.api.deviceApprove({ body: { userCode }, headers, }); await expect( auth.api.deviceApprove({ body: { userCode }, headers, }), ).rejects.toMatchObject({ body: { error: "invalid_request", error_description: "Device code already processed", }, }); }); it("should handle user code without dashes", async () => { const { user_code } = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); const cleanUserCode = user_code.replace(/-/g, ""); const response = await auth.api.deviceVerify({ query: { user_code: cleanUserCode }, }); expect("status" in response && response.status).toBe("pending"); }); it("should store and use scope from device code request", async () => { const { headers } = await signInWithTestUser(); const { device_code, user_code } = await auth.api.deviceCode({ body: { client_id: "test-client", scope: "read write profile", }, }); await auth.api.deviceApprove({ body: { userCode: user_code }, headers, }); const tokenResponse = await auth.api.deviceToken({ body: { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: device_code, client_id: "test-client", }, }); expect("scope" in tokenResponse && tokenResponse.scope).toBe( "read write profile", ); }); }); }); describe("device authorization with custom options", async () => { it("should correctly store interval as milliseconds in database", async () => { const { auth, client, db } = await getTestInstance({ plugins: [ deviceAuthorization({ interval: "5s", }), ], }); const response = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); // Response should return interval in seconds expect(response.interval).toBe(5); // Check that the interval is stored as milliseconds in the database const deviceCodeRecord: DeviceCode | null = await db.findOne({ model: "deviceCode", where: [ { field: "deviceCode", value: response.device_code, }, ], }); // Should be stored as 5000 milliseconds, not "5s" string expect(deviceCodeRecord?.pollingInterval).toBe(5000); expect(typeof deviceCodeRecord?.pollingInterval).toBe("number"); }); it("should use custom code generators", async () => { const customDeviceCode = "custom-device-code-12345"; const customUserCode = "CUSTOM12"; const { auth } = await getTestInstance({ plugins: [ deviceAuthorization({ generateDeviceCode: () => customDeviceCode, generateUserCode: () => customUserCode, }), ], }); const response = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); expect(response.device_code).toBe(customDeviceCode); expect(response.user_code).toBe(customUserCode); }); it("should respect custom expiration time", async () => { const { auth } = await getTestInstance({ plugins: [ deviceAuthorization({ expiresIn: "1min", }), ], }); const response = await auth.api.deviceCode({ body: { client_id: "test-client", }, }); expect(response.expires_in).toBe(60); }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/two-factor/backup-codes/index.ts: -------------------------------------------------------------------------------- ```typescript import { generateRandomString } from "../../../crypto/random"; import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import { sessionMiddleware } from "../../../api"; import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto"; import type { TwoFactorProvider, TwoFactorTable, UserWithTwoFactor, } from "../types"; import { APIError } from "better-call"; import { TWO_FACTOR_ERROR_CODES } from "../error-code"; import { verifyTwoFactor } from "../verify-two-factor"; import { safeJSONParse } from "../../../utils/json"; export interface BackupCodeOptions { /** * The amount of backup codes to generate * * @default 10 */ amount?: number; /** * The length of the backup codes * * @default 10 */ length?: number; /** * An optional custom function to generate backup codes */ customBackupCodesGenerate?: () => string[]; /** * How to store the backup codes in the database, whether encrypted or plain. */ storeBackupCodes?: | "plain" | "encrypted" | { encrypt: (token: string) => Promise<string>; decrypt: (token: string) => Promise<string>; }; } function generateBackupCodesFn(options?: BackupCodeOptions) { return Array.from({ length: options?.amount ?? 10 }) .fill(null) .map(() => generateRandomString(options?.length ?? 10, "a-z", "0-9", "A-Z")) .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`); } export async function generateBackupCodes( secret: string, options?: BackupCodeOptions, ) { const backupCodes = options?.customBackupCodesGenerate ? options.customBackupCodesGenerate() : generateBackupCodesFn(options); if (options?.storeBackupCodes === "encrypted") { const encCodes = await symmetricEncrypt({ data: JSON.stringify(backupCodes), key: secret, }); return { backupCodes, encryptedBackupCodes: encCodes, }; } if ( typeof options?.storeBackupCodes === "object" && "encrypt" in options?.storeBackupCodes ) { return { backupCodes, encryptedBackupCodes: await options?.storeBackupCodes.encrypt( JSON.stringify(backupCodes), ), }; } return { backupCodes, encryptedBackupCodes: JSON.stringify(backupCodes), }; } export async function verifyBackupCode( data: { backupCodes: string; code: string; }, key: string, options?: BackupCodeOptions, ) { const codes = await getBackupCodes(data.backupCodes, key, options); if (!codes) { return { status: false, updated: null, }; } return { status: codes.includes(data.code), updated: codes.filter((code) => code !== data.code), }; } export async function getBackupCodes( backupCodes: string, key: string, options?: BackupCodeOptions, ) { if (options?.storeBackupCodes === "encrypted") { const decrypted = await symmetricDecrypt({ key, data: backupCodes }); return safeJSONParse<string[]>(decrypted); } if ( typeof options?.storeBackupCodes === "object" && "decrypt" in options?.storeBackupCodes ) { const decrypted = await options?.storeBackupCodes.decrypt(backupCodes); return safeJSONParse<string[]>(decrypted); } return safeJSONParse<string[]>(backupCodes); } export const backupCode2fa = (opts: BackupCodeOptions) => { const twoFactorTable = "twoFactor"; return { id: "backup_code", endpoints: { /** * ### Endpoint * * POST `/two-factor/verify-backup-code` * * ### API Methods * * **server:** * `auth.api.verifyBackupCode` * * **client:** * `authClient.twoFactor.verifyBackupCode` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-verify-backup-code) */ verifyBackupCode: createAuthEndpoint( "/two-factor/verify-backup-code", { method: "POST", body: z.object({ code: z.string().meta({ description: `A backup code to verify. Eg: "123456"`, }), /** * Disable setting the session cookie */ disableSession: z .boolean() .meta({ description: "If true, the session cookie will not be set.", }) .optional(), /** * if true, the device will be trusted * for 30 days. It'll be refreshed on * every sign in request within this time. */ trustDevice: z .boolean() .meta({ description: "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true", }) .optional(), }), metadata: { openapi: { description: "Verify a backup code for two-factor authentication", responses: { "200": { description: "Backup code verified successfully", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", properties: { id: { type: "string", description: "Unique identifier of the user", }, email: { type: "string", format: "email", nullable: true, description: "User's email address", }, emailVerified: { type: "boolean", nullable: true, description: "Whether the email is verified", }, name: { type: "string", nullable: true, description: "User's name", }, image: { type: "string", format: "uri", nullable: true, description: "User's profile image URL", }, twoFactorEnabled: { type: "boolean", description: "Whether two-factor authentication is enabled for the user", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the user was created", }, updatedAt: { type: "string", format: "date-time", description: "Timestamp when the user was last updated", }, }, required: [ "id", "twoFactorEnabled", "createdAt", "updatedAt", ], description: "The authenticated user object with two-factor details", }, session: { type: "object", properties: { token: { type: "string", description: "Session token", }, userId: { type: "string", description: "ID of the user associated with the session", }, createdAt: { type: "string", format: "date-time", description: "Timestamp when the session was created", }, expiresAt: { type: "string", format: "date-time", description: "Timestamp when the session expires", }, }, required: [ "token", "userId", "createdAt", "expiresAt", ], description: "The current session object, included unless disableSession is true", }, }, required: ["user", "session"], }, }, }, }, }, }, }, }, async (ctx) => { const { session, valid } = await verifyTwoFactor(ctx); const user = session.user as UserWithTwoFactor; const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({ model: twoFactorTable, where: [ { field: "userId", value: user.id, }, ], }); if (!twoFactor) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED, }); } const validate = await verifyBackupCode( { backupCodes: twoFactor.backupCodes, code: ctx.body.code, }, ctx.context.secret, opts, ); if (!validate.status) { throw new APIError("UNAUTHORIZED", { message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE, }); } const updatedBackupCodes = await symmetricEncrypt({ key: ctx.context.secret, data: JSON.stringify(validate.updated), }); await ctx.context.adapter.updateMany({ model: twoFactorTable, update: { backupCodes: updatedBackupCodes, }, where: [ { field: "userId", value: user.id, }, ], }); if (!ctx.body.disableSession) { return valid(ctx); } return ctx.json({ token: session.session?.token, user: { id: session.user?.id, email: session.user.email, emailVerified: session.user.emailVerified, name: session.user.name, image: session.user.image, createdAt: session.user.createdAt, updatedAt: session.user.updatedAt, }, }); }, ), /** * ### Endpoint * * POST `/two-factor/generate-backup-codes` * * ### API Methods * * **server:** * `auth.api.generateBackupCodes` * * **client:** * `authClient.twoFactor.generateBackupCodes` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-generate-backup-codes) */ generateBackupCodes: createAuthEndpoint( "/two-factor/generate-backup-codes", { method: "POST", body: z.object({ password: z.string().meta({ description: "The users password.", }), }), use: [sessionMiddleware], metadata: { openapi: { description: "Generate new backup codes for two-factor authentication", responses: { "200": { description: "Backup codes generated successfully", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", description: "Indicates if the backup codes were generated successfully", enum: [true], }, backupCodes: { type: "array", items: { type: "string" }, description: "Array of generated backup codes in plain text", }, }, required: ["status", "backupCodes"], }, }, }, }, }, }, }, }, async (ctx) => { const user = ctx.context.session.user as UserWithTwoFactor; if (!user.twoFactorEnabled) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED, }); } await ctx.context.password.checkPassword(user.id, ctx); const backupCodes = await generateBackupCodes( ctx.context.secret, opts, ); await ctx.context.adapter.updateMany({ model: twoFactorTable, update: { backupCodes: backupCodes.encryptedBackupCodes, }, where: [ { field: "userId", value: ctx.context.session.user.id, }, ], }); return ctx.json({ status: true, backupCodes: backupCodes.backupCodes, }); }, ), /** * ### Endpoint * * GET `/two-factor/view-backup-codes` * * ### API Methods * * **server:** * `auth.api.viewBackupCodes` * * **client:** * `authClient.twoFactor.viewBackupCodes` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-view-backup-codes) */ viewBackupCodes: createAuthEndpoint( "/two-factor/view-backup-codes", { method: "GET", body: z.object({ userId: z.coerce.string().meta({ description: `The user ID to view all backup codes. Eg: "user-id"`, }), }), metadata: { SERVER_ONLY: true, }, }, async (ctx) => { const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({ model: twoFactorTable, where: [ { field: "userId", value: ctx.body.userId, }, ], }); if (!twoFactor) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED, }); } const decryptedBackupCodes = await getBackupCodes( twoFactor.backupCodes, ctx.context.secret, opts, ); if (!decryptedBackupCodes) { throw new APIError("BAD_REQUEST", { message: TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE, }); } return ctx.json({ status: true, backupCodes: decryptedBackupCodes, }); }, ), }, } satisfies TwoFactorProvider; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts: -------------------------------------------------------------------------------- ```typescript import { and, asc, count, desc, eq, gt, gte, inArray, notInArray, like, lt, lte, ne, or, sql, SQL, } from "drizzle-orm"; import { BetterAuthError } from "@better-auth/core/error"; import type { BetterAuthOptions } from "@better-auth/core"; import { createAdapterFactory, type AdapterFactoryOptions, type AdapterFactoryCustomizeAdapterCreator, } from "../adapter-factory"; import type { DBAdapterDebugLogOption, DBAdapter, Where, } from "@better-auth/core/db/adapter"; export interface DB { [key: string]: any; } export interface DrizzleAdapterConfig { /** * The schema object that defines the tables and fields */ schema?: Record<string, any>; /** * The database provider */ provider: "pg" | "mysql" | "sqlite"; /** * If the table names in the schema are plural * set this to true. For example, if the schema * has an object with a key "users" instead of "user" */ usePlural?: boolean; /** * Enable debug logs for the adapter * * @default false */ debugLogs?: DBAdapterDebugLogOption; /** * By default snake case is used for table and field names * when the CLI is used to generate the schema. If you want * to use camel case, set this to true. * @default false */ camelCase?: boolean; /** * Whether to execute multiple operations in a transaction. * * If the database doesn't support transactions, * set this to `false` and operations will be executed sequentially. * @default false */ transaction?: boolean; } export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { let lazyOptions: BetterAuthOptions | null = null; const createCustomAdapter = (db: DB): AdapterFactoryCustomizeAdapterCreator => ({ getFieldName, debugLog }) => { function getSchema(model: string) { const schema = config.schema || db._.fullSchema; if (!schema) { throw new BetterAuthError( "Drizzle adapter failed to initialize. Schema not found. Please provide a schema object in the adapter options object.", ); } const schemaModel = schema[model]; if (!schemaModel) { throw new BetterAuthError( `[# Drizzle Adapter]: The model "${model}" was not found in the schema object. Please pass the schema directly to the adapter options.`, ); } return schemaModel; } const withReturning = async ( model: string, builder: any, data: Record<string, any>, where?: Where[], ) => { if (config.provider !== "mysql") { const c = await builder.returning(); return c[0]; } await builder.execute(); const schemaModel = getSchema(model); const builderVal = builder.config?.values; if (where?.length) { // If we're updating a field that's in the where clause, use the new value const updatedWhere = where.map((w) => { // If this field was updated, use the new value for lookup if (data[w.field] !== undefined) { return { ...w, value: data[w.field] }; } return w; }); const clause = convertWhereClause(updatedWhere, model); const res = await db .select() .from(schemaModel) .where(...clause); return res[0]; } else if (builderVal && builderVal[0]?.id?.value) { let tId = builderVal[0]?.id?.value; if (!tId) { //get last inserted id const lastInsertId = await db .select({ id: sql`LAST_INSERT_ID()` }) .from(schemaModel) .orderBy(desc(schemaModel.id)) .limit(1); tId = lastInsertId[0].id; } const res = await db .select() .from(schemaModel) .where(eq(schemaModel.id, tId)) .limit(1) .execute(); return res[0]; } else if (data.id) { const res = await db .select() .from(schemaModel) .where(eq(schemaModel.id, data.id)) .limit(1) .execute(); return res[0]; } else { // If the user doesn't have `id` as a field, then this will fail. // We expect that they defined `id` in all of their models. if (!("id" in schemaModel)) { throw new BetterAuthError( `The model "${model}" does not have an "id" field. Please use the "id" field as your primary key.`, ); } const res = await db .select() .from(schemaModel) .orderBy(desc(schemaModel.id)) .limit(1) .execute(); return res[0]; } }; function convertWhereClause(where: Where[], model: string) { const schemaModel = getSchema(model); if (!where) return []; if (where.length === 1) { const w = where[0]; if (!w) { return []; } const field = getFieldName({ model, field: w.field }); if (!schemaModel[field]) { throw new BetterAuthError( `The field "${w.field}" does not exist in the schema for the model "${model}". Please update your schema.`, ); } if (w.operator === "in") { if (!Array.isArray(w.value)) { throw new BetterAuthError( `The value for the field "${w.field}" must be an array when using the "in" operator.`, ); } return [inArray(schemaModel[field], w.value)]; } if (w.operator === "not_in") { if (!Array.isArray(w.value)) { throw new BetterAuthError( `The value for the field "${w.field}" must be an array when using the "not_in" operator.`, ); } return [notInArray(schemaModel[field], w.value)]; } if (w.operator === "contains") { return [like(schemaModel[field], `%${w.value}%`)]; } if (w.operator === "starts_with") { return [like(schemaModel[field], `${w.value}%`)]; } if (w.operator === "ends_with") { return [like(schemaModel[field], `%${w.value}`)]; } if (w.operator === "lt") { return [lt(schemaModel[field], w.value)]; } if (w.operator === "lte") { return [lte(schemaModel[field], w.value)]; } if (w.operator === "ne") { return [ne(schemaModel[field], w.value)]; } if (w.operator === "gt") { return [gt(schemaModel[field], w.value)]; } if (w.operator === "gte") { return [gte(schemaModel[field], w.value)]; } return [eq(schemaModel[field], w.value)]; } const andGroup = where.filter( (w) => w.connector === "AND" || !w.connector, ); const orGroup = where.filter((w) => w.connector === "OR"); const andClause = and( ...andGroup.map((w) => { const field = getFieldName({ model, field: w.field }); if (w.operator === "in") { if (!Array.isArray(w.value)) { throw new BetterAuthError( `The value for the field "${w.field}" must be an array when using the "in" operator.`, ); } return inArray(schemaModel[field], w.value); } if (w.operator === "not_in") { if (!Array.isArray(w.value)) { throw new BetterAuthError( `The value for the field "${w.field}" must be an array when using the "not_in" operator.`, ); } return notInArray(schemaModel[field], w.value); } if (w.operator === "contains") { return like(schemaModel[field], `%${w.value}%`); } if (w.operator === "starts_with") { return like(schemaModel[field], `${w.value}%`); } if (w.operator === "ends_with") { return like(schemaModel[field], `%${w.value}`); } if (w.operator === "lt") { return lt(schemaModel[field], w.value); } if (w.operator === "lte") { return lte(schemaModel[field], w.value); } if (w.operator === "gt") { return gt(schemaModel[field], w.value); } if (w.operator === "gte") { return gte(schemaModel[field], w.value); } if (w.operator === "ne") { return ne(schemaModel[field], w.value); } return eq(schemaModel[field], w.value); }), ); const orClause = or( ...orGroup.map((w) => { const field = getFieldName({ model, field: w.field }); if (w.operator === "in") { if (!Array.isArray(w.value)) { throw new BetterAuthError( `The value for the field "${w.field}" must be an array when using the "in" operator.`, ); } return inArray(schemaModel[field], w.value); } if (w.operator === "not_in") { if (!Array.isArray(w.value)) { throw new BetterAuthError( `The value for the field "${w.field}" must be an array when using the "not_in" operator.`, ); } return notInArray(schemaModel[field], w.value); } if (w.operator === "contains") { return like(schemaModel[field], `%${w.value}%`); } if (w.operator === "starts_with") { return like(schemaModel[field], `${w.value}%`); } if (w.operator === "ends_with") { return like(schemaModel[field], `%${w.value}`); } if (w.operator === "lt") { return lt(schemaModel[field], w.value); } if (w.operator === "lte") { return lte(schemaModel[field], w.value); } if (w.operator === "gt") { return gt(schemaModel[field], w.value); } if (w.operator === "gte") { return gte(schemaModel[field], w.value); } if (w.operator === "ne") { return ne(schemaModel[field], w.value); } return eq(schemaModel[field], w.value); }), ); const clause: SQL<unknown>[] = []; if (andGroup.length) clause.push(andClause!); if (orGroup.length) clause.push(orClause!); return clause; } function checkMissingFields( schema: Record<string, any>, model: string, values: Record<string, any>, ) { if (!schema) { throw new BetterAuthError( "Drizzle adapter failed to initialize. Schema not found. Please provide a schema object in the adapter options object.", ); } for (const key in values) { if (!schema[key]) { throw new BetterAuthError( `The field "${key}" does not exist in the "${model}" schema. Please update your drizzle schema or re-generate using "npx @better-auth/cli generate".`, ); } } } return { async create({ model, data: values }) { const schemaModel = getSchema(model); checkMissingFields(schemaModel, model, values); const builder = db.insert(schemaModel).values(values); const returned = await withReturning(model, builder, values); return returned; }, async findOne({ model, where }) { const schemaModel = getSchema(model); const clause = convertWhereClause(where, model); const res = await db .select() .from(schemaModel) .where(...clause); if (!res.length) return null; return res[0]; }, async findMany({ model, where, sortBy, limit, offset }) { const schemaModel = getSchema(model); const clause = where ? convertWhereClause(where, model) : []; const sortFn = sortBy?.direction === "desc" ? desc : asc; const builder = db .select() .from(schemaModel) .limit(limit || 100) .offset(offset || 0); if (sortBy?.field) { builder.orderBy( sortFn( schemaModel[getFieldName({ model, field: sortBy?.field })], ), ); } return (await builder.where(...clause)) as any[]; }, async count({ model, where }) { const schemaModel = getSchema(model); const clause = where ? convertWhereClause(where, model) : []; const res = await db .select({ count: count() }) .from(schemaModel) .where(...clause); return res[0].count; }, async update({ model, where, update: values }) { const schemaModel = getSchema(model); const clause = convertWhereClause(where, model); const builder = db .update(schemaModel) .set(values) .where(...clause); return await withReturning(model, builder, values as any, where); }, async updateMany({ model, where, update: values }) { const schemaModel = getSchema(model); const clause = convertWhereClause(where, model); const builder = db .update(schemaModel) .set(values) .where(...clause); return await builder; }, async delete({ model, where }) { const schemaModel = getSchema(model); const clause = convertWhereClause(where, model); const builder = db.delete(schemaModel).where(...clause); return await builder; }, async deleteMany({ model, where }) { const schemaModel = getSchema(model); const clause = convertWhereClause(where, model); const builder = db.delete(schemaModel).where(...clause); return await builder; }, options: config, }; }; let adapterOptions: AdapterFactoryOptions | null = null; adapterOptions = { config: { adapterId: "drizzle", adapterName: "Drizzle Adapter", usePlural: config.usePlural ?? false, debugLogs: config.debugLogs ?? false, transaction: (config.transaction ?? false) ? (cb) => db.transaction((tx: DB) => { const adapter = createAdapterFactory({ config: adapterOptions!.config, adapter: createCustomAdapter(tx), })(lazyOptions!); return cb(adapter); }) : false, }, adapter: createCustomAdapter(db), }; const adapter = createAdapterFactory(adapterOptions); return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => { lazyOptions = options; return adapter(options); }; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/middlewares/origin-check.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { createAuthClient } from "../../client"; import { createAuthEndpoint } from "@better-auth/core/api"; import { isSimpleRequest, originCheck } from "./origin-check"; import * as z from "zod"; describe("Origin Check", async (it) => { const { customFetchImpl, testUser } = await getTestInstance({ trustedOrigins: [ "http://localhost:5000", "https://trusted.com", "*.my-site.com", "https://*.protocol-site.com", ], emailAndPassword: { enabled: true, async sendResetPassword(url, user) {}, }, advanced: { disableCSRFCheck: false, disableOriginCheck: false, }, }); it("should allow trusted origins", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "http://localhost:3000", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "http://localhost:3000/callback", }); expect(res.data?.user).toBeDefined(); }); it("should not allow untrusted origins", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, }, }); const res = await client.signIn.email({ email: "[email protected]", password: "password", callbackURL: "http://malicious.com", }); expect(res.error?.status).toBe(403); expect(res.error?.message).toBe("Invalid callbackURL"); }); it("should allow query params in callback url", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://localhost:3000", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "/dashboard?test=123", }); expect(res.data?.user).toBeDefined(); }); it("should allow plus signs in the callback url", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://localhost:3000", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "/dashboard+page?test=123+456", }); expect(res.data?.user).toBeDefined(); }); it("should reject callback url with double slash", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://localhost:3000", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "//evil.com", }); expect(res.error?.status).toBe(403); }); it("should reject callback urls with encoded malicious content", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://localhost:3000", }, }, }); const maliciousPatterns = [ "/%5C/evil.com", `/\\/\\/evil.com`, "/%5C/evil.com", "/..%2F..%2Fevil.com", "javascript:alert('xss')", "data:text/html,<script>alert('xss')</script>", ]; for (const pattern of maliciousPatterns) { const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: pattern, }); expect(res.error?.status).toBe(403); } }); it("should reject untrusted origin headers", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "malicious.com", cookie: "session=123", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, }); expect(res.error?.status).toBe(403); }); it("should reject untrusted origin headers which start with trusted origin", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://trusted.com.malicious.com", cookie: "session=123", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, }); expect(res.error?.status).toBe(403); }); it("should reject untrusted origin subdomains", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "http://sub-domain.trusted.com", cookie: "session=123", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, }); expect(res.error?.status).toBe(403); }); it("should allow untrusted origin if they don't contain cookies", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "http://sub-domain.trusted.com", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, }); expect(res.data?.user).toBeDefined(); }); it("should reject untrusted redirectTo", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, }, }); const res = await client.requestPasswordReset({ email: testUser.email, redirectTo: "http://malicious.com", }); expect(res.error?.status).toBe(403); expect(res.error?.message).toBe("Invalid redirectURL"); }); it("should work with list of trusted origins", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://trusted.com", }, }, }); const res = await client.requestPasswordReset({ email: testUser.email, redirectTo: "http://localhost:5000/reset-password", }); expect(res.data?.status).toBeTruthy(); const res2 = await client.signIn.email({ email: testUser.email, password: testUser.password, fetchOptions: { query: { currentURL: "http://localhost:5000", }, }, }); expect(res2.data?.user).toBeDefined(); }); it("should work with wildcard trusted origins", async (ctx) => { const client = createAuthClient({ baseURL: "https://sub-domain.my-site.com", fetchOptions: { customFetchImpl, headers: { origin: "https://sub-domain.my-site.com", }, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "https://sub-domain.my-site.com/callback", }); expect(res.data?.user).toBeDefined(); // Test another subdomain with the wildcard pattern const client2 = createAuthClient({ baseURL: "https://another-sub.my-site.com", fetchOptions: { customFetchImpl, headers: { origin: "https://another-sub.my-site.com", }, }, }); const res2 = await client2.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "https://another-sub.my-site.com/callback", }); expect(res2.data?.user).toBeDefined(); }); it("should work with GET requests", async (ctx) => { const client = createAuthClient({ baseURL: "https://sub-domain.my-site.com", fetchOptions: { customFetchImpl, headers: { origin: "https://google.com", cookie: "value", }, }, }); const res = await client.$fetch("/ok"); expect(res.data).toMatchObject({ ok: true }); }); it("should handle POST requests with proper origin validation", async (ctx) => { // Test with valid origin const validClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "http://localhost:5000", cookie: "session=123", }, }, }); const validRes = await validClient.signIn.email({ email: testUser.email, password: testUser.password, }); expect(validRes.data?.user).toBeDefined(); // Test with invalid origin const invalidClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "http://untrusted-domain.com", cookie: "session=123", }, }, }); const invalidRes = await invalidClient.signIn.email({ email: testUser.email, password: testUser.password, }); expect(invalidRes.error?.status).toBe(403); }); it("should work with relative callbackURL with query params", async (ctx) => { const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, }, }); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "/[email protected]", }); expect(res.data?.user).toBeDefined(); }); it("should work with protocol specific wildcard trusted origins", async () => { // Test HTTPS protocol specific wildcard - should work const httpsClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "https://api.protocol-site.com", cookie: "session=123", }, }, }); const httpsRes = await httpsClient.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "https://app.protocol-site.com/dashboard", }); expect(httpsRes.data?.user).toBeDefined(); // Test HTTP with HTTPS protocol wildcard - should fail const httpClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, headers: { origin: "http://api.protocol-site.com", cookie: "session=123", }, }, }); const httpRes = await httpClient.signIn.email({ email: testUser.email, password: testUser.password, }); expect(httpRes.error?.status).toBe(403); }); it("should work with custom scheme wildcards (e.g. exp:// for Expo)", async () => { const { customFetchImpl, testUser } = await getTestInstance({ trustedOrigins: [ "exp://10.0.0.*:*/*", "exp://192.168.*.*:*/*", "exp://172.*.*.*:*/*", ], emailAndPassword: { enabled: true, async sendResetPassword(url, user) {}, }, }); // Test custom scheme with wildcard - should work const expoClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl, }, }); // Test with IP matching the wildcard pattern const resWithIP = await expoClient.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "exp://10.0.0.29:8081/--/", }); expect(resWithIP.data?.user).toBeDefined(); // Test with different IP range that matches const resWithIP2 = await expoClient.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "exp://192.168.1.100:8081/--/", }); expect(resWithIP2.data?.user).toBeDefined(); // Test with different IP range that matches const resWithIP3 = await expoClient.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "exp://172.16.0.1:8081/--/", }); expect(resWithIP3.data?.user).toBeDefined(); // Test with IP that doesn't match any pattern - should fail const resWithUnmatchedIP = await expoClient.signIn.email({ email: testUser.email, password: testUser.password, callbackURL: "exp://203.0.113.0:8081/--/", }); expect(resWithUnmatchedIP.error?.status).toBe(403); }); }); describe("origin check middleware", async (it) => { it("should return invalid origin", async () => { const { client } = await getTestInstance({ trustedOrigins: ["https://trusted-site.com"], plugins: [ { id: "test", endpoints: { test: createAuthEndpoint( "/test", { method: "GET", query: z.object({ callbackURL: z.string(), }), use: [originCheck((c) => c.query.callbackURL)], }, async (c) => { return c.query.callbackURL; }, ), }, }, ], }); const invalid = await client.$fetch( "/test?callbackURL=https://malicious-site.com", ); expect(invalid.error?.status).toBe(403); const valid = await client.$fetch("/test?callbackURL=/dashboard"); expect(valid.data).toBe("/dashboard"); const validTrusted = await client.$fetch( "/test?callbackURL=https://trusted-site.com/path", ); expect(validTrusted.data).toBe("https://trusted-site.com/path"); const sampleInternalEndpointInvalid = await client.$fetch( "/verify-email?callbackURL=https://malicious-site.com&token=xyz", ); expect(sampleInternalEndpointInvalid.error?.status).toBe(403); }); }); describe("is simple request", async (it) => { it("should return true for simple requests", async () => { const request = new Request("http://localhost:3000/test", { method: "GET", }); const isSimple = isSimpleRequest(request.headers); expect(isSimple).toBe(true); }); it("should return false for non-simple requests", async () => { const request = new Request("http://localhost:3000/test", { method: "POST", headers: { "custom-header": "value", }, }); const isSimple = isSimpleRequest(request.headers); expect(isSimple).toBe(false); }); it("should return false for requests with a content type that is not simple", async () => { const request = new Request("http://localhost:3000/test", { method: "POST", headers: { "content-type": "application/json", }, }); const isSimple = isSimpleRequest(request.headers); expect(isSimple).toBe(false); }); it; }); ``` -------------------------------------------------------------------------------- /docs/content/docs/integrations/waku.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Waku Integration description: Integrate Better Auth with Waku. --- Better Auth can be easily integrated with Waku. Before you start, make sure you have a Better Auth instance configured. If you haven't done that yet, check out the [installation](/docs/installation). ## Create auth instance Create a file named `auth.ts` in your application. Import Better Auth and create your instance. <Callout type="warn"> Make sure to export the auth instance with the variable name `auth` or as a `default` export. </Callout> ```ts title="src/auth.ts" import { betterAuth } from "better-auth" export const auth = betterAuth({ database: { provider: "postgres", //change this to your database provider url: process.env.DATABASE_URL, // path to your database or connection string } }) ``` ## Create API Route We need to mount the handler to a API route. Create a directory for Waku's file system router at `src/pages/api/auth`. Create a catch-all route file `[...route].ts` inside the `src/pages/api/auth` directory. And add the following code: ```ts title="src/pages/api/auth/[...route].ts" import { auth } from "../../../auth" // Adjust the path as necessary export const GET = async (request: Request): Promise<Response> => { return auth.handler(request) } export const POST = async (request: Request): Promise<Response> => { return auth.handler(request) } ``` <Callout type="info"> You can change the path on your better-auth configuration but it's recommended to keep it as `src/pages/api/auth/[...route].ts` </Callout> ## Create a client Create a client instance. Here we are creating `auth-client.ts` file inside the `lib/` directory. ```ts title="src/lib/auth-client.ts" import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react export const authClient = createAuthClient({ //you can pass client configuration here }) export type Session = typeof authClient.$Infer.Session // you can infer typescript types from the authClient ``` Once you have created the client, you can use it to sign up, sign in, and perform other actions. Some of the actions are reactive. The client uses [nano-store](https://github.com/nanostores/nanostores) to store the state and re-render the components when the state changes. The client also uses [better-fetch](https://github.com/bekacru/better-fetch) to make the requests. You can pass the fetch configuration to the client. ## RSC and Server actions The `api` object exported from the auth instance contains all the actions that you can perform on the server. Every endpoint made inside Better Auth is a invocable as a function. Including plugins endpoints. **Example: Getting Session on a server action** ```tsx title="server.ts" "use server" // Waku currently only supports file-level "use server" import { auth } from "./auth" import { getContext } from "waku/middleware/context" export const someAuthenticatedAction = async () => { "use server" const session = await auth.api.getSession({ headers: new Headers(getContext().req.headers), }) }; ``` **Example: Getting Session on a RSC** ```tsx import { auth } from "../auth" import { getContext } from "waku/middleware/context" export async function ServerComponent() { const session = await auth.api.getSession({ headers: new Headers(getContext().req.headers), }) if(!session) { return <div>Not authenticated</div> } return ( <div> <h1>Welcome {session.user.name}</h1> </div> ) } ``` <Callout type="warn">RSCs that run after the response has started streaming cannot set cookies. The [cookie cache](/docs/concepts/session-management#cookie-cache) will not be refreshed until the server is interacted with from the client via Server Actions or Route Handlers.</Callout> ### Server Action Cookies When you call a function that needs to set cookies, like `signInEmail` or `signUpEmail` in a server action, cookies won’t be set. We can create a plugin that works together with our middleware to set cookies. ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { wakuCookies } from "better-auth/waku"; import { getContextData } from "waku/middleware/context"; export const auth = betterAuth({ //...your config plugins: [wakuCookies()] // make sure this is the last plugin in the array // [!code highlight] }) function wakuCookies() { return { id: "waku-cookies", hooks: { after: [ { matcher(ctx) { return true; }, handler: createAuthMiddleware(async (ctx) => { const returned = ctx.context.responseHeaders; if ("_flag" in ctx && ctx._flag === "router") { return; } if (returned instanceof Headers) { const setCookieHeader = returned?.get("set-cookie"); if (!setCookieHeader) return; const contextData = getContextData(); contextData.betterAuthSetCookie = setCookieHeader; } }), }, ], }, } satisfies BetterAuthPlugin; } ``` See below for the middleware to create to add the `contextData.betterAuthSetCookie` cookies to the response. Now, when you call functions that set cookies, they will be automatically set. ```ts "use server"; import { auth } from "../auth" const signIn = async () => { await auth.api.signInEmail({ body: { email: "[email protected]", password: "password", } }) } ``` ### Middleware In Waku middleware, it's recommended to only check for the existence of a session cookie to handle redirection. This avoids blocking requests by making API or database calls. You can use the `getSessionCookie` helper from Better Auth for this purpose: <Callout type="warn"> The <code>getSessionCookie()</code> function does not automatically reference the auth config specified in <code>auth.ts</code>. Therefore, if you customized the cookie name or prefix, you need to ensure that the configuration in <code>getSessionCookie()</code> matches the config defined in your <code>auth.ts</code>. </Callout> ```ts title="src/middleware/auth.ts" import type { Middleware } from "waku/config" import { getSession } from "../auth" import { getSessionCookie } from "better-auth/cookies" const authMiddleware: Middleware = () => { return async (ctx, next) => { const sessionCookie = getSessionCookie( new Request(ctx.req.url, { body: ctx.req.body, headers: ctx.req.headers, method: ctx.req.method, }) ) // THIS IS NOT SECURE! // This is the recommended approach to optimistically redirect users // We recommend handling auth checks in each page/route if (!sessionCookie && ctx.req.url.pathname !== "/") { if (!ctx.req.url.pathname.endsWith(".txt")) { // Currently RSC requests end in .txt and don't handle redirect responses // The redirect needs to be encoded in the React flight stream somehow // There is some functionality in Waku to do this from a server component // but not from middleware. ctx.res.status = 302; ctx.res.headers = { Location: new URL("/", ctx.req.url).toString(), }; } } // TODO possible to inspect ctx.req.url and not do this on every request // Or skip starting the promise here and just invoke from server components and functions getSession() await next() if (ctx.data.betterAuthSetCookie) { ctx.res.headers ||= {} let origSetCookie = ctx.res.headers["set-cookie"] || ([] as string[]) if (typeof origSetCookie === "string") { origSetCookie = [origSetCookie] } ctx.res.headers["set-cookie"] = [ ...origSetCookie, ctx.data.betterAuthSetCookie as string, ] } } }; export default authMiddleware; ``` <Callout type="warn"> **Security Warning:** The `getSessionCookie` function only checks for the existence of a session cookie; it does **not** validate it. Relying solely on this check for security is dangerous, as anyone can manually create a cookie to bypass it. You must always validate the session on your server for any protected actions or pages. </Callout> <Callout type="info"> If you have a custom cookie name or prefix, you can pass it to the `getSessionCookie` function. ```ts const sessionCookie = getSessionCookie(request, { cookieName: "my_session_cookie", cookiePrefix: "my_prefix" }) ``` </Callout> Alternatively, you can use the `getCookieCache` helper to get the session object from the cookie cache. ```ts import { getCookieCache } from "better-auth/cookies" const authMiddleware: Middleware = () => { return async (ctx, next) => { const session = await getCookieCache(ctx.req) if (!session && ctx.req.url.pathname !== "/") { if (!ctx.req.url.pathname.endsWith(".txt")) { ctx.res.status = 302 ctx.res.headers = { Location: new URL("/", ctx.req.url).toString(), } } } } await next(); } } export default authMiddleware; ``` Note that your middleware will need to be added to a waku.config.ts file (create this file if it doesn't already exist in your project): ```ts title="waku.config.ts" import { defineConfig } from "waku/config"; export default defineConfig({ middleware: [ "waku/middleware/context", "waku/middleware/dev-server", "./src/middleware/auth.ts", "waku/middleware/handler", ], }); ``` ### How to handle auth checks in each page/route In this example, we are using the `auth.api.getSession` function within a server component to get the session object, then we are checking if the session is valid. If it's not, we are redirecting the user to the sign-in page. Waku has `getContext` to get the request headers and `getContextData()` to store data per request. We can use this to avoid fetching the session more than once per request. ```ts title="auth.ts" import { getContext, getContextData } from "waku/middleware/context"; // Code from above to create the server auth config // export const auth = ... export function getSession(): Promise<Session | null> { const contextData = getContextData(); const ctx = getContext(); const existingSessionPromise = contextData.sessionPromise as | Promise<Session | null> | undefined; if (existingSessionPromise) { return existingSessionPromise; } const sessionPromise = auth.api.getSession({ headers: new Headers(ctx.req.headers), }); contextData.sessionPromise = sessionPromise; return sessionPromise; } ``` ```tsx title="src/pages/dashboard.tsx" import { getSession } from "../auth"; import { unstable_redirect as redirect } from 'waku/router/server'; export default async function DashboardPage() { const session = await getSession() if (!session) { redirect("/sign-in") } return ( <div> <h1>Welcome {session.user.name}</h1> </div> ) } ``` ### Example usage #### Sign Up ```ts title="src/components/signup.tsx" "use client" import { useState } from "react" import { authClient } from "../lib/auth-client" export default function SignUp() { const [email, setEmail] = useState("") const [name, setName] = useState("") const [password, setPassword] = useState("") const signUp = async () => { await authClient.signUp.email( { email, password, name, }, { onRequest: (ctx) => { // show loading state }, onSuccess: (ctx) => { // redirect to home }, onError: (ctx) => { alert(ctx.error) }, }, ) } return ( <div> <h2> Sign Up </h2> <form onSubmit={signUp} > <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" /> <button type="submit" > Sign Up </button> </form> </div> ) } ``` #### Sign In ```ts title="src/components/signin.tsx" "use client" import { useState } from "react" import { authClient } from "../lib/auth-client" export default function SignIn() { const [email, setEmail] = useState("") const [password, setPassword] = useState("") const signIn = async () => { await authClient.signIn.email( { email, password, }, { onRequest: (ctx) => { // show loading state }, onSuccess: (ctx) => { // redirect to home }, onError: (ctx) => { alert(ctx.error) }, }, ) } return ( <div> <h2> Sign In </h2> <form onSubmit={signIn}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <button type="submit" > Sign In </button> </form> </div> ) } ``` ```