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