diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be00cd9d..25fe8229 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -53,6 +53,7 @@ export const app = new Hono() if (isPublicCorsPath(c.req.path)) { c.set('user', null); c.set('session', null); + c.set('orgRoles', {}); return next(); } @@ -68,6 +69,7 @@ export const app = new Hono() if (!session) { c.set('user', null); c.set('session', null); + c.set('orgRoles', {}); await next(); return; @@ -75,6 +77,7 @@ export const app = new Hono() c.set('user', session.user); c.set('session', session.session); + c.set('orgRoles', (session as { orgRoles?: Record }).orgRoles ?? {}); await next(); }) diff --git a/apps/api/src/middlewares/org-admin-or-automation-key.ts b/apps/api/src/middlewares/org-admin-or-automation-key.ts index f1405fd9..615260c9 100644 --- a/apps/api/src/middlewares/org-admin-or-automation-key.ts +++ b/apps/api/src/middlewares/org-admin-or-automation-key.ts @@ -3,7 +3,6 @@ import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organizatio import { ErrorCodes } from '@api/utils/errors'; import { ROLE } from '@cio/utils/constants'; -import { getOrgRolesForUser } from '@api/utils/redis/org-roles-cache'; import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key'; export const orgAdminOrAutomationKeyMiddleware = @@ -56,7 +55,7 @@ export const orgAdminOrAutomationKeyMiddleware = ); } - const orgRoles = await getOrgRolesForUser(user.id); + const orgRoles = (c.get('orgRoles') as Record | undefined) ?? {}; if (orgRoles[orgId] !== ROLE.ADMIN) { return c.json( { diff --git a/apps/api/src/middlewares/org-admin.ts b/apps/api/src/middlewares/org-admin.ts index 9fe86154..f5e50bb2 100644 --- a/apps/api/src/middlewares/org-admin.ts +++ b/apps/api/src/middlewares/org-admin.ts @@ -2,11 +2,11 @@ import { Context, Next } from 'hono'; import { ErrorCodes } from '@api/utils/errors'; import { ROLE } from '@cio/utils/constants'; -import { getOrgRolesForUser } from '@api/utils/redis/org-roles-cache'; /** * Middleware to check if the authenticated user is an admin of the organization. - * Reads `orgRoles` from the Redis-backed cache (DB fallback on miss). + * Reads the role from the Better Auth session (populated by the customSession plugin), + * so no per-request DB query is performed. * * Requires authMiddleware to be applied first. * Expects orgId in the 'cio-org-id' header. @@ -37,7 +37,7 @@ export const orgAdminMiddleware = async (c: Context, next: Next) => { ); } - const orgRoles = await getOrgRolesForUser(user.id); + const orgRoles = (c.get('orgRoles') as Record | undefined) ?? {}; const roleId = orgRoles[orgId]; if (roleId !== ROLE.ADMIN) { diff --git a/apps/api/src/middlewares/org-member.ts b/apps/api/src/middlewares/org-member.ts index 3ed66533..8b3e91c7 100644 --- a/apps/api/src/middlewares/org-member.ts +++ b/apps/api/src/middlewares/org-member.ts @@ -1,11 +1,9 @@ import { Context, Next } from 'hono'; -import { getOrgRolesForUser } from '@api/utils/redis/org-roles-cache'; /** * Middleware to check if the authenticated user is a member of the organization. - * Loads `orgRoles` from the Redis-backed cache (DB fallback on miss) so the - * lookup stays out of the Better Auth session cookie — keeping that cookie - * small enough for the oauth-proxy redirect to fit in a Location header. + * Reads the role from the Better Auth session (populated by the customSession plugin), + * so no per-request DB query is performed. * * Requires authMiddleware to be applied first. * Expects orgId in the 'cio-org-id' header. @@ -37,7 +35,7 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => { ); } - const orgRoles = await getOrgRolesForUser(user.id); + const orgRoles = (c.get('orgRoles') as Record | undefined) ?? {}; const roleId = orgRoles[orgId]; if (roleId === undefined) { diff --git a/apps/api/src/types/auth.ts b/apps/api/src/types/auth.ts index 2fd1974e..8270767e 100644 --- a/apps/api/src/types/auth.ts +++ b/apps/api/src/types/auth.ts @@ -6,6 +6,7 @@ export type AuthSession = { actorId: string | null; automationKey: TOrganizationApiKey | null; orgId: string | null; + orgRoles: Record; session: typeof auth.$Infer.Session.session | null; user: typeof auth.$Infer.Session.user | null; userRole: number | null; diff --git a/apps/api/src/utils/redis/org-roles-cache.ts b/apps/api/src/utils/redis/org-roles-cache.ts deleted file mode 100644 index 7f863700..00000000 --- a/apps/api/src/utils/redis/org-roles-cache.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getUserOrgRolesMap } from '@cio/db/queries/organization'; -import { logRedisUnavailableOnce, redis } from './redis'; - -const TTL_SECONDS = 60 * 60; // 1 hour, matches the prior cookieCache.maxAge. - -const cacheKey = (userId: string) => `org-roles:${userId}`; - -export async function getOrgRolesForUser(userId: string): Promise> { - try { - const cached = await redis.get(cacheKey(userId)); - if (cached) { - try { - return JSON.parse(cached) as Record; - } catch { - // Stale/corrupt entry — fall through to DB. - } - } - } catch (error) { - logRedisUnavailableOnce('Redis GET org-roles failed, falling back to DB', error); - } - - const orgRoles = await getUserOrgRolesMap(userId); - - try { - await redis.set(cacheKey(userId), JSON.stringify(orgRoles), { EX: TTL_SECONDS }); - } catch (error) { - logRedisUnavailableOnce('Redis SET org-roles failed, cache disabled', error); - } - - return orgRoles; -} - -export async function invalidateOrgRolesCache(userId: string): Promise { - try { - await redis.del(cacheKey(userId)); - } catch (error) { - logRedisUnavailableOnce('Redis DEL org-roles failed, falling back to TTL', error); - } -} diff --git a/packages/db/GOOGLE_AUTH.md b/packages/db/GOOGLE_AUTH.md new file mode 100644 index 00000000..996151b8 --- /dev/null +++ b/packages/db/GOOGLE_AUTH.md @@ -0,0 +1,167 @@ +# Google OAuth across multiple apex domains + +This doc explains how Google sign-in works for ClassroomIO across three different host families: + +- `app.classroomio.com` — admin dashboard +- `.classroomio.school` — free-tier tenant sites +- `.com` — BYOD custom domains via Cloudflare for SaaS + +All three need to sign users in via Google, set host-only auth cookies on the originating host, and land the user back on the page they started from. + +## The problem + +Google OAuth requires every callback URL to be **registered ahead of time** in Google Cloud Console. Wildcards are not allowed in standard apps. We can't register `*.classroomio.school` or every BYOD customer's domain. + +We also can't share auth cookies across apex domains: a cookie on `api.classroomio.com` won't be sent to a request on `acme.classroomio.school`. Even setting `Domain=.classroomio.school` won't help — the OAuth callback always lands on `api.classroomio.com` per the registered URI, and that response can only set cookies for `api.classroomio.com`. + +## The architecture + +``` +acme.classroomio.school api.classroomio.com Google +───────────────────────── ───────────────────── ────── + +POST /proxy/api/auth/sign-in/social + │ + ▼ + Cloudflare Worker (apps/tenant-router) + ─── strips /proxy, forwards ─────────────▶ POST /api/auth/sign-in/social + │ + │ oAuthProxy plugin sees + │ currentURL ≠ productionURL, + │ rewrites callbackURL to + │ acme.classroomio.school/ + │ api/auth/oauth-proxy-callback + │ and forces redirect_uri to + │ api.classroomio.com/.../callback/google + ▼ + ◀───── 200 { url: google authorize URL } ───────────── + browser navigates to Google ────────────────────────────────────────────────────────────────▶ + │ + │ user + │ consents + │ + ◀────────────────────────────────────────── 302 redirect_uri (api.classroomio.com/...) ─────── + │ + GET /api/auth/callback/google?state=...&code=... ──▶│ + │ + │ oAuthProxy plugin: skipStateCookieCheck=true + │ Better Auth: exchange code, create user, + │ create session, set Set-Cookie + │ for api host + │ oAuthProxy after-middleware: encrypt all + │ Set-Cookie content into URL + │ location=…?cookies= + │ + │ APP-LEVEL SWAP (apps/api/src/app.ts): + │ stash encrypted cookies in Redis + │ under a short token, rewrite location to + │ …?token= (drops 10KB → ~150 bytes) + ▼ + ◀────────── 302 …/oauth-proxy-callback?callbackURL=…&token= ──────────────────────── + browser follows back to the tenant host + │ + ▼ + Cloudflare Worker forwards (via `/api/auth/*` carve-out, no /proxy prefix) + │ + GET /api/auth/oauth-proxy-callback?callbackURL=…&token= + │ + │ APP-LEVEL SWAP (inbound): + │ read token from Redis, restore + │ ?cookies=, delete token + │ + │ Better Auth oAuthProxy endpoint: + │ decrypt the cookies blob, + │ re-emit each Set-Cookie on this response + │ (so the browser scopes them to the + │ tenant host) + ▼ + ◀────────────────────────────────────── 302 to original callbackURL ────────────────────────── + browser lands on acme.classroomio.school/, logged in. +``` + +The same flow works for `app.classroomio.com` (the proxy-skip branch in `oAuthProxy` kicks in when `currentURL === productionURL`) and for BYOD domains (`learn.acme.com`) routed through Cloudflare for SaaS. + +## Pieces and where they live + +| Concern | File | Notes | +|---|---|---| +| Better Auth + `oAuthProxy` plugin registration | `packages/db/src/auth.ts` | `productionURL: CONSTANTS.BASE_URL` makes `api.classroomio.com` the canonical OAuth-callback URL. | +| Auth endpoint mount in Hono | `apps/api/src/app.ts` (`/api/auth/*` handler) | Rewrites `request.url` from `X-Forwarded-Host`/`-Proto` so the plugin sees the tenant host the user actually visited. | +| Cookies → token swap (outbound + inbound) | `apps/api/src/app.ts` (`/api/auth/*` handler) | Stashes the encrypted `cookies=…` blob in Redis under a short token, drops Location header from 10KB+ to ~150 bytes. | +| Redis storage helpers | `apps/api/src/utils/redis/oauth-handoff.ts` | `storeHandoffPayload` (2-minute TTL) + `consumeHandoffPayload` (single-use, GET-and-DEL). | +| Cloudflare Worker | `apps/tenant-router/src/index.ts` | Forwards `/proxy/*` → API (stripping prefix) and `/api/auth/*` → API (as-is, no strip), everything else → dashboard. | +| Browser-side base URL | `apps/dashboard/src/lib/utils/services/api/index.ts` (`getRequestBaseUrl`) | Returns `${origin}/proxy` in the browser so auth cookies stay host-only. | +| Browser-side auth client baseURL | `apps/dashboard/src/lib/utils/services/auth/client.ts` | `${origin}/proxy/api/auth` — Better Auth's client treats a baseURL with a path as the full auth root, so we encode the post-Worker-strip path here. | +| Auto-enrollment after first signup | `packages/db/src/auth/hooks/tenant-provisioning.ts` | Resolves the tenant org from `cio-org-id` header (email signup) or `x-forwarded-host` (OAuth host). Idempotent membership insert. | +| `customSession` plugin (adds `orgRoles` to session) | `packages/db/src/auth.ts` | Loads `{ [orgId]: roleId }` via `getUserOrgRolesMap` and ships it inside the `session_data` cookie cache. Org-scoped middlewares read it from `c.get('orgRoles')` — no per-request DB query. | + +## Why the Redis handoff exists + +Better Auth's `oAuthProxy` plugin handles cross-origin OAuth by encrypting **every Set-Cookie** the OAuth callback emits — `session_token`, `session_data`, `account_data`, `dontRememberToken` — into a single `cookies=` query parameter on the redirect URL. For ClassroomIO with `cookieCache.enabled: true` and `storeAccountCookie: true`, that blob hits ~5KB raw / ~10KB hex-encoded. + +Some hop between Render and the browser (Render's edge proxy, suspect HTTP header size limit) silently drops responses with a Location header that big — the API thinks it sent a 302, but the browser never receives one. The OAuth flow hangs. + +The Redis handoff sidesteps the size limit entirely: + +- Encrypted cookies blob → Redis, keyed by a 32-hex-char token. +- Location header carries only the token (~150 bytes total URL). +- Tenant-host endpoint reads token from Redis, restores the cookies query param, hands the request to Better Auth as-if-original. + +Single-use (GET-and-DEL on read) and 2-minute TTL keep the surface small. + +## Google Cloud Console settings + +You only ever need **one** OAuth client and **one** registered callback URI for unlimited tenants: + +- **Authorized JavaScript origins**: `https://api.classroomio.com` +- **Authorized redirect URIs**: `https://api.classroomio.com/api/auth/callback/google` + +Do **not** add tenant subdomains, `*.classroomio.school`, or BYOD customer domains — Google rejects wildcards for standard apps and the `oAuthProxy` plugin handles the redirect to the original tenant host on its own. + +### Sensitive scopes warning + +If Google's consent screen warns users that the app is requesting "sensitive" or "restricted" scopes (like Drive) during sign-in, check **Google Cloud Console → OAuth consent screen → Scopes**. Sign-in only requests `email`, `profile`, `openid`. If `drive.readonly` is in the declared scopes list there, remove it. Drive access is requested separately via the in-app picker (`apps/dashboard/src/lib/utils/functions/google-drive-picker.ts`) using incremental authorization, so the consent prompt for it only appears when a user actually opens the Drive picker. + +## Required env vars + +On the **API** Render service: + +| Var | Value | Why | +|---|---|---| +| `BETTER_AUTH_SECRET` | random 32+ char string | Encrypts the oauth-proxy cookies blob and signs sessions. | +| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Better Auth's `baseURL` and the `oAuthProxy` plugin's `productionURL`. | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | from Google Cloud Console | OAuth client credentials. | +| `REDIS_URL` | Render Key Value internal URL | Required for the cookies-→-token handoff. Without it the handoff falls back to inline cookies and the OAuth flow breaks on tenant subdomains. | + +On the **dashboard** Render service: + +| Var | Value | Why | +|---|---|---| +| `HOST_HEADER` | `x-forwarded-host` | SvelteKit adapter-node reads the effective host from this so `event.url.host` is the tenant/admin host the user is on. | +| `PROTOCOL_HEADER` | `x-forwarded-proto` | Same, for protocol. | +| `PRIVATE_APP_HOST` | `classroomio.school` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. | +| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Used at runtime only as a fallback / for `authClient`'s SSR-time URL constructor; browser code uses `${origin}/proxy` via `getRequestBaseUrl`. | +| `PRIVATE_SERVER_URL` | API's internal Render URL | Server-side dashboard → API calls bypass the public edge. | + +## Verifying the flow + +After any change, exercise the full sequence and check the API logs: + +``` +[auth-handler] POST /api/auth/sign-in/social → 200 +[auth-handler] GET /api/auth/callback/google → 302 location.length≈150 +[auth-handler] GET /api/auth/oauth-proxy-callback → 302 (to original callbackURL) +``` + +If `location.length` is in the thousands again, the cookies → token swap didn't fire (check Redis connectivity, or that the response Location actually contains `cookies=`). + +If the proxy-callback request never appears, the Worker's `/api/auth/*` carve-out isn't deployed (`pnpm --filter @cio/tenant-router deploy`). + +If you get `state_security_mismatch`, the `oAuthProxy` plugin didn't detect the proxied flow on the callback side — most likely the `X-Forwarded-Host` rewrite in `apps/api/src/app.ts` isn't running, so `currentURL` looked like the production URL. + +## Known gotchas + +- **`storeAccountCookie: true` is fine again** with the Redis handoff. The size of the cookies blob no longer matters; it just has to fit in a Redis value (which has no practical ceiling here). +- **`customSession` is gone.** `orgRoles` is fetched per-request from Redis in `org-member`, `org-admin`, and `org-admin-or-automation-key` middlewares (DB on cache miss, 1-hour TTL). Anywhere that mutates `organizationmember` for an **existing** user should call `invalidateOrgRolesCache(userId)` so the role change is visible immediately instead of after the TTL. +- **OAuth state cookie check is skipped** on the callback when the `oAuthProxy` plugin engages (`skipStateCookieCheck: true`). CSRF is still protected by the unguessable random state token stored in the DB (Better Auth's `verification` table) and the 10-minute expiry. This is the documented Better Auth mitigation. +- **Render service name swap** — `cio-api.onrender.com` is the **dashboard** service, `cio-api-vymh.onrender.com` is the **API** service. Render doesn't allow renaming. See `apps/tenant-router/wrangler.toml` for the same warning. diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index aca8dbff..04d72f1a 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -6,9 +6,11 @@ import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email import { betterAuth } from 'better-auth/minimal'; import { createProfileHook } from './auth/hooks/create-profile'; +import { customSession } from 'better-auth/plugins/custom-session'; import { db } from '@db/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { config as emailAndPassword } from './auth/email-password'; +import { getUserOrgRolesMap } from './queries/organization/organization'; import { loginLink } from './auth/plugins/login-link'; import { oAuthProxy } from 'better-auth/plugins/oauth-proxy'; import { resolveTrustedBrowserOrigin } from './utils'; @@ -76,7 +78,8 @@ export const auth: ReturnType = betterAuth({ user: { create: { after: async (user, ctx) => { - console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id, ctx }); + console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id }); + const request = (ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ?? (ctx as { request?: Request } | undefined)?.request; @@ -119,6 +122,20 @@ export const auth: ReturnType = betterAuth({ productionURL: CONSTANTS.BASE_URL }), loginLink(), - tokenExchange() + tokenExchange(), + // Attaches the user's org memberships ({ [orgId]: roleId }) to the session + // so org-scoped middlewares can authorize without a per-request DB query. + // Refreshes when the session cookie cache expires (see session.cookieCache.maxAge). + customSession(async ({ user, session }) => { + let orgRoles: Record = {}; + try { + if (user?.id) { + orgRoles = await getUserOrgRolesMap(user.id); + } + } catch (error) { + console.error('customSession: failed to load orgRoles', error); + } + return { user, session, orgRoles }; + }) ] });