/** * OAuth middleware tests * * Fixture style mirrors `tests/middleware/session.test.ts`: * - real `Request` / `Response` / `ManduContext` * - real `createCookieSessionStorage` * - fake `fetch` injected via `_oauthWith` — no network * * The `_oauthWith` seam is the reason we can unit-test the full callback flow * without spinning up provider doubles: we record what the middleware * requested and return canned responses. */ import { describe, it, expect } from "bun:test"; import { _oauthWith, oauth, github, google, type OAuthOptions, type OAuthProvider, } from "../index"; import { session, saveSession } from "../../session"; import type { Session} from "../../../filling/session"; import { createCookieSessionStorage, type SessionStorage, } from "../../../filling/session"; import { ManduContext } from "../../../filling/context"; import { AuthenticationError } from "../../../filling/auth"; // ========== Fixtures ========== const SECRET = "oauth-mw-test-secret-32bytes!!!!!"; function makeReq(url: string, init: RequestInit & { cookie?: string } = {}): Request { const { cookie, headers: rawHeaders, ...rest } = init; const headers = new Headers(rawHeaders as HeadersInit | undefined); if (cookie) headers.set("cookie", cookie); return new Request(url, { ...rest, headers }); } function makeCtx(req: Request): ManduContext { return new ManduContext(req); } function makeStorage(): SessionStorage { return createCookieSessionStorage({ cookie: { secrets: [SECRET] }, }); } /** Install the session middleware on a fresh context. */ async function attachSession(ctx: ManduContext, storage: SessionStorage): Promise { const mw = session({ storage }); await mw(ctx); } function readSetCookieLines(res: Response): string[] { return res.headers.getSetCookie?.() ?? []; } function readSetCookieValue(res: Response, name: string): string | null { const lines = readSetCookieLines(res); for (const line of lines) { if (line.startsWith(`${name}=`)) { const [nv] = line.split(";"); const eq = nv.indexOf("="); if (eq <= 0) return null; return decodeURIComponent(nv.slice(eq + 1).trim()); } } return null; } /** Fetch recorder — each call pushes a record and returns the next scripted response. */ interface FetchCall { url: string; method: string; headers: Record; body: string | null; } interface ScriptedResponse { status?: number; body: unknown; /** If true, `body` is already a string and is not JSON-stringified. */ raw?: boolean; } function makeFetch(responses: ScriptedResponse[]): { fetch: typeof globalThis.fetch; calls: FetchCall[]; } { const calls: FetchCall[] = []; const queue = [...responses]; const fetchImpl = async ( input: URL | RequestInfo, init?: RequestInit, ): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; const method = (init?.method ?? "GET").toUpperCase(); const rawHeaders: Record = {}; const hdrs = new Headers(init?.headers as HeadersInit | undefined); hdrs.forEach((v, k) => { rawHeaders[k] = v; }); const bodyText = typeof init?.body === "string" ? init.body : init?.body === undefined || init.body === null ? null : String(init.body); calls.push({ url, method, headers: rawHeaders, body: bodyText }); const next = queue.shift(); if (!next) { throw new Error(`[test fetch] no scripted response for ${method} ${url}`); } const status = next.status ?? 200; const payload = next.raw ? String(next.body) : JSON.stringify(next.body); return new Response(payload, { status, headers: { "Content-Type": "application/json" }, }); }; return { fetch: fetchImpl as typeof globalThis.fetch, calls }; } /** Base options factory — tests override only what they care about. */ function baseOptions(overrides: Partial = {}): OAuthOptions { return { provider: github(), clientId: "test-client-id", clientSecret: "test-client-secret", redirectUri: "https://app.example.com/auth/github/callback", resolveUser: async () => "user-42", ...overrides, }; } /** * Seed a pending OAuth record into a fresh session's cookie. Returns the * signed cookie header value suitable for `makeReq({ cookie: ... })`. * * We build the signed cookie by taking an empty session, mutating it, and * committing — exactly what the start flow does at runtime. */ async function seedPendingCookie( storage: SessionStorage, pending: { state: string; codeVerifier: string | null; provider: string; }, ): Promise { const sentinelReq = makeReq("https://app.example.com/_seed"); const sentinel = makeCtx(sentinelReq); await attachSession(sentinel, storage); const s = sentinel.get("session")!; s.set("oauth:pending", pending); await saveSession(sentinel); const out = sentinel.cookies.getSetCookieHeaders(); if (out.length === 0) { throw new Error("seed: no Set-Cookie produced"); } // Convert Set-Cookie into a request-side cookie header. const [nameValue] = out[0].split(";"); return nameValue; } // ========== Tests ========== describe("oauth middleware / path matching", () => { it("(1) passes through on non-matching path (returns void)", async () => { const storage = makeStorage(); const req = makeReq("https://app.example.com/api/posts"); const ctx = makeCtx(req); await attachSession(ctx, storage); const mw = oauth(baseOptions()); const result = await mw(ctx); expect(result).toBeUndefined(); }); it("(17) passes through on /auth/ (path param doesn't match configured provider name)", async () => { const storage = makeStorage(); // Configure GitHub (path: /auth/github) but hit /auth/foo — should not match. const req = makeReq("https://app.example.com/auth/foo"); const ctx = makeCtx(req); await attachSession(ctx, storage); const mw = oauth(baseOptions()); const result = await mw(ctx); expect(result).toBeUndefined(); }); it("(16) custom paths config routes correctly", async () => { const storage = makeStorage(); const req = makeReq("https://app.example.com/login/github"); const ctx = makeCtx(req); await attachSession(ctx, storage); const { fetch: fakeFetch } = makeFetch([]); const mw = _oauthWith( fakeFetch, baseOptions({ paths: { start: "/login/:provider", callback: "/login/:provider/callback" }, }), ); const res = await mw(ctx); expect(res).toBeInstanceOf(Response); expect((res as Response).status).toBe(302); const location = (res as Response).headers.get("location") ?? ""; expect(location.startsWith("https://github.com/login/oauth/authorize")).toBe(true); }); }); describe("oauth middleware / start flow", () => { it("(2) throws AuthenticationError when session middleware is missing", async () => { const req = makeReq("https://app.example.com/auth/github"); const ctx = makeCtx(req); // DO NOT install session. const mw = oauth(baseOptions()); let captured: unknown = null; try { await mw(ctx); } catch (e) { captured = e; } expect(captured).toBeInstanceOf(AuthenticationError); expect((captured as Error).message).toContain("session middleware"); }); it("(3) start redirects to provider authorization endpoint with correct query params", async () => { const storage = makeStorage(); const req = makeReq("https://app.example.com/auth/github"); const ctx = makeCtx(req); await attachSession(ctx, storage); const mw = oauth(baseOptions()); const res = (await mw(ctx)) as Response; expect(res.status).toBe(302); const loc = new URL(res.headers.get("location") ?? ""); expect(loc.origin + loc.pathname).toBe("https://github.com/login/oauth/authorize"); expect(loc.searchParams.get("response_type")).toBe("code"); expect(loc.searchParams.get("client_id")).toBe("test-client-id"); expect(loc.searchParams.get("redirect_uri")).toBe( "https://app.example.com/auth/github/callback", ); expect(loc.searchParams.get("scope")).toBe("read:user user:email"); const state = loc.searchParams.get("state"); expect(state).not.toBeNull(); expect((state ?? "").length).toBeGreaterThan(8); expect(loc.searchParams.get("code_challenge")).not.toBeNull(); expect(loc.searchParams.get("code_challenge_method")).toBe("S256"); }); it("(4) start writes oauth:pending into session with state + codeVerifier + provider name", async () => { const storage = makeStorage(); const req = makeReq("https://app.example.com/auth/github"); const ctx = makeCtx(req); await attachSession(ctx, storage); const mw = oauth(baseOptions()); const res = (await mw(ctx)) as Response; const loc = new URL(res.headers.get("location") ?? ""); const stateFromUrl = loc.searchParams.get("state"); expect(stateFromUrl).not.toBeNull(); const s = ctx.get("session")!; const pending = s.get<{ state: string; codeVerifier: string | null; provider: string }>( "oauth:pending", ); expect(pending).toBeDefined(); expect(pending!.state).toBe(stateFromUrl as string); expect(pending!.provider).toBe("github"); expect(typeof pending!.codeVerifier).toBe("string"); expect((pending!.codeVerifier ?? "").length).toBeGreaterThan(32); }); it("(5) start emits Set-Cookie with the session so it lands on the redirect response", async () => { const storage = makeStorage(); const req = makeReq("https://app.example.com/auth/github"); const ctx = makeCtx(req); await attachSession(ctx, storage); const mw = oauth(baseOptions()); const res = (await mw(ctx)) as Response; const cookieVal = readSetCookieValue(res, "__session"); expect(cookieVal).not.toBeNull(); expect((cookieVal ?? "").length).toBeGreaterThan(0); }); it("(12) pkce=false provider: start omits code_challenge, callback omits code_verifier", async () => { const storage = makeStorage(); const customProvider: OAuthProvider = { ...github(), name: "custom-no-pkce", pkce: false, }; // --- Start leg --- const startReq = makeReq("https://app.example.com/auth/custom-no-pkce"); const startCtx = makeCtx(startReq); await attachSession(startCtx, storage); const { fetch: fakeFetch, calls } = makeFetch([ { body: { access_token: "tok-1", token_type: "bearer" } }, { body: { id: 1, login: "x", email: "x@y.com", name: "X", avatar_url: "u" } }, ]); const options = baseOptions({ provider: customProvider }); const mw = _oauthWith(fakeFetch, options); const startRes = (await mw(startCtx)) as Response; const startLoc = new URL(startRes.headers.get("location") ?? ""); expect(startLoc.searchParams.get("code_challenge")).toBeNull(); expect(startLoc.searchParams.get("code_challenge_method")).toBeNull(); const state = startLoc.searchParams.get("state")!; // Seed a callback request carrying the pending state cookie that the // start leg would have set. const startCookieLine = readSetCookieLines(startRes).find((l) => l.startsWith("__session="))!; const [nameValue] = startCookieLine.split(";"); const cbReq = makeReq( `https://app.example.com/auth/custom-no-pkce/callback?state=${state}&code=abc`, { cookie: nameValue }, ); const cbCtx = makeCtx(cbReq); await attachSession(cbCtx, storage); await mw(cbCtx); // First fetch call is the token exchange. Body should NOT include code_verifier. expect(calls.length).toBeGreaterThanOrEqual(1); const tokenCall = calls[0]; expect(tokenCall.body ?? "").not.toContain("code_verifier"); }); }); describe("oauth middleware / callback flow", () => { const providerName = "github"; async function runCallback( overrides: { pending?: { state: string; codeVerifier: string | null; provider: string } | null; state?: string; code?: string; responses?: ScriptedResponse[]; options?: Partial; callbackPath?: string; } = {}, ): Promise<{ res: Response; ctx: ManduContext; calls: FetchCall[] }> { const storage = makeStorage(); const pending = overrides.pending === undefined ? { state: "state-AAA", codeVerifier: "verifier-XYZ", provider: providerName } : overrides.pending; let cookie: string | undefined; if (pending) { cookie = await seedPendingCookie(storage, pending); } const state = overrides.state ?? pending?.state ?? "state-AAA"; const code = overrides.code ?? "auth-code-123"; const url = `https://app.example.com${overrides.callbackPath ?? "/auth/github/callback"}` + `?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`; const req = makeReq(url, cookie ? { cookie } : {}); const ctx = makeCtx(req); await attachSession(ctx, storage); const { fetch: fakeFetch, calls } = makeFetch(overrides.responses ?? []); const mw = _oauthWith(fakeFetch, baseOptions(overrides.options)); const res = (await mw(ctx)) as Response; return { res, ctx, calls }; } it("(6) callback with no pending state in session → 403", async () => { const { res } = await runCallback({ pending: null }); expect(res.status).toBe(403); }); it("(7) callback with state mismatch → 403", async () => { const { res } = await runCallback({ pending: { state: "state-REAL", codeVerifier: null, provider: providerName }, state: "state-FORGED", }); expect(res.status).toBe(403); }); it("(8) callback happy path: exchanges code, fetches userinfo, logs user in, redirects", async () => { const resolvedUsers: string[] = []; const { res, ctx, calls } = await runCallback({ responses: [ { body: { access_token: "ghs_abc", token_type: "bearer" } }, { body: { id: 99, email: "a@b.com", name: "A B", avatar_url: "https://a/b" } }, ], options: { resolveUser: async (profile) => { resolvedUsers.push(profile.id); return "local-user-7"; }, }, }); // Two HTTP calls: token + userinfo. expect(calls.length).toBe(2); expect(calls[0].url).toBe("https://github.com/login/oauth/access_token"); expect(calls[0].method).toBe("POST"); expect(calls[0].headers["accept"]).toBe("application/json"); expect(calls[0].body ?? "").toContain("grant_type=authorization_code"); expect(calls[0].body ?? "").toContain("code=auth-code-123"); expect(calls[0].body ?? "").toContain("code_verifier=verifier-XYZ"); expect(calls[1].url).toBe("https://api.github.com/user"); expect(calls[1].method).toBe("GET"); expect(calls[1].headers["authorization"]).toBe("Bearer ghs_abc"); // resolveUser saw a normalized profile. expect(resolvedUsers).toEqual(["99"]); // Response is a redirect to post-login target (default "/"). expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/"); // Session now carries userId. const s = ctx.get("session")!; expect(s.get("userId")).toBe("local-user-7"); // Pending record cleared. expect(s.get("oauth:pending")).toBeUndefined(); }); it("(9) callback: resolveUser returning a Response bubbles up unchanged (loginUser NOT called)", async () => { const custom = new Response("signup-needed", { status: 302, headers: { location: "/signup?provider=github" }, }); const { res, ctx } = await runCallback({ responses: [ { body: { access_token: "ghs_abc" } }, { body: { id: 1 } }, ], options: { resolveUser: async () => custom, }, }); expect(res).toBe(custom); const s = ctx.get("session")!; expect(s.get("userId")).toBeUndefined(); }); it("(10) callback: token endpoint 500 → 502, provider details not leaked", async () => { const { res } = await runCallback({ responses: [{ status: 500, body: { error: "internal_secret_detail" } }], }); expect(res.status).toBe(502); const payload = (await res.json()) as { error: string; provider: string }; expect(payload.error).toBe("oauth_failed"); expect(payload.provider).toBe("github"); // The sensitive error string must not appear in the response body. expect(JSON.stringify(payload)).not.toContain("internal_secret_detail"); }); it("(11) callback: userinfo endpoint 403 → 502", async () => { const { res } = await runCallback({ responses: [ { body: { access_token: "ghs_abc" } }, { status: 403, body: { message: "bad scope" } }, ], }); expect(res.status).toBe(502); const payload = (await res.json()) as { error: string }; expect(payload.error).toBe("oauth_failed"); }); }); describe("oauth middleware / provider presets", () => { it("(13) github preset normalizeProfile shape", () => { const provider = github(); const raw = { id: 42, login: "octocat", email: "octo@github.com", name: "Octo Cat", avatar_url: "https://avatars/octo.png", extra: "passthrough", }; const profile = provider.normalizeProfile(raw); expect(profile.id).toBe("42"); expect(profile.email).toBe("octo@github.com"); expect(profile.name).toBe("Octo Cat"); expect(profile.avatarUrl).toBe("https://avatars/octo.png"); expect(profile.raw).toBe(raw); expect(provider.name).toBe("github"); expect(provider.pkce).toBe(true); expect(provider.scopes).toEqual(["read:user", "user:email"]); }); it("(14) google preset normalizeProfile shape", () => { const provider = google(); const raw = { sub: "1099999999999999", email: "alice@gmail.com", name: "Alice", picture: "https://lh3.googleusercontent.com/a/x", email_verified: true, }; const profile = provider.normalizeProfile(raw); expect(profile.id).toBe("1099999999999999"); expect(profile.email).toBe("alice@gmail.com"); expect(profile.name).toBe("Alice"); expect(profile.avatarUrl).toBe("https://lh3.googleusercontent.com/a/x"); expect(profile.raw).toBe(raw); expect(provider.name).toBe("google"); expect(provider.pkce).toBe(true); expect(provider.scopes).toEqual(["openid", "email", "profile"]); }); }); describe("oauth middleware / constant-time state comparison", () => { /** * Validates the behavior of the safeEqual helper as expressed through the * public API: a callback whose returned state differs from the stored one * at ANY byte position (not just the first) must still be rejected. * * If the implementation short-circuited on the first mismatched index it * would be a timing oracle; correctness is asserted here by checking that * TWO distinct mismatch shapes (early-byte-diff vs late-byte-diff) both * produce 403 — i.e. the comparison looked at the whole string either way. */ it("(15) equal-length states differing at any index are rejected (no short-circuit)", async () => { const storage = makeStorage(); async function runWith(returnedState: string): Promise { const stored = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars const cookie = await seedPendingCookie(storage, { state: stored, codeVerifier: null, provider: "github", }); const url = `https://app.example.com/auth/github/callback?state=${encodeURIComponent(returnedState)}&code=x`; const ctx = makeCtx(makeReq(url, { cookie })); await attachSession(ctx, storage); const { fetch: fakeFetch } = makeFetch([]); const mw = _oauthWith(fakeFetch, baseOptions()); const res = (await mw(ctx)) as Response; return res.status; } // Early mismatch: first byte differs. const earlyDiff = "Xbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // Late mismatch: last byte differs. const lateDiff = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFX"; expect(await runWith(earlyDiff)).toBe(403); expect(await runWith(lateDiff)).toBe(403); }); });