@forjio/engine-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/__tests__/api-keys.test.d.ts +2 -0
  2. package/dist/__tests__/api-keys.test.d.ts.map +1 -0
  3. package/dist/__tests__/api-keys.test.js +78 -0
  4. package/dist/__tests__/api-keys.test.js.map +1 -0
  5. package/dist/__tests__/helpers/express-mock.d.ts +13 -0
  6. package/dist/__tests__/helpers/express-mock.d.ts.map +1 -0
  7. package/dist/__tests__/helpers/express-mock.js +46 -0
  8. package/dist/__tests__/helpers/express-mock.js.map +1 -0
  9. package/dist/__tests__/helpers/redis-mock.d.ts +19 -0
  10. package/dist/__tests__/helpers/redis-mock.d.ts.map +1 -0
  11. package/dist/__tests__/helpers/redis-mock.js +69 -0
  12. package/dist/__tests__/helpers/redis-mock.js.map +1 -0
  13. package/dist/__tests__/jwt.test.d.ts +2 -0
  14. package/dist/__tests__/jwt.test.d.ts.map +1 -0
  15. package/dist/__tests__/jwt.test.js +98 -0
  16. package/dist/__tests__/jwt.test.js.map +1 -0
  17. package/dist/__tests__/middleware.test.d.ts +2 -0
  18. package/dist/__tests__/middleware.test.d.ts.map +1 -0
  19. package/dist/__tests__/middleware.test.js +173 -0
  20. package/dist/__tests__/middleware.test.js.map +1 -0
  21. package/dist/__tests__/session.test.d.ts +2 -0
  22. package/dist/__tests__/session.test.d.ts.map +1 -0
  23. package/dist/__tests__/session.test.js +96 -0
  24. package/dist/__tests__/session.test.js.map +1 -0
  25. package/dist/api-keys.d.ts +29 -0
  26. package/dist/api-keys.d.ts.map +1 -0
  27. package/dist/api-keys.js +50 -0
  28. package/dist/api-keys.js.map +1 -0
  29. package/dist/index.d.ts +10 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +31 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/jwt.d.ts +7 -0
  34. package/dist/jwt.d.ts.map +1 -0
  35. package/dist/jwt.js +66 -0
  36. package/dist/jwt.js.map +1 -0
  37. package/dist/middleware.d.ts +29 -0
  38. package/dist/middleware.d.ts.map +1 -0
  39. package/dist/middleware.js +94 -0
  40. package/dist/middleware.js.map +1 -0
  41. package/dist/oauth.d.ts +16 -0
  42. package/dist/oauth.d.ts.map +1 -0
  43. package/dist/oauth.js +78 -0
  44. package/dist/oauth.js.map +1 -0
  45. package/dist/session.d.ts +9 -0
  46. package/dist/session.d.ts.map +1 -0
  47. package/dist/session.js +61 -0
  48. package/dist/session.js.map +1 -0
  49. package/dist/types.d.ts +50 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +3 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +34 -0
  54. package/src/__tests__/api-keys.test.ts +94 -0
  55. package/src/__tests__/helpers/express-mock.ts +47 -0
  56. package/src/__tests__/helpers/redis-mock.ts +65 -0
  57. package/src/__tests__/jwt.test.ts +122 -0
  58. package/src/__tests__/middleware.test.ts +220 -0
  59. package/src/__tests__/session.test.ts +120 -0
  60. package/src/api-keys.ts +56 -0
  61. package/src/index.ts +46 -0
  62. package/src/jwt.ts +61 -0
  63. package/src/middleware.ts +113 -0
  64. package/src/oauth.ts +111 -0
  65. package/src/session.ts +78 -0
  66. package/src/types.ts +45 -0
  67. package/tsconfig.json +19 -0
  68. package/vitest.config.ts +21 -0
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createMockReq, createMockRes, createMockNext } from './helpers/express-mock';
3
+ import { authenticateToken, requireApiKey, requireRole } from '../middleware';
4
+ import { signAccessToken } from '../jwt';
5
+ import { AuthConfig, ApiKey } from '../types';
6
+
7
+ const TEST_CONFIG: AuthConfig = {
8
+ jwtSecret: 'test-middleware-secret',
9
+ jwtRefreshSecret: 'test-middleware-refresh-secret',
10
+ };
11
+
12
+ const TEST_PAYLOAD = {
13
+ userId: 'user-789',
14
+ email: 'mw@example.com',
15
+ role: 'admin',
16
+ subscriptionTier: 'enterprise',
17
+ };
18
+
19
+ describe('authenticateToken', () => {
20
+ const middleware = authenticateToken(TEST_CONFIG);
21
+
22
+ it('should call next() with valid Bearer token', () => {
23
+ const token = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
24
+ const req = createMockReq({
25
+ headers: { authorization: `Bearer ${token}` },
26
+ });
27
+ const res = createMockRes();
28
+ const next = createMockNext();
29
+
30
+ middleware(req, res, next);
31
+
32
+ expect(next).toHaveBeenCalled();
33
+ expect(res.status).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('should set req.authUser with payload', () => {
37
+ const token = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
38
+ const req = createMockReq({
39
+ headers: { authorization: `Bearer ${token}` },
40
+ });
41
+ const res = createMockRes();
42
+ const next = createMockNext();
43
+
44
+ middleware(req, res, next);
45
+
46
+ expect(req.authUser).toBeDefined();
47
+ expect(req.authUser.userId).toBe('user-789');
48
+ expect(req.authUser.email).toBe('mw@example.com');
49
+ expect(req.authUser.role).toBe('admin');
50
+ });
51
+
52
+ it('should return 401 for missing Authorization header', () => {
53
+ const req = createMockReq({ headers: {} });
54
+ const res = createMockRes();
55
+ const next = createMockNext();
56
+
57
+ middleware(req, res, next);
58
+
59
+ expect(res.status).toHaveBeenCalledWith(401);
60
+ expect(res.json).toHaveBeenCalledWith(
61
+ expect.objectContaining({ error: expect.any(String) })
62
+ );
63
+ expect(next).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('should return 401 for invalid token', () => {
67
+ const req = createMockReq({
68
+ headers: { authorization: 'Bearer invalid-token-here' },
69
+ });
70
+ const res = createMockRes();
71
+ const next = createMockNext();
72
+
73
+ middleware(req, res, next);
74
+
75
+ expect(res.status).toHaveBeenCalledWith(401);
76
+ expect(next).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('should return 401 for Authorization header without Bearer prefix', () => {
80
+ const token = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
81
+ const req = createMockReq({
82
+ headers: { authorization: token },
83
+ });
84
+ const res = createMockRes();
85
+ const next = createMockNext();
86
+
87
+ middleware(req, res, next);
88
+
89
+ expect(res.status).toHaveBeenCalledWith(401);
90
+ expect(next).not.toHaveBeenCalled();
91
+ });
92
+ });
93
+
94
+ describe('requireApiKey', () => {
95
+ const mockApiKey: ApiKey = {
96
+ id: 'key-1',
97
+ key: 'ek_hashed',
98
+ userId: 'user-1',
99
+ productSlug: 'portal',
100
+ scopes: ['read', 'write'],
101
+ expiresAt: null,
102
+ lastUsedAt: null,
103
+ createdAt: new Date(),
104
+ };
105
+
106
+ it('should call next() when X-API-Key is valid', async () => {
107
+ const validateKey = vi.fn().mockResolvedValue(mockApiKey);
108
+ const middleware = requireApiKey(validateKey);
109
+
110
+ const req = createMockReq({
111
+ headers: { 'x-api-key': 'ek_some_key' },
112
+ });
113
+ const res = createMockRes();
114
+ const next = createMockNext();
115
+
116
+ await middleware(req, res, next);
117
+
118
+ expect(validateKey).toHaveBeenCalledWith('ek_some_key');
119
+ expect(next).toHaveBeenCalled();
120
+ expect(req.apiKey).toEqual(mockApiKey);
121
+ });
122
+
123
+ it('should return 401 when no API key provided', async () => {
124
+ const validateKey = vi.fn();
125
+ const middleware = requireApiKey(validateKey);
126
+
127
+ const req = createMockReq({ headers: {} });
128
+ const res = createMockRes();
129
+ const next = createMockNext();
130
+
131
+ await middleware(req, res, next);
132
+
133
+ expect(res.status).toHaveBeenCalledWith(401);
134
+ expect(next).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it('should return 401 when API key validation fails', async () => {
138
+ const validateKey = vi.fn().mockResolvedValue(null);
139
+ const middleware = requireApiKey(validateKey);
140
+
141
+ const req = createMockReq({
142
+ headers: { 'x-api-key': 'ek_invalid' },
143
+ });
144
+ const res = createMockRes();
145
+ const next = createMockNext();
146
+
147
+ await middleware(req, res, next);
148
+
149
+ expect(res.status).toHaveBeenCalledWith(401);
150
+ expect(next).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('should return 401 for expired API key', async () => {
154
+ const expiredKey: ApiKey = {
155
+ ...mockApiKey,
156
+ expiresAt: new Date('2020-01-01'),
157
+ };
158
+ const validateKey = vi.fn().mockResolvedValue(expiredKey);
159
+ const middleware = requireApiKey(validateKey);
160
+
161
+ const req = createMockReq({
162
+ headers: { 'x-api-key': 'ek_expired' },
163
+ });
164
+ const res = createMockRes();
165
+ const next = createMockNext();
166
+
167
+ await middleware(req, res, next);
168
+
169
+ expect(res.status).toHaveBeenCalledWith(401);
170
+ expect(next).not.toHaveBeenCalled();
171
+ });
172
+ });
173
+
174
+ describe('requireRole', () => {
175
+ it('should allow matching role', () => {
176
+ const middleware = requireRole('admin');
177
+ const req = createMockReq({ authUser: { role: 'admin' } });
178
+ const res = createMockRes();
179
+ const next = createMockNext();
180
+
181
+ middleware(req, res, next);
182
+
183
+ expect(next).toHaveBeenCalled();
184
+ });
185
+
186
+ it('should reject non-matching role', () => {
187
+ const middleware = requireRole('admin');
188
+ const req = createMockReq({ authUser: { role: 'user' } });
189
+ const res = createMockRes();
190
+ const next = createMockNext();
191
+
192
+ middleware(req, res, next);
193
+
194
+ expect(res.status).toHaveBeenCalledWith(403);
195
+ expect(next).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it('should accept any of multiple allowed roles', () => {
199
+ const middleware = requireRole('admin', 'moderator');
200
+ const req = createMockReq({ authUser: { role: 'moderator' } });
201
+ const res = createMockRes();
202
+ const next = createMockNext();
203
+
204
+ middleware(req, res, next);
205
+
206
+ expect(next).toHaveBeenCalled();
207
+ });
208
+
209
+ it('should return 401 when no authUser is present', () => {
210
+ const middleware = requireRole('admin');
211
+ const req = createMockReq({});
212
+ const res = createMockRes();
213
+ const next = createMockNext();
214
+
215
+ middleware(req, res, next);
216
+
217
+ expect(res.status).toHaveBeenCalledWith(401);
218
+ expect(next).not.toHaveBeenCalled();
219
+ });
220
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { RedisMock } from './helpers/redis-mock';
3
+ import { SessionData } from '../types';
4
+
5
+ // Mock ioredis before importing session module
6
+ const redisMock = new RedisMock();
7
+ vi.mock('ioredis', () => ({
8
+ default: vi.fn(() => redisMock),
9
+ }));
10
+
11
+ // Import after mock is set up
12
+ import { createSessionStore, SessionStore } from '../session';
13
+
14
+ const TEST_SESSION: SessionData = {
15
+ userId: 'user-456',
16
+ email: 'session@example.com',
17
+ role: 'user',
18
+ productSlug: 'portal',
19
+ expiresAt: Date.now() + 3600_000, // 1 hour from now
20
+ };
21
+
22
+ describe('session store', () => {
23
+ let store: SessionStore;
24
+
25
+ beforeEach(() => {
26
+ redisMock.clear();
27
+ store = createSessionStore('redis://localhost:6379');
28
+ });
29
+
30
+ describe('create', () => {
31
+ it('should create session and return session ID', async () => {
32
+ const sessionId = await store.create(TEST_SESSION);
33
+
34
+ expect(sessionId).toBeDefined();
35
+ expect(typeof sessionId).toBe('string');
36
+ expect(sessionId.length).toBeGreaterThan(0);
37
+ });
38
+
39
+ it('should store session data retrievable by ID', async () => {
40
+ const sessionId = await store.create(TEST_SESSION);
41
+ const retrieved = await store.get(sessionId);
42
+
43
+ expect(retrieved).not.toBeNull();
44
+ expect(retrieved!.userId).toBe('user-456');
45
+ expect(retrieved!.email).toBe('session@example.com');
46
+ expect(retrieved!.role).toBe('user');
47
+ });
48
+ });
49
+
50
+ describe('get', () => {
51
+ it('should return session data for valid ID', async () => {
52
+ const sessionId = await store.create(TEST_SESSION);
53
+ const session = await store.get(sessionId);
54
+
55
+ expect(session).not.toBeNull();
56
+ expect(session!.userId).toBe(TEST_SESSION.userId);
57
+ expect(session!.productSlug).toBe(TEST_SESSION.productSlug);
58
+ });
59
+
60
+ it('should return null for non-existent ID', async () => {
61
+ const session = await store.get('non-existent-id');
62
+ expect(session).toBeNull();
63
+ });
64
+
65
+ it('should return null for logically expired session', async () => {
66
+ const expiredSession: SessionData = {
67
+ ...TEST_SESSION,
68
+ expiresAt: Date.now() - 1000, // already expired
69
+ };
70
+
71
+ const sessionId = await store.create(expiredSession);
72
+ const session = await store.get(sessionId);
73
+
74
+ expect(session).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe('destroy', () => {
79
+ it('should remove session', async () => {
80
+ const sessionId = await store.create(TEST_SESSION);
81
+
82
+ // Verify it exists first
83
+ expect(await store.get(sessionId)).not.toBeNull();
84
+
85
+ await store.destroy(sessionId);
86
+
87
+ expect(await store.get(sessionId)).toBeNull();
88
+ });
89
+ });
90
+
91
+ describe('destroyAllForUser', () => {
92
+ it('should remove all sessions for a user', async () => {
93
+ const id1 = await store.create(TEST_SESSION);
94
+ const id2 = await store.create({ ...TEST_SESSION, productSlug: 'other' });
95
+
96
+ // Both should exist
97
+ expect(await store.get(id1)).not.toBeNull();
98
+ expect(await store.get(id2)).not.toBeNull();
99
+
100
+ await store.destroyAllForUser(TEST_SESSION.userId);
101
+
102
+ expect(await store.get(id1)).toBeNull();
103
+ expect(await store.get(id2)).toBeNull();
104
+ });
105
+
106
+ it('should not affect other users sessions', async () => {
107
+ const id1 = await store.create(TEST_SESSION);
108
+ const otherSession: SessionData = {
109
+ ...TEST_SESSION,
110
+ userId: 'other-user',
111
+ };
112
+ const id2 = await store.create(otherSession);
113
+
114
+ await store.destroyAllForUser(TEST_SESSION.userId);
115
+
116
+ expect(await store.get(id1)).toBeNull();
117
+ expect(await store.get(id2)).not.toBeNull();
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,56 @@
1
+ import crypto from 'crypto';
2
+
3
+ const API_KEY_PREFIX = 'ek_';
4
+ const API_KEY_BYTES = 32;
5
+ const EXPECTED_KEY_LENGTH = API_KEY_PREFIX.length + API_KEY_BYTES * 2; // hex encoding
6
+
7
+ export interface CreateApiKeyInput {
8
+ userId: string;
9
+ productSlug: string;
10
+ scopes: string[];
11
+ expiresAt?: Date | null;
12
+ }
13
+
14
+ export interface ApiKeyWithHash {
15
+ plainKey: string;
16
+ hashedKey: string;
17
+ }
18
+
19
+ /**
20
+ * Generates a new API key with the `ek_` prefix.
21
+ * Optionally accepts a custom prefix that replaces the default.
22
+ */
23
+ export function generateApiKey(prefix?: string): string {
24
+ const keyPrefix = prefix || API_KEY_PREFIX;
25
+ const randomPart = crypto.randomBytes(API_KEY_BYTES).toString('hex');
26
+ return `${keyPrefix}${randomPart}`;
27
+ }
28
+
29
+ /**
30
+ * Creates a SHA-256 hash of an API key for secure storage.
31
+ * Only the hash should be persisted — never store the plain key.
32
+ */
33
+ export function hashApiKey(key: string): string {
34
+ return crypto.createHash('sha256').update(key).digest('hex');
35
+ }
36
+
37
+ /**
38
+ * Validates that a key matches the expected `ek_` format and length.
39
+ */
40
+ export function validateApiKeyFormat(key: string): boolean {
41
+ if (!key.startsWith(API_KEY_PREFIX)) return false;
42
+ if (key.length !== EXPECTED_KEY_LENGTH) return false;
43
+
44
+ // Verify the hex portion is valid
45
+ const hexPart = key.slice(API_KEY_PREFIX.length);
46
+ return /^[a-f0-9]+$/.test(hexPart);
47
+ }
48
+
49
+ /**
50
+ * Generates a new API key and returns both the plain key and its hash.
51
+ */
52
+ export function createApiKeyPair(prefix?: string): ApiKeyWithHash {
53
+ const plainKey = generateApiKey(prefix);
54
+ const hashedKey = hashApiKey(plainKey);
55
+ return { plainKey, hashedKey };
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Types
2
+ export type {
3
+ EngineUser,
4
+ ApiKey,
5
+ SessionData,
6
+ JwtPayload,
7
+ AuthConfig,
8
+ } from './types';
9
+
10
+ // JWT utilities
11
+ export {
12
+ signAccessToken,
13
+ signRefreshToken,
14
+ signRememberMeToken,
15
+ verifyAccessToken,
16
+ verifyRefreshToken,
17
+ } from './jwt';
18
+
19
+ // Session store
20
+ export { createSessionStore } from './session';
21
+ export type { SessionStore } from './session';
22
+
23
+ // API key utilities
24
+ export {
25
+ generateApiKey,
26
+ hashApiKey,
27
+ validateApiKeyFormat,
28
+ createApiKeyPair,
29
+ } from './api-keys';
30
+ export type { CreateApiKeyInput, ApiKeyWithHash } from './api-keys';
31
+
32
+ // OAuth strategies
33
+ export {
34
+ configureGoogleStrategy,
35
+ configureGithubStrategy,
36
+ setupOAuth,
37
+ } from './oauth';
38
+ export type { OAuthProfile, FindOrCreateUser } from './oauth';
39
+
40
+ // Express middleware
41
+ export {
42
+ authenticateToken,
43
+ requireApiKey,
44
+ requireProduct,
45
+ requireRole,
46
+ } from './middleware';
package/src/jwt.ts ADDED
@@ -0,0 +1,61 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { JwtPayload, AuthConfig } from './types';
3
+
4
+ // Expiry values in seconds to satisfy jsonwebtoken's type requirements
5
+ const DEFAULT_ACCESS_EXPIRY = 15 * 60; // 15 minutes
6
+ const DEFAULT_REFRESH_EXPIRY = 7 * 24 * 60 * 60; // 7 days
7
+ const REMEMBER_ME_EXPIRY = 30 * 24 * 60 * 60; // 30 days
8
+
9
+ function parseExpiry(value: string | undefined, fallback: number): number {
10
+ if (!value) return fallback;
11
+ // Parse simple duration strings: "15m", "7d", "30d", "1h"
12
+ const match = value.match(/^(\d+)(s|m|h|d)$/);
13
+ if (!match) return fallback;
14
+ const num = parseInt(match[1], 10);
15
+ const unit = match[2];
16
+ switch (unit) {
17
+ case 's': return num;
18
+ case 'm': return num * 60;
19
+ case 'h': return num * 3600;
20
+ case 'd': return num * 86400;
21
+ default: return fallback;
22
+ }
23
+ }
24
+
25
+ export function signAccessToken(payload: JwtPayload, config: AuthConfig): string {
26
+ return jwt.sign(payload, config.jwtSecret, {
27
+ expiresIn: parseExpiry(config.jwtExpiresIn, DEFAULT_ACCESS_EXPIRY),
28
+ });
29
+ }
30
+
31
+ export function signRefreshToken(payload: JwtPayload, config: AuthConfig): string {
32
+ return jwt.sign(payload, config.jwtRefreshSecret, {
33
+ expiresIn: parseExpiry(config.jwtRefreshExpiresIn, DEFAULT_REFRESH_EXPIRY),
34
+ });
35
+ }
36
+
37
+ export function signRememberMeToken(payload: JwtPayload, config: AuthConfig): string {
38
+ return jwt.sign(payload, config.jwtRefreshSecret, {
39
+ expiresIn: REMEMBER_ME_EXPIRY,
40
+ });
41
+ }
42
+
43
+ export function verifyAccessToken(token: string, config: AuthConfig): JwtPayload {
44
+ const decoded = jwt.verify(token, config.jwtSecret) as jwt.JwtPayload & JwtPayload;
45
+ return {
46
+ userId: decoded.userId,
47
+ email: decoded.email,
48
+ role: decoded.role,
49
+ subscriptionTier: decoded.subscriptionTier,
50
+ };
51
+ }
52
+
53
+ export function verifyRefreshToken(token: string, config: AuthConfig): JwtPayload {
54
+ const decoded = jwt.verify(token, config.jwtRefreshSecret) as jwt.JwtPayload & JwtPayload;
55
+ return {
56
+ userId: decoded.userId,
57
+ email: decoded.email,
58
+ role: decoded.role,
59
+ subscriptionTier: decoded.subscriptionTier,
60
+ };
61
+ }
@@ -0,0 +1,113 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { verifyAccessToken } from './jwt';
3
+ import { AuthConfig, JwtPayload, ApiKey } from './types';
4
+
5
+ // Extend Express Request to carry auth data via module augmentation
6
+ declare module 'express-serve-static-core' {
7
+ interface Request {
8
+ authUser?: JwtPayload;
9
+ apiKey?: ApiKey;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Middleware that validates a Bearer token from the Authorization header.
15
+ * On success, attaches the decoded JwtPayload to `req.user`.
16
+ */
17
+ export function authenticateToken(config: AuthConfig) {
18
+ return (req: Request, res: Response, next: NextFunction): void => {
19
+ const authHeader = req.headers.authorization;
20
+
21
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
22
+ res.status(401).json({ error: 'Missing or invalid Authorization header' });
23
+ return;
24
+ }
25
+
26
+ const token = authHeader.slice(7);
27
+
28
+ try {
29
+ const payload = verifyAccessToken(token, config);
30
+ req.authUser = payload;
31
+ next();
32
+ } catch {
33
+ res.status(401).json({ error: 'Invalid or expired token' });
34
+ }
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Middleware that validates an API key from the X-API-Key header.
40
+ * The `validateKey` callback should look up the hashed key in your DB.
41
+ */
42
+ export function requireApiKey(
43
+ validateKey: (key: string) => Promise<ApiKey | null>
44
+ ) {
45
+ return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
46
+ const apiKey = req.headers['x-api-key'] as string | undefined;
47
+
48
+ if (!apiKey) {
49
+ res.status(401).json({ error: 'Missing X-API-Key header' });
50
+ return;
51
+ }
52
+
53
+ try {
54
+ const keyRecord = await validateKey(apiKey);
55
+
56
+ if (!keyRecord) {
57
+ res.status(401).json({ error: 'Invalid API key' });
58
+ return;
59
+ }
60
+
61
+ // Check expiration
62
+ if (keyRecord.expiresAt && new Date(keyRecord.expiresAt) < new Date()) {
63
+ res.status(401).json({ error: 'API key has expired' });
64
+ return;
65
+ }
66
+
67
+ req.apiKey = keyRecord;
68
+ next();
69
+ } catch {
70
+ res.status(500).json({ error: 'Failed to validate API key' });
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Middleware that ensures the API key is scoped to a specific product.
77
+ * Must be used after `requireApiKey`.
78
+ */
79
+ export function requireProduct(productSlug: string) {
80
+ return (req: Request, res: Response, next: NextFunction): void => {
81
+ if (!req.apiKey) {
82
+ res.status(401).json({ error: 'No API key context' });
83
+ return;
84
+ }
85
+
86
+ if (req.apiKey.productSlug !== productSlug) {
87
+ res.status(403).json({ error: `API key not authorized for product: ${productSlug}` });
88
+ return;
89
+ }
90
+
91
+ next();
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Middleware that restricts access to specific user roles.
97
+ * Must be used after `authenticateToken`.
98
+ */
99
+ export function requireRole(...roles: string[]) {
100
+ return (req: Request, res: Response, next: NextFunction): void => {
101
+ if (!req.authUser) {
102
+ res.status(401).json({ error: 'Not authenticated' });
103
+ return;
104
+ }
105
+
106
+ if (!roles.includes(req.authUser.role)) {
107
+ res.status(403).json({ error: 'Insufficient permissions' });
108
+ return;
109
+ }
110
+
111
+ next();
112
+ };
113
+ }