This is page 45 of 67. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── middleware.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── user-additional-fields.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── sso │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sso.test.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── middleware │ │ │ │ └── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/routes/crud-access-control.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, expectTypeOf } from "vitest"; 2 | import { getTestInstance } from "../../../test-utils/test-instance"; 3 | import { organization } from "../organization"; 4 | import { createAuthClient } from "../../../client"; 5 | import { inferOrgAdditionalFields, organizationClient } from "../client"; 6 | import { createAccessControl } from "../../access"; 7 | import { adminAc, defaultStatements, memberAc, ownerAc } from "../access"; 8 | import { parseSetCookieHeader } from "../../../cookies"; 9 | import type { DBFieldAttribute } from "@better-auth/core/db"; 10 | import { ORGANIZATION_ERROR_CODES } from "../error-codes"; 11 | 12 | describe("dynamic access control", async (it) => { 13 | const ac = createAccessControl({ 14 | project: ["create", "read", "update", "delete"], 15 | sales: ["create", "read", "update", "delete"], 16 | ...defaultStatements, 17 | }); 18 | const owner = ac.newRole({ 19 | project: ["create", "delete", "update", "read"], 20 | sales: ["create", "read", "update", "delete"], 21 | ...ownerAc.statements, 22 | }); 23 | const admin = ac.newRole({ 24 | project: ["create", "read", "delete", "update"], 25 | sales: ["create", "read"], 26 | ...adminAc.statements, 27 | }); 28 | const member = ac.newRole({ 29 | project: ["read"], 30 | sales: ["read"], 31 | ...memberAc.statements, 32 | }); 33 | 34 | const additionalFields = { 35 | color: { 36 | type: "string", 37 | defaultValue: "#ffffff", 38 | required: true, 39 | }, 40 | serverOnlyValue: { 41 | type: "string", 42 | defaultValue: "server-only-value", 43 | input: false, 44 | required: true, 45 | }, 46 | } satisfies Record<string, DBFieldAttribute>; 47 | 48 | const { auth, customFetchImpl, sessionSetter, signInWithTestUser } = 49 | await getTestInstance({ 50 | plugins: [ 51 | organization({ 52 | ac, 53 | roles: { 54 | admin, 55 | member, 56 | owner, 57 | }, 58 | dynamicAccessControl: { 59 | enabled: true, 60 | }, 61 | schema: { 62 | organizationRole: { 63 | additionalFields, 64 | }, 65 | }, 66 | }), 67 | ], 68 | }); 69 | 70 | const authClient = createAuthClient({ 71 | baseURL: "http://localhost:3000", 72 | plugins: [ 73 | organizationClient({ 74 | ac, 75 | roles: { 76 | admin, 77 | member, 78 | owner, 79 | }, 80 | dynamicAccessControl: { 81 | enabled: true, 82 | }, 83 | schema: inferOrgAdditionalFields<typeof auth>(), 84 | }), 85 | ], 86 | fetchOptions: { 87 | customFetchImpl, 88 | }, 89 | }); 90 | const { 91 | organization: { checkRolePermission, hasPermission, create }, 92 | } = authClient; 93 | 94 | const { headers, user, session } = await signInWithTestUser(); 95 | 96 | async function createUser({ role }: { role: "admin" | "member" | "owner" }) { 97 | const normalUserDetails = { 98 | email: `some-test-user-${crypto.randomUUID()}@email.com`, 99 | name: `some-test-user`, 100 | password: `some-test-user-${crypto.randomUUID()}`, 101 | }; 102 | const normalUser = await auth.api.signUpEmail({ body: normalUserDetails }); 103 | const member = await auth.api.addMember({ 104 | body: { 105 | role: role || "member", 106 | userId: normalUser.user.id, 107 | organizationId: org.data?.id, 108 | }, 109 | headers, 110 | }); 111 | if (!member) throw new Error("Member not found"); 112 | let userHeaders = new Headers(); 113 | await authClient.signIn.email({ 114 | email: normalUserDetails.email, 115 | password: normalUserDetails.password, 116 | fetchOptions: { 117 | onSuccess: (context) => { 118 | const header = context.response.headers.get("set-cookie"); 119 | const cookies = parseSetCookieHeader(header || ""); 120 | const signedCookie = cookies.get("better-auth.session_token")?.value; 121 | userHeaders.set( 122 | "cookie", 123 | `better-auth.session_token=${signedCookie}`, 124 | ); 125 | }, 126 | }, 127 | }); 128 | await authClient.organization.setActive({ 129 | organizationId: org.data?.id, 130 | fetchOptions: { 131 | headers: userHeaders, 132 | }, 133 | }); 134 | 135 | return { headers: userHeaders, user: normalUser, member }; 136 | } 137 | 138 | const org = await create( 139 | { 140 | name: "test", 141 | slug: "test", 142 | metadata: { 143 | test: "test", 144 | }, 145 | }, 146 | { 147 | onSuccess: sessionSetter(headers), 148 | headers, 149 | }, 150 | ); 151 | if (!org.data) throw new Error("Organization not created"); 152 | const memberInfo = await auth.api.getActiveMember({ headers }); 153 | if (!memberInfo) throw new Error("Member info not found"); 154 | 155 | // Create an admin user in the org. 156 | const { 157 | headers: adminHeaders, 158 | user: adminUser, 159 | member: adminMember, 160 | } = await createUser({ 161 | role: "admin", 162 | }); 163 | 164 | // Create normal users in the org. 165 | const { 166 | headers: normalHeaders, 167 | user: normalUser, 168 | member: normalMember, 169 | } = await createUser({ 170 | role: "member", 171 | }); 172 | 173 | /** 174 | * The following test will: 175 | * - Creation of a new role 176 | * - Updating their own role to the newly created one (from owner to the new one) 177 | * - Tests the `hasPermission` endpoint against the new role, for both a success and a failure case. 178 | * - Additional fields passed in body, and correct return value & types. 179 | */ 180 | it("should successfully create a new role", async () => { 181 | // Create a new "test" role with permissions to create a project. 182 | const permission = { 183 | project: ["create"], 184 | }; 185 | const testRole = await authClient.organization.createRole( 186 | { 187 | role: "test", 188 | permission, 189 | additionalFields: { 190 | color: "#000000", 191 | }, 192 | }, 193 | { 194 | headers, 195 | }, 196 | ); 197 | expect(testRole.error).toBeNull(); 198 | expect(testRole.data?.success).toBe(true); 199 | expect(testRole.data?.roleData.permission).toEqual(permission); 200 | expect(testRole.data?.roleData.color).toBe("#000000"); 201 | expect(testRole.data?.roleData.serverOnlyValue).toBe("server-only-value"); 202 | expectTypeOf(testRole.data?.roleData.serverOnlyValue).toEqualTypeOf< 203 | string | undefined 204 | >(); 205 | expectTypeOf(testRole.data?.roleData.role).toEqualTypeOf< 206 | string | undefined 207 | >(); 208 | if (!testRole.data) return; 209 | 210 | // Update the role to use the new one. 211 | 212 | await auth.api.updateMemberRole({ 213 | body: { memberId: normalMember.id, role: testRole.data.roleData.role }, 214 | headers, 215 | }); 216 | 217 | // Test against `hasPermission` endpoint 218 | // Should fail because the user doesn't have the permission to delete a project. 219 | const shouldFail = await auth.api.hasPermission({ 220 | body: { 221 | organizationId: org.data?.id, 222 | permissions: { 223 | project: ["delete"], 224 | }, 225 | }, 226 | headers: normalHeaders, 227 | }); 228 | expect(shouldFail.success).toBe(false); 229 | 230 | // Should pass because the user has the permission to create a project. 231 | const shouldPass = await auth.api.hasPermission({ 232 | body: { 233 | organizationId: org.data?.id, 234 | permissions: { 235 | project: ["create"], 236 | }, 237 | }, 238 | headers: normalHeaders, 239 | }); 240 | expect(shouldPass.success).toBe(true); 241 | }); 242 | 243 | it("should not be allowed to create a role without the right ac resource permissions", async () => { 244 | const testRole = await authClient.organization.createRole( 245 | { 246 | role: `test-${crypto.randomUUID()}`, 247 | permission: { 248 | project: ["create"], 249 | }, 250 | additionalFields: { 251 | color: "#000000", 252 | }, 253 | }, 254 | { 255 | headers: normalHeaders, 256 | }, 257 | ); 258 | expect(testRole.data).toBeNull(); 259 | if (!testRole.error) throw new Error("Test role error not found"); 260 | expect(testRole.error.message).toEqual( 261 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, 262 | ); 263 | }); 264 | 265 | it("should not be allowed to create a role with higher permissions than the current role", async () => { 266 | const testRole = await authClient.organization.createRole( 267 | { 268 | role: `test-${crypto.randomUUID()}`, 269 | permission: { 270 | sales: ["create", "delete", "create", "update", "read"], // Intentionally duplicate the "create" permission. 271 | }, 272 | additionalFields: { 273 | color: "#000000", 274 | }, 275 | }, 276 | { 277 | headers: adminHeaders, 278 | }, 279 | ); 280 | expect(testRole.data).toBeNull(); 281 | if (testRole.data) throw new Error("Test role created"); 282 | expect( 283 | testRole.error.message?.startsWith( 284 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE, 285 | ), 286 | ).toBe(true); 287 | expect("missingPermissions" in testRole.error).toBe(true); 288 | if (!("missingPermissions" in testRole.error)) return; 289 | expect(testRole.error.missingPermissions).toEqual([ 290 | "sales:delete", 291 | "sales:update", 292 | ]); 293 | }); 294 | 295 | it("should not be allowed to create a role which is either predefined or already exists in DB", async () => { 296 | const testRole = await authClient.organization.createRole( 297 | { 298 | role: "admin", // This is a predefined role. 299 | permission: { 300 | project: ["create"], 301 | }, 302 | additionalFields: { 303 | color: "#000000", 304 | }, 305 | }, 306 | { 307 | headers, 308 | }, 309 | ); 310 | expect(testRole.data).toBeNull(); 311 | if (!testRole.error) throw new Error("Test role error not found"); 312 | expect(testRole.error.message).toEqual( 313 | ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN, 314 | ); 315 | 316 | const testRole2 = await authClient.organization.createRole( 317 | { 318 | role: "test", // This is a role that was created in the previous test. 319 | permission: { 320 | project: ["create"], 321 | }, 322 | additionalFields: { 323 | color: "#000000", 324 | }, 325 | }, 326 | { 327 | headers, 328 | }, 329 | ); 330 | expect(testRole2.data).toBeNull(); 331 | if (!testRole2.error) throw new Error("Test role error not found"); 332 | expect(testRole2.error.message).toEqual( 333 | ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN, 334 | ); 335 | }); 336 | 337 | it("should delete a role by id", async () => { 338 | const testRole = await authClient.organization.createRole( 339 | { 340 | role: `test-${crypto.randomUUID()}`, 341 | permission: { 342 | project: ["create"], 343 | }, 344 | additionalFields: { 345 | color: "#000000", 346 | }, 347 | }, 348 | { 349 | headers, 350 | }, 351 | ); 352 | if (!testRole.data) throw testRole.error; 353 | const roleId = testRole.data.roleData.id; 354 | 355 | const res = await auth.api.deleteOrgRole({ 356 | body: { roleId }, 357 | headers, 358 | }); 359 | expect(res).not.toBeNull(); 360 | }); 361 | 362 | it("should delete a role by name", async () => { 363 | const testRole = await authClient.organization.createRole( 364 | { 365 | role: `test-${crypto.randomUUID()}`, 366 | permission: { 367 | project: ["create"], 368 | }, 369 | additionalFields: { 370 | color: "#000000", 371 | }, 372 | }, 373 | { 374 | headers, 375 | }, 376 | ); 377 | if (!testRole.data) throw testRole.error; 378 | const roleName = testRole.data.roleData.role; 379 | 380 | const res = await auth.api.deleteOrgRole({ 381 | body: { roleName }, 382 | headers, 383 | }); 384 | expect(res).not.toBeNull(); 385 | }); 386 | 387 | it("should not be allowed to delete a role without nessesary permissions", async () => { 388 | const testRole = await authClient.organization.createRole( 389 | { 390 | role: `test-${crypto.randomUUID()}`, 391 | permission: { 392 | project: ["create"], 393 | }, 394 | additionalFields: { 395 | color: "#000000", 396 | }, 397 | }, 398 | { 399 | headers: adminHeaders, 400 | }, 401 | ); 402 | if (!testRole.data) throw testRole.error; 403 | expect( 404 | auth.api.deleteOrgRole({ 405 | body: { roleName: testRole.data.roleData.role }, 406 | headers: normalHeaders, 407 | }), 408 | ).rejects.toThrow( 409 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE, 410 | ); 411 | }); 412 | 413 | it("should not be allowed to delete a role that doesn't exist", async () => { 414 | try { 415 | const res = await auth.api.deleteOrgRole({ 416 | body: { roleName: "non-existent-role" }, 417 | headers, 418 | }); 419 | expect(res).toBeNull(); 420 | } catch (error: any) { 421 | if ("body" in error && "message" in error.body) { 422 | expect(error.body.message).toBe( 423 | ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND, 424 | ); 425 | } else { 426 | throw error; 427 | } 428 | } 429 | }); 430 | 431 | it("should list roles", async () => { 432 | const permission = { 433 | project: ["create"], 434 | ac: ["read", "update", "create", "delete"], 435 | }; 436 | await authClient.organization.createRole( 437 | { 438 | role: `list-test-role`, 439 | permission, 440 | additionalFields: { 441 | color: "#123", 442 | }, 443 | }, 444 | { 445 | headers, 446 | }, 447 | ); 448 | 449 | const res = await auth.api.listOrgRoles({ headers }); 450 | expect(res).not.toBeNull(); 451 | expect(res.length).toBeGreaterThan(0); 452 | expect(typeof res[0]!.permission === "string").toBe(false); 453 | const foundRole = res.find((x) => x.role === "list-test-role"); 454 | expect(foundRole).not.toBeNull(); 455 | expect(foundRole?.permission).toEqual(permission); 456 | expect(foundRole?.color).toBe(`#123`); 457 | expectTypeOf(foundRole?.color).toEqualTypeOf<string | undefined>(); 458 | expectTypeOf(foundRole?.serverOnlyValue).toEqualTypeOf< 459 | string | undefined 460 | >(); 461 | }); 462 | 463 | it("should not be allowed to list roles without nessesary permissions", async () => { 464 | expect(auth.api.listOrgRoles({ headers: normalHeaders })).rejects.toThrow( 465 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE, 466 | ); 467 | }); 468 | 469 | it("should get a role by id", async () => { 470 | const testRole = await authClient.organization.createRole( 471 | { 472 | role: `read-test-role-${crypto.randomUUID()}`, 473 | permission: { 474 | project: ["create"], 475 | }, 476 | additionalFields: { 477 | color: "#000000", 478 | }, 479 | }, 480 | { 481 | headers, 482 | }, 483 | ); 484 | if (!testRole.data) throw testRole.error; 485 | const roleId = testRole.data.roleData.id; 486 | const res = await auth.api.getOrgRole({ 487 | query: { 488 | roleId, 489 | organizationId: org.data?.id, 490 | }, 491 | headers, 492 | }); 493 | expect(res).not.toBeNull(); 494 | expect(res.role).toBe(testRole.data.roleData.role); 495 | expect(res.permission).toEqual(testRole.data.roleData.permission); 496 | expect(res.color).toBe("#000000"); 497 | expectTypeOf(res.color).toEqualTypeOf<string>(); 498 | }); 499 | 500 | it("should get a role by name", async () => { 501 | const testRole = await authClient.organization.createRole( 502 | { 503 | role: `read-test-role-${crypto.randomUUID()}`, 504 | permission: { 505 | project: ["create"], 506 | }, 507 | additionalFields: { 508 | color: "#000000", 509 | }, 510 | }, 511 | { 512 | headers, 513 | }, 514 | ); 515 | if (!testRole.data) throw testRole.error; 516 | const roleName = testRole.data.roleData.role; 517 | 518 | const res = await auth.api.getOrgRole({ 519 | query: { 520 | roleName, 521 | organizationId: org.data?.id, 522 | }, 523 | headers, 524 | }); 525 | expect(res).not.toBeNull(); 526 | expect(res.role).toBe(testRole.data.roleData.role); 527 | expect(res.permission).toEqual(testRole.data.roleData.permission); 528 | expect(res.color).toBe("#000000"); 529 | expectTypeOf(res.color).toEqualTypeOf<string>(); 530 | }); 531 | 532 | it("should update a role's permission by id", async () => { 533 | const testRole = await authClient.organization.createRole( 534 | { 535 | role: `update-test-role-${crypto.randomUUID()}`, 536 | permission: { 537 | project: ["create"], 538 | }, 539 | additionalFields: { 540 | color: "#000000", 541 | }, 542 | }, 543 | { 544 | headers, 545 | }, 546 | ); 547 | if (!testRole.data) throw testRole.error; 548 | const roleId = testRole.data.roleData.id; 549 | const res = await auth.api.updateOrgRole({ 550 | body: { 551 | roleId, 552 | data: { permission: { project: ["create", "delete"] } }, 553 | }, 554 | headers, 555 | }); 556 | expect(res).not.toBeNull(); 557 | expect(res.roleData.role).toBe(testRole.data.roleData.role); 558 | expect(res.roleData.permission).toEqual({ project: ["create", "delete"] }); 559 | }); 560 | 561 | it("should update a role's name by name", async () => { 562 | const testRole = await authClient.organization.createRole( 563 | { 564 | role: `test-${crypto.randomUUID()}`, 565 | permission: { 566 | project: ["create"], 567 | }, 568 | additionalFields: { 569 | color: "#000000", 570 | }, 571 | }, 572 | { headers }, 573 | ); 574 | if (!testRole.data) throw testRole.error; 575 | const roleName = testRole.data.roleData.role; 576 | 577 | const res = await auth.api.updateOrgRole({ 578 | body: { roleName, data: { roleName: `updated-${roleName}` } }, 579 | headers, 580 | }); 581 | expect(res).not.toBeNull(); 582 | expect(res.roleData.role).toBe(`updated-${roleName}`); 583 | 584 | const res2 = await auth.api.getOrgRole({ 585 | query: { 586 | roleName: `updated-${roleName}`, 587 | organizationId: org.data?.id, 588 | }, 589 | headers, 590 | }); 591 | expect(res2).not.toBeNull(); 592 | expect(res2.role).toBe(`updated-${roleName}`); 593 | }); 594 | 595 | it("should not be allowed to update a role without the right ac resource permissions", async () => { 596 | const testRole = await authClient.organization.createRole( 597 | { 598 | role: `update-not-allowed-${crypto.randomUUID()}`, 599 | permission: { 600 | project: ["create"], 601 | }, 602 | additionalFields: { 603 | color: "#000000", 604 | }, 605 | }, 606 | { headers }, 607 | ); 608 | if (!testRole.data) throw testRole.error; 609 | const roleId = testRole.data.roleData.id; 610 | await expect( 611 | auth.api.updateOrgRole({ 612 | body: { 613 | roleId, 614 | data: { roleName: `updated-${testRole.data.roleData.role}` }, 615 | }, 616 | headers: normalHeaders, 617 | }), 618 | ).rejects.toThrow(); 619 | }); 620 | 621 | it("should be able to update additional fields", async () => { 622 | const testRole = await authClient.organization.createRole( 623 | { 624 | role: `test-${crypto.randomUUID()}`, 625 | permission: { 626 | project: ["create"], 627 | }, 628 | additionalFields: { 629 | color: "#000000", 630 | //@ts-expect-error - intentionally invalid key 631 | someInvalidKey: "this would be ignored by zod", 632 | }, 633 | }, 634 | { 635 | headers, 636 | }, 637 | ); 638 | if (!testRole.data) throw testRole.error; 639 | const roleId = testRole.data.roleData.id; 640 | const res = await auth.api.updateOrgRole({ 641 | body: { roleId, data: { color: "#111111" } }, 642 | headers, 643 | }); 644 | expect(res).not.toBeNull(); 645 | expect(res.roleData.color).toBe("#111111"); 646 | //@ts-expect-error - intentionally invalid key 647 | expect(res.roleData.someInvalidKey).toBeUndefined(); 648 | }); 649 | 650 | /** 651 | * Security test cases for the privilege escalation vulnerability fix 652 | * These tests verify that member queries properly filter by userId to prevent 653 | * unauthorized privilege escalation where any member could gain admin permissions 654 | */ 655 | it("should not allow member to list roles using another member's permissions", async () => { 656 | // Create a fresh member for this test to avoid role contamination 657 | const { 658 | headers: freshMemberHeaders, 659 | user: freshMemberUser, 660 | member: freshMember, 661 | } = await createUser({ 662 | role: "member", 663 | }); 664 | 665 | // Create a test role that only admin can read 666 | const adminOnlyRole = await authClient.organization.createRole( 667 | { 668 | role: `admin-only-${crypto.randomUUID()}`, 669 | permission: { 670 | project: ["delete"], 671 | }, 672 | additionalFields: { 673 | color: "#ff0000", 674 | }, 675 | }, 676 | { 677 | headers, 678 | }, 679 | ); 680 | if (!adminOnlyRole.data) throw adminOnlyRole.error; 681 | 682 | // Try to list roles as a regular member - should succeed but with member permissions 683 | const listAsMembers = await auth.api.listOrgRoles({ 684 | query: { organizationId: org.data?.id }, 685 | headers: freshMemberHeaders, 686 | }); 687 | 688 | // Member should be able to list roles (they have ac:read permission) 689 | expect(listAsMembers).toBeDefined(); 690 | expect(Array.isArray(listAsMembers)).toBe(true); 691 | }); 692 | 693 | it("should not allow member to get role details using another member's permissions", async () => { 694 | // Create a fresh member for this test to avoid role contamination 695 | const { 696 | headers: freshMemberHeaders, 697 | user: freshMemberUser, 698 | member: freshMember, 699 | } = await createUser({ 700 | role: "member", 701 | }); 702 | 703 | // Create a test role 704 | const testRole = await authClient.organization.createRole( 705 | { 706 | role: `test-get-role-${crypto.randomUUID()}`, 707 | permission: { 708 | project: ["read"], 709 | }, 710 | additionalFields: { 711 | color: "#ff0000", 712 | }, 713 | }, 714 | { 715 | headers, 716 | }, 717 | ); 718 | if (!testRole.data) throw testRole.error; 719 | 720 | // Try to get role as a regular member - should succeed with member permissions 721 | const getRoleAsMember = await auth.api.getOrgRole({ 722 | query: { 723 | organizationId: org.data?.id, 724 | roleId: testRole.data.roleData.id, 725 | }, 726 | headers: freshMemberHeaders, 727 | }); 728 | 729 | // Member should be able to read the role (they have ac:read permission) 730 | expect(getRoleAsMember).toBeDefined(); 731 | expect(getRoleAsMember.id).toBe(testRole.data.roleData.id); 732 | }); 733 | 734 | it("should not allow member to update roles without proper permissions (privilege escalation test)", async () => { 735 | // Create a fresh member for this test to avoid role contamination 736 | const { 737 | headers: freshMemberHeaders, 738 | user: freshMemberUser, 739 | member: freshMember, 740 | } = await createUser({ 741 | role: "member", 742 | }); 743 | 744 | // Create a test role that the owner will create 745 | const vulnerableRole = await authClient.organization.createRole( 746 | { 747 | role: `vulnerable-role-${crypto.randomUUID()}`, 748 | permission: { 749 | project: ["read"], 750 | }, 751 | additionalFields: { 752 | color: "#ff0000", 753 | }, 754 | }, 755 | { 756 | headers, // owner headers 757 | }, 758 | ); 759 | if (!vulnerableRole.data) throw vulnerableRole.error; 760 | 761 | // Regular member should NOT be able to update the role 762 | // This tests the privilege escalation vulnerability fix 763 | await expect( 764 | auth.api.updateOrgRole({ 765 | body: { 766 | roleId: vulnerableRole.data.roleData.id, 767 | data: { 768 | permission: { 769 | ac: ["create", "update", "delete"], // Try to escalate privileges 770 | organization: ["update", "delete"], 771 | project: ["create", "read", "update", "delete"], 772 | }, 773 | }, 774 | }, 775 | headers: freshMemberHeaders, // member headers 776 | }), 777 | ).rejects.toThrow(); 778 | 779 | // Verify the role permissions haven't changed 780 | const roleCheck = await auth.api.getOrgRole({ 781 | query: { 782 | organizationId: org.data?.id, 783 | roleId: vulnerableRole.data.roleData.id, 784 | }, 785 | headers, 786 | }); 787 | expect(roleCheck.permission).toEqual({ 788 | project: ["read"], 789 | }); 790 | }); 791 | 792 | it("should properly identify the correct member when checking permissions", async () => { 793 | // Create a fresh member for this test to avoid role contamination 794 | const { 795 | headers: freshMemberHeaders, 796 | user: freshMemberUser, 797 | member: freshMember, 798 | } = await createUser({ 799 | role: "member", 800 | }); 801 | 802 | // This test ensures that the member lookup uses both organizationId AND userId 803 | // Create a role that only owner can update 804 | const ownerOnlyRole = await authClient.organization.createRole( 805 | { 806 | role: `owner-only-update-${crypto.randomUUID()}`, 807 | permission: { 808 | sales: ["delete"], 809 | }, 810 | additionalFields: { 811 | color: "#ff0000", 812 | }, 813 | }, 814 | { 815 | headers, // owner headers 816 | }, 817 | ); 818 | if (!ownerOnlyRole.data) throw ownerOnlyRole.error; 819 | 820 | // Member should not be able to update (doesn't have ac:update) 821 | await expect( 822 | auth.api.updateOrgRole({ 823 | body: { 824 | roleId: ownerOnlyRole.data.roleData.id, 825 | data: { 826 | roleName: "hijacked-role", 827 | }, 828 | }, 829 | headers: freshMemberHeaders, 830 | }), 831 | ).rejects.toThrow( 832 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE, 833 | ); 834 | 835 | // Admin should be able to update (has ac:update) 836 | const adminUpdate = await auth.api.updateOrgRole({ 837 | body: { 838 | roleId: ownerOnlyRole.data.roleData.id, 839 | data: { 840 | roleName: `admin-updated-${ownerOnlyRole.data.roleData.role}`, 841 | }, 842 | }, 843 | headers: adminHeaders, 844 | }); 845 | expect(adminUpdate).toBeDefined(); 846 | expect(adminUpdate.roleData.role).toContain("admin-updated"); 847 | }); 848 | 849 | it("should not allow cross-organization privilege escalation", async () => { 850 | // Create a fresh member for this test to avoid role contamination 851 | const { 852 | headers: freshMemberHeaders, 853 | user: freshMemberUser, 854 | member: freshMember, 855 | } = await createUser({ 856 | role: "member", 857 | }); 858 | 859 | // Create a second organization 860 | const org2 = await authClient.organization.create( 861 | { 862 | name: "second-org", 863 | slug: `second-org-${crypto.randomUUID()}`, 864 | }, 865 | { 866 | onSuccess: sessionSetter(headers), 867 | headers, 868 | }, 869 | ); 870 | if (!org2.data) throw new Error("Second organization not created"); 871 | 872 | // Try to list roles from org1 while active in org2 - should fail 873 | await authClient.organization.setActive({ 874 | organizationId: org2.data.id, 875 | fetchOptions: { 876 | headers: freshMemberHeaders, 877 | }, 878 | }); 879 | 880 | // This should fail because the member is not in org2 881 | await expect( 882 | auth.api.listOrgRoles({ 883 | query: { organizationId: org2.data.id }, 884 | headers: freshMemberHeaders, 885 | }), 886 | ).rejects.toThrow("You are not a member of this organization"); 887 | 888 | // Switch back to org1 889 | await authClient.organization.setActive({ 890 | organizationId: org.data?.id, 891 | fetchOptions: { 892 | headers: freshMemberHeaders, 893 | }, 894 | }); 895 | }); 896 | }); 897 | ``` -------------------------------------------------------------------------------- /docs/components/builder/index.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { Moon, PlusIcon, Sun } from "lucide-react"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogDescription, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "../ui/dialog"; 10 | import { 11 | Card, 12 | CardContent, 13 | CardFooter, 14 | CardHeader, 15 | CardTitle, 16 | } from "../ui/card"; 17 | import SignIn from "./sign-in"; 18 | import { SignUp } from "./sign-up"; 19 | import { AuthTabs } from "./tabs"; 20 | import { Label } from "../ui/label"; 21 | import { Switch } from "../ui/switch"; 22 | import { Separator } from "../ui/separator"; 23 | import { useState } from "react"; 24 | import CodeTabs from "./code-tabs"; 25 | import { cn } from "@/lib/utils"; 26 | import { socialProviders } from "./social-provider"; 27 | import { useAtom } from "jotai"; 28 | import { optionsAtom } from "./store"; 29 | import { useTheme } from "next-themes"; 30 | import { ScrollArea } from "../ui/scroll-area"; 31 | const frameworks = [ 32 | { 33 | title: "Next.js", 34 | description: "The React Framework for Production", 35 | Icon: () => ( 36 | <svg 37 | xmlns="http://www.w3.org/2000/svg" 38 | width="2em" 39 | height="2em" 40 | viewBox="0 0 15 15" 41 | > 42 | <path 43 | fill="currentColor" 44 | fillRule="evenodd" 45 | d="M0 7.5a7.5 7.5 0 1 1 11.698 6.216L4.906 4.21A.5.5 0 0 0 4 4.5V12h1V6.06l5.83 8.162A7.5 7.5 0 0 1 0 7.5M10 10V4h1v6z" 46 | clipRule="evenodd" 47 | ></path> 48 | </svg> 49 | ), 50 | }, 51 | { 52 | title: "Nuxt", 53 | description: "The Intuitive Vue Framework", 54 | Icon: () => ( 55 | <svg 56 | xmlns="http://www.w3.org/2000/svg" 57 | width="2em" 58 | height="2em" 59 | viewBox="0 0 256 256" 60 | > 61 | <g fill="none"> 62 | <rect width="256" height="256" fill="#242938" rx="60"></rect> 63 | <path 64 | fill="#00DC82" 65 | d="M138.787 189.333h68.772c2.184.001 4.33-.569 6.222-1.652a12.4 12.4 0 0 0 4.554-4.515a12.24 12.24 0 0 0-.006-12.332l-46.185-79.286a12.4 12.4 0 0 0-4.553-4.514a12.53 12.53 0 0 0-12.442 0a12.4 12.4 0 0 0-4.553 4.514l-11.809 20.287l-23.09-39.67a12.4 12.4 0 0 0-4.555-4.513a12.54 12.54 0 0 0-12.444 0a12.4 12.4 0 0 0-4.555 4.513L36.67 170.834a12.24 12.24 0 0 0-.005 12.332a12.4 12.4 0 0 0 4.554 4.515a12.5 12.5 0 0 0 6.222 1.652h43.17c17.104 0 29.718-7.446 38.397-21.973l21.072-36.169l11.287-19.356l33.873 58.142h-45.16zm-48.88-19.376l-30.127-.007l45.16-77.518l22.533 38.759l-15.087 25.906c-5.764 9.426-12.312 12.86-22.48 12.86" 66 | ></path> 67 | </g> 68 | </svg> 69 | ), 70 | }, 71 | { 72 | title: "SvelteKit", 73 | description: "Web development for the rest of us", 74 | Icon: () => ( 75 | <svg 76 | xmlns="http://www.w3.org/2000/svg" 77 | width="2em" 78 | height="2em" 79 | viewBox="0 0 256 256" 80 | > 81 | <g fill="none"> 82 | <rect width="256" height="256" fill="#FF3E00" rx="60"></rect> 83 | <g clipPath="url(#skillIconsSvelte0)"> 84 | <path 85 | fill="#fff" 86 | d="M193.034 61.797c-16.627-23.95-49.729-30.966-73.525-15.865L77.559 72.78c-11.44 7.17-19.372 18.915-21.66 32.186c-1.984 11.136-.306 22.576 5.033 32.492c-3.66 5.491-6.102 11.593-7.17 18c-2.44 13.576.764 27.61 8.696 38.745c16.78 23.95 49.728 30.966 73.525 15.865l41.949-26.695c11.441-7.17 19.373-18.915 21.661-32.187c1.983-11.135.305-22.576-5.034-32.491c3.661-5.492 6.102-11.593 7.17-18c2.593-13.729-.61-27.763-8.695-38.898" 87 | ></path> 88 | <path 89 | fill="#FF3E00" 90 | d="M115.39 196.491a33.25 33.25 0 0 1-35.695-13.271c-4.881-6.712-6.712-15.101-5.34-23.339c.306-1.373.611-2.593.916-3.966l.763-2.44L78.169 155a55.6 55.6 0 0 0 16.475 8.237l1.525.458l-.152 1.525c-.153 2.136.458 4.424 1.678 6.255c2.441 3.508 6.712 5.186 10.83 4.118c.916-.305 1.831-.61 2.594-1.068l41.796-26.695c2.136-1.372 3.509-3.355 3.966-5.796s-.152-5.034-1.525-7.017c-2.441-3.509-6.712-5.034-10.831-3.966c-.915.305-1.83.61-2.593 1.068l-16.017 10.22c-2.593 1.678-5.491 2.898-8.542 3.661a33.25 33.25 0 0 1-35.695-13.271c-4.729-6.712-6.712-15.102-5.186-23.339c1.372-7.932 6.254-15.102 13.118-19.373l41.949-26.695c2.593-1.678 5.492-2.898 8.543-3.814a33.25 33.25 0 0 1 35.695 13.272c4.881 6.712 6.711 15.101 5.339 23.339c-.306 1.373-.611 2.593-1.068 3.966l-.763 2.44l-2.136-1.525a55.6 55.6 0 0 0-16.474-8.237l-1.526-.458l.153-1.525c.153-2.136-.458-4.424-1.678-6.255c-2.441-3.508-6.712-5.034-10.83-3.966c-.916.305-1.831.61-2.594 1.068l-41.796 26.695c-2.136 1.373-3.509 3.356-3.966 5.797s.152 5.034 1.525 7.017c2.441 3.508 6.712 5.033 10.831 3.966c.915-.305 1.83-.611 2.593-1.068l16.017-10.22c2.593-1.678 5.491-2.899 8.542-3.814a33.25 33.25 0 0 1 35.695 13.271c4.881 6.712 6.712 15.102 5.339 23.339c-1.373 7.932-6.254 15.102-13.119 19.373l-41.949 26.695c-2.593 1.678-5.491 2.898-8.542 3.813" 91 | ></path> 92 | </g> 93 | <defs> 94 | <clipPath id="skillIconsSvelte0"> 95 | <path fill="#fff" d="M53 38h149.644v180H53z"></path> 96 | </clipPath> 97 | </defs> 98 | </g> 99 | </svg> 100 | ), 101 | }, 102 | { 103 | title: "SolidStart", 104 | description: "Fine-grained reactivity goes fullstack", 105 | Icon: () => ( 106 | <svg 107 | data-hk="00000010210" 108 | width="2em" 109 | height="2em" 110 | viewBox="0 0 500 500" 111 | fill="none" 112 | xmlns="http://www.w3.org/2000/svg" 113 | role="presentation" 114 | > 115 | <path 116 | d="M233.205 430.856L304.742 425.279C304.742 425.279 329.208 421.295 343.569 397.659L293.041 385.443L233.205 430.856Z" 117 | fill="url(#paint0_linear_1_2)" 118 | ></path> 119 | <path 120 | d="M134.278 263.278C113.003 264.341 73.6443 268.059 73.6443 268.059L245.173 392.614L284.265 402.44L343.569 397.925L170.977 273.105C170.977 273.105 157.148 263.278 137.203 263.278C136.139 263.278 135.342 263.278 134.278 263.278Z" 121 | fill="url(#paint1_linear_1_2)" 122 | ></path> 123 | <path 124 | d="M355.536 238.58L429.2 234.065C429.2 234.065 454.464 230.348 468.825 206.977L416.435 193.964L355.536 238.58Z" 125 | fill="url(#paint2_linear_1_2)" 126 | ></path> 127 | <path 128 | d="M251.289 68.6128C229.217 69.4095 188.795 72.5964 188.795 72.5964L367.503 200.072L407.926 210.429L469.09 206.712L289.318 78.9702C289.318 78.9702 274.426 68.6128 253.417 68.6128C252.885 68.6128 252.087 68.6128 251.289 68.6128Z" 129 | fill="url(#paint3_linear_1_2)" 130 | ></path> 131 | <path 132 | d="M31.0946 295.679C30.8287 295.945 30.8287 296.21 30.8287 296.475L77.8993 330.469L202.623 420.764C228.95 439.62 264.586 431.653 282.67 402.44L187.465 333.921L110.077 277.62C100.504 270.715 89.8663 267.528 79.2289 267.528C60.6134 267.528 42.2639 277.354 31.0946 295.679Z" 133 | fill="url(#paint4_linear_1_2)" 134 | ></path> 135 | <path 136 | d="M147.043 99.9505C147.043 100.216 146.776 100.482 146.511 100.747L195.442 135.538L244.374 170.062L325.751 227.957C353.142 247.345 389.841 239.642 407.925 210.695L358.461 175.374L308.997 140.318L228.153 82.6881C218.047 75.5177 206.611 72.0652 195.442 72.0652C176.561 72.3308 158.212 81.8915 147.043 99.9505Z" 137 | fill="url(#paint5_linear_1_2)" 138 | ></path> 139 | <path 140 | d="M112.471 139.255L175.497 208.305C178.423 212.289 181.614 216.006 185.337 219.193L308.199 354.105L369.364 350.387C387.448 321.439 380.002 282.135 352.611 262.748L271.234 204.852L222.568 170.328L173.636 135.538L112.471 139.255Z" 141 | fill="url(#paint6_linear_1_2)" 142 | ></path> 143 | <path 144 | d="M111.939 140.052C94.1213 168.734 101.567 207.509 128.427 226.629L209.005 283.994L258.735 319.049L308.199 354.105C326.283 325.158 318.836 285.852 291.445 266.465L112.471 139.255C112.471 139.521 112.204 139.787 111.939 140.052Z" 145 | fill="url(#paint7_linear_1_2)" 146 | ></path> 147 | <defs> 148 | <linearGradient 149 | id="paint0_linear_1_2" 150 | x1="359.728" 151 | y1="56.8062" 152 | x2="265.623" 153 | y2="521.28" 154 | gradientUnits="userSpaceOnUse" 155 | > 156 | <stop stopColor="#1593F5"></stop> 157 | <stop offset="1" stopColor="#0084CE"></stop> 158 | </linearGradient> 159 | <linearGradient 160 | id="paint1_linear_1_2" 161 | x1="350.496" 162 | y1="559.872" 163 | x2="-44.0802" 164 | y2="-73.2062" 165 | gradientUnits="userSpaceOnUse" 166 | > 167 | <stop stopColor="#1593F5"></stop> 168 | <stop offset="1" stopColor="#0084CE"></stop> 169 | </linearGradient> 170 | <linearGradient 171 | id="paint2_linear_1_2" 172 | x1="610.25" 173 | y1="570.526" 174 | x2="372.635" 175 | y2="144.034" 176 | gradientUnits="userSpaceOnUse" 177 | > 178 | <stop stopColor="white"></stop> 179 | <stop offset="1" stopColor="#15ABFF"></stop> 180 | </linearGradient> 181 | <linearGradient 182 | id="paint3_linear_1_2" 183 | x1="188.808" 184 | y1="-180.608" 185 | x2="390.515" 186 | y2="281.703" 187 | gradientUnits="userSpaceOnUse" 188 | > 189 | <stop stopColor="white"></stop> 190 | <stop offset="1" stopColor="#79CFFF"></stop> 191 | </linearGradient> 192 | <linearGradient 193 | id="paint4_linear_1_2" 194 | x1="415.84" 195 | y1="-4.74684" 196 | x2="95.1922" 197 | y2="439.83" 198 | gradientUnits="userSpaceOnUse" 199 | > 200 | <stop stopColor="#0057E5"></stop> 201 | <stop offset="1" stopColor="#0084CE"></stop> 202 | </linearGradient> 203 | <linearGradient 204 | id="paint5_linear_1_2" 205 | x1="343.141" 206 | y1="-21.5427" 207 | x2="242.301" 208 | y2="256.708" 209 | gradientUnits="userSpaceOnUse" 210 | > 211 | <stop stopColor="white"></stop> 212 | <stop offset="1" stopColor="#15ABFF"></stop> 213 | </linearGradient> 214 | <linearGradient 215 | id="paint6_linear_1_2" 216 | x1="469.095" 217 | y1="533.421" 218 | x2="-37.6939" 219 | y2="-135.731" 220 | gradientUnits="userSpaceOnUse" 221 | > 222 | <stop stopColor="white"></stop> 223 | <stop offset="1" stopColor="#79CFFF"></stop> 224 | </linearGradient> 225 | <linearGradient 226 | id="paint7_linear_1_2" 227 | x1="380.676" 228 | y1="-89.0869" 229 | x2="120.669" 230 | y2="424.902" 231 | gradientUnits="userSpaceOnUse" 232 | > 233 | <stop stopColor="white"></stop> 234 | <stop offset="1" stopColor="#79CFFF"></stop> 235 | </linearGradient> 236 | </defs> 237 | </svg> 238 | ), 239 | }, 240 | ]; 241 | 242 | export function Builder() { 243 | const [currentStep, setCurrentStep] = useState(0); 244 | 245 | const [options, setOptions] = useAtom(optionsAtom); 246 | const { setTheme, resolvedTheme } = useTheme(); 247 | return ( 248 | <Dialog> 249 | <DialogTrigger asChild> 250 | <button className="bg-stone-950 no-underline group cursor-pointer relative p-px text-xs font-semibold leading-6 text-white md:inline-block hidden"> 251 | <span className="absolute inset-0 overflow-hidden rounded-sm"> 252 | <span className="absolute inset-0 rounded-sm bg-[image:radial-gradient(75%_100%_at_50%_0%,rgba(56,189,248,0.6)_0%,rgba(56,189,248,0)_75%)] opacity-0 transition-opacity duration-500 group-hover:opacity-100"></span> 253 | </span> 254 | <div className="relative flex space-x-2 items-center z-10 rounded-none bg-zinc-950 py-2 px-4 ring-1 ring-white/10 "> 255 | <PlusIcon size={14} /> 256 | <span>Create Sign in Box</span> 257 | </div> 258 | <span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-emerald-400/0 via-stone-800/90 to-emerald-400/0 transition-opacity duration-500 group-hover:opacity-40"></span> 259 | </button> 260 | </DialogTrigger> 261 | <DialogContent className="max-w-7xl h-5/6 overflow-clip !rounded-none"> 262 | <DialogHeader> 263 | <DialogTitle>Create Sign in Box</DialogTitle> 264 | <DialogDescription> 265 | Configure the sign in box to your liking and copy the code to your 266 | application. 267 | </DialogDescription> 268 | </DialogHeader> 269 | 270 | <div className="flex gap-4 md:gap-12 flex-col md:flex-row items-center md:items-start"> 271 | <div className={cn("w-4/12")}> 272 | <div 273 | className="overflow-scroll h-[580px] relative" 274 | style={{ 275 | scrollbarWidth: "none", 276 | scrollbarColor: "transparent transparent", 277 | //@ts-expect-error 278 | "&::-webkit-scrollbar": { 279 | display: "none", 280 | }, 281 | }} 282 | > 283 | {options.signUp ? ( 284 | <AuthTabs 285 | tabs={[ 286 | { 287 | title: "Sign In", 288 | value: "sign-in", 289 | content: <SignIn />, 290 | }, 291 | { 292 | title: "Sign Up", 293 | value: "sign-up", 294 | content: <SignUp />, 295 | }, 296 | ]} 297 | /> 298 | ) : ( 299 | <SignIn /> 300 | )} 301 | </div> 302 | </div> 303 | <ScrollArea 304 | className="w-[45%] flex-grow" 305 | style={{ 306 | scrollbarWidth: "none", 307 | scrollbarColor: "transparent transparent", 308 | //@ts-expect-error 309 | "&::-webkit-scrollbar": { 310 | display: "none", 311 | }, 312 | }} 313 | > 314 | <div className="h-[580px]"> 315 | {currentStep === 0 ? ( 316 | <Card className="rounded-none flex-grow h-full"> 317 | <CardHeader className="flex flex-row justify-between"> 318 | <CardTitle>Configuration</CardTitle> 319 | <div 320 | className="cursor-pointer" 321 | onClick={() => { 322 | if (resolvedTheme === "dark") { 323 | setTheme("light"); 324 | } else { 325 | setTheme("dark"); 326 | } 327 | }} 328 | > 329 | {resolvedTheme === "dark" ? ( 330 | <Moon onClick={() => setTheme("light")} size={18} /> 331 | ) : ( 332 | <Sun onClick={() => setTheme("dark")} size={18} /> 333 | )} 334 | </div> 335 | </CardHeader> 336 | <CardContent className="max-h-[400px] overflow-scroll"> 337 | <div className="flex flex-col gap-2"> 338 | <div> 339 | <Label>Email & Password</Label> 340 | </div> 341 | <Separator /> 342 | <div className="flex items-center justify-between"> 343 | <div className="flex items-center"> 344 | <Label 345 | className="cursor-pointer" 346 | htmlFor="email-provider-email" 347 | > 348 | Enabled 349 | </Label> 350 | </div> 351 | <Switch 352 | id="email-provider-email" 353 | checked={options.email} 354 | onCheckedChange={(checked) => { 355 | setOptions((prev) => ({ 356 | ...prev, 357 | email: checked, 358 | magicLink: checked ? false : prev.magicLink, 359 | signUp: checked, 360 | })); 361 | }} 362 | /> 363 | </div> 364 | <div className="flex items-center justify-between"> 365 | <div className="flex items-center gap-2"> 366 | <Label 367 | className="cursor-pointer" 368 | htmlFor="email-provider-remember-me" 369 | > 370 | Remember Me 371 | </Label> 372 | </div> 373 | <Switch 374 | id="email-provider-remember-me" 375 | checked={options.rememberMe} 376 | onCheckedChange={(checked) => { 377 | setOptions((prev) => ({ 378 | ...prev, 379 | rememberMe: checked, 380 | })); 381 | }} 382 | /> 383 | </div> 384 | <div className="flex items-center justify-between"> 385 | <div className="flex items-center gap-2"> 386 | <Label 387 | className="cursor-pointer" 388 | htmlFor="email-provider-forget-password" 389 | > 390 | Forget Password 391 | </Label> 392 | </div> 393 | <Switch 394 | id="email-provider-forget-password" 395 | checked={options.requestPasswordReset} 396 | onCheckedChange={(checked) => { 397 | setOptions((prev) => ({ 398 | ...prev, 399 | requestPasswordReset: checked, 400 | })); 401 | }} 402 | /> 403 | </div> 404 | </div> 405 | <div className="flex flex-col gap-2 mt-4"> 406 | <div> 407 | <Label>Social Providers</Label> 408 | </div> 409 | <Separator /> 410 | {Object.entries(socialProviders).map( 411 | ([provider, { Icon }]) => ( 412 | <div 413 | className="flex items-center justify-between" 414 | key={provider} 415 | > 416 | <div className="flex items-center gap-2"> 417 | <Icon /> 418 | <Label 419 | className="cursor-pointer" 420 | htmlFor={"social-provider".concat( 421 | "-", 422 | provider, 423 | )} 424 | > 425 | {provider.charAt(0).toUpperCase() + 426 | provider.slice(1)} 427 | </Label> 428 | </div> 429 | <Switch 430 | id={"social-provider".concat("-", provider)} 431 | checked={options.socialProviders.includes( 432 | provider, 433 | )} 434 | onCheckedChange={(checked) => { 435 | setOptions((prev) => ({ 436 | ...prev, 437 | socialProviders: checked 438 | ? [...prev.socialProviders, provider] 439 | : prev.socialProviders.filter( 440 | (p) => p !== provider, 441 | ), 442 | })); 443 | }} 444 | /> 445 | </div> 446 | ), 447 | )} 448 | </div> 449 | <div className="flex flex-col gap-2 mt-4"> 450 | <div> 451 | <Label>Plugins</Label> 452 | </div> 453 | <Separator /> 454 | <div className="flex items-center justify-between"> 455 | <div className="flex items-center gap-2"> 456 | <svg 457 | xmlns="http://www.w3.org/2000/svg" 458 | width="1em" 459 | height="1em" 460 | viewBox="0 0 24 24" 461 | > 462 | <path 463 | fill="currentColor" 464 | d="M5 20q-.825 0-1.412-.587T3 18v-.8q0-.85.438-1.562T4.6 14.55q1.55-.775 3.15-1.162T11 13q.35 0 .7.013t.7.062q.275.025.437.213t.163.462q.05 1.175.575 2.213t1.4 1.762q.175.125.275.313t.1.412V19q0 .425-.288.713T14.35 20zm6-8q-1.65 0-2.825-1.175T7 8t1.175-2.825T11 4t2.825 1.175T15 8t-1.175 2.825T11 12m7.5 2q.425 0 .713-.288T19.5 13t-.288-.712T18.5 12t-.712.288T17.5 13t.288.713t.712.287m.15 8.65l-1-1q-.05-.05-.15-.35v-4.45q-1.1-.325-1.8-1.237T15 13.5q0-1.45 1.025-2.475T18.5 10t2.475 1.025T22 13.5q0 1.125-.638 2t-1.612 1.25l.9.9q.15.15.15.35t-.15.35l-.8.8q-.15.15-.15.35t.15.35l.8.8q.15.15.15.35t-.15.35l-1.3 1.3q-.15.15-.35.15t-.35-.15" 465 | ></path> 466 | </svg> 467 | <Label 468 | className="cursor-pointer" 469 | htmlFor="plugin-passkey" 470 | > 471 | Passkey 472 | </Label> 473 | </div> 474 | <Switch 475 | id="plugin-passkey" 476 | checked={options.passkey} 477 | onCheckedChange={(checked) => { 478 | setOptions((prev) => ({ 479 | ...prev, 480 | passkey: checked, 481 | })); 482 | }} 483 | /> 484 | </div> 485 | 486 | <div className="flex items-center justify-between"> 487 | <div className="flex items-center gap-2"> 488 | <svg 489 | xmlns="http://www.w3.org/2000/svg" 490 | width="1em" 491 | height="1em" 492 | viewBox="0 0 24 24" 493 | > 494 | <g fill="none"> 495 | <path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"></path> 496 | <path 497 | fill="currentColor" 498 | d="M17.5 3a4.5 4.5 0 0 1 4.495 4.288L22 7.5V15a2 2 0 0 1-1.85 1.995L20 17h-3v3a1 1 0 0 1-1.993.117L15 20v-3H4a2 2 0 0 1-1.995-1.85L2 15V7.5a4.5 4.5 0 0 1 4.288-4.495L6.5 3zm-11 2A2.5 2.5 0 0 0 4 7.5V15h5V7.5A2.5 2.5 0 0 0 6.5 5M7 8a1 1 0 0 1 .117 1.993L7 10H6a1 1 0 0 1-.117-1.993L6 8z" 499 | ></path> 500 | </g> 501 | </svg> 502 | <Label 503 | className="cursor-pointer" 504 | htmlFor="plugin-otp-magic-link" 505 | > 506 | Magic Link 507 | </Label> 508 | </div> 509 | <Switch 510 | id="plugin-otp-magic-link" 511 | checked={options.magicLink} 512 | onCheckedChange={(checked) => { 513 | setOptions((prev) => ({ 514 | ...prev, 515 | magicLink: checked, 516 | email: checked ? false : prev.email, 517 | signUp: checked ? false : prev.signUp, 518 | })); 519 | }} 520 | /> 521 | </div> 522 | </div> 523 | <div className="mt-4"> 524 | <Separator /> 525 | <div className="flex items-center justify-between mt-2"> 526 | <Label 527 | className="cursor-pointer" 528 | htmlFor="label-powered-by" 529 | > 530 | Show Built with label 531 | </Label> 532 | <Switch 533 | id="label-powered-by" 534 | checked={options.label} 535 | onCheckedChange={(checked) => { 536 | setOptions((prev) => ({ 537 | ...prev, 538 | label: checked, 539 | })); 540 | }} 541 | /> 542 | </div> 543 | </div> 544 | </CardContent> 545 | <CardFooter> 546 | <button 547 | className="bg-stone-950 no-underline group cursor-pointer relative shadow-2xl shadow-zinc-900 rounded-sm p-px text-xs font-semibold leading-6 text-white inline-block w-full" 548 | onClick={() => { 549 | setCurrentStep(currentStep + 1); 550 | }} 551 | > 552 | <span className="absolute inset-0 overflow-hidden rounded-sm"> 553 | <span className="absolute inset-0 rounded-sm bg-[image:radial-gradient(75%_100%_at_50%_0%,rgba(56,189,248,0.6)_0%,rgba(56,189,248,0)_75%)] opacity-0 transition-opacity duration-500 group-hover:opacity-100"></span> 554 | </span> 555 | <div className="relative flex space-x-2 items-center z-10 rounded-none bg-zinc-950 py-2 px-4 ring-1 ring-white/10 justify-center"> 556 | <span>Continue</span> 557 | </div> 558 | <span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-emerald-400/0 via-stone-800/90 to-emerald-400/0 transition-opacity duration-500 group-hover:opacity-40"></span> 559 | </button> 560 | </CardFooter> 561 | </Card> 562 | ) : currentStep === 1 ? ( 563 | <Card className="rounded-none flex-grow h-full"> 564 | <CardHeader> 565 | <CardTitle>Choose Framework</CardTitle> 566 | <p 567 | className="text-blue-400 hover:underline mt-1 text-sm cursor-pointer" 568 | onClick={() => { 569 | setCurrentStep(0); 570 | }} 571 | > 572 | Go Back 573 | </p> 574 | </CardHeader> 575 | <CardContent className="flex items-start gap-2 flex-wrap justify-between"> 576 | {frameworks.map((fm) => ( 577 | <div 578 | onClick={() => { 579 | if (fm.title === "Next.js") { 580 | setCurrentStep(currentStep + 1); 581 | } 582 | }} 583 | className={cn( 584 | "flex flex-col items-center gap-4 border p-6 rounded-md w-5/12 flex-grow h-44 relative", 585 | fm.title !== "Next.js" 586 | ? "opacity-55" 587 | : "hover:ring-1 transition-all ring-border hover:bg-background duration-200 ease-in-out cursor-pointer", 588 | )} 589 | key={fm.title} 590 | > 591 | {fm.title !== "Next.js" && ( 592 | <span className="absolute top-4 right-4 text-xs"> 593 | Coming Soon 594 | </span> 595 | )} 596 | <fm.Icon /> 597 | <Label className="text-2xl">{fm.title}</Label> 598 | <p className="text-sm">{fm.description}</p> 599 | </div> 600 | ))} 601 | </CardContent> 602 | </Card> 603 | ) : ( 604 | <Card className="rounded-none w-full overflow-y-hidden h-full overflow-auto"> 605 | <CardHeader> 606 | <div className="flex flex-col -mb-2 items-start"> 607 | <CardTitle>Code</CardTitle> 608 | </div> 609 | </CardHeader> 610 | <CardContent> 611 | <div className="flex gap-2 items-baseline"> 612 | <p> 613 | Copy the code below and paste it in your application to 614 | get started. 615 | </p> 616 | <p 617 | className="text-blue-400 hover:underline mt-1 text-sm cursor-pointer" 618 | onClick={() => { 619 | setCurrentStep(0); 620 | }} 621 | > 622 | Go Back 623 | </p> 624 | </div> 625 | <div> 626 | <CodeTabs /> 627 | </div> 628 | </CardContent> 629 | </Card> 630 | )} 631 | </div> 632 | </ScrollArea> 633 | </div> 634 | </DialogContent> 635 | </Dialog> 636 | ); 637 | } 638 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { afterAll, beforeAll, describe, expect, it } from "vitest"; 2 | import { genericOAuth } from "."; 3 | import { createAuthClient } from "../../client"; 4 | import { getTestInstance } from "../../test-utils/test-instance"; 5 | import { genericOAuthClient } from "./client"; 6 | 7 | import { betterFetch } from "@better-fetch/fetch"; 8 | import { OAuth2Server } from "oauth2-mock-server"; 9 | import { parseSetCookieHeader } from "../../cookies"; 10 | 11 | describe("oauth2", async () => { 12 | const providerId = "test"; 13 | const clientId = "test-client-id"; 14 | const clientSecret = "test-client-secret"; 15 | const server = new OAuth2Server(); 16 | await server.start(); 17 | const port = Number(server.issuer.url?.split(":")[2]!); 18 | 19 | afterAll(async () => { 20 | await server.stop(); 21 | }); 22 | 23 | const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ 24 | plugins: [ 25 | genericOAuth({ 26 | config: [ 27 | { 28 | providerId, 29 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 30 | clientId: clientId, 31 | clientSecret: clientSecret, 32 | pkce: true, 33 | }, 34 | ], 35 | }), 36 | ], 37 | }); 38 | 39 | const authClient = createAuthClient({ 40 | plugins: [genericOAuthClient()], 41 | baseURL: "http://localhost:3000", 42 | fetchOptions: { 43 | customFetchImpl, 44 | }, 45 | }); 46 | 47 | beforeAll(async () => { 48 | const context = await auth.$context; 49 | await context.internalAdapter.createUser({ 50 | email: "[email protected]", 51 | name: "OAuth2 Test", 52 | }); 53 | await server.issuer.keys.generate("RS256"); 54 | }); 55 | 56 | server.service.on("beforeUserinfo", (userInfoResponse, req) => { 57 | userInfoResponse.body = { 58 | email: "[email protected]", 59 | name: "OAuth2 Test", 60 | sub: "oauth2", 61 | picture: "https://test.com/picture.png", 62 | email_verified: true, 63 | }; 64 | userInfoResponse.statusCode = 200; 65 | }); 66 | 67 | async function simulateOAuthFlow( 68 | authUrl: string, 69 | headers: Headers, 70 | fetchImpl?: (...args: any) => any, 71 | ) { 72 | let location: string | null = null; 73 | await betterFetch(authUrl, { 74 | method: "GET", 75 | redirect: "manual", 76 | onError(context) { 77 | location = context.response.headers.get("location"); 78 | }, 79 | }); 80 | 81 | if (!location) throw new Error("No redirect location found"); 82 | 83 | let callbackURL = ""; 84 | const newHeaders = new Headers(); 85 | await betterFetch(location, { 86 | method: "GET", 87 | customFetchImpl: fetchImpl || customFetchImpl, 88 | headers, 89 | onError(context) { 90 | callbackURL = context.response.headers.get("location") || ""; 91 | cookieSetter(newHeaders)(context); 92 | }, 93 | }); 94 | 95 | return { callbackURL, headers: newHeaders }; 96 | } 97 | 98 | it("should redirect to the provider and handle the response", async () => { 99 | let headers = new Headers(); 100 | const signInRes = await authClient.signIn.oauth2({ 101 | providerId: "test", 102 | callbackURL: "http://localhost:3000/dashboard", 103 | newUserCallbackURL: "http://localhost:3000/new_user", 104 | fetchOptions: { 105 | onSuccess: cookieSetter(headers), 106 | }, 107 | }); 108 | expect(signInRes.data).toMatchObject({ 109 | url: expect.stringContaining(`http://localhost:${port}/authorize`), 110 | redirect: true, 111 | }); 112 | const { callbackURL } = await simulateOAuthFlow( 113 | signInRes.data?.url || "", 114 | headers, 115 | ); 116 | expect(callbackURL).toBe("http://localhost:3000/dashboard"); 117 | }); 118 | 119 | it("should redirect to the provider and handle the response for a new user", async () => { 120 | server.service.once("beforeUserinfo", (userInfoResponse) => { 121 | userInfoResponse.body = { 122 | email: "[email protected]", 123 | name: "OAuth2 Test 2", 124 | sub: "oauth2-2", 125 | picture: "https://test.com/picture.png", 126 | email_verified: true, 127 | }; 128 | userInfoResponse.statusCode = 200; 129 | }); 130 | 131 | let headers = new Headers(); 132 | const signInRes = await authClient.signIn.oauth2({ 133 | providerId: "test", 134 | callbackURL: "http://localhost:3000/dashboard", 135 | newUserCallbackURL: "http://localhost:3000/new_user", 136 | fetchOptions: { 137 | onSuccess: cookieSetter(headers), 138 | }, 139 | }); 140 | expect(signInRes.data).toMatchObject({ 141 | url: expect.stringContaining(`http://localhost:${port}/authorize`), 142 | redirect: true, 143 | }); 144 | const { callbackURL, headers: newHeaders } = await simulateOAuthFlow( 145 | signInRes.data?.url || "", 146 | headers, 147 | ); 148 | expect(callbackURL).toBe("http://localhost:3000/new_user"); 149 | const session = await authClient.getSession({ 150 | fetchOptions: { 151 | headers: newHeaders, 152 | }, 153 | }); 154 | console.log(session.data, newHeaders); 155 | const ctx = await auth.$context; 156 | const accounts = await ctx.internalAdapter.findAccounts( 157 | session.data?.user.id!, 158 | ); 159 | const account = accounts[0]; 160 | expect(account).toMatchObject({ 161 | providerId, 162 | accountId: "oauth2-2", 163 | userId: session.data?.user.id, 164 | accessToken: expect.any(String), 165 | refreshToken: expect.any(String), 166 | accessTokenExpiresAt: expect.any(Date), 167 | refreshTokenExpiresAt: null, 168 | scope: expect.any(String), 169 | idToken: expect.any(String), 170 | }); 171 | }); 172 | 173 | it("should redirect to the provider and handle the response after linked", async () => { 174 | let headers = new Headers(); 175 | const res = await authClient.signIn.oauth2({ 176 | providerId: "test", 177 | callbackURL: "http://localhost:3000/dashboard", 178 | newUserCallbackURL: "http://localhost:3000/new_user", 179 | fetchOptions: { 180 | onSuccess: cookieSetter(headers), 181 | }, 182 | }); 183 | const { callbackURL } = await simulateOAuthFlow( 184 | res.data?.url || "", 185 | headers, 186 | ); 187 | expect(callbackURL).toBe("http://localhost:3000/dashboard"); 188 | }); 189 | 190 | it("should handle invalid provider ID", async () => { 191 | const res = await authClient.signIn.oauth2({ 192 | providerId: "invalid-provider", 193 | callbackURL: "http://localhost:3000/dashboard", 194 | newUserCallbackURL: "http://localhost:3000/new_user", 195 | }); 196 | expect(res.error?.status).toBe(400); 197 | }); 198 | 199 | it("should handle server error during OAuth flow", async () => { 200 | server.service.once("beforeUserinfo", (userInfoResponse) => { 201 | userInfoResponse.body = { 202 | email: "[email protected]", 203 | name: "OAuth2 Test", 204 | sub: "oauth2", 205 | picture: "https://test.com/picture.png", 206 | email_verified: true, 207 | }; 208 | userInfoResponse.statusCode = 500; 209 | }); 210 | 211 | let headers = new Headers(); 212 | const res = await authClient.signIn.oauth2( 213 | { 214 | providerId: "test", 215 | callbackURL: "http://localhost:3000/dashboard", 216 | newUserCallbackURL: "http://localhost:3000/new_user", 217 | }, 218 | { 219 | onSuccess(context) { 220 | const parsedSetCookie = parseSetCookieHeader( 221 | context.response.headers.get("Set-Cookie") || "", 222 | ); 223 | headers.set( 224 | "cookie", 225 | `better-auth.state=${ 226 | parsedSetCookie.get("better-auth.state")?.value 227 | }; better-auth.pk_code_verifier=${ 228 | parsedSetCookie.get("better-auth.pk_code_verifier")?.value 229 | }`, 230 | ); 231 | }, 232 | }, 233 | ); 234 | 235 | const { callbackURL } = await simulateOAuthFlow( 236 | res.data?.url || "", 237 | headers, 238 | ); 239 | expect(callbackURL).toContain("?error="); 240 | }); 241 | 242 | it("should work with custom redirect uri", async () => { 243 | const { customFetchImpl, auth } = await getTestInstance({ 244 | plugins: [ 245 | genericOAuth({ 246 | config: [ 247 | { 248 | providerId: "test2", 249 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 250 | clientId: clientId, 251 | clientSecret: clientSecret, 252 | redirectURI: "http://localhost:3000/api/auth/callback/test2", 253 | pkce: true, 254 | }, 255 | ], 256 | }), 257 | ], 258 | }); 259 | const headers = new Headers(); 260 | const authClient = createAuthClient({ 261 | plugins: [genericOAuthClient()], 262 | baseURL: "http://localhost:3000", 263 | fetchOptions: { 264 | customFetchImpl, 265 | onSuccess: cookieSetter(headers), 266 | }, 267 | }); 268 | 269 | const res = await authClient.signIn.oauth2({ 270 | providerId: "test2", 271 | callbackURL: "http://localhost:3000/dashboard", 272 | newUserCallbackURL: "http://localhost:3000/new_user", 273 | fetchOptions: { 274 | onSuccess: cookieSetter(headers), 275 | }, 276 | }); 277 | expect(res.data?.url).toContain(`http://localhost:${port}/authorize`); 278 | const { callbackURL } = await simulateOAuthFlow( 279 | res.data?.url || "", 280 | headers, 281 | customFetchImpl, 282 | ); 283 | expect(callbackURL).toBe("http://localhost:3000/new_user"); 284 | }); 285 | 286 | it("should not create user when sign ups are disabled", async () => { 287 | server.service.once("beforeUserinfo", (userInfoResponse) => { 288 | userInfoResponse.body = { 289 | email: "[email protected]", 290 | name: "OAuth2 Test Signup Disabled", 291 | sub: "oauth2-signup-disabled", 292 | picture: "https://test.com/picture.png", 293 | email_verified: true, 294 | }; 295 | userInfoResponse.statusCode = 200; 296 | }); 297 | 298 | const { customFetchImpl, cookieSetter } = await getTestInstance({ 299 | plugins: [ 300 | genericOAuth({ 301 | config: [ 302 | { 303 | providerId: "test2", 304 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 305 | clientId: clientId, 306 | clientSecret: clientSecret, 307 | pkce: true, 308 | disableImplicitSignUp: true, 309 | }, 310 | ], 311 | }), 312 | ], 313 | }); 314 | const authClient = createAuthClient({ 315 | plugins: [genericOAuthClient()], 316 | baseURL: "http://localhost:3000", 317 | fetchOptions: { 318 | customFetchImpl, 319 | }, 320 | }); 321 | const headers = new Headers(); 322 | const res = await authClient.signIn.oauth2({ 323 | providerId: "test2", 324 | callbackURL: "http://localhost:3000/dashboard", 325 | errorCallbackURL: "http://localhost:3000/error", 326 | fetchOptions: { 327 | onSuccess: cookieSetter(headers), 328 | }, 329 | }); 330 | expect(res.data?.url).toContain(`http://localhost:${port}/authorize`); 331 | const { callbackURL } = await simulateOAuthFlow( 332 | res.data?.url || "", 333 | headers, 334 | customFetchImpl, 335 | ); 336 | expect(callbackURL).toBe( 337 | "http://localhost:3000/error?error=signup_disabled", 338 | ); 339 | }); 340 | 341 | it("should create user when sign ups are disabled and sign up is requested", async () => { 342 | server.service.once("beforeUserinfo", (userInfoResponse) => { 343 | userInfoResponse.body = { 344 | email: "[email protected]", 345 | name: "OAuth2 Test Signup Disabled And Requested", 346 | sub: "oauth2-signup-disabled-and-requested", 347 | picture: "https://test.com/picture.png", 348 | email_verified: true, 349 | }; 350 | userInfoResponse.statusCode = 200; 351 | }); 352 | 353 | const { customFetchImpl, cookieSetter } = await getTestInstance({ 354 | plugins: [ 355 | genericOAuth({ 356 | config: [ 357 | { 358 | providerId: "test2", 359 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 360 | clientId: clientId, 361 | clientSecret: clientSecret, 362 | pkce: true, 363 | disableImplicitSignUp: true, 364 | }, 365 | ], 366 | }), 367 | ], 368 | }); 369 | 370 | const authClient = createAuthClient({ 371 | plugins: [genericOAuthClient()], 372 | baseURL: "http://localhost:3000", 373 | fetchOptions: { 374 | customFetchImpl, 375 | }, 376 | }); 377 | const headers = new Headers(); 378 | const res = await authClient.signIn.oauth2({ 379 | providerId: "test2", 380 | callbackURL: "http://localhost:3000/dashboard", 381 | errorCallbackURL: "http://localhost:3000/error", 382 | requestSignUp: true, 383 | fetchOptions: { 384 | onSuccess: cookieSetter(headers), 385 | }, 386 | }); 387 | expect(res.data?.url).toContain(`http://localhost:${port}/authorize`); 388 | const { callbackURL } = await simulateOAuthFlow( 389 | res.data?.url || "", 390 | headers, 391 | customFetchImpl, 392 | ); 393 | expect(callbackURL).toBe("http://localhost:3000/dashboard"); 394 | }); 395 | 396 | it("should pass authorization headers in oAuth2Callback", async () => { 397 | const customHeaders = { 398 | "X-Custom-Header": "test-value", 399 | }; 400 | 401 | let receivedHeaders: Record<string, string> = {}; 402 | server.service.once("beforeTokenSigning", (token, req) => { 403 | receivedHeaders = req.headers as Record<string, string>; 404 | }); 405 | 406 | const { customFetchImpl, cookieSetter } = await getTestInstance({ 407 | plugins: [ 408 | genericOAuth({ 409 | config: [ 410 | { 411 | providerId: "test3", 412 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 413 | clientId: clientId, 414 | clientSecret: clientSecret, 415 | pkce: true, 416 | authorizationHeaders: customHeaders, 417 | }, 418 | ], 419 | }), 420 | ], 421 | }); 422 | const headers = new Headers(); 423 | const authClient = createAuthClient({ 424 | plugins: [genericOAuthClient()], 425 | baseURL: "http://localhost:3000", 426 | fetchOptions: { 427 | customFetchImpl, 428 | onSuccess: cookieSetter(headers), 429 | }, 430 | }); 431 | 432 | const res = await authClient.signIn.oauth2({ 433 | providerId: "test3", 434 | callbackURL: "http://localhost:3000/dashboard", 435 | newUserCallbackURL: "http://localhost:3000/new_user", 436 | fetchOptions: { 437 | onSuccess: cookieSetter(headers), 438 | }, 439 | }); 440 | 441 | expect(res.data?.url).toContain(`http://localhost:${port}/authorize`); 442 | await simulateOAuthFlow(res.data?.url || "", headers, customFetchImpl); 443 | 444 | expect(receivedHeaders).toHaveProperty("x-custom-header"); 445 | expect(receivedHeaders["x-custom-header"]).toBe("test-value"); 446 | }); 447 | 448 | it("should delete oauth user with verification flow without password", async () => { 449 | let token = ""; 450 | const { customFetchImpl, cookieSetter } = await getTestInstance({ 451 | user: { 452 | deleteUser: { 453 | enabled: true, 454 | async sendDeleteAccountVerification(data, _) { 455 | token = data.token; 456 | }, 457 | }, 458 | }, 459 | plugins: [ 460 | genericOAuth({ 461 | config: [ 462 | { 463 | providerId: "test", 464 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 465 | clientId: clientId, 466 | clientSecret: clientSecret, 467 | }, 468 | ], 469 | }), 470 | ], 471 | }); 472 | const headers = new Headers(); 473 | const client = createAuthClient({ 474 | plugins: [genericOAuthClient()], 475 | baseURL: "http://localhost:3000", 476 | fetchOptions: { 477 | customFetchImpl, 478 | onSuccess: cookieSetter(headers), 479 | }, 480 | }); 481 | const signInRes = await client.signIn.oauth2({ 482 | providerId: "test", 483 | callbackURL: "http://localhost:3000/dashboard", 484 | newUserCallbackURL: "http://localhost:3000/new_user", 485 | fetchOptions: { 486 | onSuccess: cookieSetter(headers), 487 | }, 488 | }); 489 | 490 | expect(signInRes.data).toMatchObject({ 491 | url: expect.stringContaining(`http://localhost:${port}/authorize`), 492 | redirect: true, 493 | }); 494 | 495 | const { headers: newHeaders } = await simulateOAuthFlow( 496 | signInRes.data?.url || "", 497 | headers, 498 | customFetchImpl, 499 | ); 500 | 501 | const session = await client.getSession({ 502 | fetchOptions: { 503 | headers: newHeaders, 504 | }, 505 | }); 506 | expect(session.data).not.toBeNull(); 507 | 508 | const deleteRes = await client.deleteUser({ 509 | fetchOptions: { 510 | headers: newHeaders, 511 | }, 512 | }); 513 | 514 | expect(deleteRes.data).toMatchObject({ 515 | success: true, 516 | }); 517 | 518 | expect(token.length).toBe(32); 519 | 520 | const deleteCallbackRes = await client.deleteUser({ 521 | token, 522 | fetchOptions: { 523 | headers: newHeaders, 524 | }, 525 | }); 526 | expect(deleteCallbackRes.data).toMatchObject({ 527 | success: true, 528 | }); 529 | const nullSession = await client.getSession({ 530 | fetchOptions: { 531 | headers, 532 | }, 533 | }); 534 | expect(nullSession.data).toBeNull(); 535 | }); 536 | 537 | it("should handle numeric account IDs correctly and prevent duplicate accounts", async () => { 538 | const numericAccountId = 123456789; 539 | const userEmail = "[email protected]"; 540 | 541 | server.service.once("beforeUserinfo", (userInfoResponse) => { 542 | userInfoResponse.body = { 543 | email: userEmail, 544 | name: "Numeric ID Test User", 545 | sub: numericAccountId, 546 | picture: "https://test.com/picture.png", 547 | email_verified: true, 548 | }; 549 | userInfoResponse.statusCode = 200; 550 | }); 551 | 552 | const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ 553 | plugins: [ 554 | genericOAuth({ 555 | config: [ 556 | { 557 | providerId: "numeric-test", 558 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 559 | clientId: clientId, 560 | clientSecret: clientSecret, 561 | pkce: true, 562 | }, 563 | ], 564 | }), 565 | ], 566 | }); 567 | const headers = new Headers(); 568 | const authClient = createAuthClient({ 569 | plugins: [genericOAuthClient()], 570 | baseURL: "http://localhost:3000", 571 | fetchOptions: { 572 | customFetchImpl, 573 | onSuccess: cookieSetter(headers), 574 | }, 575 | }); 576 | 577 | const firstSignIn = await authClient.signIn.oauth2({ 578 | providerId: "numeric-test", 579 | callbackURL: "http://localhost:3000/dashboard", 580 | newUserCallbackURL: "http://localhost:3000/new_user", 581 | }); 582 | 583 | const { callbackURL: firstCallbackURL, headers: firstHeaders } = 584 | await simulateOAuthFlow( 585 | firstSignIn.data?.url || "", 586 | headers, 587 | customFetchImpl, 588 | ); 589 | 590 | expect(firstCallbackURL).toBe("http://localhost:3000/new_user"); 591 | 592 | const firstSession = await authClient.getSession({ 593 | fetchOptions: { 594 | headers: firstHeaders, 595 | }, 596 | }); 597 | 598 | expect(firstSession.data).not.toBeNull(); 599 | const userId = firstSession.data?.user.id!; 600 | 601 | const ctx = await auth.$context; 602 | const accountsAfterFirst = await ctx.internalAdapter.findAccounts(userId); 603 | expect(accountsAfterFirst).toHaveLength(1); 604 | expect(accountsAfterFirst[0]).toMatchObject({ 605 | providerId: "numeric-test", 606 | accountId: String(numericAccountId), 607 | userId: userId, 608 | }); 609 | 610 | server.service.once("beforeUserinfo", (userInfoResponse) => { 611 | userInfoResponse.body = { 612 | email: userEmail, 613 | name: "Numeric ID Test User", 614 | sub: numericAccountId, 615 | picture: "https://test.com/picture.png", 616 | email_verified: true, 617 | }; 618 | userInfoResponse.statusCode = 200; 619 | }); 620 | 621 | const secondSignIn = await authClient.signIn.oauth2({ 622 | providerId: "numeric-test", 623 | callbackURL: "http://localhost:3000/dashboard", 624 | }); 625 | 626 | const { callbackURL: secondCallbackURL, headers: secondHeaders } = 627 | await simulateOAuthFlow( 628 | secondSignIn.data?.url || "", 629 | headers, 630 | customFetchImpl, 631 | ); 632 | 633 | expect(secondCallbackURL).toBe("http://localhost:3000/dashboard"); 634 | 635 | const secondSession = await authClient.getSession({ 636 | fetchOptions: { 637 | headers: secondHeaders, 638 | }, 639 | }); 640 | 641 | expect(secondSession.data).not.toBeNull(); 642 | expect(secondSession.data?.user.id).toBe(userId); 643 | 644 | const accountsAfterSecond = await ctx.internalAdapter.findAccounts(userId); 645 | expect(accountsAfterSecond).toHaveLength(1); 646 | expect(accountsAfterSecond[0]!.accountId).toBe(String(numericAccountId)); 647 | }); 648 | 649 | it("should handle custom getUserInfo returning numeric ID", async () => { 650 | const numericId = 987654321; 651 | 652 | const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ 653 | plugins: [ 654 | genericOAuth({ 655 | config: [ 656 | { 657 | providerId: "custom-numeric", 658 | authorizationUrl: `http://localhost:${port}/authorize`, 659 | tokenUrl: `http://localhost:${port}/token`, 660 | clientId: clientId, 661 | clientSecret: clientSecret, 662 | pkce: true, 663 | getUserInfo: async (_tokens) => { 664 | return { 665 | id: numericId, 666 | email: "[email protected]", 667 | name: "Custom Numeric User", 668 | emailVerified: true, 669 | image: "https://test.com/avatar.png", 670 | }; 671 | }, 672 | }, 673 | ], 674 | }), 675 | ], 676 | }); 677 | const headers = new Headers(); 678 | const authClient = createAuthClient({ 679 | plugins: [genericOAuthClient()], 680 | baseURL: "http://localhost:3000", 681 | fetchOptions: { 682 | customFetchImpl, 683 | onSuccess: cookieSetter(headers), 684 | }, 685 | }); 686 | 687 | const signInRes = await authClient.signIn.oauth2({ 688 | providerId: "custom-numeric", 689 | callbackURL: "http://localhost:3000/dashboard", 690 | newUserCallbackURL: "http://localhost:3000/new_user", 691 | }); 692 | 693 | const { callbackURL, headers: newHeaders } = await simulateOAuthFlow( 694 | signInRes.data?.url || "", 695 | headers, 696 | customFetchImpl, 697 | ); 698 | 699 | expect(callbackURL).toBe("http://localhost:3000/new_user"); 700 | 701 | const session = await authClient.getSession({ 702 | fetchOptions: { 703 | headers: newHeaders, 704 | }, 705 | }); 706 | 707 | const ctx = await auth.$context; 708 | const accounts = await ctx.internalAdapter.findAccounts( 709 | session.data?.user.id!, 710 | ); 711 | 712 | expect(accounts[0]!.accountId).toBe(String(numericId)); 713 | }); 714 | 715 | it("should handle mapProfileToUser returning numeric ID", async () => { 716 | const numericProfileId = 111222333; 717 | 718 | server.service.once("beforeUserinfo", (userInfoResponse) => { 719 | userInfoResponse.body = { 720 | email: "[email protected]", 721 | name: "Map Profile Numeric User", 722 | sub: "string-sub-id", 723 | user_id: numericProfileId, 724 | picture: "https://test.com/picture.png", 725 | email_verified: true, 726 | }; 727 | userInfoResponse.statusCode = 200; 728 | }); 729 | 730 | const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ 731 | plugins: [ 732 | genericOAuth({ 733 | config: [ 734 | { 735 | providerId: "map-profile-numeric", 736 | discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, 737 | clientId: clientId, 738 | clientSecret: clientSecret, 739 | pkce: true, 740 | mapProfileToUser: (profile) => { 741 | return { 742 | id: profile.user_id, 743 | email: profile.email, 744 | name: profile.name, 745 | emailVerified: profile.email_verified, 746 | }; 747 | }, 748 | }, 749 | ], 750 | }), 751 | ], 752 | }); 753 | const headers = new Headers(); 754 | const authClient = createAuthClient({ 755 | plugins: [genericOAuthClient()], 756 | baseURL: "http://localhost:3000", 757 | fetchOptions: { 758 | customFetchImpl, 759 | onSuccess: cookieSetter(headers), 760 | }, 761 | }); 762 | 763 | const signInRes = await authClient.signIn.oauth2({ 764 | providerId: "map-profile-numeric", 765 | callbackURL: "http://localhost:3000/dashboard", 766 | newUserCallbackURL: "http://localhost:3000/new_user", 767 | }); 768 | 769 | const { callbackURL, headers: newHeaders } = await simulateOAuthFlow( 770 | signInRes.data?.url || "", 771 | headers, 772 | customFetchImpl, 773 | ); 774 | 775 | expect(callbackURL).toBe("http://localhost:3000/new_user"); 776 | 777 | const session = await authClient.getSession({ 778 | fetchOptions: { 779 | headers: newHeaders, 780 | }, 781 | }); 782 | 783 | const ctx = await auth.$context; 784 | const accounts = await ctx.internalAdapter.findAccounts( 785 | session.data?.user.id!, 786 | ); 787 | 788 | expect(accounts[0]!.accountId).toBe(String(numericProfileId)); 789 | }); 790 | 791 | it("should handle Strava OAuth with custom mapProfileToUser", async () => { 792 | const stravaUserId = 12345678; 793 | const stravaProfile = { 794 | id: stravaUserId, 795 | firstname: "John", 796 | lastname: "Doe", 797 | profile: "https://example.com/strava-avatar.jpg", 798 | email_verified: true, 799 | }; 800 | 801 | server.service.once("beforeUserinfo", (userInfoResponse) => { 802 | userInfoResponse.body = stravaProfile; 803 | userInfoResponse.statusCode = 200; 804 | }); 805 | 806 | const { customFetchImpl, auth, cookieSetter } = await getTestInstance({ 807 | plugins: [ 808 | genericOAuth({ 809 | config: [ 810 | { 811 | providerId: "strava", 812 | authorizationUrl: `http://localhost:${port}/authorize`, 813 | tokenUrl: `http://localhost:${port}/token`, 814 | userInfoUrl: `http://localhost:${port}/userinfo`, 815 | clientId: "STRAVA_CLIENT_ID", 816 | clientSecret: "STRAVA_CLIENT_SECRET", 817 | scopes: ["read", "activity:read_all"], 818 | pkce: true, 819 | mapProfileToUser: (profile) => { 820 | const fullName = `${profile.firstname} ${profile.lastname}`; 821 | return { 822 | id: profile.id, 823 | email: `${profile.id}@strava.local`, 824 | name: fullName, 825 | image: profile.profile, 826 | emailVerified: true, 827 | }; 828 | }, 829 | }, 830 | ], 831 | }), 832 | ], 833 | }); 834 | const headers = new Headers(); 835 | const authClient = createAuthClient({ 836 | plugins: [genericOAuthClient()], 837 | baseURL: "http://localhost:3000", 838 | fetchOptions: { 839 | customFetchImpl, 840 | onSuccess: cookieSetter(headers), 841 | }, 842 | }); 843 | 844 | const signInRes = await authClient.signIn.oauth2({ 845 | providerId: "strava", 846 | callbackURL: "http://localhost:3000/dashboard", 847 | newUserCallbackURL: "http://localhost:3000/new_user", 848 | }); 849 | 850 | expect(signInRes.data?.url).toContain(`http://localhost:${port}/authorize`); 851 | // we missed the `activity:read_all` 852 | expect(signInRes.data?.url).toContain("scope=read+activity"); 853 | 854 | const { callbackURL, headers: newHeaders } = await simulateOAuthFlow( 855 | signInRes.data?.url || "", 856 | headers, 857 | customFetchImpl, 858 | ); 859 | 860 | expect(callbackURL).toBe("http://localhost:3000/new_user"); 861 | 862 | const session = await authClient.getSession({ 863 | fetchOptions: { 864 | headers: newHeaders, 865 | }, 866 | }); 867 | 868 | expect(session.data).not.toBeNull(); 869 | expect(session.data?.user.email).toBe(`${stravaUserId}@strava.local`); 870 | expect(session.data?.user.name).toBe("John Doe"); 871 | expect(session.data?.user.image).toBe( 872 | "https://example.com/strava-avatar.jpg", 873 | ); 874 | 875 | const ctx = await auth.$context; 876 | const accounts = await ctx.internalAdapter.findAccounts( 877 | session.data?.user.id!, 878 | ); 879 | 880 | expect(accounts[0]).toMatchObject({ 881 | providerId: "strava", 882 | accountId: String(stravaUserId), 883 | userId: session.data?.user.id, 884 | }); 885 | }); 886 | }); 887 | ```