Openstatus
www.openstatus.dev
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}