Skip to content

Commit c0b4b46

Browse files
committed
fix(auth): support localhost OAuth state cookies
1 parent 61cad93 commit c0b4b46

5 files changed

Lines changed: 88 additions & 2 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ MICROSOFT_CLIENT_SECRET=
3333
# Extra origins allowed to initiate auth flows (comma-separated).
3434
# NOTE: every https *.monashcoding.com subdomain is trusted automatically — you do NOT
3535
# need to list MAC apps here. Use this only for off-domain origins, e.g. local dev:
36-
# TRUSTED_ORIGINS=http://localhost:3000
36+
# TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001
37+
# Origins are exact: 127.0.0.1 and other ports must be listed separately if actually used.
3738
TRUSTED_ORIGINS=
3839

3940
# JWT audience claim MAC apps verify against.

DEPLOY-dokploy.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ docker exec -it "$(docker ps -qf name=dokploy.1)" bash -c "pnpm run reset-passwo
4343
The compose only references `${...}`, so an empty Environment tab means empty values — e.g. a
4444
blank `POSTGRES_PASSWORD` makes Postgres refuse to start. Verify:
4545
- `BETTER_AUTH_URL=https://auth.monashcoding.com`
46+
- `TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001` if both local app ports
47+
are in use. Origins are exact, so only add `127.0.0.1` variants if developers actually
48+
browse to those addresses.
4649
- `POSTGRES_PASSWORD` is **URL/shell-safe** (letters+digits only — no `$ @ : / #`).
4750
- No value is truncated when pasted (watch long ones like `GOOGLE_CLIENT_ID`, which must end
4851
in `.apps.googleusercontent.com`).
@@ -77,6 +80,36 @@ curl -s -X POST https://auth.monashcoding.com/api/auth/sign-in/social \
7780
```
7881
Paste that returned `url` into a browser to walk the real Google consent → callback flow.
7982

83+
### Verify localhost OAuth after this cookie-policy change
84+
85+
The auth service deliberately issues Better Auth cookies with `HttpOnly; Secure;
86+
SameSite=None`. This is required because local MAC apps create the OAuth state cookie and
87+
later use the session through credentialed cross-site requests to the production auth
88+
origin. Better Auth's CSRF and trusted-origin validation remain enabled; do not replace the
89+
explicit `TRUSTED_ORIGINS` allowlist with `*`.
90+
91+
After deploying:
92+
93+
1. Confirm the Dokploy Environment tab contains every **actual** local origin, comma-separated
94+
(normally `http://localhost:3000,http://localhost:3001`), then redeploy after any change.
95+
2. Use a Chrome Guest profile (or clear cookies for `auth.monashcoding.com`), open the local
96+
app, enable **Preserve log** in DevTools Network, and start Google sign-in once.
97+
3. Inspect `POST /api/auth/sign-in/social`. It must return the exact requesting origin in
98+
`Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials: true`, and a redacted
99+
state cookie equivalent to:
100+
`__Secure-better-auth.state=<value>; Domain=.monashcoding.com; Path=/; HttpOnly; Secure; SameSite=None`.
101+
4. Confirm that cookie is stored under `https://auth.monashcoding.com` before leaving for
102+
Google, then confirm the callback succeeds without `state_mismatch` and returns to the
103+
allowlisted local callback URL.
104+
5. Repeat from both local ports that the apps actually use, then smoke-test the production
105+
MAC Study and Job Board origins.
106+
107+
`SameSite=None` cannot override a browser setting that blocks third-party cookies entirely.
108+
If DevTools says the cookie was blocked for that reason, the durable next step is a small
109+
allowlisted page on `auth.monashcoding.com` that starts OAuth while the auth domain is the
110+
top-level site. Do not work around that policy by disabling Better Auth's state-cookie,
111+
CSRF, or origin checks.
112+
80113
---
81114

82115
## Troubleshooting: 404 / hanging requests

auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"dev": "tsx watch src/server.ts",
1111
"build": "tsc",
1212
"start": "node dist/server.js",
13+
"test": "tsx --test src/auth.test.ts",
1314
"typecheck": "tsc --noEmit",
1415
"lint": "biome check src",
1516
"lint:fix": "biome check src --write",

auth/src/auth.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import assert from "node:assert/strict";
2+
import { after, test } from "node:test";
3+
4+
// auth.ts constructs the Drizzle adapter at import time. postgres.js connects lazily, so
5+
// this non-routable URL is sufficient for inspecting the generated cookie policy without
6+
// querying a database or making any network request.
7+
process.env.DATABASE_URL = "postgres://test:test@127.0.0.1:1/test";
8+
process.env.BETTER_AUTH_URL = "https://auth.monashcoding.com";
9+
process.env.BETTER_AUTH_SECRET = "test-only-secret-that-is-at-least-32-characters";
10+
process.env.GOOGLE_CLIENT_ID = "test-google-client-id";
11+
process.env.GOOGLE_CLIENT_SECRET = "test-google-client-secret";
12+
process.env.MICROSOFT_CLIENT_ID = "test-microsoft-client-id";
13+
process.env.MICROSOFT_CLIENT_SECRET = "test-microsoft-client-secret";
14+
15+
const [{ auth }, { client }] = await Promise.all([import("./auth.js"), import("./db.js")]);
16+
17+
after(async () => {
18+
await client.end({ timeout: 0 });
19+
});
20+
21+
test("OAuth state and session cookies support credentialed localhost requests", async () => {
22+
const context = await auth.$context;
23+
const stateCookie = context.createAuthCookie("state", { maxAge: 300 });
24+
const sessionCookie = context.authCookies.sessionToken;
25+
26+
for (const cookie of [stateCookie, sessionCookie]) {
27+
assert.equal(cookie.attributes.httpOnly, true);
28+
assert.equal(cookie.attributes.secure, true);
29+
assert.equal(cookie.attributes.sameSite, "none");
30+
assert.equal(cookie.attributes.domain, ".monashcoding.com");
31+
assert.equal(cookie.attributes.path, "/");
32+
}
33+
34+
assert.equal(stateCookie.name, "__Secure-better-auth.state");
35+
assert.equal(stateCookie.attributes.maxAge, 300);
36+
});

auth/src/auth.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
*
99
* Option shapes here were verified against the installed better-auth@1.6.x types
1010
* (jwt plugin `jwt.definePayload`/`expirationTime`, `socialProviders.microsoft.tenantId`,
11-
* `advanced.crossSubDomainCookies`, `user.additionalFields`, `databaseHooks`).
11+
* `advanced.crossSubDomainCookies`/`defaultCookieAttributes`,
12+
* `user.additionalFields`, `databaseHooks`).
1213
*/
1314
import { betterAuth } from "better-auth";
1415
import { drizzleAdapter } from "better-auth/adapters/drizzle";
@@ -18,6 +19,7 @@ import { claimsForEmail } from "./roster/lookup.js";
1819
import { schema } from "./schema.js";
1920

2021
const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
22+
const isSecureAuthOrigin = baseURL.startsWith("https://");
2123
const audience = process.env.JWT_AUDIENCE ?? "mac-suite";
2224

2325
// Any https app on a *.monashcoding.com subdomain is trusted by default. Better Auth
@@ -171,6 +173,19 @@ export const auth = betterAuth({
171173
enabled: true,
172174
domain: ".monashcoding.com",
173175
},
176+
177+
// Local MAC apps start OAuth and fetch their resulting session/token from this
178+
// production auth origin. Those are cross-site credentialed requests, so Better
179+
// Auth's SameSite=Lax default prevents both the short-lived signed state cookie and
180+
// the resulting session cookie from being stored/sent. SameSite=None requires Secure
181+
// on the deployed HTTPS origin; retain Lax/non-Secure for a plain-HTTP local auth server.
182+
// HttpOnly keeps the cookies inaccessible to application JavaScript. Better Auth's CSRF
183+
// and trusted-origin checks remain enabled and CORS only reflects allowed origins.
184+
defaultCookieAttributes: {
185+
httpOnly: true,
186+
secure: isSecureAuthOrigin,
187+
sameSite: isSecureAuthOrigin ? "none" : "lax",
188+
},
174189
},
175190

176191
plugins: [

0 commit comments

Comments
 (0)