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

auth.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.

auth.tsBlame285 lines · 2 contributors
06d5ffeClaude1/**
2 * Auth middleware — reads session cookie, injects user into context.
05b973eClaude3 * Uses in-memory session cache to avoid DB roundtrip on every request.
058d752Claude4 *
5 * B6: API requests can also authenticate via `Authorization: Bearer <token>`
6 * using an OAuth access token. The token's scopes and the owning app are
7 * stashed on the context for scope checks downstream.
06d5ffeClaude8 */
9
10import { createMiddleware } from "hono/factory";
11import { getCookie } from "hono/cookie";
12import { eq, gt } from "drizzle-orm";
13import { db } from "../db";
25a91a6Claude14import { sessions, users, oauthAccessTokens, apiTokens } from "../db/schema";
06d5ffeClaude15import type { User } from "../db/schema";
05b973eClaude16import { sessionCache } from "../lib/cache";
058d752Claude17import { sha256Hex } from "../lib/oauth";
06d5ffeClaude18
19export type AuthEnv = {
20 Variables: {
21 user: User | null;
058d752Claude22 /** When the caller authenticated via an OAuth bearer token, these are set. */
23 oauthScopes?: string[];
24 oauthAppId?: string;
2316901Claude25 /** Set by `csrf` middleware on GET requests; required field on POST forms. */
26 csrfToken?: string;
27 /** Set by `requestContext` middleware on every request — opaque request id. */
28 requestId?: string;
29 /** Wall-clock ms when the request entered the app — used by metrics. */
30 requestStart?: number;
31 /** Set by `requireRepoAccess` middleware — the resolved repository row. */
32 repository?: unknown;
33 /** Set by `requireRepoAccess` — the caller's access level ('read'|'write'|'admin'|'owner'). */
34 repoAccess?: string;
35 /** Set by API-auth middleware — how the caller authenticated. */
36 authMethod?: "token" | "session" | "none";
37 /** Set by API-auth middleware — scopes carried by the PAT or session. */
38 tokenScopes?: string[];
b5a7bb3ccanty labs39 /**
40 * Set by softAuth when an `Authorization: Bearer glct_/glc_` token was
41 * presented but rejected (expired, revoked, or unknown). Routes use this
42 * to emit an RFC 6750 `error="invalid_token"` challenge instead of
43 * treating the caller as anonymous.
44 */
45 bearerInvalid?: boolean;
06d5ffeClaude46 };
47};
48
058d752Claude49async function loadUserFromBearer(
50 token: string
51): Promise<{ user: User; scopes: string[]; appId: string } | null> {
52 if (!token.startsWith("glct_")) return null;
53 try {
54 const hash = await sha256Hex(token);
55 const [row] = await db
56 .select()
57 .from(oauthAccessTokens)
58 .where(eq(oauthAccessTokens.accessTokenHash, hash))
59 .limit(1);
60 if (!row) return null;
61 if (row.revokedAt) return null;
62 if (new Date(row.expiresAt) < new Date()) return null;
63 const [user] = await db
64 .select()
65 .from(users)
66 .where(eq(users.id, row.userId))
67 .limit(1);
68 if (!user) return null;
a28cedeClaude69 // Best-effort: update lastUsedAt. Never fail auth on this — but log so
70 // a persistent DB write failure surfaces (was a silent .catch(() => {}).)
058d752Claude71 db
72 .update(oauthAccessTokens)
73 .set({ lastUsedAt: new Date() })
74 .where(eq(oauthAccessTokens.id, row.id))
a28cedeClaude75 .catch((err) => {
76 console.warn(
77 "[auth] oauth lastUsedAt update failed:",
78 err instanceof Error ? err.message : err
79 );
80 });
058d752Claude81 return {
82 user,
83 scopes: row.scopes ? row.scopes.split(/\s+/).filter(Boolean) : [],
84 appId: row.appId,
85 };
86 } catch {
87 return null;
88 }
89}
90
25a91a6Claude91/**
92 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
93 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
94 * Scopes are comma-separated in the row; return as array so callers can check.
95 */
96async function loadUserFromPat(
97 token: string
98): Promise<{ user: User; scopes: string[] } | null> {
99 if (!token.startsWith("glc_")) return null;
100 try {
101 const hash = await sha256Hex(token);
102 const [row] = await db
103 .select()
104 .from(apiTokens)
105 .where(eq(apiTokens.tokenHash, hash))
106 .limit(1);
107 if (!row) return null;
108 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
109 const [user] = await db
110 .select()
111 .from(users)
112 .where(eq(users.id, row.userId))
113 .limit(1);
114 if (!user) return null;
115 db
116 .update(apiTokens)
117 .set({ lastUsedAt: new Date() })
118 .where(eq(apiTokens.id, row.id))
a28cedeClaude119 .catch((err) => {
120 console.warn(
121 "[auth] PAT lastUsedAt update failed:",
122 err instanceof Error ? err.message : err
123 );
124 });
25a91a6Claude125 return {
126 user,
127 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
128 };
129 } catch {
130 return null;
131 }
132}
133
06d5ffeClaude134/**
135 * Soft auth — sets c.get("user") to the current user or null.
136 * Does NOT block unauthenticated requests.
05b973eClaude137 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
06d5ffeClaude138 */
139export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude140 // B6: Bearer token takes precedence over cookie for API calls.
141 const authHeader = c.req.header("authorization") || "";
142 if (authHeader.toLowerCase().startsWith("bearer ")) {
143 const bearer = authHeader.slice(7).trim();
144 if (bearer.startsWith("glct_")) {
145 const result = await loadUserFromBearer(bearer);
146 if (result) {
147 c.set("user", result.user);
148 c.set("oauthScopes", result.scopes);
149 c.set("oauthAppId", result.appId);
150 return next();
151 }
b5a7bb3ccanty labs152 // Token presented but rejected — stay soft (cookie fallback still
153 // runs), but flag it so routes can signal invalid_token per RFC 6750.
154 c.set("bearerInvalid", true);
25a91a6Claude155 } else if (bearer.startsWith("glc_")) {
156 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
157 const result = await loadUserFromPat(bearer);
158 if (result) {
159 c.set("user", result.user);
160 c.set("oauthScopes", result.scopes);
161 return next();
162 }
b5a7bb3ccanty labs163 c.set("bearerInvalid", true);
058d752Claude164 }
165 }
166
06d5ffeClaude167 const token = getCookie(c, "session");
168 if (!token) {
169 c.set("user", null);
170 return next();
171 }
172
05b973eClaude173 // Check session cache first
174 const cachedUser = sessionCache.get(token) as User | null | undefined;
175 if (cachedUser !== undefined) {
176 c.set("user", cachedUser);
177 return next();
178 }
179
06d5ffeClaude180 try {
181 const [session] = await db
182 .select()
183 .from(sessions)
184 .where(eq(sessions.token, token))
185 .limit(1);
186
7298a17Claude187 if (
188 !session ||
189 new Date(session.expiresAt) < new Date() ||
190 session.requires2fa
191 ) {
05b973eClaude192 sessionCache.set(token, null as any);
06d5ffeClaude193 c.set("user", null);
194 return next();
195 }
196
197 const [user] = await db
198 .select()
199 .from(users)
200 .where(eq(users.id, session.userId))
201 .limit(1);
202
05b973eClaude203 // Cache the result (user or null)
204 sessionCache.set(token, (user || null) as any);
06d5ffeClaude205 c.set("user", user || null);
206 } catch {
207 c.set("user", null);
208 }
209
210 return next();
211});
212
213/**
214 * Hard auth — redirects to /login if not authenticated.
215 */
216export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude217 // B6: Bearer token takes precedence over cookie for API calls.
218 const authHeader = c.req.header("authorization") || "";
219 if (authHeader.toLowerCase().startsWith("bearer ")) {
220 const bearer = authHeader.slice(7).trim();
221 if (bearer.startsWith("glct_")) {
222 const result = await loadUserFromBearer(bearer);
223 if (result) {
224 c.set("user", result.user);
225 c.set("oauthScopes", result.scopes);
226 c.set("oauthAppId", result.appId);
227 return next();
228 }
229 // Bearer token was presented but invalid — return 401 instead of
230 // redirecting to /login (API clients don't follow HTML redirects).
231 return c.json({ error: "Invalid or expired token" }, 401);
232 }
25a91a6Claude233 if (bearer.startsWith("glc_")) {
234 // C2: Personal access tokens for CLI clients.
235 const result = await loadUserFromPat(bearer);
236 if (result) {
237 c.set("user", result.user);
238 c.set("oauthScopes", result.scopes);
239 return next();
240 }
241 return c.json({ error: "Invalid or expired token" }, 401);
242 }
058d752Claude243 }
244
06d5ffeClaude245 const token = getCookie(c, "session");
246 if (!token) {
247 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
248 }
249
250 try {
251 const [session] = await db
252 .select()
253 .from(sessions)
254 .where(eq(sessions.token, token))
255 .limit(1);
256
257 if (!session || new Date(session.expiresAt) < new Date()) {
258 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
259 }
260
7298a17Claude261 // 2FA pending — route the user to the code prompt instead of letting
262 // them access protected pages.
263 if (session.requires2fa) {
264 return c.redirect(
265 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
266 );
267 }
268
06d5ffeClaude269 const [user] = await db
270 .select()
271 .from(users)
272 .where(eq(users.id, session.userId))
273 .limit(1);
274
275 if (!user) {
276 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
277 }
278
279 c.set("user", user);
280 } catch {
281 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
282 }
283
284 return next();
285});