diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d57282a..933bb19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: jobs: check: - name: Typecheck & build + name: Test, typecheck & build runs-on: ubuntu-latest defaults: run: @@ -23,6 +23,7 @@ jobs: - run: cp .dev.vars.example .dev.vars - run: npx wrangler types - run: npm run typecheck + - run: npm test - run: npm run build deploy: diff --git a/app/.dev.vars.example b/app/.dev.vars.example index e73ac76..463e36b 100644 --- a/app/.dev.vars.example +++ b/app/.dev.vars.example @@ -2,17 +2,9 @@ # optional secrets show up on Env. ANTHROPIC_API_KEY="" ENGINE_MCP_TOKEN="" -# Entra ID sign-in. Use a separate dev app registration with a -# http://localhost:5173/auth/callback redirect URI; these override the vars -# in wrangler.jsonc during local dev. -ENTRA_TENANT_ID="" -ENTRA_CLIENT_ID="" -ENTRA_CLIENT_SECRET="" -# Any long random string; signs the session cookie. +# Princeton CAS needs no client secret. Set a long random string to sign +# sessions and pending login cookies (use a separate value for local dev). SESSION_SECRET="" -# OIT API store credentials (ActiveDirectory subscription) for alias->netid. -OIT_CONSUMER_KEY="" -OIT_CONSUMER_SECRET="" # Google Calendar MCP (dev needs its own OAuth client with a # http://localhost:5173/oauth/google/callback redirect URI). GOOGLE_OAUTH_CLIENT_ID="" diff --git a/app/README.md b/app/README.md index 4c6ebf5..638f30b 100644 --- a/app/README.md +++ b/app/README.md @@ -28,15 +28,23 @@ browser (React + useAgentChat over WebSocket) - **Models**: Claude Opus 5 by default, with Sonnet 5 and a Workers AI fallback ("Campus") in the switcher. Claude models require the `ANTHROPIC_API_KEY` secret; without it every choice falls back to Campus. -- **Identity**: Princeton sign-in via Microsoft Entra ID (`src/server/auth.ts` - — OIDC auth code + PKCE, confidential client, signed HttpOnly session - cookie). netid is derived from the account email's local part; there is no - way to choose one. Every agent Durable Object is named `u--…` and +- **Identity**: Princeton CAS sign-in (`src/server/auth.ts`, using + `https://authenticate.princeton.edu/cas`). The Worker validates service + tickets through `/p3/serviceValidate` over HTTPS and takes the NetID from + CAS's authenticated `user`, never from an email alias or browser input. + Display name and email use CAS attributes when released, falling back to + NetID and `@princeton.edu`. Every agent Durable Object is named `u--…` and the Worker rejects any request whose session doesn't own that prefix, so - chats (messages *and* workspace files) are strictly per user. Requires the - `ENTRA_TENANT_ID` / `ENTRA_CLIENT_ID` vars plus `ENTRA_CLIENT_SECRET` and - `SESSION_SECRET` secrets, and an app-registration redirect URI of - `/auth/callback` for every origin the app is served from. + chats (messages *and* workspace files) are strictly per user. Requires only + `SESSION_SECRET`, which signs the seven-day HttpOnly session cookie and a + ten-minute pending-login cookie. A random state is included in the CAS + service URL and checked against that cookie before ticket validation. + The service URL is `/auth/callback?state=`; CAS must + allow that callback path and its query parameters for each deployed/dev + origin. Logout ends the PI session only, leaving Princeton SSO active. + Sessions issued before the CAS migration require a fresh sign-in. Existing + chats remain under their NetID; any historical email-alias namespaces are + not automatically reassigned. - **Planner**: mirrors TigerJunction's ReCal calendar — its default color palette, solid blocks with an ink left border for locked-in sections, and striped translucent blocks for section options not picked yet. The engine @@ -67,6 +75,11 @@ npm run dev `npm run typecheck` needs `worker-configuration.d.ts` — generate it with `npx wrangler types` (CI does this automatically). +`npm test` runs the auth flow tests with Node 22.6 or newer, including ticket +validation, browser state, expired/tampered cookies, and session migration. +Local CAS sign-in requires a callback origin permitted by Princeton CAS; +use an approved HTTPS development origin if localhost is not permitted. + The Workers AI "Campus" model runs remotely even in dev, so wrangler needs Cloudflare credentials (`wrangler login`, or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`). The Claude path only needs `ANTHROPIC_API_KEY` in @@ -81,10 +94,13 @@ Pushes to `main` deploy automatically via GitHub Actions ```bash npm run deploy npx wrangler secret put ANTHROPIC_API_KEY # enables the Claude models -npx wrangler secret put ENTRA_CLIENT_SECRET # Entra app registration secret npx wrangler secret put SESSION_SECRET # any long random string ``` +CAS needs no client ID, tenant ID, or client secret. The old Entra and OIT +directory credentials are no longer read and can be removed from the Worker +after deployment. Keep the existing `SESSION_SECRET` configured. + Config knobs (in `wrangler.jsonc`): `ENGINE_MCP_BASE` (engine base URL) and the optional `ENGINE_MCP_TOKEN` secret if the engine ever requires a bearer token. diff --git a/app/package.json b/app/package.json index c0a837a..6cfa042 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,8 @@ "dev": "vite dev", "build": "vite build", "deploy": "vite build && wrangler deploy -c dist/pi/wrangler.json", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test tests/*.test.ts" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.0", diff --git a/app/src/client/lib/auth.ts b/app/src/client/lib/auth.ts index 9dd0666..3baa9a4 100644 --- a/app/src/client/lib/auth.ts +++ b/app/src/client/lib/auth.ts @@ -15,7 +15,7 @@ export type AuthState = /** Signed in when the tab opened; the session has since lapsed. */ | { status: "expired"; identity: Identity }; -/** Where signIn() parks the page the student was on, across the Entra hop. */ +/** Where signIn() parks the page the student was on, across the CAS hop. */ const RETURN_KEY = "pi:return-to"; /** @@ -108,7 +108,7 @@ export function useIdentity(): { auth: AuthState; revalidate: () => void } { } /** - * Hands off to the Worker's Entra flow, remembering the page the student asked + * Hands off to the Worker's CAS flow, remembering the page the student asked * for. The callback always lands on "/", so the shell replays this on arrival. */ export function signIn(returnTo = location.pathname + location.search) { diff --git a/app/src/server/auth.ts b/app/src/server/auth.ts index 6507d33..ec61648 100644 --- a/app/src/server/auth.ts +++ b/app/src/server/auth.ts @@ -1,13 +1,11 @@ /** - * Princeton sign-in via Microsoft Entra ID (OIDC authorization code + PKCE, - * confidential client). The Worker owns the whole flow; the browser only ever - * sees an HttpOnly signed session cookie. - * - * netid is derived from the account email's local part (jdoe@princeton.edu → - * jdoe) — there is deliberately no way to choose one. + * Princeton CAS sign-in. Only a ticket validated by Princeton can supply the + * NetID; the browser receives an HttpOnly signed session cookie. */ +import { z } from "zod"; export type Session = { + provider: "cas"; netid: string; name: string; email: string; @@ -15,13 +13,12 @@ export type Session = { exp: number; }; +const CAS_BASE = "https://authenticate.princeton.edu/cas"; const SESSION_COOKIE = "pi_session"; -const OAUTH_COOKIE = "pi_oauth"; +const CAS_COOKIE = "pi_cas"; const SESSION_TTL_S = 7 * 24 * 60 * 60; -const ALLOWED_DOMAIN = "princeton.edu"; - -/* ── small codecs ─────────────────────────────────────────────────── */ - +const LOGIN_TTL_S = 600; +const NETID = /^[a-z][a-z0-9]{0,63}$/; const enc = new TextEncoder(); function b64url(bytes: ArrayBuffer | Uint8Array): string { @@ -31,27 +28,20 @@ function b64url(bytes: ArrayBuffer | Uint8Array): string { return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } -function unb64url(s: string): Uint8Array | null { +function unb64url(s: string): Uint8Array | null { try { - const pad = s.replace(/-/g, "+").replace(/_/g, "/"); - const bin = atob(pad); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); } catch { return null; } } -async function hmac(secret: string, data: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - enc.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] +async function hmacKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, + false, ["sign", "verify"] ); - return b64url(await crypto.subtle.sign("HMAC", key, enc.encode(data))); } async function signToken( @@ -59,26 +49,27 @@ async function signToken( secret: string ): Promise { const body = b64url(enc.encode(JSON.stringify(payload))); - return `${body}.${await hmac(secret, body)}`; + const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body)); + return `${body}.${b64url(sig)}`; } -async function verifyToken( +async function verifyToken( token: string | undefined, secret: string -): Promise { +): Promise { if (!token) return null; - const [body, sig] = token.split("."); - if (!body || !sig) return null; - const expected = await hmac(secret, body); - if (sig.length !== expected.length) return null; - // constant-time-ish compare - let diff = 0; - for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i); - if (diff !== 0) return null; + const parts = token.split("."); + if (parts.length !== 2) return null; + const [body, sig] = parts; + const signature = unb64url(sig); const raw = unb64url(body); - if (!raw) return null; + if (!signature || !raw) return null; + const valid = await crypto.subtle.verify( + "HMAC", await hmacKey(secret), signature, enc.encode(body) + ); + if (!valid) return null; try { - return JSON.parse(new TextDecoder().decode(raw)) as T; + return JSON.parse(new TextDecoder().decode(raw)); } catch { return null; } @@ -97,36 +88,53 @@ function cookie(name: string, value: string, maxAge: number): string { return `${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; } -/* ── session ──────────────────────────────────────────────────────── */ +function response(body: BodyInit | null, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set("cache-control", "no-store"); + headers.set("referrer-policy", "no-referrer"); + return new Response(body, { ...init, headers }); +} + +const sessionSchema = z.object({ + provider: z.literal("cas"), + netid: z.string().regex(NETID), + name: z.string(), + email: z.string(), + exp: z.number().finite(), +}); export async function getSession( request: Request, env: Env ): Promise { if (!env.SESSION_SECRET) return null; - const s = await verifyToken( - readCookie(request, SESSION_COOKIE), - env.SESSION_SECRET - ); - if (!s || typeof s.netid !== "string" || !s.netid) return null; - if (typeof s.exp !== "number" || s.exp < Date.now() / 1000) return null; - return s; -} - -/* ── routes ───────────────────────────────────────────────────────── */ - -function authority(env: Env): string { - return `https://login.microsoftonline.com/${env.ENTRA_TENANT_ID}`; + const parsed = sessionSchema.safeParse(await verifyToken( + readCookie(request, SESSION_COOKIE), env.SESSION_SECRET + )); + if (!parsed.success || parsed.data.exp <= Date.now() / 1000) return null; + return parsed.data; } -function misconfigured(env: Env): Response | null { - if (env.ENTRA_TENANT_ID && env.ENTRA_CLIENT_ID && env.ENTRA_CLIENT_SECRET && env.SESSION_SECRET) { - return null; - } - return new Response( - "Sign-in isn't configured: set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET, and SESSION_SECRET.", - { status: 503 } - ); +const pendingSchema = z.object({ + state: z.string().min(1), + service: z.string().url(), + exp: z.number().finite(), +}); + +const casResponseSchema = z.object({ + serviceResponse: z.object({ + authenticationFailure: z.unknown().optional(), + authenticationSuccess: z.object({ + user: z.string().transform((user) => user.toLowerCase()).pipe(z.string().regex(NETID)), + attributes: z.record(z.string(), z.unknown()).optional(), + }).optional(), + }), +}); + +function attribute(attributes: Record, key: string): string | undefined { + const value = attributes[key]; + const first = Array.isArray(value) ? value[0] : value; + return typeof first === "string" && first.trim() ? first.trim() : undefined; } /** Handles /auth/*; returns null for other paths. */ @@ -135,146 +143,119 @@ export async function handleAuth( env: Env ): Promise { const url = new URL(request.url); - const redirectUri = `${url.origin}/auth/callback`; if (url.pathname === "/auth/me") { const session = await getSession(request, env); - if (!session) return Response.json({ signedIn: false }, { status: 401 }); - return Response.json({ - signedIn: true, - netid: session.netid, - name: session.name, - email: session.email, + return response(JSON.stringify(session ? { + signedIn: true, netid: session.netid, name: session.name, email: session.email, + } : { signedIn: false }), { + status: session ? 200 : 401, + headers: { "content-type": "application/json" }, }); } + if (url.pathname === "/auth/login" || url.pathname === "/auth/callback") { + if (!env.SESSION_SECRET) { + return response("Sign-in isn't configured: set SESSION_SECRET.", { status: 503 }); + } + } + if (url.pathname === "/auth/login") { - const bad = misconfigured(env); - if (bad) return bad; - const state = b64url(crypto.getRandomValues(new Uint8Array(16))); - const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); - const challenge = b64url( - await crypto.subtle.digest("SHA-256", enc.encode(verifier)) - ); - const authorize = new URL(`${authority(env)}/oauth2/v2.0/authorize`); - authorize.search = new URLSearchParams({ - client_id: env.ENTRA_CLIENT_ID, - response_type: "code", - redirect_uri: redirectUri, - response_mode: "query", - scope: "openid profile email", - state, - code_challenge: challenge, - code_challenge_method: "S256", - }).toString(); - const pending = await signToken( - { state, verifier, exp: Date.now() / 1000 + 600 }, - env.SESSION_SECRET - ); - return new Response(null, { + const state = b64url(crypto.getRandomValues(new Uint8Array(32))); + const service = new URL("/auth/callback", url.origin); + // CAS binds each ticket to this exact service, including our browser state. + service.searchParams.set("state", state); + const authorize = new URL(`${CAS_BASE}/login`); + authorize.searchParams.set("service", service.toString()); + const pending = await signToken({ + state, service: service.toString(), exp: Date.now() / 1000 + LOGIN_TTL_S, + }, env.SESSION_SECRET); + return response(null, { status: 302, headers: { location: authorize.toString(), - "set-cookie": cookie(OAUTH_COOKIE, pending, 600), + "set-cookie": cookie(CAS_COOKIE, pending, LOGIN_TTL_S), }, }); } if (url.pathname === "/auth/callback") { - const bad = misconfigured(env); - if (bad) return bad; - const fail = (why: string) => - new Response(`Sign-in failed: ${why}`, { status: 400 }); - - const code = url.searchParams.get("code"); + const fail = (why: string, status = 400) => response( + `Sign-in failed: ${why}. Please start again at /auth/login.`, { + status, headers: { "set-cookie": cookie(CAS_COOKIE, "", 0) }, + } + ); + const ticket = url.searchParams.get("ticket"); const state = url.searchParams.get("state"); - if (!code || !state) { - return fail(url.searchParams.get("error_description") ?? "missing code"); + if (!ticket || !ticket.startsWith("ST-") || !state) { + return fail("missing or invalid ticket/state"); } - const pending = await verifyToken<{ - state: string; - verifier: string; - exp: number; - }>(readCookie(request, OAUTH_COOKIE), env.SESSION_SECRET); - if (!pending || pending.exp < Date.now() / 1000) { - return fail("your sign-in attempt expired — please try again"); + const parsed = pendingSchema.safeParse(await verifyToken( + readCookie(request, CAS_COOKIE), env.SESSION_SECRET + )); + if (!parsed.success || parsed.data.exp <= Date.now() / 1000) { + return fail("your sign-in attempt expired"); } - if (pending.state !== state) return fail("state mismatch"); - - const tokenRes = await fetch(`${authority(env)}/oauth2/v2.0/token`, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: env.ENTRA_CLIENT_ID, - client_secret: env.ENTRA_CLIENT_SECRET, - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - code_verifier: pending.verifier, - }), - }); - if (!tokenRes.ok) { - const detail = await tokenRes.text(); - console.warn("token exchange failed", tokenRes.status, detail.slice(0, 300)); - return fail("could not complete sign-in with Microsoft"); + const pending = parsed.data; + const service = new URL("/auth/callback", url.origin); + service.searchParams.set("state", state); + if (pending.state !== state || pending.service !== service.toString()) { + return fail("sign-in state mismatch"); } - const tokens = (await tokenRes.json()) as { id_token?: string }; - if (!tokens.id_token) return fail("no identity returned"); - // The id_token comes straight from Microsoft's token endpoint over TLS - // (back channel), so the transport authenticates it; we still check the - // claims that matter. - const claimsRaw = unb64url(tokens.id_token.split(".")[1] ?? ""); - if (!claimsRaw) return fail("malformed identity token"); - let claims: Record; + const validate = new URL(`${CAS_BASE}/p3/serviceValidate`); + validate.search = new URLSearchParams({ + service: pending.service, ticket, format: "JSON", + }).toString(); + let data: unknown; try { - claims = JSON.parse(new TextDecoder().decode(claimsRaw)); + const res = await fetch(validate, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return fail("Princeton CAS is unavailable", 502); + data = await res.json(); } catch { - return fail("malformed identity token"); - } - if (claims.aud !== env.ENTRA_CLIENT_ID) return fail("wrong audience"); - if (claims.tid !== env.ENTRA_TENANT_ID) return fail("wrong tenant"); - if (typeof claims.exp !== "number" || claims.exp < Date.now() / 1000) { - return fail("expired identity token"); + return fail("could not validate your ticket with Princeton CAS", 502); } - - const email = String( - claims.email ?? claims.preferred_username ?? claims.upn ?? "" - ).toLowerCase(); - const match = email.match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/); - if (!match || match[2] !== ALLOWED_DOMAIN) { - return fail( - `a ${ALLOWED_DOMAIN} account is required (signed in as ${email || "unknown"})` - ); + const validated = casResponseSchema.safeParse(data); + if (!validated.success) return fail("invalid identity returned by Princeton CAS"); + const cas = validated.data.serviceResponse; + if ("authenticationFailure" in cas || !cas.authenticationSuccess) { + return fail("Princeton CAS rejected your ticket"); } - // Princeton often signs people in with an email ALIAS (jane.doe@… for - // netid jd1234), so resolve the real netid through OIT's directory. - const netid = await resolveNetid(email, match[1], env); - + const { user: netid, attributes = {} } = cas.authenticationSuccess; const session: Session = { + provider: "cas", netid, - name: String(claims.name ?? netid), - email, + name: attribute(attributes, "displayName") ?? attribute(attributes, "displayname") + ?? attribute(attributes, "cn") ?? netid, + email: attribute(attributes, "mail") ?? `${netid}@princeton.edu`, exp: Math.floor(Date.now() / 1000) + SESSION_TTL_S, }; const token = await signToken(session, env.SESSION_SECRET); - return new Response(null, { + return response(null, { status: 302, headers: [ ["location", "/"], ["set-cookie", cookie(SESSION_COOKIE, token, SESSION_TTL_S)], - ["set-cookie", cookie(OAUTH_COOKIE, "", 0)], + ["set-cookie", cookie(CAS_COOKIE, "", 0)], + ["set-cookie", cookie("pi_oauth", "", 0)], ], }); } if (url.pathname === "/auth/logout") { - return new Response(null, { + // Sign out of PI only; other Princeton services keep their CAS session. + return response(null, { status: 302, - headers: { - location: "/", - "set-cookie": cookie(SESSION_COOKIE, "", 0), - }, + headers: [ + ["location", "/"], + ["set-cookie", cookie(SESSION_COOKIE, "", 0)], + ["set-cookie", cookie(CAS_COOKIE, "", 0)], + ["set-cookie", cookie("pi_oauth", "", 0)], + ], }); } @@ -285,93 +266,3 @@ export async function handleAuth( export function userPrefix(netid: string): string { return `u-${netid}-`; } - -/* ── alias → netid via OIT's ActiveDirectory API ──────────────────── */ - -const OIT_TOKEN_URL = "https://api.princeton.edu/token"; -const DEFAULT_AD_BASE = "https://api.princeton.edu/active-directory/1.0.6"; - -/** WSO2 access token, cached for the isolate's lifetime. */ -let oitTokenCache: { token: string; exp: number } | null = null; - -async function oitToken(env: Env): Promise { - if (oitTokenCache && oitTokenCache.exp > Date.now() / 1000 + 30) { - return oitTokenCache.token; - } - const res = await fetch(OIT_TOKEN_URL, { - method: "POST", - headers: { - authorization: `Basic ${btoa(`${env.OIT_CONSUMER_KEY}:${env.OIT_CONSUMER_SECRET}`)}`, - "content-type": "application/x-www-form-urlencoded", - }, - body: "grant_type=client_credentials", - }); - if (!res.ok) throw new Error(`OIT token grant failed: ${res.status}`); - const body = (await res.json()) as { - access_token: string; - expires_in?: number; - }; - oitTokenCache = { - token: body.access_token, - exp: Date.now() / 1000 + (body.expires_in ?? 3000), - }; - return body.access_token; -} - -async function oitUsersLookup( - env: Env, - token: string, - params: Record -): Promise { - const base = (env.OIT_AD_BASE || DEFAULT_AD_BASE).replace(/\/$/, ""); - const res = await fetch(`${base}/users?${new URLSearchParams(params)}`, { - headers: { authorization: `Bearer ${token}`, accept: "application/json" }, - }); - if (!res.ok) return null; - const text = await res.text(); - if (!text.trim() || text.startsWith("<")) return null; - try { - const data = JSON.parse(text) as unknown; - const list = Array.isArray(data) - ? data - : typeof data === "object" && data != null - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - ((data as any).users ?? (data as any).result ?? [data]) - : []; - for (const entry of list) { - const uid = entry?.uid ?? entry?.netid; - if (typeof uid === "string" && uid) return uid.toLowerCase(); - } - } catch { - /* fall through */ - } - return null; -} - -/** - * Map a sign-in email to the real netid. The email's local part may be an - * alias, so the directory (`mail` attribute) is authoritative; if lookup is - * unavailable or misses, the local part is the best remaining guess. - */ -async function resolveNetid( - email: string, - localpart: string, - env: Env -): Promise { - if (!env.OIT_CONSUMER_KEY || !env.OIT_CONSUMER_SECRET) { - console.warn("OIT credentials not set — using email local part as netid"); - return localpart; - } - try { - const token = await oitToken(env); - const byMail = await oitUsersLookup(env, token, { mail: email }); - if (byMail) return byMail; - // The local part may already be the netid — confirm against the directory. - const byUid = await oitUsersLookup(env, token, { uid: localpart }); - if (byUid === localpart) return localpart; - console.warn(`OIT lookup could not resolve ${email}; using local part`); - } catch (err) { - console.warn("OIT lookup failed", err); - } - return localpart; -} diff --git a/app/tests/auth.test.ts b/app/tests/auth.test.ts new file mode 100644 index 0000000..30c3e1d --- /dev/null +++ b/app/tests/auth.test.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { afterEach, mock, test } from "node:test"; +import { getSession, handleAuth, userPrefix } from "../src/server/auth.ts"; + +const origin = "https://pi.tigerapps.org"; +const env = { SESSION_SECRET: "test-only-session-secret" } as Env; + +afterEach(() => mock.restoreAll()); + +function signed(payload: unknown): string { + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const sig = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url"); + return `${body}.${sig}`; +} + +function cookieFrom(res: Response, name: string): string { + const entry = res.headers.getSetCookie().find((value) => value.startsWith(`${name}=`)); + assert.ok(entry, `missing ${name}`); + return entry.split(";")[0]; +} + +async function start(base = origin) { + const res = await handleAuth(new Request(`${base}/auth/login`), env); + assert.ok(res); + assert.equal(res.status, 302); + const login = new URL(res.headers.get("location")!); + const service = login.searchParams.get("service")!; + const callback = new URL(service); + callback.searchParams.set("ticket", "ST-test-ticket"); + return { res, login, service, callback, cookie: cookieFrom(res, "pi_cas") }; +} + +function success(user = "jd1234", attributes?: Record) { + return Response.json({ serviceResponse: { authenticationSuccess: { user, attributes } } }); +} + +test("login redirects to Princeton with a unique, cookie-bound service URL", async () => { + const first = await start(); + const second = await start(); + assert.equal(first.login.origin, "https://authenticate.princeton.edu"); + assert.equal(first.login.pathname, "/cas/login"); + const service = new URL(first.service); + assert.equal(service.origin, origin); + assert.equal(service.pathname, "/auth/callback"); + assert.match(service.searchParams.get("state")!, /^[A-Za-z0-9_-]{43}$/); + assert.notEqual(first.service, second.service); + assert.match(first.res.headers.get("set-cookie")!, /HttpOnly; Secure; SameSite=Lax; Max-Age=600/); + assert.equal(first.res.headers.get("cache-control"), "no-store"); + assert.equal(first.res.headers.get("referrer-policy"), "no-referrer"); +}); + +test("validated CAS NetID owns the session even when the email is an alias", async () => { + const flow = await start(); + flow.callback.searchParams.set("netid", "attacker"); + const fetchMock = mock.method(globalThis, "fetch", async (input: URL, init: RequestInit) => { + const url = new URL(input); + assert.equal(url.origin, "https://authenticate.princeton.edu"); + assert.equal(url.pathname, "/cas/p3/serviceValidate"); + assert.equal(url.searchParams.get("service"), flow.service); + assert.equal(url.searchParams.get("ticket"), "ST-test-ticket"); + assert.equal(url.searchParams.get("format"), "JSON"); + assert.equal(init.redirect, "error"); + assert.ok(init.signal instanceof AbortSignal); + return success("JD1234", { displayname: ["Jane Doe"], mail: ["jane.doe@princeton.edu"] }); + }); + const res = await handleAuth(new Request(flow.callback, { headers: { cookie: flow.cookie } }), env); + assert.ok(res); + assert.equal(res.status, 302); + assert.equal(res.headers.get("location"), "/"); + assert.equal(fetchMock.mock.callCount(), 1); + assert.match(res.headers.getSetCookie().find((v) => v.startsWith("pi_session="))!, /Max-Age=604800/); + assert.equal(cookieFrom(res, "pi_cas"), "pi_cas="); + const req = new Request(`${origin}/auth/me`, { headers: { cookie: cookieFrom(res, "pi_session") } }); + const session = await getSession(req, env); + assert.equal(session?.provider, "cas"); + assert.equal(session?.netid, "jd1234"); + assert.equal(userPrefix(session!.netid), "u-jd1234-"); + const me = await handleAuth(req, env); + assert.equal(me?.status, 200); + assert.equal(me?.headers.get("cache-control"), "no-store"); + assert.deepEqual(await me?.json(), { + signedIn: true, netid: "jd1234", name: "Jane Doe", email: "jane.doe@princeton.edu", + }); +}); + +for (const [label, attributes, name, email] of [ + ["no attributes", undefined, "jd1234", "jd1234@princeton.edu"], + ["scalar attributes", { displayName: "Jane Doe", mail: "jane@princeton.edu" }, "Jane Doe", "jane@princeton.edu"], + ["malformed attributes", { displayName: 42, mail: [], cn: ["Jane"] }, "Jane", "jd1234@princeton.edu"], +] as const) { + test(`CAS profile supports ${label}`, async () => { + const flow = await start(); + mock.method(globalThis, "fetch", async () => success("jd1234", attributes)); + const res = await handleAuth(new Request(flow.callback, { headers: { cookie: flow.cookie } }), env); + assert.equal(res?.status, 302); + const session = await getSession(new Request(origin, { headers: { cookie: cookieFrom(res!, "pi_session") } }), env); + assert.equal(session?.name, name); + assert.equal(session?.email, email); + }); +} + +for (const scenario of ["missing cookie", "other browser", "tampered cookie", "expired cookie", "missing expiry", "wrong state", "wrong origin", "missing ticket", "proxy ticket"]) { + test(`rejects ${scenario} before contacting CAS`, async () => { + const flow = await start(); + let cookie = flow.cookie; + const payload = JSON.parse(Buffer.from(cookie.split("=")[1].split(".")[0], "base64url").toString()); + if (scenario === "missing cookie") cookie = ""; + if (scenario === "other browser") cookie = (await start()).cookie; + if (scenario === "tampered cookie") cookie += "extra"; + if (scenario === "expired cookie") cookie = `pi_cas=${signed({ ...payload, exp: 1 })}`; + if (scenario === "missing expiry") { + delete payload.exp; + cookie = `pi_cas=${signed(payload)}`; + } + if (scenario === "wrong state") flow.callback.searchParams.set("state", "wrong"); + if (scenario === "wrong origin") flow.callback.hostname = "other.tigerapps.org"; + if (scenario === "missing ticket") flow.callback.searchParams.delete("ticket"); + if (scenario === "proxy ticket") flow.callback.searchParams.set("ticket", "PT-proxy-ticket"); + const fetchMock = mock.method(globalThis, "fetch", async () => { throw new Error("must not fetch"); }); + const res = await handleAuth(new Request(flow.callback, { headers: { cookie } }), env); + assert.equal(res?.status, 400); + assert.equal(fetchMock.mock.callCount(), 0); + assert.equal(cookieFrom(res!, "pi_cas"), "pi_cas="); + assert.ok(!res?.headers.getSetCookie().some((v) => v.startsWith("pi_session="))); + }); +} + +for (const [label, body] of [ + ["rejected ticket", { serviceResponse: { authenticationFailure: { code: "INVALID_TICKET" } } }], + ["missing user", { serviceResponse: { authenticationSuccess: {} } }], + ["email in place of NetID", { serviceResponse: { authenticationSuccess: { user: "jane.doe@princeton.edu" } } }], + ["unsafe NetID", { serviceResponse: { authenticationSuccess: { user: "jd1234-desk" } } }], + ["both success and failure", { serviceResponse: { authenticationSuccess: { user: "jd1234" }, authenticationFailure: {} } }], + ["unexpected JSON", null], +] as const) { + test(`does not create a session for ${label}`, async () => { + const flow = await start(); + mock.method(globalThis, "fetch", async () => Response.json(body)); + const res = await handleAuth(new Request(flow.callback, { headers: { cookie: flow.cookie } }), env); + assert.equal(res?.status, 400); + assert.ok(!res?.headers.getSetCookie().some((v) => v.startsWith("pi_session="))); + }); +} + +for (const scenario of ["network error", "timeout", "HTTP error", "non-JSON body"]) { + test(`handles CAS ${scenario} without exposing the ticket`, async () => { + const flow = await start(); + mock.method(globalThis, "fetch", async () => { + if (scenario === "network error") throw new Error("ST-test-ticket connection failure"); + if (scenario === "timeout") throw new DOMException("timed out", "TimeoutError"); + return new Response("upstream error", { status: scenario === "HTTP error" ? 503 : 200 }); + }); + const res = await handleAuth(new Request(flow.callback, { headers: { cookie: flow.cookie } }), env); + assert.equal(res?.status, 502); + assert.ok(!(await res!.text()).includes("ST-test-ticket")); + }); +} + +test("CAS rejection prevents replay of an already redeemed service ticket", async () => { + const flow = await start(); + let redeemed = false; + mock.method(globalThis, "fetch", async () => { + if (redeemed) return Response.json({ serviceResponse: { authenticationFailure: { code: "INVALID_TICKET" } } }); + redeemed = true; + return success(); + }); + const req = () => new Request(flow.callback, { headers: { cookie: flow.cookie } }); + assert.equal((await handleAuth(req(), env))?.status, 302); + assert.equal((await handleAuth(req(), env))?.status, 400); +}); + +test("legacy Entra, expired, malformed and tampered session cookies are rejected", async () => { + const session = { provider: "cas", netid: "jd1234", name: "Jane", email: "jd1234@princeton.edu", exp: Date.now() / 1000 + 60 }; + for (const value of [ + signed({ ...session, provider: undefined }), + signed({ ...session, exp: 1 }), + signed({ ...session, exp: undefined }), + signed({ ...session, netid: "jd1234-other" }), + signed(null), + signed(session) + ".trailing", + signed(session) + "bad", + "not-a-token", + ]) { + const req = new Request(`${origin}/auth/me`, { headers: { cookie: `pi_session=${value}` } }); + assert.equal(await getSession(req, env), null); + const me = await handleAuth(req, env); + assert.equal(me?.status, 401); + assert.deepEqual(await me?.json(), { signedIn: false }); + } +}); + +test("logout clears session, pending CAS and legacy OAuth cookies", async () => { + const res = await handleAuth(new Request(`${origin}/auth/logout`), env); + assert.equal(res?.status, 302); + assert.equal(res?.headers.get("location"), "/"); + for (const name of ["pi_session", "pi_cas", "pi_oauth"]) { + assert.equal(cookieFrom(res!, name), `${name}=`); + } + assert.ok(res?.headers.getSetCookie().every((v) => v.includes("Max-Age=0"))); +}); + +test("SESSION_SECRET is the only required sign-in configuration", async () => { + await start(); + for (const path of ["/auth/login", "/auth/callback"]) { + assert.equal((await handleAuth(new Request(origin + path), {} as Env))?.status, 503); + } + assert.equal(await getSession(new Request(origin), {} as Env), null); + assert.equal(await handleAuth(new Request(`${origin}/other`), env), null); +}); + +test("development service uses its own origin", async () => { + const flow = await start("http://localhost:5173"); + assert.equal(new URL(flow.service).origin, "http://localhost:5173"); +}); diff --git a/app/wrangler.jsonc b/app/wrangler.jsonc index f120a3d..abaa349 100644 --- a/app/wrangler.jsonc +++ b/app/wrangler.jsonc @@ -19,10 +19,6 @@ "vars": { "ENGINE_MCP_BASE": "https://junction-engine.tigerapps.org", - // OAuth client identifiers are public by design; the secret is a Worker secret. - "ENTRA_TENANT_ID": "2ff60116-7431-425d-b5af-077d7791bda4", - "ENTRA_CLIENT_ID": "ad41fa25-b30a-495e-a3bb-56ce2464f106", - "OIT_AD_BASE": "https://api.princeton.edu/active-directory/1.0.6", "APP_ORIGIN": "https://pi.tigerapps.org", // Public OAuth client id from the Google Cloud project; the secret is a Worker secret. "GOOGLE_OAUTH_CLIENT_ID": "548956142814-upcmsgqr9pbafnlsorhimvje1negt5lm.apps.googleusercontent.com"