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

api-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.

api-auth.tsBlame164 lines · 2 contributors
45e31d0Claude1/**
2 * API Token Authentication Middleware
3 *
4 * Validates Bearer tokens from the Authorization header against stored API tokens.
5 * Supports both session cookies (for web UI) and API tokens (for programmatic access).
6 */
7
8import { createMiddleware } from "hono/factory";
9import { getCookie } from "hono/cookie";
10import { eq, gt } from "drizzle-orm";
11import { db } from "../db";
12import { apiTokens, sessions, users } from "../db/schema";
13import type { User } from "../db/schema";
14
15export type ApiAuthEnv = {
16 Variables: {
17 user: User | null;
18 authMethod: "session" | "token" | "none";
19 tokenScopes: string[];
20 };
21};
22
23/**
24 * Authenticate via Bearer token OR session cookie.
25 * Sets c.get("user"), c.get("authMethod"), c.get("tokenScopes").
26 */
27export const apiAuth = createMiddleware<ApiAuthEnv>(async (c, next) => {
28 // Try Bearer token first
29 const authHeader = c.req.header("Authorization");
30 if (authHeader?.startsWith("Bearer ")) {
31 const token = authHeader.slice(7);
e75eddcClaude32
33 // Agent-multiplayer v1: `agentAuth` middleware (when mounted earlier
34 // in the chain) handles `agt_` tokens. Skip them here so we don't
35 // wrongly 401 a valid agent token. authMethod stays "none" because
36 // the agent context lives at c.get("agent"), not c.get("user").
37 if (token.startsWith("agt_")) {
38 c.set("user", null);
39 c.set("authMethod", "none");
40 c.set("tokenScopes", []);
41 return next();
42 }
43
45e31d0Claude44 try {
45 const hasher = new Bun.CryptoHasher("sha256");
46 hasher.update(token);
47 const tokenHash = hasher.digest("hex");
48
49 const [apiToken] = await db
50 .select()
51 .from(apiTokens)
52 .where(eq(apiTokens.tokenHash, tokenHash))
53 .limit(1);
54
55 if (!apiToken) {
56 return c.json({ error: "Invalid API token" }, 401);
57 }
58
59 // Check expiration
60 if (apiToken.expiresAt && new Date(apiToken.expiresAt) < new Date()) {
61 return c.json({ error: "API token expired" }, 401);
62 }
63
64 // Get user
65 const [user] = await db
66 .select()
67 .from(users)
68 .where(eq(users.id, apiToken.userId))
69 .limit(1);
70
71 if (!user) {
72 return c.json({ error: "Token owner not found" }, 401);
73 }
74
75 // Update last used
76 await db
77 .update(apiTokens)
78 .set({ lastUsedAt: new Date() })
79 .where(eq(apiTokens.id, apiToken.id));
80
81 c.set("user", user);
82 c.set("authMethod", "token");
83 c.set("tokenScopes", apiToken.scopes.split(",").map((s) => s.trim()));
84 return next();
85 } catch {
86 return c.json({ error: "Authentication failed" }, 401);
87 }
88 }
89
90 // Fall back to session cookie
3e8f8e8Claude91 try {
92 const sessionToken = getCookie(c, "session");
93 if (sessionToken) {
45e31d0Claude94 const [session] = await db
95 .select()
96 .from(sessions)
97 .where(eq(sessions.token, sessionToken))
98 .limit(1);
99
81807e1ccantynz-alt100 // `session.requires2fa` is set at password-verification time and only
101 // cleared once the second factor is accepted (routes/auth.tsx:565,674,
102 // 716). middleware/auth.ts refuses such a session in both its softAuth
103 // and requireAuth paths — this one only checked expiry, so a session
104 // that had passed the password step but NOT 2FA authenticated fully
105 // here, and was handed ["repo","user","admin"] scopes. That bypassed
106 // 2FA for the entire API surface using apiAuth.
107 if (
108 session &&
109 new Date(session.expiresAt) >= new Date() &&
110 !session.requires2fa
111 ) {
45e31d0Claude112 const [user] = await db
113 .select()
114 .from(users)
115 .where(eq(users.id, session.userId))
116 .limit(1);
117
118 if (user) {
119 c.set("user", user);
120 c.set("authMethod", "session");
121 c.set("tokenScopes", ["repo", "user", "admin"]);
122 return next();
123 }
124 }
125 }
3e8f8e8Claude126 } catch {
127 // DB unavailable — fall through to unauthenticated
45e31d0Claude128 }
129
130 c.set("user", null);
131 c.set("authMethod", "none");
132 c.set("tokenScopes", []);
133 return next();
134});
135
136/**
137 * Require authentication — returns 401 for API routes instead of redirecting.
138 */
139export const requireApiAuth = createMiddleware<ApiAuthEnv>(async (c, next) => {
140 const user = c.get("user");
141 if (!user) {
142 return c.json(
143 { error: "Authentication required", hint: "Use Authorization: Bearer <token> header" },
144 401
145 );
146 }
147 return next();
148});
149
150/**
151 * Require specific token scope.
152 */
153export function requireScope(scope: string) {
154 return createMiddleware<ApiAuthEnv>(async (c, next) => {
155 const scopes = c.get("tokenScopes") || [];
156 if (!scopes.includes(scope) && !scopes.includes("admin")) {
157 return c.json(
158 { error: `Insufficient scope. Required: ${scope}` },
159 403
160 );
161 }
162 return next();
163 });
164}