This is page 22 of 69. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-declaration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.base.json ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/kysely-adapter/bun-sqlite-dialect.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * @see {@link https://github.com/dylanblokhuis/kysely-bun-sqlite} - Fork of the original kysely-bun-sqlite package by @dylanblokhuis 3 | */ 4 | import { 5 | Kysely, 6 | CompiledQuery, 7 | DEFAULT_MIGRATION_LOCK_TABLE, 8 | DEFAULT_MIGRATION_TABLE, 9 | sql, 10 | type DatabaseConnection, 11 | type QueryResult, 12 | type DatabaseIntrospector, 13 | type SchemaMetadata, 14 | type DatabaseMetadataOptions, 15 | type TableMetadata, 16 | type DatabaseMetadata, 17 | type Driver, 18 | type Dialect, 19 | type QueryCompiler, 20 | type DialectAdapter, 21 | } from "kysely"; 22 | import { DefaultQueryCompiler } from "kysely"; 23 | import { DialectAdapterBase } from "kysely"; 24 | import type { Database } from "bun:sqlite"; 25 | 26 | export class BunSqliteAdapter implements DialectAdapterBase { 27 | get supportsCreateIfNotExists(): boolean { 28 | return true; 29 | } 30 | 31 | get supportsTransactionalDdl(): boolean { 32 | return false; 33 | } 34 | 35 | get supportsReturning(): boolean { 36 | return true; 37 | } 38 | 39 | async acquireMigrationLock(): Promise<void> { 40 | // SQLite only has one connection that's reserved by the migration system 41 | // for the whole time between acquireMigrationLock and releaseMigrationLock. 42 | // We don't need to do anything here. 43 | } 44 | 45 | async releaseMigrationLock(): Promise<void> { 46 | // SQLite only has one connection that's reserved by the migration system 47 | // for the whole time between acquireMigrationLock and releaseMigrationLock. 48 | // We don't need to do anything here. 49 | } 50 | get supportsOutput(): boolean { 51 | return true; 52 | } 53 | } 54 | 55 | /** 56 | * Config for the SQLite dialect. 57 | */ 58 | export interface BunSqliteDialectConfig { 59 | /** 60 | * An sqlite Database instance or a function that returns one. 61 | */ 62 | database: Database; 63 | 64 | /** 65 | * Called once when the first query is executed. 66 | */ 67 | onCreateConnection?: (connection: DatabaseConnection) => Promise<void>; 68 | } 69 | 70 | export class BunSqliteDriver implements Driver { 71 | readonly #config: BunSqliteDialectConfig; 72 | readonly #connectionMutex = new ConnectionMutex(); 73 | 74 | #db?: Database; 75 | #connection?: DatabaseConnection; 76 | 77 | constructor(config: BunSqliteDialectConfig) { 78 | this.#config = { ...config }; 79 | } 80 | 81 | async init(): Promise<void> { 82 | this.#db = this.#config.database; 83 | 84 | this.#connection = new BunSqliteConnection(this.#db); 85 | 86 | if (this.#config.onCreateConnection) { 87 | await this.#config.onCreateConnection(this.#connection); 88 | } 89 | } 90 | 91 | async acquireConnection(): Promise<DatabaseConnection> { 92 | // SQLite only has one single connection. We use a mutex here to wait 93 | // until the single connection has been released. 94 | await this.#connectionMutex.lock(); 95 | return this.#connection!; 96 | } 97 | 98 | async beginTransaction(connection: DatabaseConnection): Promise<void> { 99 | await connection.executeQuery(CompiledQuery.raw("begin")); 100 | } 101 | 102 | async commitTransaction(connection: DatabaseConnection): Promise<void> { 103 | await connection.executeQuery(CompiledQuery.raw("commit")); 104 | } 105 | 106 | async rollbackTransaction(connection: DatabaseConnection): Promise<void> { 107 | await connection.executeQuery(CompiledQuery.raw("rollback")); 108 | } 109 | 110 | async releaseConnection(): Promise<void> { 111 | this.#connectionMutex.unlock(); 112 | } 113 | 114 | async destroy(): Promise<void> { 115 | this.#db?.close(); 116 | } 117 | } 118 | 119 | class BunSqliteConnection implements DatabaseConnection { 120 | readonly #db: Database; 121 | 122 | constructor(db: Database) { 123 | this.#db = db; 124 | } 125 | 126 | executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> { 127 | const { sql, parameters } = compiledQuery; 128 | const stmt = this.#db.prepare(sql); 129 | 130 | return Promise.resolve({ 131 | rows: stmt.all(parameters as any) as O[], 132 | }); 133 | } 134 | 135 | async *streamQuery() { 136 | throw new Error("Streaming query is not supported by SQLite driver."); 137 | } 138 | } 139 | 140 | class ConnectionMutex { 141 | #promise?: Promise<void>; 142 | #resolve?: () => void; 143 | 144 | async lock(): Promise<void> { 145 | while (this.#promise) { 146 | await this.#promise; 147 | } 148 | 149 | this.#promise = new Promise((resolve) => { 150 | this.#resolve = resolve; 151 | }); 152 | } 153 | 154 | unlock(): void { 155 | const resolve = this.#resolve; 156 | 157 | this.#promise = undefined; 158 | this.#resolve = undefined; 159 | 160 | resolve?.(); 161 | } 162 | } 163 | 164 | export class BunSqliteIntrospector implements DatabaseIntrospector { 165 | readonly #db: Kysely<unknown>; 166 | 167 | constructor(db: Kysely<unknown>) { 168 | this.#db = db; 169 | } 170 | 171 | async getSchemas(): Promise<SchemaMetadata[]> { 172 | // Sqlite doesn't support schemas. 173 | return []; 174 | } 175 | 176 | async getTables( 177 | options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, 178 | ): Promise<TableMetadata[]> { 179 | let query = this.#db 180 | // @ts-expect-error 181 | .selectFrom("sqlite_schema") 182 | // @ts-expect-error 183 | .where("type", "=", "table") 184 | // @ts-expect-error 185 | .where("name", "not like", "sqlite_%") 186 | .select("name") 187 | .$castTo<{ name: string }>(); 188 | 189 | if (!options.withInternalKyselyTables) { 190 | query = query 191 | // @ts-expect-error 192 | .where("name", "!=", DEFAULT_MIGRATION_TABLE) 193 | // @ts-expect-error 194 | .where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE); 195 | } 196 | 197 | const tables = await query.execute(); 198 | return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name))); 199 | } 200 | 201 | async getMetadata( 202 | options?: DatabaseMetadataOptions, 203 | ): Promise<DatabaseMetadata> { 204 | return { 205 | tables: await this.getTables(options), 206 | }; 207 | } 208 | 209 | async #getTableMetadata(table: string): Promise<TableMetadata> { 210 | const db = this.#db; 211 | 212 | // Get the SQL that was used to create the table. 213 | const createSql = await db 214 | // @ts-expect-error 215 | .selectFrom("sqlite_master") 216 | // @ts-expect-error 217 | .where("name", "=", table) 218 | .select("sql") 219 | .$castTo<{ sql: string | undefined }>() 220 | .execute(); 221 | 222 | // Try to find the name of the column that has `autoincrement` 🤦 223 | const autoIncrementCol = createSql[0]?.sql 224 | ?.split(/[\(\),]/) 225 | ?.find((it) => it.toLowerCase().includes("autoincrement")) 226 | ?.split(/\s+/)?.[0] 227 | ?.replace(/["`]/g, ""); 228 | 229 | const columns = await db 230 | .selectFrom( 231 | sql<{ 232 | name: string; 233 | type: string; 234 | notnull: 0 | 1; 235 | dflt_value: any; 236 | }>`pragma_table_info(${table})`.as("table_info"), 237 | ) 238 | .select(["name", "type", "notnull", "dflt_value"]) 239 | .execute(); 240 | 241 | return { 242 | name: table, 243 | columns: columns.map((col) => ({ 244 | name: col.name, 245 | dataType: col.type, 246 | isNullable: !col.notnull, 247 | isAutoIncrementing: col.name === autoIncrementCol, 248 | hasDefaultValue: col.dflt_value != null, 249 | })), 250 | isView: true, 251 | }; 252 | } 253 | } 254 | 255 | export class BunSqliteQueryCompiler extends DefaultQueryCompiler { 256 | protected override getCurrentParameterPlaceholder() { 257 | return "?"; 258 | } 259 | 260 | protected override getLeftIdentifierWrapper(): string { 261 | return '"'; 262 | } 263 | 264 | protected override getRightIdentifierWrapper(): string { 265 | return '"'; 266 | } 267 | 268 | protected override getAutoIncrement() { 269 | return "autoincrement"; 270 | } 271 | } 272 | 273 | export class BunSqliteDialect implements Dialect { 274 | readonly #config: BunSqliteDialectConfig; 275 | 276 | constructor(config: BunSqliteDialectConfig) { 277 | this.#config = { ...config }; 278 | } 279 | 280 | createDriver(): Driver { 281 | return new BunSqliteDriver(this.#config); 282 | } 283 | 284 | createQueryCompiler(): QueryCompiler { 285 | return new BunSqliteQueryCompiler(); 286 | } 287 | 288 | createAdapter(): DialectAdapter { 289 | return new BunSqliteAdapter(); 290 | } 291 | 292 | createIntrospector(db: Kysely<any>): DatabaseIntrospector { 293 | return new BunSqliteIntrospector(db); 294 | } 295 | } 296 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/zoom.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import { generateCodeChallenge, validateAuthorizationCode } from "../oauth2"; 3 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 4 | 5 | export type LoginType = 6 | | 0 /** Facebook OAuth */ 7 | | 1 /** Google OAuth */ 8 | | 24 /** Apple OAuth */ 9 | | 27 /** Microsoft OAuth */ 10 | | 97 /** Mobile device */ 11 | | 98 /** RingCentral OAuth */ 12 | | 99 /** API user */ 13 | | 100 /** Zoom Work email */ 14 | | 101; /** Single Sign-On (SSO) */ 15 | 16 | export type AccountStatus = "pending" | "active" | "inactive"; 17 | 18 | export type PronounOption = 19 | | 1 /** Ask the user every time */ 20 | | 2 /** Always display */ 21 | | 3; /** Do not display */ 22 | 23 | export interface PhoneNumber { 24 | /** The country code of the phone number (Example: "+1") */ 25 | code: string; 26 | 27 | /** The country of the phone number (Example: "US") */ 28 | country: string; 29 | 30 | /** The label for the phone number (Example: "Mobile") */ 31 | label: string; 32 | 33 | /** The phone number itself (Example: "800000000") */ 34 | number: string; 35 | 36 | /** Whether the phone number has been verified (Example: true) */ 37 | verified: boolean; 38 | } 39 | 40 | /** 41 | * See the full documentation below: 42 | * https://developers.zoom.us/docs/api/users/#tag/users/GET/users/{userId} 43 | */ 44 | export interface ZoomProfile extends Record<string, any> { 45 | /** The user's account ID (Example: "q6gBJVO5TzexKYTb_I2rpg") */ 46 | account_id: string; 47 | /** The user's account number (Example: 10009239) */ 48 | account_number: number; 49 | /** The user's cluster (Example: "us04") */ 50 | cluster: string; 51 | /** The user's CMS ID. Only enabled for Kaltura integration (Example: "KDcuGIm1QgePTO8WbOqwIQ") */ 52 | cms_user_id: string; 53 | /** The user's cost center (Example: "cost center") */ 54 | cost_center: string; 55 | /** User create time (Example: "2018-10-31T04:32:37Z") */ 56 | created_at: string; 57 | /** Department (Example: "Developers") */ 58 | dept: string; 59 | /** User's display name (Example: "Jill Chill") */ 60 | display_name: string; 61 | /** User's email address (Example: "[email protected]") */ 62 | email: string; 63 | /** User's first name (Example: "Jill") */ 64 | first_name: string; 65 | /** IDs of the web groups that the user belongs to (Example: ["RSMaSp8sTEGK0_oamiA2_w"]) */ 66 | group_ids: string[]; 67 | /** User ID (Example: "zJKyaiAyTNC-MWjiWC18KQ") */ 68 | id: string; 69 | /** IM IDs of the groups that the user belongs to (Example: ["t-_-d56CSWG-7BF15LLrOw"]) */ 70 | im_group_ids: string[]; 71 | /** The user's JID (Example: "[email protected]") */ 72 | jid: string; 73 | /** The user's job title (Example: "API Developer") */ 74 | job_title: string; 75 | /** Default language for the Zoom Web Portal (Example: "en-US") */ 76 | language: string; 77 | /** User last login client version (Example: "5.9.6.4993(mac)") */ 78 | last_client_version: string; 79 | /** User last login time (Example: "2021-05-05T20:40:30Z") */ 80 | last_login_time: string; 81 | /** User's last name (Example: "Chill") */ 82 | last_name: string; 83 | /** The time zone of the user (Example: "Asia/Shanghai") */ 84 | timezone: string; 85 | /** User's location (Example: "Paris") */ 86 | location: string; 87 | /** The user's login method (Example: 101) */ 88 | login_types: LoginType[]; 89 | /** User's personal meeting URL (Example: "example.com") */ 90 | personal_meeting_url: string; 91 | /** This field has been deprecated and will not be supported in the future. 92 | * Use the phone_numbers field instead of this field. 93 | * The user's phone number (Example: "+1 800000000") */ 94 | // @deprecated true 95 | phone_number?: string; 96 | /** The URL for user's profile picture (Example: "example.com") */ 97 | pic_url: string; 98 | /** Personal Meeting ID (PMI) (Example: 3542471135) */ 99 | pmi: number; 100 | /** Unique identifier of the user's assigned role (Example: "0") */ 101 | role_id: string; 102 | /** User's role name (Example: "Admin") */ 103 | role_name: string; 104 | /** Status of user's account (Example: "pending") */ 105 | status: AccountStatus; 106 | /** Use the personal meeting ID (PMI) for instant meetings (Example: false) */ 107 | use_pmi: boolean; 108 | /** The time and date when the user was created (Example: "2018-10-31T04:32:37Z") */ 109 | user_created_at: string; 110 | /** Displays whether user is verified or not (Example: 1) */ 111 | verified: number; 112 | /** The user's Zoom Workplace plan option (Example: 64) */ 113 | zoom_one_type: number; 114 | /** The user's company (Example: "Jill") */ 115 | company?: string; 116 | /** Custom attributes that have been assigned to the user (Example: [{ "key": "cbf_cywdkexrtqc73f97gd4w6g", "name": "A1", "value": "1" }]) */ 117 | custom_attributes?: { key: string; name: string; value: string }[]; 118 | /** The employee's unique ID. This field only returns when SAML single sign-on (SSO) is enabled. 119 | * The `login_type` value is `101` (SSO) (Example: "HqDyI037Qjili1kNsSIrIg") */ 120 | employee_unique_id?: string; 121 | /** The manager for the user (Example: "[email protected]") */ 122 | manager?: string; 123 | /** The user's country for the company phone number (Example: "US") 124 | * @deprecated true */ 125 | phone_country?: string; 126 | /** The phone number's ISO country code (Example: "+1") */ 127 | phone_numbers?: PhoneNumber[]; 128 | /** The user's plan type (Example: "1") */ 129 | plan_united_type?: string; 130 | /** The user's pronouns (Example: "3123") */ 131 | pronouns?: string; 132 | /** The user's display pronouns setting (Example: 1) */ 133 | pronouns_option?: PronounOption; 134 | /** Personal meeting room URL, if the user has one (Example: "example.com") */ 135 | vanity_url?: string; 136 | } 137 | 138 | export interface ZoomOptions extends ProviderOptions<ZoomProfile> { 139 | clientId: string; 140 | pkce?: boolean; 141 | } 142 | 143 | export const zoom = (userOptions: ZoomOptions) => { 144 | const options = { 145 | pkce: true, 146 | ...userOptions, 147 | }; 148 | 149 | return { 150 | id: "zoom", 151 | name: "Zoom", 152 | createAuthorizationURL: async ({ state, redirectURI, codeVerifier }) => { 153 | const params = new URLSearchParams({ 154 | response_type: "code", 155 | redirect_uri: options.redirectURI ? options.redirectURI : redirectURI, 156 | client_id: options.clientId, 157 | state, 158 | }); 159 | 160 | if (options.pkce) { 161 | const codeChallenge = await generateCodeChallenge(codeVerifier); 162 | params.set("code_challenge_method", "S256"); 163 | params.set("code_challenge", codeChallenge); 164 | } 165 | 166 | const url = new URL("https://zoom.us/oauth/authorize"); 167 | url.search = params.toString(); 168 | 169 | return url; 170 | }, 171 | validateAuthorizationCode: async ({ code, redirectURI, codeVerifier }) => { 172 | return validateAuthorizationCode({ 173 | code, 174 | redirectURI: options.redirectURI || redirectURI, 175 | codeVerifier, 176 | options, 177 | tokenEndpoint: "https://zoom.us/oauth/token", 178 | authentication: "post", 179 | }); 180 | }, 181 | async getUserInfo(token) { 182 | if (options.getUserInfo) { 183 | return options.getUserInfo(token); 184 | } 185 | const { data: profile, error } = await betterFetch<ZoomProfile>( 186 | "https://api.zoom.us/v2/users/me", 187 | { 188 | headers: { 189 | authorization: `Bearer ${token.accessToken}`, 190 | }, 191 | }, 192 | ); 193 | 194 | if (error) { 195 | return null; 196 | } 197 | 198 | const userMap = await options.mapProfileToUser?.(profile); 199 | 200 | return { 201 | user: { 202 | id: profile.id, 203 | name: profile.display_name, 204 | image: profile.pic_url, 205 | email: profile.email, 206 | emailVerified: Boolean(profile.verified), 207 | ...userMap, 208 | }, 209 | data: { 210 | ...profile, 211 | }, 212 | }; 213 | }, 214 | } satisfies OAuthProvider<ZoomProfile>; 215 | }; 216 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/kysely-adapter/node-sqlite-dialect.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * @see {@link https://nodejs.org/api/sqlite.html} - Node.js SQLite API documentation 3 | */ 4 | import { 5 | Kysely, 6 | CompiledQuery, 7 | DEFAULT_MIGRATION_LOCK_TABLE, 8 | DEFAULT_MIGRATION_TABLE, 9 | sql, 10 | type DatabaseConnection, 11 | type QueryResult, 12 | type DatabaseIntrospector, 13 | type SchemaMetadata, 14 | type DatabaseMetadataOptions, 15 | type TableMetadata, 16 | type DatabaseMetadata, 17 | type Driver, 18 | type Dialect, 19 | type QueryCompiler, 20 | type DialectAdapter, 21 | } from "kysely"; 22 | import { DefaultQueryCompiler } from "kysely"; 23 | import { DialectAdapterBase } from "kysely"; 24 | import type { DatabaseSync } from "node:sqlite"; 25 | 26 | export class NodeSqliteAdapter implements DialectAdapterBase { 27 | get supportsCreateIfNotExists(): boolean { 28 | return true; 29 | } 30 | 31 | get supportsTransactionalDdl(): boolean { 32 | return false; 33 | } 34 | 35 | get supportsReturning(): boolean { 36 | return true; 37 | } 38 | 39 | async acquireMigrationLock(): Promise<void> { 40 | // SQLite only has one connection that's reserved by the migration system 41 | // for the whole time between acquireMigrationLock and releaseMigrationLock. 42 | // We don't need to do anything here. 43 | } 44 | 45 | async releaseMigrationLock(): Promise<void> { 46 | // SQLite only has one connection that's reserved by the migration system 47 | // for the whole time between acquireMigrationLock and releaseMigrationLock. 48 | // We don't need to do anything here. 49 | } 50 | get supportsOutput(): boolean { 51 | return true; 52 | } 53 | } 54 | 55 | /** 56 | * Config for the SQLite dialect. 57 | */ 58 | export interface NodeSqliteDialectConfig { 59 | /** 60 | * A sqlite DatabaseSync instance or a function that returns one. 61 | */ 62 | database: DatabaseSync; 63 | 64 | /** 65 | * Called once when the first query is executed. 66 | */ 67 | onCreateConnection?: (connection: DatabaseConnection) => Promise<void>; 68 | } 69 | 70 | export class NodeSqliteDriver implements Driver { 71 | readonly #config: NodeSqliteDialectConfig; 72 | readonly #connectionMutex = new ConnectionMutex(); 73 | 74 | #db?: DatabaseSync; 75 | #connection?: DatabaseConnection; 76 | 77 | constructor(config: NodeSqliteDialectConfig) { 78 | this.#config = { ...config }; 79 | } 80 | 81 | async init(): Promise<void> { 82 | this.#db = this.#config.database; 83 | 84 | this.#connection = new NodeSqliteConnection(this.#db); 85 | 86 | if (this.#config.onCreateConnection) { 87 | await this.#config.onCreateConnection(this.#connection); 88 | } 89 | } 90 | 91 | async acquireConnection(): Promise<DatabaseConnection> { 92 | // SQLite only has one single connection. We use a mutex here to wait 93 | // until the single connection has been released. 94 | await this.#connectionMutex.lock(); 95 | return this.#connection!; 96 | } 97 | 98 | async beginTransaction(connection: DatabaseConnection): Promise<void> { 99 | await connection.executeQuery(CompiledQuery.raw("begin")); 100 | } 101 | 102 | async commitTransaction(connection: DatabaseConnection): Promise<void> { 103 | await connection.executeQuery(CompiledQuery.raw("commit")); 104 | } 105 | 106 | async rollbackTransaction(connection: DatabaseConnection): Promise<void> { 107 | await connection.executeQuery(CompiledQuery.raw("rollback")); 108 | } 109 | 110 | async releaseConnection(): Promise<void> { 111 | this.#connectionMutex.unlock(); 112 | } 113 | 114 | async destroy(): Promise<void> { 115 | this.#db?.close(); 116 | } 117 | } 118 | 119 | class NodeSqliteConnection implements DatabaseConnection { 120 | readonly #db: DatabaseSync; 121 | 122 | constructor(db: DatabaseSync) { 123 | this.#db = db; 124 | } 125 | 126 | executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> { 127 | const { sql, parameters } = compiledQuery; 128 | const stmt = this.#db.prepare(sql); 129 | 130 | const rows = stmt.all(...(parameters as any[])) as O[]; 131 | 132 | return Promise.resolve({ 133 | rows, 134 | }); 135 | } 136 | 137 | async *streamQuery() { 138 | throw new Error("Streaming query is not supported by SQLite driver."); 139 | } 140 | } 141 | 142 | class ConnectionMutex { 143 | #promise?: Promise<void>; 144 | #resolve?: () => void; 145 | 146 | async lock(): Promise<void> { 147 | while (this.#promise) { 148 | await this.#promise; 149 | } 150 | 151 | this.#promise = new Promise((resolve) => { 152 | this.#resolve = resolve; 153 | }); 154 | } 155 | 156 | unlock(): void { 157 | const resolve = this.#resolve; 158 | 159 | this.#promise = undefined; 160 | this.#resolve = undefined; 161 | 162 | resolve?.(); 163 | } 164 | } 165 | 166 | export class NodeSqliteIntrospector implements DatabaseIntrospector { 167 | readonly #db: Kysely<unknown>; 168 | 169 | constructor(db: Kysely<unknown>) { 170 | this.#db = db; 171 | } 172 | 173 | async getSchemas(): Promise<SchemaMetadata[]> { 174 | // Sqlite doesn't support schemas. 175 | return []; 176 | } 177 | 178 | async getTables( 179 | options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, 180 | ): Promise<TableMetadata[]> { 181 | let query = this.#db 182 | // @ts-expect-error 183 | .selectFrom("sqlite_schema") 184 | // @ts-expect-error 185 | .where("type", "=", "table") 186 | // @ts-expect-error 187 | .where("name", "not like", "sqlite_%") 188 | .select("name") 189 | .$castTo<{ name: string }>(); 190 | 191 | if (!options.withInternalKyselyTables) { 192 | query = query 193 | // @ts-expect-error 194 | .where("name", "!=", DEFAULT_MIGRATION_TABLE) 195 | // @ts-expect-error 196 | .where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE); 197 | } 198 | 199 | const tables = await query.execute(); 200 | return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name))); 201 | } 202 | 203 | async getMetadata( 204 | options?: DatabaseMetadataOptions, 205 | ): Promise<DatabaseMetadata> { 206 | return { 207 | tables: await this.getTables(options), 208 | }; 209 | } 210 | 211 | async #getTableMetadata(table: string): Promise<TableMetadata> { 212 | const db = this.#db; 213 | 214 | // Get the SQL that was used to create the table. 215 | const createSql = await db 216 | // @ts-expect-error 217 | .selectFrom("sqlite_master") 218 | // @ts-expect-error 219 | .where("name", "=", table) 220 | .select("sql") 221 | .$castTo<{ sql: string | undefined }>() 222 | .execute(); 223 | 224 | // Try to find the name of the column that has `autoincrement` >& 225 | const autoIncrementCol = createSql[0]?.sql 226 | ?.split(/[\(\),]/) 227 | ?.find((it) => it.toLowerCase().includes("autoincrement")) 228 | ?.split(/\s+/)?.[0] 229 | ?.replace(/["`]/g, ""); 230 | 231 | const columns = await db 232 | .selectFrom( 233 | sql<{ 234 | name: string; 235 | type: string; 236 | notnull: 0 | 1; 237 | dflt_value: any; 238 | }>`pragma_table_info(${table})`.as("table_info"), 239 | ) 240 | .select(["name", "type", "notnull", "dflt_value"]) 241 | .execute(); 242 | 243 | return { 244 | name: table, 245 | columns: columns.map((col) => ({ 246 | name: col.name, 247 | dataType: col.type, 248 | isNullable: !col.notnull, 249 | isAutoIncrementing: col.name === autoIncrementCol, 250 | hasDefaultValue: col.dflt_value != null, 251 | })), 252 | isView: true, 253 | }; 254 | } 255 | } 256 | 257 | export class NodeSqliteQueryCompiler extends DefaultQueryCompiler { 258 | protected override getCurrentParameterPlaceholder() { 259 | return "?"; 260 | } 261 | 262 | protected override getLeftIdentifierWrapper(): string { 263 | return '"'; 264 | } 265 | 266 | protected override getRightIdentifierWrapper(): string { 267 | return '"'; 268 | } 269 | 270 | protected override getAutoIncrement() { 271 | return "autoincrement"; 272 | } 273 | } 274 | 275 | export class NodeSqliteDialect implements Dialect { 276 | readonly #config: NodeSqliteDialectConfig; 277 | 278 | constructor(config: NodeSqliteDialectConfig) { 279 | this.#config = { ...config }; 280 | } 281 | 282 | createDriver(): Driver { 283 | return new NodeSqliteDriver(this.#config); 284 | } 285 | 286 | createQueryCompiler(): QueryCompiler { 287 | return new NodeSqliteQueryCompiler(); 288 | } 289 | 290 | createAdapter(): DialectAdapter { 291 | return new NodeSqliteAdapter(); 292 | } 293 | 294 | createIntrospector(db: Kysely<any>): DatabaseIntrospector { 295 | return new NodeSqliteIntrospector(db); 296 | } 297 | } 298 | ``` -------------------------------------------------------------------------------- /docs/content/docs/authentication/apple.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Apple 3 | description: Apple provider setup and usage. 4 | --- 5 | <Steps> 6 | <Step> 7 | ### Get your OAuth credentials 8 | To use Apple sign in, you need a client ID and client secret. You can get them from the [Apple Developer Portal](https://developer.apple.com/account/resources/authkeys/list). 9 | 10 | You will need an active **Apple Developer account** to access the developer portal and generate these credentials. 11 | 12 | Follow these steps to set up your App ID, Service ID, and generate the key needed for your client secret: 13 | 14 | 1. **Navigate to Certificates, Identifiers & Profiles:** 15 | In the Apple Developer Portal, go to the "Certificates, Identifiers & Profiles" section. 16 | 17 | 2. **Create an App ID:** 18 | * Go to the `Identifiers` tab. 19 | * Click the `+` icon next to Identifiers. 20 | * Select `App IDs`, then click `Continue`. 21 | * Select `App` as the type, then click `Continue`. 22 | * **Description:** Enter a name for your app (e.g., "My Awesome App"). This name may be displayed to users when they sign in. 23 | * **Bundle ID:** Set a bundle ID. The recommended format is a reverse domain name (e.g., `com.yourcompany.yourapp`). Using a suffix like `.ai` (for app identifier) can help with organization but is not required (e.g., `com.yourcompany.yourapp.ai`). 24 | * Scroll down to **Capabilities**. Select the checkbox for `Sign In with Apple`. 25 | * Click `Continue`, then `Register`. 26 | 27 | 3. **Create a Service ID:** 28 | * Go back to the `Identifiers` tab. 29 | * Click the `+` icon. 30 | * Select `Service IDs`, then click `Continue`. 31 | * **Description:** Enter a description for this service (e.g., your app name again). 32 | * **Identifier:** Set a unique identifier for the service. Use a reverse domain format, distinct from your App ID (e.g., `com.yourcompany.yourapp.si`, where `.si` indicates service identifier - this is for your organization and not required). **This Service ID will be your `clientId`.** 33 | * Click `Continue`, then `Register`. 34 | 35 | 4. **Configure the Service ID:** 36 | * Find the Service ID you just created in the `Identifiers` list and click on it. 37 | * Check the `Sign In with Apple` capability, then click `Configure`. 38 | * Under **Primary App ID**, select the App ID you created earlier (e.g., `com.yourcompany.yourapp.ai`). 39 | * Under **Domains and Subdomains**, list all the root domains you will use for Sign In with Apple (e.g., `example.com`, `anotherdomain.com`). 40 | * Under **Return URLs**, enter the callback URL. `https://yourdomain.com/api/auth/callback/apple`. Add all necessary return URLs. 41 | * Click `Next`, then `Done`. 42 | * Click `Continue`, then `Save`. 43 | 44 | 5. **Create a Client Secret Key:** 45 | * Go to the `Keys` tab. 46 | * Click the `+` icon to create a new key. 47 | * **Key Name:** Enter a name for the key (e.g., "Sign In with Apple Key"). 48 | * Scroll down and select the checkbox for `Sign In with Apple`. 49 | * Click the `Configure` button next to `Sign In with Apple`. 50 | * Select the **Primary App ID** you created earlier. 51 | * Click `Save`, then `Continue`, then `Register`. 52 | * **Download the Key:** Immediately download the `.p8` key file. **This file is only available for download once.** Note the Key ID (available on the Keys page after creation) and your Team ID (available in your Apple Developer Account settings). 53 | 54 | 6. **Generate the Client Secret (JWT):** 55 | Apple requires a JSON Web Token (JWT) to be generated dynamically using the downloaded `.p8` key, the Key ID, and your Team ID. This JWT serves as your `clientSecret`. 56 | 57 | You can use the guide below from [Apple's documentation](https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret) to understand how to generate this client secret. You can also use our built in generator [below](#generate-apple-client-secret-jwt) to generate the client secret JWT required for 'Sign in with Apple'. 58 | 59 | **Note:** Apple allows a maximum expiration of 6 months (180 days) for the client secret JWT. You will need to regenerate the client secret before it expires to maintain uninterrupted authentication. 60 | 61 | 62 | </Step> 63 | <Step> 64 | ### Configure the provider 65 | To configure the provider, you need to add it to the `socialProviders` option of the auth instance. 66 | 67 | You also need to add `https://appleid.apple.com` to the `trustedOrigins` array in your auth instance configuration to allow communication with Apple's authentication servers. 68 | 69 | ```ts title="auth.ts" 70 | import { betterAuth } from "better-auth" 71 | 72 | export const auth = betterAuth({ 73 | socialProviders: { 74 | apple: { // [!code highlight] 75 | clientId: process.env.APPLE_CLIENT_ID as string, // [!code highlight] 76 | clientSecret: process.env.APPLE_CLIENT_SECRET as string, // [!code highlight] 77 | // Optional 78 | appBundleIdentifier: process.env.APPLE_APP_BUNDLE_IDENTIFIER as string, // [!code highlight] 79 | }, // [!code highlight] 80 | }, 81 | // Add appleid.apple.com to trustedOrigins for Sign In with Apple flows 82 | trustedOrigins: ["https://appleid.apple.com"], // [!code highlight] 83 | }) 84 | ``` 85 | 86 | On native iOS, it doesn't use the service ID but the app ID (bundle ID) as client ID, so if using the service ID as `clientId` in `signIn.social` with `idToken`, it throws an error: `JWTClaimValidationFailed: unexpected "aud" claim value`. So you need to provide the `appBundleIdentifier` when you want to sign in with Apple using the ID Token. 87 | </Step> 88 | </Steps> 89 | 90 | 91 | ## Usage 92 | 93 | ### Sign In with Apple 94 | 95 | To sign in with Apple, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties: 96 | - `provider`: The provider to use. It should be set to `apple`. 97 | 98 | ```ts title="auth-client.ts" / 99 | import { createAuthClient } from "better-auth/client" 100 | const authClient = createAuthClient() 101 | 102 | const signIn = async () => { 103 | const data = await authClient.signIn.social({ 104 | provider: "apple" 105 | }) 106 | } 107 | ``` 108 | 109 | 110 | ### Sign In with Apple With ID Token 111 | 112 | To sign in with Apple using the ID Token, you can use the `signIn.social` function to pass the ID Token. 113 | 114 | This is useful when you have the ID Token from Apple on the client-side and want to use it to sign in on the server. 115 | 116 | <Callout> 117 | If ID token is provided no redirection will happen, and the user will be signed in directly. 118 | </Callout> 119 | 120 | ```ts title="auth-client.ts" 121 | await authClient.signIn.social({ 122 | provider: "apple", 123 | idToken: { 124 | token: // Apple ID Token, 125 | nonce: // Nonce (optional) 126 | accessToken: // Access Token (optional) 127 | } 128 | }) 129 | ``` 130 | 131 | ## Generate Apple Client Secret (JWT) 132 | 133 | <GenerateAppleJwt /> 134 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/db/with-hooks.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { DBPreservedModels } from "@better-auth/core/db"; 2 | import type { BetterAuthOptions } from "@better-auth/core"; 3 | import type { DBAdapter, Where } from "@better-auth/core/db/adapter"; 4 | import { getCurrentAdapter } from "@better-auth/core/context"; 5 | import { getCurrentAuthContext } from "@better-auth/core/context"; 6 | 7 | export function getWithHooks( 8 | adapter: DBAdapter<BetterAuthOptions>, 9 | ctx: { 10 | options: BetterAuthOptions; 11 | hooks: Exclude<BetterAuthOptions["databaseHooks"], undefined>[]; 12 | }, 13 | ) { 14 | const hooks = ctx.hooks; 15 | type BaseModels = Extract< 16 | DBPreservedModels, 17 | "user" | "account" | "session" | "verification" 18 | >; 19 | async function createWithHooks<T extends Record<string, any>>( 20 | data: T, 21 | model: BaseModels, 22 | customCreateFn?: { 23 | fn: (data: Record<string, any>) => void | Promise<any>; 24 | executeMainFn?: boolean; 25 | }, 26 | ) { 27 | const context = await getCurrentAuthContext(); 28 | let actualData = data; 29 | for (const hook of hooks || []) { 30 | const toRun = hook[model]?.create?.before; 31 | if (toRun) { 32 | // @ts-expect-error context type mismatch 33 | const result = await toRun(actualData as any, context); 34 | if (result === false) { 35 | return null; 36 | } 37 | const isObject = typeof result === "object" && "data" in result; 38 | if (isObject) { 39 | actualData = { 40 | ...actualData, 41 | ...result.data, 42 | }; 43 | } 44 | } 45 | } 46 | 47 | const customCreated = customCreateFn 48 | ? await customCreateFn.fn(actualData) 49 | : null; 50 | const created = 51 | !customCreateFn || customCreateFn.executeMainFn 52 | ? await (await getCurrentAdapter(adapter)).create<T>({ 53 | model, 54 | data: actualData as any, 55 | forceAllowId: true, 56 | }) 57 | : customCreated; 58 | 59 | for (const hook of hooks || []) { 60 | const toRun = hook[model]?.create?.after; 61 | if (toRun) { 62 | // @ts-expect-error context type mismatch 63 | await toRun(created as any, context); 64 | } 65 | } 66 | 67 | return created; 68 | } 69 | 70 | async function updateWithHooks<T extends Record<string, any>>( 71 | data: any, 72 | where: Where[], 73 | model: BaseModels, 74 | customUpdateFn?: { 75 | fn: (data: Record<string, any>) => void | Promise<any>; 76 | executeMainFn?: boolean; 77 | }, 78 | ) { 79 | const context = await getCurrentAuthContext(); 80 | let actualData = data; 81 | 82 | for (const hook of hooks || []) { 83 | const toRun = hook[model]?.update?.before; 84 | if (toRun) { 85 | // @ts-expect-error context type mismatch 86 | const result = await toRun(data as any, context); 87 | if (result === false) { 88 | return null; 89 | } 90 | const isObject = typeof result === "object"; 91 | actualData = isObject ? (result as any).data : result; 92 | } 93 | } 94 | 95 | const customUpdated = customUpdateFn 96 | ? await customUpdateFn.fn(actualData) 97 | : null; 98 | 99 | const updated = 100 | !customUpdateFn || customUpdateFn.executeMainFn 101 | ? await (await getCurrentAdapter(adapter)).update<T>({ 102 | model, 103 | update: actualData, 104 | where, 105 | }) 106 | : customUpdated; 107 | 108 | for (const hook of hooks || []) { 109 | const toRun = hook[model]?.update?.after; 110 | if (toRun) { 111 | // @ts-expect-error context type mismatch 112 | await toRun(updated as any, context); 113 | } 114 | } 115 | return updated; 116 | } 117 | 118 | async function updateManyWithHooks<T extends Record<string, any>>( 119 | data: any, 120 | where: Where[], 121 | model: BaseModels, 122 | customUpdateFn?: { 123 | fn: (data: Record<string, any>) => void | Promise<any>; 124 | executeMainFn?: boolean; 125 | }, 126 | ) { 127 | const context = await getCurrentAuthContext(); 128 | let actualData = data; 129 | 130 | for (const hook of hooks || []) { 131 | const toRun = hook[model]?.update?.before; 132 | if (toRun) { 133 | // @ts-expect-error context type mismatch 134 | const result = await toRun(data as any, context); 135 | if (result === false) { 136 | return null; 137 | } 138 | const isObject = typeof result === "object"; 139 | actualData = isObject ? (result as any).data : result; 140 | } 141 | } 142 | 143 | const customUpdated = customUpdateFn 144 | ? await customUpdateFn.fn(actualData) 145 | : null; 146 | 147 | const updated = 148 | !customUpdateFn || customUpdateFn.executeMainFn 149 | ? await (await getCurrentAdapter(adapter)).updateMany({ 150 | model, 151 | update: actualData, 152 | where, 153 | }) 154 | : customUpdated; 155 | 156 | for (const hook of hooks || []) { 157 | const toRun = hook[model]?.update?.after; 158 | if (toRun) { 159 | // @ts-expect-error context type mismatch 160 | await toRun(updated as any, context); 161 | } 162 | } 163 | 164 | return updated; 165 | } 166 | 167 | async function deleteWithHooks<T extends Record<string, any>>( 168 | where: Where[], 169 | model: BaseModels, 170 | customDeleteFn?: { 171 | fn: (where: Where[]) => void | Promise<any>; 172 | executeMainFn?: boolean; 173 | }, 174 | ) { 175 | const context = await getCurrentAuthContext(); 176 | let entityToDelete: T | null = null; 177 | 178 | try { 179 | const entities = await (await getCurrentAdapter(adapter)).findMany<T>({ 180 | model, 181 | where, 182 | limit: 1, 183 | }); 184 | entityToDelete = entities[0] || null; 185 | } catch (error) { 186 | // If we can't find the entity, we'll still proceed with deletion 187 | } 188 | 189 | if (entityToDelete) { 190 | for (const hook of hooks || []) { 191 | const toRun = hook[model]?.delete?.before; 192 | if (toRun) { 193 | // @ts-expect-error context type mismatch 194 | const result = await toRun(entityToDelete as any, context); 195 | if (result === false) { 196 | return null; 197 | } 198 | } 199 | } 200 | } 201 | 202 | const customDeleted = customDeleteFn 203 | ? await customDeleteFn.fn(where) 204 | : null; 205 | 206 | const deleted = 207 | !customDeleteFn || customDeleteFn.executeMainFn 208 | ? await (await getCurrentAdapter(adapter)).delete({ 209 | model, 210 | where, 211 | }) 212 | : customDeleted; 213 | 214 | if (entityToDelete) { 215 | for (const hook of hooks || []) { 216 | const toRun = hook[model]?.delete?.after; 217 | if (toRun) { 218 | // @ts-expect-error context type mismatch 219 | await toRun(entityToDelete as any, context); 220 | } 221 | } 222 | } 223 | 224 | return deleted; 225 | } 226 | 227 | async function deleteManyWithHooks<T extends Record<string, any>>( 228 | where: Where[], 229 | model: BaseModels, 230 | customDeleteFn?: { 231 | fn: (where: Where[]) => void | Promise<any>; 232 | executeMainFn?: boolean; 233 | }, 234 | ) { 235 | const context = await getCurrentAuthContext(); 236 | let entitiesToDelete: T[] = []; 237 | 238 | try { 239 | entitiesToDelete = await (await getCurrentAdapter(adapter)).findMany<T>({ 240 | model, 241 | where, 242 | }); 243 | } catch (error) { 244 | // If we can't find the entities, we'll still proceed with deletion 245 | } 246 | 247 | for (const entity of entitiesToDelete) { 248 | for (const hook of hooks || []) { 249 | const toRun = hook[model]?.delete?.before; 250 | if (toRun) { 251 | // @ts-expect-error context type mismatch 252 | const result = await toRun(entity as any, context); 253 | if (result === false) { 254 | return null; 255 | } 256 | } 257 | } 258 | } 259 | 260 | const customDeleted = customDeleteFn 261 | ? await customDeleteFn.fn(where) 262 | : null; 263 | 264 | const deleted = 265 | !customDeleteFn || customDeleteFn.executeMainFn 266 | ? await (await getCurrentAdapter(adapter)).deleteMany({ 267 | model, 268 | where, 269 | }) 270 | : customDeleted; 271 | 272 | for (const entity of entitiesToDelete) { 273 | for (const hook of hooks || []) { 274 | const toRun = hook[model]?.delete?.after; 275 | if (toRun) { 276 | // @ts-expect-error context type mismatch 277 | await toRun(entity as any, context); 278 | } 279 | } 280 | } 281 | 282 | return deleted; 283 | } 284 | 285 | return { 286 | createWithHooks, 287 | updateWithHooks, 288 | updateManyWithHooks, 289 | deleteWithHooks, 290 | deleteManyWithHooks, 291 | }; 292 | } 293 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { createAuthEndpoint } from "@better-auth/core/api"; 3 | import { APIError } from "../../../api"; 4 | import { API_KEY_TABLE_NAME, ERROR_CODES } from ".."; 5 | import type { apiKeySchema } from "../schema"; 6 | import type { ApiKey } from "../types"; 7 | import { isRateLimited } from "../rate-limit"; 8 | import type { PredefinedApiKeyOptions } from "."; 9 | import { safeJSONParse } from "../../../utils/json"; 10 | import { role } from "../../access"; 11 | import { defaultKeyHasher } from "../"; 12 | import type { AuthContext, GenericEndpointContext } from "@better-auth/core"; 13 | 14 | export async function validateApiKey({ 15 | hashedKey, 16 | ctx, 17 | opts, 18 | schema, 19 | permissions, 20 | }: { 21 | hashedKey: string; 22 | opts: PredefinedApiKeyOptions; 23 | schema: ReturnType<typeof apiKeySchema>; 24 | permissions?: Record<string, string[]>; 25 | ctx: GenericEndpointContext; 26 | }) { 27 | const apiKey = await ctx.context.adapter.findOne<ApiKey>({ 28 | model: API_KEY_TABLE_NAME, 29 | where: [ 30 | { 31 | field: "key", 32 | value: hashedKey, 33 | }, 34 | ], 35 | }); 36 | 37 | if (!apiKey) { 38 | throw new APIError("UNAUTHORIZED", { 39 | message: ERROR_CODES.INVALID_API_KEY, 40 | }); 41 | } 42 | 43 | if (apiKey.enabled === false) { 44 | throw new APIError("UNAUTHORIZED", { 45 | message: ERROR_CODES.KEY_DISABLED, 46 | code: "KEY_DISABLED" as const, 47 | }); 48 | } 49 | 50 | if (apiKey.expiresAt) { 51 | const now = new Date().getTime(); 52 | const expiresAt = new Date(apiKey.expiresAt).getTime(); 53 | if (now > expiresAt) { 54 | try { 55 | ctx.context.adapter.delete({ 56 | model: API_KEY_TABLE_NAME, 57 | where: [ 58 | { 59 | field: "id", 60 | value: apiKey.id, 61 | }, 62 | ], 63 | }); 64 | } catch (error) { 65 | ctx.context.logger.error(`Failed to delete expired API keys:`, error); 66 | } 67 | 68 | throw new APIError("UNAUTHORIZED", { 69 | message: ERROR_CODES.KEY_EXPIRED, 70 | code: "KEY_EXPIRED" as const, 71 | }); 72 | } 73 | } 74 | 75 | if (permissions) { 76 | const apiKeyPermissions = apiKey.permissions 77 | ? safeJSONParse<{ 78 | [key: string]: string[]; 79 | }>(apiKey.permissions) 80 | : null; 81 | 82 | if (!apiKeyPermissions) { 83 | throw new APIError("UNAUTHORIZED", { 84 | message: ERROR_CODES.KEY_NOT_FOUND, 85 | code: "KEY_NOT_FOUND" as const, 86 | }); 87 | } 88 | const r = role(apiKeyPermissions as any); 89 | const result = r.authorize(permissions); 90 | if (!result.success) { 91 | throw new APIError("UNAUTHORIZED", { 92 | message: ERROR_CODES.KEY_NOT_FOUND, 93 | code: "KEY_NOT_FOUND" as const, 94 | }); 95 | } 96 | } 97 | 98 | let remaining = apiKey.remaining; 99 | let lastRefillAt = apiKey.lastRefillAt; 100 | 101 | if (apiKey.remaining === 0 && apiKey.refillAmount === null) { 102 | // if there is no more remaining requests, and there is no refill amount, than the key is revoked 103 | try { 104 | ctx.context.adapter.delete({ 105 | model: API_KEY_TABLE_NAME, 106 | where: [ 107 | { 108 | field: "id", 109 | value: apiKey.id, 110 | }, 111 | ], 112 | }); 113 | } catch (error) { 114 | ctx.context.logger.error(`Failed to delete expired API keys:`, error); 115 | } 116 | 117 | throw new APIError("TOO_MANY_REQUESTS", { 118 | message: ERROR_CODES.USAGE_EXCEEDED, 119 | code: "USAGE_EXCEEDED" as const, 120 | }); 121 | } else if (remaining !== null) { 122 | let now = new Date().getTime(); 123 | const refillInterval = apiKey.refillInterval; 124 | const refillAmount = apiKey.refillAmount; 125 | let lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime(); 126 | 127 | if (refillInterval && refillAmount) { 128 | // if they provide refill info, then we should refill once the interval is reached. 129 | 130 | const timeSinceLastRequest = now - lastTime; 131 | if (timeSinceLastRequest > refillInterval) { 132 | remaining = refillAmount; 133 | lastRefillAt = new Date(); 134 | } 135 | } 136 | 137 | if (remaining === 0) { 138 | // if there are no more remaining requests, than the key is invalid 139 | throw new APIError("TOO_MANY_REQUESTS", { 140 | message: ERROR_CODES.USAGE_EXCEEDED, 141 | code: "USAGE_EXCEEDED" as const, 142 | }); 143 | } else { 144 | remaining--; 145 | } 146 | } 147 | 148 | const { message, success, update, tryAgainIn } = isRateLimited(apiKey, opts); 149 | 150 | const newApiKey = await ctx.context.adapter.update<ApiKey>({ 151 | model: API_KEY_TABLE_NAME, 152 | where: [ 153 | { 154 | field: "id", 155 | value: apiKey.id, 156 | }, 157 | ], 158 | update: { 159 | ...update, 160 | remaining, 161 | lastRefillAt, 162 | }, 163 | }); 164 | 165 | if (!newApiKey) { 166 | throw new APIError("INTERNAL_SERVER_ERROR", { 167 | message: ERROR_CODES.FAILED_TO_UPDATE_API_KEY, 168 | code: "INTERNAL_SERVER_ERROR" as const, 169 | }); 170 | } 171 | 172 | if (success === false) { 173 | throw new APIError("UNAUTHORIZED", { 174 | message: message ?? undefined, 175 | code: "RATE_LIMITED" as const, 176 | details: { 177 | tryAgainIn, 178 | }, 179 | }); 180 | } 181 | 182 | return newApiKey; 183 | } 184 | 185 | export function verifyApiKey({ 186 | opts, 187 | schema, 188 | deleteAllExpiredApiKeys, 189 | }: { 190 | opts: PredefinedApiKeyOptions; 191 | schema: ReturnType<typeof apiKeySchema>; 192 | deleteAllExpiredApiKeys( 193 | ctx: AuthContext, 194 | byPassLastCheckTime?: boolean, 195 | ): void; 196 | }) { 197 | return createAuthEndpoint( 198 | "/api-key/verify", 199 | { 200 | method: "POST", 201 | body: z.object({ 202 | key: z.string().meta({ 203 | description: "The key to verify", 204 | }), 205 | permissions: z 206 | .record(z.string(), z.array(z.string())) 207 | .meta({ 208 | description: "The permissions to verify.", 209 | }) 210 | .optional(), 211 | }), 212 | metadata: { 213 | SERVER_ONLY: true, 214 | }, 215 | }, 216 | async (ctx) => { 217 | const { key } = ctx.body; 218 | 219 | if (key.length < opts.defaultKeyLength) { 220 | // if the key is shorter than the default key length, than we know the key is invalid. 221 | // we can't check if the key is exactly equal to the default key length, because 222 | // a prefix may be added to the key. 223 | return ctx.json({ 224 | valid: false, 225 | error: { 226 | message: ERROR_CODES.INVALID_API_KEY, 227 | code: "KEY_NOT_FOUND" as const, 228 | }, 229 | key: null, 230 | }); 231 | } 232 | 233 | if (opts.customAPIKeyValidator) { 234 | const isValid = await opts.customAPIKeyValidator({ ctx, key }); 235 | if (!isValid) { 236 | return ctx.json({ 237 | valid: false, 238 | error: { 239 | message: ERROR_CODES.INVALID_API_KEY, 240 | code: "KEY_NOT_FOUND" as const, 241 | }, 242 | key: null, 243 | }); 244 | } 245 | } 246 | 247 | const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key); 248 | 249 | let apiKey: ApiKey | null = null; 250 | 251 | try { 252 | apiKey = await validateApiKey({ 253 | hashedKey: hashed, 254 | permissions: ctx.body.permissions, 255 | ctx, 256 | opts, 257 | schema, 258 | }); 259 | await deleteAllExpiredApiKeys(ctx.context); 260 | } catch (error) { 261 | if (error instanceof APIError) { 262 | return ctx.json({ 263 | valid: false, 264 | error: { 265 | message: error.body?.message, 266 | code: error.body?.code as string, 267 | }, 268 | key: null, 269 | }); 270 | } 271 | 272 | return ctx.json({ 273 | valid: false, 274 | error: { 275 | message: ERROR_CODES.INVALID_API_KEY, 276 | code: "INVALID_API_KEY" as const, 277 | }, 278 | key: null, 279 | }); 280 | } 281 | 282 | const { key: _, ...returningApiKey } = apiKey ?? { 283 | key: 1, 284 | permissions: undefined, 285 | }; 286 | if ("metadata" in returningApiKey) { 287 | returningApiKey.metadata = 288 | schema.apikey.fields.metadata.transform.output( 289 | returningApiKey.metadata as never as string, 290 | ); 291 | } 292 | 293 | returningApiKey.permissions = returningApiKey.permissions 294 | ? safeJSONParse<{ 295 | [key: string]: string[]; 296 | }>(returningApiKey.permissions) 297 | : null; 298 | 299 | return ctx.json({ 300 | valid: true, 301 | error: null, 302 | key: apiKey === null ? null : (returningApiKey as Omit<ApiKey, "key">), 303 | }); 304 | }, 305 | ); 306 | } 307 | ``` -------------------------------------------------------------------------------- /packages/stripe/src/hooks.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { type GenericEndpointContext, logger } from "better-auth"; 2 | import type Stripe from "stripe"; 3 | import type { InputSubscription, StripeOptions, Subscription } from "./types"; 4 | import { getPlanByPriceInfo } from "./utils"; 5 | 6 | export async function onCheckoutSessionCompleted( 7 | ctx: GenericEndpointContext, 8 | options: StripeOptions, 9 | event: Stripe.Event, 10 | ) { 11 | try { 12 | const client = options.stripeClient; 13 | const checkoutSession = event.data.object as Stripe.Checkout.Session; 14 | if (checkoutSession.mode === "setup" || !options.subscription?.enabled) { 15 | return; 16 | } 17 | const subscription = await client.subscriptions.retrieve( 18 | checkoutSession.subscription as string, 19 | ); 20 | const priceId = subscription.items.data[0]?.price.id; 21 | const priceLookupKey = subscription.items.data[0]?.price.lookup_key || null; 22 | const plan = await getPlanByPriceInfo( 23 | options, 24 | priceId as string, 25 | priceLookupKey, 26 | ); 27 | if (plan) { 28 | const referenceId = 29 | checkoutSession?.client_reference_id || 30 | checkoutSession?.metadata?.referenceId; 31 | const subscriptionId = checkoutSession?.metadata?.subscriptionId; 32 | const seats = subscription.items.data[0]!.quantity; 33 | if (referenceId && subscriptionId) { 34 | const trial = 35 | subscription.trial_start && subscription.trial_end 36 | ? { 37 | trialStart: new Date(subscription.trial_start * 1000), 38 | trialEnd: new Date(subscription.trial_end * 1000), 39 | } 40 | : {}; 41 | 42 | let dbSubscription = 43 | await ctx.context.adapter.update<InputSubscription>({ 44 | model: "subscription", 45 | update: { 46 | plan: plan.name.toLowerCase(), 47 | status: subscription.status, 48 | updatedAt: new Date(), 49 | periodStart: new Date( 50 | subscription.items.data[0]!.current_period_start * 1000, 51 | ), 52 | periodEnd: new Date( 53 | subscription.items.data[0]!.current_period_end * 1000, 54 | ), 55 | stripeSubscriptionId: checkoutSession.subscription as string, 56 | seats, 57 | ...trial, 58 | }, 59 | where: [ 60 | { 61 | field: "id", 62 | value: subscriptionId, 63 | }, 64 | ], 65 | }); 66 | 67 | if (trial.trialStart && plan.freeTrial?.onTrialStart) { 68 | await plan.freeTrial.onTrialStart(dbSubscription as Subscription); 69 | } 70 | 71 | if (!dbSubscription) { 72 | dbSubscription = await ctx.context.adapter.findOne<Subscription>({ 73 | model: "subscription", 74 | where: [ 75 | { 76 | field: "id", 77 | value: subscriptionId, 78 | }, 79 | ], 80 | }); 81 | } 82 | await options.subscription?.onSubscriptionComplete?.( 83 | { 84 | event, 85 | subscription: dbSubscription as Subscription, 86 | stripeSubscription: subscription, 87 | plan, 88 | }, 89 | ctx, 90 | ); 91 | return; 92 | } 93 | } 94 | } catch (e: any) { 95 | logger.error(`Stripe webhook failed. Error: ${e.message}`); 96 | } 97 | } 98 | 99 | export async function onSubscriptionUpdated( 100 | ctx: GenericEndpointContext, 101 | options: StripeOptions, 102 | event: Stripe.Event, 103 | ) { 104 | try { 105 | if (!options.subscription?.enabled) { 106 | return; 107 | } 108 | const subscriptionUpdated = event.data.object as Stripe.Subscription; 109 | const priceId = subscriptionUpdated.items.data[0]!.price.id; 110 | const priceLookupKey = 111 | subscriptionUpdated.items.data[0]!.price.lookup_key || null; 112 | const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey); 113 | 114 | const subscriptionId = subscriptionUpdated.metadata?.subscriptionId; 115 | const customerId = subscriptionUpdated.customer?.toString(); 116 | let subscription = await ctx.context.adapter.findOne<Subscription>({ 117 | model: "subscription", 118 | where: subscriptionId 119 | ? [{ field: "id", value: subscriptionId }] 120 | : [{ field: "stripeSubscriptionId", value: subscriptionUpdated.id }], 121 | }); 122 | if (!subscription) { 123 | const subs = await ctx.context.adapter.findMany<Subscription>({ 124 | model: "subscription", 125 | where: [{ field: "stripeCustomerId", value: customerId }], 126 | }); 127 | if (subs.length > 1) { 128 | const activeSub = subs.find( 129 | (sub: Subscription) => 130 | sub.status === "active" || sub.status === "trialing", 131 | ); 132 | if (!activeSub) { 133 | logger.warn( 134 | `Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`, 135 | ); 136 | return; 137 | } 138 | subscription = activeSub; 139 | } else { 140 | subscription = subs[0]!; 141 | } 142 | } 143 | 144 | const seats = subscriptionUpdated.items.data[0]!.quantity; 145 | await ctx.context.adapter.update({ 146 | model: "subscription", 147 | update: { 148 | ...(plan 149 | ? { 150 | plan: plan.name.toLowerCase(), 151 | limits: plan.limits, 152 | } 153 | : {}), 154 | updatedAt: new Date(), 155 | status: subscriptionUpdated.status, 156 | periodStart: new Date( 157 | subscriptionUpdated.items.data[0]!.current_period_start * 1000, 158 | ), 159 | periodEnd: new Date( 160 | subscriptionUpdated.items.data[0]!.current_period_end * 1000, 161 | ), 162 | cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end, 163 | seats, 164 | stripeSubscriptionId: subscriptionUpdated.id, 165 | }, 166 | where: [ 167 | { 168 | field: "id", 169 | value: subscription.id, 170 | }, 171 | ], 172 | }); 173 | const subscriptionCanceled = 174 | subscriptionUpdated.status === "active" && 175 | subscriptionUpdated.cancel_at_period_end && 176 | !subscription.cancelAtPeriodEnd; //if this is true, it means the subscription was canceled before the event was triggered 177 | if (subscriptionCanceled) { 178 | await options.subscription.onSubscriptionCancel?.({ 179 | subscription, 180 | cancellationDetails: 181 | subscriptionUpdated.cancellation_details || undefined, 182 | stripeSubscription: subscriptionUpdated, 183 | event, 184 | }); 185 | } 186 | await options.subscription.onSubscriptionUpdate?.({ 187 | event, 188 | subscription, 189 | }); 190 | if (plan) { 191 | if ( 192 | subscriptionUpdated.status === "active" && 193 | subscription.status === "trialing" && 194 | plan.freeTrial?.onTrialEnd 195 | ) { 196 | await plan.freeTrial.onTrialEnd({ subscription }, ctx); 197 | } 198 | if ( 199 | subscriptionUpdated.status === "incomplete_expired" && 200 | subscription.status === "trialing" && 201 | plan.freeTrial?.onTrialExpired 202 | ) { 203 | await plan.freeTrial.onTrialExpired(subscription, ctx); 204 | } 205 | } 206 | } catch (error: any) { 207 | logger.error(`Stripe webhook failed. Error: ${error}`); 208 | } 209 | } 210 | 211 | export async function onSubscriptionDeleted( 212 | ctx: GenericEndpointContext, 213 | options: StripeOptions, 214 | event: Stripe.Event, 215 | ) { 216 | if (!options.subscription?.enabled) { 217 | return; 218 | } 219 | try { 220 | const subscriptionDeleted = event.data.object as Stripe.Subscription; 221 | const subscriptionId = subscriptionDeleted.id; 222 | const subscription = await ctx.context.adapter.findOne<Subscription>({ 223 | model: "subscription", 224 | where: [ 225 | { 226 | field: "stripeSubscriptionId", 227 | value: subscriptionId, 228 | }, 229 | ], 230 | }); 231 | if (subscription) { 232 | await ctx.context.adapter.update({ 233 | model: "subscription", 234 | where: [ 235 | { 236 | field: "id", 237 | value: subscription.id, 238 | }, 239 | ], 240 | update: { 241 | status: "canceled", 242 | updatedAt: new Date(), 243 | }, 244 | }); 245 | await options.subscription.onSubscriptionDeleted?.({ 246 | event, 247 | stripeSubscription: subscriptionDeleted, 248 | subscription, 249 | }); 250 | } else { 251 | logger.warn( 252 | `Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`, 253 | ); 254 | } 255 | } catch (error: any) { 256 | logger.error(`Stripe webhook failed. Error: ${error}`); 257 | } 258 | } 259 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { atom } from "nanostores"; 2 | import type { 3 | InferInvitation, 4 | InferMember, 5 | Invitation, 6 | Member, 7 | Organization, 8 | Team, 9 | } from "../../plugins/organization/schema"; 10 | import type { Prettify } from "../../types/helper"; 11 | import { type AccessControl, type Role } from "../access"; 12 | import type { BetterAuthClientPlugin } from "@better-auth/core"; 13 | import { type OrganizationPlugin } from "./organization"; 14 | import { useAuthQuery } from "../../client"; 15 | import { 16 | defaultStatements, 17 | adminAc, 18 | memberAc, 19 | ownerAc, 20 | defaultRoles, 21 | } from "./access"; 22 | import type { DBFieldAttribute } from "@better-auth/core/db"; 23 | import type { BetterAuthOptions, BetterAuthPlugin } from "../../types"; 24 | import type { OrganizationOptions } from "./types"; 25 | import type { HasPermissionBaseInput } from "./permission"; 26 | import { hasPermissionFn } from "./permission"; 27 | 28 | /** 29 | * Using the same `hasPermissionFn` function, but without the need for a `ctx` parameter or the `organizationId` parameter. 30 | */ 31 | export const clientSideHasPermission = (input: HasPermissionBaseInput) => { 32 | const acRoles: { 33 | [x: string]: Role<any> | undefined; 34 | } = input.options.roles || defaultRoles; 35 | 36 | return hasPermissionFn(input, acRoles); 37 | }; 38 | 39 | interface OrganizationClientOptions { 40 | ac?: AccessControl; 41 | roles?: { 42 | [key in string]: Role; 43 | }; 44 | teams?: { 45 | enabled: boolean; 46 | }; 47 | schema?: { 48 | organization?: { 49 | additionalFields?: { 50 | [key: string]: DBFieldAttribute; 51 | }; 52 | }; 53 | member?: { 54 | additionalFields?: { 55 | [key: string]: DBFieldAttribute; 56 | }; 57 | }; 58 | invitation?: { 59 | additionalFields?: { 60 | [key: string]: DBFieldAttribute; 61 | }; 62 | }; 63 | team?: { 64 | additionalFields?: { 65 | [key: string]: DBFieldAttribute; 66 | }; 67 | }; 68 | organizationRole?: { 69 | additionalFields?: { 70 | [key: string]: DBFieldAttribute; 71 | }; 72 | }; 73 | }; 74 | dynamicAccessControl?: { 75 | enabled: boolean; 76 | }; 77 | } 78 | 79 | export const organizationClient = <CO extends OrganizationClientOptions>( 80 | options?: CO, 81 | ) => { 82 | const $listOrg = atom<boolean>(false); 83 | const $activeOrgSignal = atom<boolean>(false); 84 | const $activeMemberSignal = atom<boolean>(false); 85 | const $activeMemberRoleSignal = atom<boolean>(false); 86 | 87 | type DefaultStatements = typeof defaultStatements; 88 | type Statements = CO["ac"] extends AccessControl<infer S> 89 | ? S 90 | : DefaultStatements; 91 | type PermissionType = { 92 | [key in keyof Statements]?: Array< 93 | Statements[key] extends readonly unknown[] 94 | ? Statements[key][number] 95 | : never 96 | >; 97 | }; 98 | type PermissionExclusive = 99 | | { 100 | /** 101 | * @deprecated Use `permissions` instead 102 | */ 103 | permission: PermissionType; 104 | permissions?: never; 105 | } 106 | | { 107 | permissions: PermissionType; 108 | permission?: never; 109 | }; 110 | 111 | const roles = { 112 | admin: adminAc, 113 | member: memberAc, 114 | owner: ownerAc, 115 | ...options?.roles, 116 | }; 117 | 118 | type OrganizationReturn = CO["teams"] extends { enabled: true } 119 | ? { 120 | members: InferMember<CO>[]; 121 | invitations: InferInvitation<CO>[]; 122 | teams: Team[]; 123 | } & Organization 124 | : { 125 | members: InferMember<CO>[]; 126 | invitations: InferInvitation<CO>[]; 127 | } & Organization; 128 | 129 | type Schema = CO["schema"]; 130 | return { 131 | id: "organization", 132 | $InferServerPlugin: {} as OrganizationPlugin<{ 133 | ac: CO["ac"] extends AccessControl 134 | ? CO["ac"] 135 | : AccessControl<DefaultStatements>; 136 | roles: CO["roles"] extends Record<string, Role> 137 | ? CO["roles"] 138 | : { 139 | admin: Role; 140 | member: Role; 141 | owner: Role; 142 | }; 143 | teams: { 144 | enabled: CO["teams"] extends { enabled: true } ? true : false; 145 | }; 146 | schema: Schema; 147 | dynamicAccessControl: { 148 | enabled: CO["dynamicAccessControl"] extends { enabled: true } 149 | ? true 150 | : false; 151 | }; 152 | }>, 153 | getActions: ($fetch, _$store, co) => ({ 154 | $Infer: { 155 | ActiveOrganization: {} as OrganizationReturn, 156 | Organization: {} as Organization, 157 | Invitation: {} as InferInvitation<CO>, 158 | Member: {} as InferMember<CO>, 159 | Team: {} as Team, 160 | }, 161 | organization: { 162 | checkRolePermission: < 163 | R extends CO extends { roles: any } 164 | ? keyof CO["roles"] 165 | : "admin" | "member" | "owner", 166 | >( 167 | data: PermissionExclusive & { 168 | role: R; 169 | }, 170 | ) => { 171 | const isAuthorized = clientSideHasPermission({ 172 | role: data.role as string, 173 | options: { 174 | ac: options?.ac, 175 | roles: roles, 176 | }, 177 | permissions: (data.permissions ?? data.permission) as any, 178 | }); 179 | return isAuthorized; 180 | }, 181 | }, 182 | }), 183 | getAtoms: ($fetch) => { 184 | const listOrganizations = useAuthQuery<Organization[]>( 185 | $listOrg, 186 | "/organization/list", 187 | $fetch, 188 | { 189 | method: "GET", 190 | }, 191 | ); 192 | const activeOrganization = useAuthQuery< 193 | Prettify< 194 | Organization & { 195 | members: (Member & { 196 | user: { 197 | id: string; 198 | name: string; 199 | email: string; 200 | image: string | undefined; 201 | }; 202 | })[]; 203 | invitations: Invitation[]; 204 | } 205 | > 206 | >( 207 | [$activeOrgSignal], 208 | "/organization/get-full-organization", 209 | $fetch, 210 | () => ({ 211 | method: "GET", 212 | }), 213 | ); 214 | 215 | const activeMember = useAuthQuery<Member>( 216 | [$activeMemberSignal], 217 | "/organization/get-active-member", 218 | $fetch, 219 | { 220 | method: "GET", 221 | }, 222 | ); 223 | 224 | const activeMemberRole = useAuthQuery<{ role: string }>( 225 | [$activeMemberRoleSignal], 226 | "/organization/get-active-member-role", 227 | $fetch, 228 | { 229 | method: "GET", 230 | }, 231 | ); 232 | 233 | return { 234 | $listOrg, 235 | $activeOrgSignal, 236 | $activeMemberSignal, 237 | $activeMemberRoleSignal, 238 | activeOrganization, 239 | listOrganizations, 240 | activeMember, 241 | activeMemberRole, 242 | }; 243 | }, 244 | pathMethods: { 245 | "/organization/get-full-organization": "GET", 246 | "/organization/list-user-teams": "GET", 247 | }, 248 | atomListeners: [ 249 | { 250 | matcher(path) { 251 | return ( 252 | path === "/organization/create" || 253 | path === "/organization/delete" || 254 | path === "/organization/update" 255 | ); 256 | }, 257 | signal: "$listOrg", 258 | }, 259 | { 260 | matcher(path) { 261 | return path.startsWith("/organization"); 262 | }, 263 | signal: "$activeOrgSignal", 264 | }, 265 | { 266 | matcher(path) { 267 | return path.startsWith("/organization/set-active"); 268 | }, 269 | signal: "$sessionSignal", 270 | }, 271 | { 272 | matcher(path) { 273 | return path.includes("/organization/update-member-role"); 274 | }, 275 | signal: "$activeMemberSignal", 276 | }, 277 | { 278 | matcher(path) { 279 | return path.includes("/organization/update-member-role"); 280 | }, 281 | signal: "$activeMemberRoleSignal", 282 | }, 283 | ], 284 | } satisfies BetterAuthClientPlugin; 285 | }; 286 | 287 | export const inferOrgAdditionalFields = < 288 | O extends { 289 | options: BetterAuthOptions; 290 | }, 291 | S extends OrganizationOptions["schema"] = undefined, 292 | >( 293 | schema?: S, 294 | ) => { 295 | type FindById< 296 | T extends readonly BetterAuthPlugin[], 297 | TargetId extends string, 298 | > = Extract<T[number], { id: TargetId }>; 299 | 300 | type Auth = O extends { options: any } ? O : { options: { plugins: [] } }; 301 | 302 | type OrganizationPlugin = FindById< 303 | // @ts-expect-error 304 | Auth["options"]["plugins"], 305 | "organization" 306 | >; 307 | type Schema = O extends Object 308 | ? O extends Exclude<OrganizationOptions["schema"], undefined> 309 | ? O 310 | : OrganizationPlugin extends { options: { schema: infer S } } 311 | ? S extends OrganizationOptions["schema"] 312 | ? S 313 | : undefined 314 | : undefined 315 | : undefined; 316 | return {} as undefined extends S ? Schema : S; 317 | }; 318 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/jwt/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { BetterAuthPlugin } from "@better-auth/core"; 2 | import { schema } from "./schema"; 3 | import { getJwksAdapter } from "./adapter"; 4 | import { getJwtToken, signJWT } from "./sign"; 5 | import type { JSONWebKeySet, JWTPayload } from "jose"; 6 | import { APIError, sessionMiddleware } from "../../api"; 7 | import { 8 | createAuthEndpoint, 9 | createAuthMiddleware, 10 | } from "@better-auth/core/api"; 11 | import { mergeSchema } from "../../db/schema"; 12 | import * as z from "zod"; 13 | import { BetterAuthError } from "@better-auth/core/error"; 14 | import type { JwtOptions } from "./types"; 15 | import { createJwk } from "./utils"; 16 | export type * from "./types"; 17 | export { generateExportedKeyPair, createJwk } from "./utils"; 18 | 19 | export const jwt = (options?: JwtOptions) => { 20 | // Remote url must be set when using signing function 21 | if (options?.jwt?.sign && !options.jwks?.remoteUrl) { 22 | throw new BetterAuthError( 23 | "jwks_config", 24 | "jwks.remoteUrl must be set when using jwt.sign", 25 | ); 26 | } 27 | 28 | // Alg is required to be specified when using remote url (needed in openid metadata) 29 | if (options?.jwks?.remoteUrl && !options.jwks?.keyPairConfig?.alg) { 30 | throw new BetterAuthError( 31 | "jwks_config", 32 | "must specify alg when using the oidc plugin and jwks.remoteUrl", 33 | ); 34 | } 35 | 36 | return { 37 | id: "jwt", 38 | options, 39 | endpoints: { 40 | getJwks: createAuthEndpoint( 41 | "/jwks", 42 | { 43 | method: "GET", 44 | metadata: { 45 | openapi: { 46 | description: "Get the JSON Web Key Set", 47 | responses: { 48 | "200": { 49 | description: "JSON Web Key Set retrieved successfully", 50 | content: { 51 | "application/json": { 52 | schema: { 53 | type: "object", 54 | properties: { 55 | keys: { 56 | type: "array", 57 | description: "Array of public JSON Web Keys", 58 | items: { 59 | type: "object", 60 | properties: { 61 | kid: { 62 | type: "string", 63 | description: 64 | "Key ID uniquely identifying the key, corresponds to the 'id' from the stored Jwk", 65 | }, 66 | kty: { 67 | type: "string", 68 | description: 69 | "Key type (e.g., 'RSA', 'EC', 'OKP')", 70 | }, 71 | alg: { 72 | type: "string", 73 | description: 74 | "Algorithm intended for use with the key (e.g., 'EdDSA', 'RS256')", 75 | }, 76 | use: { 77 | type: "string", 78 | description: 79 | "Intended use of the public key (e.g., 'sig' for signature)", 80 | enum: ["sig"], 81 | nullable: true, 82 | }, 83 | n: { 84 | type: "string", 85 | description: 86 | "Modulus for RSA keys (base64url-encoded)", 87 | nullable: true, 88 | }, 89 | e: { 90 | type: "string", 91 | description: 92 | "Exponent for RSA keys (base64url-encoded)", 93 | nullable: true, 94 | }, 95 | crv: { 96 | type: "string", 97 | description: 98 | "Curve name for elliptic curve keys (e.g., 'Ed25519', 'P-256')", 99 | nullable: true, 100 | }, 101 | x: { 102 | type: "string", 103 | description: 104 | "X coordinate for elliptic curve keys (base64url-encoded)", 105 | nullable: true, 106 | }, 107 | y: { 108 | type: "string", 109 | description: 110 | "Y coordinate for elliptic curve keys (base64url-encoded)", 111 | nullable: true, 112 | }, 113 | }, 114 | required: ["kid", "kty", "alg"], 115 | }, 116 | }, 117 | }, 118 | required: ["keys"], 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | async (ctx) => { 128 | // Disables endpoint if using remote url strategy 129 | if (options?.jwks?.remoteUrl) { 130 | throw new APIError("NOT_FOUND"); 131 | } 132 | 133 | const adapter = getJwksAdapter(ctx.context.adapter); 134 | 135 | const keySets = await adapter.getAllKeys(); 136 | 137 | if (keySets.length === 0) { 138 | const key = await createJwk(ctx, options); 139 | keySets.push(key); 140 | } 141 | 142 | const keyPairConfig = options?.jwks?.keyPairConfig; 143 | const defaultCrv = keyPairConfig 144 | ? "crv" in keyPairConfig 145 | ? (keyPairConfig as { crv: string }).crv 146 | : undefined 147 | : undefined; 148 | return ctx.json({ 149 | keys: keySets.map((keySet) => { 150 | return { 151 | alg: keySet.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA", 152 | crv: keySet.crv ?? defaultCrv, 153 | ...JSON.parse(keySet.publicKey), 154 | kid: keySet.id, 155 | }; 156 | }), 157 | } satisfies JSONWebKeySet as JSONWebKeySet); 158 | }, 159 | ), 160 | 161 | getToken: createAuthEndpoint( 162 | "/token", 163 | { 164 | method: "GET", 165 | requireHeaders: true, 166 | use: [sessionMiddleware], 167 | metadata: { 168 | openapi: { 169 | description: "Get a JWT token", 170 | responses: { 171 | 200: { 172 | description: "Success", 173 | content: { 174 | "application/json": { 175 | schema: { 176 | type: "object", 177 | properties: { 178 | token: { 179 | type: "string", 180 | }, 181 | }, 182 | }, 183 | }, 184 | }, 185 | }, 186 | }, 187 | }, 188 | }, 189 | }, 190 | async (ctx) => { 191 | const jwt = await getJwtToken(ctx, options); 192 | return ctx.json({ 193 | token: jwt, 194 | }); 195 | }, 196 | ), 197 | signJWT: createAuthEndpoint( 198 | "/sign-jwt", 199 | { 200 | method: "POST", 201 | metadata: { 202 | SERVER_ONLY: true, 203 | $Infer: { 204 | body: {} as { 205 | payload: JWTPayload; 206 | overrideOptions?: JwtOptions; 207 | }, 208 | }, 209 | }, 210 | body: z.object({ 211 | payload: z.record(z.string(), z.any()), 212 | overrideOptions: z.record(z.string(), z.any()).optional(), 213 | }), 214 | }, 215 | async (c) => { 216 | const jwt = await signJWT(c, { 217 | options: { 218 | ...options, 219 | ...c.body.overrideOptions, 220 | }, 221 | payload: c.body.payload, 222 | }); 223 | return c.json({ token: jwt }); 224 | }, 225 | ), 226 | }, 227 | hooks: { 228 | after: [ 229 | { 230 | matcher(context) { 231 | return context.path === "/get-session"; 232 | }, 233 | handler: createAuthMiddleware(async (ctx) => { 234 | if (options?.disableSettingJwtHeader) { 235 | return; 236 | } 237 | 238 | const session = ctx.context.session || ctx.context.newSession; 239 | if (session && session.session) { 240 | const jwt = await getJwtToken(ctx, options); 241 | const exposedHeaders = 242 | ctx.context.responseHeaders?.get( 243 | "access-control-expose-headers", 244 | ) || ""; 245 | const headersSet = new Set( 246 | exposedHeaders 247 | .split(",") 248 | .map((header) => header.trim()) 249 | .filter(Boolean), 250 | ); 251 | headersSet.add("set-auth-jwt"); 252 | ctx.setHeader("set-auth-jwt", jwt); 253 | ctx.setHeader( 254 | "Access-Control-Expose-Headers", 255 | Array.from(headersSet).join(", "), 256 | ); 257 | } 258 | }), 259 | }, 260 | ], 261 | }, 262 | schema: mergeSchema(schema, options?.schema), 263 | } satisfies BetterAuthPlugin; 264 | }; 265 | 266 | export { getJwtToken }; 267 | ``` -------------------------------------------------------------------------------- /docs/content/docs/plugins/mcp.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: MCP 3 | description: MCP provider plugin for Better Auth 4 | --- 5 | 6 | `OAuth` `MCP` 7 | 8 | The **MCP** plugin lets your app act as an OAuth provider for MCP clients. It handles authentication and makes it easy to issue and manage access tokens for MCP applications. 9 | 10 | ## Installation 11 | 12 | <Steps> 13 | <Step> 14 | ### Add the Plugin 15 | 16 | Add the MCP plugin to your auth configuration and specify the login page path. 17 | 18 | ```ts title="auth.ts" 19 | import { betterAuth } from "better-auth"; 20 | import { mcp } from "better-auth/plugins"; 21 | 22 | export const auth = betterAuth({ 23 | plugins: [ 24 | mcp({ 25 | loginPage: "/sign-in" // path to your login page 26 | }) 27 | ] 28 | }); 29 | ``` 30 | <Callout> 31 | This doesn't have a client plugin, so you don't need to make any changes to your authClient. 32 | </Callout> 33 | </Step> 34 | 35 | <Step> 36 | ### Generate Schema 37 | 38 | Run the migration or generate the schema to add the necessary fields and tables to the database. 39 | 40 | <Tabs items={["migrate", "generate"]}> 41 | <Tab value="migrate"> 42 | ```bash 43 | npx @better-auth/cli migrate 44 | ``` 45 | </Tab> 46 | <Tab value="generate"> 47 | ```bash 48 | npx @better-auth/cli generate 49 | ``` 50 | </Tab> 51 | </Tabs> 52 | The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](#schema) section for details. 53 | </Step> 54 | </Steps> 55 | 56 | ## Usage 57 | 58 | ### OAuth Discovery Metadata 59 | 60 | Better Auth already handles the `/api/auth/.well-known/oauth-authorization-server` route automatically but some client may fail to parse the `WWW-Authenticate` header and default to `/.well-known/oauth-authorization-server` (this can happen, for example, if your CORS configuration doesn't expose the `WWW-Authenticate`). For this reason it's better to add a route to expose OAuth metadata for MCP clients: 61 | 62 | ```ts title=".well-known/oauth-authorization-server/route.ts" 63 | import { oAuthDiscoveryMetadata } from "better-auth/plugins"; 64 | import { auth } from "../../../lib/auth"; 65 | 66 | export const GET = oAuthDiscoveryMetadata(auth); 67 | ``` 68 | 69 | ### OAuth Protected Resource Metadata 70 | 71 | Better Auth already handles the `/api/auth/.well-known/oauth-protected-resource` route automatically but some client may fail to parse the `WWW-Authenticate` header and default to `/.well-known/oauth-protected-resource` (this can happen, for example, if your CORS configuration doesn't expose the `WWW-Authenticate`). For this reason it's better to add a route to expose OAuth metadata for MCP clients: 72 | 73 | ```ts title="/.well-known/oauth-protected-resource/route.ts" 74 | import { oAuthProtectedResourceMetadata } from "better-auth/plugins"; 75 | import { auth } from "@/lib/auth"; 76 | 77 | export const GET = oAuthProtectedResourceMetadata(auth); 78 | ``` 79 | 80 | ### MCP Session Handling 81 | 82 | You can use the helper function `withMcpAuth` to get the session and handle unauthenticated calls automatically. 83 | 84 | 85 | ```ts title="api/[transport]/route.ts" 86 | import { auth } from "@/lib/auth"; 87 | import { createMcpHandler } from "@vercel/mcp-adapter"; 88 | import { withMcpAuth } from "better-auth/plugins"; 89 | import { z } from "zod"; 90 | 91 | const handler = withMcpAuth(auth, (req, session) => { 92 | // session contains the access token record with scopes and user ID 93 | return createMcpHandler( 94 | (server) => { 95 | server.tool( 96 | "echo", 97 | "Echo a message", 98 | { message: z.string() }, 99 | async ({ message }) => { 100 | return { 101 | content: [{ type: "text", text: `Tool echo: ${message}` }], 102 | }; 103 | }, 104 | ); 105 | }, 106 | { 107 | capabilities: { 108 | tools: { 109 | echo: { 110 | description: "Echo a message", 111 | }, 112 | }, 113 | }, 114 | }, 115 | { 116 | redisUrl: process.env.REDIS_URL, 117 | basePath: "/api", 118 | verboseLogs: true, 119 | maxDuration: 60, 120 | }, 121 | )(req); 122 | }); 123 | 124 | export { handler as GET, handler as POST, handler as DELETE }; 125 | ``` 126 | 127 | You can also use `auth.api.getMcpSession` to get the session using the access token sent from the MCP client: 128 | 129 | ```ts title="api/[transport]/route.ts" 130 | import { auth } from "@/lib/auth"; 131 | import { createMcpHandler } from "@vercel/mcp-adapter"; 132 | import { z } from "zod"; 133 | 134 | const handler = async (req: Request) => { 135 | // session contains the access token record with scopes and user ID 136 | const session = await auth.api.getMcpSession({ 137 | headers: req.headers 138 | }) 139 | if(!session){ 140 | //this is important and you must return 401 141 | return new Response(null, { 142 | status: 401 143 | }) 144 | } 145 | return createMcpHandler( 146 | (server) => { 147 | server.tool( 148 | "echo", 149 | "Echo a message", 150 | { message: z.string() }, 151 | async ({ message }) => { 152 | return { 153 | content: [{ type: "text", text: `Tool echo: ${message}` }], 154 | }; 155 | }, 156 | ); 157 | }, 158 | { 159 | capabilities: { 160 | tools: { 161 | echo: { 162 | description: "Echo a message", 163 | }, 164 | }, 165 | }, 166 | }, 167 | { 168 | redisUrl: process.env.REDIS_URL, 169 | basePath: "/api", 170 | verboseLogs: true, 171 | maxDuration: 60, 172 | }, 173 | )(req); 174 | } 175 | 176 | export { handler as GET, handler as POST, handler as DELETE }; 177 | ``` 178 | 179 | ## Configuration 180 | 181 | The MCP plugin accepts the following configuration options: 182 | 183 | <TypeTable 184 | type={{ 185 | loginPage: { 186 | description: "Path to the login page where users will be redirected for authentication", 187 | type: "string", 188 | required: true 189 | }, 190 | resource: { 191 | description: "The resource that should be returned by the protected resource metadata endpoint", 192 | type: "string", 193 | required: false 194 | }, 195 | oidcConfig: { 196 | description: "Optional OIDC configuration options", 197 | type: "object", 198 | required: false 199 | } 200 | }} 201 | /> 202 | 203 | ### OIDC Configuration 204 | 205 | The plugin supports additional OIDC configuration options through the `oidcConfig` parameter: 206 | 207 | <TypeTable 208 | type={{ 209 | codeExpiresIn: { 210 | description: "Expiration time for authorization codes in seconds", 211 | type: "number", 212 | default: 600 213 | }, 214 | accessTokenExpiresIn: { 215 | description: "Expiration time for access tokens in seconds", 216 | type: "number", 217 | default: 3600 218 | }, 219 | refreshTokenExpiresIn: { 220 | description: "Expiration time for refresh tokens in seconds", 221 | type: "number", 222 | default: 604800 223 | }, 224 | defaultScope: { 225 | description: "Default scope for OAuth requests", 226 | type: "string", 227 | default: "openid" 228 | }, 229 | scopes: { 230 | description: "Additional scopes to support", 231 | type: "string[]", 232 | default: '["openid", "profile", "email", "offline_access"]' 233 | } 234 | }} 235 | /> 236 | 237 | ## Schema 238 | 239 | The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](#schema) section for details. 240 | ``` -------------------------------------------------------------------------------- /demo/nextjs/components/ui/menubar.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { 5 | CheckIcon, 6 | ChevronRightIcon, 7 | DotFilledIcon, 8 | } from "@radix-ui/react-icons"; 9 | import * as MenubarPrimitive from "@radix-ui/react-menubar"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | const MenubarMenu = MenubarPrimitive.Menu; 14 | 15 | const MenubarGroup = MenubarPrimitive.Group; 16 | 17 | const MenubarPortal = MenubarPrimitive.Portal; 18 | 19 | const MenubarSub = MenubarPrimitive.Sub; 20 | 21 | const MenubarRadioGroup = MenubarPrimitive.RadioGroup; 22 | 23 | const Menubar = ({ 24 | ref, 25 | className, 26 | ...props 27 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> & { 28 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.Root>>; 29 | }) => ( 30 | <MenubarPrimitive.Root 31 | ref={ref} 32 | className={cn( 33 | "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", 34 | className, 35 | )} 36 | {...props} 37 | /> 38 | ); 39 | Menubar.displayName = MenubarPrimitive.Root.displayName; 40 | 41 | const MenubarTrigger = ({ 42 | ref, 43 | className, 44 | ...props 45 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> & { 46 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.Trigger>>; 47 | }) => ( 48 | <MenubarPrimitive.Trigger 49 | ref={ref} 50 | className={cn( 51 | "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", 52 | className, 53 | )} 54 | {...props} 55 | /> 56 | ); 57 | MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; 58 | 59 | const MenubarSubTrigger = ({ ref, className, inset, children, ...props }) => ( 60 | <MenubarPrimitive.SubTrigger 61 | ref={ref} 62 | className={cn( 63 | "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", 64 | inset && "pl-8", 65 | className, 66 | )} 67 | {...props} 68 | > 69 | {children} 70 | <ChevronRightIcon className="ml-auto h-4 w-4" /> 71 | </MenubarPrimitive.SubTrigger> 72 | ); 73 | MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; 74 | 75 | const MenubarSubContent = ({ 76 | ref, 77 | className, 78 | ...props 79 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> & { 80 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.SubContent>>; 81 | }) => ( 82 | <MenubarPrimitive.SubContent 83 | ref={ref} 84 | className={cn( 85 | "z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 86 | className, 87 | )} 88 | {...props} 89 | /> 90 | ); 91 | MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; 92 | 93 | const MenubarContent = ({ 94 | ref, 95 | className, 96 | align = "start", 97 | alignOffset = -4, 98 | sideOffset = 8, 99 | ...props 100 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> & { 101 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.Content>>; 102 | }) => ( 103 | <MenubarPrimitive.Portal> 104 | <MenubarPrimitive.Content 105 | ref={ref} 106 | align={align} 107 | alignOffset={alignOffset} 108 | sideOffset={sideOffset} 109 | className={cn( 110 | "z-50 min-w-48 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 111 | className, 112 | )} 113 | {...props} 114 | /> 115 | </MenubarPrimitive.Portal> 116 | ); 117 | MenubarContent.displayName = MenubarPrimitive.Content.displayName; 118 | 119 | const MenubarItem = ({ ref, className, inset, ...props }) => ( 120 | <MenubarPrimitive.Item 121 | ref={ref} 122 | className={cn( 123 | "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", 124 | inset && "pl-8", 125 | className, 126 | )} 127 | {...props} 128 | /> 129 | ); 130 | MenubarItem.displayName = MenubarPrimitive.Item.displayName; 131 | 132 | const MenubarCheckboxItem = ({ 133 | ref, 134 | className, 135 | children, 136 | checked, 137 | ...props 138 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> & { 139 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.CheckboxItem>>; 140 | }) => ( 141 | <MenubarPrimitive.CheckboxItem 142 | ref={ref} 143 | className={cn( 144 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", 145 | className, 146 | )} 147 | checked={checked} 148 | {...props} 149 | > 150 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 151 | <MenubarPrimitive.ItemIndicator> 152 | <CheckIcon className="h-4 w-4" /> 153 | </MenubarPrimitive.ItemIndicator> 154 | </span> 155 | {children} 156 | </MenubarPrimitive.CheckboxItem> 157 | ); 158 | MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; 159 | 160 | const MenubarRadioItem = ({ 161 | ref, 162 | className, 163 | children, 164 | ...props 165 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> & { 166 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.RadioItem>>; 167 | }) => ( 168 | <MenubarPrimitive.RadioItem 169 | ref={ref} 170 | className={cn( 171 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", 172 | className, 173 | )} 174 | {...props} 175 | > 176 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 177 | <MenubarPrimitive.ItemIndicator> 178 | <DotFilledIcon className="h-4 w-4 fill-current" /> 179 | </MenubarPrimitive.ItemIndicator> 180 | </span> 181 | {children} 182 | </MenubarPrimitive.RadioItem> 183 | ); 184 | MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; 185 | 186 | const MenubarLabel = ({ ref, className, inset, ...props }) => ( 187 | <MenubarPrimitive.Label 188 | ref={ref} 189 | className={cn( 190 | "px-2 py-1.5 text-sm font-semibold", 191 | inset && "pl-8", 192 | className, 193 | )} 194 | {...props} 195 | /> 196 | ); 197 | MenubarLabel.displayName = MenubarPrimitive.Label.displayName; 198 | 199 | const MenubarSeparator = ({ 200 | ref, 201 | className, 202 | ...props 203 | }: React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> & { 204 | ref: React.RefObject<React.ElementRef<typeof MenubarPrimitive.Separator>>; 205 | }) => ( 206 | <MenubarPrimitive.Separator 207 | ref={ref} 208 | className={cn("-mx-1 my-1 h-px bg-muted", className)} 209 | {...props} 210 | /> 211 | ); 212 | MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; 213 | 214 | const MenubarShortcut = ({ 215 | className, 216 | ...props 217 | }: React.HTMLAttributes<HTMLSpanElement>) => { 218 | return ( 219 | <span 220 | className={cn( 221 | "ml-auto text-xs tracking-widest text-muted-foreground", 222 | className, 223 | )} 224 | {...props} 225 | /> 226 | ); 227 | }; 228 | MenubarShortcut.displayname = "MenubarShortcut"; 229 | 230 | export { 231 | Menubar, 232 | MenubarMenu, 233 | MenubarTrigger, 234 | MenubarContent, 235 | MenubarItem, 236 | MenubarSeparator, 237 | MenubarLabel, 238 | MenubarCheckboxItem, 239 | MenubarRadioGroup, 240 | MenubarRadioItem, 241 | MenubarPortal, 242 | MenubarSubContent, 243 | MenubarSubTrigger, 244 | MenubarGroup, 245 | MenubarSub, 246 | MenubarShortcut, 247 | }; 248 | ``` -------------------------------------------------------------------------------- /docs/app/docs/lib/get-llm-text.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { remark } from "remark"; 2 | import remarkGfm from "remark-gfm"; 3 | import { fileGenerator, remarkDocGen } from "fumadocs-docgen"; 4 | import { remarkNpm } from "fumadocs-core/mdx-plugins"; 5 | import remarkStringify from "remark-stringify"; 6 | import remarkMdx from "remark-mdx"; 7 | import { remarkAutoTypeTable } from "fumadocs-typescript"; 8 | import { remarkInclude } from "fumadocs-mdx/config"; 9 | import { readFile } from "fs/promises"; 10 | 11 | function extractAPIMethods(rawContent: string): string { 12 | const apiMethodRegex = /<APIMethod\s+([^>]+)>([\s\S]*?)<\/APIMethod>/g; 13 | 14 | return rawContent.replace(apiMethodRegex, (match, attributes, content) => { 15 | // Parse attributes by matching 16 | const pathMatch = attributes.match(/path="([^"]+)"/); 17 | const methodMatch = attributes.match(/method="([^"]+)"/); 18 | const requireSessionMatch = attributes.match(/requireSession/); 19 | const isServerOnlyMatch = attributes.match(/isServerOnly/); 20 | const isClientOnlyMatch = attributes.match(/isClientOnly/); 21 | const noResultMatch = attributes.match(/noResult/); 22 | const resultVariableMatch = attributes.match(/resultVariable="([^"]+)"/); 23 | const forceAsBodyMatch = attributes.match(/forceAsBody/); 24 | const forceAsQueryMatch = attributes.match(/forceAsQuery/); 25 | 26 | const path = pathMatch ? pathMatch[1] : ""; 27 | const method = methodMatch ? methodMatch[1] : "GET"; 28 | const requireSession = !!requireSessionMatch; 29 | const isServerOnly = !!isServerOnlyMatch; 30 | const isClientOnly = !!isClientOnlyMatch; 31 | const noResult = !!noResultMatch; 32 | const resultVariable = resultVariableMatch 33 | ? resultVariableMatch[1] 34 | : "data"; 35 | const forceAsBody = !!forceAsBodyMatch; 36 | const forceAsQuery = !!forceAsQueryMatch; 37 | 38 | const typeMatch = content.match(/type\s+(\w+)\s*=\s*\{([\s\S]*?)\}/); 39 | if (!typeMatch) { 40 | return match; // Return original if no type found 41 | } 42 | 43 | const functionName = typeMatch[1]; 44 | const typeBody = typeMatch[2]; 45 | 46 | const properties = parseTypeBody(typeBody); 47 | 48 | const clientCode = generateClientCode(functionName, properties, path); 49 | const serverCode = generateServerCode( 50 | functionName, 51 | properties, 52 | method, 53 | requireSession, 54 | forceAsBody, 55 | forceAsQuery, 56 | noResult, 57 | resultVariable, 58 | ); 59 | 60 | return ` 61 | ### Client Side 62 | 63 | \`\`\`ts 64 | ${clientCode} 65 | \`\`\` 66 | 67 | ### Server Side 68 | 69 | \`\`\`ts 70 | ${serverCode} 71 | \`\`\` 72 | 73 | ### Type Definition 74 | 75 | \`\`\`ts 76 | type ${functionName} = {${typeBody} 77 | } 78 | \`\`\` 79 | `; 80 | }); 81 | } 82 | 83 | function parseTypeBody(typeBody: string) { 84 | const properties: Array<{ 85 | name: string; 86 | type: string; 87 | required: boolean; 88 | description: string; 89 | exampleValue: string; 90 | isServerOnly: boolean; 91 | isClientOnly: boolean; 92 | }> = []; 93 | 94 | const lines = typeBody.split("\n"); 95 | 96 | for (const line of lines) { 97 | const trimmed = line.trim(); 98 | 99 | if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*")) 100 | continue; 101 | const propMatch = trimmed.match( 102 | /^(\w+)(\?)?:\s*(.+?)(\s*=\s*["']([^"']+)["'])?(\s*\/\/\s*(.+))?$/, 103 | ); 104 | if (propMatch) { 105 | const [, name, optional, type, , exampleValue, , description] = propMatch; 106 | 107 | let cleanType = type.trim(); 108 | let cleanExampleValue = exampleValue || ""; 109 | 110 | cleanType = cleanType.replace(/,$/, ""); 111 | 112 | properties.push({ 113 | name, 114 | type: cleanType, 115 | required: !optional, 116 | description: description || "", 117 | exampleValue: cleanExampleValue, 118 | isServerOnly: false, 119 | isClientOnly: false, 120 | }); 121 | } 122 | } 123 | 124 | return properties; 125 | } 126 | 127 | // Generate client code example 128 | function generateClientCode( 129 | functionName: string, 130 | properties: any[], 131 | path: string, 132 | ) { 133 | if (!functionName || !path) { 134 | return "// Unable to generate client code - missing function name or path"; 135 | } 136 | 137 | const clientMethodPath = pathToDotNotation(path); 138 | const body = createClientBody(properties); 139 | 140 | return `const { data, error } = await authClient.${clientMethodPath}(${body});`; 141 | } 142 | 143 | // Generate server code example 144 | function generateServerCode( 145 | functionName: string, 146 | properties: any[], 147 | method: string, 148 | requireSession: boolean, 149 | forceAsBody: boolean, 150 | forceAsQuery: boolean, 151 | noResult: boolean, 152 | resultVariable: string, 153 | ) { 154 | if (!functionName) { 155 | return "// Unable to generate server code - missing function name"; 156 | } 157 | 158 | const body = createServerBody( 159 | properties, 160 | method, 161 | requireSession, 162 | forceAsBody, 163 | forceAsQuery, 164 | ); 165 | 166 | return `${noResult ? "" : `const ${resultVariable} = `}await auth.api.${functionName}(${body});`; 167 | } 168 | 169 | function pathToDotNotation(input: string): string { 170 | return input 171 | .split("/") 172 | .filter(Boolean) 173 | .map((segment) => 174 | segment 175 | .split("-") 176 | .map((word, i) => 177 | i === 0 178 | ? word.toLowerCase() 179 | : word.charAt(0).toUpperCase() + word.slice(1), 180 | ) 181 | .join(""), 182 | ) 183 | .join("."); 184 | } 185 | 186 | // Helper function to create client body (simplified version) 187 | function createClientBody(props: any[]) { 188 | if (props.length === 0) return "{}"; 189 | 190 | let body = "{\n"; 191 | 192 | for (const prop of props) { 193 | if (prop.isServerOnly) continue; 194 | 195 | let comment = ""; 196 | if (!prop.required || prop.description) { 197 | const comments = []; 198 | if (!prop.required) comments.push("required"); 199 | if (prop.description) comments.push(prop.description); 200 | comment = ` // ${comments.join(", ")}`; 201 | } 202 | 203 | body += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`; 204 | } 205 | 206 | body += "}"; 207 | return body; 208 | } 209 | 210 | function createServerBody( 211 | props: any[], 212 | method: string, 213 | requireSession: boolean, 214 | forceAsBody: boolean, 215 | forceAsQuery: boolean, 216 | ) { 217 | const relevantProps = props.filter((x) => !x.isClientOnly); 218 | 219 | if (relevantProps.length === 0 && !requireSession) { 220 | return "{}"; 221 | } 222 | 223 | let serverBody = "{\n"; 224 | 225 | if (relevantProps.length > 0) { 226 | const bodyKey = 227 | (method === "POST" || forceAsBody) && !forceAsQuery ? "body" : "query"; 228 | serverBody += ` ${bodyKey}: {\n`; 229 | 230 | for (const prop of relevantProps) { 231 | let comment = ""; 232 | if (!prop.required || prop.description) { 233 | const comments = []; 234 | if (!prop.required) comments.push("required"); 235 | if (prop.description) comments.push(prop.description); 236 | comment = ` // ${comments.join(", ")}`; 237 | } 238 | 239 | serverBody += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`; 240 | } 241 | 242 | serverBody += " }"; 243 | } 244 | 245 | if (requireSession) { 246 | if (relevantProps.length > 0) serverBody += ","; 247 | serverBody += 248 | "\n // This endpoint requires session cookies.\n headers: await headers()"; 249 | } 250 | 251 | serverBody += "\n}"; 252 | return serverBody; 253 | } 254 | 255 | const processor = remark() 256 | .use(remarkMdx) 257 | .use(remarkInclude) 258 | .use(remarkGfm) 259 | .use(remarkAutoTypeTable) 260 | .use(remarkDocGen, { generators: [fileGenerator()] }) 261 | .use(remarkNpm) 262 | .use(remarkStringify); 263 | 264 | export async function getLLMText(docPage: any) { 265 | const category = [docPage.slugs[0]]; 266 | 267 | // Read the raw file content 268 | const rawContent = await readFile(docPage.data._file.absolutePath, "utf-8"); 269 | 270 | // Extract APIMethod components & other nested wrapper before processing 271 | const processedContent = extractAPIMethods(rawContent); 272 | 273 | const processed = await processor.process({ 274 | path: docPage.data._file.absolutePath, 275 | value: processedContent, 276 | }); 277 | 278 | return `# ${category}: ${docPage.data.title} 279 | URL: ${docPage.url} 280 | Source: https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/${ 281 | docPage.file.path 282 | } 283 | 284 | ${docPage.data.description} 285 | 286 | ${processed.toString()} 287 | `; 288 | } 289 | ```