diff --git a/.env.example b/.env.example index e472bba..e67bd67 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,8 @@ MICROSOFT_CLIENT_SECRET= # Extra origins allowed to initiate auth flows (comma-separated). # NOTE: every https *.monashcoding.com subdomain is trusted automatically — you do NOT # need to list MAC apps here. Use this only for off-domain origins, e.g. local dev: -# TRUSTED_ORIGINS=http://localhost:3000 +# TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001 +# Origins are exact: 127.0.0.1 and other ports must be listed separately if actually used. TRUSTED_ORIGINS= # JWT audience claim MAC apps verify against. diff --git a/DEPLOY-dokploy.md b/DEPLOY-dokploy.md index e74a475..e9d32ab 100644 --- a/DEPLOY-dokploy.md +++ b/DEPLOY-dokploy.md @@ -43,6 +43,9 @@ docker exec -it "$(docker ps -qf name=dokploy.1)" bash -c "pnpm run reset-passwo The compose only references `${...}`, so an empty Environment tab means empty values — e.g. a blank `POSTGRES_PASSWORD` makes Postgres refuse to start. Verify: - `BETTER_AUTH_URL=https://auth.monashcoding.com` + - `TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001` if both local app ports + are in use. Origins are exact, so only add `127.0.0.1` variants if developers actually + browse to those addresses. - `POSTGRES_PASSWORD` is **URL/shell-safe** (letters+digits only — no `$ @ : / #`). - No value is truncated when pasted (watch long ones like `GOOGLE_CLIENT_ID`, which must end in `.apps.googleusercontent.com`). @@ -77,6 +80,36 @@ curl -s -X POST https://auth.monashcoding.com/api/auth/sign-in/social \ ``` Paste that returned `url` into a browser to walk the real Google consent → callback flow. +### Verify localhost OAuth after this cookie-policy change + +The auth service deliberately issues Better Auth cookies with `HttpOnly; Secure; +SameSite=None`. This is required because local MAC apps create the OAuth state cookie and +later use the session through credentialed cross-site requests to the production auth +origin. Better Auth's CSRF and trusted-origin validation remain enabled; do not replace the +explicit `TRUSTED_ORIGINS` allowlist with `*`. + +After deploying: + +1. Confirm the Dokploy Environment tab contains every **actual** local origin, comma-separated + (normally `http://localhost:3000,http://localhost:3001`), then redeploy after any change. +2. Use a Chrome Guest profile (or clear cookies for `auth.monashcoding.com`), open the local + app, enable **Preserve log** in DevTools Network, and start Google sign-in once. +3. Inspect `POST /api/auth/sign-in/social`. It must return the exact requesting origin in + `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials: true`, and a redacted + state cookie equivalent to: + `__Secure-better-auth.state=; Domain=.monashcoding.com; Path=/; HttpOnly; Secure; SameSite=None`. +4. Confirm that cookie is stored under `https://auth.monashcoding.com` before leaving for + Google, then confirm the callback succeeds without `state_mismatch` and returns to the + allowlisted local callback URL. +5. Repeat from both local ports that the apps actually use, then smoke-test the production + MAC Study and Job Board origins. + +`SameSite=None` cannot override a browser setting that blocks third-party cookies entirely. +If DevTools says the cookie was blocked for that reason, the durable next step is a small +allowlisted page on `auth.monashcoding.com` that starts OAuth while the auth domain is the +top-level site. Do not work around that policy by disabling Better Auth's state-cookie, +CSRF, or origin checks. + --- ## Troubleshooting: 404 / hanging requests diff --git a/auth/package.json b/auth/package.json index 9354050..6300a8a 100644 --- a/auth/package.json +++ b/auth/package.json @@ -10,6 +10,7 @@ "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js", + "test": "tsx --test src/auth.test.ts", "typecheck": "tsc --noEmit", "lint": "biome check src", "lint:fix": "biome check src --write", diff --git a/auth/src/auth.test.ts b/auth/src/auth.test.ts new file mode 100644 index 0000000..5e3aaad --- /dev/null +++ b/auth/src/auth.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; + +// auth.ts constructs the Drizzle adapter at import time. postgres.js connects lazily, so +// this non-routable URL is sufficient for inspecting the generated cookie policy without +// querying a database or making any network request. +process.env.DATABASE_URL = "postgres://test:test@127.0.0.1:1/test"; +process.env.BETTER_AUTH_URL = "https://auth.monashcoding.com"; +process.env.BETTER_AUTH_SECRET = "test-only-secret-that-is-at-least-32-characters"; +process.env.GOOGLE_CLIENT_ID = "test-google-client-id"; +process.env.GOOGLE_CLIENT_SECRET = "test-google-client-secret"; +process.env.MICROSOFT_CLIENT_ID = "test-microsoft-client-id"; +process.env.MICROSOFT_CLIENT_SECRET = "test-microsoft-client-secret"; + +const [{ auth }, { client }] = await Promise.all([import("./auth.js"), import("./db.js")]); + +after(async () => { + await client.end({ timeout: 0 }); +}); + +test("OAuth state and session cookies support credentialed localhost requests", async () => { + const context = await auth.$context; + const stateCookie = context.createAuthCookie("state", { maxAge: 300 }); + const sessionCookie = context.authCookies.sessionToken; + + for (const cookie of [stateCookie, sessionCookie]) { + assert.equal(cookie.attributes.httpOnly, true); + assert.equal(cookie.attributes.secure, true); + assert.equal(cookie.attributes.sameSite, "none"); + assert.equal(cookie.attributes.domain, ".monashcoding.com"); + assert.equal(cookie.attributes.path, "/"); + } + + assert.equal(stateCookie.name, "__Secure-better-auth.state"); + assert.equal(stateCookie.attributes.maxAge, 300); +}); diff --git a/auth/src/auth.ts b/auth/src/auth.ts index b0f0cdc..233fee6 100644 --- a/auth/src/auth.ts +++ b/auth/src/auth.ts @@ -8,7 +8,8 @@ * * Option shapes here were verified against the installed better-auth@1.6.x types * (jwt plugin `jwt.definePayload`/`expirationTime`, `socialProviders.microsoft.tenantId`, - * `advanced.crossSubDomainCookies`, `user.additionalFields`, `databaseHooks`). + * `advanced.crossSubDomainCookies`/`defaultCookieAttributes`, + * `user.additionalFields`, `databaseHooks`). */ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; @@ -18,6 +19,7 @@ import { claimsForEmail } from "./roster/lookup.js"; import { schema } from "./schema.js"; const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; +const isSecureAuthOrigin = baseURL.startsWith("https://"); const audience = process.env.JWT_AUDIENCE ?? "mac-suite"; // Any https app on a *.monashcoding.com subdomain is trusted by default. Better Auth @@ -171,6 +173,19 @@ export const auth = betterAuth({ enabled: true, domain: ".monashcoding.com", }, + + // Local MAC apps start OAuth and fetch their resulting session/token from this + // production auth origin. Those are cross-site credentialed requests, so Better + // Auth's SameSite=Lax default prevents both the short-lived signed state cookie and + // the resulting session cookie from being stored/sent. SameSite=None requires Secure + // on the deployed HTTPS origin; retain Lax/non-Secure for a plain-HTTP local auth server. + // HttpOnly keeps the cookies inaccessible to application JavaScript. Better Auth's CSRF + // and trusted-origin checks remain enabled and CORS only reflects allowed origins. + defaultCookieAttributes: { + httpOnly: true, + secure: isSecureAuthOrigin, + sameSite: isSecureAuthOrigin ? "none" : "lax", + }, }, plugins: [