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.tsBlame249 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;
06d5ffeClaude25 };
26};
27
058d752Claude28async function loadUserFromBearer(
29 token: string
30): Promise<{ user: User; scopes: string[]; appId: string } | null> {
31 if (!token.startsWith("glct_")) return null;
32 try {
33 const hash = await sha256Hex(token);
34 const [row] = await db
35 .select()
36 .from(oauthAccessTokens)
37 .where(eq(oauthAccessTokens.accessTokenHash, hash))
38 .limit(1);
39 if (!row) return null;
40 if (row.revokedAt) return null;
41 if (new Date(row.expiresAt) < new Date()) return null;
42 const [user] = await db
43 .select()
44 .from(users)
45 .where(eq(users.id, row.userId))
46 .limit(1);
47 if (!user) return null;
48 // Best-effort: update lastUsedAt. Never fail auth on this.
49 db
50 .update(oauthAccessTokens)
51 .set({ lastUsedAt: new Date() })
52 .where(eq(oauthAccessTokens.id, row.id))
53 .catch(() => {});
54 return {
55 user,
56 scopes: row.scopes ? row.scopes.split(/\s+/).filter(Boolean) : [],
57 appId: row.appId,
58 };
59 } catch {
60 return null;
61 }
62}
63
25a91a6Claude64/**
65 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
66 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
67 * Scopes are comma-separated in the row; return as array so callers can check.
68 */
69async function loadUserFromPat(
70 token: string
71): Promise<{ user: User; scopes: string[] } | null> {
72 if (!token.startsWith("glc_")) return null;
73 try {
74 const hash = await sha256Hex(token);
75 const [row] = await db
76 .select()
77 .from(apiTokens)
78 .where(eq(apiTokens.tokenHash, hash))
79 .limit(1);
80 if (!row) return null;
81 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
82 const [user] = await db
83 .select()
84 .from(users)
85 .where(eq(users.id, row.userId))
86 .limit(1);
87 if (!user) return null;
88 db
89 .update(apiTokens)
90 .set({ lastUsedAt: new Date() })
91 .where(eq(apiTokens.id, row.id))
92 .catch(() => {});
93 return {
94 user,
95 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
96 };
97 } catch {
98 return null;
99 }
100}
101
06d5ffeClaude102/**
103 * Soft auth — sets c.get("user") to the current user or null.
104 * Does NOT block unauthenticated requests.
05b973eClaude105 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
06d5ffeClaude106 */
107export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude108 // B6: Bearer token takes precedence over cookie for API calls.
109 const authHeader = c.req.header("authorization") || "";
110 if (authHeader.toLowerCase().startsWith("bearer ")) {
111 const bearer = authHeader.slice(7).trim();
112 if (bearer.startsWith("glct_")) {
113 const result = await loadUserFromBearer(bearer);
114 if (result) {
115 c.set("user", result.user);
116 c.set("oauthScopes", result.scopes);
117 c.set("oauthAppId", result.appId);
118 return next();
119 }
25a91a6Claude120 } else if (bearer.startsWith("glc_")) {
121 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
122 const result = await loadUserFromPat(bearer);
123 if (result) {
124 c.set("user", result.user);
125 c.set("oauthScopes", result.scopes);
126 return next();
127 }
058d752Claude128 }
129 }
130
06d5ffeClaude131 const token = getCookie(c, "session");
132 if (!token) {
133 c.set("user", null);
134 return next();
135 }
136
05b973eClaude137 // Check session cache first
138 const cachedUser = sessionCache.get(token) as User | null | undefined;
139 if (cachedUser !== undefined) {
140 c.set("user", cachedUser);
141 return next();
142 }
143
06d5ffeClaude144 try {
145 const [session] = await db
146 .select()
147 .from(sessions)
148 .where(eq(sessions.token, token))
149 .limit(1);
150
7298a17Claude151 if (
152 !session ||
153 new Date(session.expiresAt) < new Date() ||
154 session.requires2fa
155 ) {
05b973eClaude156 sessionCache.set(token, null as any);
06d5ffeClaude157 c.set("user", null);
158 return next();
159 }
160
161 const [user] = await db
162 .select()
163 .from(users)
164 .where(eq(users.id, session.userId))
165 .limit(1);
166
05b973eClaude167 // Cache the result (user or null)
168 sessionCache.set(token, (user || null) as any);
06d5ffeClaude169 c.set("user", user || null);
170 } catch {
171 c.set("user", null);
172 }
173
174 return next();
175});
176
177/**
178 * Hard auth — redirects to /login if not authenticated.
179 */
180export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude181 // B6: Bearer token takes precedence over cookie for API calls.
182 const authHeader = c.req.header("authorization") || "";
183 if (authHeader.toLowerCase().startsWith("bearer ")) {
184 const bearer = authHeader.slice(7).trim();
185 if (bearer.startsWith("glct_")) {
186 const result = await loadUserFromBearer(bearer);
187 if (result) {
188 c.set("user", result.user);
189 c.set("oauthScopes", result.scopes);
190 c.set("oauthAppId", result.appId);
191 return next();
192 }
193 // Bearer token was presented but invalid — return 401 instead of
194 // redirecting to /login (API clients don't follow HTML redirects).
195 return c.json({ error: "Invalid or expired token" }, 401);
196 }
25a91a6Claude197 if (bearer.startsWith("glc_")) {
198 // C2: Personal access tokens for CLI clients.
199 const result = await loadUserFromPat(bearer);
200 if (result) {
201 c.set("user", result.user);
202 c.set("oauthScopes", result.scopes);
203 return next();
204 }
205 return c.json({ error: "Invalid or expired token" }, 401);
206 }
058d752Claude207 }
208
06d5ffeClaude209 const token = getCookie(c, "session");
210 if (!token) {
211 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
212 }
213
214 try {
215 const [session] = await db
216 .select()
217 .from(sessions)
218 .where(eq(sessions.token, token))
219 .limit(1);
220
221 if (!session || new Date(session.expiresAt) < new Date()) {
222 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
223 }
224
7298a17Claude225 // 2FA pending — route the user to the code prompt instead of letting
226 // them access protected pages.
227 if (session.requires2fa) {
228 return c.redirect(
229 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
230 );
231 }
232
06d5ffeClaude233 const [user] = await db
234 .select()
235 .from(users)
236 .where(eq(users.id, session.userId))
237 .limit(1);
238
239 if (!user) {
240 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
241 }
242
243 c.set("user", user);
244 } catch {
245 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
246 }
247
248 return next();
249});