Skip to content

Commit 61e0c58

Browse files
committed
Add api keys
1 parent 76d76f8 commit 61e0c58

5 files changed

Lines changed: 250 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "openworkers-api",
3-
"version": "1.1.7",
3+
"version": "1.1.8",
44
"license": "MIT",
55
"module": "src/index.ts",
66
"type": "module",

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import databases from './routes/databases';
1212
import kv from './routes/kv';
1313
import storage from './routes/storage';
1414
import ai from './routes/ai';
15+
import apiKeys from './routes/api-keys';
1516
import pkg from '../package.json';
1617
import { sql } from './services/db/client';
1718

@@ -67,6 +68,7 @@ v1.route('/databases', databases);
6768
v1.route('/kv', kv);
6869
v1.route('/storage', storage);
6970
v1.route('/ai', ai);
71+
v1.route('/api-keys', apiKeys);
7072
v1.route('/', users);
7173

7274
api.route('/v1', v1);

src/middlewares/auth.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,60 @@
11
import { jwt } from 'hono/jwt';
2+
import type { Context, Next } from 'hono';
23
import type { JWTPayload } from '../types';
34
import { jwt as jwtConfig } from '../config';
45
import { HTTPException } from 'hono/http-exception';
6+
import { findApiKeyByToken, updateApiKeyLastUsed } from '../services/db/api-keys';
57

68
declare module 'hono' {
79
interface ContextVariableMap {
810
userId: string;
911
username: string;
1012
jwtPayload: JWTPayload;
13+
authMethod: 'jwt' | 'api_key';
1114
}
1215
}
1316

1417
// Create JWT middleware with secret from config
15-
export function createAuthMiddleware() {
18+
export function createJwtMiddleware() {
1619
return jwt({
1720
secret: jwtConfig.access.secret,
1821
cookie: 'access_token' // Also check cookie for token
1922
});
2023
}
2124

25+
// Combined auth middleware: API key first, then JWT
26+
export function createAuthMiddleware() {
27+
const jwtMiddleware = createJwtMiddleware();
28+
29+
return async (c: Context, next: Next) => {
30+
const authHeader = c.req.header('Authorization');
31+
32+
// Check for API key (Bearer ow_...)
33+
if (authHeader?.startsWith('Bearer ow_')) {
34+
const token = authHeader.substring(7);
35+
const apiKey = await findApiKeyByToken(token);
36+
37+
if (!apiKey) {
38+
return c.json({ error: 'Invalid API key' }, 401);
39+
}
40+
41+
// Set user context
42+
c.set('userId', apiKey.userId);
43+
c.set('authMethod', 'api_key');
44+
45+
// Update last used (fire and forget)
46+
updateApiKeyLastUsed(apiKey.id).catch(() => {});
47+
48+
return next();
49+
}
50+
51+
// Fall back to JWT
52+
return jwtMiddleware(c, next);
53+
};
54+
}
55+
2256
// Error handler for JWT errors - returns JSON instead of text
23-
export async function errorHandler(err: Error, c: any) {
57+
export async function errorHandler(err: Error, c: Context) {
2458
if (err instanceof HTTPException && err.status === 401) {
2559
return c.json({ error: 'Unauthorized', message: err.message }, 401);
2660
} else if (err instanceof HTTPException) {
@@ -30,14 +64,21 @@ export async function errorHandler(err: Error, c: any) {
3064
throw err;
3165
}
3266

33-
// Middleware to extract userId from JWT payload
34-
export async function extractUser(c: any, next: any) {
67+
// Middleware to extract userId from JWT payload (only needed for JWT auth)
68+
export async function extractUser(c: Context, next: Next) {
69+
// Skip if already authenticated via API key
70+
if (c.get('authMethod') === 'api_key') {
71+
return next();
72+
}
73+
3574
const payload = c.get('jwtPayload') as JWTPayload;
75+
3676
if (!payload) {
3777
return c.json({ error: 'Unauthorized' }, 401);
3878
}
3979

4080
c.set('userId', payload.sub);
81+
c.set('authMethod', 'jwt');
4182

4283
await next();
4384
}

src/routes/api-keys.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Hono } from 'hono';
2+
import { z, ZodError } from 'zod';
3+
import { createApiKey, listApiKeys, deleteApiKey } from '../services/db/api-keys';
4+
5+
const apiKeys = new Hono();
6+
7+
// Schema for creating an API key
8+
const CreateApiKeySchema = z.object({
9+
name: z.string().min(1).max(100),
10+
expiresAt: z.string().datetime().optional()
11+
});
12+
13+
// POST /api-keys - Create a new API key
14+
apiKeys.post('/', async (c) => {
15+
try {
16+
const userId = c.get('userId');
17+
const body = await c.req.json();
18+
const input = CreateApiKeySchema.parse(body);
19+
20+
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : undefined;
21+
const { apiKey, token } = await createApiKey(userId, input.name, expiresAt);
22+
23+
// Return the full token - this is the ONLY time it's available
24+
return c.json({
25+
id: apiKey.id,
26+
name: apiKey.name,
27+
tokenPrefix: apiKey.tokenPrefix,
28+
token, // Full token - user must save this!
29+
expiresAt: apiKey.expiresAt?.toISOString() ?? null,
30+
createdAt: apiKey.createdAt.toISOString()
31+
}, 201);
32+
} catch (error) {
33+
if (error instanceof ZodError) {
34+
return c.json({ error: 'Invalid input', details: error.issues }, 400);
35+
}
36+
37+
console.error('Failed to create API key:', error);
38+
return c.json({ error: 'Failed to create API key' }, 500);
39+
}
40+
});
41+
42+
// GET /api-keys - List user's API keys
43+
apiKeys.get('/', async (c) => {
44+
try {
45+
const userId = c.get('userId');
46+
const keys = await listApiKeys(userId);
47+
48+
return c.json(keys.map(key => ({
49+
id: key.id,
50+
name: key.name,
51+
tokenPrefix: key.tokenPrefix,
52+
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
53+
expiresAt: key.expiresAt?.toISOString() ?? null,
54+
createdAt: key.createdAt.toISOString()
55+
})));
56+
} catch (error) {
57+
console.error('Failed to list API keys:', error);
58+
return c.json({ error: 'Failed to list API keys' }, 500);
59+
}
60+
});
61+
62+
// DELETE /api-keys/:id - Delete an API key
63+
apiKeys.delete('/:id', async (c) => {
64+
try {
65+
const userId = c.get('userId');
66+
const keyId = c.req.param('id');
67+
68+
const deleted = await deleteApiKey(userId, keyId);
69+
70+
if (!deleted) {
71+
return c.json({ error: 'API key not found' }, 404);
72+
}
73+
74+
return c.json({ message: 'API key deleted' });
75+
} catch (error) {
76+
console.error('Failed to delete API key:', error);
77+
return c.json({ error: 'Failed to delete API key' }, 500);
78+
}
79+
});
80+
81+
export default apiKeys;

src/services/db/api-keys.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { sql } from './client';
2+
3+
export interface ApiKey {
4+
id: string;
5+
userId: string;
6+
name: string;
7+
tokenPrefix: string;
8+
lastUsedAt: Date | null;
9+
expiresAt: Date | null;
10+
createdAt: Date;
11+
}
12+
13+
interface ApiKeyRow {
14+
id: string;
15+
user_id: string;
16+
name: string;
17+
token_prefix: string;
18+
last_used_at: string | null;
19+
expires_at: string | null;
20+
created_at: string;
21+
}
22+
23+
function rowToApiKey(row: ApiKeyRow): ApiKey {
24+
return {
25+
id: row.id,
26+
userId: row.user_id,
27+
name: row.name,
28+
tokenPrefix: row.token_prefix,
29+
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : null,
30+
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
31+
createdAt: new Date(row.created_at)
32+
};
33+
}
34+
35+
// Generate a random API key token
36+
function generateToken(): string {
37+
const bytes = new Uint8Array(24);
38+
crypto.getRandomValues(bytes);
39+
const random = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
40+
return `ow_${random}`;
41+
}
42+
43+
// Hash token using SHA-256
44+
async function hashToken(token: string): Promise<string> {
45+
const encoder = new TextEncoder();
46+
const data = encoder.encode(token);
47+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
48+
const hashArray = Array.from(new Uint8Array(hashBuffer));
49+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
50+
}
51+
52+
// Create a new API key - returns the full token (only time it's available)
53+
export async function createApiKey(
54+
userId: string,
55+
name: string,
56+
expiresAt?: Date
57+
): Promise<{ apiKey: ApiKey; token: string }> {
58+
const token = generateToken();
59+
const tokenPrefix = token.substring(0, 12);
60+
const tokenHash = await hashToken(token);
61+
62+
const rows = await sql<ApiKeyRow>(
63+
`INSERT INTO api_keys (user_id, name, token_prefix, token_hash, expires_at)
64+
VALUES ($1::uuid, $2, $3, $4, $5::timestamptz)
65+
RETURNING id, user_id, name, token_prefix, last_used_at, expires_at, created_at`,
66+
[userId, name, tokenPrefix, tokenHash, expiresAt?.toISOString() ?? null]
67+
);
68+
69+
return {
70+
apiKey: rowToApiKey(rows[0]!),
71+
token
72+
};
73+
}
74+
75+
// Find API key by token (for authentication)
76+
export async function findApiKeyByToken(token: string): Promise<ApiKey | null> {
77+
const tokenHash = await hashToken(token);
78+
79+
const rows = await sql<ApiKeyRow>(
80+
`SELECT id, user_id, name, token_prefix, last_used_at, expires_at, created_at
81+
FROM api_keys
82+
WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())`,
83+
[tokenHash]
84+
);
85+
86+
return rows[0] ? rowToApiKey(rows[0]) : null;
87+
}
88+
89+
// List user's API keys
90+
export async function listApiKeys(userId: string): Promise<ApiKey[]> {
91+
const rows = await sql<ApiKeyRow>(
92+
`SELECT id, user_id, name, token_prefix, last_used_at, expires_at, created_at
93+
FROM api_keys
94+
WHERE user_id = $1::uuid
95+
ORDER BY created_at DESC`,
96+
[userId]
97+
);
98+
99+
return rows.map(rowToApiKey);
100+
}
101+
102+
// Delete an API key
103+
export async function deleteApiKey(userId: string, keyId: string): Promise<boolean> {
104+
const result = await sql<{ count: string }>(
105+
`WITH deleted AS (
106+
DELETE FROM api_keys WHERE id = $1::uuid AND user_id = $2::uuid RETURNING *
107+
)
108+
SELECT COUNT(*) as count FROM deleted`,
109+
[keyId, userId]
110+
);
111+
112+
return parseInt(result[0]?.count ?? '0', 10) > 0;
113+
}
114+
115+
// Update last used timestamp
116+
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
117+
await sql(
118+
`UPDATE api_keys SET last_used_at = NOW() WHERE id = $1::uuid`,
119+
[keyId]
120+
);
121+
}

0 commit comments

Comments
 (0)