diff --git a/.env.example b/.env.example index 15612ff..892ef2d 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,17 @@ # ----------------------------------------------------------------------------- # - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys) # - local_noauth: local trusted mode with injected admin user (admin@localhost) -# - hosted: Better Auth email/password + organization mode +# - team: Better Auth email/password, invite-only. One shared workspace; the +# owner provisions accounts. No billing, captcha, or transactional email. +# - hosted: the full multi-tenant SaaS (billing, Google login, email verification) # # Defaults to cloudflare_access when unset. # AUTH_MODE=cloudflare_access +# Required when AUTH_MODE=team (set both in the build env AND the runtime). +# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters +# BETTER_AUTH_URL=https://seo.example.com + # Required when AUTH_MODE=cloudflare_access. See docs/SELF_HOSTING_CLOUDFLARE.md. # TEAM_DOMAIN=https://your-team.cloudflareaccess.com # POLICY_AUD=your-cloudflare-access-aud-tag diff --git a/src/client/components/AuthConfigErrorCard.tsx b/src/client/components/AuthConfigErrorCard.tsx index 47b8bc5..1c13998 100644 --- a/src/client/components/AuthConfigErrorCard.tsx +++ b/src/client/components/AuthConfigErrorCard.tsx @@ -1,5 +1,8 @@ import { ShieldAlert } from "lucide-react"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + isHostedClientAuthMode, + isSessionClientAuthMode, +} from "@/lib/auth-mode"; const CLOUDFLARE_SETUP_GUIDE_URL = "https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE.md#2-configure-authentication-and-secrets"; @@ -14,6 +17,7 @@ export function AuthConfigErrorCard({ onRetry, }: AuthConfigErrorCardProps) { const isHostedMode = isHostedClientAuthMode(); + const isTeamMode = isSessionClientAuthMode() && !isHostedMode; return (
@@ -27,7 +31,15 @@ export function AuthConfigErrorCard({ {message}
- {isHostedMode ? ( + {isTeamMode ? ( +

+ Team mode requires BETTER_AUTH_SECRET + (32+ characters) and + BETTER_AUTH_URL + {" "} + on the deployment. +

+ ) : isHostedMode ? (

Hosted mode requires{" "} BETTER_AUTH_SECRET diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx index 849434f..25f090b 100644 --- a/src/client/components/Sidebar.tsx +++ b/src/client/components/Sidebar.tsx @@ -25,7 +25,10 @@ import { SamSidebarPanel } from "@/client/features/sam/SamSidebarPanel"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { closeDropdown } from "@/client/lib/dropdown"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + isHostedClientAuthMode, + isSessionClientAuthMode, +} from "@/lib/auth-mode"; import { BILLING_ROUTE } from "@/shared/billing"; interface SidebarProps { @@ -237,12 +240,13 @@ function SidebarViewTab({ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) { const { data: session } = useSession(); const isHostedMode = isHostedClientAuthMode(); + const isSessionMode = isSessionClientAuthMode(); const email = session?.user?.email; const [isSwitching, setIsSwitching] = useState(false); const orgContextQuery = useQuery({ ...organizationContextQueryOptions(), - enabled: isHostedMode && Boolean(email), + enabled: isSessionMode && Boolean(email), }); const organizations = orgContextQuery.data?.organizations ?? []; const activeOrganizationId = orgContextQuery.data?.organizationId; @@ -338,7 +342,7 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) { ) : null} - {isHostedMode ? ( + {isSessionMode ? ( <>

  • { - if (typeof window === "undefined" || !isHostedMode) { + if (typeof window === "undefined" || !isSessionMode) { return; } window.location.replace(signInHref); - }, [isHostedMode, signInHref]); + }, [isSessionMode, signInHref]); - if (isHostedMode) { + if (isSessionMode) { return null; } diff --git a/src/client/features/auth/AuthPage.tsx b/src/client/features/auth/AuthPage.tsx index 7f3078e..95e6a0b 100644 --- a/src/client/features/auth/AuthPage.tsx +++ b/src/client/features/auth/AuthPage.tsx @@ -1,9 +1,14 @@ +import { Link } from "@tanstack/react-router"; import { z } from "zod"; import { getCurrentAuthRedirect, getOAuthSignedQuery, + getSignInSearch, } from "@/lib/auth-redirect"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + isHostedClientAuthMode, + isSessionClientAuthMode, +} from "@/lib/auth-mode"; export const authRedirectSearchSchema = z.object({ redirect: z.string().optional(), @@ -16,11 +21,15 @@ export function useAuthPageState(redirect: string | undefined) { ? getOAuthSignedQuery(window.location.search) : null; const isHostedMode = isHostedClientAuthMode(); + // `team` mode: email/password sign-in is live, but Google social login and + // self-serve signup are not. + const isSessionMode = isSessionClientAuthMode(); return { redirectTo, oauthQuery, isHostedMode, + isSessionMode, }; } @@ -120,6 +129,25 @@ export function AuthPageCard({ ); } +// `team` mode has no self-serve signup — the workspace owner provisions +// accounts. Shown in place of the sign-up form. +export function InviteOnlyCard({ redirectTo }: { redirectTo: string }) { + return ( + + + Back to sign in + + + ); +} + export function AuthPageShell({ children }: { children: React.ReactNode }) { return ( // `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the diff --git a/src/client/features/auth/useHostedAuthRouteGuard.ts b/src/client/features/auth/useHostedAuthRouteGuard.ts index a203ad7..869b732 100644 --- a/src/client/features/auth/useHostedAuthRouteGuard.ts +++ b/src/client/features/auth/useHostedAuthRouteGuard.ts @@ -4,6 +4,7 @@ import { useSession } from "@/lib/auth-client"; import { isEmailVerificationBypassed, isHostedClientAuthMode, + isSessionClientAuthMode, } from "@/lib/auth-mode"; import { getCurrentAuthRedirectFromHref, @@ -14,12 +15,17 @@ import { export function useHostedAuthRouteGuard() { const navigate = useNavigate(); const { data: session, isPending } = useSession(); + // `hosted` and `team` both require a Better Auth session; only `hosted` has + // an email-verification step (team has no transactional email). + const isSessionMode = isSessionClientAuthMode(); const isHostedMode = isHostedClientAuthMode(); const emailVerified = - session?.user?.emailVerified === true || isEmailVerificationBypassed(); + !isHostedMode || + session?.user?.emailVerified === true || + isEmailVerificationBypassed(); useEffect(() => { - if (isPending || !isHostedMode) { + if (isPending || !isSessionMode) { return; } @@ -43,7 +49,7 @@ export function useHostedAuthRouteGuard() { } }, [ isPending, - isHostedMode, + isSessionMode, emailVerified, session?.user?.email, session?.user?.id, @@ -55,6 +61,7 @@ export function useHostedAuthRouteGuard() { return { isHostedMode, - canRenderAuthenticatedContent: !isHostedMode || hasVerifiedHostedSession, + isSessionMode, + canRenderAuthenticatedContent: !isSessionMode || hasVerifiedHostedSession, }; } diff --git a/src/env.d.ts b/src/env.d.ts index db7d478..788c269 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -24,7 +24,7 @@ declare namespace Cloudflare { // oxlint-disable-next-line typescript-eslint/consistent-type-imports AUDIT_ENGINE: Service; - AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; + AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted" | "team"; BYPASS_EMAIL_VERIFICATION?: string; TEAM_DOMAIN?: string; POLICY_AUD?: string; @@ -66,7 +66,7 @@ declare namespace Cloudflare { } interface ImportMetaEnv { - readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted"; + readonly AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted" | "team"; readonly DATABASE_PROVIDER?: "d1" | "postgres"; readonly BYPASS_EMAIL_VERIFICATION?: string; readonly POSTHOG_PUBLIC_KEY?: string; diff --git a/src/lib/auth-mode.ts b/src/lib/auth-mode.ts index 30797f2..571ad3b 100644 --- a/src/lib/auth-mode.ts +++ b/src/lib/auth-mode.ts @@ -4,6 +4,7 @@ export const AUTH_MODES = [ "cloudflare_access", "local_noauth", "hosted", + "team", ] as const; type AuthMode = (typeof AUTH_MODES)[number]; @@ -42,6 +43,23 @@ export function isHostedClientAuthMode() { return isHostedAuthMode(import.meta.env.AUTH_MODE); } +export function isTeamAuthMode(value: string | null | undefined) { + return getAuthMode(value) === "team"; +} + +// "Is there a Better Auth login session?" — true for both the paid hosted SaaS +// and the internal `team` mode. Use this (not isHostedAuthMode) wherever the +// question is "does this request carry a real user session?" rather than "is +// this the billed multi-tenant product?". +export function isSessionAuthMode(value: string | null | undefined) { + const mode = getAuthMode(value); + return mode === "hosted" || mode === "team"; +} + +export function isSessionClientAuthMode() { + return isSessionAuthMode(import.meta.env.AUTH_MODE); +} + export function isEmailVerificationBypassed() { // Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips // verification and never marks users emailVerified, so the client must treat diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d237fba..bd2a495 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,7 +11,11 @@ import { pgDb } from "@/db/pg/client"; import * as pgSchema from "@/db/pg/schema"; import { getDatabaseProvider } from "@/db/provider"; import { z } from "zod"; -import { isHostedAuthMode } from "@/lib/auth-mode"; +import { + isHostedAuthMode, + isSessionAuthMode, + isTeamAuthMode, +} from "@/lib/auth-mode"; import { createApiKeyPlugin } from "@/lib/auth-api-key"; import { createBaseAuthConfig } from "@/lib/auth-config"; import { @@ -43,12 +47,12 @@ function createAuth() { // Hosted needs the real configured URL (cookies, callbacks, /api/auth routes // all use it). Self-hosted only builds this instance to mint/refresh Search // Console tokens, which never read baseURL — so a placeholder is fine there. - const baseUrl = isHostedAuthMode(env.AUTH_MODE) + const baseUrl = isSessionAuthMode(env.AUTH_MODE) ? getHostedBaseUrl() : "http://localhost"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const baseAuthConfig = createBaseAuthConfig( - isHostedAuthMode(env.AUTH_MODE) + isSessionAuthMode(env.AUTH_MODE) ? { organization: { // No sendInvitationEmail here on purpose: better-auth swallows a @@ -161,28 +165,39 @@ function createAuth() { ...baseAuthConfig, emailAndPassword: { ...baseAuthConfig.emailAndPassword, - requireEmailVerification: !bypassEmail, + // `team` mode has no transactional email, so no verification and no + // self-serve signup — the workspace owner provisions accounts. + requireEmailVerification: isHostedAuthMode(env.AUTH_MODE) + ? !bypassEmail + : false, + disableSignUp: isTeamAuthMode(env.AUTH_MODE) + ? true + : (baseAuthConfig.emailAndPassword.disableSignUp ?? false), resetPasswordTokenExpiresIn: 60 * 60, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { + // Reset-by-email is hosted-only (Loops). In `team` mode the owner + // resets a member's password from the admin screen instead. + if (!isHostedAuthMode(env.AUTH_MODE)) return; await sendHostedPasswordResetEmail({ email: user.email, resetUrl: url, }); }, }, - emailVerification: bypassEmail - ? undefined - : { - sendOnSignUp: true, - autoSignInAfterVerification: true, - sendVerificationEmail: async ({ user, url }) => { - await sendHostedVerificationEmail({ - email: user.email, - confirmationUrl: url, - }); - }, - }, + emailVerification: + isHostedAuthMode(env.AUTH_MODE) && !bypassEmail + ? { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + await sendHostedVerificationEmail({ + email: user.email, + confirmationUrl: url, + }); + }, + } + : undefined, socialProviders: getSocialProviders(), // Where OAuth redirect-flow failures land when Better Auth can't honor a // per-flow errorCallbackURL (Google-side errors like a canceled consent @@ -226,8 +241,8 @@ function createAuth() { return { data: user }; }, after: async (user, ctx) => { - await syncHostedSignupContact(user); if (isHostedAuthMode(env.AUTH_MODE)) { + await syncHostedSignupContact(user); await captureDubReferralSignup(user.id, ctx?.request); } }, @@ -377,7 +392,7 @@ function hasHostedAuthEmailConfig() { }); } -export function hasHostedAuthConfig() { +function hasHostedAuthConfig() { try { getHostedBaseUrl(); getHostedSecret(); @@ -392,6 +407,26 @@ export function hasHostedAuthConfig() { } } +// `team` mode needs only a base URL and a signing secret — no Google, Turnstile, +// or transactional email. +function hasTeamAuthConfig() { + try { + getHostedBaseUrl(); + getHostedSecret(); + return true; + } catch { + return false; + } +} + +// The config gate for whichever session-based mode is active. Non-session modes +// (cloudflare_access, local_noauth) never call this. +export function hasSessionAuthConfig() { + return isHostedAuthMode(env.AUTH_MODE) + ? hasHostedAuthConfig() + : hasTeamAuthConfig(); +} + export function getAuth() { if (authInstance) { return authInstance; diff --git a/src/lib/selfhost-preflight.ts b/src/lib/selfhost-preflight.ts index eb0555e..e20dd7d 100644 --- a/src/lib/selfhost-preflight.ts +++ b/src/lib/selfhost-preflight.ts @@ -58,6 +58,37 @@ function checkAuthMode(env: EnvRecord, items: PreflightItem[]): void { return; } + if (mode === "team") { + const missing = ["BETTER_AUTH_URL", "BETTER_AUTH_SECRET"].filter( + (name) => !get(env, name), + ); + const secret = get(env, "BETTER_AUTH_SECRET"); + if (missing.length) { + items.push({ + key: "auth", + name: "AUTH_MODE", + level: "fail", + message: `team mode requires ${missing.join(", ")}.`, + }); + } else if (secret && secret.length < MIN_BETTER_AUTH_SECRET_LENGTH) { + items.push({ + key: "auth", + name: "BETTER_AUTH_SECRET", + level: "fail", + message: `team mode requires BETTER_AUTH_SECRET to be at least ${MIN_BETTER_AUTH_SECRET_LENGTH} characters (it signs session cookies).`, + }); + } else { + items.push({ + key: "auth", + name: "AUTH_MODE", + level: "ok", + message: + "team — Better Auth email/password, invite-only. Accounts are provisioned by the workspace owner.", + }); + } + return; + } + if (mode === "hosted") { const missing = [ "BETTER_AUTH_URL", diff --git a/src/middleware/ensure-user/hosted.ts b/src/middleware/ensure-user/hosted.ts index 0dfd25f..d3e4001 100644 --- a/src/middleware/ensure-user/hosted.ts +++ b/src/middleware/ensure-user/hosted.ts @@ -1,4 +1,4 @@ -import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; +import { getAuth, hasSessionAuthConfig } from "@/lib/auth"; import { getActiveOrganizationId } from "@/lib/auth-session"; import { AuthRepository } from "@/server/auth/repositories/AuthRepository"; import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization"; @@ -6,10 +6,10 @@ import { AppError } from "@/server/lib/errors"; import type { EnsuredUserContext } from "./types"; async function requireHostedSession(headers: Headers) { - if (!hasHostedAuthConfig()) { + if (!hasSessionAuthConfig()) { throw new AppError( "AUTH_CONFIG_MISSING", - "Missing Better Auth hosted configuration", + "Missing Better Auth configuration", ); } diff --git a/src/middleware/ensure-user/resolve.ts b/src/middleware/ensure-user/resolve.ts index 81eefd4..99974d8 100644 --- a/src/middleware/ensure-user/resolve.ts +++ b/src/middleware/ensure-user/resolve.ts @@ -1,5 +1,5 @@ import { env } from "cloudflare:workers"; -import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; +import { getAuthMode, isSessionAuthMode } from "@/lib/auth-mode"; import { resolveCloudflareAccessContext } from "./cloudflareAccess"; import { resolveLocalNoAuthContext } from "./delegated"; import { resolveHostedContext } from "./hosted"; @@ -15,7 +15,10 @@ export async function resolveUserContextFromHeaders( if (authMode === "local_noauth") { return resolveLocalNoAuthContext(); } - if (isHostedAuthMode(authMode)) { + if (isSessionAuthMode(authMode)) { + // `hosted` and `team` both resolve a Better Auth session; they differ only + // in config requirements (checked in resolveHostedContext) and which + // SaaS-only features are wired around them. return resolveHostedContext(headers); } return resolveCloudflareAccessContext(headers); diff --git a/src/routes/_app/settings.tsx b/src/routes/_app/settings.tsx index 33fa486..01b9632 100644 --- a/src/routes/_app/settings.tsx +++ b/src/routes/_app/settings.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { isSessionClientAuthMode } from "@/lib/auth-mode"; export const Route = createFileRoute("/_app/settings")({ component: SettingsLayout, @@ -12,7 +12,7 @@ function SettingsLayout() { const tabs = [ { to: "/settings" as const, label: "Personal", exact: true }, // Self-host has no memberships — the organization tab would 404. - ...(isHostedClientAuthMode() + ...(isSessionClientAuthMode() ? [{ to: "/settings/organization" as const, label: "Organization" }] : []), ]; diff --git a/src/routes/_app/settings/organization.tsx b/src/routes/_app/settings/organization.tsx index ea7430a..56577dc 100644 --- a/src/routes/_app/settings/organization.tsx +++ b/src/routes/_app/settings/organization.tsx @@ -1,12 +1,12 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { TeamSettings } from "@/client/features/team/TeamSettings"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { isSessionClientAuthMode } from "@/lib/auth-mode"; export const Route = createFileRoute("/_app/settings/organization")({ // Self-host has no memberships or invitations — the better-auth HTTP // surface isn't even mounted there. beforeLoad: () => { - if (!isHostedClientAuthMode()) { + if (!isSessionClientAuthMode()) { throw notFound(); } }, diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx index 26b3439..22600bc 100644 --- a/src/routes/_auth.sign-in.tsx +++ b/src/routes/_auth.sign-in.tsx @@ -26,11 +26,11 @@ export const Route = createFileRoute("/_auth/sign-in")({ function SignInPage() { const search = Route.useSearch(); const navigate = useNavigate(); - const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState( - search.redirect, - ); + const { redirectTo, oauthQuery, isHostedMode, isSessionMode } = + useAuthPageState(search.redirect); const authCallbackURL = redirectTo; - const [showEmailForm, setShowEmailForm] = useState(false); + // `team` mode has no Google button, so go straight to the email/password form. + const [showEmailForm, setShowEmailForm] = useState(!isHostedMode); const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [socialError, setSocialError] = useState(null); @@ -155,7 +155,7 @@ function SignInPage() { <> { void handleContinueWithGoogle(); @@ -190,7 +190,7 @@ function SignInPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="email" - disabled={!isHostedMode} + disabled={!isSessionMode} required /> {error ? ( @@ -214,7 +214,7 @@ function SignInPage() { value={field.state.value} onChange={(event) => field.handleChange(event.target.value)} autoComplete="current-password" - disabled={!isHostedMode} + disabled={!isSessionMode} required /> {error ? ( @@ -240,7 +240,7 @@ function SignInPage() { ) : null} diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index a448037..1915dd4 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -5,6 +5,7 @@ import { AuthPageCard, AuthMethodChooser, authRedirectSearchSchema, + InviteOnlyCard, useAuthPageState, } from "@/client/features/auth/AuthPage"; import { @@ -51,7 +52,9 @@ export const Route = createFileRoute("/_auth/sign-up")({ function SignUpPage() { const search = Route.useSearch(); const navigate = useNavigate(); - const { redirectTo, isHostedMode } = useAuthPageState(search.redirect); + const { redirectTo, isHostedMode, isSessionMode } = useAuthPageState( + search.redirect, + ); const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo; const [showEmailForm, setShowEmailForm] = useState(false); const google = useGoogleSignUp({ redirectTo, postSignupRedirect }); @@ -148,6 +151,12 @@ function SignUpPage() { }, }); + // `team` mode: accounts are provisioned by the workspace owner, not + // self-serve. (Non-session modes never reach this route's guard.) + if (isSessionMode && !isHostedMode) { + return ; + } + return ( { @@ -31,7 +31,7 @@ function AuthPageLayout() { void navigate({ href: redirectTo, replace: true }); }, [navigate, redirectTo, session?.user?.id]); - if (isHostedMode && (isPending || session?.user?.id)) { + if (isSessionMode && (isPending || session?.user?.id)) { return null; } diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx index 32b8bb1..333351b 100644 --- a/src/routes/_authenticated.tsx +++ b/src/routes/_authenticated.tsx @@ -9,7 +9,7 @@ export const Route = createFileRoute("/_authenticated")({ function AuthenticatedShellLayout() { const authGate = useHostedAuthRouteGuard(); - if (!authGate.isHostedMode || !authGate.canRenderAuthenticatedContent) { + if (!authGate.isSessionMode || !authGate.canRenderAuthenticatedContent) { return null; } diff --git a/src/routes/accept-invitation.$id.tsx b/src/routes/accept-invitation.$id.tsx index f3e758e..9a748ee 100644 --- a/src/routes/accept-invitation.$id.tsx +++ b/src/routes/accept-invitation.$id.tsx @@ -4,11 +4,11 @@ import { useState } from "react"; import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage"; import { captureClientEvent } from "@/client/lib/posthog"; import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { isSessionClientAuthMode } from "@/lib/auth-mode"; export const Route = createFileRoute("/accept-invitation/$id")({ beforeLoad: () => { - if (!isHostedClientAuthMode()) { + if (!isSessionClientAuthMode()) { throw notFound(); } }, diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts index 0e87161..d593c72 100644 --- a/src/routes/api/auth/$.ts +++ b/src/routes/api/auth/$.ts @@ -1,17 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; import { env } from "cloudflare:workers"; -import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; -import { isHostedAuthMode } from "@/lib/auth-mode"; +import { getAuth, hasSessionAuthConfig } from "@/lib/auth"; +import { isSessionAuthMode } from "@/lib/auth-mode"; async function handleAuthRequest(request: Request) { - if (!isHostedAuthMode(env.AUTH_MODE)) { + if (!isSessionAuthMode(env.AUTH_MODE)) { return new Response("Not found", { status: 404, }); } - if (!hasHostedAuthConfig()) { - return new Response("Missing Better Auth hosted configuration", { + if (!hasSessionAuthConfig()) { + return new Response("Missing Better Auth configuration", { status: 500, }); } diff --git a/src/server/features/google/selfHostedOAuth.ts b/src/server/features/google/selfHostedOAuth.ts index 817e288..16f87a8 100644 --- a/src/server/features/google/selfHostedOAuth.ts +++ b/src/server/features/google/selfHostedOAuth.ts @@ -6,9 +6,14 @@ import { z } from "zod"; import { db } from "@/db"; import { account } from "@/db/schema"; import { getAuth } from "@/lib/auth"; -import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; +import { + getAuthMode, + isHostedAuthMode, + isSessionAuthMode, +} from "@/lib/auth-mode"; import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; +import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; import { AppError } from "@/server/lib/errors"; import { responseForAppError } from "@/server/lib/http-errors"; import { getPublicOrigin } from "@/server/mcp/public-origin"; @@ -385,10 +390,13 @@ export async function handleSelfHostedGoogleOAuthCallbackRequest( try { const authMode = getAuthMode(env.AUTH_MODE); if (isHostedAuthMode(authMode)) { + // Hosted uses Better Auth's genericOAuth provider, not this hand-rolled + // flow. return new Response("Not found", { status: 404 }); } - const context = - authMode === "local_noauth" + const context = isSessionAuthMode(authMode) + ? await resolveHostedContext(request.headers) + : authMode === "local_noauth" ? await resolveLocalNoAuthContext() : await resolveCloudflareAccessContext(request.headers); return await handleSelfHostedGoogleOAuthCallback({