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.tsBlame263 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;
62 // Best-effort: update lastUsedAt. Never fail auth on this.
63 db
64 .update(oauthAccessTokens)
65 .set({ lastUsedAt: new Date() })
66 .where(eq(oauthAccessTokens.id, row.id))
67 .catch(() => {});
68 return {
69 user,
70 scopes: row.scopes ? row.scopes.split(/\s+/).filter(Boolean) : [],
71 appId: row.appId,
72 };
73 } catch {
74 return null;
75 }
76}
77
25a91a6Claude78/**
79 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
80 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
81 * Scopes are comma-separated in the row; return as array so callers can check.
82 */
83async function loadUserFromPat(
84 token: string
85): Promise<{ user: User; scopes: string[] } | null> {
86 if (!token.startsWith("glc_")) return null;
87 try {
88 const hash = await sha256Hex(token);
89 const [row] = await db
90 .select()
91 .from(apiTokens)
92 .where(eq(apiTokens.tokenHash, hash))
93 .limit(1);
94 if (!row) return null;
95 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
96 const [user] = await db
97 .select()
98 .from(users)
99 .where(eq(users.id, row.userId))
100 .limit(1);
101 if (!user) return null;
102 db
103 .update(apiTokens)
104 .set({ lastUsedAt: new Date() })
105 .where(eq(apiTokens.id, row.id))
106 .catch(() => {});
107 return {
108 user,
109 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
110 };
111 } catch {
112 return null;
113 }
114}
115
06d5ffeClaude116/**
117 * Soft auth — sets c.get("user") to the current user or null.
118 * Does NOT block unauthenticated requests.
05b973eClaude119 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
06d5ffeClaude120 */
121export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude122 // B6: Bearer token takes precedence over cookie for API calls.
123 const authHeader = c.req.header("authorization") || "";
124 if (authHeader.toLowerCase().startsWith("bearer ")) {
125 const bearer = authHeader.slice(7).trim();
126 if (bearer.startsWith("glct_")) {
127 const result = await loadUserFromBearer(bearer);
128 if (result) {
129 c.set("user", result.user);
130 c.set("oauthScopes", result.scopes);
131 c.set("oauthAppId", result.appId);
132 return next();
133 }
25a91a6Claude134 } else if (bearer.startsWith("glc_")) {
135 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
136 const result = await loadUserFromPat(bearer);
137 if (result) {
138 c.set("user", result.user);
139 c.set("oauthScopes", result.scopes);
140 return next();
141 }
058d752Claude142 }
143 }
144
06d5ffeClaude145 const token = getCookie(c, "session");
146 if (!token) {
147 c.set("user", null);
148 return next();
149 }
150
05b973eClaude151 // Check session cache first
152 const cachedUser = sessionCache.get(token) as User | null | undefined;
153 if (cachedUser !== undefined) {
154 c.set("user", cachedUser);
155 return next();
156 }
157
06d5ffeClaude158 try {
159 const [session] = await db
160 .select()
161 .from(sessions)
162 .where(eq(sessions.token, token))
163 .limit(1);
164
7298a17Claude165 if (
166 !session ||
167 new Date(session.expiresAt) < new Date() ||
168 session.requires2fa
169 ) {
05b973eClaude170 sessionCache.set(token, null as any);
06d5ffeClaude171 c.set("user", null);
172 return next();
173 }
174
175 const [user] = await db
176 .select()
177 .from(users)
178 .where(eq(users.id, session.userId))
179 .limit(1);
180
05b973eClaude181 // Cache the result (user or null)
182 sessionCache.set(token, (user || null) as any);
06d5ffeClaude183 c.set("user", user || null);
184 } catch {
185 c.set("user", null);
186 }
187
188 return next();
189});
190
191/**
192 * Hard auth — redirects to /login if not authenticated.
193 */
194export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude195 // B6: Bearer token takes precedence over cookie for API calls.
196 const authHeader = c.req.header("authorization") || "";
197 if (authHeader.toLowerCase().startsWith("bearer ")) {
198 const bearer = authHeader.slice(7).trim();
199 if (bearer.startsWith("glct_")) {
200 const result = await loadUserFromBearer(bearer);
201 if (result) {
202 c.set("user", result.user);
203 c.set("oauthScopes", result.scopes);
204 c.set("oauthAppId", result.appId);
205 return next();
206 }
207 // Bearer token was presented but invalid — return 401 instead of
208 // redirecting to /login (API clients don't follow HTML redirects).
209 return c.json({ error: "Invalid or expired token" }, 401);
210 }
25a91a6Claude211 if (bearer.startsWith("glc_")) {
212 // C2: Personal access tokens for CLI clients.
213 const result = await loadUserFromPat(bearer);
214 if (result) {
215 c.set("user", result.user);
216 c.set("oauthScopes", result.scopes);
217 return next();
218 }
219 return c.json({ error: "Invalid or expired token" }, 401);
220 }
058d752Claude221 }
222
06d5ffeClaude223 const token = getCookie(c, "session");
224 if (!token) {
225 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
226 }
227
228 try {
229 const [session] = await db
230 .select()
231 .from(sessions)
232 .where(eq(sessions.token, token))
233 .limit(1);
234
235 if (!session || new Date(session.expiresAt) < new Date()) {
236 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
237 }
238
7298a17Claude239 // 2FA pending — route the user to the code prompt instead of letting
240 // them access protected pages.
241 if (session.requires2fa) {
242 return c.redirect(
243 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
244 );
245 }
246
06d5ffeClaude247 const [user] = await db
248 .select()
249 .from(users)
250 .where(eq(users.id, session.userId))
251 .limit(1);
252
253 if (!user) {
254 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
255 }
256
257 c.set("user", user);
258 } catch {
259 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
260 }
261
262 return next();
263});