@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.
- package/dist/__tests__/api-keys.test.d.ts +2 -0
- package/dist/__tests__/api-keys.test.d.ts.map +1 -0
- package/dist/__tests__/api-keys.test.js +78 -0
- package/dist/__tests__/api-keys.test.js.map +1 -0
- package/dist/__tests__/helpers/express-mock.d.ts +13 -0
- package/dist/__tests__/helpers/express-mock.d.ts.map +1 -0
- package/dist/__tests__/helpers/express-mock.js +46 -0
- package/dist/__tests__/helpers/express-mock.js.map +1 -0
- package/dist/__tests__/helpers/redis-mock.d.ts +19 -0
- package/dist/__tests__/helpers/redis-mock.d.ts.map +1 -0
- package/dist/__tests__/helpers/redis-mock.js +69 -0
- package/dist/__tests__/helpers/redis-mock.js.map +1 -0
- package/dist/__tests__/jwt.test.d.ts +2 -0
- package/dist/__tests__/jwt.test.d.ts.map +1 -0
- package/dist/__tests__/jwt.test.js +98 -0
- package/dist/__tests__/jwt.test.js.map +1 -0
- package/dist/__tests__/middleware.test.d.ts +2 -0
- package/dist/__tests__/middleware.test.d.ts.map +1 -0
- package/dist/__tests__/middleware.test.js +173 -0
- package/dist/__tests__/middleware.test.js.map +1 -0
- package/dist/__tests__/session.test.d.ts +2 -0
- package/dist/__tests__/session.test.d.ts.map +1 -0
- package/dist/__tests__/session.test.js +96 -0
- package/dist/__tests__/session.test.js.map +1 -0
- package/dist/api-keys.d.ts +29 -0
- package/dist/api-keys.d.ts.map +1 -0
- package/dist/api-keys.js +50 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +7 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +66 -0
- package/dist/jwt.js.map +1 -0
- package/dist/middleware.d.ts +29 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +94 -0
- package/dist/middleware.js.map +1 -0
- package/dist/oauth.d.ts +16 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +78 -0
- package/dist/oauth.js.map +1 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +61 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +34 -0
- package/src/__tests__/api-keys.test.ts +94 -0
- package/src/__tests__/helpers/express-mock.ts +47 -0
- package/src/__tests__/helpers/redis-mock.ts +65 -0
- package/src/__tests__/jwt.test.ts +122 -0
- package/src/__tests__/middleware.test.ts +220 -0
- package/src/__tests__/session.test.ts +120 -0
- package/src/api-keys.ts +56 -0
- package/src/index.ts +46 -0
- package/src/jwt.ts +61 -0
- package/src/middleware.ts +113 -0
- package/src/oauth.ts +111 -0
- package/src/session.ts +78 -0
- package/src/types.ts +45 -0
- package/tsconfig.json +19 -0
- 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
|
+
});
|
package/src/api-keys.ts
ADDED
|
@@ -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
|
+
}
|