Add team auth mode (backend, inert until AUTH_MODE=team)
Introduces a fourth AUTH_MODE, `team`: Better Auth email/password with the
existing organization/member/role/invitation stack, but none of the hosted
SaaS coupling (no Autumn billing, Turnstile, Loops email, Google social
login, onboarding chat, PostHog, disposable-email block, dub referrals).
- auth-mode.ts: add `team`; add isTeamAuthMode / isSessionAuthMode /
isSessionClientAuthMode ("is there a login session?" vs isHostedAuthMode's
"is this the billed product?").
- auth.ts: createAuth() builds a valid instance for `team` — verification
off, self-serve signup disabled, no captcha/Loops/social. hasTeamAuthConfig
(BETTER_AUTH_URL + BETTER_AUTH_SECRET only) + hasSessionAuthConfig.
- ensure-user: resolve.ts routes `team` through resolveHostedContext;
requireHostedSession + selfHostedOAuth callback accept any session mode.
- api/auth/$.ts: mount the Better Auth handler for `team` too.
- Client: route guards, sidebar account menu / sign-out, settings
Organization tab, invitation accept, and error cards switch from
isHostedClientAuthMode to isSessionClientAuthMode where they mean "has a
session". Sign-in goes straight to the email form (no Google button);
sign-up shows an invite-only notice.
- selfhost-preflight: validate `team` (requires BETTER_AUTH_URL +
BETTER_AUTH_SECRET >= 32 chars).
- .env.example: document `team`.
Ships inert: AUTH_MODE stays local_noauth. tsc / oxlint / knip clean;
test suite unchanged (1164 pass, 1 pre-existing Windows-CRLF failure in
samSkills.test.ts).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b489f78129
commit
c47b032f1a
@ -18,11 +18,17 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
|
# - cloudflare_access: validate Cloudflare Access JWTs (recommended for deploys)
|
||||||
# - local_noauth: local trusted mode with injected admin user (admin@localhost)
|
# - 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.
|
# Defaults to cloudflare_access when unset.
|
||||||
# AUTH_MODE=cloudflare_access
|
# 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.
|
# Required when AUTH_MODE=cloudflare_access. See docs/SELF_HOSTING_CLOUDFLARE.md.
|
||||||
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
|
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
|
||||||
# POLICY_AUD=your-cloudflare-access-aud-tag
|
# POLICY_AUD=your-cloudflare-access-aud-tag
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import {
|
||||||
|
isHostedClientAuthMode,
|
||||||
|
isSessionClientAuthMode,
|
||||||
|
} from "@/lib/auth-mode";
|
||||||
|
|
||||||
const CLOUDFLARE_SETUP_GUIDE_URL =
|
const CLOUDFLARE_SETUP_GUIDE_URL =
|
||||||
"https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE.md#2-configure-authentication-and-secrets";
|
"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,
|
onRetry,
|
||||||
}: AuthConfigErrorCardProps) {
|
}: AuthConfigErrorCardProps) {
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
|
const isTeamMode = isSessionClientAuthMode() && !isHostedMode;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card w-full max-w-2xl bg-base-100 border border-base-300 shadow-xl">
|
<div className="card w-full max-w-2xl bg-base-100 border border-base-300 shadow-xl">
|
||||||
@ -27,7 +31,15 @@ export function AuthConfigErrorCard({
|
|||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHostedMode ? (
|
{isTeamMode ? (
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Team mode requires <code className="mx-1">BETTER_AUTH_SECRET</code>
|
||||||
|
(32+ characters) and <code className="mx-1">
|
||||||
|
BETTER_AUTH_URL
|
||||||
|
</code>{" "}
|
||||||
|
on the deployment.
|
||||||
|
</p>
|
||||||
|
) : isHostedMode ? (
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/70">
|
||||||
Hosted mode requires{" "}
|
Hosted mode requires{" "}
|
||||||
<code className="mx-1">BETTER_AUTH_SECRET</code>
|
<code className="mx-1">BETTER_AUTH_SECRET</code>
|
||||||
|
|||||||
@ -25,7 +25,10 @@ import { SamSidebarPanel } from "@/client/features/sam/SamSidebarPanel";
|
|||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { closeDropdown } from "@/client/lib/dropdown";
|
import { closeDropdown } from "@/client/lib/dropdown";
|
||||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
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";
|
import { BILLING_ROUTE } from "@/shared/billing";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@ -237,12 +240,13 @@ function SidebarViewTab({
|
|||||||
function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
|
function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
|
const isSessionMode = isSessionClientAuthMode();
|
||||||
const email = session?.user?.email;
|
const email = session?.user?.email;
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
const [isSwitching, setIsSwitching] = useState(false);
|
||||||
|
|
||||||
const orgContextQuery = useQuery({
|
const orgContextQuery = useQuery({
|
||||||
...organizationContextQueryOptions(),
|
...organizationContextQueryOptions(),
|
||||||
enabled: isHostedMode && Boolean(email),
|
enabled: isSessionMode && Boolean(email),
|
||||||
});
|
});
|
||||||
const organizations = orgContextQuery.data?.organizations ?? [];
|
const organizations = orgContextQuery.data?.organizations ?? [];
|
||||||
const activeOrganizationId = orgContextQuery.data?.organizationId;
|
const activeOrganizationId = orgContextQuery.data?.organizationId;
|
||||||
@ -338,7 +342,7 @@ function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
<ThemePreferenceMenuItems />
|
<ThemePreferenceMenuItems />
|
||||||
{isHostedMode ? (
|
{isSessionMode ? (
|
||||||
<>
|
<>
|
||||||
<li
|
<li
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { getSignInHref, getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHref, getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isSessionClientAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
type UnauthenticatedErrorCardProps = {
|
type UnauthenticatedErrorCardProps = {
|
||||||
message: string;
|
message: string;
|
||||||
@ -11,21 +11,21 @@ export function UnauthenticatedErrorCard({
|
|||||||
message,
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: UnauthenticatedErrorCardProps) {
|
}: UnauthenticatedErrorCardProps) {
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isSessionMode = isSessionClientAuthMode();
|
||||||
const signInHref =
|
const signInHref =
|
||||||
typeof window === "undefined"
|
typeof window === "undefined"
|
||||||
? getSignInHref("/")
|
? getSignInHref("/")
|
||||||
: getSignInHrefForLocation(window.location);
|
: getSignInHrefForLocation(window.location);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !isHostedMode) {
|
if (typeof window === "undefined" || !isSessionMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.replace(signInHref);
|
window.location.replace(signInHref);
|
||||||
}, [isHostedMode, signInHref]);
|
}, [isSessionMode, signInHref]);
|
||||||
|
|
||||||
if (isHostedMode) {
|
if (isSessionMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
getCurrentAuthRedirect,
|
getCurrentAuthRedirect,
|
||||||
getOAuthSignedQuery,
|
getOAuthSignedQuery,
|
||||||
|
getSignInSearch,
|
||||||
} from "@/lib/auth-redirect";
|
} from "@/lib/auth-redirect";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import {
|
||||||
|
isHostedClientAuthMode,
|
||||||
|
isSessionClientAuthMode,
|
||||||
|
} from "@/lib/auth-mode";
|
||||||
|
|
||||||
export const authRedirectSearchSchema = z.object({
|
export const authRedirectSearchSchema = z.object({
|
||||||
redirect: z.string().optional(),
|
redirect: z.string().optional(),
|
||||||
@ -16,11 +21,15 @@ export function useAuthPageState(redirect: string | undefined) {
|
|||||||
? getOAuthSignedQuery(window.location.search)
|
? getOAuthSignedQuery(window.location.search)
|
||||||
: null;
|
: null;
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
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 {
|
return {
|
||||||
redirectTo,
|
redirectTo,
|
||||||
oauthQuery,
|
oauthQuery,
|
||||||
isHostedMode,
|
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 (
|
||||||
|
<AuthPageCard
|
||||||
|
title="Ask your admin for access"
|
||||||
|
helperText="This workspace is invite-only. Your workspace owner creates accounts and shares the sign-in details."
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/sign-in"
|
||||||
|
search={getSignInSearch(redirectTo)}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</AuthPageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthPageShell({ children }: { children: React.ReactNode }) {
|
export function AuthPageShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
// `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the
|
// `h-[100dvh]` + `overflow-y-auto` makes this a scroll container, and the
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useSession } from "@/lib/auth-client";
|
|||||||
import {
|
import {
|
||||||
isEmailVerificationBypassed,
|
isEmailVerificationBypassed,
|
||||||
isHostedClientAuthMode,
|
isHostedClientAuthMode,
|
||||||
|
isSessionClientAuthMode,
|
||||||
} from "@/lib/auth-mode";
|
} from "@/lib/auth-mode";
|
||||||
import {
|
import {
|
||||||
getCurrentAuthRedirectFromHref,
|
getCurrentAuthRedirectFromHref,
|
||||||
@ -14,12 +15,17 @@ import {
|
|||||||
export function useHostedAuthRouteGuard() {
|
export function useHostedAuthRouteGuard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: session, isPending } = useSession();
|
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 isHostedMode = isHostedClientAuthMode();
|
||||||
const emailVerified =
|
const emailVerified =
|
||||||
session?.user?.emailVerified === true || isEmailVerificationBypassed();
|
!isHostedMode ||
|
||||||
|
session?.user?.emailVerified === true ||
|
||||||
|
isEmailVerificationBypassed();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPending || !isHostedMode) {
|
if (isPending || !isSessionMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +49,7 @@ export function useHostedAuthRouteGuard() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isPending,
|
isPending,
|
||||||
isHostedMode,
|
isSessionMode,
|
||||||
emailVerified,
|
emailVerified,
|
||||||
session?.user?.email,
|
session?.user?.email,
|
||||||
session?.user?.id,
|
session?.user?.id,
|
||||||
@ -55,6 +61,7 @@ export function useHostedAuthRouteGuard() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isHostedMode,
|
isHostedMode,
|
||||||
canRenderAuthenticatedContent: !isHostedMode || hasVerifiedHostedSession,
|
isSessionMode,
|
||||||
|
canRenderAuthenticatedContent: !isSessionMode || hasVerifiedHostedSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/env.d.ts
vendored
4
src/env.d.ts
vendored
@ -24,7 +24,7 @@ declare namespace Cloudflare {
|
|||||||
// oxlint-disable-next-line typescript-eslint/consistent-type-imports
|
// oxlint-disable-next-line typescript-eslint/consistent-type-imports
|
||||||
AUDIT_ENGINE: Service<typeof import("./audit-worker").default>;
|
AUDIT_ENGINE: Service<typeof import("./audit-worker").default>;
|
||||||
|
|
||||||
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted" | "team";
|
||||||
BYPASS_EMAIL_VERIFICATION?: string;
|
BYPASS_EMAIL_VERIFICATION?: string;
|
||||||
TEAM_DOMAIN?: string;
|
TEAM_DOMAIN?: string;
|
||||||
POLICY_AUD?: string;
|
POLICY_AUD?: string;
|
||||||
@ -66,7 +66,7 @@ declare namespace Cloudflare {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
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 DATABASE_PROVIDER?: "d1" | "postgres";
|
||||||
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
readonly BYPASS_EMAIL_VERIFICATION?: string;
|
||||||
readonly POSTHOG_PUBLIC_KEY?: string;
|
readonly POSTHOG_PUBLIC_KEY?: string;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const AUTH_MODES = [
|
|||||||
"cloudflare_access",
|
"cloudflare_access",
|
||||||
"local_noauth",
|
"local_noauth",
|
||||||
"hosted",
|
"hosted",
|
||||||
|
"team",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type AuthMode = (typeof AUTH_MODES)[number];
|
type AuthMode = (typeof AUTH_MODES)[number];
|
||||||
@ -42,6 +43,23 @@ export function isHostedClientAuthMode() {
|
|||||||
return isHostedAuthMode(import.meta.env.AUTH_MODE);
|
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() {
|
export function isEmailVerificationBypassed() {
|
||||||
// Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips
|
// Local-dev escape hatch (BYPASS_EMAIL_VERIFICATION=true). The server skips
|
||||||
// verification and never marks users emailVerified, so the client must treat
|
// verification and never marks users emailVerified, so the client must treat
|
||||||
|
|||||||
@ -11,7 +11,11 @@ import { pgDb } from "@/db/pg/client";
|
|||||||
import * as pgSchema from "@/db/pg/schema";
|
import * as pgSchema from "@/db/pg/schema";
|
||||||
import { getDatabaseProvider } from "@/db/provider";
|
import { getDatabaseProvider } from "@/db/provider";
|
||||||
import { z } from "zod";
|
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 { createApiKeyPlugin } from "@/lib/auth-api-key";
|
||||||
import { createBaseAuthConfig } from "@/lib/auth-config";
|
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||||
import {
|
import {
|
||||||
@ -43,12 +47,12 @@ function createAuth() {
|
|||||||
// Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
|
// Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
|
||||||
// all use it). Self-hosted only builds this instance to mint/refresh Search
|
// 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.
|
// 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()
|
? getHostedBaseUrl()
|
||||||
: "http://localhost";
|
: "http://localhost";
|
||||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||||
const baseAuthConfig = createBaseAuthConfig(
|
const baseAuthConfig = createBaseAuthConfig(
|
||||||
isHostedAuthMode(env.AUTH_MODE)
|
isSessionAuthMode(env.AUTH_MODE)
|
||||||
? {
|
? {
|
||||||
organization: {
|
organization: {
|
||||||
// No sendInvitationEmail here on purpose: better-auth swallows a
|
// No sendInvitationEmail here on purpose: better-auth swallows a
|
||||||
@ -161,28 +165,39 @@ function createAuth() {
|
|||||||
...baseAuthConfig,
|
...baseAuthConfig,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
...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,
|
resetPasswordTokenExpiresIn: 60 * 60,
|
||||||
revokeSessionsOnPasswordReset: true,
|
revokeSessionsOnPasswordReset: true,
|
||||||
sendResetPassword: async ({ user, url }) => {
|
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({
|
await sendHostedPasswordResetEmail({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
resetUrl: url,
|
resetUrl: url,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emailVerification: bypassEmail
|
emailVerification:
|
||||||
? undefined
|
isHostedAuthMode(env.AUTH_MODE) && !bypassEmail
|
||||||
: {
|
? {
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
await sendHostedVerificationEmail({
|
await sendHostedVerificationEmail({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
confirmationUrl: url,
|
confirmationUrl: url,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
|
: undefined,
|
||||||
socialProviders: getSocialProviders(),
|
socialProviders: getSocialProviders(),
|
||||||
// Where OAuth redirect-flow failures land when Better Auth can't honor a
|
// Where OAuth redirect-flow failures land when Better Auth can't honor a
|
||||||
// per-flow errorCallbackURL (Google-side errors like a canceled consent
|
// per-flow errorCallbackURL (Google-side errors like a canceled consent
|
||||||
@ -226,8 +241,8 @@ function createAuth() {
|
|||||||
return { data: user };
|
return { data: user };
|
||||||
},
|
},
|
||||||
after: async (user, ctx) => {
|
after: async (user, ctx) => {
|
||||||
await syncHostedSignupContact(user);
|
|
||||||
if (isHostedAuthMode(env.AUTH_MODE)) {
|
if (isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
|
await syncHostedSignupContact(user);
|
||||||
await captureDubReferralSignup(user.id, ctx?.request);
|
await captureDubReferralSignup(user.id, ctx?.request);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -377,7 +392,7 @@ function hasHostedAuthEmailConfig() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasHostedAuthConfig() {
|
function hasHostedAuthConfig() {
|
||||||
try {
|
try {
|
||||||
getHostedBaseUrl();
|
getHostedBaseUrl();
|
||||||
getHostedSecret();
|
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() {
|
export function getAuth() {
|
||||||
if (authInstance) {
|
if (authInstance) {
|
||||||
return authInstance;
|
return authInstance;
|
||||||
|
|||||||
@ -58,6 +58,37 @@ function checkAuthMode(env: EnvRecord, items: PreflightItem[]): void {
|
|||||||
return;
|
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") {
|
if (mode === "hosted") {
|
||||||
const missing = [
|
const missing = [
|
||||||
"BETTER_AUTH_URL",
|
"BETTER_AUTH_URL",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
|
||||||
import { getActiveOrganizationId } from "@/lib/auth-session";
|
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { resolveActiveHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
@ -6,10 +6,10 @@ import { AppError } from "@/server/lib/errors";
|
|||||||
import type { EnsuredUserContext } from "./types";
|
import type { EnsuredUserContext } from "./types";
|
||||||
|
|
||||||
async function requireHostedSession(headers: Headers) {
|
async function requireHostedSession(headers: Headers) {
|
||||||
if (!hasHostedAuthConfig()) {
|
if (!hasSessionAuthConfig()) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"AUTH_CONFIG_MISSING",
|
"AUTH_CONFIG_MISSING",
|
||||||
"Missing Better Auth hosted configuration",
|
"Missing Better Auth configuration",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
import { getAuthMode, isSessionAuthMode } from "@/lib/auth-mode";
|
||||||
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
|
import { resolveCloudflareAccessContext } from "./cloudflareAccess";
|
||||||
import { resolveLocalNoAuthContext } from "./delegated";
|
import { resolveLocalNoAuthContext } from "./delegated";
|
||||||
import { resolveHostedContext } from "./hosted";
|
import { resolveHostedContext } from "./hosted";
|
||||||
@ -15,7 +15,10 @@ export async function resolveUserContextFromHeaders(
|
|||||||
if (authMode === "local_noauth") {
|
if (authMode === "local_noauth") {
|
||||||
return resolveLocalNoAuthContext();
|
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 resolveHostedContext(headers);
|
||||||
}
|
}
|
||||||
return resolveCloudflareAccessContext(headers);
|
return resolveCloudflareAccessContext(headers);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/_app/settings")({
|
||||||
component: SettingsLayout,
|
component: SettingsLayout,
|
||||||
@ -12,7 +12,7 @@ function SettingsLayout() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ to: "/settings" as const, label: "Personal", exact: true },
|
{ to: "/settings" as const, label: "Personal", exact: true },
|
||||||
// Self-host has no memberships — the organization tab would 404.
|
// Self-host has no memberships — the organization tab would 404.
|
||||||
...(isHostedClientAuthMode()
|
...(isSessionClientAuthMode()
|
||||||
? [{ to: "/settings/organization" as const, label: "Organization" }]
|
? [{ to: "/settings/organization" as const, label: "Organization" }]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
import { TeamSettings } from "@/client/features/team/TeamSettings";
|
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")({
|
export const Route = createFileRoute("/_app/settings/organization")({
|
||||||
// Self-host has no memberships or invitations — the better-auth HTTP
|
// Self-host has no memberships or invitations — the better-auth HTTP
|
||||||
// surface isn't even mounted there.
|
// surface isn't even mounted there.
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
if (!isHostedClientAuthMode()) {
|
if (!isSessionClientAuthMode()) {
|
||||||
throw notFound();
|
throw notFound();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -26,11 +26,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
|
|||||||
function SignInPage() {
|
function SignInPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
|
const { redirectTo, oauthQuery, isHostedMode, isSessionMode } =
|
||||||
search.redirect,
|
useAuthPageState(search.redirect);
|
||||||
);
|
|
||||||
const authCallbackURL = redirectTo;
|
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 [isStartingGoogle, setIsStartingGoogle] = useState(false);
|
||||||
const [socialError, setSocialError] = useState<string | null>(null);
|
const [socialError, setSocialError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -155,7 +155,7 @@ function SignInPage() {
|
|||||||
<>
|
<>
|
||||||
<AuthMethodChooser
|
<AuthMethodChooser
|
||||||
googleLabel="Continue with Google"
|
googleLabel="Continue with Google"
|
||||||
disabled={!isHostedMode}
|
disabled={!isSessionMode}
|
||||||
isBusy={isStartingGoogle}
|
isBusy={isStartingGoogle}
|
||||||
onContinueWithGoogle={() => {
|
onContinueWithGoogle={() => {
|
||||||
void handleContinueWithGoogle();
|
void handleContinueWithGoogle();
|
||||||
@ -190,7 +190,7 @@ function SignInPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={!isHostedMode}
|
disabled={!isSessionMode}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -214,7 +214,7 @@ function SignInPage() {
|
|||||||
value={field.state.value}
|
value={field.state.value}
|
||||||
onChange={(event) => field.handleChange(event.target.value)}
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={!isHostedMode}
|
disabled={!isSessionMode}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
@ -240,7 +240,7 @@ function SignInPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="btn btn-soft w-full"
|
className="btn btn-soft w-full"
|
||||||
disabled={!isHostedMode || isSubmitting}
|
disabled={!isSessionMode || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
AuthPageCard,
|
AuthPageCard,
|
||||||
AuthMethodChooser,
|
AuthMethodChooser,
|
||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
|
InviteOnlyCard,
|
||||||
useAuthPageState,
|
useAuthPageState,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import {
|
import {
|
||||||
@ -51,7 +52,9 @@ export const Route = createFileRoute("/_auth/sign-up")({
|
|||||||
function SignUpPage() {
|
function SignUpPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { redirectTo, isHostedMode } = useAuthPageState(search.redirect);
|
const { redirectTo, isHostedMode, isSessionMode } = useAuthPageState(
|
||||||
|
search.redirect,
|
||||||
|
);
|
||||||
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
const postSignupRedirect = redirectTo === "/" ? "/onboarding" : redirectTo;
|
||||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||||
const google = useGoogleSignUp({ redirectTo, postSignupRedirect });
|
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 <InviteOnlyCard redirectTo={redirectTo} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageCard
|
<AuthPageCard
|
||||||
title="Create your account"
|
title="Create your account"
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
authRedirectSearchSchema,
|
authRedirectSearchSchema,
|
||||||
} from "@/client/features/auth/AuthPage";
|
} from "@/client/features/auth/AuthPage";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isSessionClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
|
import { getCurrentAuthRedirect } from "@/lib/auth-redirect";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth")({
|
export const Route = createFileRoute("/_auth")({
|
||||||
@ -17,7 +17,7 @@ function AuthPageLayout() {
|
|||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isSessionMode = isSessionClientAuthMode();
|
||||||
const redirectTo = getCurrentAuthRedirect(search.redirect);
|
const redirectTo = getCurrentAuthRedirect(search.redirect);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -31,7 +31,7 @@ function AuthPageLayout() {
|
|||||||
void navigate({ href: redirectTo, replace: true });
|
void navigate({ href: redirectTo, replace: true });
|
||||||
}, [navigate, redirectTo, session?.user?.id]);
|
}, [navigate, redirectTo, session?.user?.id]);
|
||||||
|
|
||||||
if (isHostedMode && (isPending || session?.user?.id)) {
|
if (isSessionMode && (isPending || session?.user?.id)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const Route = createFileRoute("/_authenticated")({
|
|||||||
function AuthenticatedShellLayout() {
|
function AuthenticatedShellLayout() {
|
||||||
const authGate = useHostedAuthRouteGuard();
|
const authGate = useHostedAuthRouteGuard();
|
||||||
|
|
||||||
if (!authGate.isHostedMode || !authGate.canRenderAuthenticatedContent) {
|
if (!authGate.isSessionMode || !authGate.canRenderAuthenticatedContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import { useState } from "react";
|
|||||||
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
|
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client";
|
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")({
|
export const Route = createFileRoute("/accept-invitation/$id")({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
if (!isHostedClientAuthMode()) {
|
if (!isSessionClientAuthMode()) {
|
||||||
throw notFound();
|
throw notFound();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
import { getAuth, hasSessionAuthConfig } from "@/lib/auth";
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
import { isSessionAuthMode } from "@/lib/auth-mode";
|
||||||
|
|
||||||
async function handleAuthRequest(request: Request) {
|
async function handleAuthRequest(request: Request) {
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
if (!isSessionAuthMode(env.AUTH_MODE)) {
|
||||||
return new Response("Not found", {
|
return new Response("Not found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasHostedAuthConfig()) {
|
if (!hasSessionAuthConfig()) {
|
||||||
return new Response("Missing Better Auth hosted configuration", {
|
return new Response("Missing Better Auth configuration", {
|
||||||
status: 500,
|
status: 500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,14 @@ import { z } from "zod";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { account } from "@/db/schema";
|
import { account } from "@/db/schema";
|
||||||
import { getAuth } from "@/lib/auth";
|
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 { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
|
||||||
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
||||||
|
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
||||||
import { AppError } from "@/server/lib/errors";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { responseForAppError } from "@/server/lib/http-errors";
|
import { responseForAppError } from "@/server/lib/http-errors";
|
||||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
@ -385,10 +390,13 @@ export async function handleSelfHostedGoogleOAuthCallbackRequest(
|
|||||||
try {
|
try {
|
||||||
const authMode = getAuthMode(env.AUTH_MODE);
|
const authMode = getAuthMode(env.AUTH_MODE);
|
||||||
if (isHostedAuthMode(authMode)) {
|
if (isHostedAuthMode(authMode)) {
|
||||||
|
// Hosted uses Better Auth's genericOAuth provider, not this hand-rolled
|
||||||
|
// flow.
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
const context =
|
const context = isSessionAuthMode(authMode)
|
||||||
authMode === "local_noauth"
|
? await resolveHostedContext(request.headers)
|
||||||
|
: authMode === "local_noauth"
|
||||||
? await resolveLocalNoAuthContext()
|
? await resolveLocalNoAuthContext()
|
||||||
: await resolveCloudflareAccessContext(request.headers);
|
: await resolveCloudflareAccessContext(request.headers);
|
||||||
return await handleSelfHostedGoogleOAuthCallback({
|
return await handleSelfHostedGoogleOAuthCallback({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user