This is page 52 of 67. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── 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-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.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 │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── 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.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { expect, test, describe, beforeAll } from "vitest"; 2 | import type { User } from "../types"; 3 | import type { BetterAuthOptions } from "@better-auth/core"; 4 | import type { DBAdapter } from "@better-auth/core/db/adapter"; 5 | import { generateId } from "../utils"; 6 | 7 | interface AdapterTestOptions { 8 | getAdapter: ( 9 | customOptions?: Omit<BetterAuthOptions, "database">, 10 | ) => Promise<DBAdapter<BetterAuthOptions>> | DBAdapter<BetterAuthOptions>; 11 | disableTests?: Partial<Record<keyof typeof adapterTests, boolean>>; 12 | testPrefix?: string; 13 | } 14 | 15 | interface NumberIdAdapterTestOptions { 16 | getAdapter: ( 17 | customOptions?: Omit<BetterAuthOptions, "database">, 18 | ) => Promise<DBAdapter<BetterAuthOptions>>; 19 | disableTests?: Partial<Record<keyof typeof numberIdAdapterTests, boolean>>; 20 | testPrefix?: string; 21 | } 22 | 23 | const adapterTests = { 24 | CREATE_MODEL: "create model", 25 | CREATE_MODEL_SHOULD_ALWAYS_RETURN_AN_ID: 26 | "create model should always return an id", 27 | FIND_MODEL: "find model", 28 | FIND_MODEL_WITHOUT_ID: "find model without id", 29 | FIND_MODEL_WITH_SELECT: "find model with select", 30 | FIND_MODEL_WITH_MODIFIED_FIELD_NAME: "find model with modified field name", 31 | UPDATE_MODEL: "update model", 32 | SHOULD_FIND_MANY: "should find many", 33 | SHOULD_FIND_MANY_WITH_WHERE: "should find many with where", 34 | SHOULD_FIND_MANY_WITH_OPERATORS: "should find many with operators", 35 | SHOULD_WORK_WITH_REFERENCE_FIELDS: "should work with reference fields", 36 | SHOULD_FIND_MANY_WITH_NOT_IN_OPERATOR: 37 | "should find many with not in operator", 38 | SHOULD_FIND_MANY_WITH_SORT_BY: "should find many with sortBy", 39 | SHOULD_FIND_MANY_WITH_LIMIT: "should find many with limit", 40 | SHOULD_FIND_MANY_WITH_OFFSET: "should find many with offset", 41 | SHOULD_UPDATE_WITH_MULTIPLE_WHERE: "should update with multiple where", 42 | DELETE_MODEL: "delete model", 43 | SHOULD_DELETE_MANY: "should delete many", 44 | SHOULD_NOT_THROW_ON_DELETE_RECORD_NOT_FOUND: 45 | "shouldn't throw on delete record not found", 46 | SHOULD_NOT_THROW_ON_RECORD_NOT_FOUND: "shouldn't throw on record not found", 47 | SHOULD_FIND_MANY_WITH_CONTAINS_OPERATOR: 48 | "should find many with contains operator", 49 | SHOULD_SEARCH_USERS_WITH_STARTS_WITH: "should search users with startsWith", 50 | SHOULD_SEARCH_USERS_WITH_ENDS_WITH: "should search users with endsWith", 51 | SHOULD_PREFER_GENERATE_ID_IF_PROVIDED: "should prefer generateId if provided", 52 | SHOULD_ROLLBACK_FAILING_TRANSACTION: "should rollback failing transaction", 53 | SHOULD_RETURN_TRANSACTION_RESULT: "should return transaction result", 54 | SHOULD_FIND_MANY_WITH_CONNECTORS: "should find many with connectors", 55 | } as const; 56 | 57 | const { ...numberIdAdapterTestsCopy } = adapterTests; 58 | 59 | const numberIdAdapterTests = { 60 | ...numberIdAdapterTestsCopy, 61 | SHOULD_RETURN_A_NUMBER_ID_AS_A_RESULT: 62 | "Should return a number id as a result", 63 | SHOULD_INCREMENT_THE_ID_BY_1: "Should increment the id by 1", 64 | }; 65 | 66 | // @ts-expect-error 67 | // biome-ignore lint/performance/noDelete: testing propose 68 | delete numberIdAdapterTests.SHOULD_NOT_THROW_ON_DELETE_RECORD_NOT_FOUND; 69 | 70 | /** 71 | * @deprecated Use `testAdapter` instead. 72 | */ 73 | function adapterTest( 74 | { getAdapter, disableTests: disabledTests, testPrefix }: AdapterTestOptions, 75 | internalOptions?: { 76 | predefinedOptions: Omit<BetterAuthOptions, "database">; 77 | }, 78 | ) { 79 | console.warn( 80 | "This test function is deprecated and will be removed in the future. Use `testAdapter` instead.", 81 | ); 82 | const adapter = async () => 83 | await getAdapter(internalOptions?.predefinedOptions); 84 | 85 | async function resetDebugLogs() { 86 | //@ts-expect-error 87 | (await adapter())?.adapterTestDebugLogs?.resetDebugLogs(); 88 | } 89 | 90 | async function printDebugLogs() { 91 | //@ts-expect-error 92 | (await adapter())?.adapterTestDebugLogs?.printDebugLogs(); 93 | } 94 | 95 | // Generate unique test identifier for this test run to avoid conflicts 96 | const testRunId = 97 | Date.now().toString(36) + Math.random().toString(36).substring(2, 5); 98 | const getUniqueEmail = (base: string) => `${testRunId}_${base}`; 99 | 100 | //@ts-expect-error - intentionally omitting id 101 | let user: { 102 | name: string; 103 | email: string; 104 | emailVerified: boolean; 105 | createdAt: Date; 106 | updatedAt: Date; 107 | id: string; 108 | } = { 109 | name: "user", 110 | email: getUniqueEmail("[email protected]"), 111 | emailVerified: true, 112 | createdAt: new Date(), 113 | updatedAt: new Date(), 114 | }; 115 | 116 | test.skipIf(disabledTests?.CREATE_MODEL)( 117 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.CREATE_MODEL}`, 118 | async ({ onTestFailed }) => { 119 | await resetDebugLogs(); 120 | onTestFailed(async () => { 121 | await printDebugLogs(); 122 | }); 123 | const res = await (await adapter()).create({ 124 | model: "user", 125 | data: user, 126 | }); 127 | user.id = res.id; 128 | expect({ 129 | name: res.name, 130 | email: res.email, 131 | }).toEqual({ 132 | name: user.name, 133 | email: user.email, 134 | }); 135 | }, 136 | ); 137 | 138 | test.skipIf(disabledTests?.CREATE_MODEL_SHOULD_ALWAYS_RETURN_AN_ID)( 139 | `${testPrefix ? `${testPrefix} - ` : ""}${ 140 | adapterTests.CREATE_MODEL_SHOULD_ALWAYS_RETURN_AN_ID 141 | }`, 142 | async ({ onTestFailed }) => { 143 | await resetDebugLogs(); 144 | onTestFailed(async () => { 145 | await printDebugLogs(); 146 | }); 147 | const res = await (await adapter()).create({ 148 | model: "user", 149 | data: { 150 | name: "test-name-without-id", 151 | email: getUniqueEmail("[email protected]"), 152 | }, 153 | }); 154 | expect(res).toHaveProperty("id"); 155 | expect(typeof res?.id).toEqual("string"); 156 | }, 157 | ); 158 | 159 | test.skipIf(disabledTests?.FIND_MODEL)( 160 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.FIND_MODEL}`, 161 | async ({ onTestFailed }) => { 162 | await resetDebugLogs(); 163 | onTestFailed(async () => { 164 | await printDebugLogs(); 165 | }); 166 | const res = await (await adapter()).findOne<User>({ 167 | model: "user", 168 | where: [ 169 | { 170 | field: "id", 171 | value: user.id, 172 | }, 173 | ], 174 | }); 175 | expect({ 176 | name: res?.name, 177 | email: res?.email, 178 | }).toEqual({ 179 | name: user.name, 180 | email: user.email, 181 | }); 182 | }, 183 | ); 184 | 185 | test.skipIf(disabledTests?.FIND_MODEL_WITHOUT_ID)( 186 | `${testPrefix ? `${testPrefix} - ` : ""}${ 187 | adapterTests.FIND_MODEL_WITHOUT_ID 188 | }`, 189 | async ({ onTestFailed }) => { 190 | await resetDebugLogs(); 191 | onTestFailed(async () => { 192 | await printDebugLogs(); 193 | }); 194 | const res = await (await adapter()).findOne<User>({ 195 | model: "user", 196 | where: [ 197 | { 198 | field: "email", 199 | value: user.email, 200 | }, 201 | ], 202 | }); 203 | expect({ 204 | name: res?.name, 205 | email: res?.email, 206 | }).toEqual({ 207 | name: user.name, 208 | email: user.email, 209 | }); 210 | }, 211 | ); 212 | 213 | test.skipIf(disabledTests?.FIND_MODEL_WITH_MODIFIED_FIELD_NAME)( 214 | `${testPrefix ? `${testPrefix} - ` : ""}${ 215 | adapterTests.FIND_MODEL_WITH_MODIFIED_FIELD_NAME 216 | }`, 217 | async ({ onTestFailed }) => { 218 | await resetDebugLogs(); 219 | onTestFailed(async () => { 220 | await printDebugLogs(); 221 | }); 222 | const email = getUniqueEmail("[email protected]"); 223 | const adapter = await getAdapter( 224 | Object.assign( 225 | { 226 | user: { 227 | fields: { 228 | email: "email_address", 229 | }, 230 | }, 231 | }, 232 | internalOptions?.predefinedOptions, 233 | ), 234 | ); 235 | const user = await adapter.create({ 236 | model: "user", 237 | data: { 238 | email, 239 | name: "test-name-with-modified-field", 240 | emailVerified: true, 241 | createdAt: new Date(), 242 | updatedAt: new Date(), 243 | }, 244 | }); 245 | expect(user.email).toEqual(email); 246 | const res = await adapter.findOne<User>({ 247 | model: "user", 248 | where: [ 249 | { 250 | field: "email", 251 | value: email, 252 | }, 253 | ], 254 | }); 255 | expect(res).not.toBeNull(); 256 | expect(res?.email).toEqual(email); 257 | }, 258 | ); 259 | 260 | test.skipIf(disabledTests?.FIND_MODEL_WITH_SELECT)( 261 | `${testPrefix ? `${testPrefix} - ` : ""}${ 262 | adapterTests.FIND_MODEL_WITH_SELECT 263 | }`, 264 | async ({ onTestFailed }) => { 265 | await resetDebugLogs(); 266 | onTestFailed(async () => { 267 | await printDebugLogs(); 268 | }); 269 | const res = await (await adapter()).findOne({ 270 | model: "user", 271 | where: [ 272 | { 273 | field: "id", 274 | value: user.id, 275 | }, 276 | ], 277 | select: ["email"], 278 | }); 279 | expect(res).toEqual({ email: user.email }); 280 | }, 281 | ); 282 | 283 | test.skipIf(disabledTests?.UPDATE_MODEL)( 284 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.UPDATE_MODEL}`, 285 | async ({ onTestFailed }) => { 286 | await resetDebugLogs(); 287 | onTestFailed(async () => { 288 | await printDebugLogs(); 289 | }); 290 | const newEmail = getUniqueEmail("[email protected]"); 291 | 292 | const res = await (await adapter()).update<User>({ 293 | model: "user", 294 | where: [ 295 | { 296 | field: "id", 297 | value: user.id, 298 | }, 299 | ], 300 | update: { 301 | email: newEmail, 302 | }, 303 | }); 304 | expect(res).toMatchObject({ 305 | email: newEmail, 306 | name: user.name, 307 | }); 308 | }, 309 | ); 310 | 311 | test.skipIf(disabledTests?.SHOULD_FIND_MANY)( 312 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.SHOULD_FIND_MANY}`, 313 | async ({ onTestFailed }) => { 314 | await resetDebugLogs(); 315 | onTestFailed(async () => { 316 | await printDebugLogs(); 317 | }); 318 | const res = await (await adapter()).findMany({ 319 | model: "user", 320 | }); 321 | expect(res.length).toBe(3); 322 | }, 323 | ); 324 | 325 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_WHERE)( 326 | `${testPrefix ? `${testPrefix} - ` : ""}${ 327 | adapterTests.SHOULD_FIND_MANY_WITH_WHERE 328 | }`, 329 | async ({ onTestFailed }) => { 330 | await resetDebugLogs(); 331 | onTestFailed(async () => { 332 | await printDebugLogs(); 333 | }); 334 | const user = await (await adapter()).create<User>({ 335 | model: "user", 336 | data: { 337 | name: "user2", 338 | email: getUniqueEmail("[email protected]"), 339 | emailVerified: true, 340 | createdAt: new Date(), 341 | updatedAt: new Date(), 342 | }, 343 | }); 344 | const res = await (await adapter()).findMany({ 345 | model: "user", 346 | where: [ 347 | { 348 | field: "id", 349 | value: user.id, 350 | }, 351 | ], 352 | }); 353 | expect(res.length).toBe(1); 354 | }, 355 | ); 356 | 357 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_OPERATORS)( 358 | `${testPrefix ? `${testPrefix} - ` : ""}${ 359 | adapterTests.SHOULD_FIND_MANY_WITH_OPERATORS 360 | }`, 361 | async ({ onTestFailed }) => { 362 | await resetDebugLogs(); 363 | onTestFailed(async () => { 364 | await printDebugLogs(); 365 | }); 366 | const newUser = await (await adapter()).create<User>({ 367 | model: "user", 368 | data: { 369 | name: "user", 370 | email: getUniqueEmail("[email protected]"), 371 | emailVerified: true, 372 | createdAt: new Date(), 373 | updatedAt: new Date(), 374 | }, 375 | }); 376 | const res = await (await adapter()).findMany({ 377 | model: "user", 378 | where: [ 379 | { 380 | field: "id", 381 | operator: "in", 382 | value: [user.id, newUser.id], 383 | }, 384 | ], 385 | }); 386 | expect(res.length).toBe(2); 387 | }, 388 | ); 389 | 390 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_NOT_IN_OPERATOR)( 391 | `${testPrefix ? `${testPrefix} - ` : ""}${ 392 | adapterTests.SHOULD_FIND_MANY_WITH_NOT_IN_OPERATOR 393 | }`, 394 | async ({ onTestFailed }) => { 395 | await resetDebugLogs(); 396 | onTestFailed(async () => { 397 | await printDebugLogs(); 398 | }); 399 | 400 | const newUser3 = await (await adapter()).create<User>({ 401 | model: "user", 402 | data: { 403 | name: "user", 404 | email: getUniqueEmail("[email protected]"), 405 | emailVerified: true, 406 | createdAt: new Date(), 407 | updatedAt: new Date(), 408 | }, 409 | }); 410 | const allUsers = await (await adapter()).findMany<User>({ 411 | model: "user", 412 | }); 413 | expect(allUsers.length).toBe(6); 414 | const usersWithoutNotIn = await (await adapter()).findMany<User>({ 415 | model: "user", 416 | where: [ 417 | { 418 | field: "id", 419 | operator: "not_in", 420 | value: [user.id, newUser3.id], 421 | }, 422 | ], 423 | }); 424 | expect(usersWithoutNotIn.length).toBe(4); 425 | //cleanup 426 | await (await adapter()).delete({ 427 | model: "user", 428 | where: [ 429 | { 430 | field: "id", 431 | value: newUser3.id, 432 | }, 433 | ], 434 | }); 435 | }, 436 | ); 437 | 438 | test.skipIf(disabledTests?.SHOULD_WORK_WITH_REFERENCE_FIELDS)( 439 | `${testPrefix ? `${testPrefix} - ` : ""}${ 440 | adapterTests.SHOULD_WORK_WITH_REFERENCE_FIELDS 441 | }`, 442 | async ({ onTestFailed }) => { 443 | await resetDebugLogs(); 444 | onTestFailed(async () => { 445 | await printDebugLogs(); 446 | }); 447 | let token = null; 448 | const user = await (await adapter()).create<Record<string, any>>({ 449 | model: "user", 450 | data: { 451 | name: "user", 452 | email: getUniqueEmail("[email protected]"), 453 | emailVerified: true, 454 | createdAt: new Date(), 455 | updatedAt: new Date(), 456 | }, 457 | }); 458 | const session = await (await adapter()).create({ 459 | model: "session", 460 | data: { 461 | token: generateId(), 462 | createdAt: new Date(), 463 | updatedAt: new Date(), 464 | userId: user.id, 465 | expiresAt: new Date(), 466 | }, 467 | }); 468 | token = session.token; 469 | const res = await (await adapter()).findOne({ 470 | model: "session", 471 | where: [ 472 | { 473 | field: "userId", 474 | value: user.id, 475 | }, 476 | ], 477 | }); 478 | const resToken = await (await adapter()).findOne({ 479 | model: "session", 480 | where: [ 481 | { 482 | field: "token", 483 | value: token, 484 | }, 485 | ], 486 | }); 487 | expect(res).toMatchObject({ 488 | userId: user.id, 489 | }); 490 | expect(resToken).toMatchObject({ 491 | userId: user.id, 492 | }); 493 | }, 494 | ); 495 | 496 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_SORT_BY)( 497 | `${testPrefix ? `${testPrefix} - ` : ""}${ 498 | adapterTests.SHOULD_FIND_MANY_WITH_SORT_BY 499 | }`, 500 | async ({ onTestFailed }) => { 501 | await resetDebugLogs(); 502 | onTestFailed(async () => { 503 | await printDebugLogs(); 504 | }); 505 | await (await adapter()).create({ 506 | model: "user", 507 | data: { 508 | name: "a", 509 | email: getUniqueEmail("[email protected]"), 510 | emailVerified: true, 511 | createdAt: new Date(), 512 | updatedAt: new Date(), 513 | }, 514 | }); 515 | const res = await (await adapter()).findMany<User>({ 516 | model: "user", 517 | sortBy: { 518 | field: "name", 519 | direction: "asc", 520 | }, 521 | }); 522 | expect(res[0]!.name).toBe("a"); 523 | 524 | const res2 = await (await adapter()).findMany<User>({ 525 | model: "user", 526 | sortBy: { 527 | field: "name", 528 | direction: "desc", 529 | }, 530 | }); 531 | 532 | expect(res2[res2.length - 1]!.name).toBe("a"); 533 | }, 534 | ); 535 | 536 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_LIMIT)( 537 | `${testPrefix ? `${testPrefix} - ` : ""}${ 538 | adapterTests.SHOULD_FIND_MANY_WITH_LIMIT 539 | }`, 540 | async ({ onTestFailed }) => { 541 | await resetDebugLogs(); 542 | onTestFailed(async () => { 543 | await printDebugLogs(); 544 | }); 545 | const res = await (await adapter()).findMany({ 546 | model: "user", 547 | limit: 1, 548 | }); 549 | expect(res.length).toBe(1); 550 | }, 551 | ); 552 | 553 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_OFFSET)( 554 | `${testPrefix ? `${testPrefix} - ` : ""}${ 555 | adapterTests.SHOULD_FIND_MANY_WITH_OFFSET 556 | }`, 557 | async ({ onTestFailed }) => { 558 | await resetDebugLogs(); 559 | onTestFailed(async () => { 560 | await printDebugLogs(); 561 | }); 562 | const res = await (await adapter()).findMany({ 563 | model: "user", 564 | offset: 2, 565 | }); 566 | expect(res.length).toBe(5); 567 | }, 568 | ); 569 | 570 | test.skipIf(disabledTests?.SHOULD_UPDATE_WITH_MULTIPLE_WHERE)( 571 | `${testPrefix ? `${testPrefix} - ` : ""}${ 572 | adapterTests.SHOULD_UPDATE_WITH_MULTIPLE_WHERE 573 | }`, 574 | async ({ onTestFailed }) => { 575 | await resetDebugLogs(); 576 | onTestFailed(async () => { 577 | await printDebugLogs(); 578 | }); 579 | // Note: user's email was already updated in the previous test 580 | const currentEmail = getUniqueEmail("[email protected]"); 581 | await (await adapter()).updateMany({ 582 | model: "user", 583 | where: [ 584 | { 585 | field: "name", 586 | value: user.name, 587 | }, 588 | { 589 | field: "email", 590 | value: currentEmail, 591 | }, 592 | ], 593 | update: { 594 | email: getUniqueEmail("[email protected]"), 595 | }, 596 | }); 597 | const updatedUser = await (await adapter()).findOne<User>({ 598 | model: "user", 599 | where: [ 600 | { 601 | field: "email", 602 | value: getUniqueEmail("[email protected]"), 603 | }, 604 | ], 605 | }); 606 | expect(updatedUser).toMatchObject({ 607 | name: user.name, 608 | email: getUniqueEmail("[email protected]"), 609 | }); 610 | }, 611 | ); 612 | 613 | test.skipIf(disabledTests?.DELETE_MODEL)( 614 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.DELETE_MODEL}`, 615 | async ({ onTestFailed }) => { 616 | await resetDebugLogs(); 617 | onTestFailed(async () => { 618 | await printDebugLogs(); 619 | }); 620 | await (await adapter()).delete({ 621 | model: "user", 622 | where: [ 623 | { 624 | field: "id", 625 | value: user.id, 626 | }, 627 | ], 628 | }); 629 | const findRes = await (await adapter()).findOne({ 630 | model: "user", 631 | where: [ 632 | { 633 | field: "id", 634 | value: user.id, 635 | }, 636 | ], 637 | }); 638 | expect(findRes).toBeNull(); 639 | }, 640 | ); 641 | 642 | test.skipIf(disabledTests?.SHOULD_DELETE_MANY)( 643 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.SHOULD_DELETE_MANY}`, 644 | async ({ onTestFailed }) => { 645 | await resetDebugLogs(); 646 | onTestFailed(async () => { 647 | await printDebugLogs(); 648 | }); 649 | for (const i of ["to-be-delete-1", "to-be-delete-2", "to-be-delete-3"]) { 650 | await (await adapter()).create({ 651 | model: "user", 652 | data: { 653 | name: "to-be-deleted", 654 | email: getUniqueEmail(`email@test-${i}.com`), 655 | emailVerified: true, 656 | createdAt: new Date(), 657 | updatedAt: new Date(), 658 | }, 659 | }); 660 | } 661 | const findResFirst = await (await adapter()).findMany({ 662 | model: "user", 663 | where: [ 664 | { 665 | field: "name", 666 | value: "to-be-deleted", 667 | }, 668 | ], 669 | }); 670 | expect(findResFirst.length).toBe(3); 671 | await (await adapter()).deleteMany({ 672 | model: "user", 673 | where: [ 674 | { 675 | field: "name", 676 | value: "to-be-deleted", 677 | }, 678 | ], 679 | }); 680 | const findRes = await (await adapter()).findMany({ 681 | model: "user", 682 | where: [ 683 | { 684 | field: "name", 685 | value: "to-be-deleted", 686 | }, 687 | ], 688 | }); 689 | expect(findRes.length).toBe(0); 690 | }, 691 | ); 692 | 693 | test.skipIf(disabledTests?.SHOULD_NOT_THROW_ON_DELETE_RECORD_NOT_FOUND)( 694 | `${testPrefix ? `${testPrefix} - ` : ""}${ 695 | adapterTests.SHOULD_NOT_THROW_ON_DELETE_RECORD_NOT_FOUND 696 | }`, 697 | async ({ onTestFailed }) => { 698 | await resetDebugLogs(); 699 | onTestFailed(async () => { 700 | await printDebugLogs(); 701 | }); 702 | await (await adapter()).delete({ 703 | model: "user", 704 | where: [ 705 | { 706 | field: "id", 707 | value: "100000", 708 | }, 709 | ], 710 | }); 711 | }, 712 | ); 713 | 714 | test.skipIf(disabledTests?.SHOULD_NOT_THROW_ON_RECORD_NOT_FOUND)( 715 | `${testPrefix ? `${testPrefix} - ` : ""}${ 716 | adapterTests.SHOULD_NOT_THROW_ON_RECORD_NOT_FOUND 717 | }`, 718 | async ({ onTestFailed }) => { 719 | await resetDebugLogs(); 720 | onTestFailed(async () => { 721 | await printDebugLogs(); 722 | }); 723 | const res = await (await adapter()).findOne({ 724 | model: "user", 725 | where: [ 726 | { 727 | field: "id", 728 | value: "100000", 729 | }, 730 | ], 731 | }); 732 | expect(res).toBeNull(); 733 | }, 734 | ); 735 | 736 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_CONTAINS_OPERATOR)( 737 | `${testPrefix ? `${testPrefix} - ` : ""}${ 738 | adapterTests.SHOULD_FIND_MANY_WITH_CONTAINS_OPERATOR 739 | }`, 740 | async ({ onTestFailed }) => { 741 | await resetDebugLogs(); 742 | onTestFailed(async () => { 743 | await printDebugLogs(); 744 | }); 745 | const res = await (await adapter()).findMany({ 746 | model: "user", 747 | where: [ 748 | { 749 | field: "name", 750 | operator: "contains", 751 | value: "user2", 752 | }, 753 | ], 754 | }); 755 | expect(res.length).toBe(1); 756 | }, 757 | ); 758 | 759 | test.skipIf(disabledTests?.SHOULD_SEARCH_USERS_WITH_STARTS_WITH)( 760 | `${testPrefix ? `${testPrefix} - ` : ""}${ 761 | adapterTests.SHOULD_SEARCH_USERS_WITH_STARTS_WITH 762 | }`, 763 | async ({ onTestFailed }) => { 764 | await resetDebugLogs(); 765 | onTestFailed(async () => { 766 | await printDebugLogs(); 767 | }); 768 | await (await adapter()).create({ 769 | model: "user", 770 | data: { 771 | name: "user_starts", 772 | email: getUniqueEmail("[email protected]"), 773 | emailVerified: true, 774 | createdAt: new Date(), 775 | updatedAt: new Date(), 776 | }, 777 | }); 778 | await (await adapter()).create({ 779 | model: "user", 780 | data: { 781 | name: "user2_starts", 782 | email: getUniqueEmail("[email protected]"), 783 | emailVerified: true, 784 | createdAt: new Date(), 785 | updatedAt: new Date(), 786 | }, 787 | }); 788 | await (await adapter()).create({ 789 | model: "user", 790 | data: { 791 | name: "user3_starts", 792 | email: getUniqueEmail("[email protected]"), 793 | emailVerified: true, 794 | createdAt: new Date(), 795 | updatedAt: new Date(), 796 | }, 797 | }); 798 | const res = await (await adapter()).findMany({ 799 | model: "user", 800 | where: [ 801 | { 802 | field: "name", 803 | operator: "starts_with", 804 | value: "user", 805 | }, 806 | ], 807 | }); 808 | expect(res.length).toBeGreaterThanOrEqual(3); 809 | }, 810 | ); 811 | 812 | test.skipIf(disabledTests?.SHOULD_SEARCH_USERS_WITH_ENDS_WITH)( 813 | `${testPrefix ? `${testPrefix} - ` : ""}${ 814 | adapterTests.SHOULD_SEARCH_USERS_WITH_ENDS_WITH 815 | }`, 816 | async ({ onTestFailed }) => { 817 | await resetDebugLogs(); 818 | onTestFailed(async () => { 819 | await printDebugLogs(); 820 | }); 821 | // Create test user for this test with unique suffix 822 | await (await adapter()).create({ 823 | model: "user", 824 | data: { 825 | name: "tester2", 826 | email: getUniqueEmail("[email protected]"), 827 | emailVerified: true, 828 | createdAt: new Date(), 829 | updatedAt: new Date(), 830 | }, 831 | }); 832 | const res = await (await adapter()).findMany({ 833 | model: "user", 834 | where: [ 835 | { 836 | field: "name", 837 | operator: "ends_with", 838 | value: "ter2", 839 | }, 840 | ], 841 | }); 842 | expect(res.length).toBe(1); 843 | }, 844 | ); 845 | 846 | test.skipIf(disabledTests?.SHOULD_PREFER_GENERATE_ID_IF_PROVIDED)( 847 | `${testPrefix ? `${testPrefix} - ` : ""}${ 848 | adapterTests.SHOULD_PREFER_GENERATE_ID_IF_PROVIDED 849 | }`, 850 | async ({ onTestFailed }) => { 851 | await resetDebugLogs(); 852 | onTestFailed(async () => { 853 | await printDebugLogs(); 854 | }); 855 | const customAdapter = await getAdapter( 856 | Object.assign( 857 | { 858 | advanced: { 859 | database: { 860 | generateId: () => "mocked-id", 861 | }, 862 | }, 863 | } satisfies BetterAuthOptions, 864 | internalOptions?.predefinedOptions, 865 | ), 866 | ); 867 | 868 | const res = await customAdapter.create({ 869 | model: "user", 870 | data: { 871 | name: "user4", 872 | email: getUniqueEmail("[email protected]"), 873 | emailVerified: true, 874 | createdAt: new Date(), 875 | updatedAt: new Date(), 876 | }, 877 | }); 878 | 879 | expect(res.id).toBe("mocked-id"); 880 | }, 881 | ); 882 | 883 | test.skipIf(disabledTests?.SHOULD_ROLLBACK_FAILING_TRANSACTION)( 884 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.SHOULD_ROLLBACK_FAILING_TRANSACTION}`, 885 | async ({ onTestFailed, skip }) => { 886 | await resetDebugLogs(); 887 | onTestFailed(async () => { 888 | await printDebugLogs(); 889 | }); 890 | const customAdapter = await adapter(); 891 | 892 | // Check if adapter actually supports transactions 893 | const enableTransaction = 894 | customAdapter?.options?.adapterConfig.transaction; 895 | if (!enableTransaction) { 896 | skip( 897 | `Skipping test: ${ 898 | customAdapter?.options?.adapterConfig.adapterName || "Adapter" 899 | } 900 | does not support transactions`, 901 | ); 902 | return; 903 | } 904 | 905 | const user5 = { 906 | name: "user5", 907 | email: getUniqueEmail("[email protected]"), 908 | emailVerified: true, 909 | createdAt: new Date(), 910 | updatedAt: new Date(), 911 | }; 912 | const user6 = { 913 | name: "user6", 914 | email: getUniqueEmail("[email protected]"), 915 | emailVerified: true, 916 | createdAt: new Date(), 917 | updatedAt: new Date(), 918 | }; 919 | await expect( 920 | customAdapter.transaction(async (tx) => { 921 | await tx.create({ model: "user", data: user5 }); 922 | throw new Error("Simulated failure"); 923 | await tx.create({ model: "user", data: user6 }); 924 | }), 925 | ).rejects.toThrow("Simulated failure"); 926 | 927 | await expect( 928 | customAdapter.findMany({ 929 | model: "user", 930 | where: [ 931 | { 932 | field: "email", 933 | value: user5.email, 934 | connector: "OR", 935 | }, 936 | { 937 | field: "email", 938 | value: user6.email, 939 | connector: "OR", 940 | }, 941 | ], 942 | }), 943 | ).resolves.toEqual([]); 944 | }, 945 | ); 946 | 947 | test.skipIf(disabledTests?.SHOULD_RETURN_TRANSACTION_RESULT)( 948 | `${testPrefix ? `${testPrefix} - ` : ""}${adapterTests.SHOULD_RETURN_TRANSACTION_RESULT}`, 949 | async ({ onTestFailed, skip }) => { 950 | await resetDebugLogs(); 951 | onTestFailed(async () => { 952 | await printDebugLogs(); 953 | }); 954 | const customAdapter = await adapter(); 955 | 956 | const enableTransaction = 957 | customAdapter?.options?.adapterConfig.transaction; 958 | if (!enableTransaction) { 959 | skip( 960 | `Skipping test: ${ 961 | customAdapter?.options?.adapterConfig.adapterName || "Adapter" 962 | } 963 | does not support transactions`, 964 | ); 965 | return; 966 | } 967 | 968 | const result = await customAdapter.transaction(async (tx) => { 969 | const createdUser = await tx.create<User>({ 970 | model: "user", 971 | data: { 972 | name: "user6", 973 | email: getUniqueEmail("[email protected]"), 974 | emailVerified: true, 975 | createdAt: new Date(), 976 | updatedAt: new Date(), 977 | }, 978 | }); 979 | 980 | return createdUser.email; 981 | }); 982 | 983 | expect(result).toEqual(getUniqueEmail("[email protected]")); 984 | }, 985 | ); 986 | 987 | test.skipIf(disabledTests?.SHOULD_FIND_MANY_WITH_CONNECTORS)( 988 | `${testPrefix ? `${testPrefix} - ` : ""}${ 989 | adapterTests.SHOULD_FIND_MANY_WITH_CONNECTORS 990 | }`, 991 | async ({ onTestFailed }) => { 992 | await resetDebugLogs(); 993 | onTestFailed(async () => { 994 | await printDebugLogs(); 995 | }); 996 | 997 | await (await adapter()).create({ 998 | model: "user", 999 | data: { 1000 | name: "connector-user1", 1001 | email: getUniqueEmail("[email protected]"), 1002 | emailVerified: true, 1003 | createdAt: new Date(), 1004 | updatedAt: new Date(), 1005 | }, 1006 | }); 1007 | await (await adapter()).create({ 1008 | model: "user", 1009 | data: { 1010 | name: "con-user2", 1011 | email: getUniqueEmail("[email protected]"), 1012 | emailVerified: true, 1013 | createdAt: new Date(), 1014 | updatedAt: new Date(), 1015 | }, 1016 | }); 1017 | 1018 | const andRes = await (await adapter()).findMany({ 1019 | model: "user", 1020 | where: [ 1021 | { 1022 | field: "name", 1023 | value: "con-user2", 1024 | connector: "AND", 1025 | }, 1026 | { 1027 | field: "email", 1028 | value: getUniqueEmail("[email protected]"), 1029 | connector: "AND", 1030 | }, 1031 | ], 1032 | }); 1033 | 1034 | expect(andRes.length).toBe(1); 1035 | 1036 | const orRes = await (await adapter()).findMany({ 1037 | model: "user", 1038 | where: [ 1039 | { 1040 | field: "name", 1041 | value: "connector-user1", 1042 | connector: "OR", 1043 | }, 1044 | { 1045 | field: "name", 1046 | value: "con-user2", 1047 | connector: "OR", 1048 | }, 1049 | ], 1050 | }); 1051 | expect(orRes.length).toBe(2); 1052 | }, 1053 | ); 1054 | } 1055 | 1056 | export function runAdapterTest(opts: AdapterTestOptions) { 1057 | return adapterTest(opts); 1058 | } 1059 | 1060 | export function runNumberIdAdapterTest(opts: NumberIdAdapterTestOptions) { 1061 | const cleanup: { modelName: string; id: string }[] = []; 1062 | 1063 | // Generate unique test identifier for this test run to avoid conflicts 1064 | const testRunId = 1065 | Date.now().toString(36) + Math.random().toString(36).substr(2, 5); 1066 | const getUniqueEmail = (base: string) => `${testRunId}_${base}`; 1067 | 1068 | const adapter = async () => 1069 | await opts.getAdapter({ 1070 | advanced: { 1071 | database: { 1072 | useNumberId: true, 1073 | }, 1074 | }, 1075 | }); 1076 | describe("Should run number id specific tests", async () => { 1077 | let idNumber = -1; 1078 | 1079 | async function resetDebugLogs() { 1080 | //@ts-expect-error 1081 | (await adapter())?.adapterTestDebugLogs?.resetDebugLogs(); 1082 | } 1083 | 1084 | async function printDebugLogs() { 1085 | //@ts-expect-error 1086 | (await adapter())?.adapterTestDebugLogs?.printDebugLogs(); 1087 | } 1088 | test.skipIf(opts.disableTests?.SHOULD_RETURN_A_NUMBER_ID_AS_A_RESULT)( 1089 | `${opts.testPrefix ? `${opts.testPrefix} - ` : ""}${ 1090 | numberIdAdapterTests.SHOULD_RETURN_A_NUMBER_ID_AS_A_RESULT 1091 | }`, 1092 | async ({ onTestFailed }) => { 1093 | await resetDebugLogs(); 1094 | onTestFailed(async () => { 1095 | await printDebugLogs(); 1096 | }); 1097 | const res = await (await adapter()).create({ 1098 | model: "user", 1099 | data: { 1100 | name: "user", 1101 | email: getUniqueEmail("[email protected]"), 1102 | }, 1103 | }); 1104 | cleanup.push({ modelName: "user", id: res.id }); 1105 | expect(typeof res.id).toBe("string"); // we forcefully return all `id`s as strings. this is intentional. 1106 | expect(parseInt(res.id)).toBeGreaterThan(0); 1107 | idNumber = parseInt(res.id); 1108 | }, 1109 | ); 1110 | test.skipIf(opts.disableTests?.SHOULD_INCREMENT_THE_ID_BY_1)( 1111 | `${opts.testPrefix ? `${opts.testPrefix} - ` : ""}${ 1112 | numberIdAdapterTests.SHOULD_INCREMENT_THE_ID_BY_1 1113 | }`, 1114 | async ({ onTestFailed }) => { 1115 | await resetDebugLogs(); 1116 | onTestFailed(async () => { 1117 | console.log(`ID number from last create: ${idNumber}`); 1118 | await printDebugLogs(); 1119 | }); 1120 | const res = await (await adapter()).create({ 1121 | model: "user", 1122 | data: { 1123 | name: "user2", 1124 | email: getUniqueEmail("[email protected]"), 1125 | }, 1126 | }); 1127 | cleanup.push({ modelName: "user", id: res.id }); 1128 | expect(parseInt(res.id)).toBe(idNumber + 1); 1129 | }, 1130 | ); 1131 | }); 1132 | 1133 | describe("Should run normal adapter tests with number id enabled", async () => { 1134 | beforeAll(async () => { 1135 | for (const { modelName, id } of cleanup) { 1136 | await (await adapter()).delete({ 1137 | model: modelName, 1138 | where: [{ field: "id", value: id }], 1139 | }); 1140 | } 1141 | }); 1142 | await adapterTest( 1143 | { 1144 | ...opts, 1145 | disableTests: { 1146 | ...opts.disableTests, 1147 | SHOULD_PREFER_GENERATE_ID_IF_PROVIDED: true, 1148 | }, 1149 | }, 1150 | { 1151 | predefinedOptions: { 1152 | advanced: { 1153 | database: { 1154 | useNumberId: true, 1155 | }, 1156 | }, 1157 | }, 1158 | }, 1159 | ); 1160 | }); 1161 | } 1162 | 1163 | export function recoverProcessTZ() { 1164 | const originalTZ = process.env.TZ; 1165 | return { 1166 | [Symbol.dispose]: () => { 1167 | process.env.TZ = originalTZ; 1168 | }, 1169 | }; 1170 | } 1171 | ``` -------------------------------------------------------------------------------- /demo/nextjs/app/dashboard/user-card.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Card, 7 | CardContent, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; 13 | import { Checkbox } from "@/components/ui/checkbox"; 14 | import { 15 | Dialog, 16 | DialogContent, 17 | DialogDescription, 18 | DialogFooter, 19 | DialogHeader, 20 | DialogTitle, 21 | DialogTrigger, 22 | } from "@/components/ui/dialog"; 23 | import { Input } from "@/components/ui/input"; 24 | import { Label } from "@/components/ui/label"; 25 | import { PasswordInput } from "@/components/ui/password-input"; 26 | import { client, signOut, useSession } from "@/lib/auth-client"; 27 | import { Session } from "@/lib/auth-types"; 28 | import { MobileIcon } from "@radix-ui/react-icons"; 29 | import { 30 | Edit, 31 | Fingerprint, 32 | Laptop, 33 | Loader2, 34 | LogOut, 35 | Plus, 36 | QrCode, 37 | ShieldCheck, 38 | ShieldOff, 39 | StopCircle, 40 | Trash, 41 | X, 42 | } from "lucide-react"; 43 | import Image from "next/image"; 44 | import { useRouter } from "next/navigation"; 45 | import { useState, useTransition } from "react"; 46 | import { toast } from "sonner"; 47 | import { UAParser } from "ua-parser-js"; 48 | import { 49 | Table, 50 | TableBody, 51 | TableCell, 52 | TableHead, 53 | TableHeader, 54 | TableRow, 55 | } from "@/components/ui/table"; 56 | import QRCode from "react-qr-code"; 57 | import CopyButton from "@/components/ui/copy-button"; 58 | import { Badge } from "@/components/ui/badge"; 59 | import { useQuery } from "@tanstack/react-query"; 60 | import { SubscriptionTierLabel } from "@/components/tier-labels"; 61 | import { Component } from "./change-plan"; 62 | import { Subscription } from "@better-auth/stripe"; 63 | 64 | export default function UserCard(props: { 65 | session: Session | null; 66 | activeSessions: Session["session"][]; 67 | subscription?: Subscription; 68 | }) { 69 | const router = useRouter(); 70 | const { data, isPending } = useSession(); 71 | const session = data || props.session; 72 | const [isTerminating, setIsTerminating] = useState<string>(); 73 | const [isPendingTwoFa, setIsPendingTwoFa] = useState<boolean>(false); 74 | const [twoFaPassword, setTwoFaPassword] = useState<string>(""); 75 | const [twoFactorDialog, setTwoFactorDialog] = useState<boolean>(false); 76 | const [twoFactorVerifyURI, setTwoFactorVerifyURI] = useState<string>(""); 77 | const [isSignOut, setIsSignOut] = useState<boolean>(false); 78 | const [emailVerificationPending, setEmailVerificationPending] = 79 | useState<boolean>(false); 80 | const [activeSessions, setActiveSessions] = useState(props.activeSessions); 81 | const removeActiveSession = (id: string) => 82 | setActiveSessions(activeSessions.filter((session) => session.id !== id)); 83 | const { data: subscription } = useQuery({ 84 | queryKey: ["subscriptions"], 85 | initialData: props.subscription ? props.subscription : null, 86 | queryFn: async () => { 87 | const res = await client.subscription.list({ 88 | fetchOptions: { 89 | throw: true, 90 | }, 91 | }); 92 | return res.length ? res[0] : null; 93 | }, 94 | }); 95 | 96 | return ( 97 | <Card> 98 | <CardHeader> 99 | <CardTitle>User</CardTitle> 100 | </CardHeader> 101 | <CardContent className="grid gap-8 grid-cols-1"> 102 | <div className="flex flex-col gap-2"> 103 | <div className="flex items-start justify-between"> 104 | <div className="flex items-center gap-4"> 105 | <Avatar className="hidden h-9 w-9 sm:flex "> 106 | <AvatarImage 107 | src={session?.user.image || undefined} 108 | alt="Avatar" 109 | className="object-cover" 110 | /> 111 | <AvatarFallback>{session?.user.name.charAt(0)}</AvatarFallback> 112 | </Avatar> 113 | <div className="grid"> 114 | <div className="flex items-center gap-1"> 115 | <p className="text-sm font-medium leading-none"> 116 | {session?.user.name} 117 | </p> 118 | {!!subscription && ( 119 | <Badge 120 | className="w-min p-px rounded-full" 121 | variant="outline" 122 | > 123 | <svg 124 | xmlns="http://www.w3.org/2000/svg" 125 | width="1.2em" 126 | height="1.2em" 127 | viewBox="0 0 24 24" 128 | > 129 | <path 130 | fill="currentColor" 131 | d="m9.023 21.23l-1.67-2.814l-3.176-.685l.312-3.277L2.346 12L4.49 9.546L4.177 6.27l3.177-.685L9.023 2.77L12 4.027l2.977-1.258l1.67 2.816l3.176.684l-.312 3.277L21.655 12l-2.142 2.454l.311 3.277l-3.177.684l-1.669 2.816L12 19.973zm1.927-6.372L15.908 9.9l-.708-.72l-4.25 4.25l-2.15-2.138l-.708.708z" 132 | ></path> 133 | </svg> 134 | </Badge> 135 | )} 136 | </div> 137 | <p className="text-sm">{session?.user.email}</p> 138 | </div> 139 | </div> 140 | <EditUserDialog /> 141 | </div> 142 | <div className="flex items-center justify-between"> 143 | <div> 144 | <SubscriptionTierLabel 145 | tier={subscription?.plan?.toLowerCase() as "plus"} 146 | /> 147 | </div> 148 | <Component 149 | currentPlan={subscription?.plan?.toLowerCase() as "plus"} 150 | isTrial={subscription?.status === "trialing"} 151 | /> 152 | </div> 153 | </div> 154 | 155 | {session?.user.emailVerified ? null : ( 156 | <Alert> 157 | <AlertTitle>Verify Your Email Address</AlertTitle> 158 | <AlertDescription className="text-muted-foreground"> 159 | Please verify your email address. Check your inbox for the 160 | verification email. If you haven't received the email, click the 161 | button below to resend. 162 | <Button 163 | size="sm" 164 | variant="secondary" 165 | className="mt-2" 166 | onClick={async () => { 167 | await client.sendVerificationEmail( 168 | { 169 | email: session?.user.email || "", 170 | }, 171 | { 172 | onRequest(context) { 173 | setEmailVerificationPending(true); 174 | }, 175 | onError(context) { 176 | toast.error(context.error.message); 177 | setEmailVerificationPending(false); 178 | }, 179 | onSuccess() { 180 | toast.success("Verification email sent successfully"); 181 | setEmailVerificationPending(false); 182 | }, 183 | }, 184 | ); 185 | }} 186 | > 187 | {emailVerificationPending ? ( 188 | <Loader2 size={15} className="animate-spin" /> 189 | ) : ( 190 | "Resend Verification Email" 191 | )} 192 | </Button> 193 | </AlertDescription> 194 | </Alert> 195 | )} 196 | 197 | <div className="border-l-2 px-2 w-max gap-1 flex flex-col"> 198 | <p className="text-xs font-medium ">Active Sessions</p> 199 | {activeSessions 200 | .filter((session) => session.userAgent) 201 | .map((session) => { 202 | return ( 203 | <div key={session.id}> 204 | <div className="flex items-center gap-2 text-sm text-black font-medium dark:text-white"> 205 | {new UAParser(session.userAgent || "").getDevice().type === 206 | "mobile" ? ( 207 | <MobileIcon /> 208 | ) : ( 209 | <Laptop size={16} /> 210 | )} 211 | {new UAParser(session.userAgent || "").getOS().name || 212 | session.userAgent} 213 | , {new UAParser(session.userAgent || "").getBrowser().name} 214 | <button 215 | className="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline " 216 | onClick={async () => { 217 | setIsTerminating(session.id); 218 | const res = await client.revokeSession({ 219 | token: session.token, 220 | }); 221 | 222 | if (res.error) { 223 | toast.error(res.error.message); 224 | } else { 225 | toast.success("Session terminated successfully"); 226 | removeActiveSession(session.id); 227 | } 228 | if (session.id === props.session?.session.id) 229 | router.refresh(); 230 | setIsTerminating(undefined); 231 | }} 232 | > 233 | {isTerminating === session.id ? ( 234 | <Loader2 size={15} className="animate-spin" /> 235 | ) : session.id === props.session?.session.id ? ( 236 | "Sign Out" 237 | ) : ( 238 | "Terminate" 239 | )} 240 | </button> 241 | </div> 242 | </div> 243 | ); 244 | })} 245 | </div> 246 | <div className="border-y py-4 flex items-center flex-wrap justify-between gap-2"> 247 | <div className="flex flex-col gap-2"> 248 | <p className="text-sm">Passkeys</p> 249 | <div className="flex gap-2 flex-wrap"> 250 | <AddPasskey /> 251 | <ListPasskeys /> 252 | </div> 253 | </div> 254 | <div className="flex flex-col gap-2"> 255 | <p className="text-sm">Two Factor</p> 256 | <div className="flex gap-2"> 257 | {!!session?.user.twoFactorEnabled && ( 258 | <Dialog> 259 | <DialogTrigger asChild> 260 | <Button variant="outline" className="gap-2"> 261 | <QrCode size={16} /> 262 | <span className="md:text-sm text-xs">Scan QR Code</span> 263 | </Button> 264 | </DialogTrigger> 265 | <DialogContent className="sm:max-w-[425px] w-11/12"> 266 | <DialogHeader> 267 | <DialogTitle>Scan QR Code</DialogTitle> 268 | <DialogDescription> 269 | Scan the QR code with your TOTP app 270 | </DialogDescription> 271 | </DialogHeader> 272 | 273 | {twoFactorVerifyURI ? ( 274 | <> 275 | <div className="flex items-center justify-center"> 276 | <QRCode value={twoFactorVerifyURI} /> 277 | </div> 278 | <div className="flex gap-2 items-center justify-center"> 279 | <p className="text-sm text-muted-foreground"> 280 | Copy URI to clipboard 281 | </p> 282 | <CopyButton textToCopy={twoFactorVerifyURI} /> 283 | </div> 284 | </> 285 | ) : ( 286 | <div className="flex flex-col gap-2"> 287 | <PasswordInput 288 | value={twoFaPassword} 289 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 290 | setTwoFaPassword(e.target.value) 291 | } 292 | placeholder="Enter Password" 293 | /> 294 | <Button 295 | onClick={async () => { 296 | if (twoFaPassword.length < 8) { 297 | toast.error( 298 | "Password must be at least 8 characters", 299 | ); 300 | return; 301 | } 302 | await client.twoFactor.getTotpUri( 303 | { 304 | password: twoFaPassword, 305 | }, 306 | { 307 | onSuccess(context) { 308 | setTwoFactorVerifyURI(context.data.totpURI); 309 | }, 310 | }, 311 | ); 312 | setTwoFaPassword(""); 313 | }} 314 | > 315 | Show QR Code 316 | </Button> 317 | </div> 318 | )} 319 | </DialogContent> 320 | </Dialog> 321 | )} 322 | <Dialog open={twoFactorDialog} onOpenChange={setTwoFactorDialog}> 323 | <DialogTrigger asChild> 324 | <Button 325 | variant={ 326 | session?.user.twoFactorEnabled ? "destructive" : "outline" 327 | } 328 | className="gap-2" 329 | > 330 | {session?.user.twoFactorEnabled ? ( 331 | <ShieldOff size={16} /> 332 | ) : ( 333 | <ShieldCheck size={16} /> 334 | )} 335 | <span className="md:text-sm text-xs"> 336 | {session?.user.twoFactorEnabled 337 | ? "Disable 2FA" 338 | : "Enable 2FA"} 339 | </span> 340 | </Button> 341 | </DialogTrigger> 342 | <DialogContent className="sm:max-w-[425px] w-11/12"> 343 | <DialogHeader> 344 | <DialogTitle> 345 | {session?.user.twoFactorEnabled 346 | ? "Disable 2FA" 347 | : "Enable 2FA"} 348 | </DialogTitle> 349 | <DialogDescription> 350 | {session?.user.twoFactorEnabled 351 | ? "Disable the second factor authentication from your account" 352 | : "Enable 2FA to secure your account"} 353 | </DialogDescription> 354 | </DialogHeader> 355 | 356 | {twoFactorVerifyURI ? ( 357 | <div className="flex flex-col gap-2"> 358 | <div className="flex items-center justify-center"> 359 | <QRCode value={twoFactorVerifyURI} /> 360 | </div> 361 | <Label htmlFor="password"> 362 | Scan the QR code with your TOTP app 363 | </Label> 364 | <Input 365 | value={twoFaPassword} 366 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 367 | setTwoFaPassword(e.target.value) 368 | } 369 | placeholder="Enter OTP" 370 | /> 371 | </div> 372 | ) : ( 373 | <div className="flex flex-col gap-2"> 374 | <Label htmlFor="password">Password</Label> 375 | <PasswordInput 376 | id="password" 377 | placeholder="Password" 378 | value={twoFaPassword} 379 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 380 | setTwoFaPassword(e.target.value) 381 | } 382 | /> 383 | </div> 384 | )} 385 | <DialogFooter> 386 | <Button 387 | disabled={isPendingTwoFa} 388 | onClick={async () => { 389 | if (twoFaPassword.length < 8 && !twoFactorVerifyURI) { 390 | toast.error("Password must be at least 8 characters"); 391 | return; 392 | } 393 | setIsPendingTwoFa(true); 394 | if (session?.user.twoFactorEnabled) { 395 | const res = await client.twoFactor.disable({ 396 | password: twoFaPassword, 397 | fetchOptions: { 398 | onError(context) { 399 | toast.error(context.error.message); 400 | }, 401 | onSuccess() { 402 | toast("2FA disabled successfully"); 403 | setTwoFactorDialog(false); 404 | }, 405 | }, 406 | }); 407 | } else { 408 | if (twoFactorVerifyURI) { 409 | await client.twoFactor.verifyTotp({ 410 | code: twoFaPassword, 411 | fetchOptions: { 412 | onError(context) { 413 | setIsPendingTwoFa(false); 414 | setTwoFaPassword(""); 415 | toast.error(context.error.message); 416 | }, 417 | onSuccess() { 418 | toast("2FA enabled successfully"); 419 | setTwoFactorVerifyURI(""); 420 | setIsPendingTwoFa(false); 421 | setTwoFaPassword(""); 422 | setTwoFactorDialog(false); 423 | }, 424 | }, 425 | }); 426 | return; 427 | } 428 | const res = await client.twoFactor.enable({ 429 | password: twoFaPassword, 430 | fetchOptions: { 431 | onError(context) { 432 | toast.error(context.error.message); 433 | }, 434 | onSuccess(ctx) { 435 | setTwoFactorVerifyURI(ctx.data.totpURI); 436 | // toast.success("2FA enabled successfully"); 437 | // setTwoFactorDialog(false); 438 | }, 439 | }, 440 | }); 441 | } 442 | setIsPendingTwoFa(false); 443 | setTwoFaPassword(""); 444 | }} 445 | > 446 | {isPendingTwoFa ? ( 447 | <Loader2 size={15} className="animate-spin" /> 448 | ) : session?.user.twoFactorEnabled ? ( 449 | "Disable 2FA" 450 | ) : ( 451 | "Enable 2FA" 452 | )} 453 | </Button> 454 | </DialogFooter> 455 | </DialogContent> 456 | </Dialog> 457 | </div> 458 | </div> 459 | </div> 460 | </CardContent> 461 | <CardFooter className="gap-2 justify-between items-center"> 462 | <ChangePassword /> 463 | {session?.session.impersonatedBy ? ( 464 | <Button 465 | className="gap-2 z-10" 466 | variant="secondary" 467 | onClick={async () => { 468 | setIsSignOut(true); 469 | await client.admin.stopImpersonating(); 470 | setIsSignOut(false); 471 | toast.info("Impersonation stopped successfully"); 472 | router.push("/admin"); 473 | }} 474 | disabled={isSignOut} 475 | > 476 | <span className="text-sm"> 477 | {isSignOut ? ( 478 | <Loader2 size={15} className="animate-spin" /> 479 | ) : ( 480 | <div className="flex items-center gap-2"> 481 | <StopCircle size={16} color="red" /> 482 | Stop Impersonation 483 | </div> 484 | )} 485 | </span> 486 | </Button> 487 | ) : ( 488 | <Button 489 | className="gap-2 z-10" 490 | variant="secondary" 491 | onClick={async () => { 492 | setIsSignOut(true); 493 | await signOut({ 494 | fetchOptions: { 495 | onSuccess() { 496 | router.push("/"); 497 | }, 498 | }, 499 | }); 500 | setIsSignOut(false); 501 | }} 502 | disabled={isSignOut} 503 | > 504 | <span className="text-sm"> 505 | {isSignOut ? ( 506 | <Loader2 size={15} className="animate-spin" /> 507 | ) : ( 508 | <div className="flex items-center gap-2"> 509 | <LogOut size={16} /> 510 | Sign Out 511 | </div> 512 | )} 513 | </span> 514 | </Button> 515 | )} 516 | </CardFooter> 517 | </Card> 518 | ); 519 | } 520 | 521 | async function convertImageToBase64(file: File): Promise<string> { 522 | return new Promise((resolve, reject) => { 523 | const reader = new FileReader(); 524 | reader.onloadend = () => resolve(reader.result as string); 525 | reader.onerror = reject; 526 | reader.readAsDataURL(file); 527 | }); 528 | } 529 | 530 | function ChangePassword() { 531 | const [currentPassword, setCurrentPassword] = useState<string>(""); 532 | const [newPassword, setNewPassword] = useState<string>(""); 533 | const [confirmPassword, setConfirmPassword] = useState<string>(""); 534 | const [loading, setLoading] = useState<boolean>(false); 535 | const [open, setOpen] = useState<boolean>(false); 536 | const [signOutDevices, setSignOutDevices] = useState<boolean>(false); 537 | return ( 538 | <Dialog open={open} onOpenChange={setOpen}> 539 | <DialogTrigger asChild> 540 | <Button className="gap-2 z-10" variant="outline" size="sm"> 541 | <svg 542 | xmlns="http://www.w3.org/2000/svg" 543 | width="1em" 544 | height="1em" 545 | viewBox="0 0 24 24" 546 | > 547 | <path 548 | fill="currentColor" 549 | d="M2.5 18.5v-1h19v1zm.535-5.973l-.762-.442l.965-1.693h-1.93v-.884h1.93l-.965-1.642l.762-.443L4 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L4 10.835zm8 0l-.762-.442l.966-1.693H9.308v-.884h1.93l-.965-1.642l.762-.443L12 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L12 10.835zm8 0l-.762-.442l.966-1.693h-1.931v-.884h1.93l-.965-1.642l.762-.443L20 9.066l.966-1.643l.761.443l-.965 1.642h1.93v.884h-1.93l.965 1.693l-.762.442L20 10.835z" 550 | ></path> 551 | </svg> 552 | <span className="text-sm text-muted-foreground">Change Password</span> 553 | </Button> 554 | </DialogTrigger> 555 | <DialogContent className="sm:max-w-[425px] w-11/12"> 556 | <DialogHeader> 557 | <DialogTitle>Change Password</DialogTitle> 558 | <DialogDescription>Change your password</DialogDescription> 559 | </DialogHeader> 560 | <div className="grid gap-2"> 561 | <Label htmlFor="current-password">Current Password</Label> 562 | <PasswordInput 563 | id="current-password" 564 | value={currentPassword} 565 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 566 | setCurrentPassword(e.target.value) 567 | } 568 | autoComplete="new-password" 569 | placeholder="Password" 570 | /> 571 | <Label htmlFor="new-password">New Password</Label> 572 | <PasswordInput 573 | value={newPassword} 574 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 575 | setNewPassword(e.target.value) 576 | } 577 | autoComplete="new-password" 578 | placeholder="New Password" 579 | /> 580 | <Label htmlFor="password">Confirm Password</Label> 581 | <PasswordInput 582 | value={confirmPassword} 583 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 584 | setConfirmPassword(e.target.value) 585 | } 586 | autoComplete="new-password" 587 | placeholder="Confirm Password" 588 | /> 589 | <div className="flex gap-2 items-center"> 590 | <Checkbox 591 | onCheckedChange={(checked) => 592 | checked ? setSignOutDevices(true) : setSignOutDevices(false) 593 | } 594 | /> 595 | <p className="text-sm">Sign out from other devices</p> 596 | </div> 597 | </div> 598 | <DialogFooter> 599 | <Button 600 | onClick={async () => { 601 | if (newPassword !== confirmPassword) { 602 | toast.error("Passwords do not match"); 603 | return; 604 | } 605 | if (newPassword.length < 8) { 606 | toast.error("Password must be at least 8 characters"); 607 | return; 608 | } 609 | setLoading(true); 610 | const res = await client.changePassword({ 611 | newPassword: newPassword, 612 | currentPassword: currentPassword, 613 | revokeOtherSessions: signOutDevices, 614 | }); 615 | setLoading(false); 616 | if (res.error) { 617 | toast.error( 618 | res.error.message || 619 | "Couldn't change your password! Make sure it's correct", 620 | ); 621 | } else { 622 | setOpen(false); 623 | toast.success("Password changed successfully"); 624 | setCurrentPassword(""); 625 | setNewPassword(""); 626 | setConfirmPassword(""); 627 | } 628 | }} 629 | > 630 | {loading ? ( 631 | <Loader2 size={15} className="animate-spin" /> 632 | ) : ( 633 | "Change Password" 634 | )} 635 | </Button> 636 | </DialogFooter> 637 | </DialogContent> 638 | </Dialog> 639 | ); 640 | } 641 | 642 | function EditUserDialog() { 643 | const { data, isPending, error } = useSession(); 644 | const [name, setName] = useState<string>(); 645 | const router = useRouter(); 646 | const [image, setImage] = useState<File | null>(null); 647 | const [imagePreview, setImagePreview] = useState<string | null>(null); 648 | const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { 649 | const file = e.target.files?.[0]; 650 | if (file) { 651 | setImage(file); 652 | const reader = new FileReader(); 653 | reader.onloadend = () => { 654 | setImagePreview(reader.result as string); 655 | }; 656 | reader.readAsDataURL(file); 657 | } 658 | }; 659 | const [open, setOpen] = useState<boolean>(false); 660 | const [isLoading, startTransition] = useTransition(); 661 | return ( 662 | <Dialog open={open} onOpenChange={setOpen}> 663 | <DialogTrigger asChild> 664 | <Button size="sm" className="gap-2" variant="secondary"> 665 | <Edit size={13} /> 666 | Edit User 667 | </Button> 668 | </DialogTrigger> 669 | <DialogContent className="sm:max-w-[425px] w-11/12"> 670 | <DialogHeader> 671 | <DialogTitle>Edit User</DialogTitle> 672 | <DialogDescription>Edit user information</DialogDescription> 673 | </DialogHeader> 674 | <div className="grid gap-2"> 675 | <Label htmlFor="name">Full Name</Label> 676 | <Input 677 | id="name" 678 | type="name" 679 | placeholder={data?.user.name} 680 | required 681 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => { 682 | setName(e.target.value); 683 | }} 684 | /> 685 | <div className="grid gap-2"> 686 | <Label htmlFor="image">Profile Image</Label> 687 | <div className="flex items-end gap-4"> 688 | {imagePreview && ( 689 | <div className="relative w-16 h-16 rounded-sm overflow-hidden"> 690 | <Image 691 | src={imagePreview} 692 | alt="Profile preview" 693 | layout="fill" 694 | objectFit="cover" 695 | /> 696 | </div> 697 | )} 698 | <div className="flex items-center gap-2 w-full"> 699 | <Input 700 | id="image" 701 | type="file" 702 | accept="image/*" 703 | onChange={handleImageChange} 704 | className="w-full text-muted-foreground" 705 | /> 706 | {imagePreview && ( 707 | <X 708 | className="cursor-pointer" 709 | onClick={() => { 710 | setImage(null); 711 | setImagePreview(null); 712 | }} 713 | /> 714 | )} 715 | </div> 716 | </div> 717 | </div> 718 | </div> 719 | <DialogFooter> 720 | <Button 721 | disabled={isLoading} 722 | onClick={async () => { 723 | startTransition(async () => { 724 | await client.updateUser({ 725 | image: image ? await convertImageToBase64(image) : undefined, 726 | name: name ? name : undefined, 727 | fetchOptions: { 728 | onSuccess: () => { 729 | toast.success("User updated successfully"); 730 | }, 731 | onError: (error) => { 732 | toast.error(error.error.message); 733 | }, 734 | }, 735 | }); 736 | startTransition(() => { 737 | setName(""); 738 | router.refresh(); 739 | setImage(null); 740 | setImagePreview(null); 741 | setOpen(false); 742 | }); 743 | }); 744 | }} 745 | > 746 | {isLoading ? ( 747 | <Loader2 size={15} className="animate-spin" /> 748 | ) : ( 749 | "Update" 750 | )} 751 | </Button> 752 | </DialogFooter> 753 | </DialogContent> 754 | </Dialog> 755 | ); 756 | } 757 | 758 | function AddPasskey() { 759 | const [isOpen, setIsOpen] = useState(false); 760 | const [passkeyName, setPasskeyName] = useState(""); 761 | const [isLoading, setIsLoading] = useState(false); 762 | 763 | const handleAddPasskey = async () => { 764 | if (!passkeyName) { 765 | toast.error("Passkey name is required"); 766 | return; 767 | } 768 | setIsLoading(true); 769 | const res = await client.passkey.addPasskey({ 770 | name: passkeyName, 771 | }); 772 | if (res?.error) { 773 | toast.error(res?.error.message); 774 | } else { 775 | setIsOpen(false); 776 | toast.success("Passkey added successfully. You can now use it to login."); 777 | } 778 | setIsLoading(false); 779 | }; 780 | return ( 781 | <Dialog open={isOpen} onOpenChange={setIsOpen}> 782 | <DialogTrigger asChild> 783 | <Button variant="outline" className="gap-2 text-xs md:text-sm"> 784 | <Plus size={15} /> 785 | Add New Passkey 786 | </Button> 787 | </DialogTrigger> 788 | <DialogContent className="sm:max-w-[425px] w-11/12"> 789 | <DialogHeader> 790 | <DialogTitle>Add New Passkey</DialogTitle> 791 | <DialogDescription> 792 | Create a new passkey to securely access your account without a 793 | password. 794 | </DialogDescription> 795 | </DialogHeader> 796 | <div className="grid gap-2"> 797 | <Label htmlFor="passkey-name">Passkey Name</Label> 798 | <Input 799 | id="passkey-name" 800 | value={passkeyName} 801 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 802 | setPasskeyName(e.target.value) 803 | } 804 | /> 805 | </div> 806 | <DialogFooter> 807 | <Button 808 | disabled={isLoading} 809 | type="submit" 810 | onClick={handleAddPasskey} 811 | className="w-full" 812 | > 813 | {isLoading ? ( 814 | <Loader2 size={15} className="animate-spin" /> 815 | ) : ( 816 | <> 817 | <Fingerprint className="mr-2 h-4 w-4" /> 818 | Create Passkey 819 | </> 820 | )} 821 | </Button> 822 | </DialogFooter> 823 | </DialogContent> 824 | </Dialog> 825 | ); 826 | } 827 | 828 | function ListPasskeys() { 829 | const { data } = client.useListPasskeys(); 830 | const [isOpen, setIsOpen] = useState(false); 831 | const [passkeyName, setPasskeyName] = useState(""); 832 | 833 | const handleAddPasskey = async () => { 834 | if (!passkeyName) { 835 | toast.error("Passkey name is required"); 836 | return; 837 | } 838 | setIsLoading(true); 839 | const res = await client.passkey.addPasskey({ 840 | name: passkeyName, 841 | }); 842 | setIsLoading(false); 843 | if (res?.error) { 844 | toast.error(res?.error.message); 845 | } else { 846 | toast.success("Passkey added successfully. You can now use it to login."); 847 | } 848 | }; 849 | const [isLoading, setIsLoading] = useState(false); 850 | const [isDeletePasskey, setIsDeletePasskey] = useState<boolean>(false); 851 | return ( 852 | <Dialog open={isOpen} onOpenChange={setIsOpen}> 853 | <DialogTrigger asChild> 854 | <Button variant="outline" className="text-xs md:text-sm"> 855 | <Fingerprint className="mr-2 h-4 w-4" /> 856 | <span>Passkeys {data?.length ? `[${data?.length}]` : ""}</span> 857 | </Button> 858 | </DialogTrigger> 859 | <DialogContent className="sm:max-w-[425px] w-11/12"> 860 | <DialogHeader> 861 | <DialogTitle>Passkeys</DialogTitle> 862 | <DialogDescription>List of passkeys</DialogDescription> 863 | </DialogHeader> 864 | {data?.length ? ( 865 | <Table> 866 | <TableHeader> 867 | <TableRow> 868 | <TableHead>Name</TableHead> 869 | </TableRow> 870 | </TableHeader> 871 | <TableBody> 872 | {data.map((passkey) => ( 873 | <TableRow 874 | key={passkey.id} 875 | className="flex justify-between items-center" 876 | > 877 | <TableCell>{passkey.name || "My Passkey"}</TableCell> 878 | <TableCell className="text-right"> 879 | <button 880 | onClick={async () => { 881 | const res = await client.passkey.deletePasskey({ 882 | id: passkey.id, 883 | fetchOptions: { 884 | onRequest: () => { 885 | setIsDeletePasskey(true); 886 | }, 887 | onSuccess: () => { 888 | toast("Passkey deleted successfully"); 889 | setIsDeletePasskey(false); 890 | }, 891 | onError: (error) => { 892 | toast.error(error.error.message); 893 | setIsDeletePasskey(false); 894 | }, 895 | }, 896 | }); 897 | }} 898 | > 899 | {isDeletePasskey ? ( 900 | <Loader2 size={15} className="animate-spin" /> 901 | ) : ( 902 | <Trash 903 | size={15} 904 | className="cursor-pointer text-red-600" 905 | /> 906 | )} 907 | </button> 908 | </TableCell> 909 | </TableRow> 910 | ))} 911 | </TableBody> 912 | </Table> 913 | ) : ( 914 | <p className="text-sm text-muted-foreground">No passkeys found</p> 915 | )} 916 | {!data?.length && ( 917 | <div className="flex flex-col gap-2"> 918 | <div className="flex flex-col gap-2"> 919 | <Label htmlFor="passkey-name" className="text-sm"> 920 | New Passkey 921 | </Label> 922 | <Input 923 | id="passkey-name" 924 | value={passkeyName} 925 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => 926 | setPasskeyName(e.target.value) 927 | } 928 | placeholder="My Passkey" 929 | /> 930 | </div> 931 | <Button type="submit" onClick={handleAddPasskey} className="w-full"> 932 | {isLoading ? ( 933 | <Loader2 size={15} className="animate-spin" /> 934 | ) : ( 935 | <> 936 | <Fingerprint className="mr-2 h-4 w-4" /> 937 | Create Passkey 938 | </> 939 | )} 940 | </Button> 941 | </div> 942 | )} 943 | <DialogFooter> 944 | <Button onClick={() => setIsOpen(false)}>Close</Button> 945 | </DialogFooter> 946 | </DialogContent> 947 | </Dialog> 948 | ); 949 | } 950 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/mcp/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { 3 | createAuthEndpoint, 4 | createAuthMiddleware, 5 | } from "@better-auth/core/api"; 6 | import type { BetterAuthPlugin, BetterAuthOptions } from "@better-auth/core"; 7 | import { 8 | oidcProvider, 9 | type Client, 10 | type CodeVerificationValue, 11 | type OAuthAccessToken, 12 | type OIDCMetadata, 13 | type OIDCOptions, 14 | } from "../oidc-provider"; 15 | import { APIError, getSessionFromCtx } from "../../api"; 16 | import { base64 } from "@better-auth/utils/base64"; 17 | import { generateRandomString } from "../../crypto"; 18 | import { createHash } from "@better-auth/utils/hash"; 19 | import { getWebcryptoSubtle } from "@better-auth/utils"; 20 | import { SignJWT } from "jose"; 21 | import { parseSetCookieHeader } from "../../cookies"; 22 | import { schema } from "../oidc-provider/schema"; 23 | import { authorizeMCPOAuth } from "./authorize"; 24 | import { getBaseURL } from "../../utils/url"; 25 | import { isProduction } from "@better-auth/core/env"; 26 | import { logger } from "@better-auth/core/env"; 27 | import type { GenericEndpointContext } from "@better-auth/core"; 28 | 29 | interface MCPOptions { 30 | loginPage: string; 31 | resource?: string; 32 | oidcConfig?: OIDCOptions; 33 | } 34 | 35 | export const getMCPProviderMetadata = ( 36 | ctx: GenericEndpointContext, 37 | options?: OIDCOptions, 38 | ): OIDCMetadata => { 39 | const issuer = ctx.context.options.baseURL as string; 40 | const baseURL = ctx.context.baseURL; 41 | if (!issuer || !baseURL) { 42 | throw new APIError("INTERNAL_SERVER_ERROR", { 43 | error: "invalid_issuer", 44 | error_description: 45 | "issuer or baseURL is not set. If you're the app developer, please make sure to set the `baseURL` in your auth config.", 46 | }); 47 | } 48 | return { 49 | issuer, 50 | authorization_endpoint: `${baseURL}/mcp/authorize`, 51 | token_endpoint: `${baseURL}/mcp/token`, 52 | userinfo_endpoint: `${baseURL}/mcp/userinfo`, 53 | jwks_uri: `${baseURL}/mcp/jwks`, 54 | registration_endpoint: `${baseURL}/mcp/register`, 55 | scopes_supported: ["openid", "profile", "email", "offline_access"], 56 | response_types_supported: ["code"], 57 | response_modes_supported: ["query"], 58 | grant_types_supported: ["authorization_code", "refresh_token"], 59 | acr_values_supported: [ 60 | "urn:mace:incommon:iap:silver", 61 | "urn:mace:incommon:iap:bronze", 62 | ], 63 | subject_types_supported: ["public"], 64 | id_token_signing_alg_values_supported: ["RS256", "none"], 65 | token_endpoint_auth_methods_supported: [ 66 | "client_secret_basic", 67 | "client_secret_post", 68 | "none", 69 | ], 70 | code_challenge_methods_supported: ["S256"], 71 | claims_supported: [ 72 | "sub", 73 | "iss", 74 | "aud", 75 | "exp", 76 | "nbf", 77 | "iat", 78 | "jti", 79 | "email", 80 | "email_verified", 81 | "name", 82 | ], 83 | ...options?.metadata, 84 | }; 85 | }; 86 | 87 | export const getMCPProtectedResourceMetadata = ( 88 | ctx: GenericEndpointContext, 89 | options?: MCPOptions, 90 | ) => { 91 | const baseURL = ctx.context.baseURL; 92 | 93 | return { 94 | resource: options?.resource ?? new URL(baseURL).origin, 95 | authorization_servers: [baseURL], 96 | jwks_uri: options?.oidcConfig?.metadata?.jwks_uri ?? `${baseURL}/mcp/jwks`, 97 | scopes_supported: options?.oidcConfig?.metadata?.scopes_supported ?? [ 98 | "openid", 99 | "profile", 100 | "email", 101 | "offline_access", 102 | ], 103 | bearer_methods_supported: ["header"], 104 | resource_signing_alg_values_supported: ["RS256", "none"], 105 | }; 106 | }; 107 | 108 | export const mcp = (options: MCPOptions) => { 109 | const opts = { 110 | codeExpiresIn: 600, 111 | defaultScope: "openid", 112 | accessTokenExpiresIn: 3600, 113 | refreshTokenExpiresIn: 604800, 114 | allowPlainCodeChallengeMethod: true, 115 | ...options.oidcConfig, 116 | loginPage: options.loginPage, 117 | scopes: [ 118 | "openid", 119 | "profile", 120 | "email", 121 | "offline_access", 122 | ...(options.oidcConfig?.scopes || []), 123 | ], 124 | }; 125 | const modelName = { 126 | oauthClient: "oauthApplication", 127 | oauthAccessToken: "oauthAccessToken", 128 | oauthConsent: "oauthConsent", 129 | }; 130 | const provider = oidcProvider(opts); 131 | return { 132 | id: "mcp", 133 | hooks: { 134 | after: [ 135 | { 136 | matcher() { 137 | return true; 138 | }, 139 | handler: createAuthMiddleware(async (ctx) => { 140 | const cookie = await ctx.getSignedCookie( 141 | "oidc_login_prompt", 142 | ctx.context.secret, 143 | ); 144 | const cookieName = ctx.context.authCookies.sessionToken.name; 145 | const parsedSetCookieHeader = parseSetCookieHeader( 146 | ctx.context.responseHeaders?.get("set-cookie") || "", 147 | ); 148 | const hasSessionToken = parsedSetCookieHeader.has(cookieName); 149 | if (!cookie || !hasSessionToken) { 150 | return; 151 | } 152 | ctx.setCookie("oidc_login_prompt", "", { 153 | maxAge: 0, 154 | }); 155 | const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value; 156 | const sessionToken = sessionCookie?.split(".")[0]!; 157 | if (!sessionToken) { 158 | return; 159 | } 160 | const session = 161 | await ctx.context.internalAdapter.findSession(sessionToken); 162 | if (!session) { 163 | return; 164 | } 165 | ctx.query = JSON.parse(cookie); 166 | ctx.query!.prompt = "consent"; 167 | ctx.context.session = session; 168 | const response = await authorizeMCPOAuth(ctx, opts); 169 | return response; 170 | }), 171 | }, 172 | ], 173 | }, 174 | endpoints: { 175 | getMcpOAuthConfig: createAuthEndpoint( 176 | "/.well-known/oauth-authorization-server", 177 | { 178 | method: "GET", 179 | metadata: { 180 | client: false, 181 | }, 182 | }, 183 | async (c) => { 184 | try { 185 | const metadata = getMCPProviderMetadata(c, options); 186 | return c.json(metadata); 187 | } catch (e) { 188 | console.log(e); 189 | return c.json(null); 190 | } 191 | }, 192 | ), 193 | getMCPProtectedResource: createAuthEndpoint( 194 | "/.well-known/oauth-protected-resource", 195 | { 196 | method: "GET", 197 | metadata: { 198 | client: false, 199 | }, 200 | }, 201 | async (c) => { 202 | const metadata = getMCPProtectedResourceMetadata(c, options); 203 | return c.json(metadata); 204 | }, 205 | ), 206 | mcpOAuthAuthorize: createAuthEndpoint( 207 | "/mcp/authorize", 208 | { 209 | method: "GET", 210 | query: z.record(z.string(), z.any()), 211 | metadata: { 212 | openapi: { 213 | description: "Authorize an OAuth2 request using MCP", 214 | responses: { 215 | "200": { 216 | description: "Authorization response generated successfully", 217 | content: { 218 | "application/json": { 219 | schema: { 220 | type: "object", 221 | additionalProperties: true, 222 | description: 223 | "Authorization response, contents depend on the authorize function implementation", 224 | }, 225 | }, 226 | }, 227 | }, 228 | }, 229 | }, 230 | }, 231 | }, 232 | async (ctx) => { 233 | return authorizeMCPOAuth(ctx, opts); 234 | }, 235 | ), 236 | mcpOAuthToken: createAuthEndpoint( 237 | "/mcp/token", 238 | { 239 | method: "POST", 240 | body: z.record(z.any(), z.any()), 241 | metadata: { 242 | isAction: false, 243 | }, 244 | }, 245 | async (ctx) => { 246 | //cors 247 | ctx.setHeader("Access-Control-Allow-Origin", "*"); 248 | ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); 249 | ctx.setHeader( 250 | "Access-Control-Allow-Headers", 251 | "Content-Type, Authorization", 252 | ); 253 | ctx.setHeader("Access-Control-Max-Age", "86400"); 254 | 255 | let { body } = ctx; 256 | if (!body) { 257 | throw ctx.error("BAD_REQUEST", { 258 | error_description: "request body not found", 259 | error: "invalid_request", 260 | }); 261 | } 262 | if (body instanceof FormData) { 263 | body = Object.fromEntries(body.entries()); 264 | } 265 | if (!(body instanceof Object)) { 266 | throw new APIError("BAD_REQUEST", { 267 | error_description: "request body is not an object", 268 | error: "invalid_request", 269 | }); 270 | } 271 | let { client_id, client_secret } = body; 272 | const authorization = 273 | ctx.request?.headers.get("authorization") || null; 274 | if ( 275 | authorization && 276 | !client_id && 277 | !client_secret && 278 | authorization.startsWith("Basic ") 279 | ) { 280 | try { 281 | const encoded = authorization.replace("Basic ", ""); 282 | const decoded = new TextDecoder().decode(base64.decode(encoded)); 283 | if (!decoded.includes(":")) { 284 | throw new APIError("UNAUTHORIZED", { 285 | error_description: "invalid authorization header format", 286 | error: "invalid_client", 287 | }); 288 | } 289 | const [id, secret] = decoded.split(":"); 290 | if (!id || !secret) { 291 | throw new APIError("UNAUTHORIZED", { 292 | error_description: "invalid authorization header format", 293 | error: "invalid_client", 294 | }); 295 | } 296 | client_id = id; 297 | client_secret = secret; 298 | } catch (error) { 299 | throw new APIError("UNAUTHORIZED", { 300 | error_description: "invalid authorization header format", 301 | error: "invalid_client", 302 | }); 303 | } 304 | } 305 | const { 306 | grant_type, 307 | code, 308 | redirect_uri, 309 | refresh_token, 310 | code_verifier, 311 | } = body; 312 | if (grant_type === "refresh_token") { 313 | if (!refresh_token) { 314 | throw new APIError("BAD_REQUEST", { 315 | error_description: "refresh_token is required", 316 | error: "invalid_request", 317 | }); 318 | } 319 | const token = await ctx.context.adapter.findOne<OAuthAccessToken>({ 320 | model: "oauthAccessToken", 321 | where: [ 322 | { 323 | field: "refreshToken", 324 | value: refresh_token.toString(), 325 | }, 326 | ], 327 | }); 328 | if (!token) { 329 | throw new APIError("UNAUTHORIZED", { 330 | error_description: "invalid refresh token", 331 | error: "invalid_grant", 332 | }); 333 | } 334 | if (token.clientId !== client_id?.toString()) { 335 | throw new APIError("UNAUTHORIZED", { 336 | error_description: "invalid client_id", 337 | error: "invalid_client", 338 | }); 339 | } 340 | if (token.refreshTokenExpiresAt < new Date()) { 341 | throw new APIError("UNAUTHORIZED", { 342 | error_description: "refresh token expired", 343 | error: "invalid_grant", 344 | }); 345 | } 346 | const accessToken = generateRandomString(32, "a-z", "A-Z"); 347 | const newRefreshToken = generateRandomString(32, "a-z", "A-Z"); 348 | const accessTokenExpiresAt = new Date( 349 | Date.now() + opts.accessTokenExpiresIn * 1000, 350 | ); 351 | const refreshTokenExpiresAt = new Date( 352 | Date.now() + opts.refreshTokenExpiresIn * 1000, 353 | ); 354 | await ctx.context.adapter.create({ 355 | model: modelName.oauthAccessToken, 356 | data: { 357 | accessToken, 358 | refreshToken: newRefreshToken, 359 | accessTokenExpiresAt, 360 | refreshTokenExpiresAt, 361 | clientId: client_id.toString(), 362 | userId: token.userId, 363 | scopes: token.scopes, 364 | createdAt: new Date(), 365 | updatedAt: new Date(), 366 | }, 367 | }); 368 | return ctx.json({ 369 | access_token: accessToken, 370 | token_type: "bearer", 371 | expires_in: opts.accessTokenExpiresIn, 372 | refresh_token: newRefreshToken, 373 | scope: token.scopes, 374 | }); 375 | } 376 | 377 | if (!code) { 378 | throw new APIError("BAD_REQUEST", { 379 | error_description: "code is required", 380 | error: "invalid_request", 381 | }); 382 | } 383 | 384 | if (opts.requirePKCE && !code_verifier) { 385 | throw new APIError("BAD_REQUEST", { 386 | error_description: "code verifier is missing", 387 | error: "invalid_request", 388 | }); 389 | } 390 | 391 | /** 392 | * We need to check if the code is valid before we can proceed 393 | * with the rest of the request. 394 | */ 395 | const verificationValue = 396 | await ctx.context.internalAdapter.findVerificationValue( 397 | code.toString(), 398 | ); 399 | if (!verificationValue) { 400 | throw new APIError("UNAUTHORIZED", { 401 | error_description: "invalid code", 402 | error: "invalid_grant", 403 | }); 404 | } 405 | if (verificationValue.expiresAt < new Date()) { 406 | throw new APIError("UNAUTHORIZED", { 407 | error_description: "code expired", 408 | error: "invalid_grant", 409 | }); 410 | } 411 | 412 | await ctx.context.internalAdapter.deleteVerificationValue( 413 | verificationValue.id, 414 | ); 415 | 416 | if (!client_id) { 417 | throw new APIError("UNAUTHORIZED", { 418 | error_description: "client_id is required", 419 | error: "invalid_client", 420 | }); 421 | } 422 | if (!grant_type) { 423 | throw new APIError("BAD_REQUEST", { 424 | error_description: "grant_type is required", 425 | error: "invalid_request", 426 | }); 427 | } 428 | if (grant_type !== "authorization_code") { 429 | throw new APIError("BAD_REQUEST", { 430 | error_description: "grant_type must be 'authorization_code'", 431 | error: "unsupported_grant_type", 432 | }); 433 | } 434 | 435 | if (!redirect_uri) { 436 | throw new APIError("BAD_REQUEST", { 437 | error_description: "redirect_uri is required", 438 | error: "invalid_request", 439 | }); 440 | } 441 | 442 | const client = await ctx.context.adapter 443 | .findOne<Record<string, any>>({ 444 | model: modelName.oauthClient, 445 | where: [{ field: "clientId", value: client_id.toString() }], 446 | }) 447 | .then((res) => { 448 | if (!res) { 449 | return null; 450 | } 451 | return { 452 | ...res, 453 | redirectURLs: res.redirectURLs.split(","), 454 | metadata: res.metadata ? JSON.parse(res.metadata) : {}, 455 | } as Client; 456 | }); 457 | if (!client) { 458 | throw new APIError("UNAUTHORIZED", { 459 | error_description: "invalid client_id", 460 | error: "invalid_client", 461 | }); 462 | } 463 | if (client.disabled) { 464 | throw new APIError("UNAUTHORIZED", { 465 | error_description: "client is disabled", 466 | error: "invalid_client", 467 | }); 468 | } 469 | // For public clients (type: 'public'), validate PKCE instead of client_secret 470 | if (client.type === "public") { 471 | // Public clients must use PKCE 472 | if (!code_verifier) { 473 | throw new APIError("BAD_REQUEST", { 474 | error_description: 475 | "code verifier is required for public clients", 476 | error: "invalid_request", 477 | }); 478 | } 479 | // PKCE validation happens later in the flow, so we skip client_secret validation 480 | } else { 481 | // For confidential clients, validate client_secret 482 | if (!client_secret) { 483 | throw new APIError("UNAUTHORIZED", { 484 | error_description: 485 | "client_secret is required for confidential clients", 486 | error: "invalid_client", 487 | }); 488 | } 489 | const isValidSecret = 490 | client.clientSecret === client_secret.toString(); 491 | if (!isValidSecret) { 492 | throw new APIError("UNAUTHORIZED", { 493 | error_description: "invalid client_secret", 494 | error: "invalid_client", 495 | }); 496 | } 497 | } 498 | const value = JSON.parse( 499 | verificationValue.value, 500 | ) as CodeVerificationValue; 501 | if (value.clientId !== client_id.toString()) { 502 | throw new APIError("UNAUTHORIZED", { 503 | error_description: "invalid client_id", 504 | error: "invalid_client", 505 | }); 506 | } 507 | if (value.redirectURI !== redirect_uri.toString()) { 508 | throw new APIError("UNAUTHORIZED", { 509 | error_description: "invalid redirect_uri", 510 | error: "invalid_client", 511 | }); 512 | } 513 | if (value.codeChallenge && !code_verifier) { 514 | throw new APIError("BAD_REQUEST", { 515 | error_description: "code verifier is missing", 516 | error: "invalid_request", 517 | }); 518 | } 519 | 520 | const challenge = 521 | value.codeChallengeMethod === "plain" 522 | ? code_verifier 523 | : await createHash("SHA-256", "base64urlnopad").digest( 524 | code_verifier, 525 | ); 526 | 527 | if (challenge !== value.codeChallenge) { 528 | throw new APIError("UNAUTHORIZED", { 529 | error_description: "code verification failed", 530 | error: "invalid_request", 531 | }); 532 | } 533 | 534 | const requestedScopes = value.scope; 535 | await ctx.context.internalAdapter.deleteVerificationValue( 536 | verificationValue.id, 537 | ); 538 | const accessToken = generateRandomString(32, "a-z", "A-Z"); 539 | const refreshToken = generateRandomString(32, "A-Z", "a-z"); 540 | const accessTokenExpiresAt = new Date( 541 | Date.now() + opts.accessTokenExpiresIn * 1000, 542 | ); 543 | const refreshTokenExpiresAt = new Date( 544 | Date.now() + opts.refreshTokenExpiresIn * 1000, 545 | ); 546 | await ctx.context.adapter.create({ 547 | model: modelName.oauthAccessToken, 548 | data: { 549 | accessToken, 550 | refreshToken, 551 | accessTokenExpiresAt, 552 | refreshTokenExpiresAt, 553 | clientId: client_id.toString(), 554 | userId: value.userId, 555 | scopes: requestedScopes.join(" "), 556 | createdAt: new Date(), 557 | updatedAt: new Date(), 558 | }, 559 | }); 560 | const user = await ctx.context.internalAdapter.findUserById( 561 | value.userId, 562 | ); 563 | if (!user) { 564 | throw new APIError("UNAUTHORIZED", { 565 | error_description: "user not found", 566 | error: "invalid_grant", 567 | }); 568 | } 569 | let secretKey = { 570 | alg: "HS256", 571 | key: await getWebcryptoSubtle().generateKey( 572 | { 573 | name: "HMAC", 574 | hash: "SHA-256", 575 | }, 576 | true, 577 | ["sign", "verify"], 578 | ), 579 | }; 580 | const profile = { 581 | given_name: user.name.split(" ")[0]!, 582 | family_name: user.name.split(" ")[1]!, 583 | name: user.name, 584 | profile: user.image, 585 | updated_at: user.updatedAt.toISOString(), 586 | }; 587 | const email = { 588 | email: user.email, 589 | email_verified: user.emailVerified, 590 | }; 591 | const userClaims = { 592 | ...(requestedScopes.includes("profile") ? profile : {}), 593 | ...(requestedScopes.includes("email") ? email : {}), 594 | }; 595 | 596 | const additionalUserClaims = opts.getAdditionalUserInfoClaim 597 | ? await opts.getAdditionalUserInfoClaim( 598 | user, 599 | requestedScopes, 600 | client, 601 | ) 602 | : {}; 603 | 604 | const idToken = await new SignJWT({ 605 | sub: user.id, 606 | aud: client_id.toString(), 607 | iat: Date.now(), 608 | auth_time: ctx.context.session 609 | ? new Date(ctx.context.session.session.createdAt).getTime() 610 | : undefined, 611 | nonce: value.nonce, 612 | acr: "urn:mace:incommon:iap:silver", // default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata 613 | ...userClaims, 614 | ...additionalUserClaims, 615 | }) 616 | .setProtectedHeader({ alg: secretKey.alg }) 617 | .setIssuedAt() 618 | .setExpirationTime( 619 | Math.floor(Date.now() / 1000) + opts.accessTokenExpiresIn, 620 | ) 621 | .sign(secretKey.key); 622 | return ctx.json( 623 | { 624 | access_token: accessToken, 625 | token_type: "Bearer", 626 | expires_in: opts.accessTokenExpiresIn, 627 | refresh_token: requestedScopes.includes("offline_access") 628 | ? refreshToken 629 | : undefined, 630 | scope: requestedScopes.join(" "), 631 | id_token: requestedScopes.includes("openid") 632 | ? idToken 633 | : undefined, 634 | }, 635 | { 636 | headers: { 637 | "Cache-Control": "no-store", 638 | Pragma: "no-cache", 639 | }, 640 | }, 641 | ); 642 | }, 643 | ), 644 | registerMcpClient: createAuthEndpoint( 645 | "/mcp/register", 646 | { 647 | method: "POST", 648 | body: z.object({ 649 | redirect_uris: z.array(z.string()), 650 | token_endpoint_auth_method: z 651 | .enum(["none", "client_secret_basic", "client_secret_post"]) 652 | .default("client_secret_basic") 653 | .optional(), 654 | grant_types: z 655 | .array( 656 | z.enum([ 657 | "authorization_code", 658 | "implicit", 659 | "password", 660 | "client_credentials", 661 | "refresh_token", 662 | "urn:ietf:params:oauth:grant-type:jwt-bearer", 663 | "urn:ietf:params:oauth:grant-type:saml2-bearer", 664 | ]), 665 | ) 666 | .default(["authorization_code"]) 667 | .optional(), 668 | response_types: z 669 | .array(z.enum(["code", "token"])) 670 | .default(["code"]) 671 | .optional(), 672 | client_name: z.string().optional(), 673 | client_uri: z.string().optional(), 674 | logo_uri: z.string().optional(), 675 | scope: z.string().optional(), 676 | contacts: z.array(z.string()).optional(), 677 | tos_uri: z.string().optional(), 678 | policy_uri: z.string().optional(), 679 | jwks_uri: z.string().optional(), 680 | jwks: z.record(z.string(), z.any()).optional(), 681 | metadata: z.record(z.any(), z.any()).optional(), 682 | software_id: z.string().optional(), 683 | software_version: z.string().optional(), 684 | software_statement: z.string().optional(), 685 | }), 686 | metadata: { 687 | openapi: { 688 | description: "Register an OAuth2 application", 689 | responses: { 690 | "200": { 691 | description: "OAuth2 application registered successfully", 692 | content: { 693 | "application/json": { 694 | schema: { 695 | type: "object", 696 | properties: { 697 | name: { 698 | type: "string", 699 | description: "Name of the OAuth2 application", 700 | }, 701 | icon: { 702 | type: "string", 703 | nullable: true, 704 | description: "Icon URL for the application", 705 | }, 706 | metadata: { 707 | type: "object", 708 | additionalProperties: true, 709 | nullable: true, 710 | description: 711 | "Additional metadata for the application", 712 | }, 713 | clientId: { 714 | type: "string", 715 | description: "Unique identifier for the client", 716 | }, 717 | clientSecret: { 718 | type: "string", 719 | description: 720 | "Secret key for the client. Not included for public clients.", 721 | }, 722 | redirectURLs: { 723 | type: "array", 724 | items: { type: "string", format: "uri" }, 725 | description: "List of allowed redirect URLs", 726 | }, 727 | type: { 728 | type: "string", 729 | description: "Type of the client", 730 | enum: ["web", "public"], 731 | }, 732 | authenticationScheme: { 733 | type: "string", 734 | description: 735 | "Authentication scheme used by the client", 736 | enum: ["client_secret", "none"], 737 | }, 738 | disabled: { 739 | type: "boolean", 740 | description: "Whether the client is disabled", 741 | enum: [false], 742 | }, 743 | userId: { 744 | type: "string", 745 | nullable: true, 746 | description: 747 | "ID of the user who registered the client, null if registered anonymously", 748 | }, 749 | createdAt: { 750 | type: "string", 751 | format: "date-time", 752 | description: "Creation timestamp", 753 | }, 754 | updatedAt: { 755 | type: "string", 756 | format: "date-time", 757 | description: "Last update timestamp", 758 | }, 759 | }, 760 | required: [ 761 | "name", 762 | "clientId", 763 | "redirectURLs", 764 | "type", 765 | "authenticationScheme", 766 | "disabled", 767 | "createdAt", 768 | "updatedAt", 769 | ], 770 | }, 771 | }, 772 | }, 773 | }, 774 | }, 775 | }, 776 | }, 777 | }, 778 | async (ctx) => { 779 | const body = ctx.body; 780 | const session = await getSessionFromCtx(ctx); 781 | ctx.setHeader("Access-Control-Allow-Origin", "*"); 782 | ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); 783 | ctx.setHeader( 784 | "Access-Control-Allow-Headers", 785 | "Content-Type, Authorization", 786 | ); 787 | ctx.setHeader("Access-Control-Max-Age", "86400"); 788 | ctx.headers?.set("Access-Control-Max-Age", "86400"); 789 | if ( 790 | (!body.grant_types || 791 | body.grant_types.includes("authorization_code") || 792 | body.grant_types.includes("implicit")) && 793 | (!body.redirect_uris || body.redirect_uris.length === 0) 794 | ) { 795 | throw new APIError("BAD_REQUEST", { 796 | error: "invalid_redirect_uri", 797 | error_description: 798 | "Redirect URIs are required for authorization_code and implicit grant types", 799 | }); 800 | } 801 | 802 | if (body.grant_types && body.response_types) { 803 | if ( 804 | body.grant_types.includes("authorization_code") && 805 | !body.response_types.includes("code") 806 | ) { 807 | throw new APIError("BAD_REQUEST", { 808 | error: "invalid_client_metadata", 809 | error_description: 810 | "When 'authorization_code' grant type is used, 'code' response type must be included", 811 | }); 812 | } 813 | if ( 814 | body.grant_types.includes("implicit") && 815 | !body.response_types.includes("token") 816 | ) { 817 | throw new APIError("BAD_REQUEST", { 818 | error: "invalid_client_metadata", 819 | error_description: 820 | "When 'implicit' grant type is used, 'token' response type must be included", 821 | }); 822 | } 823 | } 824 | 825 | const clientId = 826 | opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z"); 827 | const clientSecret = 828 | opts.generateClientSecret?.() || 829 | generateRandomString(32, "a-z", "A-Z"); 830 | 831 | // Determine client type based on auth method 832 | const clientType = 833 | body.token_endpoint_auth_method === "none" ? "public" : "web"; 834 | const finalClientSecret = clientType === "public" ? "" : clientSecret; 835 | 836 | await ctx.context.adapter.create({ 837 | model: modelName.oauthClient, 838 | data: { 839 | name: body.client_name, 840 | icon: body.logo_uri, 841 | metadata: body.metadata ? JSON.stringify(body.metadata) : null, 842 | clientId: clientId, 843 | clientSecret: finalClientSecret, 844 | redirectURLs: body.redirect_uris.join(","), 845 | type: clientType, 846 | authenticationScheme: 847 | body.token_endpoint_auth_method || "client_secret_basic", 848 | disabled: false, 849 | userId: session?.session.userId, 850 | createdAt: new Date(), 851 | updatedAt: new Date(), 852 | }, 853 | }); 854 | 855 | const responseData = { 856 | client_id: clientId, 857 | client_id_issued_at: Math.floor(Date.now() / 1000), 858 | redirect_uris: body.redirect_uris, 859 | token_endpoint_auth_method: 860 | body.token_endpoint_auth_method || "client_secret_basic", 861 | grant_types: body.grant_types || ["authorization_code"], 862 | response_types: body.response_types || ["code"], 863 | client_name: body.client_name, 864 | client_uri: body.client_uri, 865 | logo_uri: body.logo_uri, 866 | scope: body.scope, 867 | contacts: body.contacts, 868 | tos_uri: body.tos_uri, 869 | policy_uri: body.policy_uri, 870 | jwks_uri: body.jwks_uri, 871 | jwks: body.jwks, 872 | software_id: body.software_id, 873 | software_version: body.software_version, 874 | software_statement: body.software_statement, 875 | metadata: body.metadata, 876 | ...(clientType !== "public" 877 | ? { 878 | client_secret: finalClientSecret, 879 | client_secret_expires_at: 0, // 0 means it doesn't expire 880 | } 881 | : {}), 882 | }; 883 | 884 | return new Response(JSON.stringify(responseData), { 885 | status: 201, 886 | headers: { 887 | "Content-Type": "application/json", 888 | "Cache-Control": "no-store", 889 | Pragma: "no-cache", 890 | }, 891 | }); 892 | }, 893 | ), 894 | getMcpSession: createAuthEndpoint( 895 | "/mcp/get-session", 896 | { 897 | method: "GET", 898 | requireHeaders: true, 899 | }, 900 | async (c) => { 901 | const accessToken = c.headers 902 | ?.get("Authorization") 903 | ?.replace("Bearer ", ""); 904 | if (!accessToken) { 905 | c.headers?.set("WWW-Authenticate", "Bearer"); 906 | return c.json(null); 907 | } 908 | const accessTokenData = 909 | await c.context.adapter.findOne<OAuthAccessToken>({ 910 | model: modelName.oauthAccessToken, 911 | where: [ 912 | { 913 | field: "accessToken", 914 | value: accessToken, 915 | }, 916 | ], 917 | }); 918 | if (!accessTokenData) { 919 | return c.json(null); 920 | } 921 | return c.json(accessTokenData); 922 | }, 923 | ), 924 | }, 925 | schema, 926 | } satisfies BetterAuthPlugin; 927 | }; 928 | 929 | export const withMcpAuth = < 930 | Auth extends { 931 | api: { 932 | getMcpSession: (...args: any) => Promise<OAuthAccessToken | null>; 933 | }; 934 | options: BetterAuthOptions; 935 | }, 936 | >( 937 | auth: Auth, 938 | handler: ( 939 | req: Request, 940 | sesssion: OAuthAccessToken, 941 | ) => Response | Promise<Response>, 942 | ) => { 943 | return async (req: Request) => { 944 | const baseURL = getBaseURL(auth.options.baseURL, auth.options.basePath); 945 | if (!baseURL && !isProduction) { 946 | logger.warn("Unable to get the baseURL, please check your config!"); 947 | } 948 | const session = await auth.api.getMcpSession({ 949 | headers: req.headers, 950 | }); 951 | const wwwAuthenticateValue = `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"`; 952 | if (!session) { 953 | return Response.json( 954 | { 955 | jsonrpc: "2.0", 956 | error: { 957 | code: -32000, 958 | message: "Unauthorized: Authentication required", 959 | "www-authenticate": wwwAuthenticateValue, 960 | }, 961 | id: null, 962 | }, 963 | { 964 | status: 401, 965 | headers: { 966 | "WWW-Authenticate": wwwAuthenticateValue, 967 | // we also add this headers otherwise browser based clients will not be able to read the `www-authenticate` header 968 | "Access-Control-Expose-Headers": "WWW-Authenticate", 969 | }, 970 | }, 971 | ); 972 | } 973 | return handler(req, session); 974 | }; 975 | }; 976 | 977 | export const oAuthDiscoveryMetadata = < 978 | Auth extends { 979 | api: { 980 | getMcpOAuthConfig: (...args: any) => any; 981 | }; 982 | }, 983 | >( 984 | auth: Auth, 985 | ) => { 986 | return async (request: Request) => { 987 | const res = await auth.api.getMcpOAuthConfig(); 988 | return new Response(JSON.stringify(res), { 989 | status: 200, 990 | headers: { 991 | "Content-Type": "application/json", 992 | "Access-Control-Allow-Origin": "*", 993 | "Access-Control-Allow-Methods": "POST, OPTIONS", 994 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 995 | "Access-Control-Max-Age": "86400", 996 | }, 997 | }); 998 | }; 999 | }; 1000 | 1001 | export const oAuthProtectedResourceMetadata = < 1002 | Auth extends { 1003 | api: { 1004 | getMCPProtectedResource: (...args: any) => any; 1005 | }; 1006 | }, 1007 | >( 1008 | auth: Auth, 1009 | ) => { 1010 | return async (request: Request) => { 1011 | const res = await auth.api.getMCPProtectedResource(); 1012 | return new Response(JSON.stringify(res), { 1013 | status: 200, 1014 | headers: { 1015 | "Content-Type": "application/json", 1016 | "Access-Control-Allow-Origin": "*", 1017 | "Access-Control-Allow-Methods": "POST, OPTIONS", 1018 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 1019 | "Access-Control-Max-Age": "86400", 1020 | }, 1021 | }); 1022 | }; 1023 | }; 1024 | ```