From 266f9f2b22048fba4a743c041b01ccccb6c08eda Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 20 May 2026 16:33:11 +0100 Subject: [PATCH] chore: simplify workflow for self hosters. --- .../src/lib/utils/services/api/index.ts | 15 +++++--- .../src/lib/utils/services/auth/client.ts | 36 +++++++++++-------- packages/db/src/auth.ts | 32 ++++++++++++----- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index 9d2e7128..0d6c2bd3 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -16,10 +16,17 @@ export const getRequestBaseUrl = () => { return process.env.PRIVATE_SERVER_URL || env.PUBLIC_SERVER_URL; } - // Browser: same-origin via the Cloudflare Worker `/proxy` prefix. Keeps - // auth cookies host-only on whichever domain the user is currently - // visiting (app.classroomio.com, .classroomio.school, or BYOD). - // The Worker strips `/proxy` and forwards the remainder to the API. + // Self-hosted: dashboard and API are on different subdomains of the + // operator's apex. Browser calls go straight to PUBLIC_SERVER_URL; + // cookies cross subdomains via AUTH_COOKIE_DOMAIN. No Worker proxy. + if (env.PUBLIC_IS_SELFHOSTED === 'true') { + return env.PUBLIC_SERVER_URL ?? ''; + } + + // Cloud (multi-tenant): same-origin via the Cloudflare Worker `/proxy` + // prefix so auth cookies stay host-only on whichever tenant or BYOD + // domain the user is currently visiting. The Worker strips `/proxy` + // before forwarding to the API. return `${window.location.origin}/proxy`; }; diff --git a/apps/dashboard/src/lib/utils/services/auth/client.ts b/apps/dashboard/src/lib/utils/services/auth/client.ts index c23c5c99..b114adf2 100644 --- a/apps/dashboard/src/lib/utils/services/auth/client.ts +++ b/apps/dashboard/src/lib/utils/services/auth/client.ts @@ -2,25 +2,33 @@ import { createAuthClient } from 'better-auth/svelte'; import { env } from '$env/dynamic/public'; import { ssoClient } from '@better-auth/sso/client'; -// Browser: same-origin via the Cloudflare Worker, so auth cookies stay -// host-only on whichever domain the user is on (app.classroomio.com, -// .classroomio.school, or a BYOD domain). The URL is -// `/proxy/api/auth`: -// - `/proxy` is the Worker's same-origin escape hatch — it strips -// that prefix before forwarding to the upstream API. -// - `/api/auth` is where Better Auth is mounted on the Hono API. -// Better Auth's client treats any baseURL with a path as the auth root -// verbatim (it won't auto-append `/api/auth`), so we encode the full -// downstream path here. +// Browser baseURL depends on deployment shape: +// +// - Self-hosted: dashboard and API are on different subdomains under +// one apex. Browser calls go straight to PUBLIC_SERVER_URL; cookies +// cross subdomains via AUTH_COOKIE_DOMAIN. No Worker proxy. +// +// - Cloud (multi-tenant): same-origin via the Cloudflare Worker so +// auth cookies stay host-only on whichever tenant/BYOD domain the +// user is on. We encode `/proxy/api/auth` because Better +// Auth's client treats any baseURL with a path as the full auth +// root verbatim (won't auto-append `/api/auth`). // // SSR: `authClient` methods are never invoked server-side (the dashboard // uses `authServerClient` for that), but Better Auth's constructor still // validates baseURL with `new URL(...)`, so we hand it the public API // URL as an absolute-URL placeholder. -const baseURL = - typeof window !== 'undefined' - ? `${window.location.origin}/proxy/api/auth` - : env.PUBLIC_SERVER_URL || 'http://localhost:3002'; +function resolveBaseURL() { + if (typeof window === 'undefined') { + return env.PUBLIC_SERVER_URL || 'http://localhost:3002'; + } + if (env.PUBLIC_IS_SELFHOSTED === 'true') { + return env.PUBLIC_SERVER_URL || `${window.location.origin}/api/auth`; + } + return `${window.location.origin}/proxy/api/auth`; +} + +const baseURL = resolveBaseURL(); export const authClient = createAuthClient({ baseURL, diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index 04d72f1a..7d66accd 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -19,6 +19,22 @@ import { syncUserWithProfile } from './auth/hooks/sync-user'; import { tokenExchange } from './auth/plugins/token-exchange'; import { trackLoginHook } from './auth/hooks/track-login'; +/** + * Cloud (multi-tenant) only. Routes OAuth/SSO callbacks to the canonical + * production URL while completing the flow on whichever tenant host the + * user signed in from (.classroomio.school or a BYOD domain). + * + * Self-hosted instances run with one apex (api. + app.) + * and use AUTH_COOKIE_DOMAIN for cross-subdomain cookies, so the proxy + * isn't needed there. + */ +function buildOAuthProxyPlugin() { + if (process.env.PUBLIC_IS_SELFHOSTED === 'true') { + return []; + } + return [oAuthProxy({ productionURL: CONSTANTS.BASE_URL })]; +} + export const auth: ReturnType = betterAuth({ baseURL: CONSTANTS.BASE_URL, database: drizzleAdapter(db, { @@ -58,7 +74,12 @@ export const auth: ReturnType = betterAuth({ }, advanced: { cookiePrefix: 'classroomio', - crossSubDomainCookies: { enabled: false }, + // Cloud (multi-tenant): host-only cookies on each tenant/BYOD domain. + // Self-hosted: cross-subdomain cookies under AUTH_COOKIE_DOMAIN so the + // session set on `api.` is also sent to `app.`. + crossSubDomainCookies: process.env.AUTH_COOKIE_DOMAIN?.trim() + ? { enabled: true, domain: process.env.AUTH_COOKIE_DOMAIN.trim() } + : { enabled: false }, database: { generateId: false } @@ -113,14 +134,7 @@ export const auth: ReturnType = betterAuth({ // OIDC providers are registered dynamically per organization // via the admin API (auth.api.registerSSOProvider) }), - // Lets OAuth/SSO callbacks land on the canonical production URL - // (api.classroomio.com) while still completing the flow on the tenant - // host the user signed in from (.classroomio.school or a BYOD - // domain). Without this, the state cookie set on the tenant host isn't - // sent to the api host on the Google redirect → state_security_mismatch. - oAuthProxy({ - productionURL: CONSTANTS.BASE_URL - }), + ...buildOAuthProxyPlugin(), loginLink(), tokenExchange(), // Attaches the user's org memberships ({ [orgId]: roleId }) to the session