Skip to content

Commit f74f069

Browse files
committed
Merge PR #261: feature/api-keys-endpoints
2 parents 4eb36e6 + f6640f4 commit f74f069

8 files changed

Lines changed: 514 additions & 28 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Add fields for API key auth, scopes, and usage tracking
2+
alter table public.api_keys
3+
add column if not exists key_hash text,
4+
add column if not exists scopes text[] default '{}' not null,
5+
add column if not exists revoked boolean default false not null,
6+
add column if not exists last_used_at timestamptz,
7+
add column if not exists request_count integer default 0 not null;
8+
9+
alter table public.api_keys
10+
add constraint if not exists api_keys_key_hash_unique unique(key_hash);

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import auditRoutes from './routes/audit';
2222
import webhookRoutes from './routes/webhooks';
2323
import complianceRoutes from './routes/compliance';
2424
import tagsRoutes from './routes/tags';
25+
import apiKeysRoutes from './routes/api-keys';
2526
import { createExchangeRatesRouter } from './routes/exchange-rates';
2627
import { ExchangeRateService } from './services/exchange-rate/exchange-rate-service';
2728
import { FiatRateProvider } from './services/exchange-rate/fiat-provider';
@@ -76,6 +77,7 @@ app.get('/health', (req, res) => {
7677
});
7778

7879
// API Routes
80+
app.use('/api/keys', apiKeysRoutes);
7981
app.use('/api/subscriptions', subscriptionRoutes);
8082
app.use('/api/risk-score', riskScoreRoutes);
8183
app.use('/api/simulation', simulationRoutes);

backend/src/middleware/auth.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from 'crypto';
12
import { Request, Response, NextFunction } from 'express';
23
import { supabase } from '../config/database';
34
import logger from '../config/logger';
@@ -11,27 +12,112 @@ export interface AuthenticatedRequest extends Request {
1112
id: string;
1213
email: string;
1314
role: UserRole;
15+
email?: string;
16+
authMethod?: 'jwt' | 'api_key';
17+
scopes?: string[];
1418
};
1519
}
1620

21+
const API_KEY_SCOPES = new Set([
22+
'subscriptions:read',
23+
'subscriptions:write',
24+
'webhooks:write',
25+
'analytics:read',
26+
]);
27+
28+
export function requireScope(requiredScope: string | string[]) {
29+
const requiredScopes = Array.isArray(requiredScope) ? requiredScope : [requiredScope];
30+
31+
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
32+
if (!req.user) {
33+
res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' });
34+
return;
35+
}
36+
37+
if (req.user.authMethod === 'api_key') {
38+
const keyScopes = req.user.scopes || [];
39+
const missing = requiredScopes.filter((scope) => !keyScopes.includes(scope));
40+
41+
if (missing.length > 0) {
42+
res.status(403).json({
43+
error: 'Forbidden',
44+
message: `API key missing required scopes: ${missing.join(', ')}`,
45+
});
46+
return;
47+
}
48+
}
49+
50+
next();
51+
};
52+
}
53+
54+
async function authenticateWithApiKey(
55+
req: AuthenticatedRequest,
56+
res: Response,
57+
next: NextFunction,
58+
): Promise<boolean> {
59+
const apiKey = req.headers['x-api-key'] as string;
60+
61+
if (!apiKey) {
62+
return false;
63+
}
64+
65+
const hash = crypto.createHash('sha256').update(apiKey).digest('hex');
66+
67+
const { data: keyRecord, error } = await supabase
68+
.from('api_keys')
69+
.select('user_id, scopes, revoked, last_used_at, request_count')
70+
.eq('key_hash', hash)
71+
.eq('revoked', false)
72+
.single();
73+
74+
if (error || !keyRecord) {
75+
res.status(401).json({ error: 'Invalid API key' });
76+
return true;
77+
}
78+
79+
// Update last_used_at and request_count
80+
await supabase
81+
.from('api_keys')
82+
.update({
83+
last_used_at: new Date().toISOString(),
84+
request_count: (keyRecord.request_count ?? 0) + 1,
85+
})
86+
.eq('key_hash', hash);
87+
88+
req.user = {
89+
id: keyRecord.user_id,
90+
authMethod: 'api_key',
91+
scopes: Array.isArray(keyRecord.scopes) ? keyRecord.scopes : [],
92+
};
93+
94+
setRequestUserId(keyRecord.user_id);
95+
next();
96+
return true;
97+
}
98+
1799
/**
18100
* Authentication middleware
19-
* Supports both JWT tokens (Bearer) and HTTP-only cookies
101+
* Supports API key (x-api-key) and JWT tokens (Bearer and cookie)
20102
*/
21103
export async function authenticate(
22104
req: AuthenticatedRequest,
23105
res: Response,
24-
next: NextFunction
106+
next: NextFunction,
25107
): Promise<void> {
26108
try {
109+
const apiKeyAttempted = await authenticateWithApiKey(req, res, next);
110+
if (apiKeyAttempted) {
111+
return;
112+
}
113+
27114
// Try to get token from Authorization header (Bearer token)
28115
const authHeader = req.headers.authorization;
29116
let token: string | null = null;
30117

31118
if (authHeader && authHeader.startsWith('Bearer ')) {
32119
token = authHeader.substring(7);
33120
} else if (req.cookies?.authToken) {
34-
// Fallback to cookie-based auth
35121
token = req.cookies.authToken;
36122
}
37123

@@ -64,6 +150,8 @@ export async function authenticate(
64150
id: user.id,
65151
email: user.email || '',
66152
role,
153+
authMethod: 'jwt',
154+
scopes: Array.from(API_KEY_SCOPES),
67155
};
68156
setRequestUserId(user.id);
69157
Sentry.setUser({ id: user.id, email: user.email });
@@ -86,9 +174,14 @@ export async function authenticate(
86174
export async function optionalAuthenticate(
87175
req: AuthenticatedRequest,
88176
res: Response,
89-
next: NextFunction
177+
next: NextFunction,
90178
): Promise<void> {
91179
try {
180+
const apiKeyAttempted = await authenticateWithApiKey(req, res, next);
181+
if (apiKeyAttempted) {
182+
return;
183+
}
184+
92185
const authHeader = req.headers.authorization;
93186
let token: string | null = null;
94187

@@ -108,6 +201,8 @@ export async function optionalAuthenticate(
108201
id: user.id,
109202
email: user.email || '',
110203
role,
204+
authMethod: 'jwt',
205+
scopes: Array.from(API_KEY_SCOPES),
111206
};
112207
setRequestUserId(user.id);
113208
Sentry.setUser({ id: user.id, email: user.email });
@@ -121,3 +216,4 @@ export async function optionalAuthenticate(
121216
next();
122217
}
123218
}
219+

backend/src/routes/api-keys.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Router, Response } from 'express';
2+
import crypto from 'crypto';
3+
import { supabase } from '../config/database';
4+
import { authenticate, AuthenticatedRequest, requireScope } from '../middleware/auth';
5+
import logger from '../config/logger';
6+
7+
const router = Router();
8+
9+
// All endpoints are for authenticated users (JWT or API key edit rights via user auth).
10+
router.use(authenticate);
11+
12+
const VALID_SCOPES = new Set(["subscriptions:read", "subscriptions:write", "webhooks:write", "analytics:read"]);
13+
14+
function normalizeScopes(scopes: unknown): string[] {
15+
if (Array.isArray(scopes)) {
16+
return scopes
17+
.map((scope) => String(scope || '').trim())
18+
.filter((scope) => scope && VALID_SCOPES.has(scope));
19+
}
20+
21+
if (typeof scopes === 'string') {
22+
return scopes
23+
.split(',')
24+
.map((scope) => scope.trim())
25+
.filter((scope) => scope && VALID_SCOPES.has(scope));
26+
}
27+
28+
return [];
29+
}
30+
31+
function generateApiKey(): { key: string; hash: string } {
32+
const key = `sk_${crypto.randomBytes(32).toString('hex')}`;
33+
const hash = crypto.createHash('sha256').update(key).digest('hex');
34+
return { key, hash };
35+
}
36+
37+
router.post('/', requireScope('subscriptions:write'), async (req: AuthenticatedRequest, res: Response) => {
38+
try {
39+
if (!req.user?.id) {
40+
return res.status(401).json({ error: 'Unauthorized' });
41+
}
42+
43+
const { name, scopes } = req.body || {};
44+
45+
const serviceName = String(name || 'default').trim();
46+
if (!serviceName) {
47+
return res.status(400).json({ error: 'service name is required' });
48+
}
49+
50+
const normalizedScopes = normalizeScopes(scopes);
51+
if (normalizedScopes.length === 0) {
52+
return res.status(400).json({ error: 'at least one valid scope is required' });
53+
}
54+
55+
const { key, hash } = generateApiKey();
56+
57+
let insertResult: any;
58+
try {
59+
insertResult = await supabase.from('api_keys').insert([
60+
{
61+
user_id: req.user.id,
62+
service_name: serviceName,
63+
key_hash: hash,
64+
scopes: normalizedScopes,
65+
revoked: false,
66+
last_used_at: null,
67+
request_count: 0,
68+
},
69+
]);
70+
} catch (dbError) {
71+
logger.error('insert call threw', dbError);
72+
throw dbError;
73+
}
74+
75+
const error = (insertResult as any).error;
76+
77+
if (error) {
78+
logger.error('Failed to create API key', { error });
79+
return res.status(500).json({ error: 'Failed to create API key' });
80+
}
81+
82+
console.log('about to send success response');
83+
return res.status(201).json({ success: true, key, scopes: normalizedScopes });
84+
} catch (error) {
85+
logger.error('Create API key error:', error);
86+
console.error('Create API key error:', error);
87+
return res.status(500).json({ error: String(error) || 'Internal server error' });
88+
}
89+
});
90+
91+
router.get('/', requireScope('subscriptions:read'), async (req: AuthenticatedRequest, res: Response) => {
92+
try {
93+
if (!req.user?.id) {
94+
return res.status(401).json({ error: 'Unauthorized' });
95+
}
96+
97+
const { data, error } = await supabase
98+
.from('api_keys')
99+
.select('id, service_name, scopes, revoked, created_at, updated_at, last_used_at, request_count')
100+
.eq('user_id', req.user.id)
101+
.order('created_at', { ascending: false });
102+
103+
if (error) {
104+
logger.error('Failed to list API keys', { error });
105+
return res.status(500).json({ error: 'Failed to list API keys' });
106+
}
107+
108+
return res.json({ success: true, data });
109+
} catch (error) {
110+
logger.error('List API keys error:', error);
111+
return res.status(500).json({ error: 'Internal server error' });
112+
}
113+
});
114+
115+
router.delete('/:id', requireScope('subscriptions:write'), async (req: AuthenticatedRequest, res: Response) => {
116+
try {
117+
if (!req.user?.id) {
118+
return res.status(401).json({ error: 'Unauthorized' });
119+
}
120+
121+
const keyId = req.params.id;
122+
123+
const { data: existingKey, error: fetchError } = await supabase
124+
.from('api_keys')
125+
.select('id')
126+
.eq('id', keyId)
127+
.eq('user_id', req.user.id)
128+
.single();
129+
130+
if (fetchError || !existingKey) {
131+
return res.status(404).json({ error: 'API key not found' });
132+
}
133+
134+
const { error } = await supabase
135+
.from('api_keys')
136+
.update({ revoked: true, updated_at: new Date().toISOString() })
137+
.eq('id', keyId)
138+
.eq('user_id', req.user.id);
139+
140+
if (error) {
141+
logger.error('Failed to revoke API key', { error });
142+
return res.status(500).json({ error: 'Failed to revoke API key' });
143+
}
144+
145+
return res.json({ success: true });
146+
} catch (error) {
147+
logger.error('Revoke API key error:', error);
148+
return res.status(500).json({ error: 'Internal server error' });
149+
}
150+
});
151+
152+
router.get('/:id/usage', requireScope('subscriptions:read'), async (req: AuthenticatedRequest, res: Response) => {
153+
try {
154+
if (!req.user?.id) {
155+
return res.status(401).json({ error: 'Unauthorized' });
156+
}
157+
158+
const keyId = req.params.id;
159+
160+
const { data, error } = await supabase
161+
.from('api_keys')
162+
.select('id, service_name, scopes, revoked, created_at, updated_at, last_used_at, request_count')
163+
.eq('id', keyId)
164+
.eq('user_id', req.user.id)
165+
.single();
166+
167+
if (error || !data) {
168+
logger.error('Failed to fetch API key usage', { error });
169+
return res.status(404).json({ error: 'API key not found' });
170+
}
171+
172+
return res.json({ success: true, data });
173+
} catch (error) {
174+
logger.error('API key usage error:', error);
175+
return res.status(500).json({ error: 'Internal server error' });
176+
}
177+
});
178+
179+
export default router;

0 commit comments

Comments
 (0)