This is page 20 of 69. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-declaration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.base.json ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /docs/app/api/og-release/route.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { ImageResponse } from "@vercel/og"; 2 | import { z } from "zod"; 3 | export const runtime = "edge"; 4 | 5 | const ogSchema = z.object({ 6 | heading: z.string(), 7 | description: z.string().optional(), 8 | date: z.string().optional(), 9 | }); 10 | 11 | export async function GET(req: Request) { 12 | try { 13 | const geist = await fetch( 14 | new URL("../../../assets/Geist.ttf", import.meta.url), 15 | ).then((res) => res.arrayBuffer()); 16 | 17 | const url = new URL(req.url); 18 | const urlParamsValues = Object.fromEntries(url.searchParams); 19 | const validParams = ogSchema.parse(urlParamsValues); 20 | 21 | const { heading, description, date } = validParams; 22 | const trueHeading = 23 | heading.length > 140 ? `${heading.substring(0, 140)}...` : heading; 24 | 25 | return new ImageResponse( 26 | <div 27 | tw="flex w-full h-full relative flex-col" 28 | style={{ 29 | background: 30 | "radial-gradient(circle 230px at 0% 0%, #000000, #000000)", 31 | fontFamily: "Geist", 32 | color: "white", 33 | }} 34 | > 35 | <div 36 | tw="flex w-full h-full relative" 37 | style={{ 38 | borderRadius: "10px", 39 | border: "1px solid rgba(32, 34, 34, 0.5)", 40 | }} 41 | > 42 | <div 43 | tw="absolute" 44 | style={{ 45 | width: "350px", 46 | height: "120px", 47 | borderRadius: "100px", 48 | background: "#c7c7c7", 49 | opacity: 0.21, 50 | filter: "blur(35px)", 51 | transform: "rotate(50deg)", 52 | top: "18%", 53 | left: "0%", 54 | }} 55 | /> 56 | 57 | <div 58 | tw="flex flex-col w-full relative h-full p-8" 59 | style={{ 60 | gap: "14px", 61 | position: "relative", 62 | zIndex: 999, 63 | }} 64 | > 65 | <div 66 | tw="absolute bg-repeat w-full h-full" 67 | style={{ 68 | width: "100%", 69 | height: "100%", 70 | zIndex: 999, 71 | 72 | background: 73 | "url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbG5zOnN2Z2pzPSJodHRwOi8vc3ZnanMuZGV2L3N2Z2pzIiB2aWV3Qm94PSIwIDAgODAwIDgwMCIgd2lkdGg9IjgwMCIgaGVpZ2h0PSI4MDAiPjxnIHN0cm9rZS13aWR0aD0iMy41IiBzdHJva2U9ImhzbGEoMCwgMCUsIDEwMCUsIDEuMDApIiBmaWxsPSJub25lIiBvcGFjaXR5PSIwLjUiPjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIiB4PSIwIiB5PSIwIiBvcGFjaXR5PSIwLjE1Ij48L3JlY3Q+PGNpcmNsZSByPSIxMC44NTUyNjMxNTc4OTQ3MzYiIGN4PSIwIiBjeT0iMCIgZmlsbD0iaHNsYSgwLCAwJSwgMTAwJSwgMS4wMCkiIHN0cm9rZT0ibm9uZSI+PC9jaXJjbGU+PHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSI0MDAiIHg9IjQwMCIgeT0iMCIgb3BhY2l0eT0iMC4xNSI+PC9yZWN0PjxjaXJjbGUgcj0iMTAuODU1MjYzMTU3ODk0NzM2IiBjeD0iNDAwIiBjeT0iMCIgZmlsbD0iaHNsYSgwLCAwJSwgMTAwJSwgMS4wMCkiIHN0cm9rZT0ibm9uZSI+PC9jaXJjbGU+PHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSI0MDAiIHg9IjgwMCIgeT0iMCIgb3BhY2l0eT0iMC4xNSI+PC9yZWN0PjxjaXJjbGUgcj0iMTAuODU1MjYzMTU3ODk0NzM2IiBjeD0iODAwIiBjeT0iMCIgZmlsbD0iaHNsYSgwLCAwJSwgMTAwJSwgMS4wMCkiIHN0cm9rZT0ibm9uZSI+PC9jaXJjbGU+PHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSI0MDAiIHg9IjAiIHk9IjQwMCIgb3BhY2l0eT0iMC4xNSI+PC9yZWN0PjxjaXJjbGUgcj0iMTAuODU1MjYzMTU3ODk0NzM2IiBjeD0iMCIgY3k9IjQwMCIgZmlsbD0iaHNsYSgwLCAwJSwgMTAwJSwgMS4wMCkiIHN0cm9rZT0ibm9uZSI+PC9jaXJjbGU+PHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSI0MDAiIHg9IjQwMCIgeT0iNDAwIiBvcGFjaXR5PSIwLjE1Ij48L3JlY3Q+PGNpcmNsZSByPSIxMC44NTUyNjMxNTc4OTQ3MzYiIGN4PSI0MDAiIGN5PSI0MDAiIGZpbGw9ImhzbGEoMCwgMCUsIDEwMCUsIDEuMDApIiBzdHJva2U9Im5vbmUiPjwvY2lyY2xlPjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIiB4PSI4MDAiIHk9IjQwMCIgb3BhY2l0eT0iMC4xNSI+PC9yZWN0PjxjaXJjbGUgcj0iMTAuODU1MjYzMTU3ODk0NzM2IiBjeD0iODAwIiBjeT0iNDAwIiBmaWxsPSJoc2xhKDAsIDAlLCAxMDAlLCAxLjAwKSIgc3Ryb2tlPSJub25lIj48L2NpcmNsZT48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeD0iMCIgeT0iODAwIiBvcGFjaXR5PSIwLjE1Ij48L3JlY3Q+PGNpcmNsZSByPSIxMC44NTUyNjMxNTc4OTQ3MzYiIGN4PSIwIiBjeT0iODAwIiBmaWxsPSJoc2xhKDAsIDAlLCAxMDAlLCAxLjAwKSIgc3Ryb2tlPSJub25lIj48L2NpcmNsZT48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgeD0iNDAwIiB5PSI4MDAiIG9wYWNpdHk9IjAuMTUiPjwvcmVjdD48Y2lyY2xlIHI9IjEwLjg1NTI2MzE1Nzg5NDczNiIgY3g9IjQwMCIgY3k9IjgwMCIgZmlsbD0iaHNsYSgwLCAwJSwgMTAwJSwgMS4wMCkiIHN0cm9rZT0ibm9uZSI+PC9jaXJjbGU+PHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSI0MDAiIHg9IjgwMCIgeT0iODAwIiBvcGFjaXR5PSIwLjE1Ij48L3JlY3Q+PGNpcmNsZSByPSIxMC44NTUyNjM1NTc4OTQ3MzYiIGN4PSI4MDAiIGN5PSI4MDAiIGZpbGw9ImhzbGEoMCwgMCUsIDEwMCUsIDEuMDApIiBzdHJva2U9Im5vbmUiPjwvY2lyY2xlPjwvZz48L3N2Zz4=')", 74 | backgroundSize: "25px 25px", 75 | display: "flex", 76 | alignItems: "flex-start", 77 | justifyContent: "flex-start", 78 | position: "relative", 79 | flexDirection: "column", 80 | textAlign: "left", 81 | paddingLeft: "170px", 82 | gap: "14px", 83 | }} 84 | /> 85 | <div 86 | tw="flex text-6xl absolute bottom-56 isolate font-bold" 87 | style={{ 88 | paddingLeft: "170px", 89 | paddingTop: "200px", 90 | background: "linear-gradient(45deg, #000000 4%, #fff, #000)", 91 | backgroundClip: "text", 92 | color: "transparent", 93 | }} 94 | > 95 | {trueHeading} 96 | </div> 97 | 98 | <div 99 | tw="flex absolute bottom-44 z-[999] text-2xl" 100 | style={{ 101 | paddingLeft: "170px", 102 | background: 103 | "linear-gradient(10deg, #d4d4d8, 04%, #fff, #d4d4d8)", 104 | backgroundClip: "text", 105 | opacity: 0.7, 106 | color: "transparent", 107 | }} 108 | > 109 | {description} 110 | </div> 111 | 112 | <div 113 | tw="flex text-2xl absolute bottom-28 z-[999]" 114 | style={{ 115 | paddingLeft: "170px", 116 | background: 117 | "linear-gradient(10deg, #d4d4d8, 04%, #fff, #d4d4d8)", 118 | backgroundClip: "text", 119 | opacity: 0.8, 120 | color: "transparent", 121 | }} 122 | > 123 | {date} 124 | </div> 125 | </div> 126 | 127 | {/* Lines */} 128 | <div 129 | tw="absolute top-10% w-full h-px" 130 | style={{ 131 | background: "linear-gradient(90deg, #888888 30%, #1d1f1f 70%)", 132 | }} 133 | /> 134 | <div 135 | tw="absolute bottom-10% w-full h-px" 136 | style={{ 137 | background: "#2c2c2c", 138 | }} 139 | /> 140 | <div 141 | tw="absolute left-10% h-full w-px" 142 | style={{ 143 | background: "linear-gradient(180deg, #747474 30%, #222424 70%)", 144 | }} 145 | /> 146 | <div 147 | tw="absolute right-10% h-full w-px" 148 | style={{ 149 | background: "#2c2c2c", 150 | }} 151 | /> 152 | </div> 153 | </div>, 154 | { 155 | width: 1200, 156 | height: 630, 157 | fonts: [ 158 | { 159 | name: "Geist", 160 | data: geist, 161 | weight: 400, 162 | style: "normal", 163 | }, 164 | ], 165 | }, 166 | ); 167 | } catch (err) { 168 | console.log({ err }); 169 | return new Response("Failed to generate the OG image", { status: 500 }); 170 | } 171 | } 172 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/apple.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import { APIError } from "better-call"; 3 | import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; 4 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 5 | import { 6 | refreshAccessToken, 7 | createAuthorizationURL, 8 | validateAuthorizationCode, 9 | } from "../oauth2"; 10 | export interface AppleProfile { 11 | /** 12 | * The subject registered claim identifies the principal that’s the subject 13 | * of the identity token. Because this token is for your app, the value is 14 | * the unique identifier for the user. 15 | */ 16 | sub: string; 17 | /** 18 | * A String value representing the user's email address. 19 | * The email address is either the user's real email address or the proxy 20 | * address, depending on their status private email relay service. 21 | */ 22 | email: string; 23 | /** 24 | * A string or Boolean value that indicates whether the service verifies 25 | * the email. The value can either be a string ("true" or "false") or a 26 | * Boolean (true or false). The system may not verify email addresses for 27 | * Sign in with Apple at Work & School users, and this claim is "false" or 28 | * false for those users. 29 | */ 30 | email_verified: true | "true"; 31 | /** 32 | * A string or Boolean value that indicates whether the email that the user 33 | * shares is the proxy address. The value can either be a string ("true" or 34 | * "false") or a Boolean (true or false). 35 | */ 36 | is_private_email: boolean; 37 | /** 38 | * An Integer value that indicates whether the user appears to be a real 39 | * person. Use the value of this claim to mitigate fraud. The possible 40 | * values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For 41 | * more information, see ASUserDetectionStatus. This claim is present only 42 | * in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14 43 | * and later. The claim isn’t present or supported for web-based apps. 44 | */ 45 | real_user_status: number; 46 | /** 47 | * The user’s full name in the format provided during the authorization 48 | * process. 49 | */ 50 | name: string; 51 | /** 52 | * The URL to the user's profile picture. 53 | */ 54 | picture: string; 55 | user?: AppleNonConformUser; 56 | } 57 | 58 | /** 59 | * This is the shape of the `user` query parameter that Apple sends the first 60 | * time the user consents to the app. 61 | * @see https://developer.apple.com/documentation/signinwithapplerestapi/request-an-authorization-to-the-sign-in-with-apple-server./ 62 | */ 63 | export interface AppleNonConformUser { 64 | name: { 65 | firstName: string; 66 | lastName: string; 67 | }; 68 | email: string; 69 | } 70 | 71 | export interface AppleOptions extends ProviderOptions<AppleProfile> { 72 | clientId: string; 73 | appBundleIdentifier?: string; 74 | audience?: string | string[]; 75 | } 76 | 77 | export const apple = (options: AppleOptions) => { 78 | const tokenEndpoint = "https://appleid.apple.com/auth/token"; 79 | return { 80 | id: "apple", 81 | name: "Apple", 82 | async createAuthorizationURL({ state, scopes, redirectURI }) { 83 | const _scope = options.disableDefaultScope ? [] : ["email", "name"]; 84 | options.scope && _scope.push(...options.scope); 85 | scopes && _scope.push(...scopes); 86 | const url = await createAuthorizationURL({ 87 | id: "apple", 88 | options, 89 | authorizationEndpoint: "https://appleid.apple.com/auth/authorize", 90 | scopes: _scope, 91 | state, 92 | redirectURI, 93 | responseMode: "form_post", 94 | responseType: "code id_token", 95 | }); 96 | return url; 97 | }, 98 | validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { 99 | return validateAuthorizationCode({ 100 | code, 101 | codeVerifier, 102 | redirectURI, 103 | options, 104 | tokenEndpoint, 105 | }); 106 | }, 107 | async verifyIdToken(token, nonce) { 108 | if (options.disableIdTokenSignIn) { 109 | return false; 110 | } 111 | if (options.verifyIdToken) { 112 | return options.verifyIdToken(token, nonce); 113 | } 114 | const decodedHeader = decodeProtectedHeader(token); 115 | const { kid, alg: jwtAlg } = decodedHeader; 116 | if (!kid || !jwtAlg) return false; 117 | const publicKey = await getApplePublicKey(kid); 118 | const { payload: jwtClaims } = await jwtVerify(token, publicKey, { 119 | algorithms: [jwtAlg], 120 | issuer: "https://appleid.apple.com", 121 | audience: 122 | options.audience && options.audience.length 123 | ? options.audience 124 | : options.appBundleIdentifier 125 | ? options.appBundleIdentifier 126 | : options.clientId, 127 | maxTokenAge: "1h", 128 | }); 129 | ["email_verified", "is_private_email"].forEach((field) => { 130 | if (jwtClaims[field] !== undefined) { 131 | jwtClaims[field] = Boolean(jwtClaims[field]); 132 | } 133 | }); 134 | if (nonce && jwtClaims.nonce !== nonce) { 135 | return false; 136 | } 137 | return !!jwtClaims; 138 | }, 139 | refreshAccessToken: options.refreshAccessToken 140 | ? options.refreshAccessToken 141 | : async (refreshToken) => { 142 | return refreshAccessToken({ 143 | refreshToken, 144 | options: { 145 | clientId: options.clientId, 146 | clientKey: options.clientKey, 147 | clientSecret: options.clientSecret, 148 | }, 149 | tokenEndpoint: "https://appleid.apple.com/auth/token", 150 | }); 151 | }, 152 | async getUserInfo(token) { 153 | if (options.getUserInfo) { 154 | return options.getUserInfo(token); 155 | } 156 | if (!token.idToken) { 157 | return null; 158 | } 159 | const profile = decodeJwt<AppleProfile>(token.idToken); 160 | if (!profile) { 161 | return null; 162 | } 163 | const name = token.user 164 | ? `${token.user.name?.firstName} ${token.user.name?.lastName}` 165 | : profile.name || profile.email; 166 | const emailVerified = 167 | typeof profile.email_verified === "boolean" 168 | ? profile.email_verified 169 | : profile.email_verified === "true"; 170 | const enrichedProfile = { 171 | ...profile, 172 | name, 173 | }; 174 | const userMap = await options.mapProfileToUser?.(enrichedProfile); 175 | return { 176 | user: { 177 | id: profile.sub, 178 | name: enrichedProfile.name, 179 | emailVerified: emailVerified, 180 | email: profile.email, 181 | ...userMap, 182 | }, 183 | data: enrichedProfile, 184 | }; 185 | }, 186 | options, 187 | } satisfies OAuthProvider<AppleProfile>; 188 | }; 189 | 190 | export const getApplePublicKey = async (kid: string) => { 191 | const APPLE_BASE_URL = "https://appleid.apple.com"; 192 | const JWKS_APPLE_URI = "/auth/keys"; 193 | const { data } = await betterFetch<{ 194 | keys: Array<{ 195 | kid: string; 196 | alg: string; 197 | kty: string; 198 | use: string; 199 | n: string; 200 | e: string; 201 | }>; 202 | }>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`); 203 | if (!data?.keys) { 204 | throw new APIError("BAD_REQUEST", { 205 | message: "Keys not found", 206 | }); 207 | } 208 | const jwk = data.keys.find((key) => key.kid === kid); 209 | if (!jwk) { 210 | throw new Error(`JWK with kid ${kid} not found`); 211 | } 212 | return await importJWK(jwk, jwk.alg); 213 | }; 214 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/test-utils/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { afterAll } from "vitest"; 2 | import { betterAuth } from "../auth"; 3 | import { createAuthClient } from "../client/vanilla"; 4 | import type { BetterAuthOptions, Session, User } from "../types"; 5 | import { getMigrations } from "../db/get-migration"; 6 | import { parseSetCookieHeader, setCookieToHeader } from "../cookies"; 7 | import type { SuccessContext } from "@better-fetch/fetch"; 8 | import { getAdapter } from "../db/utils"; 9 | import { getBaseURL } from "../utils/url"; 10 | import { Kysely, MysqlDialect, PostgresDialect, sql } from "kysely"; 11 | import { Pool } from "pg"; 12 | import { MongoClient } from "mongodb"; 13 | import { mongodbAdapter } from "../adapters/mongodb-adapter"; 14 | import { createPool } from "mysql2/promise"; 15 | import { bearer } from "../plugins"; 16 | import type { BetterAuthClientOptions } from "@better-auth/core"; 17 | 18 | export async function getTestInstanceMemory< 19 | O extends Partial<BetterAuthOptions>, 20 | C extends BetterAuthClientOptions, 21 | >( 22 | options?: O, 23 | config?: { 24 | clientOptions?: C; 25 | port?: number; 26 | disableTestUser?: boolean; 27 | testUser?: Partial<User>; 28 | testWith?: "sqlite" | "postgres" | "mongodb" | "mysql" | "memory"; 29 | }, 30 | ) { 31 | const testWith = config?.testWith || "memory"; 32 | const postgres = new Kysely({ 33 | dialect: new PostgresDialect({ 34 | pool: new Pool({ 35 | connectionString: "postgres://user:password@localhost:5432/better_auth", 36 | }), 37 | }), 38 | }); 39 | 40 | const mysql = new Kysely({ 41 | dialect: new MysqlDialect( 42 | createPool("mysql://user:password@localhost:3306/better_auth"), 43 | ), 44 | }); 45 | 46 | async function mongodbClient() { 47 | const dbClient = async (connectionString: string, dbName: string) => { 48 | const client = new MongoClient(connectionString); 49 | await client.connect(); 50 | const db = client.db(dbName); 51 | return db; 52 | }; 53 | const db = await dbClient("mongodb://127.0.0.1:27017", "better-auth"); 54 | return db; 55 | } 56 | 57 | const opts = { 58 | socialProviders: { 59 | github: { 60 | clientId: "test", 61 | clientSecret: "test", 62 | }, 63 | google: { 64 | clientId: "test", 65 | clientSecret: "test", 66 | }, 67 | }, 68 | secret: "better-auth.secret", 69 | database: 70 | testWith === "postgres" 71 | ? { db: postgres, type: "postgres" } 72 | : testWith === "mongodb" 73 | ? mongodbAdapter(await mongodbClient()) 74 | : testWith === "mysql" 75 | ? { db: mysql, type: "mysql" } 76 | : undefined, 77 | emailAndPassword: { 78 | enabled: true, 79 | }, 80 | rateLimit: { 81 | enabled: false, 82 | }, 83 | advanced: { 84 | cookies: {}, 85 | }, 86 | } satisfies BetterAuthOptions; 87 | 88 | const auth = betterAuth({ 89 | baseURL: "http://localhost:" + (config?.port || 3000), 90 | ...opts, 91 | ...options, 92 | advanced: { 93 | disableCSRFCheck: true, 94 | ...options?.advanced, 95 | }, 96 | plugins: [bearer(), ...(options?.plugins || [])], 97 | } as unknown as O extends undefined ? typeof opts : O & typeof opts); 98 | 99 | const testUser = { 100 | email: "[email protected]", 101 | password: "test123456", 102 | name: "test user", 103 | ...config?.testUser, 104 | }; 105 | async function createTestUser() { 106 | if (config?.disableTestUser) { 107 | return; 108 | } 109 | //@ts-expect-error 110 | const res = await auth.api.signUpEmail({ 111 | body: testUser, 112 | }); 113 | } 114 | 115 | if (testWith !== "mongodb" && testWith !== "memory") { 116 | const { runMigrations } = await getMigrations({ 117 | ...auth.options, 118 | database: opts.database, 119 | }); 120 | await runMigrations(); 121 | } 122 | 123 | await createTestUser(); 124 | 125 | afterAll(async () => { 126 | if (testWith === "mongodb") { 127 | const db = await mongodbClient(); 128 | await db.dropDatabase(); 129 | return; 130 | } 131 | if (testWith === "postgres") { 132 | await sql`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`.execute( 133 | postgres, 134 | ); 135 | await postgres.destroy(); 136 | return; 137 | } 138 | 139 | if (testWith === "mysql") { 140 | await sql`SET FOREIGN_KEY_CHECKS = 0;`.execute(mysql); 141 | const tables = await mysql.introspection.getTables(); 142 | for (const table of tables) { 143 | // @ts-expect-error 144 | await mysql.deleteFrom(table.name).execute(); 145 | } 146 | await sql`SET FOREIGN_KEY_CHECKS = 1;`.execute(mysql); 147 | return; 148 | } 149 | }); 150 | 151 | async function signInWithTestUser() { 152 | if (config?.disableTestUser) { 153 | throw new Error("Test user is disabled"); 154 | } 155 | let headers = new Headers(); 156 | const setCookie = (name: string, value: string) => { 157 | const current = headers.get("cookie"); 158 | headers.set("cookie", `${current || ""}; ${name}=${value}`); 159 | }; 160 | //@ts-expect-error 161 | const { data, error } = await client.signIn.email({ 162 | email: testUser.email, 163 | password: testUser.password, 164 | fetchOptions: { 165 | //@ts-expect-error 166 | onSuccess(context) { 167 | const header = context.response.headers.get("set-cookie"); 168 | const cookies = parseSetCookieHeader(header || ""); 169 | const signedCookie = cookies.get("better-auth.session_token")?.value; 170 | headers.set("cookie", `better-auth.session_token=${signedCookie}`); 171 | }, 172 | }, 173 | }); 174 | return { 175 | session: data.session as Session, 176 | user: data.user as User, 177 | headers, 178 | setCookie, 179 | }; 180 | } 181 | async function signInWithUser(email: string, password: string) { 182 | let headers = new Headers(); 183 | //@ts-expect-error 184 | const { data } = await client.signIn.email({ 185 | email, 186 | password, 187 | fetchOptions: { 188 | //@ts-expect-error 189 | onSuccess(context) { 190 | const header = context.response.headers.get("set-cookie"); 191 | const cookies = parseSetCookieHeader(header || ""); 192 | const signedCookie = cookies.get("better-auth.session_token")?.value; 193 | headers.set("cookie", `better-auth.session_token=${signedCookie}`); 194 | }, 195 | }, 196 | }); 197 | return { 198 | res: data as { 199 | user: User; 200 | session: Session; 201 | }, 202 | headers, 203 | }; 204 | } 205 | 206 | const customFetchImpl = async ( 207 | url: string | URL | Request, 208 | init?: RequestInit, 209 | ) => { 210 | return auth.handler(new Request(url, init)); 211 | }; 212 | 213 | function sessionSetter(headers: Headers) { 214 | return (context: SuccessContext) => { 215 | const header = context.response.headers.get("set-cookie"); 216 | if (header) { 217 | const cookies = parseSetCookieHeader(header || ""); 218 | const signedCookie = cookies.get("better-auth.session_token")?.value; 219 | headers.set("cookie", `better-auth.session_token=${signedCookie}`); 220 | } 221 | }; 222 | } 223 | 224 | const client = createAuthClient({ 225 | ...(config?.clientOptions as C extends undefined ? {} : C), 226 | baseURL: getBaseURL( 227 | options?.baseURL || "http://localhost:" + (config?.port || 3000), 228 | options?.basePath || "/api/auth", 229 | ), 230 | fetchOptions: { 231 | customFetchImpl, 232 | }, 233 | }); 234 | return { 235 | auth, 236 | client, 237 | testUser, 238 | signInWithTestUser, 239 | signInWithUser, 240 | cookieSetter: setCookieToHeader, 241 | customFetchImpl, 242 | sessionSetter, 243 | db: await getAdapter(auth.options), 244 | }; 245 | } 246 | ``` -------------------------------------------------------------------------------- /docs/components/ui/navigation-menu.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; 5 | import { cva } from "class-variance-authority"; 6 | import { ChevronDownIcon } from "lucide-react"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | 10 | function NavigationMenu({ 11 | className, 12 | children, 13 | viewport = true, 14 | ...props 15 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & { 16 | viewport?: boolean; 17 | }) { 18 | return ( 19 | <NavigationMenuPrimitive.Root 20 | data-slot="navigation-menu" 21 | data-viewport={viewport} 22 | className={cn( 23 | "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", 24 | className, 25 | )} 26 | {...props} 27 | > 28 | {children} 29 | {viewport && <NavigationMenuViewport />} 30 | </NavigationMenuPrimitive.Root> 31 | ); 32 | } 33 | 34 | function NavigationMenuList({ 35 | className, 36 | ...props 37 | }: React.ComponentProps<typeof NavigationMenuPrimitive.List>) { 38 | return ( 39 | <NavigationMenuPrimitive.List 40 | data-slot="navigation-menu-list" 41 | className={cn( 42 | "group flex flex-1 list-none items-center justify-center gap-1", 43 | className, 44 | )} 45 | {...props} 46 | /> 47 | ); 48 | } 49 | 50 | function NavigationMenuItem({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) { 54 | return ( 55 | <NavigationMenuPrimitive.Item 56 | data-slot="navigation-menu-item" 57 | className={cn("relative", className)} 58 | {...props} 59 | /> 60 | ); 61 | } 62 | 63 | const navigationMenuTriggerStyle = cva( 64 | "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1", 65 | ); 66 | 67 | function NavigationMenuTrigger({ 68 | className, 69 | children, 70 | ...props 71 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) { 72 | return ( 73 | <NavigationMenuPrimitive.Trigger 74 | data-slot="navigation-menu-trigger" 75 | className={cn(navigationMenuTriggerStyle(), "group", className)} 76 | {...props} 77 | > 78 | {children}{" "} 79 | <ChevronDownIcon 80 | className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180" 81 | aria-hidden="true" 82 | /> 83 | </NavigationMenuPrimitive.Trigger> 84 | ); 85 | } 86 | 87 | function NavigationMenuContent({ 88 | className, 89 | ...props 90 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) { 91 | return ( 92 | <NavigationMenuPrimitive.Content 93 | data-slot="navigation-menu-content" 94 | className={cn( 95 | "data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto", 96 | "group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none", 97 | className, 98 | )} 99 | {...props} 100 | /> 101 | ); 102 | } 103 | 104 | function NavigationMenuViewport({ 105 | className, 106 | ...props 107 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) { 108 | return ( 109 | <div 110 | className={cn( 111 | "absolute top-full left-0 isolate z-50 flex justify-center", 112 | )} 113 | > 114 | <NavigationMenuPrimitive.Viewport 115 | data-slot="navigation-menu-viewport" 116 | className={cn( 117 | "origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]", 118 | className, 119 | )} 120 | {...props} 121 | /> 122 | </div> 123 | ); 124 | } 125 | 126 | function NavigationMenuLink({ 127 | className, 128 | ...props 129 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) { 130 | return ( 131 | <NavigationMenuPrimitive.Link 132 | data-slot="navigation-menu-link" 133 | className={cn( 134 | "data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4", 135 | className, 136 | )} 137 | {...props} 138 | /> 139 | ); 140 | } 141 | 142 | function NavigationMenuIndicator({ 143 | className, 144 | ...props 145 | }: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) { 146 | return ( 147 | <NavigationMenuPrimitive.Indicator 148 | data-slot="navigation-menu-indicator" 149 | className={cn( 150 | "data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden", 151 | className, 152 | )} 153 | {...props} 154 | > 155 | <div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" /> 156 | </NavigationMenuPrimitive.Indicator> 157 | ); 158 | } 159 | 160 | export { 161 | NavigationMenu, 162 | NavigationMenuList, 163 | NavigationMenuItem, 164 | NavigationMenuContent, 165 | NavigationMenuTrigger, 166 | NavigationMenuLink, 167 | NavigationMenuIndicator, 168 | NavigationMenuViewport, 169 | navigationMenuTriggerStyle, 170 | }; 171 | ``` -------------------------------------------------------------------------------- /docs/content/docs/concepts/email.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Email 3 | description: Learn how to use email with Better Auth. 4 | --- 5 | 6 | Email is a key part of Better Auth, required for all users regardless of their authentication method. Better Auth provides email and password authentication out of the box, and a lot of utilities to help you manage email verification, password reset, and more. 7 | 8 | 9 | ## Email Verification 10 | 11 | Email verification is a security feature that ensures users provide a valid email address. It helps prevent spam and abuse by confirming that the email address belongs to the user. In this guide, you'll get a walk through of how to implement token based email verification in your app. 12 | To use otp based email verification, check out the [OTP Verification](/docs/plugins/email-otp) guide. 13 | 14 | ### Adding Email Verification to Your App 15 | 16 | To enable email verification, you need to pass a function that sends a verification email with a link. 17 | 18 | - **sendVerificationEmail**: This function is triggered when email verification starts. It accepts a data object with the following properties: 19 | - `user`: The user object containing the email address. 20 | - `url`: The verification URL the user must click to verify their email. 21 | - `token`: The verification token used to complete the email verification to be used when implementing a custom verification URL. 22 | 23 | and a `request` object as the second parameter. 24 | 25 | ```ts title="auth.ts" 26 | import { betterAuth } from 'better-auth'; 27 | import { sendEmail } from './email'; // your email sending function 28 | 29 | export const auth = betterAuth({ 30 | emailVerification: { 31 | sendVerificationEmail: async ({ user, url, token }, request) => { 32 | await sendEmail({ 33 | to: user.email, 34 | subject: 'Verify your email address', 35 | text: `Click the link to verify your email: ${url}` 36 | }) 37 | } 38 | } 39 | }) 40 | ``` 41 | 42 | ### Triggering Email Verification 43 | 44 | You can initiate email verification in several ways: 45 | 46 | #### 1. During Sign-up 47 | 48 | To automatically send a verification email at signup, set `emailVerification.sendOnSignUp` to `true`. 49 | 50 | ```ts title="auth.ts" 51 | import { betterAuth } from 'better-auth'; 52 | 53 | export const auth = betterAuth({ 54 | emailVerification: { 55 | sendOnSignUp: true 56 | } 57 | }) 58 | ``` 59 | 60 | This sends a verification email when a user signs up. For social logins, email verification status is read from the SSO. 61 | 62 | <Callout> 63 | With `sendOnSignUp` enabled, when the user logs in with an SSO that does not claim the email as verified, Better Auth will dispatch a verification email, but the verification is not required to login even when `requireEmailVerification` is enabled. 64 | </Callout> 65 | 66 | #### 2. Require Email Verification 67 | 68 | If you enable require email verification, users must verify their email before they can log in. And every time a user tries to sign in, `sendVerificationEmail` is called. 69 | 70 | <Callout> 71 | This only works if you have `sendVerificationEmail` implemented and if the user is trying to sign in with email and password. 72 | </Callout> 73 | 74 | ```ts title="auth.ts" 75 | export const auth = betterAuth({ 76 | emailAndPassword: { 77 | requireEmailVerification: true 78 | } 79 | }) 80 | ``` 81 | 82 | if a user tries to sign in without verifying their email, you can handle the error and show a message to the user. 83 | 84 | ```ts title="auth-client.ts" 85 | await authClient.signIn.email({ 86 | email: "[email protected]", 87 | password: "password" 88 | }, { 89 | onError: (ctx) => { 90 | // Handle the error 91 | if(ctx.error.status === 403) { 92 | alert("Please verify your email address") 93 | } 94 | //you can also show the original error message 95 | alert(ctx.error.message) 96 | } 97 | }) 98 | ``` 99 | 100 | #### 3. Manually 101 | 102 | You can also manually trigger email verification by calling `sendVerificationEmail`. 103 | 104 | ```ts 105 | await authClient.sendVerificationEmail({ 106 | email: "[email protected]", 107 | callbackURL: "/" // The redirect URL after verification 108 | }) 109 | ``` 110 | 111 | ### Verifying the Email 112 | 113 | If the user clicks the provided verification URL, their email is automatically verified, and they are redirected to the `callbackURL`. 114 | 115 | For manual verification, you can send the user a custom link with the `token` and call the `verifyEmail` function. 116 | 117 | ```ts 118 | await authClient.verifyEmail({ 119 | query: { 120 | token: "" // Pass the token here 121 | } 122 | }) 123 | ``` 124 | 125 | ### Auto Sign In After Verification 126 | 127 | To sign in the user automatically after they successfully verify their email, set the `autoSignInAfterVerification` option to `true`: 128 | 129 | ```ts 130 | const auth = betterAuth({ 131 | //...your other options 132 | emailVerification: { 133 | autoSignInAfterVerification: true 134 | } 135 | }) 136 | ``` 137 | 138 | ### Callback after successful email verification 139 | 140 | You can run custom code immediately after a user verifies their email using the `afterEmailVerification` callback. This is useful for any side-effects you want to trigger, like granting access to special features or logging the event. 141 | 142 | The `afterEmailVerification` function runs automatically when a user's email is confirmed, receiving the `user` object and `request` details so you can perform actions for that specific user. 143 | 144 | Here's how you can set it up: 145 | 146 | ```ts title="auth.ts" 147 | import { betterAuth } from 'better-auth'; 148 | 149 | export const auth = betterAuth({ 150 | emailVerification: { 151 | async afterEmailVerification(user, request) { 152 | // Your custom logic here, e.g., grant access to premium features 153 | console.log(`${user.email} has been successfully verified!`); 154 | } 155 | } 156 | }) 157 | ``` 158 | 159 | ## Password Reset Email 160 | 161 | Password reset allows users to reset their password if they forget it. Better Auth provides a simple way to implement password reset functionality. 162 | 163 | You can enable password reset by passing a function that sends a password reset email with a link. 164 | 165 | ```ts title="auth.ts" 166 | import { betterAuth } from 'better-auth'; 167 | import { sendEmail } from './email'; // your email sending function 168 | 169 | export const auth = betterAuth({ 170 | emailAndPassword: { 171 | enabled: true, 172 | sendResetPassword: async ({ user, url, token }, request) => { 173 | await sendEmail({ 174 | to: user.email, 175 | subject: 'Reset your password', 176 | text: `Click the link to reset your password: ${url}` 177 | }) 178 | } 179 | } 180 | }) 181 | ``` 182 | 183 | Check out the [Email and Password](/docs/authentication/email-password#forget-password) guide for more details on how to implement password reset in your app. 184 | Also you can check out the [Otp verification](/docs/plugins/email-otp#reset-password) guide for how to implement password reset with OTP in your app. 185 | ``` -------------------------------------------------------------------------------- /docs/components/docs/page.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import type { TableOfContents } from "fumadocs-core/server"; 2 | import { 3 | type AnchorHTMLAttributes, 4 | forwardRef, 5 | type HTMLAttributes, 6 | type ReactNode, 7 | } from "react"; 8 | import { type AnchorProviderProps, AnchorProvider } from "fumadocs-core/toc"; 9 | import { replaceOrDefault } from "./shared"; 10 | import { cn } from "../../lib/utils"; 11 | import { 12 | Footer, 13 | type FooterProps, 14 | LastUpdate, 15 | TocPopoverHeader, 16 | type BreadcrumbProps, 17 | PageBody, 18 | PageArticle, 19 | } from "./page.client"; 20 | import { 21 | Toc, 22 | TOCItems, 23 | TocPopoverTrigger, 24 | TocPopoverContent, 25 | type TOCProps, 26 | TOCScrollArea, 27 | } from "./layout/toc"; 28 | import { buttonVariants } from "./ui/button"; 29 | import { Edit, Text } from "lucide-react"; 30 | import { I18nLabel } from "fumadocs-ui/provider"; 31 | 32 | type TableOfContentOptions = Omit<TOCProps, "items" | "children"> & 33 | Pick<AnchorProviderProps, "single"> & { 34 | enabled: boolean; 35 | component: ReactNode; 36 | }; 37 | 38 | type TableOfContentPopoverOptions = Omit<TableOfContentOptions, "single">; 39 | 40 | interface EditOnGitHubOptions 41 | extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "children"> { 42 | owner: string; 43 | repo: string; 44 | 45 | /** 46 | * SHA or ref (branch or tag) name. 47 | * 48 | * @defaultValue main 49 | */ 50 | sha?: string; 51 | 52 | /** 53 | * File path in the repo 54 | */ 55 | path: string; 56 | } 57 | 58 | interface BreadcrumbOptions extends BreadcrumbProps { 59 | enabled: boolean; 60 | component: ReactNode; 61 | 62 | /** 63 | * Show the full path to the current page 64 | * 65 | * @defaultValue false 66 | * @deprecated use `includePage` instead 67 | */ 68 | full?: boolean; 69 | } 70 | 71 | interface FooterOptions extends FooterProps { 72 | enabled: boolean; 73 | component: ReactNode; 74 | } 75 | 76 | export interface DocsPageProps { 77 | toc?: TableOfContents; 78 | 79 | /** 80 | * Extend the page to fill all available space 81 | * 82 | * @defaultValue false 83 | */ 84 | full?: boolean; 85 | 86 | tableOfContent?: Partial<TableOfContentOptions>; 87 | tableOfContentPopover?: Partial<TableOfContentPopoverOptions>; 88 | 89 | /** 90 | * Replace or disable breadcrumb 91 | */ 92 | breadcrumb?: Partial<BreadcrumbOptions>; 93 | 94 | /** 95 | * Footer navigation, you can disable it by passing `false` 96 | */ 97 | footer?: Partial<FooterOptions>; 98 | 99 | editOnGithub?: EditOnGitHubOptions; 100 | lastUpdate?: Date | string | number; 101 | 102 | container?: HTMLAttributes<HTMLDivElement>; 103 | article?: HTMLAttributes<HTMLElement>; 104 | children: ReactNode; 105 | } 106 | 107 | export function DocsPage({ 108 | toc = [], 109 | full = false, 110 | tableOfContentPopover: { 111 | enabled: tocPopoverEnabled, 112 | component: tocPopoverReplace, 113 | ...tocPopoverOptions 114 | } = {}, 115 | tableOfContent: { 116 | enabled: tocEnabled, 117 | component: tocReplace, 118 | ...tocOptions 119 | } = {}, 120 | ...props 121 | }: DocsPageProps) { 122 | const isTocRequired = 123 | toc.length > 0 || 124 | tocOptions.footer !== undefined || 125 | tocOptions.header !== undefined; 126 | 127 | // disable TOC on full mode, you can still enable it with `enabled` option. 128 | tocEnabled ??= !full && isTocRequired; 129 | 130 | tocPopoverEnabled ??= 131 | toc.length > 0 || 132 | tocPopoverOptions.header !== undefined || 133 | tocPopoverOptions.footer !== undefined; 134 | 135 | return ( 136 | <AnchorProvider toc={toc} single={tocOptions.single}> 137 | <PageBody 138 | {...props.container} 139 | className={cn(props.container?.className)} 140 | style={ 141 | { 142 | "--fd-tocnav-height": !tocPopoverEnabled ? "0px" : undefined, 143 | ...props.container?.style, 144 | } as object 145 | } 146 | > 147 | {replaceOrDefault( 148 | { enabled: tocPopoverEnabled, component: tocPopoverReplace }, 149 | <TocPopoverHeader className="h-10"> 150 | <TocPopoverTrigger className="w-full" items={toc} /> 151 | <TocPopoverContent> 152 | {tocPopoverOptions.header} 153 | <TOCScrollArea isMenu> 154 | <TOCItems items={toc} /> 155 | </TOCScrollArea> 156 | {tocPopoverOptions.footer} 157 | </TocPopoverContent> 158 | </TocPopoverHeader>, 159 | { 160 | items: toc, 161 | ...tocPopoverOptions, 162 | }, 163 | )} 164 | <PageArticle 165 | {...props.article} 166 | className={cn( 167 | full || !tocEnabled ? "max-w-[1120px]" : "max-w-[860px]", 168 | props.article?.className, 169 | )} 170 | > 171 | {props.children} 172 | <div role="none" className="flex-1" /> 173 | <div className="flex flex-row flex-wrap items-center justify-between gap-4 empty:hidden"> 174 | {props.editOnGithub ? ( 175 | <EditOnGitHub {...props.editOnGithub} /> 176 | ) : null} 177 | {props.lastUpdate ? ( 178 | <LastUpdate date={new Date(props.lastUpdate)} /> 179 | ) : null} 180 | </div> 181 | {replaceOrDefault( 182 | props.footer, 183 | <Footer items={props.footer?.items} />, 184 | )} 185 | </PageArticle> 186 | </PageBody> 187 | {replaceOrDefault( 188 | { enabled: tocEnabled, component: tocReplace }, 189 | <Toc> 190 | {tocOptions.header} 191 | <h3 className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"> 192 | <Text className="size-4" /> 193 | <I18nLabel label="toc" /> 194 | </h3> 195 | <TOCScrollArea> 196 | <TOCItems items={toc} /> 197 | </TOCScrollArea> 198 | {tocOptions.footer} 199 | </Toc>, 200 | { 201 | items: toc, 202 | ...tocOptions, 203 | }, 204 | )} 205 | </AnchorProvider> 206 | ); 207 | } 208 | 209 | function EditOnGitHub({ 210 | owner, 211 | repo, 212 | sha, 213 | path, 214 | ...props 215 | }: EditOnGitHubOptions) { 216 | const href = `https://github.com/${owner}/${repo}/blob/${sha}/${path.startsWith("/") ? path.slice(1) : path}`; 217 | 218 | return ( 219 | <a 220 | href={href} 221 | target="_blank" 222 | rel="noreferrer noopener" 223 | {...props} 224 | className={cn( 225 | buttonVariants({ 226 | color: "secondary", 227 | className: "gap-1.5 text-fd-muted-foreground", 228 | }), 229 | props.className, 230 | )} 231 | > 232 | <Edit className="size-3.5" /> 233 | <I18nLabel label="editOnGithub" /> 234 | </a> 235 | ); 236 | } 237 | 238 | /** 239 | * Add typography styles 240 | */ 241 | export const DocsBody = forwardRef< 242 | HTMLDivElement, 243 | HTMLAttributes<HTMLDivElement> 244 | >((props, ref) => ( 245 | <div ref={ref} {...props} className={cn("prose", props.className)}> 246 | {props.children} 247 | </div> 248 | )); 249 | 250 | DocsBody.displayName = "DocsBody"; 251 | 252 | export const DocsDescription = forwardRef< 253 | HTMLParagraphElement, 254 | HTMLAttributes<HTMLParagraphElement> 255 | >((props, ref) => { 256 | // don't render if no description provided 257 | if (props.children === undefined) return null; 258 | 259 | return ( 260 | <p 261 | ref={ref} 262 | {...props} 263 | className={cn("mb-8 text-lg text-fd-muted-foreground", props.className)} 264 | > 265 | {props.children} 266 | </p> 267 | ); 268 | }); 269 | 270 | DocsDescription.displayName = "DocsDescription"; 271 | 272 | export const DocsTitle = forwardRef< 273 | HTMLHeadingElement, 274 | HTMLAttributes<HTMLHeadingElement> 275 | >((props, ref) => { 276 | return ( 277 | <h1 278 | ref={ref} 279 | {...props} 280 | className={cn("text-3xl font-semibold", props.className)} 281 | > 282 | {props.children} 283 | </h1> 284 | ); 285 | }); 286 | 287 | DocsTitle.displayName = "DocsTitle"; 288 | 289 | /** 290 | * For separate MDX page 291 | */ 292 | export function withArticle({ children }: { children: ReactNode }): ReactNode { 293 | return ( 294 | <main className="container py-12"> 295 | <article className="prose">{children}</article> 296 | </main> 297 | ); 298 | } 299 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/mcp/authorize.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "better-call"; 2 | import { getSessionFromCtx } from "../../api"; 3 | import type { 4 | AuthorizationQuery, 5 | Client, 6 | OIDCOptions, 7 | } from "../oidc-provider/types"; 8 | import { generateRandomString } from "../../crypto"; 9 | import type { GenericEndpointContext } from "@better-auth/core"; 10 | 11 | function redirectErrorURL(url: string, error: string, description: string) { 12 | return `${ 13 | url.includes("?") ? "&" : "?" 14 | }error=${error}&error_description=${description}`; 15 | } 16 | 17 | export async function authorizeMCPOAuth( 18 | ctx: GenericEndpointContext, 19 | options: OIDCOptions, 20 | ) { 21 | ctx.setHeader("Access-Control-Allow-Origin", "*"); 22 | ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); 23 | ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); 24 | ctx.setHeader("Access-Control-Max-Age", "86400"); 25 | const opts = { 26 | codeExpiresIn: 600, 27 | defaultScope: "openid", 28 | ...options, 29 | scopes: [ 30 | "openid", 31 | "profile", 32 | "email", 33 | "offline_access", 34 | ...(options?.scopes || []), 35 | ], 36 | }; 37 | if (!ctx.request) { 38 | throw new APIError("UNAUTHORIZED", { 39 | error_description: "request not found", 40 | error: "invalid_request", 41 | }); 42 | } 43 | const session = await getSessionFromCtx(ctx); 44 | if (!session) { 45 | /** 46 | * If the user is not logged in, we need to redirect them to the 47 | * login page. 48 | */ 49 | await ctx.setSignedCookie( 50 | "oidc_login_prompt", 51 | JSON.stringify(ctx.query), 52 | ctx.context.secret, 53 | { 54 | maxAge: 600, 55 | path: "/", 56 | sameSite: "lax", 57 | }, 58 | ); 59 | const queryFromURL = ctx.request.url?.split("?")[1]!; 60 | throw ctx.redirect(`${options.loginPage}?${queryFromURL}`); 61 | } 62 | 63 | const query = ctx.query as AuthorizationQuery; 64 | if (!query.client_id) { 65 | throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`); 66 | } 67 | 68 | if (!query.response_type) { 69 | throw ctx.redirect( 70 | redirectErrorURL( 71 | `${ctx.context.baseURL}/error`, 72 | "invalid_request", 73 | "response_type is required", 74 | ), 75 | ); 76 | } 77 | 78 | const client = await ctx.context.adapter 79 | .findOne<Record<string, any>>({ 80 | model: "oauthApplication", 81 | where: [ 82 | { 83 | field: "clientId", 84 | value: ctx.query.client_id, 85 | }, 86 | ], 87 | }) 88 | .then((res) => { 89 | if (!res) { 90 | return null; 91 | } 92 | return { 93 | ...res, 94 | redirectURLs: res.redirectURLs.split(","), 95 | metadata: res.metadata ? JSON.parse(res.metadata) : {}, 96 | } as Client; 97 | }); 98 | if (!client) { 99 | throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`); 100 | } 101 | const redirectURI = client.redirectURLs.find( 102 | (url) => url === ctx.query.redirect_uri, 103 | ); 104 | 105 | if (!redirectURI || !query.redirect_uri) { 106 | /** 107 | * show UI error here warning the user that the redirect URI is invalid 108 | */ 109 | throw new APIError("BAD_REQUEST", { 110 | message: "Invalid redirect URI", 111 | }); 112 | } 113 | if (client.disabled) { 114 | throw ctx.redirect(`${ctx.context.baseURL}/error?error=client_disabled`); 115 | } 116 | 117 | if (query.response_type !== "code") { 118 | throw ctx.redirect( 119 | `${ctx.context.baseURL}/error?error=unsupported_response_type`, 120 | ); 121 | } 122 | 123 | const requestScope = 124 | query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" "); 125 | const invalidScopes = requestScope.filter((scope) => { 126 | return !opts.scopes.includes(scope); 127 | }); 128 | if (invalidScopes.length) { 129 | throw ctx.redirect( 130 | redirectErrorURL( 131 | query.redirect_uri, 132 | "invalid_scope", 133 | `The following scopes are invalid: ${invalidScopes.join(", ")}`, 134 | ), 135 | ); 136 | } 137 | 138 | if ( 139 | (!query.code_challenge || !query.code_challenge_method) && 140 | options.requirePKCE 141 | ) { 142 | throw ctx.redirect( 143 | redirectErrorURL( 144 | query.redirect_uri, 145 | "invalid_request", 146 | "pkce is required", 147 | ), 148 | ); 149 | } 150 | 151 | if (!query.code_challenge_method) { 152 | query.code_challenge_method = "plain"; 153 | } 154 | 155 | if ( 156 | ![ 157 | "s256", 158 | options.allowPlainCodeChallengeMethod ? "plain" : "s256", 159 | ].includes(query.code_challenge_method?.toLowerCase() || "") 160 | ) { 161 | throw ctx.redirect( 162 | redirectErrorURL( 163 | query.redirect_uri, 164 | "invalid_request", 165 | "invalid code_challenge method", 166 | ), 167 | ); 168 | } 169 | 170 | const code = generateRandomString(32, "a-z", "A-Z", "0-9"); 171 | const codeExpiresInMs = opts.codeExpiresIn * 1000; 172 | const expiresAt = new Date(Date.now() + codeExpiresInMs); 173 | try { 174 | /** 175 | * Save the code in the database 176 | */ 177 | await ctx.context.internalAdapter.createVerificationValue({ 178 | value: JSON.stringify({ 179 | clientId: client.clientId, 180 | redirectURI: query.redirect_uri, 181 | scope: requestScope, 182 | userId: session.user.id, 183 | authTime: new Date(session.session.createdAt).getTime(), 184 | /** 185 | * If the prompt is set to `consent`, then we need 186 | * to require the user to consent to the scopes. 187 | * 188 | * This means the code now needs to be treated as a 189 | * consent request. 190 | * 191 | * once the user consents, the code will be updated 192 | * with the actual code. This is to prevent the 193 | * client from using the code before the user 194 | * consents. 195 | */ 196 | requireConsent: query.prompt === "consent", 197 | state: query.prompt === "consent" ? query.state : null, 198 | codeChallenge: query.code_challenge, 199 | codeChallengeMethod: query.code_challenge_method, 200 | nonce: query.nonce, 201 | }), 202 | identifier: code, 203 | expiresAt, 204 | }); 205 | } catch (e) { 206 | throw ctx.redirect( 207 | redirectErrorURL( 208 | query.redirect_uri, 209 | "server_error", 210 | "An error occurred while processing the request", 211 | ), 212 | ); 213 | } 214 | 215 | // Consent is NOT required - redirect with the code immediately 216 | if (query.prompt !== "consent") { 217 | const redirectURIWithCode = new URL(redirectURI); 218 | redirectURIWithCode.searchParams.set("code", code); 219 | redirectURIWithCode.searchParams.set("state", ctx.query.state); 220 | throw ctx.redirect(redirectURIWithCode.toString()); 221 | } 222 | 223 | // Consent is REQUIRED - redirect to consent page or show consent HTML 224 | if (options?.consentPage) { 225 | // Set cookie to support cookie-based consent flows 226 | await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, { 227 | maxAge: 600, 228 | path: "/", 229 | sameSite: "lax", 230 | }); 231 | 232 | // Pass the consent code as a URL parameter to support URL-BASED consent flows 233 | const urlParams = new URLSearchParams(); 234 | urlParams.set("consent_code", code); 235 | urlParams.set("client_id", client.clientId); 236 | urlParams.set("scope", requestScope.join(" ")); 237 | const consentURI = `${options.consentPage}?${urlParams.toString()}`; 238 | 239 | throw ctx.redirect(consentURI); 240 | } 241 | 242 | // No consent page configured - fall back to direct redirect with code 243 | const redirectURIWithCode = new URL(redirectURI); 244 | redirectURIWithCode.searchParams.set("code", code); 245 | redirectURIWithCode.searchParams.set("state", ctx.query.state); 246 | throw ctx.redirect(redirectURIWithCode.toString()); 247 | } 248 | ``` -------------------------------------------------------------------------------- /demo/nextjs/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | const ContextMenu = ContextMenuPrimitive.Root; 14 | 15 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger; 16 | 17 | const ContextMenuGroup = ContextMenuPrimitive.Group; 18 | 19 | const ContextMenuPortal = ContextMenuPrimitive.Portal; 20 | 21 | const ContextMenuSub = ContextMenuPrimitive.Sub; 22 | 23 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 24 | 25 | const ContextMenuSubTrigger = ({ 26 | ref, 27 | className, 28 | inset, 29 | children, 30 | ...props 31 | }) => ( 32 | <ContextMenuPrimitive.SubTrigger 33 | ref={ref} 34 | className={cn( 35 | "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", 36 | inset && "pl-8", 37 | className, 38 | )} 39 | {...props} 40 | > 41 | {children} 42 | <ChevronRightIcon className="ml-auto h-4 w-4" /> 43 | </ContextMenuPrimitive.SubTrigger> 44 | ); 45 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; 46 | 47 | const ContextMenuSubContent = ({ 48 | ref, 49 | className, 50 | ...props 51 | }: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & { 52 | ref: React.RefObject< 53 | React.ElementRef<typeof ContextMenuPrimitive.SubContent> 54 | >; 55 | }) => ( 56 | <ContextMenuPrimitive.SubContent 57 | ref={ref} 58 | className={cn( 59 | "z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 60 | className, 61 | )} 62 | {...props} 63 | /> 64 | ); 65 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; 66 | 67 | const ContextMenuContent = ({ 68 | ref, 69 | className, 70 | ...props 71 | }: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { 72 | ref: React.RefObject<React.ElementRef<typeof ContextMenuPrimitive.Content>>; 73 | }) => ( 74 | <ContextMenuPrimitive.Portal> 75 | <ContextMenuPrimitive.Content 76 | ref={ref} 77 | className={cn( 78 | "z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 79 | className, 80 | )} 81 | {...props} 82 | /> 83 | </ContextMenuPrimitive.Portal> 84 | ); 85 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; 86 | 87 | const ContextMenuItem = ({ ref, className, inset, ...props }) => ( 88 | <ContextMenuPrimitive.Item 89 | ref={ref} 90 | className={cn( 91 | "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", 92 | inset && "pl-8", 93 | className, 94 | )} 95 | {...props} 96 | /> 97 | ); 98 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; 99 | 100 | const ContextMenuCheckboxItem = ({ 101 | ref, 102 | className, 103 | children, 104 | checked, 105 | ...props 106 | }: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { 107 | ref: React.RefObject< 108 | React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem> 109 | >; 110 | }) => ( 111 | <ContextMenuPrimitive.CheckboxItem 112 | ref={ref} 113 | className={cn( 114 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", 115 | className, 116 | )} 117 | checked={checked} 118 | {...props} 119 | > 120 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 121 | <ContextMenuPrimitive.ItemIndicator> 122 | <CheckIcon className="h-4 w-4" /> 123 | </ContextMenuPrimitive.ItemIndicator> 124 | </span> 125 | {children} 126 | </ContextMenuPrimitive.CheckboxItem> 127 | ); 128 | ContextMenuCheckboxItem.displayName = 129 | ContextMenuPrimitive.CheckboxItem.displayName; 130 | 131 | const ContextMenuRadioItem = ({ 132 | ref, 133 | className, 134 | children, 135 | ...props 136 | }: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { 137 | ref: React.RefObject<React.ElementRef<typeof ContextMenuPrimitive.RadioItem>>; 138 | }) => ( 139 | <ContextMenuPrimitive.RadioItem 140 | ref={ref} 141 | className={cn( 142 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", 143 | className, 144 | )} 145 | {...props} 146 | > 147 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 148 | <ContextMenuPrimitive.ItemIndicator> 149 | <DotFilledIcon className="h-4 w-4 fill-current" /> 150 | </ContextMenuPrimitive.ItemIndicator> 151 | </span> 152 | {children} 153 | </ContextMenuPrimitive.RadioItem> 154 | ); 155 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; 156 | 157 | const ContextMenuLabel = ({ ref, className, inset, ...props }) => ( 158 | <ContextMenuPrimitive.Label 159 | ref={ref} 160 | className={cn( 161 | "px-2 py-1.5 text-sm font-semibold text-foreground", 162 | inset && "pl-8", 163 | className, 164 | )} 165 | {...props} 166 | /> 167 | ); 168 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; 169 | 170 | const ContextMenuSeparator = ({ 171 | ref, 172 | className, 173 | ...props 174 | }: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> & { 175 | ref: React.RefObject<React.ElementRef<typeof ContextMenuPrimitive.Separator>>; 176 | }) => ( 177 | <ContextMenuPrimitive.Separator 178 | ref={ref} 179 | className={cn("-mx-1 my-1 h-px bg-border", className)} 180 | {...props} 181 | /> 182 | ); 183 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; 184 | 185 | const ContextMenuShortcut = ({ 186 | className, 187 | ...props 188 | }: React.HTMLAttributes<HTMLSpanElement>) => { 189 | return ( 190 | <span 191 | className={cn( 192 | "ml-auto text-xs tracking-widest text-muted-foreground", 193 | className, 194 | )} 195 | {...props} 196 | /> 197 | ); 198 | }; 199 | ContextMenuShortcut.displayName = "ContextMenuShortcut"; 200 | 201 | export { 202 | ContextMenu, 203 | ContextMenuTrigger, 204 | ContextMenuContent, 205 | ContextMenuItem, 206 | ContextMenuCheckboxItem, 207 | ContextMenuRadioItem, 208 | ContextMenuLabel, 209 | ContextMenuSeparator, 210 | ContextMenuShortcut, 211 | ContextMenuGroup, 212 | ContextMenuPortal, 213 | ContextMenuSub, 214 | ContextMenuSubContent, 215 | ContextMenuSubTrigger, 216 | ContextMenuRadioGroup, 217 | }; 218 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { logger } from "@better-auth/core/env"; 2 | import { createAdapterFactory } from "../adapter-factory"; 3 | import type { BetterAuthOptions } from "@better-auth/core"; 4 | import type { 5 | DBAdapterDebugLogOption, 6 | CleanedWhere, 7 | } from "@better-auth/core/db/adapter"; 8 | 9 | export interface MemoryDB { 10 | [key: string]: any[]; 11 | } 12 | 13 | export interface MemoryAdapterConfig { 14 | debugLogs?: DBAdapterDebugLogOption; 15 | } 16 | 17 | export const memoryAdapter = (db: MemoryDB, config?: MemoryAdapterConfig) => { 18 | let lazyOptions: BetterAuthOptions | null = null; 19 | let adapterCreator = createAdapterFactory({ 20 | config: { 21 | adapterId: "memory", 22 | adapterName: "Memory Adapter", 23 | usePlural: false, 24 | debugLogs: config?.debugLogs || false, 25 | customTransformInput(props) { 26 | if ( 27 | props.options.advanced?.database?.useNumberId && 28 | props.field === "id" && 29 | props.action === "create" 30 | ) { 31 | return db[props.model]!.length + 1; 32 | } 33 | return props.data; 34 | }, 35 | transaction: async (cb) => { 36 | let clone = structuredClone(db); 37 | try { 38 | const r = await cb(adapterCreator(lazyOptions!)); 39 | return r; 40 | } catch (error) { 41 | // Rollback changes 42 | Object.keys(db).forEach((key) => { 43 | db[key] = clone[key]!; 44 | }); 45 | throw error; 46 | } 47 | }, 48 | }, 49 | adapter: ({ getFieldName, options, debugLog }) => { 50 | function convertWhereClause(where: CleanedWhere[], model: string) { 51 | const table = db[model]; 52 | if (!table) { 53 | logger.error( 54 | `[MemoryAdapter] Model ${model} not found in the DB`, 55 | Object.keys(db), 56 | ); 57 | throw new Error(`Model ${model} not found`); 58 | } 59 | 60 | const evalClause = (record: any, clause: CleanedWhere): boolean => { 61 | const { field, value, operator } = clause; 62 | switch (operator) { 63 | case "in": 64 | if (!Array.isArray(value)) { 65 | throw new Error("Value must be an array"); 66 | } 67 | // @ts-expect-error 68 | return value.includes(record[field]); 69 | case "not_in": 70 | if (!Array.isArray(value)) { 71 | throw new Error("Value must be an array"); 72 | } 73 | // @ts-expect-error 74 | return !value.includes(record[field]); 75 | case "contains": 76 | return record[field].includes(value); 77 | case "starts_with": 78 | return record[field].startsWith(value); 79 | case "ends_with": 80 | return record[field].endsWith(value); 81 | case "ne": 82 | return record[field] !== value; 83 | case "gt": 84 | return value != null && Boolean(record[field] > value); 85 | case "gte": 86 | return value != null && Boolean(record[field] >= value); 87 | case "lt": 88 | return value != null && Boolean(record[field] < value); 89 | case "lte": 90 | return value != null && Boolean(record[field] <= value); 91 | default: 92 | return record[field] === value; 93 | } 94 | }; 95 | 96 | return table.filter((record: any) => { 97 | if (!where.length || where.length === 0) { 98 | return true; 99 | } 100 | 101 | let result = evalClause(record, where[0]!); 102 | for (const clause of where) { 103 | const clauseResult = evalClause(record, clause); 104 | 105 | if (clause.connector === "OR") { 106 | result = result || clauseResult; 107 | } else { 108 | result = result && clauseResult; 109 | } 110 | } 111 | 112 | return result; 113 | }); 114 | } 115 | return { 116 | create: async ({ model, data }) => { 117 | if (options.advanced?.database?.useNumberId) { 118 | // @ts-expect-error 119 | data.id = db[model]!.length + 1; 120 | } 121 | if (!db[model]) { 122 | db[model] = []; 123 | } 124 | db[model]!.push(data); 125 | return data; 126 | }, 127 | findOne: async ({ model, where }) => { 128 | const res = convertWhereClause(where, model); 129 | const record = res[0] || null; 130 | return record; 131 | }, 132 | findMany: async ({ model, where, sortBy, limit, offset }) => { 133 | let table = db[model]; 134 | if (where) { 135 | table = convertWhereClause(where, model); 136 | } 137 | if (sortBy) { 138 | table = table!.sort((a, b) => { 139 | const field = getFieldName({ model, field: sortBy.field }); 140 | const aValue = a[field]; 141 | const bValue = b[field]; 142 | 143 | let comparison = 0; 144 | 145 | // Handle null/undefined values 146 | if (aValue == null && bValue == null) { 147 | comparison = 0; 148 | } else if (aValue == null) { 149 | comparison = -1; 150 | } else if (bValue == null) { 151 | comparison = 1; 152 | } 153 | // Handle string comparison 154 | else if ( 155 | typeof aValue === "string" && 156 | typeof bValue === "string" 157 | ) { 158 | comparison = aValue.localeCompare(bValue); 159 | } 160 | // Handle date comparison 161 | else if (aValue instanceof Date && bValue instanceof Date) { 162 | comparison = aValue.getTime() - bValue.getTime(); 163 | } 164 | // Handle numeric comparison 165 | else if ( 166 | typeof aValue === "number" && 167 | typeof bValue === "number" 168 | ) { 169 | comparison = aValue - bValue; 170 | } 171 | // Handle boolean comparison 172 | else if ( 173 | typeof aValue === "boolean" && 174 | typeof bValue === "boolean" 175 | ) { 176 | comparison = aValue === bValue ? 0 : aValue ? 1 : -1; 177 | } 178 | // Fallback to string comparison 179 | else { 180 | comparison = String(aValue).localeCompare(String(bValue)); 181 | } 182 | 183 | return sortBy.direction === "asc" ? comparison : -comparison; 184 | }); 185 | } 186 | if (offset !== undefined) { 187 | table = table!.slice(offset); 188 | } 189 | if (limit !== undefined) { 190 | table = table!.slice(0, limit); 191 | } 192 | return table || []; 193 | }, 194 | count: async ({ model, where }) => { 195 | if (where) { 196 | const filteredRecords = convertWhereClause(where, model); 197 | return filteredRecords.length; 198 | } 199 | return db[model]!.length; 200 | }, 201 | update: async ({ model, where, update }) => { 202 | const res = convertWhereClause(where, model); 203 | res.forEach((record) => { 204 | Object.assign(record, update); 205 | }); 206 | return res[0] || null; 207 | }, 208 | delete: async ({ model, where }) => { 209 | const table = db[model]!; 210 | const res = convertWhereClause(where, model); 211 | db[model] = table.filter((record) => !res.includes(record)); 212 | }, 213 | deleteMany: async ({ model, where }) => { 214 | const table = db[model]!; 215 | const res = convertWhereClause(where, model); 216 | let count = 0; 217 | db[model] = table.filter((record) => { 218 | if (res.includes(record)) { 219 | count++; 220 | return false; 221 | } 222 | return !res.includes(record); 223 | }); 224 | return count; 225 | }, 226 | updateMany({ model, where, update }) { 227 | const res = convertWhereClause(where, model); 228 | res.forEach((record) => { 229 | Object.assign(record, update); 230 | }); 231 | return res[0] || null; 232 | }, 233 | }; 234 | }, 235 | }); 236 | return (options: BetterAuthOptions) => { 237 | lazyOptions = options; 238 | return adapterCreator(options); 239 | }; 240 | }; 241 | ``` -------------------------------------------------------------------------------- /demo/nextjs/components/blocks/pricing.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | 3 | import { Button, buttonVariants } from "@/components/ui/button"; 4 | import { Label } from "@/components/ui/label"; 5 | import { Switch } from "@/components/ui/switch"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | import { motion } from "framer-motion"; 9 | import { Star } from "lucide-react"; 10 | import { useState, useRef, useEffect } from "react"; 11 | import confetti from "canvas-confetti"; 12 | import NumberFlow from "@number-flow/react"; 13 | import { CheckIcon } from "@radix-ui/react-icons"; 14 | import { client } from "@/lib/auth-client"; 15 | 16 | function useMediaQuery(query: string) { 17 | const [matches, setMatches] = useState(false); 18 | 19 | useEffect(() => { 20 | const media = window.matchMedia(query); 21 | if (media.matches !== matches) { 22 | setMatches(media.matches); 23 | } 24 | 25 | const listener = () => setMatches(media.matches); 26 | media.addListener(listener); 27 | 28 | return () => media.removeListener(listener); 29 | }, [query]); 30 | 31 | return matches; 32 | } 33 | 34 | interface PricingPlan { 35 | name: string; 36 | price: string; 37 | yearlyPrice: string; 38 | period: string; 39 | features: string[]; 40 | description: string; 41 | buttonText: string; 42 | href: string; 43 | isPopular: boolean; 44 | } 45 | 46 | interface PricingProps { 47 | plans: PricingPlan[]; 48 | title?: string; 49 | description?: string; 50 | } 51 | 52 | export function Pricing({ 53 | plans, 54 | title = "Simple, Transparent Pricing", 55 | description = "Choose the plan that works for you", 56 | }: PricingProps) { 57 | const [isMonthly, setIsMonthly] = useState(true); 58 | const isDesktop = useMediaQuery("(min-width: 768px)"); 59 | const switchRef = useRef<HTMLButtonElement>(null); 60 | 61 | const handleToggle = (checked: boolean) => { 62 | setIsMonthly(!checked); 63 | if (checked && switchRef.current) { 64 | const rect = switchRef.current.getBoundingClientRect(); 65 | const x = rect.left + rect.width / 2; 66 | const y = rect.top + rect.height / 2; 67 | 68 | confetti({ 69 | particleCount: 50, 70 | spread: 60, 71 | origin: { 72 | x: x / window.innerWidth, 73 | y: y / window.innerHeight, 74 | }, 75 | colors: [ 76 | "hsl(var(--primary))", 77 | "hsl(var(--accent))", 78 | "hsl(var(--secondary))", 79 | "hsl(var(--muted))", 80 | ], 81 | ticks: 200, 82 | gravity: 1.2, 83 | decay: 0.94, 84 | startVelocity: 30, 85 | shapes: ["circle"], 86 | }); 87 | } 88 | }; 89 | 90 | return ( 91 | <div className="container py-4"> 92 | <div className="text-center space-y-4 mb-3"> 93 | <h2 className="text-2xl font-bold tracking-tight sm:text-3xl"> 94 | {title} 95 | </h2> 96 | <p className="text-muted-foreground whitespace-pre-line"> 97 | {description} 98 | </p> 99 | </div> 100 | 101 | <div className="flex justify-center mb-10"> 102 | <label className="relative inline-flex items-center cursor-pointer"> 103 | <Label> 104 | <Switch 105 | ref={switchRef as any} 106 | checked={!isMonthly} 107 | onCheckedChange={handleToggle} 108 | className="relative" 109 | /> 110 | </Label> 111 | </label> 112 | <span className="ml-2 font-semibold"> 113 | Annual billing <span className="text-primary">(Save 20%)</span> 114 | </span> 115 | </div> 116 | 117 | <div className="grid grid-cols-1 md:grid-cols-3 sm:2 gap-4"> 118 | {plans.map((plan, index) => ( 119 | <motion.div 120 | key={index} 121 | initial={{ y: 50, opacity: 1 }} 122 | whileInView={ 123 | isDesktop 124 | ? { 125 | y: plan.isPopular ? -20 : 0, 126 | opacity: 1, 127 | x: index === 2 ? -30 : index === 0 ? 30 : 0, 128 | scale: index === 0 || index === 2 ? 0.94 : 1.0, 129 | } 130 | : {} 131 | } 132 | viewport={{ once: true }} 133 | transition={{ 134 | duration: 1.6, 135 | type: "spring", 136 | stiffness: 100, 137 | damping: 30, 138 | delay: 0.4, 139 | opacity: { duration: 0.5 }, 140 | }} 141 | className={cn( 142 | `rounded-sm border p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`, 143 | plan.isPopular ? "border-border border-2" : "border-border", 144 | "flex flex-col", 145 | !plan.isPopular && "mt-5", 146 | index === 0 || index === 2 147 | ? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-10" 148 | : "z-10", 149 | index === 0 && "origin-right", 150 | index === 2 && "origin-left", 151 | )} 152 | > 153 | {plan.isPopular && ( 154 | <div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-sm rounded-tr-sm flex items-center"> 155 | <Star className="text-primary-foreground h-4 w-4 fill-current" /> 156 | <span className="text-primary-foreground ml-1 font-sans font-semibold"> 157 | Popular 158 | </span> 159 | </div> 160 | )} 161 | <div className="flex-1 flex flex-col"> 162 | <p className="text-base font-semibold text-muted-foreground mt-2"> 163 | {plan.name} 164 | </p> 165 | <div className="mt-6 flex items-center justify-center gap-x-2"> 166 | <span className="text-5xl font-bold tracking-tight text-foreground"> 167 | <NumberFlow 168 | value={ 169 | isMonthly ? Number(plan.price) : Number(plan.yearlyPrice) 170 | } 171 | format={{ 172 | style: "currency", 173 | currency: "USD", 174 | minimumFractionDigits: 0, 175 | maximumFractionDigits: 0, 176 | }} 177 | transformTiming={{ 178 | duration: 500, 179 | easing: "ease-out", 180 | }} 181 | willChange 182 | className="font-variant-numeric: tabular-nums" 183 | /> 184 | </span> 185 | {plan.period !== "Next 3 months" && ( 186 | <span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground"> 187 | / {plan.period} 188 | </span> 189 | )} 190 | </div> 191 | 192 | <p className="text-xs leading-5 text-muted-foreground"> 193 | {isMonthly ? "billed monthly" : "billed annually"} 194 | </p> 195 | 196 | <ul className="mt-5 gap-2 flex flex-col"> 197 | {plan.features.map((feature, idx) => ( 198 | <li key={idx} className="flex items-start gap-2"> 199 | <CheckIcon className="h-4 w-4 text-primary mt-1 shrink-0" /> 200 | <span className="text-left">{feature}</span> 201 | </li> 202 | ))} 203 | </ul> 204 | 205 | <hr className="w-full my-4" /> 206 | <Button 207 | onClick={async () => { 208 | await client.subscription.upgrade({ 209 | plan: plan.name.toLowerCase(), 210 | successUrl: "/dashboard", 211 | }); 212 | }} 213 | className={cn( 214 | buttonVariants({ 215 | variant: "outline", 216 | }), 217 | "group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter", 218 | "transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground", 219 | plan.isPopular 220 | ? "bg-primary text-primary-foreground" 221 | : "bg-background text-foreground", 222 | )} 223 | > 224 | {plan.buttonText} 225 | </Button> 226 | <p className="mt-6 text-xs leading-5 text-muted-foreground"> 227 | {plan.description} 228 | </p> 229 | </div> 230 | </motion.div> 231 | ))} 232 | </div> 233 | </div> 234 | ); 235 | } 236 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/cognito.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; 3 | import { BetterAuthError } from "../error"; 4 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 5 | import { createAuthorizationURL, validateAuthorizationCode } from "../oauth2"; 6 | import { logger } from "../env"; 7 | import { refreshAccessToken } from "../oauth2"; 8 | import { APIError } from "better-call"; 9 | 10 | export interface CognitoProfile { 11 | sub: string; 12 | email: string; 13 | email_verified: boolean; 14 | name: string; 15 | given_name?: string; 16 | family_name?: string; 17 | picture?: string; 18 | username?: string; 19 | locale?: string; 20 | phone_number?: string; 21 | phone_number_verified?: boolean; 22 | aud: string; 23 | iss: string; 24 | exp: number; 25 | iat: number; 26 | // Custom attributes from Cognito can be added here 27 | [key: string]: any; 28 | } 29 | 30 | export interface CognitoOptions extends ProviderOptions<CognitoProfile> { 31 | clientId: string; 32 | /** 33 | * The Cognito domain (e.g., "your-app.auth.us-east-1.amazoncognito.com") 34 | */ 35 | domain: string; 36 | /** 37 | * AWS region where User Pool is hosted (e.g., "us-east-1") 38 | */ 39 | region: string; 40 | userPoolId: string; 41 | requireClientSecret?: boolean; 42 | } 43 | 44 | export const cognito = (options: CognitoOptions) => { 45 | if (!options.domain || !options.region || !options.userPoolId) { 46 | logger.error( 47 | "Domain, region and userPoolId are required for Amazon Cognito. Make sure to provide them in the options.", 48 | ); 49 | throw new BetterAuthError("DOMAIN_AND_REGION_REQUIRED"); 50 | } 51 | 52 | const cleanDomain = options.domain.replace(/^https?:\/\//, ""); 53 | const authorizationEndpoint = `https://${cleanDomain}/oauth2/authorize`; 54 | const tokenEndpoint = `https://${cleanDomain}/oauth2/token`; 55 | const userInfoEndpoint = `https://${cleanDomain}/oauth2/userinfo`; 56 | 57 | return { 58 | id: "cognito", 59 | name: "Cognito", 60 | async createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) { 61 | if (!options.clientId) { 62 | logger.error( 63 | "ClientId is required for Amazon Cognito. Make sure to provide them in the options.", 64 | ); 65 | throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED"); 66 | } 67 | 68 | if (options.requireClientSecret && !options.clientSecret) { 69 | logger.error( 70 | "Client Secret is required when requireClientSecret is true. Make sure to provide it in the options.", 71 | ); 72 | throw new BetterAuthError("CLIENT_SECRET_REQUIRED"); 73 | } 74 | const _scopes = options.disableDefaultScope 75 | ? [] 76 | : ["openid", "profile", "email"]; 77 | options.scope && _scopes.push(...options.scope); 78 | scopes && _scopes.push(...scopes); 79 | 80 | const url = await createAuthorizationURL({ 81 | id: "cognito", 82 | options: { 83 | ...options, 84 | }, 85 | authorizationEndpoint, 86 | scopes: _scopes, 87 | state, 88 | codeVerifier, 89 | redirectURI, 90 | prompt: options.prompt, 91 | }); 92 | return url; 93 | }, 94 | 95 | validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { 96 | return validateAuthorizationCode({ 97 | code, 98 | codeVerifier, 99 | redirectURI, 100 | options, 101 | tokenEndpoint, 102 | }); 103 | }, 104 | 105 | refreshAccessToken: options.refreshAccessToken 106 | ? options.refreshAccessToken 107 | : async (refreshToken) => { 108 | return refreshAccessToken({ 109 | refreshToken, 110 | options: { 111 | clientId: options.clientId, 112 | clientKey: options.clientKey, 113 | clientSecret: options.clientSecret, 114 | }, 115 | tokenEndpoint, 116 | }); 117 | }, 118 | 119 | async verifyIdToken(token, nonce) { 120 | if (options.disableIdTokenSignIn) { 121 | return false; 122 | } 123 | if (options.verifyIdToken) { 124 | return options.verifyIdToken(token, nonce); 125 | } 126 | 127 | try { 128 | const decodedHeader = decodeProtectedHeader(token); 129 | const { kid, alg: jwtAlg } = decodedHeader; 130 | if (!kid || !jwtAlg) return false; 131 | 132 | const publicKey = await getCognitoPublicKey( 133 | kid, 134 | options.region, 135 | options.userPoolId, 136 | ); 137 | const expectedIssuer = `https://cognito-idp.${options.region}.amazonaws.com/${options.userPoolId}`; 138 | 139 | const { payload: jwtClaims } = await jwtVerify(token, publicKey, { 140 | algorithms: [jwtAlg], 141 | issuer: expectedIssuer, 142 | audience: options.clientId, 143 | maxTokenAge: "1h", 144 | }); 145 | 146 | if (nonce && jwtClaims.nonce !== nonce) { 147 | return false; 148 | } 149 | return true; 150 | } catch (error) { 151 | logger.error("Failed to verify ID token:", error); 152 | return false; 153 | } 154 | }, 155 | 156 | async getUserInfo(token) { 157 | if (options.getUserInfo) { 158 | return options.getUserInfo(token); 159 | } 160 | 161 | if (token.idToken) { 162 | try { 163 | const profile = decodeJwt<CognitoProfile>(token.idToken); 164 | if (!profile) { 165 | return null; 166 | } 167 | const name = 168 | profile.name || 169 | profile.given_name || 170 | profile.username || 171 | profile.email; 172 | const enrichedProfile = { 173 | ...profile, 174 | name, 175 | }; 176 | const userMap = await options.mapProfileToUser?.(enrichedProfile); 177 | 178 | return { 179 | user: { 180 | id: profile.sub, 181 | name: enrichedProfile.name, 182 | email: profile.email, 183 | image: profile.picture, 184 | emailVerified: profile.email_verified, 185 | ...userMap, 186 | }, 187 | data: enrichedProfile, 188 | }; 189 | } catch (error) { 190 | logger.error("Failed to decode ID token:", error); 191 | } 192 | } 193 | 194 | if (token.accessToken) { 195 | try { 196 | const { data: userInfo } = await betterFetch<CognitoProfile>( 197 | userInfoEndpoint, 198 | { 199 | headers: { 200 | Authorization: `Bearer ${token.accessToken}`, 201 | }, 202 | }, 203 | ); 204 | 205 | if (userInfo) { 206 | const userMap = await options.mapProfileToUser?.(userInfo); 207 | return { 208 | user: { 209 | id: userInfo.sub, 210 | name: userInfo.name || userInfo.given_name || userInfo.username, 211 | email: userInfo.email, 212 | image: userInfo.picture, 213 | emailVerified: userInfo.email_verified, 214 | ...userMap, 215 | }, 216 | data: userInfo, 217 | }; 218 | } 219 | } catch (error) { 220 | logger.error("Failed to fetch user info from Cognito:", error); 221 | } 222 | } 223 | 224 | return null; 225 | }, 226 | 227 | options, 228 | } satisfies OAuthProvider<CognitoProfile>; 229 | }; 230 | 231 | export const getCognitoPublicKey = async ( 232 | kid: string, 233 | region: string, 234 | userPoolId: string, 235 | ) => { 236 | const COGNITO_JWKS_URI = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`; 237 | 238 | try { 239 | const { data } = await betterFetch<{ 240 | keys: Array<{ 241 | kid: string; 242 | alg: string; 243 | kty: string; 244 | use: string; 245 | n: string; 246 | e: string; 247 | }>; 248 | }>(COGNITO_JWKS_URI); 249 | 250 | if (!data?.keys) { 251 | throw new APIError("BAD_REQUEST", { 252 | message: "Keys not found", 253 | }); 254 | } 255 | 256 | const jwk = data.keys.find((key) => key.kid === kid); 257 | if (!jwk) { 258 | throw new Error(`JWK with kid ${kid} not found`); 259 | } 260 | 261 | return await importJWK(jwk, jwk.alg); 262 | } catch (error) { 263 | logger.error("Failed to fetch Cognito public key:", error); 264 | throw error; 265 | } 266 | }; 267 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { InferOptionSchema } from "../../types"; 2 | import type { Statements } from "../access"; 3 | import type { apiKeySchema } from "./schema"; 4 | import type { 5 | GenericEndpointContext, 6 | HookEndpointContext, 7 | } from "@better-auth/core"; 8 | export interface ApiKeyOptions { 9 | /** 10 | * The header name to check for API key 11 | * @default "x-api-key" 12 | */ 13 | apiKeyHeaders?: string | string[]; 14 | /** 15 | * Disable hashing of the API key. 16 | * 17 | * ⚠️ Security Warning: It's strongly recommended to not disable hashing. 18 | * Storing API keys in plaintext makes them vulnerable to database breaches, potentially exposing all your users' API keys. 19 | * 20 | * @default false 21 | */ 22 | disableKeyHashing?: boolean; 23 | /** 24 | * The function to get the API key from the context 25 | */ 26 | customAPIKeyGetter?: (ctx: HookEndpointContext) => string | null; 27 | /** 28 | * A custom function to validate the API key 29 | */ 30 | customAPIKeyValidator?: (options: { 31 | ctx: GenericEndpointContext; 32 | key: string; 33 | }) => boolean | Promise<boolean>; 34 | /** 35 | * custom key generation function 36 | */ 37 | customKeyGenerator?: (options: { 38 | /** 39 | * The length of the API key to generate 40 | */ 41 | length: number; 42 | /** 43 | * The prefix of the API key to generate 44 | */ 45 | prefix: string | undefined; 46 | }) => string | Promise<string>; 47 | /** 48 | * The configuration for storing the starting characters of the API key in the database. 49 | * 50 | * Useful if you want to display the starting characters of an API key in the UI. 51 | */ 52 | startingCharactersConfig?: { 53 | /** 54 | * Whether to store the starting characters in the database. If false, we will set `start` to `null`. 55 | * 56 | * @default true 57 | */ 58 | shouldStore?: boolean; 59 | /** 60 | * The length of the starting characters to store in the database. 61 | * 62 | * This includes the prefix length. 63 | * 64 | * @default 6 65 | */ 66 | charactersLength?: number; 67 | }; 68 | /** 69 | * The length of the API key. Longer is better. Default is 64. (Doesn't include the prefix length) 70 | * @default 64 71 | */ 72 | defaultKeyLength?: number; 73 | /** 74 | * The prefix of the API key. 75 | * 76 | * Note: We recommend you append an underscore to the prefix to make the prefix more identifiable. (eg `hello_`) 77 | */ 78 | defaultPrefix?: string; 79 | /** 80 | * The maximum length of the prefix. 81 | * 82 | * @default 32 83 | */ 84 | maximumPrefixLength?: number; 85 | /** 86 | * Whether to require a name for the API key. 87 | * 88 | * @default false 89 | */ 90 | requireName?: boolean; 91 | /** 92 | * The minimum length of the prefix. 93 | * 94 | * @default 1 95 | */ 96 | minimumPrefixLength?: number; 97 | /** 98 | * The maximum length of the name. 99 | * 100 | * @default 32 101 | */ 102 | maximumNameLength?: number; 103 | /** 104 | * The minimum length of the name. 105 | * 106 | * @default 1 107 | */ 108 | minimumNameLength?: number; 109 | /** 110 | * Whether to enable metadata for an API key. 111 | * 112 | * @default false 113 | */ 114 | enableMetadata?: boolean; 115 | /** 116 | * Customize the key expiration. 117 | */ 118 | keyExpiration?: { 119 | /** 120 | * The default expires time in milliseconds. 121 | * 122 | * If `null`, then there will be no expiration time. 123 | * 124 | * @default null 125 | */ 126 | defaultExpiresIn?: number | null; 127 | /** 128 | * Whether to disable the expires time passed from the client. 129 | * 130 | * If `true`, the expires time will be based on the default values. 131 | * 132 | * @default false 133 | */ 134 | disableCustomExpiresTime?: boolean; 135 | /** 136 | * The minimum expiresIn value allowed to be set from the client. in days. 137 | * 138 | * @default 1 139 | */ 140 | minExpiresIn?: number; 141 | /** 142 | * The maximum expiresIn value allowed to be set from the client. in days. 143 | * 144 | * @default 365 145 | */ 146 | maxExpiresIn?: number; 147 | }; 148 | /** 149 | * Default rate limiting options. 150 | */ 151 | rateLimit?: { 152 | /** 153 | * Whether to enable rate limiting. 154 | * 155 | * @default true 156 | */ 157 | enabled?: boolean; 158 | /** 159 | * The duration in milliseconds where each request is counted. 160 | * 161 | * Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. 162 | * 163 | * @default 1000 * 60 * 60 * 24 // 1 day 164 | */ 165 | timeWindow?: number; 166 | /** 167 | * Maximum amount of requests allowed within a window 168 | * 169 | * Once the `maxRequests` is reached, the request will be rejected until the `timeWindow` has passed, at which point the `timeWindow` will be reset. 170 | * 171 | * @default 10 // 10 requests per day 172 | */ 173 | maxRequests?: number; 174 | }; 175 | /** 176 | * custom schema for the API key plugin 177 | */ 178 | schema?: InferOptionSchema<ReturnType<typeof apiKeySchema>>; 179 | /** 180 | * An API Key can represent a valid session, so we automatically mock a session for the user if we find a valid API key in the request headers. 181 | * 182 | * ⚠︎ This is not recommended for production use, as it can lead to security issues. 183 | * @default false 184 | */ 185 | enableSessionForAPIKeys?: boolean; 186 | /** 187 | * Permissions for the API key. 188 | */ 189 | permissions?: { 190 | /** 191 | * The default permissions for the API key. 192 | */ 193 | defaultPermissions?: 194 | | Statements 195 | | (( 196 | userId: string, 197 | ctx: GenericEndpointContext, 198 | ) => Statements | Promise<Statements>); 199 | }; 200 | } 201 | 202 | export type ApiKey = { 203 | /** 204 | * ID 205 | */ 206 | id: string; 207 | /** 208 | * The name of the key 209 | */ 210 | name: string | null; 211 | /** 212 | * Shows the first few characters of the API key, including the prefix. 213 | * This allows you to show those few characters in the UI to make it easier for users to identify the API key. 214 | */ 215 | start: string | null; 216 | /** 217 | * The API Key prefix. Stored as plain text. 218 | */ 219 | prefix: string | null; 220 | /** 221 | * The hashed API key value 222 | */ 223 | key: string; 224 | /** 225 | * The owner of the user id 226 | */ 227 | userId: string; 228 | /** 229 | * The interval in milliseconds between refills of the `remaining` count 230 | * 231 | * @example 3600000 // refill every hour (3600000ms = 1h) 232 | */ 233 | refillInterval: number | null; 234 | /** 235 | * The amount to refill 236 | */ 237 | refillAmount: number | null; 238 | /** 239 | * The last refill date 240 | */ 241 | lastRefillAt: Date | null; 242 | /** 243 | * Sets if key is enabled or disabled 244 | * 245 | * @default true 246 | */ 247 | enabled: boolean; 248 | /** 249 | * Whether the key has rate limiting enabled. 250 | */ 251 | rateLimitEnabled: boolean; 252 | /** 253 | * The duration in milliseconds 254 | */ 255 | rateLimitTimeWindow: number | null; 256 | /** 257 | * Maximum amount of requests allowed within a window 258 | */ 259 | rateLimitMax: number | null; 260 | /** 261 | * The number of requests made within the rate limit time window 262 | */ 263 | requestCount: number; 264 | /** 265 | * Remaining requests (every time API key is used this should updated and should be updated on refill as well) 266 | */ 267 | remaining: number | null; 268 | /** 269 | * When last request occurred 270 | */ 271 | lastRequest: Date | null; 272 | /** 273 | * Expiry date of a key 274 | */ 275 | expiresAt: Date | null; 276 | /** 277 | * created at 278 | */ 279 | createdAt: Date; 280 | /** 281 | * updated at 282 | */ 283 | updatedAt: Date; 284 | /** 285 | * Extra metadata about the apiKey 286 | */ 287 | metadata: Record<string, any> | null; 288 | /** 289 | * Permissions for the API key 290 | */ 291 | permissions?: { 292 | [key: string]: string[]; 293 | } | null; 294 | }; 295 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/paypal.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import { BetterAuthError } from "../error"; 3 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 4 | import { createAuthorizationURL } from "../oauth2"; 5 | import { logger } from "../env"; 6 | import { decodeJwt } from "jose"; 7 | import { base64 } from "@better-auth/utils/base64"; 8 | 9 | export interface PayPalProfile { 10 | user_id: string; 11 | name: string; 12 | given_name: string; 13 | family_name: string; 14 | middle_name?: string; 15 | picture?: string; 16 | email: string; 17 | email_verified: boolean; 18 | gender?: string; 19 | birthdate?: string; 20 | zoneinfo?: string; 21 | locale?: string; 22 | phone_number?: string; 23 | address?: { 24 | street_address?: string; 25 | locality?: string; 26 | region?: string; 27 | postal_code?: string; 28 | country?: string; 29 | }; 30 | verified_account?: boolean; 31 | account_type?: string; 32 | age_range?: string; 33 | payer_id?: string; 34 | } 35 | 36 | export interface PayPalTokenResponse { 37 | scope?: string; 38 | access_token: string; 39 | refresh_token?: string; 40 | token_type: "Bearer"; 41 | id_token?: string; 42 | expires_in: number; 43 | nonce?: string; 44 | } 45 | 46 | export interface PayPalOptions extends ProviderOptions<PayPalProfile> { 47 | clientId: string; 48 | /** 49 | * PayPal environment - 'sandbox' for testing, 'live' for production 50 | * @default 'sandbox' 51 | */ 52 | environment?: "sandbox" | "live"; 53 | /** 54 | * Whether to request shipping address information 55 | * @default false 56 | */ 57 | requestShippingAddress?: boolean; 58 | } 59 | 60 | export const paypal = (options: PayPalOptions) => { 61 | const environment = options.environment || "sandbox"; 62 | const isSandbox = environment === "sandbox"; 63 | 64 | const authorizationEndpoint = isSandbox 65 | ? "https://www.sandbox.paypal.com/signin/authorize" 66 | : "https://www.paypal.com/signin/authorize"; 67 | 68 | const tokenEndpoint = isSandbox 69 | ? "https://api-m.sandbox.paypal.com/v1/oauth2/token" 70 | : "https://api-m.paypal.com/v1/oauth2/token"; 71 | 72 | const userInfoEndpoint = isSandbox 73 | ? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo" 74 | : "https://api-m.paypal.com/v1/identity/oauth2/userinfo"; 75 | 76 | return { 77 | id: "paypal", 78 | name: "PayPal", 79 | async createAuthorizationURL({ state, codeVerifier, redirectURI }) { 80 | if (!options.clientId || !options.clientSecret) { 81 | logger.error( 82 | "Client Id and Client Secret is required for PayPal. Make sure to provide them in the options.", 83 | ); 84 | throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED"); 85 | } 86 | 87 | /** 88 | * Log in with PayPal doesn't use traditional OAuth2 scopes 89 | * Instead, permissions are configured in the PayPal Developer Dashboard 90 | * We don't pass any scopes to avoid "invalid scope" errors 91 | **/ 92 | 93 | const _scopes: string[] = []; 94 | 95 | const url = await createAuthorizationURL({ 96 | id: "paypal", 97 | options, 98 | authorizationEndpoint, 99 | scopes: _scopes, 100 | state, 101 | codeVerifier, 102 | redirectURI, 103 | prompt: options.prompt, 104 | }); 105 | return url; 106 | }, 107 | 108 | validateAuthorizationCode: async ({ code, redirectURI }) => { 109 | /** 110 | * PayPal requires Basic Auth for token exchange 111 | **/ 112 | 113 | const credentials = base64.encode( 114 | `${options.clientId}:${options.clientSecret}`, 115 | ); 116 | 117 | try { 118 | const response = await betterFetch(tokenEndpoint, { 119 | method: "POST", 120 | headers: { 121 | Authorization: `Basic ${credentials}`, 122 | Accept: "application/json", 123 | "Accept-Language": "en_US", 124 | "Content-Type": "application/x-www-form-urlencoded", 125 | }, 126 | body: new URLSearchParams({ 127 | grant_type: "authorization_code", 128 | code: code, 129 | redirect_uri: redirectURI, 130 | }).toString(), 131 | }); 132 | 133 | if (!response.data) { 134 | throw new BetterAuthError("FAILED_TO_GET_ACCESS_TOKEN"); 135 | } 136 | 137 | const data = response.data as PayPalTokenResponse; 138 | 139 | const result = { 140 | accessToken: data.access_token, 141 | refreshToken: data.refresh_token, 142 | accessTokenExpiresAt: data.expires_in 143 | ? new Date(Date.now() + data.expires_in * 1000) 144 | : undefined, 145 | idToken: data.id_token, 146 | }; 147 | 148 | return result; 149 | } catch (error) { 150 | logger.error("PayPal token exchange failed:", error); 151 | throw new BetterAuthError("FAILED_TO_GET_ACCESS_TOKEN"); 152 | } 153 | }, 154 | 155 | refreshAccessToken: options.refreshAccessToken 156 | ? options.refreshAccessToken 157 | : async (refreshToken) => { 158 | const credentials = base64.encode( 159 | `${options.clientId}:${options.clientSecret}`, 160 | ); 161 | 162 | try { 163 | const response = await betterFetch(tokenEndpoint, { 164 | method: "POST", 165 | headers: { 166 | Authorization: `Basic ${credentials}`, 167 | Accept: "application/json", 168 | "Accept-Language": "en_US", 169 | "Content-Type": "application/x-www-form-urlencoded", 170 | }, 171 | body: new URLSearchParams({ 172 | grant_type: "refresh_token", 173 | refresh_token: refreshToken, 174 | }).toString(), 175 | }); 176 | 177 | if (!response.data) { 178 | throw new BetterAuthError("FAILED_TO_REFRESH_ACCESS_TOKEN"); 179 | } 180 | 181 | const data = response.data as any; 182 | return { 183 | accessToken: data.access_token, 184 | refreshToken: data.refresh_token, 185 | accessTokenExpiresAt: data.expires_in 186 | ? new Date(Date.now() + data.expires_in * 1000) 187 | : undefined, 188 | }; 189 | } catch (error) { 190 | logger.error("PayPal token refresh failed:", error); 191 | throw new BetterAuthError("FAILED_TO_REFRESH_ACCESS_TOKEN"); 192 | } 193 | }, 194 | 195 | async verifyIdToken(token, nonce) { 196 | if (options.disableIdTokenSignIn) { 197 | return false; 198 | } 199 | if (options.verifyIdToken) { 200 | return options.verifyIdToken(token, nonce); 201 | } 202 | try { 203 | const payload = decodeJwt(token); 204 | return !!payload.sub; 205 | } catch (error) { 206 | logger.error("Failed to verify PayPal ID token:", error); 207 | return false; 208 | } 209 | }, 210 | 211 | async getUserInfo(token) { 212 | if (options.getUserInfo) { 213 | return options.getUserInfo(token); 214 | } 215 | 216 | if (!token.accessToken) { 217 | logger.error("Access token is required to fetch PayPal user info"); 218 | return null; 219 | } 220 | 221 | try { 222 | const response = await betterFetch<PayPalProfile>( 223 | `${userInfoEndpoint}?schema=paypalv1.1`, 224 | { 225 | headers: { 226 | Authorization: `Bearer ${token.accessToken}`, 227 | Accept: "application/json", 228 | }, 229 | }, 230 | ); 231 | 232 | if (!response.data) { 233 | logger.error("Failed to fetch user info from PayPal"); 234 | return null; 235 | } 236 | 237 | const userInfo = response.data; 238 | const userMap = await options.mapProfileToUser?.(userInfo); 239 | 240 | const result = { 241 | user: { 242 | id: userInfo.user_id, 243 | name: userInfo.name, 244 | email: userInfo.email, 245 | image: userInfo.picture, 246 | emailVerified: userInfo.email_verified, 247 | ...userMap, 248 | }, 249 | data: userInfo, 250 | }; 251 | 252 | return result; 253 | } catch (error) { 254 | logger.error("Failed to fetch user info from PayPal:", error); 255 | return null; 256 | } 257 | }, 258 | 259 | options, 260 | } satisfies OAuthProvider<PayPalProfile>; 261 | }; 262 | ```