CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| 45e31d0 | 1 | /** |
| 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 | ||
| 8 | import { createMiddleware } from "hono/factory"; | |
| 9 | import { getCookie } from "hono/cookie"; | |
| 10 | import { eq, gt } from "drizzle-orm"; | |
| 11 | import { db } from "../db"; | |
| 12 | import { apiTokens, sessions, users } from "../db/schema"; | |
| 13 | import type { User } from "../db/schema"; | |
| 14 | ||
| 15 | export 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 | */ | |
| 27 | export 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); | |
| e75eddc | 32 | |
| 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 | ||
| 45e31d0 | 44 | 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 | |
| 3e8f8e8 | 91 | try { |
| 92 | const sessionToken = getCookie(c, "session"); | |
| 93 | if (sessionToken) { | |
| 45e31d0 | 94 | const [session] = await db |
| 95 | .select() | |
| 96 | .from(sessions) | |
| 97 | .where(eq(sessions.token, sessionToken)) | |
| 98 | .limit(1); | |
| 99 | ||
| 100 | if (session && new Date(session.expiresAt) >= new Date()) { | |
| 101 | const [user] = await db | |
| 102 | .select() | |
| 103 | .from(users) | |
| 104 | .where(eq(users.id, session.userId)) | |
| 105 | .limit(1); | |
| 106 | ||
| 107 | if (user) { | |
| 108 | c.set("user", user); | |
| 109 | c.set("authMethod", "session"); | |
| 110 | c.set("tokenScopes", ["repo", "user", "admin"]); | |
| 111 | return next(); | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 3e8f8e8 | 115 | } catch { |
| 116 | // DB unavailable — fall through to unauthenticated | |
| 45e31d0 | 117 | } |
| 118 | ||
| 119 | c.set("user", null); | |
| 120 | c.set("authMethod", "none"); | |
| 121 | c.set("tokenScopes", []); | |
| 122 | return next(); | |
| 123 | }); | |
| 124 | ||
| 125 | /** | |
| 126 | * Require authentication — returns 401 for API routes instead of redirecting. | |
| 127 | */ | |
| 128 | export const requireApiAuth = createMiddleware<ApiAuthEnv>(async (c, next) => { | |
| 129 | const user = c.get("user"); | |
| 130 | if (!user) { | |
| 131 | return c.json( | |
| 132 | { error: "Authentication required", hint: "Use Authorization: Bearer <token> header" }, | |
| 133 | 401 | |
| 134 | ); | |
| 135 | } | |
| 136 | return next(); | |
| 137 | }); | |
| 138 | ||
| 139 | /** | |
| 140 | * Require specific token scope. | |
| 141 | */ | |
| 142 | export function requireScope(scope: string) { | |
| 143 | return createMiddleware<ApiAuthEnv>(async (c, next) => { | |
| 144 | const scopes = c.get("tokenScopes") || []; | |
| 145 | if (!scopes.includes(scope) && !scopes.includes("admin")) { | |
| 146 | return c.json( | |
| 147 | { error: `Insufficient scope. Required: ${scope}` }, | |
| 148 | 403 | |
| 149 | ); | |
| 150 | } | |
| 151 | return next(); | |
| 152 | }); | |
| 153 | } |