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.tsBlame193 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";
058d752Claude14import { sessions, users, oauthAccessTokens } 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
06d5ffeClaude64/**
65 * Soft auth — sets c.get("user") to the current user or null.
66 * Does NOT block unauthenticated requests.
05b973eClaude67 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
06d5ffeClaude68 */
69export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude70 // B6: Bearer token takes precedence over cookie for API calls.
71 const authHeader = c.req.header("authorization") || "";
72 if (authHeader.toLowerCase().startsWith("bearer ")) {
73 const bearer = authHeader.slice(7).trim();
74 if (bearer.startsWith("glct_")) {
75 const result = await loadUserFromBearer(bearer);
76 if (result) {
77 c.set("user", result.user);
78 c.set("oauthScopes", result.scopes);
79 c.set("oauthAppId", result.appId);
80 return next();
81 }
82 }
83 }
84
06d5ffeClaude85 const token = getCookie(c, "session");
86 if (!token) {
87 c.set("user", null);
88 return next();
89 }
90
05b973eClaude91 // Check session cache first
92 const cachedUser = sessionCache.get(token) as User | null | undefined;
93 if (cachedUser !== undefined) {
94 c.set("user", cachedUser);
95 return next();
96 }
97
06d5ffeClaude98 try {
99 const [session] = await db
100 .select()
101 .from(sessions)
102 .where(eq(sessions.token, token))
103 .limit(1);
104
7298a17Claude105 if (
106 !session ||
107 new Date(session.expiresAt) < new Date() ||
108 session.requires2fa
109 ) {
05b973eClaude110 sessionCache.set(token, null as any);
06d5ffeClaude111 c.set("user", null);
112 return next();
113 }
114
115 const [user] = await db
116 .select()
117 .from(users)
118 .where(eq(users.id, session.userId))
119 .limit(1);
120
05b973eClaude121 // Cache the result (user or null)
122 sessionCache.set(token, (user || null) as any);
06d5ffeClaude123 c.set("user", user || null);
124 } catch {
125 c.set("user", null);
126 }
127
128 return next();
129});
130
131/**
132 * Hard auth — redirects to /login if not authenticated.
133 */
134export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
058d752Claude135 // B6: Bearer token takes precedence over cookie for API calls.
136 const authHeader = c.req.header("authorization") || "";
137 if (authHeader.toLowerCase().startsWith("bearer ")) {
138 const bearer = authHeader.slice(7).trim();
139 if (bearer.startsWith("glct_")) {
140 const result = await loadUserFromBearer(bearer);
141 if (result) {
142 c.set("user", result.user);
143 c.set("oauthScopes", result.scopes);
144 c.set("oauthAppId", result.appId);
145 return next();
146 }
147 // Bearer token was presented but invalid — return 401 instead of
148 // redirecting to /login (API clients don't follow HTML redirects).
149 return c.json({ error: "Invalid or expired token" }, 401);
150 }
151 }
152
06d5ffeClaude153 const token = getCookie(c, "session");
154 if (!token) {
155 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
156 }
157
158 try {
159 const [session] = await db
160 .select()
161 .from(sessions)
162 .where(eq(sessions.token, token))
163 .limit(1);
164
165 if (!session || new Date(session.expiresAt) < new Date()) {
166 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
167 }
168
7298a17Claude169 // 2FA pending — route the user to the code prompt instead of letting
170 // them access protected pages.
171 if (session.requires2fa) {
172 return c.redirect(
173 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
174 );
175 }
176
06d5ffeClaude177 const [user] = await db
178 .select()
179 .from(users)
180 .where(eq(users.id, session.userId))
181 .limit(1);
182
183 if (!user) {
184 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
185 }
186
187 c.set("user", user);
188 } catch {
189 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
190 }
191
192 return next();
193});