This guide lets any MAC app use the central auth service for sign-in. Copy it into your repo and follow it top to bottom — no prior knowledge of the auth service is needed.
Your app stores no passwords and no user accounts. Users sign in with Google or
Microsoft on the central service, which hands your app a short-lived signed token (JWT).
Your backend verifies that token locally (no network call per request) and keys its own
data by the user's stable macUserId.
- Auth service:
https://auth.monashcoding.com - You need: a backend that can read an
Authorizationheader, andnpm i jose.
User ─sign in─▶ auth.monashcoding.com ─Google/Microsoft─▶ session cookie (.monashcoding.com)
│
Your app ◀─ JWT { macUserId, email, name, roles, team } ◀─ GET /api/auth/token ◀┘
└─ verifies the JWT locally (jose + published JWKS) — no call back to auth per request
└─ stores/loads its data keyed by macUserId
- The token is signed with EdDSA (Ed25519). Your app verifies it against the public keys
at
https://auth.monashcoding.com/api/auth/jwks(fetched once and cached). - Tokens live 15 minutes. The browser's session cookie lasts much longer, so you just re-fetch a fresh token when one expires.
- The session cookie is scoped to
.monashcoding.com, so a user signed into one MAC app is already signed into yours (single sign-on) — if your app is on amonashcoding.comsubdomain.
-
Serve your app on a
*.monashcoding.comsubdomain (e.g.jobs.monashcoding.com).- This gives you automatic single sign-on across MAC apps.
- Every https
*.monashcoding.comorigin is trusted by the auth service automatically — there is nothing to register and no one to ask. You can integrate today. - Off-domain origins (including local dev on
http://localhost:3000) are not trusted by default. For local development, ask whoever runs the auth service to add your dev origin to itsTRUSTED_ORIGINSenv var, or test against a deployed*.monashcoding.compreview.
-
Install the one dependency in your backend:
npm i jose
That's it — no client IDs, no secrets, no redirect URIs to register on your side. The auth service owns the Google/Microsoft OAuth apps.
Every token your app receives carries exactly these claims:
{
"macUserId": "<stable per-person id>",
"email": "<user email>",
"name": "<display name>",
"roles": ["member", "committee", "exec"],
"team": "Events",
"ver": 1,
"iss": "https://auth.monashcoding.com",
"aud": "mac-suite",
"exp": 1234567890
}macUserId— the canonical, stable identifier for a person. Use it as the foreign key in your own tables. Never key data by email (emails change).name— the user's display name from their Google/Microsoft profile. Always present, so you can show a real name instead of the email.roles— a string array.memberis the baseline for anyone signed in.committeeis added for anyone on the central committee roster (curated in Notion),execfor execs, andadminfor infra superusers (env allowlist, not from Notion). Gate committee-only features onroles.includes("committee").team— the person's functional team (e.g."Events"), ornullif they aren't on the committee roster. Informational — it tells you where they sit, not what they can do.issis alwayshttps://auth.monashcoding.com,audis alwaysmac-suite. Your verifier checks both.
Save this as verify.ts (or .js with types stripped) in your backend. It fetches and caches
the public keys and validates every token's signature, issuer, audience, and expiry. Its only
dependency is jose.
/**
* MAC token verifier. Verifies a Better Auth JWT locally against the auth service's JWKS.
* The JWKS is fetched once and cached, so verifying a token does NOT call auth per request.
* Only dependency: `jose` -> npm i jose
*/
import { createRemoteJWKSet, jwtVerify } from "jose";
const AUTH_URL = process.env.AUTH_URL ?? "https://auth.monashcoding.com";
const ISSUER = AUTH_URL;
const AUDIENCE = process.env.JWT_AUDIENCE ?? "mac-suite";
// Cached remote JWKS (Ed25519 public keys). Reused across calls — do NOT recreate per request.
const JWKS = createRemoteJWKSet(new URL(`${AUTH_URL}/api/auth/jwks`));
/** The claims a verified MAC token is guaranteed to carry. */
export interface MacClaims {
macUserId: string;
email: string;
name: string;
roles: string[];
team: string | null;
ver: number;
}
/**
* Verify a MAC-issued JWT. Throws if the signature, issuer, audience, or expiry (`exp`)
* is invalid. Returns the typed MAC claims on success.
*/
export async function verifyMacToken(token: string): Promise<MacClaims> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER, // checks `iss`
audience: AUDIENCE, // checks `aud`
// `exp` is enforced by jwtVerify automatically.
});
return {
macUserId: payload.macUserId as string,
email: payload.email as string,
name: (payload.name as string) ?? "",
roles: (payload.roles as string[]) ?? [],
team: (payload.team as string | null) ?? null,
ver: (payload.ver as number) ?? 1,
};
}Set AUTH_URL=https://auth.monashcoding.com in your backend env (the file defaults to it, so
this is optional in production).
Social sign-in is a POST that returns a URL to redirect the user to. It is not a plain link.
const res = await fetch("https://auth.monashcoding.com/api/auth/sign-in/social", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // so the session cookie is set
body: JSON.stringify({
provider: "google", // or "microsoft"
callbackURL: "https://jobs.monashcoding.com/", // where to return after login
}),
});
window.location = (await res.json()).url; // → Google/Microsoft consent → back to your appAfter the user returns, their browser holds a session cookie for .monashcoding.com.
const { token } = await fetch("https://auth.monashcoding.com/api/auth/token", {
credentials: "include", // sends the shared cookie
}).then(r => r.json());
// attach it to calls to YOUR backend:
await fetch("/api/whatever", { headers: { Authorization: `Bearer ${token}` } });If /api/auth/token returns 401, the user isn't signed in — send them through Step 2.
import { verifyMacToken } from "./verify";
const token = req.headers.authorization?.replace("Bearer ", "");
try {
const claims = await verifyMacToken(token); // { macUserId, email, name, roles, team, ver }
// ... proceed as this user
} catch {
return res.status(401).end(); // invalid or expired token
}This is a local cryptographic check — no network call to the auth service.
// good — stable identity
await db.notes.create({ userId: claims.macUserId, body });
// bad — emails change
// await db.notes.create({ userEmail: claims.email, body });// Committee-only feature:
if (!claims.roles.includes("committee")) return res.status(403).end();
// Team-scoped view (informational):
if (claims.team === "Events") { /* ... */ }committee, exec, and team are derived from the central committee roster (curated in
Notion, synced hourly into the auth DB) — you never manage membership per-app. Removing someone
from the roster revokes committee/exec in their next token everywhere. admin is a separate
infra allowlist. Changes appear in a user's next token (within 15 minutes, or immediately after
they re-fetch one).
- Expiry: tokens last 15 minutes. When a call to your backend 401s because the token
expired, re-fetch a fresh one from
/api/auth/token(the cookie is still valid) and retry. A small wrapper that fetches-on-401 keeps this invisible to users. - Sign out:
This clears the shared session across all MAC apps.
await fetch("https://auth.monashcoding.com/api/auth/sign-out", { method: "POST", credentials: "include", });
| Endpoint | Method | Purpose |
|---|---|---|
/api/auth/sign-in/social |
POST | Start Google/Microsoft login → returns { url } |
/api/auth/token |
GET | Mint a JWT for the current session → { token } |
/api/auth/get-session |
GET | Inspect the current session |
/api/auth/sign-out |
POST | End the session (all MAC apps) |
/api/auth/jwks |
GET | Public keys — your verifier fetches this; you don't call it directly |
All are served from https://auth.monashcoding.com.
| Symptom | Likely cause |
|---|---|
| Sign-in POST is rejected / CORS error | Your app isn't on an https *.monashcoding.com origin, and the origin isn't in the auth service's TRUSTED_ORIGINS. Deploy to a subdomain, or get your dev origin whitelisted. |
/api/auth/token returns 401 |
The user has no session — run the Step 2 sign-in flow first. |
| Cookie not sent / no SSO | You forgot credentials: "include" on the fetch, or your app is on a non-monashcoding.com domain (the cookie is scoped to .monashcoding.com). |
verifyMacToken throws "exp" / expired |
Normal after 15 min — re-fetch a token and retry. |
verifyMacToken throws on aud/iss |
Your AUTH_URL / JWT_AUDIENCE don't match the service (https://auth.monashcoding.com, mac-suite). |
- App served on a
*.monashcoding.comsubdomain (auto-trusted). -
npm i jose;verify.tscopied into the backend. - Frontend: POST sign-in → redirect → GET
/api/auth/token→ sendBearerto your API. - Backend:
verifyMacTokenon every protected request. - Data keyed by
claims.macUserId, not email. - (Local dev only) dev origin added to the auth service's
TRUSTED_ORIGINS.