CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
oauth.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 058d752 | 1 | /** |
| 2 | * Tests for the OAuth 2.0 provider (Block B6). | |
| 3 | * | |
| 4 | * Covers: | |
| 5 | * - pure helper functions in src/lib/oauth.ts | |
| 6 | * - unauthed redirect behaviour for authed OAuth + developer-apps routes | |
| 7 | * - /oauth/token and /oauth/revoke endpoint surface behaviour | |
| 8 | * | |
| 9 | * These tests intentionally avoid the DB wherever possible so they run fast | |
| 10 | * and don't depend on a live Postgres. Routes that touch the DB accept either | |
| 11 | * the expected auth/validation error or 503 (DB unreachable) since the CI | |
| 12 | * runner may not have a Neon connection. | |
| 13 | */ | |
| 14 | ||
| 15 | import { describe, it, expect } from "bun:test"; | |
| 16 | import app from "../app"; | |
| 17 | import { | |
| 18 | generateClientId, | |
| 19 | generateClientSecret, | |
| 20 | generateAuthCode, | |
| 21 | generateAccessToken, | |
| 22 | generateRefreshToken, | |
| 23 | sha256Hex, | |
| 24 | b64urlFromBytes, | |
| 25 | verifyPkce, | |
| 26 | timingSafeEqual, | |
| 27 | parseScopes, | |
| 28 | serializeScopes, | |
| 29 | parseRedirectUris, | |
| 30 | isValidRedirectUri, | |
| 31 | redirectUriAllowed, | |
| 32 | SUPPORTED_SCOPES, | |
| 33 | ACCESS_TOKEN_TTL_MS, | |
| 34 | REFRESH_TOKEN_TTL_MS, | |
| 35 | AUTH_CODE_TTL_MS, | |
| 36 | } from "../lib/oauth"; | |
| 37 | ||
| 38 | describe("oauth helpers (B6)", () => { | |
| 39 | it("generateClientId returns unique values prefixed with 'glc_app_'", () => { | |
| 40 | const a = generateClientId(); | |
| 41 | const b = generateClientId(); | |
| 42 | expect(a.startsWith("glc_app_")).toBe(true); | |
| 43 | expect(b.startsWith("glc_app_")).toBe(true); | |
| 44 | expect(a).not.toBe(b); | |
| 45 | // The suffix is randomHex(12) → 24 hex chars; prefix is 8 chars → total 32. | |
| 46 | expect(a.length).toBe("glc_app_".length + 24); | |
| 47 | }); | |
| 48 | ||
| 49 | it("generateClientSecret returns unique values prefixed with 'glcs_'", () => { | |
| 50 | const a = generateClientSecret(); | |
| 51 | const b = generateClientSecret(); | |
| 52 | expect(a.startsWith("glcs_")).toBe(true); | |
| 53 | expect(b.startsWith("glcs_")).toBe(true); | |
| 54 | expect(a).not.toBe(b); | |
| 55 | // The suffix is randomHex(32) → 64 hex chars; prefix is 5 chars → total 69. | |
| 56 | expect(a.length).toBe("glcs_".length + 64); | |
| 57 | }); | |
| 58 | ||
| 59 | it("generateAuthCode returns unique values prefixed with 'glca_'", () => { | |
| 60 | const a = generateAuthCode(); | |
| 61 | const b = generateAuthCode(); | |
| 62 | expect(a.startsWith("glca_")).toBe(true); | |
| 63 | expect(b.startsWith("glca_")).toBe(true); | |
| 64 | expect(a).not.toBe(b); | |
| 65 | }); | |
| 66 | ||
| 67 | it("generateAccessToken and generateRefreshToken produce distinct prefixes", () => { | |
| 68 | const at = generateAccessToken(); | |
| 69 | const rt = generateRefreshToken(); | |
| 70 | expect(at.startsWith("glct_")).toBe(true); | |
| 71 | expect(rt.startsWith("glcr_")).toBe(true); | |
| 72 | expect(at).not.toBe(rt); | |
| 73 | }); | |
| 74 | ||
| 75 | it("sha256Hex('hello') returns the known SHA-256 hex of 'hello'", async () => { | |
| 76 | const h = await sha256Hex("hello"); | |
| 77 | expect(h).toBe( | |
| 78 | "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" | |
| 79 | ); | |
| 80 | }); | |
| 81 | ||
| 82 | it("timingSafeEqual handles equal, different, and mismatched lengths", () => { | |
| 83 | expect(timingSafeEqual("abc", "abc")).toBe(true); | |
| 84 | expect(timingSafeEqual("abc", "abd")).toBe(false); | |
| 85 | expect(timingSafeEqual("abc", "abcd")).toBe(false); | |
| 86 | expect(timingSafeEqual("", "")).toBe(true); | |
| 87 | }); | |
| 88 | ||
| 89 | it("parseScopes strips unknown scopes, deduplicates, handles separators + empty", () => { | |
| 90 | expect(parseScopes(null)).toEqual([]); | |
| 91 | expect(parseScopes(undefined)).toEqual([]); | |
| 92 | expect(parseScopes("")).toEqual([]); | |
| 93 | expect(parseScopes("read:user read:user write:repo")).toEqual([ | |
| 94 | "read:user", | |
| 95 | "write:repo", | |
| 96 | ]); | |
| 97 | expect(parseScopes("read:user,write:repo")).toEqual([ | |
| 98 | "read:user", | |
| 99 | "write:repo", | |
| 100 | ]); | |
| 101 | expect(parseScopes("nonsense,read:user")).toEqual(["read:user"]); | |
| 102 | expect(parseScopes(" ")).toEqual([]); | |
| 103 | }); | |
| 104 | ||
| 105 | it("serializeScopes round-trips through parseScopes", () => { | |
| 106 | const s = serializeScopes(["read:user", "write:repo"]); | |
| 107 | expect(s).toBe("read:user write:repo"); | |
| 108 | expect(parseScopes(s)).toEqual(["read:user", "write:repo"]); | |
| 109 | }); | |
| 110 | ||
| 111 | it("parseRedirectUris splits on newlines, trims, drops empty lines", () => { | |
| 112 | const parsed = parseRedirectUris( | |
| 113 | "https://a.com/cb\n https://b.com/cb \n\n\nhttps://c.com/cb\n" | |
| 114 | ); | |
| 115 | expect(parsed).toEqual([ | |
| 116 | "https://a.com/cb", | |
| 117 | "https://b.com/cb", | |
| 118 | "https://c.com/cb", | |
| 119 | ]); | |
| 120 | expect(parseRedirectUris("")).toEqual([]); | |
| 121 | expect(parseRedirectUris(" \n \n")).toEqual([]); | |
| 122 | }); | |
| 123 | ||
| 124 | it("isValidRedirectUri accepts valid https and localhost-http URIs", () => { | |
| 125 | expect(isValidRedirectUri("https://example.com/callback")).toBe(true); | |
| 126 | expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true); | |
| 127 | expect(isValidRedirectUri("http://127.0.0.1/cb")).toBe(true); | |
| 128 | }); | |
| 129 | ||
| 130 | it("isValidRedirectUri rejects invalid URIs", () => { | |
| 131 | expect(isValidRedirectUri("http://example.com/cb")).toBe(false); | |
| 132 | expect(isValidRedirectUri("ftp://example.com/cb")).toBe(false); | |
| 133 | expect(isValidRedirectUri("https://example.com/cb#frag")).toBe(false); | |
| 134 | expect(isValidRedirectUri("https://*.example.com/cb")).toBe(false); | |
| 135 | expect(isValidRedirectUri("not a url")).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | it("redirectUriAllowed requires exact match against the registered list", () => { | |
| 139 | expect( | |
| 140 | redirectUriAllowed("https://a.com/cb", [ | |
| 141 | "https://a.com/cb", | |
| 142 | "https://b.com/cb", | |
| 143 | ]) | |
| 144 | ).toBe(true); | |
| 145 | // Trailing slash matters — exact match only. | |
| 146 | expect(redirectUriAllowed("https://a.com/cb/", ["https://a.com/cb"])).toBe( | |
| 147 | false | |
| 148 | ); | |
| 149 | expect(redirectUriAllowed("", ["https://a.com/cb"])).toBe(false); | |
| 150 | expect(redirectUriAllowed("https://a.com/cb", [])).toBe(false); | |
| 151 | }); | |
| 152 | ||
| 153 | it("verifyPkce S256 accepts the RFC 7636 §B example", async () => { | |
| 154 | const ok = await verifyPkce({ | |
| 155 | method: "S256", | |
| 156 | challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", | |
| 157 | verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", | |
| 158 | }); | |
| 159 | expect(ok).toBe(true); | |
| 160 | }); | |
| 161 | ||
| 162 | it("verifyPkce S256 rejects a mismatched verifier", async () => { | |
| 163 | const ok = await verifyPkce({ | |
| 164 | method: "S256", | |
| 165 | challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", | |
| 166 | verifier: "not-the-right-verifier", | |
| 167 | }); | |
| 168 | expect(ok).toBe(false); | |
| 169 | }); | |
| 170 | ||
| 171 | it("verifyPkce plain matches only when verifier === challenge", async () => { | |
| 172 | expect( | |
| 173 | await verifyPkce({ method: "plain", challenge: "abc", verifier: "abc" }) | |
| 174 | ).toBe(true); | |
| 175 | expect( | |
| 176 | await verifyPkce({ method: "plain", challenge: "abc", verifier: "abd" }) | |
| 177 | ).toBe(false); | |
| 178 | }); | |
| 179 | ||
| 180 | it("verifyPkce returns false when challenge is missing", async () => { | |
| 181 | expect( | |
| 182 | await verifyPkce({ method: "S256", challenge: "", verifier: "x" }) | |
| 183 | ).toBe(false); | |
| 184 | expect( | |
| 185 | await verifyPkce({ method: "S256", challenge: null, verifier: "x" }) | |
| 186 | ).toBe(false); | |
| 187 | expect( | |
| 188 | await verifyPkce({ method: "plain", challenge: undefined, verifier: "x" }) | |
| 189 | ).toBe(false); | |
| 190 | }); | |
| 191 | ||
| 192 | it("b64urlFromBytes produces URL-safe unpadded base64", () => { | |
| 193 | // RFC 4648 test vector: "fooba" → "Zm9vYmE" | |
| 194 | const bytes = new TextEncoder().encode("fooba"); | |
| 195 | const out = b64urlFromBytes(bytes); | |
| 196 | expect(out).toBe("Zm9vYmE"); | |
| 197 | expect(out).not.toContain("="); | |
| 198 | expect(out).not.toContain("+"); | |
| 199 | expect(out).not.toContain("/"); | |
| 200 | }); | |
| 201 | ||
| 202 | it("SUPPORTED_SCOPES includes at least the core three", () => { | |
| 203 | expect(SUPPORTED_SCOPES).toContain("read:user"); | |
| 204 | expect(SUPPORTED_SCOPES).toContain("read:repo"); | |
| 205 | expect(SUPPORTED_SCOPES).toContain("write:repo"); | |
| 206 | }); | |
| 207 | ||
| 208 | it("TTL constants have sensible magnitudes", () => { | |
| 209 | expect(ACCESS_TOKEN_TTL_MS).toBe(60 * 60 * 1000); | |
| 210 | expect(REFRESH_TOKEN_TTL_MS).toBe(30 * 24 * 60 * 60 * 1000); | |
| 211 | expect(AUTH_CODE_TTL_MS).toBe(10 * 60 * 1000); | |
| 212 | expect(ACCESS_TOKEN_TTL_MS).toBeLessThan(REFRESH_TOKEN_TTL_MS); | |
| 213 | expect(AUTH_CODE_TTL_MS).toBeLessThan(ACCESS_TOKEN_TTL_MS); | |
| 214 | }); | |
| 215 | }); | |
| 216 | ||
| 217 | describe("oauth routes (B6) — unauthed redirects", () => { | |
| 218 | it("GET /oauth/authorize without session redirects to /login", async () => { | |
| 219 | const res = await app.request("/oauth/authorize"); | |
| 220 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 221 | const loc = res.headers.get("location") || ""; | |
| 222 | expect(loc.startsWith("/login")).toBe(true); | |
| 223 | }); | |
| 224 | ||
| 225 | it("POST /oauth/authorize/decision without session redirects to /login", async () => { | |
| 226 | const res = await app.request("/oauth/authorize/decision", { | |
| 227 | method: "POST", | |
| 228 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 229 | body: "decision=approve", | |
| 230 | }); | |
| 231 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 232 | const loc = res.headers.get("location") || ""; | |
| 233 | expect(loc.startsWith("/login")).toBe(true); | |
| 234 | }); | |
| 235 | ||
| 236 | it("GET /settings/authorizations without session redirects to /login", async () => { | |
| 237 | const res = await app.request("/settings/authorizations"); | |
| 238 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 239 | const loc = res.headers.get("location") || ""; | |
| 240 | expect(loc.startsWith("/login")).toBe(true); | |
| 241 | }); | |
| 242 | ||
| 243 | it("POST /settings/authorizations/:id/revoke without session redirects to /login", async () => { | |
| 244 | const res = await app.request("/settings/authorizations/some-id/revoke", { | |
| 245 | method: "POST", | |
| 246 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 247 | body: "", | |
| 248 | }); | |
| 249 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 250 | const loc = res.headers.get("location") || ""; | |
| 251 | expect(loc.startsWith("/login")).toBe(true); | |
| 252 | }); | |
| 253 | ||
| 254 | it("GET /settings/applications without session redirects to /login", async () => { | |
| 255 | const res = await app.request("/settings/applications"); | |
| 256 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 257 | const loc = res.headers.get("location") || ""; | |
| 258 | expect(loc.startsWith("/login")).toBe(true); | |
| 259 | }); | |
| 260 | ||
| 261 | it("POST /settings/applications/new without session redirects to /login", async () => { | |
| 262 | const res = await app.request("/settings/applications/new", { | |
| 263 | method: "POST", | |
| 264 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 265 | body: "name=Test", | |
| 266 | }); | |
| 267 | expect([301, 302, 303, 307]).toContain(res.status); | |
| 268 | const loc = res.headers.get("location") || ""; | |
| 269 | expect(loc.startsWith("/login")).toBe(true); | |
| 270 | }); | |
| 271 | }); | |
| 272 | ||
| 273 | describe("oauth /token endpoint (B6)", () => { | |
| 274 | it("returns 401 invalid_client when client_id is missing", async () => { | |
| 275 | const res = await app.request("/oauth/token", { | |
| 276 | method: "POST", | |
| 277 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 278 | body: "grant_type=authorization_code", | |
| 279 | }); | |
| 280 | expect(res.status).toBe(401); | |
| 281 | const body = await res.json(); | |
| 282 | expect(body.error).toBe("invalid_client"); | |
| 283 | }); | |
| 284 | ||
| 285 | it("returns 401 or 503 when client_id is unknown", async () => { | |
| 286 | const res = await app.request("/oauth/token", { | |
| 287 | method: "POST", | |
| 288 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 289 | body: | |
| 290 | "grant_type=authorization_code&client_id=glc_app_nonexistent000000000000", | |
| 291 | }); | |
| 292 | // 401 if DB confirms unknown client, 503 if DB unreachable. | |
| 293 | expect([401, 503]).toContain(res.status); | |
| 294 | if (res.status === 401) { | |
| 295 | const body = await res.json(); | |
| 296 | expect(body.error).toBe("invalid_client"); | |
| 297 | } | |
| 298 | }); | |
| 299 | ||
| 300 | it("returns 400 on malformed JSON body", async () => { | |
| 301 | const res = await app.request("/oauth/token", { | |
| 302 | method: "POST", | |
| 303 | headers: { "content-type": "application/json" }, | |
| 304 | body: "{this is not json", | |
| 305 | }); | |
| 306 | expect(res.status).toBe(400); | |
| 307 | const body = await res.json(); | |
| 308 | expect(body.error).toBeTruthy(); | |
| 309 | }); | |
| 310 | ||
| 311 | it("returns 401 or 503 for a bogus client_id + unsupported grant_type", async () => { | |
| 312 | const res = await app.request("/oauth/token", { | |
| 313 | method: "POST", | |
| 314 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 315 | body: | |
| 316 | "grant_type=password&client_id=glc_app_000000000000000000000000", | |
| 317 | }); | |
| 318 | // Client lookup runs before grant_type validation. Either the DB says | |
| 319 | // unknown client (401) or the DB is unreachable (503). | |
| 320 | expect([401, 503]).toContain(res.status); | |
| 321 | }); | |
| 322 | }); | |
| 323 | ||
| 324 | describe("oauth /revoke endpoint (B6)", () => { | |
| 325 | it("returns 401 when no client credentials are supplied", async () => { | |
| 326 | const res = await app.request("/oauth/revoke", { | |
| 327 | method: "POST", | |
| 328 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 329 | body: "token=glct_something", | |
| 330 | }); | |
| 331 | expect(res.status).toBe(401); | |
| 332 | const body = await res.json(); | |
| 333 | expect(body.error).toBe("invalid_client"); | |
| 334 | }); | |
| 335 | }); |