Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 185 lines 5.3 kB view raw
1import { UnkeyCore } from "@unkey/api/core"; 2import { keysVerifyKey } from "@unkey/api/funcs/keysVerifyKey"; 3import type { Context, Next } from "hono"; 4 5import { env } from "@/env"; 6import { OpenStatusApiError } from "@/libs/errors"; 7import type { Variables } from "@/types"; 8import { getLogger } from "@logtape/logtape"; 9import { db, eq } from "@openstatus/db"; 10import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; 11import { apiKey } from "@openstatus/db/src/schema/api-keys"; 12import { 13 shouldUpdateLastUsed, 14 verifyApiKeyHash, 15} from "@openstatus/db/src/utils/api-key"; 16 17const logger = getLogger("api-server-otel"); 18 19export async function authMiddleware( 20 c: Context<{ Variables: Variables }, "/*">, 21 next: Next, 22) { 23 const key = c.req.header("x-openstatus-key"); 24 if (!key) 25 throw new OpenStatusApiError({ 26 code: "UNAUTHORIZED", 27 message: "Missing 'x-openstatus-key' header", 28 }); 29 30 const { error, result } = await validateKey(key); 31 32 if (error) { 33 throw new OpenStatusApiError({ 34 code: "UNAUTHORIZED", 35 message: error.message, 36 }); 37 } 38 39 if (!result.valid || !result.ownerId) { 40 throw new OpenStatusApiError({ 41 code: "UNAUTHORIZED", 42 message: "Invalid API Key", 43 }); 44 } 45 46 const ownerId = Number.parseInt(result.ownerId); 47 48 if (Number.isNaN(ownerId)) { 49 throw new OpenStatusApiError({ 50 code: "UNAUTHORIZED", 51 message: "API Key is Not a Number", 52 }); 53 } 54 55 const _workspace = await db 56 .select() 57 .from(workspace) 58 .where(eq(workspace.id, ownerId)) 59 .get(); 60 61 if (!_workspace) { 62 logger.error("Workspace not found for ownerId {ownerId}", { ownerId }); 63 throw new OpenStatusApiError({ 64 code: "NOT_FOUND", 65 message: "Workspace not found, please contact support", 66 }); 67 } 68 69 const validation = selectWorkspaceSchema.safeParse(_workspace); 70 71 if (!validation.success) { 72 throw new OpenStatusApiError({ 73 code: "BAD_REQUEST", 74 message: "Workspace data is invalid", 75 }); 76 } 77 78 // Enrich wide event with business context 79 const event = c.get("event"); 80 event.workspace = { 81 id: validation.data.id, 82 name: validation.data.name, 83 plan: validation.data.plan, 84 stripe_id: validation.data.stripeId, 85 }; 86 event.auth_method = result.authMethod; 87 88 c.set("workspace", validation.data); 89 c.set("event", event); 90 await next(); 91} 92 93async function validateKey(key: string): Promise<{ 94 result: { valid: boolean; ownerId?: string; authMethod?: string }; 95 error?: { message: string }; 96}> { 97 if (env.NODE_ENV === "production") { 98 /** 99 * Both custom and Unkey API keys use the `os_` prefix for seamless transition. 100 * Custom keys are checked first in the database, then falls back to Unkey. 101 */ 102 if (key.startsWith("os_")) { 103 // Validate token format before database query 104 // if (!/^os_[a-f0-9]{32}$/.test(key)) { 105 // return { 106 // result: { valid: false }, 107 // error: { message: "Invalid API Key format" }, 108 // }; 109 // } 110 111 // 1. Try custom DB first 112 const prefix = key.slice(0, 11); // "os_" (3 chars) + 8 hex chars = 11 total 113 const customKey = await db 114 .select() 115 .from(apiKey) 116 .where(eq(apiKey.prefix, prefix)) 117 .get(); 118 119 if (customKey) { 120 // Verify hash using bcrypt-compatible verification 121 if (!(await verifyApiKeyHash(key, customKey.hashedToken))) { 122 return { 123 result: { valid: false }, 124 error: { message: "Invalid API Key" }, 125 }; 126 } 127 // Check expiration 128 if (customKey.expiresAt && customKey.expiresAt < new Date()) { 129 return { 130 result: { valid: false }, 131 error: { message: "API Key expired" }, 132 }; 133 } 134 135 // Update lastUsedAt (debounced) 136 if (shouldUpdateLastUsed(customKey.lastUsedAt)) { 137 await db 138 .update(apiKey) 139 .set({ lastUsedAt: new Date() }) 140 .where(eq(apiKey.id, customKey.id)); 141 } 142 return { 143 result: { 144 valid: true, 145 ownerId: String(customKey.workspaceId), 146 authMethod: "custom_key", 147 }, 148 }; 149 } 150 151 // 2. Fall back to Unkey (transition period) 152 const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN }); 153 const res = await keysVerifyKey(unkey, { key }); 154 if (!res.ok) { 155 logger.error("Unkey Error {*}", { ...res.error }); 156 return { 157 result: { valid: false, ownerId: undefined }, 158 error: { message: "Invalid API verification" }, 159 }; 160 } 161 return { 162 result: { 163 valid: res.value.data.valid, 164 ownerId: res.value.data.identity?.externalId, 165 authMethod: "unkey", 166 }, 167 error: undefined, 168 }; 169 } 170 // Special bypass for our workspace 171 if (key.startsWith("sa_") && key === env.SUPER_ADMIN_TOKEN) { 172 return { 173 result: { valid: true, ownerId: "1", authMethod: "super_admin" }, 174 }; 175 } 176 // In production, we only accept Unkey keys 177 throw new OpenStatusApiError({ 178 code: "UNAUTHORIZED", 179 message: "Invalid API Key", 180 }); 181 } 182 183 // In dev / test mode we can use the key as the ownerId 184 return { result: { valid: true, ownerId: key, authMethod: "dev" } }; 185}