@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
|
@@ -8,6 +8,8 @@ exports.optionalAuth = optionalAuth;
|
|
|
8
8
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
9
|
const env_1 = require("../config/env");
|
|
10
10
|
const tokens_1 = require("../oauth/tokens");
|
|
11
|
+
const session_1 = require("../auth/session");
|
|
12
|
+
const membership_1 = require("../groups/membership");
|
|
11
13
|
/**
|
|
12
14
|
* JWT authentication middleware
|
|
13
15
|
* Verifies JWT token and attaches user info (and its embedded groups) to
|
|
@@ -21,7 +23,7 @@ const tokens_1 = require("../oauth/tokens");
|
|
|
21
23
|
* enforces both — so anything still guarded by this one is a person's to do,
|
|
22
24
|
* and letting a token through would be handing an assistant the account.
|
|
23
25
|
*/
|
|
24
|
-
function authenticateToken(req, res, next) {
|
|
26
|
+
async function authenticateToken(req, res, next) {
|
|
25
27
|
try {
|
|
26
28
|
const JWT_SECRET = (0, env_1.requireEnv)('JWT_SECRET');
|
|
27
29
|
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
@@ -37,14 +39,23 @@ function authenticateToken(req, res, next) {
|
|
|
37
39
|
});
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// A signed-out or revoked device keeps a validly signed token; only its
|
|
43
|
+
// session record says it has ended.
|
|
44
|
+
if (!(await (0, session_1.isCurrentSession)(claims))) {
|
|
45
|
+
res.status(401).json({ success: false, message: 'Session has ended' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Membership is asked of group-service, not read from the token, so a
|
|
49
|
+
// change applies within seconds rather than at the next renewal.
|
|
50
|
+
const groups = await (0, membership_1.currentGroupsOf)(claims);
|
|
51
|
+
req.user = { ...claims, groups };
|
|
52
|
+
req.userGroups = groups;
|
|
44
53
|
}
|
|
45
54
|
catch (error) {
|
|
46
55
|
res.status(401).json({ success: false, message: 'Invalid or expired token' });
|
|
56
|
+
return;
|
|
47
57
|
}
|
|
58
|
+
next();
|
|
48
59
|
}
|
|
49
60
|
/**
|
|
50
61
|
* Attaches the user when a valid token is present, and never rejects.
|
|
@@ -59,7 +70,7 @@ function authenticateToken(req, res, next) {
|
|
|
59
70
|
* around would break the landing page for exactly the people most likely to
|
|
60
71
|
* have one.
|
|
61
72
|
*/
|
|
62
|
-
function optionalAuth(req, _res, next) {
|
|
73
|
+
async function optionalAuth(req, _res, next) {
|
|
63
74
|
try {
|
|
64
75
|
const JWT_SECRET = (0, env_1.requireEnv)('JWT_SECRET');
|
|
65
76
|
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
@@ -67,10 +78,11 @@ function optionalAuth(req, _res, next) {
|
|
|
67
78
|
const claims = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
|
68
79
|
// An access token is not a session; treated as none, exactly like an
|
|
69
80
|
// expired cookie, so the route still answers with its public form.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
req.
|
|
81
|
+
// An ended session is treated as none, like an expired cookie.
|
|
82
|
+
if (!(0, tokens_1.isAccessTokenClaims)(claims) && (await (0, session_1.isCurrentSession)(claims))) {
|
|
83
|
+
const groups = await (0, membership_1.currentGroupsOf)(claims);
|
|
84
|
+
req.user = { ...claims, groups };
|
|
85
|
+
req.userGroups = groups;
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authMiddleware.js","sourceRoot":"","sources":["../../src/middleware/authMiddleware.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"authMiddleware.js","sourceRoot":"","sources":["../../src/middleware/authMiddleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serviceTestSetup.d.ts","sourceRoot":"","sources":["../../src/testing/serviceTestSetup.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const session_1 = require("../auth/session");
|
|
4
|
+
const membership_1 = require("../groups/membership");
|
|
5
|
+
/**
|
|
6
|
+
* Jest setup for service test suites: they sign session tokens without a
|
|
7
|
+
* session and run with no database or group-service, so every token's session
|
|
8
|
+
* counts as live and its `groups` claim stands in for group membership.
|
|
9
|
+
* The session check itself is tested in backend-core (auth/session.test.ts).
|
|
10
|
+
*
|
|
11
|
+
* Loaded through the package's test-only entry point:
|
|
12
|
+
* setupFiles: ['@tumbaland/backend-core/testing']
|
|
13
|
+
*/
|
|
14
|
+
(0, session_1.overrideSessionCheckForTests)(async () => true);
|
|
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
|
+
(0, membership_1.overrideMembershipLookupForTests)(async (user) => user.groups ?? []);
|
|
18
|
+
//# sourceMappingURL=serviceTestSetup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serviceTestSetup.js","sourceRoot":"","sources":["../../src/testing/serviceTestSetup.ts"],"names":[],"mappings":";;AAAA,6CAA+D;AAC/D,qDAAwE;AAExE;;;;;;;;GAQG;AACH,IAAA,sCAA4B,EAAC,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,CAAC;AAE/C,6EAA6E;AAC7E,+EAA+E;AAC/E,IAAA,6CAAgC,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC"}
|
package/dist/types/auth.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export interface UserPayload {
|
|
|
6
6
|
picture?: string;
|
|
7
7
|
groups?: string[];
|
|
8
8
|
roles?: string[];
|
|
9
|
+
tokenVersion?: number;
|
|
10
|
+
/** The auth-service session (user_sessions) this token belongs to; absent on pre-sessions tokens. */
|
|
11
|
+
sid?: string;
|
|
9
12
|
}
|
|
10
13
|
/**
|
|
11
14
|
* Attaches `user`/`userGroups` to every Express `Request` across all
|
package/dist/types/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/types/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/types/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qGAAqG;IACrG,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;GAUG;AACH,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,IAAI,CAAC,EAAE,WAAW,CAAC;YACnB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;YACtB,aAAa,CAAC,EAAE,MAAM,CAAC;YACvB,IAAI,CAAC,EAAE,IAAI,CAAC;SACb;KACF;CACF;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,IAAI,GAAG,OAAO,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,SAAS,GAAG,UAAU,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC"}
|
package/package.json
CHANGED
|
@@ -11,6 +11,8 @@ import { verifyApiKey } from './service';
|
|
|
11
11
|
import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
|
|
12
12
|
import { PERSONAL_TENANT } from './types';
|
|
13
13
|
import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
|
|
14
|
+
import { overrideSessionCheckForTests } from '../auth/session';
|
|
15
|
+
import { overrideMembershipLookupForTests } from '../groups/membership';
|
|
14
16
|
|
|
15
17
|
const mockedVerify = verifyApiKey as jest.Mock;
|
|
16
18
|
const ORIGINAL_ENV = process.env;
|
|
@@ -43,11 +45,18 @@ const okVerification = (over: Record<string, unknown> = {}) => ({
|
|
|
43
45
|
...over
|
|
44
46
|
});
|
|
45
47
|
|
|
48
|
+
// Unless a test says otherwise, the owner still belongs to every group used here.
|
|
49
|
+
let memberOf: string[];
|
|
50
|
+
|
|
46
51
|
beforeEach(() => {
|
|
47
52
|
jest.clearAllMocks();
|
|
48
53
|
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
|
|
54
|
+
memberOf = ['g1', 'g2'];
|
|
55
|
+
overrideMembershipLookupForTests(async () => memberOf);
|
|
49
56
|
});
|
|
50
57
|
|
|
58
|
+
afterAll(() => overrideMembershipLookupForTests());
|
|
59
|
+
|
|
51
60
|
afterEach(() => {
|
|
52
61
|
process.env = ORIGINAL_ENV;
|
|
53
62
|
});
|
|
@@ -65,6 +74,32 @@ describe('authenticateAgent — no credentials', () => {
|
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
describe('authenticateAgent — session tokens', () => {
|
|
77
|
+
beforeEach(() => overrideSessionCheckForTests(async () => true));
|
|
78
|
+
afterAll(() => overrideSessionCheckForTests());
|
|
79
|
+
|
|
80
|
+
it('rejects a validly signed JWT whose session has ended', async () => {
|
|
81
|
+
overrideSessionCheckForTests(async () => false);
|
|
82
|
+
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', sid: 'a'.repeat(24) }, 'test-jwt-secret');
|
|
83
|
+
const res = mockRes();
|
|
84
|
+
const next = jest.fn();
|
|
85
|
+
|
|
86
|
+
await authenticateAgent()(req({ headers: { authorization: `Bearer ${token}` } }), res, next);
|
|
87
|
+
|
|
88
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
89
|
+
expect(next).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("takes the person's groups from group-service, not from the token", async () => {
|
|
93
|
+
memberOf = ['g2'];
|
|
94
|
+
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1'] }, 'test-jwt-secret');
|
|
95
|
+
const request = req({ headers: { authorization: `Bearer ${token}` } });
|
|
96
|
+
|
|
97
|
+
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
98
|
+
|
|
99
|
+
expect(request.userGroups).toEqual(['g2']);
|
|
100
|
+
expect(request.user?.groups).toEqual(['g2']);
|
|
101
|
+
});
|
|
102
|
+
|
|
68
103
|
it('accepts a valid JWT and grants the full session, with no apiKey context', async () => {
|
|
69
104
|
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1', 'g2'] }, 'test-jwt-secret');
|
|
70
105
|
const request = req({ headers: { authorization: `Bearer ${token}` } });
|
|
@@ -343,6 +378,55 @@ describe('authenticateAgent — a multi-tenant grant', () => {
|
|
|
343
378
|
});
|
|
344
379
|
});
|
|
345
380
|
|
|
381
|
+
describe('authenticateAgent — groups the owner has since left', () => {
|
|
382
|
+
it('drops a granted group the owner no longer belongs to', async () => {
|
|
383
|
+
memberOf = ['g2'];
|
|
384
|
+
mockedVerify.mockResolvedValue(
|
|
385
|
+
okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: PERSONAL_TENANT } })
|
|
386
|
+
);
|
|
387
|
+
const request = withKey();
|
|
388
|
+
|
|
389
|
+
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
390
|
+
|
|
391
|
+
expect(request.userGroups).toEqual(['g2']);
|
|
392
|
+
expect(request.apiKey?.tenants.allowed).toEqual([PERSONAL_TENANT, 'g2']);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('refuses a request for a group the owner has left, although the key was granted it', async () => {
|
|
396
|
+
memberOf = [];
|
|
397
|
+
mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT, 'g1') }));
|
|
398
|
+
const res = mockRes();
|
|
399
|
+
const next = jest.fn();
|
|
400
|
+
|
|
401
|
+
await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } } as Partial<Request>), res, next);
|
|
402
|
+
|
|
403
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
404
|
+
expect(next).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('refuses a key whose only group the owner has left, rather than acting somewhere else', async () => {
|
|
408
|
+
memberOf = [];
|
|
409
|
+
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
410
|
+
const res = mockRes();
|
|
411
|
+
const next = jest.fn();
|
|
412
|
+
|
|
413
|
+
await authenticateAgent()(withKey(), res, next);
|
|
414
|
+
|
|
415
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
416
|
+
expect(next).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('asks about the key owner, by email', async () => {
|
|
420
|
+
const lookup = jest.fn(async () => ['g1']);
|
|
421
|
+
overrideMembershipLookupForTests(lookup);
|
|
422
|
+
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
423
|
+
|
|
424
|
+
await authenticateAgent()(withKey(), mockRes(), jest.fn());
|
|
425
|
+
|
|
426
|
+
expect(lookup).toHaveBeenCalledWith({ email: 'u1@example.com' });
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
346
430
|
describe('requireScope', () => {
|
|
347
431
|
const keyReq = (scopes: string[]) =>
|
|
348
432
|
req({
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
type ApiKeyTenant,
|
|
17
17
|
type Tenant
|
|
18
18
|
} from './types';
|
|
19
|
+
import { isCurrentSession } from '../auth/session';
|
|
20
|
+
import { currentGroupsOf } from '../groups/membership';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
@@ -147,13 +149,13 @@ interface AgentCredential {
|
|
|
147
149
|
* Settling that in one place is what keeps the two from drifting into subtly
|
|
148
150
|
* different amounts of access.
|
|
149
151
|
*/
|
|
150
|
-
function admitAgent(
|
|
152
|
+
async function admitAgent(
|
|
151
153
|
req: Request,
|
|
152
154
|
res: Response,
|
|
153
155
|
next: NextFunction,
|
|
154
156
|
requiredScopes: ApiKeyScope[],
|
|
155
157
|
credential: AgentCredential
|
|
156
|
-
): void {
|
|
158
|
+
): Promise<void> {
|
|
157
159
|
const missing = requiredScopes.filter((scope) => !credential.scopes.includes(scope));
|
|
158
160
|
if (missing.length > 0) {
|
|
159
161
|
res.status(403).json({
|
|
@@ -163,7 +165,16 @@ function admitAgent(
|
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
|
-
|
|
168
|
+
// A grant names the groups the owner belonged to when they made it. Only the
|
|
169
|
+
// ones they still belong to count, so leaving a group also takes it from
|
|
170
|
+
// every key and connection they gave it to.
|
|
171
|
+
const stillMemberOf = new Set(await currentGroupsOf({ email: credential.email }));
|
|
172
|
+
const tenants: ApiKeyTenant = {
|
|
173
|
+
...credential.tenants,
|
|
174
|
+
allowed: credential.tenants.allowed.filter((tenant) => tenant === PERSONAL_TENANT || stillMemberOf.has(tenant))
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const actingAs = applyTenantGrant(req, tenants);
|
|
167
178
|
if (actingAs === null) {
|
|
168
179
|
res.status(403).json({
|
|
169
180
|
success: false,
|
|
@@ -176,11 +187,11 @@ function admitAgent(
|
|
|
176
187
|
// Only the granted groups, never the owner's full membership: this is what
|
|
177
188
|
// stops a credential reaching a group it was not issued for through any
|
|
178
189
|
// handler that consults `userGroups` instead of the `groupId` parameter.
|
|
179
|
-
req.userGroups = groupIdsOf(
|
|
190
|
+
req.userGroups = groupIdsOf(tenants.allowed);
|
|
180
191
|
req.apiKey = {
|
|
181
192
|
keyId: credential.credentialId,
|
|
182
193
|
scopes: credential.scopes,
|
|
183
|
-
tenants
|
|
194
|
+
tenants,
|
|
184
195
|
actingAs,
|
|
185
196
|
kind: credential.kind,
|
|
186
197
|
label: credential.label,
|
|
@@ -223,9 +234,13 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
if (!isAccessTokenClaims(claims)) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
237
|
+
if (!(await isCurrentSession(claims))) {
|
|
238
|
+
unauthorized(res);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const groups = await currentGroupsOf(claims as UserPayload);
|
|
242
|
+
req.user = { ...(claims as UserPayload), groups };
|
|
243
|
+
req.userGroups = groups;
|
|
229
244
|
beginAudit(req, res, next);
|
|
230
245
|
return;
|
|
231
246
|
}
|
|
@@ -240,7 +255,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
240
255
|
// `undefined`, scoped writes were refused as inaccessible, and a read
|
|
241
256
|
// whose scope collapsed to nothing fell through to "no user context" and
|
|
242
257
|
// answered from the whole collection.
|
|
243
|
-
admitAgent(req, res, next, requiredScopes, {
|
|
258
|
+
await admitAgent(req, res, next, requiredScopes, {
|
|
244
259
|
// The client, not the token: `jti` is a new value every hour.
|
|
245
260
|
credentialId: claims.clientId ?? 'unknown-client',
|
|
246
261
|
kind: 'oauth',
|
|
@@ -263,7 +278,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
263
278
|
return;
|
|
264
279
|
}
|
|
265
280
|
|
|
266
|
-
admitAgent(req, res, next, requiredScopes, {
|
|
281
|
+
await admitAgent(req, res, next, requiredScopes, {
|
|
267
282
|
credentialId: result.keyId!,
|
|
268
283
|
kind: 'api_key',
|
|
269
284
|
label: result.label || 'An API key',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const connection = { readyState: 1, collection: jest.fn() };
|
|
2
|
+
|
|
3
|
+
jest.mock('mongoose', () => {
|
|
4
|
+
const actual = jest.requireActual('mongoose');
|
|
5
|
+
return { __esModule: true, default: { connection, Types: actual.Types } };
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
import mongoose from 'mongoose';
|
|
9
|
+
import {
|
|
10
|
+
USER_SESSIONS_COLLECTION,
|
|
11
|
+
isCurrentSession,
|
|
12
|
+
isLegacySessionlessToken,
|
|
13
|
+
overrideSessionCheckForTests
|
|
14
|
+
} from './session';
|
|
15
|
+
|
|
16
|
+
const USER_ID = 'b'.repeat(24);
|
|
17
|
+
const SESSION_ID = 'a'.repeat(24);
|
|
18
|
+
const now = Math.floor(Date.now() / 1000);
|
|
19
|
+
|
|
20
|
+
describe('isLegacySessionlessToken', () => {
|
|
21
|
+
it('accepts a pre-sessions token that lived 24 hours at most', () => {
|
|
22
|
+
expect(isLegacySessionlessToken({ iat: now, exp: now + 24 * 60 * 60 })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('refuses a session-less token that lives longer, or has no expiry', () => {
|
|
26
|
+
expect(isLegacySessionlessToken({ iat: now, exp: now + 24 * 60 * 60 + 1 })).toBe(false);
|
|
27
|
+
expect(isLegacySessionlessToken({ iat: now })).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is never true for a token that names a session', () => {
|
|
31
|
+
expect(isLegacySessionlessToken({ sid: SESSION_ID, iat: now, exp: now + 60 })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('isCurrentSession', () => {
|
|
36
|
+
const findOne = jest.fn();
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
overrideSessionCheckForTests();
|
|
40
|
+
findOne.mockReset();
|
|
41
|
+
connection.readyState = 1;
|
|
42
|
+
connection.collection.mockReset().mockReturnValue({ findOne });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('accepts a live session that belongs to the token user', async () => {
|
|
46
|
+
findOne.mockResolvedValue({ _id: SESSION_ID });
|
|
47
|
+
|
|
48
|
+
await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(true);
|
|
49
|
+
|
|
50
|
+
expect(mongoose.connection.collection).toHaveBeenCalledWith(USER_SESSIONS_COLLECTION);
|
|
51
|
+
const [query, options] = findOne.mock.calls[0];
|
|
52
|
+
expect(query._id.toString()).toBe(SESSION_ID);
|
|
53
|
+
expect(query.userId.toString()).toBe(USER_ID);
|
|
54
|
+
expect(query.expiresAt.$gt).toBeInstanceOf(Date);
|
|
55
|
+
expect(options).toMatchObject({ readPreference: 'primary' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refuses a revoked or expired session', async () => {
|
|
59
|
+
findOne.mockResolvedValue(null);
|
|
60
|
+
|
|
61
|
+
await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('refuses while the database is unavailable rather than letting the token through', async () => {
|
|
65
|
+
connection.readyState = 0;
|
|
66
|
+
|
|
67
|
+
await expect(isCurrentSession({ id: USER_ID, sid: SESSION_ID })).resolves.toBe(false);
|
|
68
|
+
expect(findOne).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.each([
|
|
72
|
+
[{ id: USER_ID, sid: 'not-an-id' }],
|
|
73
|
+
[{ id: 'not-an-id', sid: SESSION_ID }],
|
|
74
|
+
[{ id: USER_ID, sid: 42 }],
|
|
75
|
+
[null]
|
|
76
|
+
])('refuses malformed claims %j without a lookup', async (claims) => {
|
|
77
|
+
await expect(isCurrentSession(claims)).resolves.toBe(false);
|
|
78
|
+
expect(findOne).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accepts a short-lived pre-sessions token without a lookup', async () => {
|
|
82
|
+
await expect(isCurrentSession({ id: USER_ID, iat: now, exp: now + 60 })).resolves.toBe(true);
|
|
83
|
+
expect(findOne).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('refuses a long-lived token with no session', async () => {
|
|
87
|
+
await expect(isCurrentSession({ id: USER_ID, iat: now, exp: now + 90 * 24 * 60 * 60 })).resolves.toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One document per signed-in device, owned and written by auth-service; every
|
|
5
|
+
* other service only reads it here. A session token names its document in the
|
|
6
|
+
* `sid` claim, and the token is only as good as that document: deleting it
|
|
7
|
+
* (sign-out, "sign out this device") ends the session on its next request, in
|
|
8
|
+
* every service, and its `expiresAt` (slid forward by auth-service while the
|
|
9
|
+
* device is in use, never past a hard cap from sign-in) is what idle expiry
|
|
10
|
+
* actually means.
|
|
11
|
+
*/
|
|
12
|
+
export const USER_SESSIONS_COLLECTION = 'user_sessions';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tokens minted before sessions existed carry no `sid` and lived 24 hours.
|
|
16
|
+
* auth-service upgrades one to a session the next time `/auth/verify` sees it,
|
|
17
|
+
* so this window closes by itself a day after the sessions release; the branch
|
|
18
|
+
* accepting them can be deleted after that.
|
|
19
|
+
*/
|
|
20
|
+
const LEGACY_TOKEN_MAX_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
21
|
+
|
|
22
|
+
const OBJECT_ID = /^[a-f\d]{24}$/i;
|
|
23
|
+
|
|
24
|
+
/** True for a pre-sessions token: no `sid`, and the old 24-hour lifetime at most. */
|
|
25
|
+
export function isLegacySessionlessToken(claims: unknown): boolean {
|
|
26
|
+
if (!claims || typeof claims !== 'object') return false;
|
|
27
|
+
const { sid, iat, exp } = claims as { sid?: unknown; iat?: unknown; exp?: unknown };
|
|
28
|
+
return (
|
|
29
|
+
sid === undefined &&
|
|
30
|
+
Number.isSafeInteger(iat) &&
|
|
31
|
+
Number.isSafeInteger(exp) &&
|
|
32
|
+
(exp as number) - (iat as number) <= LEGACY_TOKEN_MAX_LIFETIME_SECONDS
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SessionCheck = (claims: unknown) => Promise<boolean>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a verified session token still stands: for a token with a `sid`,
|
|
40
|
+
* its session belongs to the token's user and has neither been revoked nor
|
|
41
|
+
* expired. Read on the primary, uncached, so a revocation holds from the next
|
|
42
|
+
* request.
|
|
43
|
+
*/
|
|
44
|
+
const checkSessionInDatabase: SessionCheck = async (claims) => {
|
|
45
|
+
if (!claims || typeof claims !== 'object') return false;
|
|
46
|
+
const { id, sid } = claims as { id?: unknown; sid?: unknown };
|
|
47
|
+
if (sid === undefined) return isLegacySessionlessToken(claims);
|
|
48
|
+
if (typeof sid !== 'string' || !OBJECT_ID.test(sid)) return false;
|
|
49
|
+
if (typeof id !== 'string' || !OBJECT_ID.test(id)) return false;
|
|
50
|
+
// Refuse rather than queue an authentication query while the database is down.
|
|
51
|
+
if (mongoose.connection.readyState !== 1) return false;
|
|
52
|
+
|
|
53
|
+
const session = await mongoose.connection.collection(USER_SESSIONS_COLLECTION).findOne(
|
|
54
|
+
{
|
|
55
|
+
_id: new mongoose.Types.ObjectId(sid),
|
|
56
|
+
userId: new mongoose.Types.ObjectId(id),
|
|
57
|
+
expiresAt: { $gt: new Date() }
|
|
58
|
+
},
|
|
59
|
+
{ projection: { _id: 1 }, readPreference: 'primary', maxTimeMS: 3000 }
|
|
60
|
+
);
|
|
61
|
+
return session !== null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let sessionCheck: SessionCheck = checkSessionInDatabase;
|
|
65
|
+
|
|
66
|
+
export const isCurrentSession = (claims: unknown): Promise<boolean> => sessionCheck(claims);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the check, for tests only; pass nothing to restore the real one.
|
|
70
|
+
* Not exported from the package index: service suites reach it through the
|
|
71
|
+
* `@tumbaland/backend-core/testing` entry point (see testing.js).
|
|
72
|
+
*/
|
|
73
|
+
export function overrideSessionCheckForTests(check?: SessionCheck): void {
|
|
74
|
+
sessionCheck = check ?? checkSessionInDatabase;
|
|
75
|
+
}
|
package/src/groups/client.ts
CHANGED
|
@@ -76,7 +76,7 @@ interface GroupOwnerResponse {
|
|
|
76
76
|
* round, out through the proxy and back, carrying the internal token across the
|
|
77
77
|
* public edge.
|
|
78
78
|
*/
|
|
79
|
-
function internalGroupUrl(): string | undefined {
|
|
79
|
+
export function internalGroupUrl(): string | undefined {
|
|
80
80
|
return process.env.GROUP_INTERNAL_URL || process.env.GROUP_API_URL;
|
|
81
81
|
}
|
|
82
82
|
|
package/src/groups/index.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { clearMembershipCache, currentGroupsOf, getUserGroupIds } from './membership';
|
|
2
|
+
import logger from '../logging/logger';
|
|
3
|
+
|
|
4
|
+
jest.mock('../logging/logger', () => ({ __esModule: true, default: { error: jest.fn(), warn: jest.fn() } }));
|
|
5
|
+
|
|
6
|
+
const ORIGINAL_ENV = process.env;
|
|
7
|
+
const answer = (data: unknown, ok = true, status = 200) =>
|
|
8
|
+
({ ok, status, json: async () => ({ success: ok, data }) }) as unknown as Response;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
clearMembershipCache();
|
|
12
|
+
jest.useRealTimers();
|
|
13
|
+
process.env = {
|
|
14
|
+
...ORIGINAL_ENV,
|
|
15
|
+
GROUP_INTERNAL_URL: 'http://group-service:5006',
|
|
16
|
+
INTERNAL_SERVICE_TOKEN: 'internal-token',
|
|
17
|
+
SERVICE_NAME: 'album-service'
|
|
18
|
+
};
|
|
19
|
+
global.fetch = jest.fn().mockResolvedValue(answer(['g1', 'g2']));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
process.env = ORIGINAL_ENV;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('asks group-service over the internal channel', async () => {
|
|
27
|
+
await expect(getUserGroupIds('Ada@Example.com')).resolves.toEqual(['g1', 'g2']);
|
|
28
|
+
|
|
29
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
30
|
+
'http://group-service:5006/api/groups/mine/ids?userEmail=ada%40example.com',
|
|
31
|
+
expect.objectContaining({
|
|
32
|
+
headers: expect.objectContaining({ 'x-internal-token': 'internal-token', 'x-service-id': 'album-service' })
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('answers from the cache for 30 seconds, then asks again', async () => {
|
|
38
|
+
jest.useFakeTimers();
|
|
39
|
+
|
|
40
|
+
await getUserGroupIds('ada@example.com');
|
|
41
|
+
await getUserGroupIds('ADA@example.com');
|
|
42
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
43
|
+
|
|
44
|
+
jest.advanceTimersByTime(30_001);
|
|
45
|
+
await getUserGroupIds('ada@example.com');
|
|
46
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('shares one request between callers asking at the same time', async () => {
|
|
50
|
+
await Promise.all([getUserGroupIds('ada@example.com'), getUserGroupIds('ada@example.com')]);
|
|
51
|
+
|
|
52
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps the last known membership while group-service is unreachable', async () => {
|
|
56
|
+
jest.useFakeTimers();
|
|
57
|
+
await getUserGroupIds('ada@example.com');
|
|
58
|
+
jest.advanceTimersByTime(30_001);
|
|
59
|
+
(global.fetch as jest.Mock).mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
60
|
+
|
|
61
|
+
await expect(getUserGroupIds('ada@example.com')).resolves.toEqual(['g1', 'g2']);
|
|
62
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('grants no groups when it has never been able to ask', async () => {
|
|
66
|
+
(global.fetch as jest.Mock).mockResolvedValue(answer(null, false, 503));
|
|
67
|
+
|
|
68
|
+
await expect(getUserGroupIds('ada@example.com')).resolves.toEqual([]);
|
|
69
|
+
expect(logger.error).toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('grants no groups when not configured, without calling anything', async () => {
|
|
73
|
+
delete process.env.GROUP_INTERNAL_URL;
|
|
74
|
+
delete process.env.GROUP_API_URL;
|
|
75
|
+
|
|
76
|
+
await expect(getUserGroupIds('ada@example.com')).resolves.toEqual([]);
|
|
77
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('never takes the groups from the token', async () => {
|
|
81
|
+
(global.fetch as jest.Mock).mockResolvedValue(answer(['g2']));
|
|
82
|
+
|
|
83
|
+
await expect(currentGroupsOf({ email: 'ada@example.com', groups: ['g1', 'g9'] })).resolves.toEqual(['g2']);
|
|
84
|
+
});
|