Skip to content

Commit d8b6e62

Browse files
Merge pull request #252 from SheetMetalConnect/claude/fix-jwt-token-validation-01KAyvGqpLcGxpqE5AzhzvNL
Fix JWT token validation in API
2 parents fb6443b + e380df8 commit d8b6e62

File tree

31 files changed

+741
-1069
lines changed

31 files changed

+741
-1069
lines changed

docs/API_KEY_AUTHENTICATION.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# API Key Authentication
2+
3+
## Overview
4+
5+
The Eryxon Flow API uses API keys for external integrations (ERP systems, automation, etc.). Keys follow the format `ery_live_xxx` or `ery_test_xxx`.
6+
7+
## How It Works
8+
9+
### Key Generation
10+
1. Admin creates API key via dashboard (Admin > API Keys)
11+
2. System generates random key: `ery_live_<32-random-chars>`
12+
3. Key is hashed using **SHA-256** and stored in `api_keys` table
13+
4. Plaintext key shown once to user (never stored)
14+
15+
### Key Validation
16+
1. Client sends request with `Authorization: Bearer ery_live_xxx`
17+
2. System extracts key prefix (first 12 chars) for efficient lookup
18+
3. Looks up candidate keys by prefix
19+
4. Hashes provided key with **SHA-256** and compares to stored hash
20+
5. On match: sets tenant context and allows request
21+
22+
## Architecture
23+
24+
```
25+
┌─────────────────────┐ ┌──────────────────────┐
26+
│ Dashboard User │ │ External System │
27+
│ (JWT Auth) │ │ (API Key Auth) │
28+
└─────────┬───────────┘ └──────────┬───────────┘
29+
│ │
30+
▼ ▼
31+
┌─────────────────────┐ ┌──────────────────────┐
32+
│ api-key-generate │ │ api-jobs, api-parts │
33+
│ (creates keys) │ │ (validates keys) │
34+
└─────────┬───────────┘ └──────────┬───────────┘
35+
│ │
36+
│ SHA-256 hash │ SHA-256 hash
37+
▼ ▼
38+
┌──────────────────────────────────────────────────┐
39+
│ api_keys table │
40+
│ key_prefix | key_hash | tenant_id | active │
41+
└──────────────────────────────────────────────────┘
42+
```
43+
44+
## Shared Auth Module
45+
46+
All API endpoints use the shared authentication module:
47+
48+
```typescript
49+
// supabase/functions/_shared/auth.ts
50+
import { authenticateAndSetContext } from "../_shared/auth.ts";
51+
52+
// In your endpoint:
53+
const { tenantId } = await authenticateAndSetContext(req, supabase);
54+
```
55+
56+
This module:
57+
- Extracts Bearer token from Authorization header
58+
- Validates key format (`ery_live_*` or `ery_test_*`)
59+
- Looks up key by prefix (efficient query)
60+
- Hashes and compares using SHA-256
61+
- Sets tenant context for Row-Level Security
62+
- Updates `last_used_at` timestamp (async)
63+
64+
## Two Auth Patterns
65+
66+
| Pattern | Endpoints | Token Type | Use Case |
67+
|---------|-----------|------------|----------|
68+
| **API Key** | `api-jobs`, `api-parts`, etc. | `ery_live_xxx` | External integrations |
69+
| **JWT** | `api-key-generate`, `api-integrations` | Supabase session | Dashboard users |
70+
71+
## Key Format
72+
73+
```
74+
ery_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
75+
└─┬─┘└┬─┘└──────────────┬────────────────┘
76+
│ │ │
77+
│ │ └── 32 random chars
78+
│ └── Environment (live/test)
79+
└── Prefix identifier
80+
```
81+
82+
## Database Schema
83+
84+
```sql
85+
CREATE TABLE api_keys (
86+
id UUID PRIMARY KEY,
87+
tenant_id UUID NOT NULL REFERENCES tenants(id),
88+
name TEXT NOT NULL,
89+
key_prefix TEXT NOT NULL, -- First 12 chars for lookup
90+
key_hash TEXT NOT NULL, -- SHA-256 hash of full key
91+
active BOOLEAN DEFAULT true,
92+
last_used_at TIMESTAMPTZ,
93+
created_at TIMESTAMPTZ DEFAULT now(),
94+
created_by UUID REFERENCES profiles(id)
95+
);
96+
97+
-- Index for efficient prefix lookup
98+
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix) WHERE active = true;
99+
```
100+
101+
## Security Considerations
102+
103+
1. **Keys are never stored** - Only SHA-256 hash is persisted
104+
2. **Prefix lookup** - Avoids comparing all keys (O(1) vs O(n))
105+
3. **Constant-time comparison** - SHA-256 comparison prevents timing attacks
106+
4. **Async last_used update** - Doesn't block request response
107+
5. **Soft delete** - Keys are deactivated, not deleted (audit trail)
108+
109+
## Error Responses
110+
111+
| Error | HTTP Status | Cause |
112+
|-------|-------------|-------|
113+
| `Missing or invalid authorization header` | 401 | No Bearer token |
114+
| `Invalid API key format` | 401 | Key doesn't match `ery_*` pattern |
115+
| `Invalid API key` | 401 | Key not found or hash mismatch |
116+
117+
## Testing
118+
119+
```bash
120+
# Test with valid key
121+
curl -H "Authorization: Bearer ery_live_yourkey" \
122+
https://yourproject.supabase.co/functions/v1/api-jobs
123+
124+
# Expected: 200 OK with jobs data
125+
126+
# Test with invalid key
127+
curl -H "Authorization: Bearer invalid_key" \
128+
https://yourproject.supabase.co/functions/v1/api-jobs
129+
130+
# Expected: 401 Unauthorized
131+
```
132+
133+
## Historical Note
134+
135+
**Issue Fixed (Dec 2024):** API endpoints were using bcrypt for key validation while key generation used SHA-256. This caused all API key authentication to fail. Fixed by standardizing all endpoints on the shared auth module with SHA-256.

public/openapi.json

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"openapi": "3.0.3",
33
"info": {
44
"title": "Eryxon Flow API",
5-
"description": "Production workflow management API for sheet metal manufacturing. This API allows you to manage jobs, parts, tasks, and track production progress through various stages.\n\n## Authentication\n\nAll API requests require an API key. Include your API key in the `Authorization` header:\n\n```\nAuthorization: Bearer ery_live_your_api_key_here\n```\n\n## Getting Started\n\n1. **Generate an API Key**: Navigate to Admin > API Keys in the web interface\n2. **Test the Connection**: Use the `/api-stages` endpoint (read-only) to verify your key works\n3. **Create Your First Job**: Use POST `/api-jobs` to create a job with parts and tasks\n4. **Track Progress**: Use GET endpoints to retrieve and monitor job status\n\n## Rate Limiting\n\nAPI requests are rate-limited to prevent abuse. Rate limit information is included in response headers:\n- `X-RateLimit-Limit`: Maximum requests allowed\n- `X-RateLimit-Remaining`: Remaining requests in current window\n- `X-RateLimit-Reset`: Timestamp when the limit resets\n\n## Base URL\n\n```\nhttps://vatgianzotsurljznsry.supabase.co/functions/v1\n```",
5+
"description": "Production workflow management API for sheet metal manufacturing. This API allows you to manage jobs, parts, tasks, and track production progress through various stages.\n\n## Authentication\n\nAll API requests require an API key. Include your API key in the `Authorization` header:\n\n```\nAuthorization: Bearer ery_live_your_api_key_here\n```\n\n## Getting Started\n\n1. **Generate an API Key**: Navigate to Admin > API Keys in the web interface\n2. **Test the Connection**: Use the `/api-stages` endpoint (read-only) to verify your key works\n3. **Create Your First Job**: Use POST `/api-jobs` to create a job with parts and tasks\n4. **Track Progress**: Use GET endpoints to retrieve and monitor job status\n\n## Rate Limiting\n\nAPI requests are rate-limited based on your subscription plan:\n\n| Plan | Daily Limit |\n|------|-------------|\n| Free | 100 requests/day |\n| Pro | 1,000 requests/day |\n| Premium | 10,000 requests/day |\n| Enterprise | Unlimited |\n\nRate limit information is included in response headers:\n- `X-RateLimit-Remaining`: Remaining requests in current window\n- `X-RateLimit-Reset`: Timestamp when the limit resets\n- `Retry-After`: Seconds until you can retry (when rate limited)\n\nWhen you exceed your rate limit, you'll receive a `429 Too Many Requests` response.\n\n## Base URL\n\n```\nhttps://vatgianzotsurljznsry.supabase.co/functions/v1\n```",
66
"version": "1.0.0",
77
"contact": {
88
"name": "API Support",
@@ -2336,7 +2336,31 @@
23362336
}
23372337
},
23382338
"RateLimitError": {
2339-
"description": "Rate limit exceeded",
2339+
"description": "Rate limit exceeded. Your subscription plan's daily request limit has been reached.",
2340+
"headers": {
2341+
"X-RateLimit-Remaining": {
2342+
"description": "Number of requests remaining in current window",
2343+
"schema": {
2344+
"type": "integer",
2345+
"example": 0
2346+
}
2347+
},
2348+
"X-RateLimit-Reset": {
2349+
"description": "ISO 8601 timestamp when the rate limit resets",
2350+
"schema": {
2351+
"type": "string",
2352+
"format": "date-time",
2353+
"example": "2024-12-10T00:00:00Z"
2354+
}
2355+
},
2356+
"Retry-After": {
2357+
"description": "Seconds until the rate limit resets",
2358+
"schema": {
2359+
"type": "integer",
2360+
"example": 3600
2361+
}
2362+
}
2363+
},
23402364
"content": {
23412365
"application/json": {
23422366
"schema": {
@@ -2346,7 +2370,13 @@
23462370
"success": false,
23472371
"error": {
23482372
"code": "RATE_LIMIT_EXCEEDED",
2349-
"message": "Too many requests. Please try again later."
2373+
"message": "Rate limit exceeded. Your free plan allows 100 requests per day. Please upgrade your plan or wait until the limit resets.",
2374+
"statusCode": 429,
2375+
"details": {
2376+
"remaining": 0,
2377+
"resetAt": "2024-12-10T00:00:00Z",
2378+
"retryAfter": 3600
2379+
}
23502380
}
23512381
}
23522382
}

src/hooks/useSubscription.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,18 @@ export interface TenantUsageStats {
2727
total_admins: number;
2828
}
2929

30+
export interface ApiUsageStats {
31+
today_requests: number;
32+
this_month_requests: number;
33+
reset_at: string;
34+
daily_limit: number | null;
35+
}
36+
3037
export const useSubscription = () => {
3138
const { profile } = useAuth();
3239
const [subscription, setSubscription] = useState<TenantSubscription | null>(null);
3340
const [usageStats, setUsageStats] = useState<TenantUsageStats | null>(null);
41+
const [apiUsageStats, setApiUsageStats] = useState<ApiUsageStats | null>(null);
3442
const [loading, setLoading] = useState(true);
3543
const [error, setError] = useState<string | null>(null);
3644

@@ -64,6 +72,17 @@ export const useSubscription = () => {
6472
if (statsData && statsData.length > 0) {
6573
setUsageStats(statsData[0] as any);
6674
}
75+
76+
// Fetch API usage statistics
77+
const { data: apiData, error: apiError } = await supabase
78+
.rpc('get_api_usage_stats' as any);
79+
80+
if (apiError) {
81+
console.warn('API usage stats not available:', apiError);
82+
// Don't throw - API usage stats are optional
83+
} else if (apiData && apiData.length > 0) {
84+
setApiUsageStats(apiData[0] as any);
85+
}
6786
} catch (err) {
6887
console.error('Error fetching subscription:', err);
6988
setError(err instanceof Error ? err.message : 'Failed to fetch subscription data');
@@ -110,6 +129,7 @@ export const useSubscription = () => {
110129
return {
111130
subscription,
112131
usageStats,
132+
apiUsageStats,
113133
loading,
114134
error,
115135
getPlanDisplayName,

src/i18n/locales/de/admin.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,13 @@
316316
"upgradePlan": "Plan upgraden",
317317
"usage": "Nutzung",
318318
"usageThisMonth": "Nutzung diesen Monat",
319-
"users": "Benutzer"
319+
"users": "Benutzer",
320+
"apiRequests": "API Anfragen",
321+
"today": "heute",
322+
"requestsThisMonth": "Anfragen diesen Monat",
323+
"upgradeRequest": {
324+
"subject": "Upgrade Anfrage: {{planName}}",
325+
"body": "Hallo,\n\nIch möchte auf den {{planName}} Plan upgraden.\n\nAktueller Plan: {{currentPlan}}\nTenant ID: {{tenantId}}\n\nDanke!"
326+
}
320327
}
321328
}

src/i18n/locales/en/admin.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,13 @@
403403
"upgradePlan": "Upgrade Plan",
404404
"usage": "Usage",
405405
"usageThisMonth": "Usage This Month",
406-
"users": "Users"
406+
"users": "Users",
407+
"apiRequests": "API Requests",
408+
"today": "today",
409+
"requestsThisMonth": "requests this month",
410+
"upgradeRequest": {
411+
"subject": "Upgrade Request: {{planName}}",
412+
"body": "Hi,\n\nI would like to upgrade to the {{planName}} plan.\n\nCurrent Plan: {{currentPlan}}\nTenant ID: {{tenantId}}\n\nThank you!"
413+
}
407414
}
408415
}

src/i18n/locales/nl/admin.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,13 @@
553553
"upgradePlan": "Abonnement upgraden",
554554
"usage": "Gebruik",
555555
"usageThisMonth": "Gebruik deze maand",
556-
"users": "Gebruikers"
556+
"users": "Gebruikers",
557+
"apiRequests": "API Verzoeken",
558+
"today": "vandaag",
559+
"requestsThisMonth": "verzoeken deze maand",
560+
"upgradeRequest": {
561+
"subject": "Upgrade Aanvraag: {{planName}}",
562+
"body": "Hallo,\n\nIk wil graag upgraden naar het {{planName}} abonnement.\n\nHuidig Abonnement: {{currentPlan}}\nTenant ID: {{tenantId}}\n\nBedankt!"
563+
}
557564
}
558565
}

src/pages/common/MyPlan.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Briefcase,
1313
Package,
1414
Users,
15+
Zap,
1516
} from "lucide-react";
1617
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1718
import { Button } from "@/components/ui/button";
@@ -77,6 +78,7 @@ export const MyPlan: React.FC = () => {
7778
const {
7879
subscription,
7980
usageStats,
81+
apiUsageStats,
8082
loading,
8183
error,
8284
getPlanDisplayName,
@@ -273,6 +275,37 @@ export const MyPlan: React.FC = () => {
273275
className="h-2 [&>div]:bg-blue-500"
274276
/>
275277
</div>
278+
279+
{/* API Usage */}
280+
{apiUsageStats && (
281+
<div>
282+
<div className="flex justify-between mb-2">
283+
<div className="flex items-center gap-2">
284+
<Zap className="h-4 w-4 text-muted-foreground" />
285+
<span className="text-sm font-medium">{t("myPlan.apiRequests")}</span>
286+
</div>
287+
<span className="text-sm text-muted-foreground">
288+
{apiUsageStats.today_requests} /{" "}
289+
{apiUsageStats.daily_limit || "∞"} {t("myPlan.today")}
290+
</span>
291+
</div>
292+
<Progress
293+
value={getUsagePercentage(
294+
apiUsageStats.today_requests,
295+
apiUsageStats.daily_limit
296+
)}
297+
className={cn(
298+
"h-2",
299+
isAtLimit(apiUsageStats.today_requests, apiUsageStats.daily_limit)
300+
? "[&>div]:bg-destructive"
301+
: "[&>div]:bg-orange-500"
302+
)}
303+
/>
304+
<p className="text-xs text-muted-foreground mt-1">
305+
{apiUsageStats.this_month_requests.toLocaleString()} {t("myPlan.requestsThisMonth")}
306+
</p>
307+
</div>
308+
)}
276309
</CardContent>
277310
</Card>
278311

0 commit comments

Comments
 (0)