Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

oauth.test.tsBlame335 lines · 1 contributor
058d752Claude1/**
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
15import { describe, it, expect } from "bun:test";
16import app from "../app";
17import {
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
38describe("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
217describe("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
273describe("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
324describe("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});