Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions DEPLOY-dokploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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=<value>; 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
Expand Down
1 change: 1 addition & 0 deletions auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions auth/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
17 changes: 16 additions & 1 deletion auth/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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: [
Expand Down
Loading