@tumbaland/backend-core 1.41.0 → 1.43.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/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +23 -9
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/auth/session.d.ts +22 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +75 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/groups/client.d.ts +15 -0
- package/dist/groups/client.d.ts.map +1 -1
- package/dist/groups/client.js +1 -0
- package/dist/groups/client.js.map +1 -1
- package/dist/groups/index.d.ts +1 -0
- package/dist/groups/index.d.ts.map +1 -1
- package/dist/groups/index.js +5 -1
- package/dist/groups/index.js.map +1 -1
- package/dist/groups/membership.d.ts +28 -0
- package/dist/groups/membership.d.ts.map +1 -0
- package/dist/groups/membership.js +122 -0
- package/dist/groups/membership.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/authMiddleware.d.ts +2 -2
- package/dist/middleware/authMiddleware.d.ts.map +1 -1
- package/dist/middleware/authMiddleware.js +22 -10
- package/dist/middleware/authMiddleware.js.map +1 -1
- package/dist/testing/serviceTestSetup.d.ts +2 -0
- package/dist/testing/serviceTestSetup.d.ts.map +1 -0
- package/dist/testing/serviceTestSetup.js +18 -0
- package/dist/testing/serviceTestSetup.js.map +1 -0
- package/dist/types/auth.d.ts +3 -0
- package/dist/types/auth.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/middleware.test.ts +84 -0
- package/src/apiKeys/middleware.ts +25 -10
- package/src/auth/session.test.ts +89 -0
- package/src/auth/session.ts +75 -0
- package/src/groups/client.ts +1 -1
- package/src/groups/index.ts +2 -0
- package/src/groups/membership.test.ts +84 -0
- package/src/groups/membership.ts +133 -0
- package/src/index.ts +1 -0
- package/src/middleware/authMiddleware.test.ts +78 -30
- package/src/middleware/authMiddleware.ts +22 -10
- package/src/testing/serviceTestSetup.ts +17 -0
- package/src/types/auth.ts +3 -0
- package/testing.js +10 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import logger from '../logging/logger';
|
|
2
|
+
import { internalGroupUrl } from './client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which groups a person belongs to, asked of group-service.
|
|
6
|
+
*
|
|
7
|
+
* This used to travel in the session token's `groups` claim, which made it only
|
|
8
|
+
* as fresh as the token: a new member could not see the group until their next
|
|
9
|
+
* renewal, and a removed one kept access until theirs, which is why membership
|
|
10
|
+
* changes used to sign people out. Asked here instead, with a short cache, a
|
|
11
|
+
* change reaches every service within CACHE_TTL_MS.
|
|
12
|
+
*
|
|
13
|
+
* Every request pays for this only once per person per service per cache
|
|
14
|
+
* window; concurrent callers share one request.
|
|
15
|
+
*/
|
|
16
|
+
const CACHE_TTL_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
/** Bounded so a service under enumeration can't grow the cache without limit. */
|
|
19
|
+
const MAX_CACHE_ENTRIES = 5_000;
|
|
20
|
+
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
22
|
+
|
|
23
|
+
interface CacheEntry {
|
|
24
|
+
groups: string[];
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cache = new Map<string, CacheEntry>();
|
|
29
|
+
const inFlight = new Map<string, Promise<string[] | null>>();
|
|
30
|
+
|
|
31
|
+
function putInCache(email: string, groups: string[]): void {
|
|
32
|
+
if (!cache.has(email) && cache.size >= MAX_CACHE_ENTRIES) {
|
|
33
|
+
// Map iterates in insertion order, so the first key is the oldest write.
|
|
34
|
+
const oldest = cache.keys().next();
|
|
35
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
36
|
+
}
|
|
37
|
+
cache.set(email, { groups, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchUserGroupIds(email: string): Promise<string[] | null> {
|
|
41
|
+
const baseUrl = internalGroupUrl();
|
|
42
|
+
const token = process.env.INTERNAL_SERVICE_TOKEN;
|
|
43
|
+
|
|
44
|
+
if (!baseUrl || !token) {
|
|
45
|
+
logger.error('Group membership lookup not configured', {
|
|
46
|
+
hasGroupApiUrl: Boolean(baseUrl),
|
|
47
|
+
hasInternalToken: Boolean(token)
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(`${baseUrl}/api/groups/mine/ids?userEmail=${encodeURIComponent(email)}`, {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'x-internal-token': token,
|
|
58
|
+
'x-service-id': process.env.SERVICE_NAME || 'unknown-service'
|
|
59
|
+
},
|
|
60
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
logger.error('Group membership lookup failed', { email, status: response.status });
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = (await response.json()) as { success?: boolean; data?: unknown };
|
|
69
|
+
if (!body.success || !Array.isArray(body.data)) {
|
|
70
|
+
logger.error('Group membership lookup returned no list', { email });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return body.data.filter((id): id is string => typeof id === 'string');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logger.error('Group membership lookup unreachable', { email, error: (error as Error)?.message });
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The ids of the groups `email` belongs to right now.
|
|
82
|
+
*
|
|
83
|
+
* When group-service cannot be asked, the last answer is used even if it has
|
|
84
|
+
* expired, and failing that no groups at all: a person keeps their own data
|
|
85
|
+
* through an outage and loses group data until it ends, rather than being
|
|
86
|
+
* granted a group on a guess.
|
|
87
|
+
*/
|
|
88
|
+
export async function getUserGroupIds(email: string): Promise<string[]> {
|
|
89
|
+
const key = email.toLowerCase();
|
|
90
|
+
const entry = cache.get(key);
|
|
91
|
+
if (entry && entry.expiresAt > Date.now()) return entry.groups;
|
|
92
|
+
|
|
93
|
+
let pending = inFlight.get(key);
|
|
94
|
+
if (!pending) {
|
|
95
|
+
pending = fetchUserGroupIds(key).finally(() => inFlight.delete(key));
|
|
96
|
+
inFlight.set(key, pending);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const groups = await pending;
|
|
100
|
+
if (groups) {
|
|
101
|
+
putInCache(key, groups);
|
|
102
|
+
return groups;
|
|
103
|
+
}
|
|
104
|
+
if (entry) {
|
|
105
|
+
logger.warn('Using the last known group membership while group-service is unreachable', { email: key });
|
|
106
|
+
return entry.groups;
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** For tests. */
|
|
112
|
+
export function clearMembershipCache(): void {
|
|
113
|
+
cache.clear();
|
|
114
|
+
inFlight.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type MembershipLookup = (user: { email: string; groups?: string[] }) => Promise<string[]>;
|
|
118
|
+
|
|
119
|
+
const lookUpMembership: MembershipLookup = (user) => getUserGroupIds(user.email);
|
|
120
|
+
let membershipLookup: MembershipLookup = lookUpMembership;
|
|
121
|
+
|
|
122
|
+
/** The groups the auth middlewares put on `req.userGroups`. */
|
|
123
|
+
export const currentGroupsOf = (user: { email: string; groups?: string[] }): Promise<string[]> =>
|
|
124
|
+
membershipLookup(user);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Replaces the lookup, for tests only; pass nothing to restore the real one.
|
|
128
|
+
* Service suites reach it through `@tumbaland/backend-core/testing`, which makes
|
|
129
|
+
* a test token's own `groups` claim the membership, so they need no group-service.
|
|
130
|
+
*/
|
|
131
|
+
export function overrideMembershipLookupForTests(lookup?: MembershipLookup): void {
|
|
132
|
+
membershipLookup = lookup ?? lookUpMembership;
|
|
133
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export * from './audit';
|
|
|
39
39
|
|
|
40
40
|
// Middleware
|
|
41
41
|
export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
|
|
42
|
+
export { USER_SESSIONS_COLLECTION, isCurrentSession, isLegacySessionlessToken } from './auth/session';
|
|
42
43
|
export {
|
|
43
44
|
requireInternalServiceToken,
|
|
44
45
|
internalServiceTokenBypass,
|
|
@@ -4,6 +4,12 @@ import { authenticateToken, optionalAuth } from './authMiddleware';
|
|
|
4
4
|
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
5
5
|
import { mintAccessToken } from '../oauth/tokens';
|
|
6
6
|
import { UserPayload } from '../types/auth';
|
|
7
|
+
import { overrideSessionCheckForTests } from '../auth/session';
|
|
8
|
+
import { overrideMembershipLookupForTests } from '../groups/membership';
|
|
9
|
+
|
|
10
|
+
// These tokens carry no session; the session check has its own tests below and in session.test.ts.
|
|
11
|
+
beforeEach(() => overrideSessionCheckForTests(async () => true));
|
|
12
|
+
afterAll(() => overrideSessionCheckForTests());
|
|
7
13
|
|
|
8
14
|
function mockReq(overrides: Partial<Request> = {}): Request {
|
|
9
15
|
return { cookies: {}, headers: {}, ...overrides } as unknown as Request;
|
|
@@ -31,75 +37,80 @@ describe('authenticateToken', () => {
|
|
|
31
37
|
|
|
32
38
|
const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
|
|
33
39
|
|
|
34
|
-
it('attaches the decoded user and calls next for a valid cookie token', () => {
|
|
40
|
+
it('attaches the decoded user and calls next for a valid cookie token', async () => {
|
|
35
41
|
const token = jwt.sign(payload, 'test-secret');
|
|
36
42
|
const req = mockReq({ cookies: { access_token: token } });
|
|
37
43
|
const res = mockRes();
|
|
38
44
|
|
|
39
|
-
authenticateToken(req, res, next);
|
|
45
|
+
await authenticateToken(req, res, next);
|
|
40
46
|
|
|
41
47
|
expect(next).toHaveBeenCalledTimes(1);
|
|
42
48
|
expect(req.user).toMatchObject({ id: '1', email: 'user@example.com' });
|
|
43
49
|
});
|
|
44
50
|
|
|
45
|
-
it('attaches an empty userGroups array when the token has no groups claim', () => {
|
|
51
|
+
it('attaches an empty userGroups array when the token has no groups claim', async () => {
|
|
46
52
|
const token = jwt.sign(payload, 'test-secret');
|
|
47
53
|
const req = mockReq({ cookies: { access_token: token } });
|
|
48
54
|
const res = mockRes();
|
|
49
55
|
|
|
50
|
-
authenticateToken(req, res, next);
|
|
56
|
+
await authenticateToken(req, res, next);
|
|
51
57
|
|
|
52
58
|
expect(req.userGroups).toEqual([]);
|
|
53
59
|
});
|
|
54
60
|
|
|
55
|
-
it('
|
|
56
|
-
const
|
|
61
|
+
it("takes the person's groups from group-service, not from the token", async () => {
|
|
62
|
+
const lookup = jest.fn(async () => ['live-group']);
|
|
63
|
+
overrideMembershipLookupForTests(lookup);
|
|
64
|
+
const token = jwt.sign({ ...payload, groups: ['stale-group'] }, 'test-secret');
|
|
57
65
|
const req = mockReq({ cookies: { access_token: token } });
|
|
58
66
|
const res = mockRes();
|
|
59
67
|
|
|
60
|
-
authenticateToken(req, res, next);
|
|
68
|
+
await authenticateToken(req, res, next);
|
|
61
69
|
|
|
62
|
-
expect(
|
|
70
|
+
expect(lookup).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' }));
|
|
71
|
+
expect(req.userGroups).toEqual(['live-group']);
|
|
72
|
+
expect(req.user?.groups).toEqual(['live-group']);
|
|
73
|
+
overrideMembershipLookupForTests();
|
|
63
74
|
});
|
|
64
75
|
|
|
65
|
-
it('accepts a Bearer token from the Authorization header', () => {
|
|
76
|
+
it('accepts a Bearer token from the Authorization header', async () => {
|
|
66
77
|
const token = jwt.sign(payload, 'test-secret');
|
|
67
78
|
const req = mockReq({ headers: { authorization: `Bearer ${token}` } });
|
|
68
79
|
const res = mockRes();
|
|
69
80
|
|
|
70
|
-
authenticateToken(req, res, next);
|
|
81
|
+
await authenticateToken(req, res, next);
|
|
71
82
|
|
|
72
83
|
expect(next).toHaveBeenCalledTimes(1);
|
|
73
84
|
});
|
|
74
85
|
|
|
75
|
-
it('returns 401 when no token is present', () => {
|
|
86
|
+
it('returns 401 when no token is present', async () => {
|
|
76
87
|
const req = mockReq();
|
|
77
88
|
const res = mockRes();
|
|
78
89
|
|
|
79
|
-
authenticateToken(req, res, next);
|
|
90
|
+
await authenticateToken(req, res, next);
|
|
80
91
|
|
|
81
92
|
expect(next).not.toHaveBeenCalled();
|
|
82
93
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
83
94
|
expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Access token required' });
|
|
84
95
|
});
|
|
85
96
|
|
|
86
|
-
it('returns 401 for an invalid token', () => {
|
|
97
|
+
it('returns 401 for an invalid token', async () => {
|
|
87
98
|
const req = mockReq({ cookies: { access_token: 'not-a-real-token' } });
|
|
88
99
|
const res = mockRes();
|
|
89
100
|
|
|
90
|
-
authenticateToken(req, res, next);
|
|
101
|
+
await authenticateToken(req, res, next);
|
|
91
102
|
|
|
92
103
|
expect(next).not.toHaveBeenCalled();
|
|
93
104
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
94
105
|
expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Invalid or expired token' });
|
|
95
106
|
});
|
|
96
107
|
|
|
97
|
-
it('returns 401 for a token signed with a different secret', () => {
|
|
108
|
+
it('returns 401 for a token signed with a different secret', async () => {
|
|
98
109
|
const token = jwt.sign(payload, 'wrong-secret');
|
|
99
110
|
const req = mockReq({ cookies: { access_token: token } });
|
|
100
111
|
const res = mockRes();
|
|
101
112
|
|
|
102
|
-
authenticateToken(req, res, next);
|
|
113
|
+
await authenticateToken(req, res, next);
|
|
103
114
|
|
|
104
115
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
105
116
|
});
|
|
@@ -120,54 +131,54 @@ describe('optionalAuth', () => {
|
|
|
120
131
|
|
|
121
132
|
const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
|
|
122
133
|
|
|
123
|
-
it('attaches the user when the token is valid', () => {
|
|
134
|
+
it('attaches the user when the token is valid', async () => {
|
|
124
135
|
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
|
|
125
136
|
|
|
126
|
-
optionalAuth(req, mockRes(), next);
|
|
137
|
+
await optionalAuth(req, mockRes(), next);
|
|
127
138
|
|
|
128
139
|
expect(next).toHaveBeenCalledTimes(1);
|
|
129
140
|
expect(req.user).toMatchObject({ email: 'user@example.com' });
|
|
130
141
|
});
|
|
131
142
|
|
|
132
|
-
it('reads a bearer token too', () => {
|
|
143
|
+
it('reads a bearer token too', async () => {
|
|
133
144
|
const req = mockReq({ headers: { authorization: `Bearer ${jwt.sign(payload, 'test-secret')}` } });
|
|
134
145
|
|
|
135
|
-
optionalAuth(req, mockRes(), next);
|
|
146
|
+
await optionalAuth(req, mockRes(), next);
|
|
136
147
|
|
|
137
148
|
expect(req.user).toMatchObject({ email: 'user@example.com' });
|
|
138
149
|
});
|
|
139
150
|
|
|
140
|
-
it('continues anonymously when there is no token', () => {
|
|
151
|
+
it('continues anonymously when there is no token', async () => {
|
|
141
152
|
const req = mockReq();
|
|
142
153
|
const res = mockRes();
|
|
143
154
|
|
|
144
|
-
optionalAuth(req, res, next);
|
|
155
|
+
await optionalAuth(req, res, next);
|
|
145
156
|
|
|
146
157
|
expect(next).toHaveBeenCalledTimes(1);
|
|
147
158
|
expect(req.user).toBeUndefined();
|
|
148
159
|
expect(res.status).not.toHaveBeenCalled();
|
|
149
160
|
});
|
|
150
161
|
|
|
151
|
-
it('treats an expired or forged token as no token rather than as a 401', () => {
|
|
162
|
+
it('treats an expired or forged token as no token rather than as a 401', async () => {
|
|
152
163
|
// The caller asked for public data. Refusing it because a stale cookie is
|
|
153
164
|
// lying around would break the landing page for exactly the people most
|
|
154
165
|
// likely to have one.
|
|
155
166
|
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'wrong-secret') } });
|
|
156
167
|
const res = mockRes();
|
|
157
168
|
|
|
158
|
-
optionalAuth(req, res, next);
|
|
169
|
+
await optionalAuth(req, res, next);
|
|
159
170
|
|
|
160
171
|
expect(next).toHaveBeenCalledTimes(1);
|
|
161
172
|
expect(req.user).toBeUndefined();
|
|
162
173
|
expect(res.status).not.toHaveBeenCalled();
|
|
163
174
|
});
|
|
164
175
|
|
|
165
|
-
it('continues anonymously when JWT_SECRET is not configured', () => {
|
|
176
|
+
it('continues anonymously when JWT_SECRET is not configured', async () => {
|
|
166
177
|
delete process.env.JWT_SECRET;
|
|
167
178
|
const req = mockReq({ cookies: { access_token: 'anything' } });
|
|
168
179
|
const res = mockRes();
|
|
169
180
|
|
|
170
|
-
optionalAuth(req, res, next);
|
|
181
|
+
await optionalAuth(req, res, next);
|
|
171
182
|
|
|
172
183
|
expect(next).toHaveBeenCalledTimes(1);
|
|
173
184
|
expect(res.status).not.toHaveBeenCalled();
|
|
@@ -200,28 +211,65 @@ describe('OAuth access tokens are not sessions', () => {
|
|
|
200
211
|
clientName: 'Claude'
|
|
201
212
|
}).accessToken;
|
|
202
213
|
|
|
203
|
-
it('refuses one on a session-only route', () => {
|
|
214
|
+
it('refuses one on a session-only route', async () => {
|
|
204
215
|
const res = mockRes();
|
|
205
216
|
const next = jest.fn();
|
|
206
217
|
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
207
218
|
|
|
208
219
|
// It verifies — same secret — so nothing but the type check stands between
|
|
209
220
|
// an assistant and every route that has no scopes to be held to.
|
|
210
|
-
authenticateToken(req, res, next);
|
|
221
|
+
await authenticateToken(req, res, next);
|
|
211
222
|
|
|
212
223
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
213
224
|
expect(next).not.toHaveBeenCalled();
|
|
214
225
|
expect(req.user).toBeUndefined();
|
|
215
226
|
});
|
|
216
227
|
|
|
217
|
-
it('treats one as no session at all on an optional-auth route', () => {
|
|
228
|
+
it('treats one as no session at all on an optional-auth route', async () => {
|
|
218
229
|
const next = jest.fn();
|
|
219
230
|
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
220
231
|
|
|
221
|
-
optionalAuth(req, mockRes(), next);
|
|
232
|
+
await optionalAuth(req, mockRes(), next);
|
|
222
233
|
|
|
223
234
|
// The route still answers, in its public form — the same as a stale cookie.
|
|
224
235
|
expect(next).toHaveBeenCalled();
|
|
225
236
|
expect(req.user).toBeUndefined();
|
|
226
237
|
});
|
|
227
238
|
});
|
|
239
|
+
|
|
240
|
+
describe('ended sessions', () => {
|
|
241
|
+
const ORIGINAL_ENV = process.env;
|
|
242
|
+
const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User', sid: 'a'.repeat(24) };
|
|
243
|
+
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
246
|
+
overrideSessionCheckForTests(async () => false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterAll(() => {
|
|
250
|
+
process.env = ORIGINAL_ENV;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('authenticateToken refuses a validly signed token whose session has ended', async () => {
|
|
254
|
+
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
|
|
255
|
+
const res = mockRes();
|
|
256
|
+
const next = jest.fn();
|
|
257
|
+
|
|
258
|
+
await authenticateToken(req, res, next);
|
|
259
|
+
|
|
260
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
261
|
+
expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Session has ended' });
|
|
262
|
+
expect(next).not.toHaveBeenCalled();
|
|
263
|
+
expect(req.user).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('optionalAuth treats an ended session as no session', async () => {
|
|
267
|
+
const req = mockReq({ cookies: { access_token: jwt.sign(payload, 'test-secret') } });
|
|
268
|
+
const next = jest.fn();
|
|
269
|
+
|
|
270
|
+
await optionalAuth(req, mockRes(), next);
|
|
271
|
+
|
|
272
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
273
|
+
expect(req.user).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -3,6 +3,8 @@ import jwt from 'jsonwebtoken';
|
|
|
3
3
|
import { requireEnv } from '../config/env';
|
|
4
4
|
import { isAccessTokenClaims } from '../oauth/tokens';
|
|
5
5
|
import { UserPayload } from '../types/auth';
|
|
6
|
+
import { isCurrentSession } from '../auth/session';
|
|
7
|
+
import { currentGroupsOf } from '../groups/membership';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* JWT authentication middleware
|
|
@@ -17,7 +19,7 @@ import { UserPayload } from '../types/auth';
|
|
|
17
19
|
* enforces both — so anything still guarded by this one is a person's to do,
|
|
18
20
|
* and letting a token through would be handing an assistant the account.
|
|
19
21
|
*/
|
|
20
|
-
export function authenticateToken(req: Request, res: Response, next: NextFunction): void {
|
|
22
|
+
export async function authenticateToken(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
21
23
|
try {
|
|
22
24
|
const JWT_SECRET = requireEnv('JWT_SECRET');
|
|
23
25
|
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
@@ -36,13 +38,22 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
|
|
|
36
38
|
return;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// A signed-out or revoked device keeps a validly signed token; only its
|
|
42
|
+
// session record says it has ended.
|
|
43
|
+
if (!(await isCurrentSession(claims))) {
|
|
44
|
+
res.status(401).json({ success: false, message: 'Session has ended' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Membership is asked of group-service, not read from the token, so a
|
|
48
|
+
// change applies within seconds rather than at the next renewal.
|
|
49
|
+
const groups = await currentGroupsOf(claims as UserPayload);
|
|
50
|
+
req.user = { ...(claims as UserPayload), groups };
|
|
51
|
+
req.userGroups = groups;
|
|
43
52
|
} catch (error) {
|
|
44
53
|
res.status(401).json({ success: false, message: 'Invalid or expired token' });
|
|
54
|
+
return;
|
|
45
55
|
}
|
|
56
|
+
next();
|
|
46
57
|
}
|
|
47
58
|
/**
|
|
48
59
|
* Attaches the user when a valid token is present, and never rejects.
|
|
@@ -57,7 +68,7 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
|
|
|
57
68
|
* around would break the landing page for exactly the people most likely to
|
|
58
69
|
* have one.
|
|
59
70
|
*/
|
|
60
|
-
export function optionalAuth(req: Request, _res: Response, next: NextFunction): void {
|
|
71
|
+
export async function optionalAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
|
61
72
|
try {
|
|
62
73
|
const JWT_SECRET = requireEnv('JWT_SECRET');
|
|
63
74
|
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
@@ -66,10 +77,11 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction):
|
|
|
66
77
|
const claims = jwt.verify(token, JWT_SECRET);
|
|
67
78
|
// An access token is not a session; treated as none, exactly like an
|
|
68
79
|
// expired cookie, so the route still answers with its public form.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
req.
|
|
80
|
+
// An ended session is treated as none, like an expired cookie.
|
|
81
|
+
if (!isAccessTokenClaims(claims) && (await isCurrentSession(claims))) {
|
|
82
|
+
const groups = await currentGroupsOf(claims as UserPayload);
|
|
83
|
+
req.user = { ...(claims as UserPayload), groups };
|
|
84
|
+
req.userGroups = groups;
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
87
|
} catch {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { overrideSessionCheckForTests } from '../auth/session';
|
|
2
|
+
import { overrideMembershipLookupForTests } from '../groups/membership';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Jest setup for service test suites: they sign session tokens without a
|
|
6
|
+
* session and run with no database or group-service, so every token's session
|
|
7
|
+
* counts as live and its `groups` claim stands in for group membership.
|
|
8
|
+
* The session check itself is tested in backend-core (auth/session.test.ts).
|
|
9
|
+
*
|
|
10
|
+
* Loaded through the package's test-only entry point:
|
|
11
|
+
* setupFiles: ['@tumbaland/backend-core/testing']
|
|
12
|
+
*/
|
|
13
|
+
overrideSessionCheckForTests(async () => true);
|
|
14
|
+
|
|
15
|
+
// No group-service either: a test token's own `groups` claim is taken as the
|
|
16
|
+
// person's membership. The live lookup is tested in groups/membership.test.ts.
|
|
17
|
+
overrideMembershipLookupForTests(async (user) => user.groups ?? []);
|
package/src/types/auth.ts
CHANGED
package/testing.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only entry point: `@tumbaland/backend-core/testing`.
|
|
3
|
+
*
|
|
4
|
+
* Loading it makes every session token's session count as live, and a test
|
|
5
|
+
* token's `groups` claim its group membership, for service test suites that
|
|
6
|
+
* run with no database and no group-service.
|
|
7
|
+
* Kept out of the package's main exports so production code never reaches it.
|
|
8
|
+
* The session check itself is tested in backend-core (src/auth/session.test.ts).
|
|
9
|
+
*/
|
|
10
|
+
require('./dist/testing/serviceTestSetup');
|