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.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.tsBlame197 lines · 1 contributor
bfdb5e7Claude1/**
2 * OAuth 2.0 helpers (Block B6).
3 *
4 * Stateless utilities for the OAuth provider implemented in
5 * `src/routes/oauth.tsx`:
6 *
7 * - token / code / secret generation
8 * - constant-time SHA-256 hashing (matches how we store PATs)
9 * - PKCE (RFC 7636) code_challenge verification
10 * - scope parsing + validation
11 * - redirect-URI matching (exact match, no wildcards)
12 *
13 * All outputs that end up in URLs or Authorization headers are prefix-tagged
14 * so they're greppable in logs without leaking the secret portion.
15 */
16
17/** Supported OAuth scopes. Add new ones here + document on the consent screen. */
18export const SUPPORTED_SCOPES = [
19 "read:user",
20 "read:repo",
21 "write:repo",
22 "read:org",
23 "write:org",
24 "read:issue",
25 "write:issue",
26 "read:pr",
27 "write:pr",
28] as const;
29
30export type OauthScope = (typeof SUPPORTED_SCOPES)[number];
31
32/** Default access token TTL (1 hour). */
33export const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
34/** Refresh token TTL (30 days). */
35export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
36/** Authorization code TTL (10 min — well within RFC 6749's 10-minute max). */
37export const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
38
39function randomHex(byteLen: number): string {
40 const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
41 let s = "";
42 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
43 return s;
44}
45
46/** Returns a 20-char client_id like `glc_app_<12 hex>`. */
47export function generateClientId(): string {
48 return "glc_app_" + randomHex(12);
49}
50
51/** Returns a 40-char client secret like `glcs_<32 hex>`. */
52export function generateClientSecret(): string {
53 return "glcs_" + randomHex(32);
54}
55
56export function generateAuthCode(): string {
57 return "glca_" + randomHex(24);
58}
59
60export function generateAccessToken(): string {
61 return "glct_" + randomHex(32);
62}
63
64export function generateRefreshToken(): string {
65 return "glcr_" + randomHex(32);
66}
67
68/** SHA-256 hex digest. Same algorithm as src/routes/tokens.ts. */
69export async function sha256Hex(input: string): Promise<string> {
70 const data = new TextEncoder().encode(input);
71 const digest = await crypto.subtle.digest("SHA-256", data);
72 const bytes = new Uint8Array(digest);
73 let s = "";
74 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
75 return s;
76}
77
78/** Base64url (no padding) — used by PKCE. */
79export function b64urlFromBytes(bytes: Uint8Array): string {
80 let bin = "";
81 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
82 return btoa(bin)
83 .replace(/\+/g, "-")
84 .replace(/\//g, "_")
85 .replace(/=+$/, "");
86}
87
88/**
89 * PKCE verification (RFC 7636).
90 * For "S256": base64url(SHA-256(verifier)) must equal the challenge.
91 * For "plain": the verifier must equal the challenge literally.
92 * Returns true when the method is unrecognized but `challenge` is empty —
93 * callers should refuse before calling this if PKCE is required.
94 */
95export async function verifyPkce(opts: {
96 challenge: string | null | undefined;
97 method: string | null | undefined;
98 verifier: string;
99}): Promise<boolean> {
100 const challenge = (opts.challenge || "").trim();
101 if (!challenge) return false;
102 const method = (opts.method || "plain").toLowerCase();
103
104 if (method === "s256") {
105 const data = new TextEncoder().encode(opts.verifier);
106 const digest = await crypto.subtle.digest("SHA-256", data);
107 const produced = b64urlFromBytes(new Uint8Array(digest));
108 return timingSafeEqual(produced, challenge);
109 }
110 if (method === "plain") {
111 return timingSafeEqual(opts.verifier, challenge);
112 }
113 return false;
114}
115
116/** Constant-time string comparison. */
117export function timingSafeEqual(a: string, b: string): boolean {
118 if (a.length !== b.length) return false;
119 let diff = 0;
120 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
121 return diff === 0;
122}
123
124/**
125 * Parse a scope string (space-separated per RFC 6749 or comma-separated for
126 * convenience). Unknown scopes are dropped silently; duplicates collapsed.
127 */
128export function parseScopes(input: string | null | undefined): OauthScope[] {
129 if (!input) return [];
130 const parts = input.split(/[\s,]+/).filter(Boolean);
131 const seen = new Set<string>();
132 const out: OauthScope[] = [];
133 for (const p of parts) {
134 const s = p.trim().toLowerCase();
135 if (!s || seen.has(s)) continue;
136 if ((SUPPORTED_SCOPES as readonly string[]).includes(s)) {
137 out.push(s as OauthScope);
138 seen.add(s);
139 }
140 }
141 return out;
142}
143
144/** Serialize scopes back to a space-separated string. */
145export function serializeScopes(scopes: readonly OauthScope[]): string {
146 return scopes.join(" ");
147}
148
149/**
150 * Parse an app's stored `redirectUris` column (newline-separated).
151 * Empty / whitespace-only lines are ignored.
152 */
153export function parseRedirectUris(stored: string): string[] {
154 return stored
155 .split(/\r?\n/)
156 .map((s) => s.trim())
157 .filter(Boolean);
158}
159
160/**
161 * Validate a single redirect URI for storage:
162 * - must be absolute http(s):// (http only for localhost)
163 * - no fragment (`#...`)
164 * - no wildcards
165 */
166export function isValidRedirectUri(uri: string): boolean {
167 try {
168 const u = new URL(uri);
169 if (u.protocol !== "http:" && u.protocol !== "https:") return false;
170 if (u.protocol === "http:") {
171 if (!["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
172 return false;
173 }
174 }
175 if (u.hash) return false;
176 if (uri.includes("*")) return false;
177 return true;
178 } catch {
179 return false;
180 }
181}
182
183/** Exact-match check against the app's registered list. */
184export function redirectUriAllowed(
185 candidate: string,
186 registered: readonly string[]
187): boolean {
188 if (!candidate) return false;
189 for (const r of registered) {
190 if (timingSafeEqual(candidate, r)) return true;
191 }
192 return false;
193}
194
195export const __test = {
196 randomHex,
197};