This is page 48 of 67. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── middleware.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── 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-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── user-additional-fields.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 │ │ │ │ ├── sso │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sso.test.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 │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── 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 │ │ │ ├── middleware │ │ │ │ └── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/db/internal-adapter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getDate } from "../utils/date"; 2 | import { parseSessionOutput, parseUserOutput } from "./schema"; 3 | import type { BetterAuthOptions } from "@better-auth/core"; 4 | import { 5 | type Account, 6 | type Session, 7 | type User, 8 | type Verification, 9 | } from "../types"; 10 | import type { DBAdapter, Where } from "@better-auth/core/db/adapter"; 11 | import { getWithHooks } from "./with-hooks"; 12 | import { getIp } from "../utils/get-request-ip"; 13 | import { safeJSONParse } from "../utils/json"; 14 | import { generateId } from "../utils"; 15 | import { 16 | getCurrentAdapter, 17 | runWithTransaction, 18 | } from "@better-auth/core/context"; 19 | import type { InternalLogger } from "@better-auth/core/env"; 20 | import type { 21 | AuthContext, 22 | GenericEndpointContext, 23 | InternalAdapter, 24 | } from "@better-auth/core"; 25 | 26 | export const createInternalAdapter = ( 27 | adapter: DBAdapter<BetterAuthOptions>, 28 | ctx: { 29 | options: Omit<BetterAuthOptions, "logger">; 30 | logger: InternalLogger; 31 | hooks: Exclude<BetterAuthOptions["databaseHooks"], undefined>[]; 32 | generateId: AuthContext["generateId"]; 33 | }, 34 | ): InternalAdapter => { 35 | const logger = ctx.logger; 36 | const options = ctx.options; 37 | const secondaryStorage = options.secondaryStorage; 38 | const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days 39 | const { 40 | createWithHooks, 41 | updateWithHooks, 42 | updateManyWithHooks, 43 | deleteWithHooks, 44 | deleteManyWithHooks, 45 | } = getWithHooks(adapter, ctx); 46 | 47 | async function refreshUserSessions(user: User) { 48 | if (!secondaryStorage) return; 49 | 50 | const listRaw = await secondaryStorage.get(`active-sessions-${user.id}`); 51 | if (!listRaw) return; 52 | 53 | const now = Date.now(); 54 | const list = 55 | safeJSONParse<{ token: string; expiresAt: number }[]>(listRaw) || []; 56 | const validSessions = list.filter((s) => s.expiresAt > now); 57 | 58 | await Promise.all( 59 | validSessions.map(async ({ token }) => { 60 | const cached = await secondaryStorage.get(token); 61 | if (!cached) return; 62 | const parsed = safeJSONParse<{ session: Session; user: User }>(cached); 63 | if (!parsed) return; 64 | 65 | const sessionTTL = Math.max( 66 | Math.floor(new Date(parsed.session.expiresAt).getTime() - now) / 1000, 67 | 0, 68 | ); 69 | 70 | await secondaryStorage.set( 71 | token, 72 | JSON.stringify({ 73 | session: parsed.session, 74 | user, 75 | }), 76 | Math.floor(sessionTTL), 77 | ); 78 | }), 79 | ); 80 | } 81 | 82 | return { 83 | createOAuthUser: async ( 84 | user: Omit<User, "id" | "createdAt" | "updatedAt">, 85 | account: Omit<Account, "userId" | "id" | "createdAt" | "updatedAt"> & 86 | Partial<Account>, 87 | context?: GenericEndpointContext, 88 | ) => { 89 | return runWithTransaction(adapter, async () => { 90 | const createdUser = await createWithHooks( 91 | { 92 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 93 | createdAt: new Date(), 94 | updatedAt: new Date(), 95 | ...user, 96 | }, 97 | "user", 98 | undefined, 99 | context, 100 | ); 101 | const createdAccount = await createWithHooks( 102 | { 103 | ...account, 104 | userId: createdUser!.id, 105 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 106 | createdAt: new Date(), 107 | updatedAt: new Date(), 108 | }, 109 | "account", 110 | undefined, 111 | context, 112 | ); 113 | return { 114 | user: createdUser, 115 | account: createdAccount, 116 | }; 117 | }); 118 | }, 119 | createUser: async <T>( 120 | user: Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> & 121 | Partial<User> & 122 | Record<string, any>, 123 | context?: GenericEndpointContext, 124 | ) => { 125 | const createdUser = await createWithHooks( 126 | { 127 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 128 | createdAt: new Date(), 129 | updatedAt: new Date(), 130 | ...user, 131 | email: user.email?.toLowerCase(), 132 | }, 133 | "user", 134 | undefined, 135 | context, 136 | ); 137 | 138 | return createdUser as T & User; 139 | }, 140 | createAccount: async <T extends Record<string, any>>( 141 | account: Omit<Account, "id" | "createdAt" | "updatedAt"> & 142 | Partial<Account> & 143 | T, 144 | context?: GenericEndpointContext, 145 | ) => { 146 | const createdAccount = await createWithHooks( 147 | { 148 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 149 | createdAt: new Date(), 150 | updatedAt: new Date(), 151 | ...account, 152 | }, 153 | "account", 154 | undefined, 155 | context, 156 | ); 157 | return createdAccount as T & Account; 158 | }, 159 | listSessions: async (userId: string) => { 160 | if (secondaryStorage) { 161 | const currentList = await secondaryStorage.get( 162 | `active-sessions-${userId}`, 163 | ); 164 | if (!currentList) return []; 165 | 166 | const list: { token: string; expiresAt: number }[] = 167 | safeJSONParse(currentList) || []; 168 | const now = Date.now(); 169 | 170 | const validSessions = list.filter((s) => s.expiresAt > now); 171 | const sessions = []; 172 | 173 | for (const session of validSessions) { 174 | const sessionStringified = await secondaryStorage.get(session.token); 175 | if (sessionStringified) { 176 | const s = safeJSONParse<{ 177 | session: Session; 178 | user: User; 179 | }>(sessionStringified); 180 | if (!s) return []; 181 | const parsedSession = parseSessionOutput(ctx.options, { 182 | ...s.session, 183 | expiresAt: new Date(s.session.expiresAt), 184 | }); 185 | sessions.push(parsedSession); 186 | } 187 | } 188 | return sessions; 189 | } 190 | 191 | const sessions = await ( 192 | await getCurrentAdapter(adapter) 193 | ).findMany<Session>({ 194 | model: "session", 195 | where: [ 196 | { 197 | field: "userId", 198 | value: userId, 199 | }, 200 | ], 201 | }); 202 | return sessions; 203 | }, 204 | listUsers: async ( 205 | limit?: number, 206 | offset?: number, 207 | sortBy?: { 208 | field: string; 209 | direction: "asc" | "desc"; 210 | }, 211 | where?: Where[], 212 | ) => { 213 | const users = await (await getCurrentAdapter(adapter)).findMany<User>({ 214 | model: "user", 215 | limit, 216 | offset, 217 | sortBy, 218 | where, 219 | }); 220 | return users; 221 | }, 222 | countTotalUsers: async (where?: Where[]) => { 223 | const total = await (await getCurrentAdapter(adapter)).count({ 224 | model: "user", 225 | where, 226 | }); 227 | if (typeof total === "string") { 228 | return parseInt(total); 229 | } 230 | return total; 231 | }, 232 | deleteUser: async (userId: string, context?: GenericEndpointContext) => { 233 | if (secondaryStorage) { 234 | await secondaryStorage.delete(`active-sessions-${userId}`); 235 | } 236 | 237 | if (!secondaryStorage || options.session?.storeSessionInDatabase) { 238 | await deleteManyWithHooks( 239 | [ 240 | { 241 | field: "userId", 242 | value: userId, 243 | }, 244 | ], 245 | "session", 246 | undefined, 247 | context, 248 | ); 249 | } 250 | 251 | await (await getCurrentAdapter(adapter)).deleteMany({ 252 | model: "account", 253 | where: [ 254 | { 255 | field: "userId", 256 | value: userId, 257 | }, 258 | ], 259 | }); 260 | await deleteWithHooks( 261 | [ 262 | { 263 | field: "id", 264 | value: userId, 265 | }, 266 | ], 267 | "user", 268 | undefined, 269 | context, 270 | ); 271 | }, 272 | createSession: async ( 273 | userId: string, 274 | ctx: GenericEndpointContext, 275 | dontRememberMe?: boolean, 276 | override?: Partial<Session> & Record<string, any>, 277 | overrideAll?: boolean, 278 | ) => { 279 | const headers = ctx.headers || ctx.request?.headers; 280 | const { id: _, ...rest } = override || {}; 281 | const data: Omit<Session, "id"> = { 282 | ipAddress: 283 | ctx.request || ctx.headers 284 | ? getIp(ctx.request || ctx.headers!, ctx.context.options) || "" 285 | : "", 286 | userAgent: headers?.get("user-agent") || "", 287 | ...rest, 288 | /** 289 | * If the user doesn't want to be remembered 290 | * set the session to expire in 1 day. 291 | * The cookie will be set to expire at the end of the session 292 | */ 293 | expiresAt: dontRememberMe 294 | ? getDate(60 * 60 * 24, "sec") // 1 day 295 | : getDate(sessionExpiration, "sec"), 296 | userId, 297 | token: generateId(32), 298 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 299 | createdAt: new Date(), 300 | updatedAt: new Date(), 301 | ...(overrideAll ? rest : {}), 302 | }; 303 | const res = await createWithHooks( 304 | data, 305 | "session", 306 | secondaryStorage 307 | ? { 308 | fn: async (sessionData) => { 309 | /** 310 | * store the session token for the user 311 | * so we can retrieve it later for listing sessions 312 | */ 313 | const currentList = await secondaryStorage.get( 314 | `active-sessions-${userId}`, 315 | ); 316 | 317 | let list: { token: string; expiresAt: number }[] = []; 318 | const now = Date.now(); 319 | 320 | if (currentList) { 321 | list = safeJSONParse(currentList) || []; 322 | list = list.filter((session) => session.expiresAt > now); 323 | } 324 | 325 | const sorted = list.sort((a, b) => a.expiresAt - b.expiresAt); 326 | let furthestSessionExp = sorted.at(-1)?.expiresAt; 327 | 328 | sorted.push({ 329 | token: data.token, 330 | expiresAt: data.expiresAt.getTime(), 331 | }); 332 | if ( 333 | !furthestSessionExp || 334 | furthestSessionExp < data.expiresAt.getTime() 335 | ) { 336 | furthestSessionExp = data.expiresAt.getTime(); 337 | } 338 | const furthestSessionTTL = Math.max( 339 | Math.floor((furthestSessionExp - now) / 1000), 340 | 0, 341 | ); 342 | if (furthestSessionTTL > 0) { 343 | await secondaryStorage.set( 344 | `active-sessions-${userId}`, 345 | JSON.stringify(sorted), 346 | furthestSessionTTL, 347 | ); 348 | } 349 | 350 | const user = await adapter.findOne<User>({ 351 | model: "user", 352 | where: [ 353 | { 354 | field: "id", 355 | value: userId, 356 | }, 357 | ], 358 | }); 359 | const sessionTTL = Math.max( 360 | Math.floor((data.expiresAt.getTime() - now) / 1000), 361 | 0, 362 | ); 363 | if (sessionTTL > 0) { 364 | await secondaryStorage.set( 365 | data.token, 366 | JSON.stringify({ 367 | session: sessionData, 368 | user, 369 | }), 370 | sessionTTL, 371 | ); 372 | } 373 | 374 | return sessionData; 375 | }, 376 | executeMainFn: options.session?.storeSessionInDatabase, 377 | } 378 | : undefined, 379 | ctx, 380 | ); 381 | return res as Session; 382 | }, 383 | findSession: async ( 384 | token: string, 385 | ): Promise<{ 386 | session: Session & Record<string, any>; 387 | user: User & Record<string, any>; 388 | } | null> => { 389 | if (secondaryStorage) { 390 | const sessionStringified = await secondaryStorage.get(token); 391 | if (!sessionStringified && !options.session?.storeSessionInDatabase) { 392 | return null; 393 | } 394 | if (sessionStringified) { 395 | const s = safeJSONParse<{ 396 | session: Session; 397 | user: User; 398 | }>(sessionStringified); 399 | if (!s) return null; 400 | const parsedSession = parseSessionOutput(ctx.options, { 401 | ...s.session, 402 | expiresAt: new Date(s.session.expiresAt), 403 | createdAt: new Date(s.session.createdAt), 404 | updatedAt: new Date(s.session.updatedAt), 405 | }); 406 | const parsedUser = parseUserOutput(ctx.options, { 407 | ...s.user, 408 | createdAt: new Date(s.user.createdAt), 409 | updatedAt: new Date(s.user.updatedAt), 410 | }); 411 | return { 412 | session: parsedSession, 413 | user: parsedUser, 414 | }; 415 | } 416 | } 417 | 418 | const session = await (await getCurrentAdapter(adapter)).findOne<Session>( 419 | { 420 | model: "session", 421 | where: [ 422 | { 423 | value: token, 424 | field: "token", 425 | }, 426 | ], 427 | }, 428 | ); 429 | 430 | if (!session) { 431 | return null; 432 | } 433 | 434 | const user = await (await getCurrentAdapter(adapter)).findOne<User>({ 435 | model: "user", 436 | where: [ 437 | { 438 | value: session.userId, 439 | field: "id", 440 | }, 441 | ], 442 | }); 443 | if (!user) { 444 | return null; 445 | } 446 | const parsedSession = parseSessionOutput(ctx.options, session); 447 | const parsedUser = parseUserOutput(ctx.options, user); 448 | 449 | return { 450 | session: parsedSession, 451 | user: parsedUser, 452 | }; 453 | }, 454 | findSessions: async (sessionTokens: string[]) => { 455 | if (secondaryStorage) { 456 | const sessions: { 457 | session: Session; 458 | user: User; 459 | }[] = []; 460 | for (const sessionToken of sessionTokens) { 461 | const sessionStringified = await secondaryStorage.get(sessionToken); 462 | if (sessionStringified) { 463 | const s = safeJSONParse<{ 464 | session: Session; 465 | user: User; 466 | }>(sessionStringified); 467 | if (!s) return []; 468 | const session = { 469 | session: { 470 | ...s.session, 471 | expiresAt: new Date(s.session.expiresAt), 472 | }, 473 | user: { 474 | ...s.user, 475 | createdAt: new Date(s.user.createdAt), 476 | updatedAt: new Date(s.user.updatedAt), 477 | }, 478 | } as { 479 | session: Session; 480 | user: User; 481 | }; 482 | sessions.push(session); 483 | } 484 | } 485 | return sessions; 486 | } 487 | 488 | const sessions = await ( 489 | await getCurrentAdapter(adapter) 490 | ).findMany<Session>({ 491 | model: "session", 492 | where: [ 493 | { 494 | field: "token", 495 | value: sessionTokens, 496 | operator: "in", 497 | }, 498 | ], 499 | }); 500 | const userIds = sessions.map((session) => { 501 | return session.userId; 502 | }); 503 | if (!userIds.length) return []; 504 | const users = await (await getCurrentAdapter(adapter)).findMany<User>({ 505 | model: "user", 506 | where: [ 507 | { 508 | field: "id", 509 | value: userIds, 510 | operator: "in", 511 | }, 512 | ], 513 | }); 514 | return sessions.map((session) => { 515 | const user = users.find((u) => u.id === session.userId); 516 | if (!user) return null; 517 | return { 518 | session, 519 | user, 520 | }; 521 | }) as { 522 | session: Session; 523 | user: User; 524 | }[]; 525 | }, 526 | updateSession: async ( 527 | sessionToken: string, 528 | session: Partial<Session> & Record<string, any>, 529 | context?: GenericEndpointContext, 530 | ) => { 531 | const updatedSession = await updateWithHooks<Session>( 532 | session, 533 | [{ field: "token", value: sessionToken }], 534 | "session", 535 | secondaryStorage 536 | ? { 537 | async fn(data) { 538 | const currentSession = await secondaryStorage.get(sessionToken); 539 | let updatedSession: Session | null = null; 540 | if (currentSession) { 541 | const parsedSession = safeJSONParse<{ 542 | session: Session; 543 | user: User; 544 | }>(currentSession); 545 | if (!parsedSession) return null; 546 | updatedSession = { 547 | ...parsedSession.session, 548 | ...data, 549 | }; 550 | return updatedSession; 551 | } else { 552 | return null; 553 | } 554 | }, 555 | executeMainFn: options.session?.storeSessionInDatabase, 556 | } 557 | : undefined, 558 | context, 559 | ); 560 | return updatedSession; 561 | }, 562 | deleteSession: async (token: string) => { 563 | if (secondaryStorage) { 564 | // remove the session from the active sessions list 565 | const data = await secondaryStorage.get(token); 566 | if (data) { 567 | const { session } = 568 | safeJSONParse<{ 569 | session: Session; 570 | user: User; 571 | }>(data) ?? {}; 572 | if (!session) { 573 | logger.error("Session not found in secondary storage"); 574 | return; 575 | } 576 | const userId = session.userId; 577 | 578 | const currentList = await secondaryStorage.get( 579 | `active-sessions-${userId}`, 580 | ); 581 | if (currentList) { 582 | let list: { token: string; expiresAt: number }[] = 583 | safeJSONParse(currentList) || []; 584 | const now = Date.now(); 585 | 586 | const filtered = list.filter( 587 | (session) => session.expiresAt > now && session.token !== token, 588 | ); 589 | const sorted = filtered.sort((a, b) => a.expiresAt - b.expiresAt); 590 | const furthestSessionExp = sorted.at(-1)?.expiresAt; 591 | 592 | if ( 593 | filtered.length > 0 && 594 | furthestSessionExp && 595 | furthestSessionExp > Date.now() 596 | ) { 597 | await secondaryStorage.set( 598 | `active-sessions-${userId}`, 599 | JSON.stringify(filtered), 600 | Math.floor((furthestSessionExp - now) / 1000), 601 | ); 602 | } else { 603 | await secondaryStorage.delete(`active-sessions-${userId}`); 604 | } 605 | } else { 606 | logger.error("Active sessions list not found in secondary storage"); 607 | } 608 | } 609 | 610 | await secondaryStorage.delete(token); 611 | 612 | if ( 613 | !options.session?.storeSessionInDatabase || 614 | ctx.options.session?.preserveSessionInDatabase 615 | ) { 616 | return; 617 | } 618 | } 619 | await (await getCurrentAdapter(adapter)).delete<Session>({ 620 | model: "session", 621 | where: [ 622 | { 623 | field: "token", 624 | value: token, 625 | }, 626 | ], 627 | }); 628 | }, 629 | deleteAccounts: async ( 630 | userId: string, 631 | context?: GenericEndpointContext, 632 | ) => { 633 | await deleteManyWithHooks( 634 | [ 635 | { 636 | field: "userId", 637 | value: userId, 638 | }, 639 | ], 640 | "account", 641 | undefined, 642 | context, 643 | ); 644 | }, 645 | deleteAccount: async ( 646 | accountId: string, 647 | context?: GenericEndpointContext, 648 | ) => { 649 | await deleteWithHooks( 650 | [{ field: "id", value: accountId }], 651 | "account", 652 | undefined, 653 | context, 654 | ); 655 | }, 656 | deleteSessions: async ( 657 | userIdOrSessionTokens: string | string[], 658 | context?: GenericEndpointContext, 659 | ) => { 660 | if (secondaryStorage) { 661 | if (typeof userIdOrSessionTokens === "string") { 662 | const activeSession = await secondaryStorage.get( 663 | `active-sessions-${userIdOrSessionTokens}`, 664 | ); 665 | const sessions = activeSession 666 | ? safeJSONParse<{ token: string }[]>(activeSession) 667 | : []; 668 | if (!sessions) return; 669 | for (const session of sessions) { 670 | await secondaryStorage.delete(session.token); 671 | } 672 | } else { 673 | for (const sessionToken of userIdOrSessionTokens) { 674 | const session = await secondaryStorage.get(sessionToken); 675 | if (session) { 676 | await secondaryStorage.delete(sessionToken); 677 | } 678 | } 679 | } 680 | 681 | if ( 682 | !options.session?.storeSessionInDatabase || 683 | ctx.options.session?.preserveSessionInDatabase 684 | ) { 685 | return; 686 | } 687 | } 688 | await deleteManyWithHooks( 689 | [ 690 | { 691 | field: Array.isArray(userIdOrSessionTokens) ? "token" : "userId", 692 | value: userIdOrSessionTokens, 693 | operator: Array.isArray(userIdOrSessionTokens) ? "in" : undefined, 694 | }, 695 | ], 696 | "session", 697 | undefined, 698 | context, 699 | ); 700 | }, 701 | findOAuthUser: async ( 702 | email: string, 703 | accountId: string, 704 | providerId: string, 705 | ) => { 706 | // we need to find account first to avoid missing user if the email changed with the provider for the same account 707 | const account = await (await getCurrentAdapter(adapter)) 708 | .findMany<Account>({ 709 | model: "account", 710 | where: [ 711 | { 712 | value: accountId, 713 | field: "accountId", 714 | }, 715 | ], 716 | }) 717 | .then((accounts) => { 718 | return accounts.find((a) => a.providerId === providerId); 719 | }); 720 | if (account) { 721 | const user = await (await getCurrentAdapter(adapter)).findOne<User>({ 722 | model: "user", 723 | where: [ 724 | { 725 | value: account.userId, 726 | field: "id", 727 | }, 728 | ], 729 | }); 730 | if (user) { 731 | return { 732 | user, 733 | accounts: [account], 734 | }; 735 | } else { 736 | const user = await (await getCurrentAdapter(adapter)).findOne<User>({ 737 | model: "user", 738 | where: [ 739 | { 740 | value: email.toLowerCase(), 741 | field: "email", 742 | }, 743 | ], 744 | }); 745 | if (user) { 746 | return { 747 | user, 748 | accounts: [account], 749 | }; 750 | } 751 | return null; 752 | } 753 | } else { 754 | const user = await (await getCurrentAdapter(adapter)).findOne<User>({ 755 | model: "user", 756 | where: [ 757 | { 758 | value: email.toLowerCase(), 759 | field: "email", 760 | }, 761 | ], 762 | }); 763 | if (user) { 764 | const accounts = await ( 765 | await getCurrentAdapter(adapter) 766 | ).findMany<Account>({ 767 | model: "account", 768 | where: [ 769 | { 770 | value: user.id, 771 | field: "userId", 772 | }, 773 | ], 774 | }); 775 | return { 776 | user, 777 | accounts: accounts || [], 778 | }; 779 | } else { 780 | return null; 781 | } 782 | } 783 | }, 784 | findUserByEmail: async ( 785 | email: string, 786 | options?: { includeAccounts: boolean }, 787 | ) => { 788 | const user = await (await getCurrentAdapter(adapter)).findOne<User>({ 789 | model: "user", 790 | where: [ 791 | { 792 | value: email.toLowerCase(), 793 | field: "email", 794 | }, 795 | ], 796 | }); 797 | if (!user) return null; 798 | if (options?.includeAccounts) { 799 | const accounts = await ( 800 | await getCurrentAdapter(adapter) 801 | ).findMany<Account>({ 802 | model: "account", 803 | where: [ 804 | { 805 | value: user.id, 806 | field: "userId", 807 | }, 808 | ], 809 | }); 810 | return { 811 | user, 812 | accounts, 813 | }; 814 | } 815 | return { 816 | user, 817 | accounts: [], 818 | }; 819 | }, 820 | findUserById: async (userId: string) => { 821 | const user = await (await getCurrentAdapter(adapter)).findOne<User>({ 822 | model: "user", 823 | where: [ 824 | { 825 | field: "id", 826 | value: userId, 827 | }, 828 | ], 829 | }); 830 | return user; 831 | }, 832 | linkAccount: async ( 833 | account: Omit<Account, "id" | "createdAt" | "updatedAt"> & 834 | Partial<Account>, 835 | context?: GenericEndpointContext, 836 | ) => { 837 | const _account = await createWithHooks( 838 | { 839 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 840 | createdAt: new Date(), 841 | updatedAt: new Date(), 842 | ...account, 843 | }, 844 | "account", 845 | undefined, 846 | context, 847 | ); 848 | return _account; 849 | }, 850 | updateUser: async ( 851 | userId: string, 852 | data: Partial<User> & Record<string, any>, 853 | context?: GenericEndpointContext, 854 | ) => { 855 | const user = await updateWithHooks<User>( 856 | data, 857 | [ 858 | { 859 | field: "id", 860 | value: userId, 861 | }, 862 | ], 863 | "user", 864 | undefined, 865 | context, 866 | ); 867 | await refreshUserSessions(user); 868 | await refreshUserSessions(user); 869 | return user; 870 | }, 871 | updateUserByEmail: async ( 872 | email: string, 873 | data: Partial<User & Record<string, any>>, 874 | context?: GenericEndpointContext, 875 | ) => { 876 | const user = await updateWithHooks<User>( 877 | data, 878 | [ 879 | { 880 | field: "email", 881 | value: email.toLowerCase(), 882 | }, 883 | ], 884 | "user", 885 | undefined, 886 | context, 887 | ); 888 | await refreshUserSessions(user); 889 | await refreshUserSessions(user); 890 | return user; 891 | }, 892 | updatePassword: async ( 893 | userId: string, 894 | password: string, 895 | context?: GenericEndpointContext, 896 | ) => { 897 | await updateManyWithHooks( 898 | { 899 | password, 900 | }, 901 | [ 902 | { 903 | field: "userId", 904 | value: userId, 905 | }, 906 | { 907 | field: "providerId", 908 | value: "credential", 909 | }, 910 | ], 911 | "account", 912 | undefined, 913 | context, 914 | ); 915 | }, 916 | findAccounts: async (userId: string) => { 917 | const accounts = await ( 918 | await getCurrentAdapter(adapter) 919 | ).findMany<Account>({ 920 | model: "account", 921 | where: [ 922 | { 923 | field: "userId", 924 | value: userId, 925 | }, 926 | ], 927 | }); 928 | return accounts; 929 | }, 930 | findAccount: async (accountId: string) => { 931 | const account = await (await getCurrentAdapter(adapter)).findOne<Account>( 932 | { 933 | model: "account", 934 | where: [ 935 | { 936 | field: "accountId", 937 | value: accountId, 938 | }, 939 | ], 940 | }, 941 | ); 942 | return account; 943 | }, 944 | findAccountByProviderId: async (accountId: string, providerId: string) => { 945 | const account = await (await getCurrentAdapter(adapter)).findOne<Account>( 946 | { 947 | model: "account", 948 | where: [ 949 | { 950 | field: "accountId", 951 | value: accountId, 952 | }, 953 | { 954 | field: "providerId", 955 | value: providerId, 956 | }, 957 | ], 958 | }, 959 | ); 960 | return account; 961 | }, 962 | findAccountByUserId: async (userId: string) => { 963 | const account = await ( 964 | await getCurrentAdapter(adapter) 965 | ).findMany<Account>({ 966 | model: "account", 967 | where: [ 968 | { 969 | field: "userId", 970 | value: userId, 971 | }, 972 | ], 973 | }); 974 | return account; 975 | }, 976 | updateAccount: async ( 977 | id: string, 978 | data: Partial<Account>, 979 | context?: GenericEndpointContext, 980 | ) => { 981 | const account = await updateWithHooks<Account>( 982 | data, 983 | [{ field: "id", value: id }], 984 | "account", 985 | undefined, 986 | context, 987 | ); 988 | return account; 989 | }, 990 | createVerificationValue: async ( 991 | data: Omit<Verification, "createdAt" | "id" | "updatedAt"> & 992 | Partial<Verification>, 993 | context?: GenericEndpointContext, 994 | ) => { 995 | const verification = await createWithHooks( 996 | { 997 | // todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that 998 | createdAt: new Date(), 999 | updatedAt: new Date(), 1000 | ...data, 1001 | }, 1002 | "verification", 1003 | undefined, 1004 | context, 1005 | ); 1006 | return verification as Verification; 1007 | }, 1008 | findVerificationValue: async (identifier: string) => { 1009 | const verification = await ( 1010 | await getCurrentAdapter(adapter) 1011 | ).findMany<Verification>({ 1012 | model: "verification", 1013 | where: [ 1014 | { 1015 | field: "identifier", 1016 | value: identifier, 1017 | }, 1018 | ], 1019 | sortBy: { 1020 | field: "createdAt", 1021 | direction: "desc", 1022 | }, 1023 | limit: 1, 1024 | }); 1025 | if (!options.verification?.disableCleanup) { 1026 | await (await getCurrentAdapter(adapter)).deleteMany({ 1027 | model: "verification", 1028 | where: [ 1029 | { 1030 | field: "expiresAt", 1031 | value: new Date(), 1032 | operator: "lt", 1033 | }, 1034 | ], 1035 | }); 1036 | } 1037 | const lastVerification = verification[0]; 1038 | return lastVerification as Verification | null; 1039 | }, 1040 | deleteVerificationValue: async ( 1041 | id: string, 1042 | context?: GenericEndpointContext, 1043 | ) => { 1044 | await (await getCurrentAdapter(adapter)).delete<Verification>({ 1045 | model: "verification", 1046 | where: [ 1047 | { 1048 | field: "id", 1049 | value: id, 1050 | }, 1051 | ], 1052 | }); 1053 | }, 1054 | deleteVerificationByIdentifier: async ( 1055 | identifier: string, 1056 | context?: GenericEndpointContext, 1057 | ) => { 1058 | await (await getCurrentAdapter(adapter)).delete<Verification>({ 1059 | model: "verification", 1060 | where: [ 1061 | { 1062 | field: "identifier", 1063 | value: identifier, 1064 | }, 1065 | ], 1066 | }); 1067 | }, 1068 | updateVerificationValue: async ( 1069 | id: string, 1070 | data: Partial<Verification>, 1071 | context?: GenericEndpointContext, 1072 | ) => { 1073 | const verification = await updateWithHooks<Verification>( 1074 | data, 1075 | [{ field: "id", value: id }], 1076 | "verification", 1077 | undefined, 1078 | context, 1079 | ); 1080 | return verification; 1081 | }, 1082 | }; 1083 | }; 1084 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/tests/normal.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { expect } from "vitest"; 2 | import { createTestSuite } from "../create-test-suite"; 3 | import type { User } from "../../types"; 4 | import type { BetterAuthPlugin } from "@better-auth/core"; 5 | 6 | /** 7 | * This test suite tests the basic CRUD operations of the adapter. 8 | */ 9 | export const normalTestSuite = createTestSuite("normal", {}, (helpers) => { 10 | const tests = getNormalTestSuiteTests(helpers); 11 | return { 12 | "init - tests": async () => { 13 | const opts = helpers.getBetterAuthOptions(); 14 | expect(opts.advanced?.database?.useNumberId).toBe(undefined); 15 | }, 16 | ...tests, 17 | }; 18 | }); 19 | 20 | export const getNormalTestSuiteTests = ({ 21 | adapter, 22 | generate, 23 | insertRandom, 24 | modifyBetterAuthOptions, 25 | sortModels, 26 | customIdGenerator, 27 | getBetterAuthOptions, 28 | }: Parameters<Parameters<typeof createTestSuite>[2]>[0]) => { 29 | /** 30 | * Some databases (such as SQLite) sort rows orders using raw byte values 31 | * Meaning that capitalization, numbers and others goes before the rest of the alphabet 32 | * Because of the inconsistency, as a bare minimum for testing sorting functionality, we should 33 | * remove all capitalizations and numbers from the `name` field 34 | */ 35 | const createBinarySortFriendlyUsers = async (count: number) => { 36 | let users: User[] = []; 37 | for (let i = 0; i < count; i++) { 38 | const user = await generate("user"); 39 | const userResult = await adapter.create<User>({ 40 | model: "user", 41 | data: { 42 | ...user, 43 | name: user.name.replace(/[0-9]/g, "").toLowerCase(), 44 | }, 45 | forceAllowId: true, 46 | }); 47 | users.push(userResult); 48 | } 49 | return users; 50 | }; 51 | 52 | return { 53 | "create - should create a model": async () => { 54 | const user = await generate("user"); 55 | const result = await adapter.create<User>({ 56 | model: "user", 57 | data: user, 58 | forceAllowId: true, 59 | }); 60 | const options = getBetterAuthOptions(); 61 | if (options.advanced?.database?.useNumberId) { 62 | expect(typeof result.id).toEqual("string"); 63 | user.id = result.id; 64 | } else { 65 | expect(typeof result.id).toEqual("string"); 66 | } 67 | expect(result).toEqual(user); 68 | }, 69 | "create - should always return an id": async () => { 70 | const { id: _, ...user } = await generate("user"); 71 | const res = await adapter.create<User>({ 72 | model: "user", 73 | data: user, 74 | }); 75 | expect(res).toHaveProperty("id"); 76 | expect(typeof res.id).toEqual("string"); 77 | }, 78 | "create - should use generateId if provided": async () => { 79 | const ID = (await customIdGenerator?.()) || "MOCK-ID"; 80 | await modifyBetterAuthOptions( 81 | { 82 | advanced: { 83 | database: { 84 | generateId: () => ID, 85 | }, 86 | }, 87 | }, 88 | false, 89 | ); 90 | const { id: _, ...user } = await generate("user"); 91 | const res = await adapter.create<User>({ 92 | model: "user", 93 | data: user, 94 | }); 95 | expect(res.id).toEqual(ID); 96 | const findResult = await adapter.findOne<User>({ 97 | model: "user", 98 | where: [{ field: "id", value: res.id }], 99 | }); 100 | expect(findResult).toEqual(res); 101 | }, 102 | "create - should return null for nullable foreign keys": async () => { 103 | await modifyBetterAuthOptions( 104 | { 105 | plugins: [ 106 | { 107 | id: "nullable-test", 108 | schema: { 109 | testModel: { 110 | fields: { 111 | nullableReference: { 112 | type: "string", 113 | references: { field: "id", model: "user" }, 114 | required: false, 115 | }, 116 | }, 117 | }, 118 | }, 119 | } satisfies BetterAuthPlugin, 120 | ], 121 | }, 122 | true, 123 | ); 124 | const { nullableReference } = await adapter.create<{ 125 | nullableReference: string | null; 126 | }>({ 127 | model: "testModel", 128 | data: { nullableReference: null }, 129 | forceAllowId: true, 130 | }); 131 | expect(nullableReference).toBeNull(); 132 | }, 133 | "findOne - should find a model": async () => { 134 | const [user] = await insertRandom("user"); 135 | const result = await adapter.findOne<User>({ 136 | model: "user", 137 | where: [{ field: "id", value: user.id }], 138 | }); 139 | expect(result).toEqual(user); 140 | }, 141 | "findOne - should find a model using a reference field": async () => { 142 | const [user, session] = await insertRandom("session"); 143 | const result = await adapter.findOne<User>({ 144 | model: "session", 145 | where: [{ field: "userId", value: user.id }], 146 | }); 147 | expect(result).toEqual(session); 148 | }, 149 | "findOne - should not throw on record not found": async () => { 150 | const result = await adapter.findOne<User>({ 151 | model: "user", 152 | where: [{ field: "id", value: "100000" }], 153 | }); 154 | expect(result).toBeNull(); 155 | }, 156 | "findOne - should find a model without id": async () => { 157 | const [user] = await insertRandom("user"); 158 | const result = await adapter.findOne<User>({ 159 | model: "user", 160 | where: [{ field: "email", value: user.email }], 161 | }); 162 | expect(result).toEqual(user); 163 | }, 164 | "findOne - should find a model with modified field name": async () => { 165 | await modifyBetterAuthOptions( 166 | { 167 | user: { 168 | fields: { 169 | email: "email_address", 170 | }, 171 | }, 172 | }, 173 | true, 174 | ); 175 | const [user] = await insertRandom("user"); 176 | const result = await adapter.findOne<User>({ 177 | model: "user", 178 | where: [{ field: "email", value: user.email }], 179 | }); 180 | expect(result).toEqual(user); 181 | expect(result?.email).toEqual(user.email); 182 | expect(true).toEqual(true); 183 | }, 184 | "findOne - should find a model with modified model name": async () => { 185 | await modifyBetterAuthOptions( 186 | { 187 | user: { 188 | modelName: "user_custom", 189 | }, 190 | }, 191 | true, 192 | ); 193 | const [user] = await insertRandom("user"); 194 | expect(user).toBeDefined(); 195 | expect(user).toHaveProperty("id"); 196 | expect(user).toHaveProperty("name"); 197 | const result = await adapter.findOne<User>({ 198 | model: "user", 199 | where: [{ field: "email", value: user.email }], 200 | }); 201 | expect(result).toEqual(user); 202 | expect(result?.email).toEqual(user.email); 203 | expect(true).toEqual(true); 204 | }, 205 | "findOne - should find a model with additional fields": async () => { 206 | await modifyBetterAuthOptions( 207 | { 208 | user: { 209 | additionalFields: { 210 | customField: { 211 | type: "string", 212 | input: false, 213 | required: true, 214 | defaultValue: "default-value", 215 | }, 216 | }, 217 | }, 218 | }, 219 | true, 220 | ); 221 | const [user_] = await insertRandom("user"); 222 | const user = user_ as User & { customField: string }; 223 | expect(user).toHaveProperty("customField"); 224 | expect(user.customField).toBe("default-value"); 225 | const result = await adapter.findOne<User & { customField: string }>({ 226 | model: "user", 227 | where: [{ field: "customField", value: user.customField }], 228 | }); 229 | expect(result).toEqual(user); 230 | expect(result?.customField).toEqual("default-value"); 231 | }, 232 | "findOne - should select fields": async () => { 233 | const [user] = await insertRandom("user"); 234 | const result = await adapter.findOne<Pick<User, "email" | "name">>({ 235 | model: "user", 236 | where: [{ field: "id", value: user.id }], 237 | select: ["email", "name"], 238 | }); 239 | expect(result).toEqual({ email: user.email, name: user.name }); 240 | }, 241 | "findOne - should find model with date field": async () => { 242 | const [user] = await insertRandom("user"); 243 | const result = await adapter.findOne<User>({ 244 | model: "user", 245 | where: [{ field: "createdAt", value: user.createdAt, operator: "eq" }], 246 | }); 247 | expect(result).toEqual(user); 248 | expect(result?.createdAt).toBeInstanceOf(Date); 249 | expect(result?.createdAt).toEqual(user.createdAt); 250 | }, 251 | "findMany - should find many models with date fields": async () => { 252 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 253 | const youngestUser = users.sort( 254 | (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), 255 | )[0]!; 256 | const result = await adapter.findMany<User>({ 257 | model: "user", 258 | where: [ 259 | { field: "createdAt", value: youngestUser.createdAt, operator: "lt" }, 260 | ], 261 | }); 262 | expect(sortModels(result)).toEqual( 263 | sortModels( 264 | users.filter((user) => user.createdAt < youngestUser.createdAt), 265 | ), 266 | ); 267 | }, 268 | "findMany - should find many models": async () => { 269 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 270 | const result = await adapter.findMany<User>({ 271 | model: "user", 272 | }); 273 | expect(sortModels(result)).toEqual(sortModels(users)); 274 | }, 275 | "findMany - should return an empty array when no models are found": 276 | async () => { 277 | const result = await adapter.findMany<User>({ 278 | model: "user", 279 | where: [{ field: "id", value: "100000" }], 280 | }); 281 | expect(result).toEqual([]); 282 | }, 283 | "findMany - should find many models with starts_with operator": 284 | async () => { 285 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 286 | const result = await adapter.findMany<User>({ 287 | model: "user", 288 | where: [{ field: "name", value: "user", operator: "starts_with" }], 289 | }); 290 | expect(sortModels(result)).toEqual(sortModels(users)); 291 | }, 292 | "findMany - should find many models with ends_with operator": async () => { 293 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 294 | const result = await adapter.findMany<User>({ 295 | model: "user", 296 | where: [ 297 | { 298 | field: "name", 299 | value: users[0]!.name.slice(-1), 300 | operator: "ends_with", 301 | }, 302 | ], 303 | }); 304 | const expectedResult = sortModels( 305 | users.filter((user) => user.name.endsWith(users[0]!.name.slice(-1))), 306 | ); 307 | expect(sortModels(result)).toEqual(sortModels(expectedResult)); 308 | }, 309 | "findMany - should find many models with contains operator": async () => { 310 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 311 | const result = await adapter.findMany<User>({ 312 | model: "user", 313 | where: [{ field: "email", value: "@", operator: "contains" }], 314 | }); 315 | expect(sortModels(result)).toEqual(sortModels(users)); 316 | }, 317 | "findMany - should find many models with eq operator": async () => { 318 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 319 | const result = await adapter.findMany<User>({ 320 | model: "user", 321 | where: [{ field: "email", value: users[0]!.email, operator: "eq" }], 322 | }); 323 | expect(sortModels(result)).toEqual(sortModels([users[0]!])); 324 | }, 325 | "findMany - should find many models with ne operator": async () => { 326 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 327 | const result = await adapter.findMany<User>({ 328 | model: "user", 329 | where: [{ field: "email", value: users[0]!.email, operator: "ne" }], 330 | }); 331 | expect(sortModels(result)).toEqual(sortModels(users.slice(1))); 332 | }, 333 | "findMany - should find many models with gt operator": async () => { 334 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 335 | const oldestUser = users.sort( 336 | (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), 337 | )[0]!; 338 | const result = await adapter.findMany<User>({ 339 | model: "user", 340 | where: [ 341 | { 342 | field: "createdAt", 343 | value: oldestUser.createdAt, 344 | operator: "gt", 345 | }, 346 | ], 347 | }); 348 | const expectedResult = sortModels( 349 | users.filter((user) => user.createdAt > oldestUser.createdAt), 350 | ); 351 | expect(result.length).not.toBe(0); 352 | expect(sortModels(result)).toEqual(expectedResult); 353 | }, 354 | "findMany - should find many models with gte operator": async () => { 355 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 356 | const oldestUser = users.sort( 357 | (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), 358 | )[0]!; 359 | const result = await adapter.findMany<User>({ 360 | model: "user", 361 | where: [ 362 | { 363 | field: "createdAt", 364 | value: oldestUser.createdAt, 365 | operator: "gte", 366 | }, 367 | ], 368 | }); 369 | const expectedResult = users.filter( 370 | (user) => user.createdAt >= oldestUser.createdAt, 371 | ); 372 | expect(result.length).not.toBe(0); 373 | expect(sortModels(result)).toEqual(sortModels(expectedResult)); 374 | }, 375 | "findMany - should find many models with lte operator": async () => { 376 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 377 | const result = await adapter.findMany<User>({ 378 | model: "user", 379 | where: [ 380 | { field: "createdAt", value: users[0]!.createdAt, operator: "lte" }, 381 | ], 382 | }); 383 | const expectedResult = users.filter( 384 | (user) => user.createdAt <= users[0]!.createdAt, 385 | ); 386 | expect(sortModels(result)).toEqual(sortModels(expectedResult)); 387 | }, 388 | "findMany - should find many models with lt operator": async () => { 389 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 390 | const result = await adapter.findMany<User>({ 391 | model: "user", 392 | where: [ 393 | { field: "createdAt", value: users[0]!.createdAt, operator: "lt" }, 394 | ], 395 | }); 396 | const expectedResult = users.filter( 397 | (user) => user.createdAt < users[0]!.createdAt, 398 | ); 399 | expect(sortModels(result)).toEqual(sortModels(expectedResult)); 400 | }, 401 | "findMany - should find many models with in operator": async () => { 402 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 403 | const result = await adapter.findMany<User>({ 404 | model: "user", 405 | where: [ 406 | { 407 | field: "id", 408 | value: [users[0]!.id, users[1]!.id], 409 | operator: "in", 410 | }, 411 | ], 412 | }); 413 | const expectedResult = users.filter( 414 | (user) => user.id === users[0]!.id || user.id === users[1]!.id, 415 | ); 416 | expect(sortModels(result)).toEqual(sortModels(expectedResult)); 417 | }, 418 | "findMany - should find many models with not_in operator": async () => { 419 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 420 | const result = await adapter.findMany<User>({ 421 | model: "user", 422 | where: [ 423 | { 424 | field: "id", 425 | value: [users[0]!.id, users[1]!.id], 426 | operator: "not_in", 427 | }, 428 | ], 429 | }); 430 | expect(sortModels(result)).toEqual([users[2]]); 431 | }, 432 | "findMany - should find many models with sortBy": async () => { 433 | const users = await createBinarySortFriendlyUsers(5); 434 | const result = await adapter.findMany<User>({ 435 | model: "user", 436 | sortBy: { field: "name", direction: "asc" }, 437 | }); 438 | expect(result.map((x) => x.name)).toEqual( 439 | users.map((x) => x.name).sort((a, b) => a.localeCompare(b)), 440 | ); 441 | }, 442 | "findMany - should find many models with limit": async () => { 443 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 444 | const result = await adapter.findMany<User>({ 445 | model: "user", 446 | limit: 1, 447 | }); 448 | expect(result.length).toEqual(1); 449 | expect(users.find((x) => x.id === result[0]!.id)).not.toBeNull(); 450 | }, 451 | "findMany - should find many models with offset": async () => { 452 | // Note: The returned rows are ordered in no particular order 453 | // This is because databases return rows in whatever order is fastest for the query. 454 | const count = 10; 455 | await insertRandom("user", count); 456 | const result = await adapter.findMany<User>({ 457 | model: "user", 458 | offset: 2, 459 | }); 460 | expect(result.length).toEqual(count - 2); 461 | }, 462 | "findMany - should find many models with limit and offset": async () => { 463 | // Note: The returned rows are ordered in no particular order 464 | // This is because databases return rows in whatever order is fastest for the query. 465 | const count = 5; 466 | await insertRandom("user", count); 467 | const result = await adapter.findMany<User>({ 468 | model: "user", 469 | limit: 2, 470 | offset: 2, 471 | }); 472 | expect(result.length).toEqual(2); 473 | expect(result).toBeInstanceOf(Array); 474 | result.forEach((user) => { 475 | expect(user).toHaveProperty("id"); 476 | expect(user).toHaveProperty("name"); 477 | expect(user).toHaveProperty("email"); 478 | }); 479 | }, 480 | "findMany - should find many models with sortBy and offset": async () => { 481 | const users = await createBinarySortFriendlyUsers(5); 482 | const result = await adapter.findMany<User>({ 483 | model: "user", 484 | sortBy: { field: "name", direction: "asc" }, 485 | offset: 2, 486 | }); 487 | expect(result).toHaveLength(3); 488 | expect(result).toEqual( 489 | users.sort((a, b) => a["name"].localeCompare(b["name"])).slice(2), 490 | ); 491 | }, 492 | "findMany - should find many models with sortBy and limit": async () => { 493 | const users = await createBinarySortFriendlyUsers(5); 494 | const result = await adapter.findMany<User>({ 495 | model: "user", 496 | sortBy: { field: "name", direction: "asc" }, 497 | limit: 2, 498 | }); 499 | expect(result).toEqual( 500 | users.sort((a, b) => a["name"].localeCompare(b["name"])).slice(0, 2), 501 | ); 502 | }, 503 | "findMany - should find many models with sortBy and limit and offset": 504 | async () => { 505 | const users = await createBinarySortFriendlyUsers(5); 506 | const result = await adapter.findMany<User>({ 507 | model: "user", 508 | sortBy: { field: "name", direction: "asc" }, 509 | limit: 2, 510 | offset: 2, 511 | }); 512 | expect(result).toEqual( 513 | users.sort((a, b) => a["name"].localeCompare(b["name"])).slice(2, 4), 514 | ); 515 | }, 516 | "findMany - should find many models with sortBy and limit and offset and where": 517 | async () => { 518 | const users = await createBinarySortFriendlyUsers(5); 519 | const result = await adapter.findMany<User>({ 520 | model: "user", 521 | sortBy: { field: "name", direction: "asc" }, 522 | limit: 2, 523 | offset: 2, 524 | where: [{ field: "name", value: "user", operator: "starts_with" }], 525 | }); 526 | expect(result).toEqual( 527 | users.sort((a, b) => a["name"].localeCompare(b["name"])).slice(2, 4), 528 | ); 529 | }, 530 | "update - should update a model": async () => { 531 | const [user] = await insertRandom("user"); 532 | const result = await adapter.update<User>({ 533 | model: "user", 534 | where: [{ field: "id", value: user.id }], 535 | update: { name: "test-name" }, 536 | }); 537 | const expectedResult = { 538 | ...user, 539 | name: "test-name", 540 | }; 541 | // because of `onUpdate` hook, the updatedAt field will be different 542 | result!.updatedAt = user.updatedAt; 543 | expect(result).toEqual(expectedResult); 544 | const findResult = await adapter.findOne<User>({ 545 | model: "user", 546 | where: [{ field: "id", value: user.id }], 547 | }); 548 | // because of `onUpdate` hook, the updatedAt field will be different 549 | findResult!.updatedAt = user.updatedAt; 550 | expect(findResult).toEqual(expectedResult); 551 | }, 552 | "updateMany - should update all models when where is empty": async () => { 553 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 554 | await adapter.updateMany({ 555 | model: "user", 556 | where: [], 557 | update: { name: "test-name" }, 558 | }); 559 | const result = await adapter.findMany<User>({ 560 | model: "user", 561 | }); 562 | expect(sortModels(result)).toEqual( 563 | sortModels(users).map((user, i) => ({ 564 | ...user, 565 | name: "test-name", 566 | updatedAt: sortModels(result)[i]!.updatedAt, 567 | })), 568 | ); 569 | }, 570 | "updateMany - should update many models with a specific where": 571 | async () => { 572 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 573 | await adapter.updateMany({ 574 | model: "user", 575 | where: [{ field: "id", value: users[0]!.id }], 576 | update: { name: "test-name" }, 577 | }); 578 | const result = await adapter.findOne<User>({ 579 | model: "user", 580 | where: [{ field: "id", value: users[0]!.id }], 581 | }); 582 | expect(result).toEqual({ 583 | ...users[0], 584 | name: "test-name", 585 | updatedAt: result!.updatedAt, 586 | }); 587 | }, 588 | "updateMany - should update many models with a multiple where": 589 | async () => { 590 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 591 | await adapter.updateMany({ 592 | model: "user", 593 | where: [ 594 | { field: "id", value: users[0]!.id, connector: "OR" }, 595 | { field: "id", value: users[1]!.id, connector: "OR" }, 596 | ], 597 | update: { name: "test-name" }, 598 | }); 599 | const result = await adapter.findOne<User>({ 600 | model: "user", 601 | where: [{ field: "id", value: users[0]!.id }], 602 | }); 603 | expect(result).toEqual({ 604 | ...users[0], 605 | name: "test-name", 606 | updatedAt: result!.updatedAt, 607 | }); 608 | }, 609 | "delete - should delete a model": async () => { 610 | const [user] = await insertRandom("user"); 611 | await adapter.delete({ 612 | model: "user", 613 | where: [{ field: "id", value: user.id }], 614 | }); 615 | const result = await adapter.findOne<User>({ 616 | model: "user", 617 | where: [{ field: "id", value: user.id }], 618 | }); 619 | expect(result).toBeNull(); 620 | }, 621 | "delete - should not throw on record not found": async () => { 622 | await expect( 623 | adapter.delete({ 624 | model: "user", 625 | where: [{ field: "id", value: "100000" }], 626 | }), 627 | ).resolves.not.toThrow(); 628 | }, 629 | "deleteMany - should delete many models": async () => { 630 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 631 | await adapter.deleteMany({ 632 | model: "user", 633 | where: [ 634 | { field: "id", value: users[0]!.id, connector: "OR" }, 635 | { field: "id", value: users[1]!.id, connector: "OR" }, 636 | ], 637 | }); 638 | const result = await adapter.findMany<User>({ 639 | model: "user", 640 | }); 641 | expect(sortModels(result)).toEqual(sortModels(users.slice(2))); 642 | }, 643 | "deleteMany - should delete many models with numeric values": async () => { 644 | let i = 0; 645 | await modifyBetterAuthOptions( 646 | { 647 | user: { 648 | additionalFields: { 649 | numericField: { 650 | type: "number", 651 | defaultValue() { 652 | return i++; 653 | }, 654 | }, 655 | }, 656 | }, 657 | }, 658 | true, 659 | ); 660 | const users = (await insertRandom("user", 3)).map( 661 | (x) => x[0], 662 | ) as (User & { numericField: number })[]; 663 | if (!users[0] || !users[1] || !users[2]) { 664 | expect(false).toBe(true); 665 | throw new Error("Users not found"); 666 | } 667 | expect(users[0].numericField).toEqual(0); 668 | expect(users[1].numericField).toEqual(1); 669 | expect(users[2].numericField).toEqual(2); 670 | 671 | await adapter.deleteMany({ 672 | model: "user", 673 | where: [ 674 | { 675 | field: "numericField", 676 | value: users[0].numericField, 677 | operator: "gt", 678 | }, 679 | ], 680 | }); 681 | 682 | const result = await adapter.findMany<User>({ 683 | model: "user", 684 | }); 685 | expect(result).toEqual([users[0]]); 686 | }, 687 | "deleteMany - should delete many models with boolean values": async () => { 688 | const users = (await insertRandom("user", 3)).map((x) => x[0]); 689 | // in this test, we have 3 users, two of which have emailVerified set to true and one to false 690 | // delete all that has emailVerified set to true, and expect users[1] to be the only one left 691 | if (!users[0] || !users[1] || !users[2]) { 692 | expect(false).toBe(true); 693 | throw new Error("Users not found"); 694 | } 695 | await adapter.updateMany({ 696 | model: "user", 697 | where: [], 698 | update: { emailVerified: true }, 699 | }); 700 | await adapter.update({ 701 | model: "user", 702 | where: [{ field: "id", value: users[1].id }], 703 | update: { emailVerified: false }, 704 | }); 705 | await adapter.deleteMany({ 706 | model: "user", 707 | where: [{ field: "emailVerified", value: true }], 708 | }); 709 | const result = await adapter.findMany<User>({ 710 | model: "user", 711 | }); 712 | expect(result).toHaveLength(1); 713 | expect(result.find((user) => user.id === users[0]?.id)).toBeUndefined(); 714 | expect(result.find((user) => user.id === users[1]?.id)).toBeDefined(); 715 | expect(result.find((user) => user.id === users[2]?.id)).toBeUndefined(); 716 | }, 717 | "count - should count many models": async () => { 718 | const users = await insertRandom("user", 15); 719 | const result = await adapter.count({ 720 | model: "user", 721 | }); 722 | expect(result).toEqual(users.length); 723 | }, 724 | "count - should return 0 with no rows to count": async () => { 725 | const result = await adapter.count({ 726 | model: "user", 727 | }); 728 | expect(result).toEqual(0); 729 | }, 730 | "count - should count with where clause": async () => { 731 | const users = (await insertRandom("user", 15)).map((x) => x[0]); 732 | const result = await adapter.count({ 733 | model: "user", 734 | where: [ 735 | { field: "id", value: users[2]!.id, connector: "OR" }, 736 | { field: "id", value: users[3]!.id, connector: "OR" }, 737 | ], 738 | }); 739 | expect(result).toEqual(2); 740 | }, 741 | "update - should correctly return record when updating a field used in where clause": 742 | async () => { 743 | // This tests the fix for MySQL where updating a field that's in the where clause 744 | // would previously fail to find the record using the old value 745 | const [user] = await insertRandom("user"); 746 | const originalEmail = user.email; 747 | 748 | // Update the email, using the old email in the where clause 749 | const result = await adapter.update<User>({ 750 | model: "user", 751 | where: [{ field: "email", value: originalEmail }], 752 | update: { email: "[email protected]" }, 753 | }); 754 | 755 | // Should return the updated record with the new email 756 | expect(result).toBeDefined(); 757 | expect(result!.email).toBe("[email protected]"); 758 | expect(result!.id).toBe(user.id); 759 | 760 | // Verify the update persisted by finding with new email 761 | const foundUser = await adapter.findOne<User>({ 762 | model: "user", 763 | where: [{ field: "email", value: "[email protected]" }], 764 | }); 765 | expect(foundUser).toBeDefined(); 766 | expect(foundUser!.id).toBe(user.id); 767 | 768 | // Old email should not exist 769 | const oldUser = await adapter.findOne<User>({ 770 | model: "user", 771 | where: [{ field: "email", value: originalEmail }], 772 | }); 773 | expect(oldUser).toBeNull(); 774 | }, 775 | 776 | "update - should handle updating multiple fields including where clause field": 777 | async () => { 778 | const [user] = await insertRandom("user"); 779 | const originalEmail = user.email; 780 | 781 | const result = await adapter.update<User>({ 782 | model: "user", 783 | where: [{ field: "email", value: originalEmail }], 784 | update: { 785 | email: "[email protected]", 786 | name: "Updated Name", 787 | emailVerified: true, 788 | }, 789 | }); 790 | 791 | expect(result!.email).toBe("[email protected]"); 792 | expect(result!.name).toBe("Updated Name"); 793 | expect(result!.emailVerified).toBe(true); 794 | expect(result!.id).toBe(user.id); 795 | }, 796 | 797 | "update - should work when updated field is not in where clause": 798 | async () => { 799 | // Regression test: ensure normal updates still work 800 | const [user] = await insertRandom("user"); 801 | 802 | const result = await adapter.update<User>({ 803 | model: "user", 804 | where: [{ field: "email", value: user.email }], 805 | update: { name: "Updated Name Only" }, 806 | }); 807 | 808 | expect(result!.name).toBe("Updated Name Only"); 809 | expect(result!.email).toBe(user.email); // Should remain unchanged 810 | expect(result!.id).toBe(user.id); 811 | }, 812 | }; 813 | }; 814 | ``` -------------------------------------------------------------------------------- /docs/content/docs/plugins/api-key.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: API Key 3 | description: API Key plugin for Better Auth. 4 | --- 5 | 6 | The API Key plugin allows you to create and manage API keys for your application. It provides a way to authenticate and authorize API requests by verifying API keys. 7 | 8 | ## Features 9 | 10 | - Create, manage, and verify API keys 11 | - [Built-in rate limiting](/docs/plugins/api-key#rate-limiting) 12 | - [Custom expiration times, remaining count, and refill systems](/docs/plugins/api-key#remaining-refill-and-expiration) 13 | - [metadata for API keys](/docs/plugins/api-key#metadata) 14 | - Custom prefix 15 | - [Sessions from API keys](/docs/plugins/api-key#sessions-from-api-keys) 16 | 17 | ## Installation 18 | 19 | <Steps> 20 | <Step> 21 | ### Add Plugin to the server 22 | 23 | ```ts title="auth.ts" 24 | import { betterAuth } from "better-auth" 25 | import { apiKey } from "better-auth/plugins" 26 | 27 | export const auth = betterAuth({ 28 | plugins: [ // [!code highlight] 29 | apiKey() // [!code highlight] 30 | ] // [!code highlight] 31 | }) 32 | ``` 33 | </Step> 34 | <Step> 35 | ### Migrate the database 36 | 37 | Run the migration or generate the schema to add the necessary fields and tables to the database. 38 | 39 | <Tabs items={["migrate", "generate"]}> 40 | <Tab value="migrate"> 41 | ```bash 42 | npx @better-auth/cli migrate 43 | ``` 44 | </Tab> 45 | <Tab value="generate"> 46 | ```bash 47 | npx @better-auth/cli generate 48 | ``` 49 | </Tab> 50 | </Tabs> 51 | See the [Schema](#schema) section to add the fields manually. 52 | </Step> 53 | <Step> 54 | ### Add the client plugin 55 | 56 | ```ts title="auth-client.ts" 57 | import { createAuthClient } from "better-auth/client" 58 | import { apiKeyClient } from "better-auth/client/plugins" 59 | 60 | export const authClient = createAuthClient({ 61 | plugins: [ // [!code highlight] 62 | apiKeyClient() // [!code highlight] 63 | ] // [!code highlight] 64 | }) 65 | ``` 66 | </Step> 67 | 68 | </Steps> 69 | 70 | ## Usage 71 | 72 | You can view the list of API Key plugin options [here](/docs/plugins/api-key#api-key-plugin-options). 73 | 74 | ### Create an API key 75 | 76 | <APIMethod 77 | path="/api-key/create" 78 | method="POST" 79 | serverOnlyNote="If you're creating an API key on the server, without access to headers, you must pass the `userId` property. This is the ID of the user that the API key is associated with." 80 | clientOnlyNote="You can adjust more specific API key configurations by using the server method instead." 81 | > 82 | ```ts 83 | type createApiKey = { 84 | /** 85 | * Name of the Api Key. 86 | */ 87 | name?: string = 'project-api-key' 88 | /** 89 | * Expiration time of the Api Key in seconds. 90 | */ 91 | expiresIn?: number = 60 * 60 * 24 * 7 92 | /** 93 | * User Id of the user that the Api Key belongs to. server-only. 94 | * @serverOnly 95 | */ 96 | userId?: string = "user-id" 97 | /** 98 | * Prefix of the Api Key. 99 | */ 100 | prefix?: string = 'project-api-key' 101 | /** 102 | * Remaining number of requests. server-only. 103 | * @serverOnly 104 | */ 105 | remaining?: number = 100 106 | /** 107 | * Metadata of the Api Key. 108 | */ 109 | metadata?: any | null = { someKey: 'someValue' } 110 | /** 111 | * Amount to refill the remaining count of the Api Key. server-only. 112 | * @serverOnly 113 | */ 114 | refillAmount?: number = 100 115 | /** 116 | * Interval to refill the Api Key in milliseconds. server-only. 117 | * @serverOnly 118 | */ 119 | refillInterval?: number = 1000 120 | /** 121 | * 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. 122 | * @serverOnly 123 | */ 124 | rateLimitTimeWindow?: number = 1000 125 | /** 126 | * 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. 127 | * @serverOnly 128 | */ 129 | rateLimitMax?: number = 100 130 | /** 131 | * Whether the key has rate limiting enabled. server-only. 132 | * @serverOnly 133 | */ 134 | rateLimitEnabled?: boolean = true 135 | /** 136 | * Permissions of the Api Key. 137 | */ 138 | permissions?: Record<string, string[]> 139 | } 140 | ``` 141 | </APIMethod> 142 | 143 | <Callout>API keys are assigned to a user.</Callout> 144 | 145 | #### Result 146 | 147 | It'll return the `ApiKey` object which includes the `key` value for you to use. 148 | Otherwise if it throws, it will throw an `APIError`. 149 | 150 | --- 151 | 152 | ### Verify an API key 153 | 154 | <APIMethod 155 | path="/api-key/verify" 156 | method="POST" 157 | isServerOnly 158 | > 159 | ```ts 160 | const permissions = { // Permissions to check are optional. 161 | projects: ["read", "read-write"], 162 | } 163 | 164 | type verifyApiKey = { 165 | /** 166 | * The key to verify. 167 | */ 168 | key: string = "your_api_key_here" 169 | /** 170 | * The permissions to verify. Optional. 171 | */ 172 | permissions?: Record<string, string[]> 173 | } 174 | ``` 175 | </APIMethod> 176 | 177 | 178 | #### Result 179 | 180 | ```ts 181 | type Result = { 182 | valid: boolean; 183 | error: { message: string; code: string } | null; 184 | key: Omit<ApiKey, "key"> | null; 185 | }; 186 | ``` 187 | 188 | --- 189 | 190 | ### Get an API key 191 | 192 | <APIMethod 193 | path="/api-key/get" 194 | method="GET" 195 | requireSession 196 | > 197 | ```ts 198 | type getApiKey = { 199 | /** 200 | * The id of the Api Key. 201 | */ 202 | id: string = "some-api-key-id" 203 | } 204 | ``` 205 | </APIMethod> 206 | 207 | #### Result 208 | 209 | You'll receive everything about the API key details, except for the `key` value itself. 210 | If it fails, it will throw an `APIError`. 211 | 212 | ```ts 213 | type Result = Omit<ApiKey, "key">; 214 | ``` 215 | 216 | --- 217 | 218 | ### Update an API key 219 | 220 | <APIMethod path="/api-key/update" method="POST"> 221 | ```ts 222 | type updateApiKey = { 223 | /** 224 | * The id of the Api Key to update. 225 | */ 226 | keyId: string = "some-api-key-id" 227 | /** 228 | * The id of the user which the api key belongs to. server-only. 229 | * @serverOnly 230 | */ 231 | userId?: string = "some-user-id" 232 | /** 233 | * The name of the key. 234 | */ 235 | name?: string = "some-api-key-name" 236 | /** 237 | * Whether the Api Key is enabled or not. server-only. 238 | * @serverOnly 239 | */ 240 | enabled?: boolean = true 241 | /** 242 | * The number of remaining requests. server-only. 243 | * @serverOnly 244 | */ 245 | remaining?: number = 100 246 | /** 247 | * The refill amount. server-only. 248 | * @serverOnly 249 | */ 250 | refillAmount?: number = 100 251 | /** 252 | * The refill interval in milliseconds. server-only. 253 | * @serverOnly 254 | */ 255 | refillInterval?: number = 1000 256 | /** 257 | * The metadata of the Api Key. server-only. 258 | * @serverOnly 259 | */ 260 | metadata?: any | null = { "key": "value" } 261 | /** 262 | * Expiration time of the Api Key in seconds. server-only. 263 | * @serverOnly 264 | */ 265 | expiresIn?: number = 60 * 60 * 24 * 7 266 | /** 267 | * Whether the key has rate limiting enabled. server-only. 268 | * @serverOnly 269 | */ 270 | rateLimitEnabled?: boolean = true 271 | /** 272 | * The duration in milliseconds where each request is counted. server-only. 273 | * @serverOnly 274 | */ 275 | rateLimitTimeWindow?: number = 1000 276 | /** 277 | * 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. 278 | * @serverOnly 279 | */ 280 | rateLimitMax?: number = 100 281 | /** 282 | * Update the permissions on the API Key. server-only. 283 | * @serverOnly 284 | */ 285 | permissions?: Record<string, string[]> 286 | } 287 | ``` 288 | </APIMethod> 289 | 290 | #### Result 291 | 292 | If fails, throws `APIError`. 293 | Otherwise, you'll receive the API Key details, except for the `key` value itself. 294 | 295 | --- 296 | 297 | ### Delete an API Key 298 | 299 | <APIMethod 300 | path="/api-key/delete" 301 | method="POST" 302 | requireSession 303 | note="This endpoint is attempting to delete the API key from the perspective of the user. It will check if the user's ID matches the key owner to be able to delete it. If you want to delete a key without these checks, we recommend you use an ORM to directly mutate your DB instead." 304 | > 305 | ```ts 306 | type deleteApiKey = { 307 | /** 308 | * The id of the Api Key to delete. 309 | */ 310 | keyId: string = "some-api-key-id" 311 | } 312 | ``` 313 | </APIMethod> 314 | 315 | #### Result 316 | 317 | If fails, throws `APIError`. 318 | Otherwise, you'll receive: 319 | 320 | ```ts 321 | type Result = { 322 | success: boolean; 323 | }; 324 | ``` 325 | 326 | --- 327 | 328 | ### List API keys 329 | 330 | <APIMethod 331 | path="/api-key/list" 332 | method="GET" 333 | requireSession 334 | > 335 | ```ts 336 | type listApiKeys = { 337 | } 338 | ``` 339 | </APIMethod> 340 | 341 | #### Result 342 | 343 | If fails, throws `APIError`. 344 | Otherwise, you'll receive: 345 | 346 | ```ts 347 | type Result = ApiKey[]; 348 | ``` 349 | 350 | --- 351 | 352 | ### Delete all expired API keys 353 | 354 | This function will delete all API keys that have an expired expiration date. 355 | 356 | <APIMethod 357 | path="/api-key/delete-all-expired-api-keys" 358 | method="POST" 359 | isServerOnly 360 | > 361 | ```ts 362 | type deleteAllExpiredApiKeys = { 363 | } 364 | ``` 365 | </APIMethod> 366 | 367 | <Callout> 368 | We automatically delete expired API keys every time any apiKey plugin 369 | endpoints were called, however they are rate-limited to a 10 second cool down 370 | each call to prevent multiple calls to the database. 371 | </Callout> 372 | 373 | --- 374 | 375 | ## Sessions from API keys 376 | 377 | Any time an endpoint in Better Auth is called that has a valid API key in the headers, you can automatically create a mock session to represent the user by enabling `sessionForAPIKeys` option. 378 | 379 | <Callout type="warn"> 380 | This is generally not recommended, as it can lead to security issues if not used carefully. A leaked api key can be used to impersonate a user. 381 | </Callout> 382 | 383 | ```ts 384 | export const auth = betterAuth({ 385 | plugins: [ 386 | apiKey({ 387 | enableSessionForAPIKeys: true, 388 | }), 389 | ], 390 | }); 391 | ``` 392 | 393 | <Tabs items={['Server']}> 394 | <Tab value="Server"> 395 | ```ts 396 | const session = await auth.api.getSession({ 397 | headers: new Headers({ 398 | 'x-api-key': apiKey, 399 | }), 400 | }); 401 | ``` 402 | </Tab> 403 | </Tabs> 404 | 405 | 406 | The default header key is `x-api-key`, but this can be changed by setting the `apiKeyHeaders` option in the plugin options. 407 | 408 | ```ts 409 | export const auth = betterAuth({ 410 | plugins: [ 411 | apiKey({ 412 | apiKeyHeaders: ["x-api-key", "xyz-api-key"], // or you can pass just a string, eg: "x-api-key" 413 | }), 414 | ], 415 | }); 416 | ``` 417 | 418 | Or optionally, you can pass an `apiKeyGetter` function to the plugin options, which will be called with the `GenericEndpointContext`, and from there, you should return the API key, or `null` if the request is invalid. 419 | 420 | ```ts 421 | export const auth = betterAuth({ 422 | plugins: [ 423 | apiKey({ 424 | apiKeyGetter: (ctx) => { 425 | const has = ctx.request.headers.has("x-api-key"); 426 | if (!has) return null; 427 | return ctx.request.headers.get("x-api-key"); 428 | }, 429 | }), 430 | ], 431 | }); 432 | ``` 433 | 434 | ## Rate Limiting 435 | 436 | Every API key can have its own rate limit settings, however, the built-in rate-limiting only applies to the verification process for a given API key. 437 | For every other endpoint/method, you should utilize Better Auth's [built-in rate-limiting](/docs/concepts/rate-limit). 438 | 439 | You can refer to the rate-limit default configurations below in the API Key plugin options. 440 | 441 | An example default value: 442 | 443 | ```ts 444 | export const auth = betterAuth({ 445 | plugins: [ 446 | apiKey({ 447 | rateLimit: { 448 | enabled: true, 449 | timeWindow: 1000 * 60 * 60 * 24, // 1 day 450 | maxRequests: 10, // 10 requests per day 451 | }, 452 | }), 453 | ], 454 | }); 455 | ``` 456 | 457 | For each API key, you can customize the rate-limit options on create. 458 | 459 | <Callout> 460 | You can only customize the rate-limit options on the server auth instance. 461 | </Callout> 462 | 463 | ```ts 464 | const apiKey = await auth.api.createApiKey({ 465 | body: { 466 | rateLimitEnabled: true, 467 | rateLimitTimeWindow: 1000 * 60 * 60 * 24, // 1 day 468 | rateLimitMax: 10, // 10 requests per day 469 | }, 470 | headers: user_headers, 471 | }); 472 | ``` 473 | 474 | ### How does it work? 475 | 476 | For each request, a counter (internally called `requestCount`) is incremented. 477 | If the `rateLimitMax` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. 478 | 479 | ## Remaining, refill, and expiration 480 | 481 | The remaining count is the number of requests left before the API key is disabled. 482 | The refill interval is the interval in milliseconds where the `remaining` count is refilled by day. 483 | The expiration time is the expiration date of the API key. 484 | 485 | ### How does it work? 486 | 487 | #### Remaining: 488 | 489 | Whenever an API key is used, the `remaining` count is updated. 490 | If the `remaining` count is `null`, then there is no cap to key usage. 491 | Otherwise, the `remaining` count is decremented by 1. 492 | If the `remaining` count is 0, then the API key is disabled & removed. 493 | 494 | #### refillInterval & refillAmount: 495 | 496 | Whenever an API key is created, the `refillInterval` and `refillAmount` are set to `null`. 497 | This means that the API key will not be refilled automatically. 498 | However, if `refillInterval` & `refillAmount` are set, then the API key will be refilled accordingly. 499 | 500 | #### Expiration: 501 | 502 | Whenever an API key is created, the `expiresAt` is set to `null`. 503 | This means that the API key will never expire. 504 | However, if the `expiresIn` is set, then the API key will expire after the `expiresIn` time. 505 | 506 | ## Custom Key generation & verification 507 | 508 | You can customize the key generation and verification process straight from the plugin options. 509 | 510 | Here's an example: 511 | 512 | ```ts 513 | export const auth = betterAuth({ 514 | plugins: [ 515 | apiKey({ 516 | customKeyGenerator: (options: { 517 | length: number; 518 | prefix: string | undefined; 519 | }) => { 520 | const apiKey = mySuperSecretApiKeyGenerator( 521 | options.length, 522 | options.prefix 523 | ); 524 | return apiKey; 525 | }, 526 | customAPIKeyValidator: async ({ ctx, key }) => { 527 | const res = await keyService.verify(key) 528 | return res.valid 529 | }, 530 | }), 531 | ], 532 | }); 533 | ``` 534 | 535 | <Callout> 536 | If you're **not** using the `length` property provided by `customKeyGenerator`, you **must** set the `defaultKeyLength` property to how long generated keys will be. 537 | 538 | ```ts 539 | export const auth = betterAuth({ 540 | plugins: [ 541 | apiKey({ 542 | customKeyGenerator: () => { 543 | return crypto.randomUUID(); 544 | }, 545 | defaultKeyLength: 36, // Or whatever the length is 546 | }), 547 | ], 548 | }); 549 | ``` 550 | 551 | </Callout> 552 | 553 | If an API key is validated from your `customAPIKeyValidator`, we still must match that against the database's key. 554 | However, by providing this custom function, you can improve the performance of the API key verification process, 555 | as all failed keys can be invalidated without having to query your database. 556 | 557 | ## Metadata 558 | 559 | We allow you to store metadata alongside your API keys. This is useful for storing information about the key, such as a subscription plan for example. 560 | 561 | To store metadata, make sure you haven't disabled the metadata feature in the plugin options. 562 | 563 | ```ts 564 | export const auth = betterAuth({ 565 | plugins: [ 566 | apiKey({ 567 | enableMetadata: true, 568 | }), 569 | ], 570 | }); 571 | ``` 572 | 573 | Then, you can store metadata in the `metadata` field of the API key object. 574 | 575 | ```ts 576 | const apiKey = await auth.api.createApiKey({ 577 | body: { 578 | metadata: { 579 | plan: "premium", 580 | }, 581 | }, 582 | }); 583 | ``` 584 | 585 | You can then retrieve the metadata from the API key object. 586 | 587 | ```ts 588 | const apiKey = await auth.api.getApiKey({ 589 | body: { 590 | keyId: "your_api_key_id_here", 591 | }, 592 | }); 593 | 594 | console.log(apiKey.metadata.plan); // "premium" 595 | ``` 596 | 597 | ## API Key plugin options 598 | 599 | `apiKeyHeaders` <span className="opacity-70">`string | string[];`</span> 600 | 601 | The header name to check for API key. Default is `x-api-key`. 602 | 603 | `customAPIKeyGetter` <span className="opacity-70">`(ctx: GenericEndpointContext) => string | null`</span> 604 | 605 | A custom function to get the API key from the context. 606 | 607 | `customAPIKeyValidator` <span className="opacity-70">`(options: { ctx: GenericEndpointContext; key: string; }) => boolean | Promise<boolean>`</span> 608 | 609 | A custom function to validate the API key. 610 | 611 | `customKeyGenerator` <span className="opacity-70">`(options: { length: number; prefix: string | undefined; }) => string | Promise<string>`</span> 612 | 613 | A custom function to generate the API key. 614 | 615 | `startingCharactersConfig` <span className="opacity-70">`{ shouldStore?: boolean; charactersLength?: number; }`</span> 616 | 617 | Customize the starting characters configuration. 618 | 619 | <Accordions> 620 | <Accordion title="startingCharactersConfig Options"> 621 | `shouldStore` <span className="opacity-70">`boolean`</span> 622 | 623 | Wether to store the starting characters in the database. 624 | If false, we will set `start` to `null`. 625 | Default is `true`. 626 | 627 | `charactersLength` <span className="opacity-70">`number`</span> 628 | 629 | The length of the starting characters to store in the database. 630 | This includes the prefix length. 631 | Default is `6`. 632 | 633 | </Accordion> 634 | </Accordions> 635 | 636 | `defaultKeyLength` <span className="opacity-70">`number`</span> 637 | 638 | The length of the API key. Longer is better. Default is 64. (Doesn't include the prefix length) 639 | 640 | `defaultPrefix` <span className="opacity-70">`string`</span> 641 | 642 | The prefix of the API key. 643 | 644 | Note: We recommend you append an underscore to the prefix to make the prefix more identifiable. (eg `hello_`) 645 | 646 | `maximumPrefixLength` <span className="opacity-70">`number`</span> 647 | 648 | The maximum length of the prefix. 649 | 650 | `minimumPrefixLength` <span className="opacity-70">`number`</span> 651 | 652 | The minimum length of the prefix. 653 | 654 | `requireName` <span className="opacity-70">`boolean`</span> 655 | 656 | Whether to require a name for the API key. Default is `false`. 657 | 658 | `maximumNameLength` <span className="opacity-70">`number`</span> 659 | 660 | The maximum length of the name. 661 | 662 | `minimumNameLength` <span className="opacity-70">`number`</span> 663 | 664 | The minimum length of the name. 665 | 666 | `enableMetadata` <span className="opacity-70">`boolean`</span> 667 | 668 | Whether to enable metadata for an API key. 669 | 670 | `keyExpiration` <span className="opacity-70">`{ defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number; }`</span> 671 | 672 | Customize the key expiration. 673 | 674 | <Accordions> 675 | <Accordion title="keyExpiration options"> 676 | `defaultExpiresIn` <span className="opacity-70">`number | null`</span> 677 | 678 | The default expires time in milliseconds. 679 | If `null`, then there will be no expiration time. 680 | Default is `null`. 681 | 682 | `disableCustomExpiresTime` <span className="opacity-70">`boolean`</span> 683 | 684 | Wether to disable the expires time passed from the client. 685 | If `true`, the expires time will be based on the default values. 686 | Default is `false`. 687 | 688 | `minExpiresIn` <span className="opacity-70">`number`</span> 689 | 690 | The minimum expiresIn value allowed to be set from the client. in days. 691 | Default is `1`. 692 | 693 | `maxExpiresIn` <span className="opacity-70">`number`</span> 694 | 695 | The maximum expiresIn value allowed to be set from the client. in days. 696 | Default is `365`. 697 | 698 | </Accordion> 699 | </Accordions> 700 | 701 | `rateLimit` <span className="opacity-70">`{ enabled?: boolean; timeWindow?: number; maxRequests?: number; }`</span> 702 | 703 | Customize the rate-limiting. 704 | 705 | <Accordions> 706 | <Accordion title="rateLimit options"> 707 | `enabled` <span className="opacity-70">`boolean`</span> 708 | 709 | Whether to enable rate limiting. (Default true) 710 | 711 | `timeWindow` <span className="opacity-70">`number`</span> 712 | 713 | The duration in milliseconds where each request is counted. 714 | Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. 715 | 716 | `maxRequests` <span className="opacity-70">`number`</span> 717 | 718 | Maximum amount of requests allowed within a window. 719 | Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. 720 | 721 | </Accordion> 722 | </Accordions> 723 | 724 | `schema` <span className="opacity-70">`InferOptionSchema<ReturnType<typeof apiKeySchema>>`</span> 725 | 726 | Custom schema for the API key plugin. 727 | 728 | `enableSessionForAPIKeys` <span className="opacity-70">`boolean`</span> 729 | 730 | An API Key can represent a valid session, so we can mock a session for the user if we find a valid API key in the request headers. Default is `false`. 731 | 732 | `permissions` <span className="opacity-70">`{ defaultPermissions?: Statements | ((userId: string, ctx: GenericEndpointContext) => Statements | Promise<Statements>) }`</span> 733 | 734 | Permissions for the API key. 735 | 736 | Read more about permissions [here](/docs/plugins/api-key#permissions). 737 | 738 | <Accordions> 739 | <Accordion title="permissions Options"> 740 | `defaultPermissions` <span className="opacity-70">`Statements | ((userId: string, ctx: GenericEndpointContext) => Statements | Promise<Statements>)`</span> 741 | 742 | The default permissions for the API key. 743 | 744 | </Accordion> 745 | </Accordions> 746 | 747 | `disableKeyHashing` <span className="opacity-70">`boolean`</span> 748 | 749 | Disable hashing of the API key. 750 | 751 | ⚠️ Security Warning: It's strongly recommended to not disable hashing. 752 | Storing API keys in plaintext makes them vulnerable to database breaches, potentially exposing all your users' API keys. 753 | 754 | --- 755 | 756 | ## Permissions 757 | 758 | API keys can have permissions associated with them, allowing you to control access at a granular level. Permissions are structured as a record of resource types to arrays of allowed actions. 759 | 760 | ### Setting Default Permissions 761 | 762 | You can configure default permissions that will be applied to all newly created API keys: 763 | 764 | ```ts 765 | export const auth = betterAuth({ 766 | plugins: [ 767 | apiKey({ 768 | permissions: { 769 | defaultPermissions: { 770 | files: ["read"], 771 | users: ["read"], 772 | }, 773 | }, 774 | }), 775 | ], 776 | }); 777 | ``` 778 | 779 | You can also provide a function that returns permissions dynamically: 780 | 781 | ```ts 782 | export const auth = betterAuth({ 783 | plugins: [ 784 | apiKey({ 785 | permissions: { 786 | defaultPermissions: async (userId, ctx) => { 787 | // Fetch user role or other data to determine permissions 788 | return { 789 | files: ["read"], 790 | users: ["read"], 791 | }; 792 | }, 793 | }, 794 | }), 795 | ], 796 | }); 797 | ``` 798 | 799 | ### Creating API Keys with Permissions 800 | 801 | When creating an API key, you can specify custom permissions: 802 | 803 | ```ts 804 | const apiKey = await auth.api.createApiKey({ 805 | body: { 806 | name: "My API Key", 807 | permissions: { 808 | files: ["read", "write"], 809 | users: ["read"], 810 | }, 811 | userId: "userId", 812 | }, 813 | }); 814 | ``` 815 | 816 | ### Verifying API Keys with Required Permissions 817 | 818 | When verifying an API key, you can check if it has the required permissions: 819 | 820 | ```ts 821 | const result = await auth.api.verifyApiKey({ 822 | body: { 823 | key: "your_api_key_here", 824 | permissions: { 825 | files: ["read"], 826 | }, 827 | }, 828 | }); 829 | 830 | if (result.valid) { 831 | // API key is valid and has the required permissions 832 | } else { 833 | // API key is invalid or doesn't have the required permissions 834 | } 835 | ``` 836 | 837 | ### Updating API Key Permissions 838 | 839 | You can update the permissions of an existing API key: 840 | 841 | ```ts 842 | const apiKey = await auth.api.updateApiKey({ 843 | body: { 844 | keyId: existingApiKeyId, 845 | permissions: { 846 | files: ["read", "write", "delete"], 847 | users: ["read", "write"], 848 | }, 849 | }, 850 | headers: user_headers, 851 | }); 852 | ``` 853 | 854 | ### Permissions Structure 855 | 856 | Permissions follow a resource-based structure: 857 | 858 | ```ts 859 | type Permissions = { 860 | [resourceType: string]: string[]; 861 | }; 862 | 863 | // Example: 864 | const permissions = { 865 | files: ["read", "write", "delete"], 866 | users: ["read"], 867 | projects: ["read", "write"], 868 | }; 869 | ``` 870 | 871 | When verifying an API key, all required permissions must be present in the API key's permissions for validation to succeed. 872 | 873 | ## Schema 874 | 875 | Table: `apiKey` 876 | 877 | <DatabaseTable 878 | fields={[ 879 | { 880 | name: "id", 881 | type: "string", 882 | description: "The ID of the API key.", 883 | isUnique: true, 884 | isPrimaryKey: true, 885 | }, 886 | { 887 | name: "name", 888 | type: "string", 889 | description: "The name of the API key.", 890 | isOptional: true, 891 | }, 892 | { 893 | name: "start", 894 | type: "string", 895 | description: 896 | "The starting characters of the API key. Useful for showing the first few characters of the API key in the UI for the users to easily identify.", 897 | isOptional: true, 898 | }, 899 | { 900 | name: "prefix", 901 | type: "string", 902 | description: "The API Key prefix. Stored as plain text.", 903 | isOptional: true, 904 | }, 905 | { 906 | name: "key", 907 | type: "string", 908 | description: "The hashed API key itself.", 909 | }, 910 | { 911 | name: "userId", 912 | type: "string", 913 | description: "The ID of the user associated with the API key.", 914 | isForeignKey: true, 915 | }, 916 | { 917 | name: "refillInterval", 918 | type: "number", 919 | description: "The interval to refill the key in milliseconds.", 920 | isOptional: true, 921 | }, 922 | { 923 | name: "refillAmount", 924 | type: "number", 925 | description: "The amount to refill the remaining count of the key.", 926 | isOptional: true, 927 | }, 928 | { 929 | name: "lastRefillAt", 930 | type: "Date", 931 | description: "The date and time when the key was last refilled.", 932 | isOptional: true, 933 | }, 934 | { 935 | name: "enabled", 936 | type: "boolean", 937 | description: "Whether the API key is enabled.", 938 | }, 939 | { 940 | name: "rateLimitEnabled", 941 | type: "boolean", 942 | description: "Whether the API key has rate limiting enabled.", 943 | }, 944 | { 945 | name: "rateLimitTimeWindow", 946 | type: "number", 947 | description: "The time window in milliseconds for the rate limit.", 948 | isOptional: true, 949 | }, 950 | { 951 | name: "rateLimitMax", 952 | type: "number", 953 | description: 954 | "The maximum number of requests allowed within the `rateLimitTimeWindow`.", 955 | isOptional: true, 956 | }, 957 | { 958 | name: "requestCount", 959 | type: "number", 960 | description: 961 | "The number of requests made within the rate limit time window.", 962 | }, 963 | { 964 | name: "remaining", 965 | type: "number", 966 | description: "The number of requests remaining.", 967 | isOptional: true, 968 | }, 969 | { 970 | name: "lastRequest", 971 | type: "Date", 972 | description: "The date and time of the last request made to the key.", 973 | isOptional: true, 974 | }, 975 | { 976 | name: "expiresAt", 977 | type: "Date", 978 | description: "The date and time when the key will expire.", 979 | isOptional: true, 980 | }, 981 | { 982 | name: "createdAt", 983 | type: "Date", 984 | description: "The date and time the API key was created.", 985 | }, 986 | { 987 | name: "updatedAt", 988 | type: "Date", 989 | description: "The date and time the API key was updated.", 990 | }, 991 | { 992 | name: "permissions", 993 | type: "string", 994 | description: "The permissions of the key.", 995 | isOptional: true, 996 | }, 997 | { 998 | name: "metadata", 999 | type: "Object", 1000 | isOptional: true, 1001 | description: "Any additional metadata you want to store with the key.", 1002 | }, 1003 | ]} 1004 | /> 1005 | ```