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.tsBlame295 lines · 3 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";
28b52beccantynz-alt15import type { User, Repository } 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;
28b52beccantynz-alt33 /**
34 * Set by web.tsx's `assertRepoReadable` gate — the owner + repository rows
35 * it already fetched (and access-checked) for this request, so downstream
36 * handlers can reuse them instead of re-issuing the same lookups.
37 */
38 resolvedRepoCtx?: {
39 ownerRow: User;
40 repoRow: Repository;
41 access: string;
42 };
2316901Claude43 /** Set by `requireRepoAccess` — the caller's access level ('read'|'write'|'admin'|'owner'). */
44 repoAccess?: string;
45 /** Set by API-auth middleware — how the caller authenticated. */
46 authMethod?: "token" | "session" | "none";
47 /** Set by API-auth middleware — scopes carried by the PAT or session. */
48 tokenScopes?: string[];
b5a7bb3ccanty labs49 /**
50 * Set by softAuth when an `Authorization: Bearer glct_/glc_` token was
51 * presented but rejected (expired, revoked, or unknown). Routes use this
52 * to emit an RFC 6750 `error="invalid_token"` challenge instead of
53 * treating the caller as anonymous.
54 */
55 bearerInvalid?: boolean;
06d5ffeClaude56 };
57};
58
058d752Claude59async function loadUserFromBearer(
60 token: string
61): Promise<{ user: User; scopes: string[]; appId: string } | null> {
62 if (!token.startsWith("glct_")) return null;
63 try {
64 const hash = await sha256Hex(token);
65 const [row] = await db
66 .select()
67 .from(oauthAccessTokens)
68 .where(eq(oauthAccessTokens.accessTokenHash, hash))
69 .limit(1);
70 if (!row) return null;
71 if (row.revokedAt) return null;
72 if (new Date(row.expiresAt) < new Date()) return null;
73 const [user] = await db
74 .select()
75 .from(users)
76 .where(eq(users.id, row.userId))
77 .limit(1);
78 if (!user) return null;
a28cedeClaude79 // Best-effort: update lastUsedAt. Never fail auth on this — but log so
80 // a persistent DB write failure surfaces (was a silent .catch(() => {}).)
058d752Claude81 db
82 .update(oauthAccessTokens)
83 .set({ lastUsedAt: new Date() })
84 .where(eq(oauthAccessTokens.id, row.id))
a28cedeClaude85 .catch((err) => {
86 console.warn(
87 "[auth] oauth lastUsedAt update failed:",
88 err instanceof Error ? err.message : err
89 );
90 });
058d752Claude91 return {
92 user,
93 scopes: row.scopes ? row.scopes.split(/\s+/).filter(Boolean) : [],
94 appId: row.appId,
95 };
96 } catch {
97 return null;
98 }
99}
100
25a91a6Claude101/**
102 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
103 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
104 * Scopes are comma-separated in the row; return as array so callers can check.
105 */
106async function loadUserFromPat(
107 token: string
108): Promise<{ user: User; scopes: string[] } | null> {
109 if (!token.startsWith("glc_")) return null;
110 try {
111 const hash = await sha256Hex(token);
112 const [row] = await db
113 .select()
114 .from(apiTokens)
115 .where(eq(apiTokens.tokenHash, hash))
116 .limit(1);
117 if (!row) return null;
118 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
119 const [user] = await db
120 .select()
121 .from(users)
122 .where(eq(users.id, row.userId))
123 .limit(1);
124 if (!user) return null;
125 db
126 .update(apiTokens)
127 .set({ lastUsedAt: new Date() })
128 .where(eq(apiTokens.id, row.id))
a28cedeClaude129 .catch((err) => {
130 console.warn(
131 "[auth] PAT lastUsedAt update failed:",
132 err instanceof Error ? err.message : err
133 );
134 });
25a91a6Claude135 return {
136 user,
137 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
138 };
139 } catch {
140 return null;
141 }
142}
143
06d5ffeClaude144/**
145 * Soft auth — sets c.get("user") to the current user or null.
146 * Does NOT block unauthenticated requests.
05b973eClaude147 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
06d5ffeClaude148 */
149export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude150 // B6: Bearer token takes precedence over cookie for API calls.
151 const authHeader = c.req.header("authorization") || "";
152 if (authHeader.toLowerCase().startsWith("bearer ")) {
153 const bearer = authHeader.slice(7).trim();
154 if (bearer.startsWith("glct_")) {
155 const result = await loadUserFromBearer(bearer);
156 if (result) {
157 c.set("user", result.user);
158 c.set("oauthScopes", result.scopes);
159 c.set("oauthAppId", result.appId);
160 return next();
161 }
b5a7bb3ccanty labs162 // Token presented but rejected — stay soft (cookie fallback still
163 // runs), but flag it so routes can signal invalid_token per RFC 6750.
164 c.set("bearerInvalid", true);
25a91a6Claude165 } else if (bearer.startsWith("glc_")) {
166 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
167 const result = await loadUserFromPat(bearer);
168 if (result) {
169 c.set("user", result.user);
170 c.set("oauthScopes", result.scopes);
171 return next();
172 }
b5a7bb3ccanty labs173 c.set("bearerInvalid", true);
058d752Claude174 }
175 }
176
06d5ffeClaude177 const token = getCookie(c, "session");
178 if (!token) {
179 c.set("user", null);
180 return next();
181 }
182
05b973eClaude183 // Check session cache first
184 const cachedUser = sessionCache.get(token) as User | null | undefined;
185 if (cachedUser !== undefined) {
186 c.set("user", cachedUser);
187 return next();
188 }
189
06d5ffeClaude190 try {
191 const [session] = await db
192 .select()
193 .from(sessions)
194 .where(eq(sessions.token, token))
195 .limit(1);
196
7298a17Claude197 if (
198 !session ||
199 new Date(session.expiresAt) < new Date() ||
200 session.requires2fa
201 ) {
05b973eClaude202 sessionCache.set(token, null as any);
06d5ffeClaude203 c.set("user", null);
204 return next();
205 }
206
207 const [user] = await db
208 .select()
209 .from(users)
210 .where(eq(users.id, session.userId))
211 .limit(1);
212
05b973eClaude213 // Cache the result (user or null)
214 sessionCache.set(token, (user || null) as any);
06d5ffeClaude215 c.set("user", user || null);
216 } catch {
217 c.set("user", null);
218 }
219
220 return next();
221});
222
223/**
224 * Hard auth — redirects to /login if not authenticated.
225 */
226export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude227 // B6: Bearer token takes precedence over cookie for API calls.
228 const authHeader = c.req.header("authorization") || "";
229 if (authHeader.toLowerCase().startsWith("bearer ")) {
230 const bearer = authHeader.slice(7).trim();
231 if (bearer.startsWith("glct_")) {
232 const result = await loadUserFromBearer(bearer);
233 if (result) {
234 c.set("user", result.user);
235 c.set("oauthScopes", result.scopes);
236 c.set("oauthAppId", result.appId);
237 return next();
238 }
239 // Bearer token was presented but invalid — return 401 instead of
240 // redirecting to /login (API clients don't follow HTML redirects).
241 return c.json({ error: "Invalid or expired token" }, 401);
242 }
25a91a6Claude243 if (bearer.startsWith("glc_")) {
244 // C2: Personal access tokens for CLI clients.
245 const result = await loadUserFromPat(bearer);
246 if (result) {
247 c.set("user", result.user);
248 c.set("oauthScopes", result.scopes);
249 return next();
250 }
251 return c.json({ error: "Invalid or expired token" }, 401);
252 }
058d752Claude253 }
254
06d5ffeClaude255 const token = getCookie(c, "session");
256 if (!token) {
257 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
258 }
259
260 try {
261 const [session] = await db
262 .select()
263 .from(sessions)
264 .where(eq(sessions.token, token))
265 .limit(1);
266
267 if (!session || new Date(session.expiresAt) < new Date()) {
268 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
269 }
270
7298a17Claude271 // 2FA pending — route the user to the code prompt instead of letting
272 // them access protected pages.
273 if (session.requires2fa) {
274 return c.redirect(
275 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
276 );
277 }
278
06d5ffeClaude279 const [user] = await db
280 .select()
281 .from(users)
282 .where(eq(users.id, session.userId))
283 .limit(1);
284
285 if (!user) {
286 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
287 }
288
289 c.set("user", user);
290 } catch {
291 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
292 }
293
294 return next();
295});