Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame274 lines · 1 contributor
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[];
06d5ffeClaude39 };
40};
41
058d752Claude42async function loadUserFromBearer(
43 token: string
44): Promise<{ user: User; scopes: string[]; appId: string } | null> {
45 if (!token.startsWith("glct_")) return null;
46 try {
47 const hash = await sha256Hex(token);
48 const [row] = await db
49 .select()
50 .from(oauthAccessTokens)
51 .where(eq(oauthAccessTokens.accessTokenHash, hash))
52 .limit(1);
53 if (!row) return null;
54 if (row.revokedAt) return null;
55 if (new Date(row.expiresAt) < new Date()) return null;
56 const [user] = await db
57 .select()
58 .from(users)
59 .where(eq(users.id, row.userId))
60 .limit(1);
61 if (!user) return null;
a28cedeClaude62 // Best-effort: update lastUsedAt. Never fail auth on this — but log so
63 // a persistent DB write failure surfaces (was a silent .catch(() => {}).)
058d752Claude64 db
65 .update(oauthAccessTokens)
66 .set({ lastUsedAt: new Date() })
67 .where(eq(oauthAccessTokens.id, row.id))
a28cedeClaude68 .catch((err) => {
69 console.warn(
70 "[auth] oauth lastUsedAt update failed:",
71 err instanceof Error ? err.message : err
72 );
73 });
058d752Claude74 return {
75 user,
76 scopes: row.scopes ? row.scopes.split(/\s+/).filter(Boolean) : [],
77 appId: row.appId,
78 };
79 } catch {
80 return null;
81 }
82}
83
25a91a6Claude84/**
85 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
86 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
87 * Scopes are comma-separated in the row; return as array so callers can check.
88 */
89async function loadUserFromPat(
90 token: string
91): Promise<{ user: User; scopes: string[] } | null> {
92 if (!token.startsWith("glc_")) return null;
93 try {
94 const hash = await sha256Hex(token);
95 const [row] = await db
96 .select()
97 .from(apiTokens)
98 .where(eq(apiTokens.tokenHash, hash))
99 .limit(1);
100 if (!row) return null;
101 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
102 const [user] = await db
103 .select()
104 .from(users)
105 .where(eq(users.id, row.userId))
106 .limit(1);
107 if (!user) return null;
108 db
109 .update(apiTokens)
110 .set({ lastUsedAt: new Date() })
111 .where(eq(apiTokens.id, row.id))
a28cedeClaude112 .catch((err) => {
113 console.warn(
114 "[auth] PAT lastUsedAt update failed:",
115 err instanceof Error ? err.message : err
116 );
117 });
25a91a6Claude118 return {
119 user,
120 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
121 };
122 } catch {
123 return null;
124 }
125}
126
06d5ffeClaude127/**
128 * Soft auth — sets c.get("user") to the current user or null.
129 * Does NOT block unauthenticated requests.
05b973eClaude130 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
06d5ffeClaude131 */
132export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude133 // B6: Bearer token takes precedence over cookie for API calls.
134 const authHeader = c.req.header("authorization") || "";
135 if (authHeader.toLowerCase().startsWith("bearer ")) {
136 const bearer = authHeader.slice(7).trim();
137 if (bearer.startsWith("glct_")) {
138 const result = await loadUserFromBearer(bearer);
139 if (result) {
140 c.set("user", result.user);
141 c.set("oauthScopes", result.scopes);
142 c.set("oauthAppId", result.appId);
143 return next();
144 }
25a91a6Claude145 } else if (bearer.startsWith("glc_")) {
146 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
147 const result = await loadUserFromPat(bearer);
148 if (result) {
149 c.set("user", result.user);
150 c.set("oauthScopes", result.scopes);
151 return next();
152 }
058d752Claude153 }
154 }
155
06d5ffeClaude156 const token = getCookie(c, "session");
157 if (!token) {
158 c.set("user", null);
159 return next();
160 }
161
05b973eClaude162 // Check session cache first
163 const cachedUser = sessionCache.get(token) as User | null | undefined;
164 if (cachedUser !== undefined) {
165 c.set("user", cachedUser);
166 return next();
167 }
168
06d5ffeClaude169 try {
170 const [session] = await db
171 .select()
172 .from(sessions)
173 .where(eq(sessions.token, token))
174 .limit(1);
175
7298a17Claude176 if (
177 !session ||
178 new Date(session.expiresAt) < new Date() ||
179 session.requires2fa
180 ) {
05b973eClaude181 sessionCache.set(token, null as any);
06d5ffeClaude182 c.set("user", null);
183 return next();
184 }
185
186 const [user] = await db
187 .select()
188 .from(users)
189 .where(eq(users.id, session.userId))
190 .limit(1);
191
05b973eClaude192 // Cache the result (user or null)
193 sessionCache.set(token, (user || null) as any);
06d5ffeClaude194 c.set("user", user || null);
195 } catch {
196 c.set("user", null);
197 }
198
199 return next();
200});
201
202/**
203 * Hard auth — redirects to /login if not authenticated.
204 */
205export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude206 // B6: Bearer token takes precedence over cookie for API calls.
207 const authHeader = c.req.header("authorization") || "";
208 if (authHeader.toLowerCase().startsWith("bearer ")) {
209 const bearer = authHeader.slice(7).trim();
210 if (bearer.startsWith("glct_")) {
211 const result = await loadUserFromBearer(bearer);
212 if (result) {
213 c.set("user", result.user);
214 c.set("oauthScopes", result.scopes);
215 c.set("oauthAppId", result.appId);
216 return next();
217 }
218 // Bearer token was presented but invalid — return 401 instead of
219 // redirecting to /login (API clients don't follow HTML redirects).
220 return c.json({ error: "Invalid or expired token" }, 401);
221 }
25a91a6Claude222 if (bearer.startsWith("glc_")) {
223 // C2: Personal access tokens for CLI clients.
224 const result = await loadUserFromPat(bearer);
225 if (result) {
226 c.set("user", result.user);
227 c.set("oauthScopes", result.scopes);
228 return next();
229 }
230 return c.json({ error: "Invalid or expired token" }, 401);
231 }
058d752Claude232 }
233
06d5ffeClaude234 const token = getCookie(c, "session");
235 if (!token) {
236 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
237 }
238
239 try {
240 const [session] = await db
241 .select()
242 .from(sessions)
243 .where(eq(sessions.token, token))
244 .limit(1);
245
246 if (!session || new Date(session.expiresAt) < new Date()) {
247 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
248 }
249
7298a17Claude250 // 2FA pending — route the user to the code prompt instead of letting
251 // them access protected pages.
252 if (session.requires2fa) {
253 return c.redirect(
254 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
255 );
256 }
257
06d5ffeClaude258 const [user] = await db
259 .select()
260 .from(users)
261 .where(eq(users.id, session.userId))
262 .limit(1);
263
264 if (!user) {
265 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
266 }
267
268 c.set("user", user);
269 } catch {
270 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
271 }
272
273 return next();
274});