@tumbaland/backend-core 1.37.0 → 1.39.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/ApiKey.d.ts +23 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +19 -2
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +17 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +71 -34
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +11 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +43 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.js.map +1 -1
- package/dist/audit/AuditEvent.d.ts +51 -0
- package/dist/audit/AuditEvent.d.ts.map +1 -0
- package/dist/audit/AuditEvent.js +69 -0
- package/dist/audit/AuditEvent.js.map +1 -0
- package/dist/audit/actor.d.ts +41 -0
- package/dist/audit/actor.d.ts.map +1 -0
- package/dist/audit/actor.js +39 -0
- package/dist/audit/actor.js.map +1 -0
- package/dist/audit/context.d.ts +40 -0
- package/dist/audit/context.d.ts.map +1 -0
- package/dist/audit/context.js +60 -0
- package/dist/audit/context.js.map +1 -0
- package/dist/audit/index.d.ts +12 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +22 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/plugin.d.ts +23 -0
- package/dist/audit/plugin.d.ts.map +1 -0
- package/dist/audit/plugin.js +226 -0
- package/dist/audit/plugin.js.map +1 -0
- package/dist/audit/reads.d.ts +47 -0
- package/dist/audit/reads.d.ts.map +1 -0
- package/dist/audit/reads.js +94 -0
- package/dist/audit/reads.js.map +1 -0
- package/dist/audit/service.d.ts +49 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +65 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/models.d.ts +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +19 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +37 -5
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +9 -2
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +25 -1
- package/src/apiKeys/ApiKey.ts +31 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +126 -20
- package/src/apiKeys/middleware.ts +99 -39
- package/src/apiKeys/service.test.ts +58 -9
- package/src/apiKeys/service.ts +25 -6
- package/src/apiKeys/types.ts +63 -8
- package/src/audit/AuditEvent.ts +79 -0
- package/src/audit/actor.test.ts +95 -0
- package/src/audit/actor.ts +68 -0
- package/src/audit/context.test.ts +91 -0
- package/src/audit/context.ts +83 -0
- package/src/audit/index.ts +11 -0
- package/src/audit/plugin.test.ts +258 -0
- package/src/audit/plugin.ts +254 -0
- package/src/audit/reads.test.ts +164 -0
- package/src/audit/reads.ts +88 -0
- package/src/audit/service.test.ts +115 -0
- package/src/audit/service.ts +92 -0
- package/src/index.ts +1 -0
- package/src/middleware/authMiddleware.test.ts +6 -1
- package/src/oauth/models.ts +32 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +22 -5
- package/src/oauth/tokens.ts +52 -7
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import { AuditEvent } from './AuditEvent';
|
|
3
|
+
import { actorOf, type Actor } from './actor';
|
|
4
|
+
import logger from '../logging/logger';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Record what software read, and only what software read.
|
|
8
|
+
*
|
|
9
|
+
* A person browsing their own journal generates nothing here. That is not a
|
|
10
|
+
* shortcut: one dashboard load hits half a dozen endpoints, so logging it would
|
|
11
|
+
* bury the interesting rows under thousands of uninteresting ones and put a
|
|
12
|
+
* database write behind every page view.
|
|
13
|
+
*
|
|
14
|
+
* What an assistant read is the part worth keeping, because it is the part that
|
|
15
|
+
* left the server. A created entry leaves a record behind either way; a read
|
|
16
|
+
* leaves nothing at all, and it is exactly what the privacy page warns about —
|
|
17
|
+
* ask an assistant what you did last month and those entries go to whoever runs
|
|
18
|
+
* it. This is the only place that becomes visible.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Arm the read trail for one request, if it is one worth recording.
|
|
22
|
+
*
|
|
23
|
+
* Nothing is written here — the listener fires when the response finishes, so a
|
|
24
|
+
* refused request is not recorded as a read and the count reflects what was
|
|
25
|
+
* actually sent.
|
|
26
|
+
*/
|
|
27
|
+
export function recordRead(req: Request, res: Response, actor: Actor, service: string): void {
|
|
28
|
+
if (req.method !== 'GET' || actor.kind === 'person') return;
|
|
29
|
+
|
|
30
|
+
res.on('finish', () => {
|
|
31
|
+
if (res.statusCode >= 400) return;
|
|
32
|
+
|
|
33
|
+
void AuditEvent.create({
|
|
34
|
+
at: new Date(),
|
|
35
|
+
service,
|
|
36
|
+
action: 'read',
|
|
37
|
+
resource: resourceOf(req.baseUrl + req.path),
|
|
38
|
+
actorKind: actor.kind,
|
|
39
|
+
userId: actor.userId,
|
|
40
|
+
actorLabel: actor.label,
|
|
41
|
+
actorCredentialId: actor.credentialId,
|
|
42
|
+
tenant: actor.tenant,
|
|
43
|
+
count: res.locals.auditCount as number | undefined
|
|
44
|
+
}).catch((error) => {
|
|
45
|
+
logger.warn('Could not record a read', { error: (error as Error)?.message });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The same thing as standalone middleware, for anything that authenticates its
|
|
52
|
+
* own way rather than through `authenticateAgent`.
|
|
53
|
+
*/
|
|
54
|
+
export const auditReads =
|
|
55
|
+
(service: string): RequestHandler =>
|
|
56
|
+
(req: Request, res: Response, next: NextFunction): void => {
|
|
57
|
+
const actor = actorOf(req);
|
|
58
|
+
if (actor) recordRead(req, res, actor, service);
|
|
59
|
+
next();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What a path was asking for, in one word.
|
|
64
|
+
*
|
|
65
|
+
* The first path segment, which is the collection on every route here —
|
|
66
|
+
* `/api/activities/68d6…` is about activities. Deliberately crude: a trail
|
|
67
|
+
* naming the resource is readable, and one naming the full path with ids in it
|
|
68
|
+
* is a request log wearing an audit log's clothes.
|
|
69
|
+
*/
|
|
70
|
+
export function resourceOf(path: string): string {
|
|
71
|
+
const segments = path.split('/').filter((segment) => segment && segment !== 'api');
|
|
72
|
+
return segments[0]?.replace(/-/g, '_') ?? 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How many records a response carried.
|
|
77
|
+
*
|
|
78
|
+
* Set by a handler that knows; absent otherwise. The size of what left the
|
|
79
|
+
* server is most of what makes a read worth recording — "read the journal" and
|
|
80
|
+
* "read four hundred entries of the journal" are different events.
|
|
81
|
+
*/
|
|
82
|
+
export const countRead = (res: Response, count: number): void => {
|
|
83
|
+
// Guarded for the same reason recording is fire-and-forget: instrumentation
|
|
84
|
+
// must not be able to fail the thing it instruments. Express always provides
|
|
85
|
+
// `locals`, but a handler called directly does not have to.
|
|
86
|
+
if (!res?.locals) return;
|
|
87
|
+
res.locals.auditCount = count;
|
|
88
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
3
|
+
|
|
4
|
+
import { AuditEvent } from './AuditEvent';
|
|
5
|
+
import { listAuditEvents } from './service';
|
|
6
|
+
|
|
7
|
+
let mongo: MongoMemoryServer;
|
|
8
|
+
|
|
9
|
+
const event = (over: Record<string, unknown> = {}) => ({
|
|
10
|
+
at: new Date('2026-09-05T10:00:00Z'),
|
|
11
|
+
service: 'relationship-service',
|
|
12
|
+
action: 'create' as const,
|
|
13
|
+
resource: 'activity',
|
|
14
|
+
actorKind: 'assistant' as const,
|
|
15
|
+
userId: 'u1',
|
|
16
|
+
actorLabel: 'Claude',
|
|
17
|
+
actorCredentialId: 'client-1',
|
|
18
|
+
tenant: 'g1',
|
|
19
|
+
...over
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
mongo = await MongoMemoryServer.create();
|
|
24
|
+
await mongoose.connect(mongo.getUri());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await mongoose.disconnect();
|
|
29
|
+
await mongo.stop();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
beforeEach(() => AuditEvent.deleteMany({}));
|
|
33
|
+
|
|
34
|
+
describe('listAuditEvents', () => {
|
|
35
|
+
it('returns one account’s trail and never anyone else’s', async () => {
|
|
36
|
+
await AuditEvent.insertMany([event(), event({ userId: 'u2', actorLabel: 'Someone else' })]);
|
|
37
|
+
|
|
38
|
+
const { events, total } = await listAuditEvents({ userId: 'u1' });
|
|
39
|
+
|
|
40
|
+
// Scoped by the query rather than filtered afterwards: the trail carries old
|
|
41
|
+
// values of journal entries, so a wider read must not be one forgotten
|
|
42
|
+
// condition away.
|
|
43
|
+
expect(total).toBe(1);
|
|
44
|
+
expect(events[0].actorLabel).toBe('Claude');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns newest first, which is the only order anyone reads a trail in', async () => {
|
|
48
|
+
await AuditEvent.insertMany([
|
|
49
|
+
event({ at: new Date('2026-09-01T10:00:00Z'), resource: 'older' }),
|
|
50
|
+
event({ at: new Date('2026-09-05T10:00:00Z'), resource: 'newer' })
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const { events } = await listAuditEvents({ userId: 'u1' });
|
|
54
|
+
expect(events.map((e) => e.resource)).toEqual(['newer', 'older']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('narrows to one assistant, which is the question people actually ask', async () => {
|
|
58
|
+
await AuditEvent.insertMany([
|
|
59
|
+
event({ actorCredentialId: 'client-1', actorLabel: 'Claude' }),
|
|
60
|
+
event({ actorCredentialId: 'client-2', actorLabel: 'ChatGPT' })
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const { events } = await listAuditEvents({ userId: 'u1', credentialId: 'client-1' });
|
|
64
|
+
|
|
65
|
+
expect(events).toHaveLength(1);
|
|
66
|
+
expect(events[0].actorLabel).toBe('Claude');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('separates what software did from what a person did', async () => {
|
|
70
|
+
await AuditEvent.insertMany([
|
|
71
|
+
event({ actorKind: 'assistant' }),
|
|
72
|
+
event({ actorKind: 'person', actorLabel: 'Tom' })
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const { events } = await listAuditEvents({ userId: 'u1', actorKind: 'person' });
|
|
76
|
+
expect(events.map((e) => e.actorLabel)).toEqual(['Tom']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('narrows by action and by date range', async () => {
|
|
80
|
+
await AuditEvent.insertMany([
|
|
81
|
+
event({ action: 'delete', at: new Date('2026-09-01T10:00:00Z') }),
|
|
82
|
+
event({ action: 'delete', at: new Date('2026-09-05T10:00:00Z') }),
|
|
83
|
+
event({ action: 'read', at: new Date('2026-09-05T11:00:00Z') })
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const { events } = await listAuditEvents({
|
|
87
|
+
userId: 'u1',
|
|
88
|
+
action: 'delete',
|
|
89
|
+
from: new Date('2026-09-03T00:00:00Z')
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(events).toHaveLength(1);
|
|
93
|
+
expect(events[0].at).toBe('2026-09-05T10:00:00.000Z');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('hands back the change detail, which is the reason to look', async () => {
|
|
97
|
+
await AuditEvent.insertMany([event({ action: 'update', changes: { hours: { from: 3, to: 2 } } })]);
|
|
98
|
+
|
|
99
|
+
const { events } = await listAuditEvents({ userId: 'u1' });
|
|
100
|
+
expect(events[0].changes).toEqual({ hours: { from: 3, to: 2 } });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('pages, and caps a caller asking for everything at once', async () => {
|
|
104
|
+
await AuditEvent.insertMany(Array.from({ length: 5 }, (_, i) => event({ resource: `r${i}` })));
|
|
105
|
+
|
|
106
|
+
const { events, total, totalPages } = await listAuditEvents({ userId: 'u1', limit: 2, page: 2 });
|
|
107
|
+
|
|
108
|
+
expect(events).toHaveLength(2);
|
|
109
|
+
expect(total).toBe(5);
|
|
110
|
+
expect(totalPages).toBe(3);
|
|
111
|
+
|
|
112
|
+
const capped = await listAuditEvents({ userId: 'u1', limit: 5000 });
|
|
113
|
+
expect(capped.events.length).toBeLessThanOrEqual(200);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AuditEvent, type IAuditEvent } from './AuditEvent';
|
|
2
|
+
import type { ActorKind } from './actor';
|
|
3
|
+
|
|
4
|
+
export interface AuditQuery {
|
|
5
|
+
userId: string;
|
|
6
|
+
/** narrow to one actor — a key's id or an OAuth client id */
|
|
7
|
+
credentialId?: string;
|
|
8
|
+
actorKind?: ActorKind;
|
|
9
|
+
action?: IAuditEvent['action'];
|
|
10
|
+
resource?: string;
|
|
11
|
+
/** narrow to one area — 'relationship-service', 'album-service' */
|
|
12
|
+
service?: string;
|
|
13
|
+
from?: Date;
|
|
14
|
+
to?: Date;
|
|
15
|
+
limit?: number;
|
|
16
|
+
page?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuditEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
at: string;
|
|
22
|
+
service: string;
|
|
23
|
+
action: IAuditEvent['action'];
|
|
24
|
+
resource: string;
|
|
25
|
+
resourceId?: string;
|
|
26
|
+
actorKind: ActorKind;
|
|
27
|
+
actorLabel: string;
|
|
28
|
+
actorCredentialId?: string;
|
|
29
|
+
tenant: string;
|
|
30
|
+
changes?: Record<string, { from: unknown; to: unknown }>;
|
|
31
|
+
snapshot?: Record<string, unknown>;
|
|
32
|
+
count?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toEntry = (event: IAuditEvent): AuditEntry => ({
|
|
36
|
+
id: String(event._id),
|
|
37
|
+
at: event.at.toISOString(),
|
|
38
|
+
service: event.service,
|
|
39
|
+
action: event.action,
|
|
40
|
+
resource: event.resource,
|
|
41
|
+
resourceId: event.resourceId,
|
|
42
|
+
actorKind: event.actorKind,
|
|
43
|
+
actorLabel: event.actorLabel,
|
|
44
|
+
actorCredentialId: event.actorCredentialId,
|
|
45
|
+
tenant: event.tenant,
|
|
46
|
+
changes: event.changes,
|
|
47
|
+
snapshot: event.snapshot,
|
|
48
|
+
count: event.count
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One account's trail, newest first.
|
|
53
|
+
*
|
|
54
|
+
* Always scoped to `userId` by the query rather than filtered afterwards, so
|
|
55
|
+
* there is no path where a wider read is one forgotten condition away. The trail
|
|
56
|
+
* carries old values of journal entries, which makes it as sensitive as the
|
|
57
|
+
* journal itself.
|
|
58
|
+
*/
|
|
59
|
+
export const listAuditEvents = async (
|
|
60
|
+
query: AuditQuery
|
|
61
|
+
): Promise<{ events: AuditEntry[]; total: number; page: number; totalPages: number }> => {
|
|
62
|
+
const limit = Math.min(query.limit ?? 50, 200);
|
|
63
|
+
const page = Math.max(query.page ?? 1, 1);
|
|
64
|
+
|
|
65
|
+
const filter: Record<string, unknown> = { userId: query.userId };
|
|
66
|
+
if (query.credentialId) filter.actorCredentialId = query.credentialId;
|
|
67
|
+
if (query.actorKind) filter.actorKind = query.actorKind;
|
|
68
|
+
if (query.action) filter.action = query.action;
|
|
69
|
+
if (query.resource) filter.resource = query.resource;
|
|
70
|
+
if (query.service) filter.service = query.service;
|
|
71
|
+
if (query.from || query.to) {
|
|
72
|
+
const at: Record<string, Date> = {};
|
|
73
|
+
if (query.from) at.$gte = query.from;
|
|
74
|
+
if (query.to) at.$lte = query.to;
|
|
75
|
+
filter.at = at;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [events, total] = await Promise.all([
|
|
79
|
+
AuditEvent.find(filter)
|
|
80
|
+
.sort({ at: -1 })
|
|
81
|
+
.skip((page - 1) * limit)
|
|
82
|
+
.limit(limit),
|
|
83
|
+
AuditEvent.countDocuments(filter)
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
events: events.map(toEntry),
|
|
88
|
+
total,
|
|
89
|
+
page,
|
|
90
|
+
totalPages: Math.ceil(total / limit)
|
|
91
|
+
};
|
|
92
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Request, Response } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { authenticateToken, optionalAuth } from './authMiddleware';
|
|
4
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
4
5
|
import { mintAccessToken } from '../oauth/tokens';
|
|
5
6
|
import { UserPayload } from '../types/auth';
|
|
6
7
|
|
|
@@ -192,7 +193,11 @@ describe('OAuth access tokens are not sessions', () => {
|
|
|
192
193
|
resource: 'https://mcp.example.com',
|
|
193
194
|
issuer: 'https://auth.example.com',
|
|
194
195
|
scopes: ['relationship:read'],
|
|
195
|
-
|
|
196
|
+
tenants: [PERSONAL_TENANT],
|
|
197
|
+
defaultTenant: PERSONAL_TENANT,
|
|
198
|
+
tenantNames: {},
|
|
199
|
+
clientId: 'client-1',
|
|
200
|
+
clientName: 'Claude'
|
|
196
201
|
}).accessToken;
|
|
197
202
|
|
|
198
203
|
it('refuses one on a session-only route', () => {
|
package/src/oauth/models.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import { auditPlugin } from '../audit/plugin';
|
|
3
|
+
import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* A client registered through RFC 7591 Dynamic Client Registration.
|
|
@@ -46,7 +48,13 @@ export interface IAuthorizationCode extends Document {
|
|
|
46
48
|
userName: string;
|
|
47
49
|
redirectUri: string;
|
|
48
50
|
scopes: string[];
|
|
49
|
-
|
|
51
|
+
/** every tenant the user approved, and the one chosen as the default */
|
|
52
|
+
tenants: Tenant[];
|
|
53
|
+
defaultTenant: Tenant;
|
|
54
|
+
/** what each approved tenant is called, snapshotted at consent */
|
|
55
|
+
tenantNames: Record<Tenant, string>;
|
|
56
|
+
/** the single tenant an older grant was pinned to; read by `readTenants` */
|
|
57
|
+
groupId?: string | null;
|
|
50
58
|
/** RFC 8707: the MCP server this will be minted for */
|
|
51
59
|
resource: string;
|
|
52
60
|
/** PKCE, always S256 — the spec does not allow `plain` */
|
|
@@ -66,6 +74,10 @@ const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
|
|
|
66
74
|
userName: { type: String, default: '' },
|
|
67
75
|
redirectUri: { type: String, required: true },
|
|
68
76
|
scopes: { type: [String], default: [] },
|
|
77
|
+
tenants: { type: [String], default: () => [PERSONAL_TENANT] },
|
|
78
|
+
defaultTenant: { type: String, default: PERSONAL_TENANT },
|
|
79
|
+
tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
80
|
+
// Written by the single-tenant model this replaced; read-only now.
|
|
69
81
|
groupId: { type: String, default: null },
|
|
70
82
|
resource: { type: String, required: true },
|
|
71
83
|
codeChallenge: { type: String, required: true },
|
|
@@ -96,7 +108,11 @@ export interface IRefreshToken extends Document {
|
|
|
96
108
|
clientId: string;
|
|
97
109
|
userId: string;
|
|
98
110
|
scopes: string[];
|
|
99
|
-
|
|
111
|
+
tenants: Tenant[];
|
|
112
|
+
defaultTenant: Tenant;
|
|
113
|
+
tenantNames: Record<Tenant, string>;
|
|
114
|
+
/** the single tenant an older grant was pinned to; read by `readTenants` */
|
|
115
|
+
groupId?: string | null;
|
|
100
116
|
resource: string;
|
|
101
117
|
/**
|
|
102
118
|
* When the user actually connected this assistant.
|
|
@@ -118,6 +134,10 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
|
|
|
118
134
|
clientId: { type: String, required: true },
|
|
119
135
|
userId: { type: String, required: true, index: true },
|
|
120
136
|
scopes: { type: [String], default: [] },
|
|
137
|
+
tenants: { type: [String], default: () => [PERSONAL_TENANT] },
|
|
138
|
+
defaultTenant: { type: String, default: PERSONAL_TENANT },
|
|
139
|
+
tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
140
|
+
// Written by the single-tenant model this replaced; read-only now.
|
|
121
141
|
groupId: { type: String, default: null },
|
|
122
142
|
resource: { type: String, required: true },
|
|
123
143
|
grantedAt: { type: Date, default: Date.now },
|
|
@@ -129,6 +149,16 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
|
|
|
129
149
|
|
|
130
150
|
RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
|
|
131
151
|
|
|
152
|
+
/**
|
|
153
|
+
* A grant appearing and being revoked is the shape of "who connected Claude,
|
|
154
|
+
* and when did it stop" — the question the connections page answers for the
|
|
155
|
+
* present and this answers for the past.
|
|
156
|
+
*
|
|
157
|
+
* `tokenHash` is redacted: it is the only thing standing between a copy of this
|
|
158
|
+
* collection and a working refresh token.
|
|
159
|
+
*/
|
|
160
|
+
RefreshTokenSchema.plugin(auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
|
|
161
|
+
|
|
132
162
|
export const RefreshToken = mongoose.models.RefreshToken
|
|
133
163
|
? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
|
|
134
164
|
: mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
2
3
|
|
|
3
4
|
jest.mock('./models', () => ({
|
|
4
5
|
OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
|
|
@@ -122,13 +123,21 @@ describe('issueAuthorizationCode', () => {
|
|
|
122
123
|
userName: 'Tester',
|
|
123
124
|
redirectUri: 'https://claude.ai/callback',
|
|
124
125
|
scopes: ['relationship:read'],
|
|
125
|
-
|
|
126
|
+
tenants: ['g1'],
|
|
127
|
+
defaultTenant: 'g1',
|
|
128
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
126
129
|
resource: 'https://mcp.tumbaland.eu',
|
|
127
130
|
codeChallenge: CHALLENGE
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
const [doc] = mockedCodeCreate.mock.calls[0];
|
|
131
|
-
expect(doc).toMatchObject({
|
|
134
|
+
expect(doc).toMatchObject({
|
|
135
|
+
userId: 'u1',
|
|
136
|
+
tenants: ['g1'],
|
|
137
|
+
defaultTenant: 'g1',
|
|
138
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
139
|
+
scopes: ['relationship:read']
|
|
140
|
+
});
|
|
132
141
|
expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
|
|
133
142
|
});
|
|
134
143
|
});
|
|
@@ -224,7 +233,9 @@ describe('issueRefreshToken', () => {
|
|
|
224
233
|
clientId: 'client-1',
|
|
225
234
|
userId: 'u1',
|
|
226
235
|
scopes: ['relationship:read'],
|
|
227
|
-
|
|
236
|
+
tenants: [PERSONAL_TENANT],
|
|
237
|
+
defaultTenant: PERSONAL_TENANT,
|
|
238
|
+
tenantNames: {},
|
|
228
239
|
resource: 'https://mcp.tumbaland.eu'
|
|
229
240
|
});
|
|
230
241
|
|
|
@@ -311,7 +322,9 @@ describe('refresh tokens', () => {
|
|
|
311
322
|
clientId: 'client-1',
|
|
312
323
|
userId: 'u1',
|
|
313
324
|
scopes: ['relationship:read'],
|
|
314
|
-
|
|
325
|
+
tenants: [PERSONAL_TENANT],
|
|
326
|
+
defaultTenant: PERSONAL_TENANT,
|
|
327
|
+
tenantNames: {},
|
|
315
328
|
resource: 'https://mcp.tumbaland.eu'
|
|
316
329
|
});
|
|
317
330
|
|
|
@@ -324,7 +337,9 @@ describe('refresh tokens', () => {
|
|
|
324
337
|
mockedRefreshFindOne.mockResolvedValue({
|
|
325
338
|
userId: 'u1',
|
|
326
339
|
scopes: ['relationship:read'],
|
|
327
|
-
|
|
340
|
+
tenants: [PERSONAL_TENANT, 'g1'],
|
|
341
|
+
defaultTenant: 'g1',
|
|
342
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
328
343
|
resource: 'https://mcp.tumbaland.eu',
|
|
329
344
|
grantedAt: new Date(),
|
|
330
345
|
save: jest.fn()
|
|
@@ -333,7 +348,44 @@ describe('refresh tokens', () => {
|
|
|
333
348
|
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
|
|
334
349
|
ok: true,
|
|
335
350
|
userId: 'u1',
|
|
336
|
-
|
|
351
|
+
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('carries the tenants through rotation rather than re-deriving them', async () => {
|
|
356
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
357
|
+
userId: 'u1',
|
|
358
|
+
scopes: ['relationship:read'],
|
|
359
|
+
tenants: [PERSONAL_TENANT, 'g1'],
|
|
360
|
+
defaultTenant: 'g1',
|
|
361
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
362
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
363
|
+
grantedAt: new Date(),
|
|
364
|
+
save: jest.fn()
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await redeemRefreshToken('tok', 'client-1');
|
|
368
|
+
|
|
369
|
+
// Rotation reissues the same grant; re-deriving would quietly narrow a
|
|
370
|
+
// connection every time it refreshed.
|
|
371
|
+
const [doc] = mockedRefreshCreate.mock.calls.at(-1)!;
|
|
372
|
+
expect(doc.tenants).toEqual([PERSONAL_TENANT, 'g1']);
|
|
373
|
+
expect(doc.defaultTenant).toBe('g1');
|
|
374
|
+
expect(doc.tenantNames).toEqual({ g1: 'Irja & Tom' });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('reads a grant issued before tenants were a set', async () => {
|
|
378
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
379
|
+
userId: 'u1',
|
|
380
|
+
scopes: ['relationship:read'],
|
|
381
|
+
groupId: 'g1',
|
|
382
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
383
|
+
grantedAt: new Date(),
|
|
384
|
+
save: jest.fn()
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
|
|
388
|
+
tenants: { allowed: ['g1'], default: 'g1' }
|
|
337
389
|
});
|
|
338
390
|
});
|
|
339
391
|
|
package/src/oauth/service.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
2
2
|
import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
readTenants,
|
|
5
|
+
type ApiKeyScope,
|
|
6
|
+
type ApiKeyTenant,
|
|
7
|
+
type Tenant
|
|
8
|
+
} from '../apiKeys/types';
|
|
4
9
|
|
|
5
10
|
/** How long a user has between approving and the client exchanging the code. */
|
|
6
11
|
const CODE_TTL_MS = 60 * 1000;
|
|
@@ -84,7 +89,9 @@ export interface IssueCodeInput {
|
|
|
84
89
|
userName: string;
|
|
85
90
|
redirectUri: string;
|
|
86
91
|
scopes: ApiKeyScope[];
|
|
87
|
-
|
|
92
|
+
tenants: Tenant[];
|
|
93
|
+
defaultTenant: Tenant;
|
|
94
|
+
tenantNames: Record<Tenant, string>;
|
|
88
95
|
resource: string;
|
|
89
96
|
codeChallenge: string;
|
|
90
97
|
}
|
|
@@ -166,7 +173,9 @@ export const issueRefreshToken = async (input: {
|
|
|
166
173
|
clientId: string;
|
|
167
174
|
userId: string;
|
|
168
175
|
scopes: ApiKeyScope[];
|
|
169
|
-
|
|
176
|
+
tenants: Tenant[];
|
|
177
|
+
defaultTenant: Tenant;
|
|
178
|
+
tenantNames: Record<Tenant, string>;
|
|
170
179
|
resource: string;
|
|
171
180
|
}): Promise<IssuedRefreshToken> => {
|
|
172
181
|
await RefreshToken.updateMany(
|
|
@@ -185,7 +194,8 @@ export interface RefreshRedemption {
|
|
|
185
194
|
reused?: boolean;
|
|
186
195
|
userId?: string;
|
|
187
196
|
scopes?: ApiKeyScope[];
|
|
188
|
-
|
|
197
|
+
tenants?: ApiKeyTenant;
|
|
198
|
+
tenantNames?: Record<Tenant, string>;
|
|
189
199
|
resource?: string;
|
|
190
200
|
/** the replacement the client must store; the presented one is now dead */
|
|
191
201
|
rotatedToken?: string;
|
|
@@ -233,7 +243,11 @@ export const redeemRefreshToken = async (
|
|
|
233
243
|
clientId,
|
|
234
244
|
userId: record.userId,
|
|
235
245
|
scopes: record.scopes,
|
|
236
|
-
|
|
246
|
+
// Carried, not reset: rotation reissues the same grant, and re-deriving the
|
|
247
|
+
// tenants would quietly narrow a connection every time it refreshed.
|
|
248
|
+
tenants: record.tenants,
|
|
249
|
+
defaultTenant: record.defaultTenant,
|
|
250
|
+
tenantNames: record.tenantNames,
|
|
237
251
|
resource: record.resource,
|
|
238
252
|
// Carried, not reset: this is still the grant the user approved.
|
|
239
253
|
grantedAt: record.grantedAt ?? record.createdAt
|
|
@@ -243,7 +257,8 @@ export const redeemRefreshToken = async (
|
|
|
243
257
|
ok: true,
|
|
244
258
|
userId: record.userId,
|
|
245
259
|
scopes: record.scopes as ApiKeyScope[],
|
|
246
|
-
|
|
260
|
+
tenants: readTenants(record),
|
|
261
|
+
tenantNames: record.tenantNames ?? {},
|
|
247
262
|
resource: record.resource,
|
|
248
263
|
rotatedToken: rotated
|
|
249
264
|
};
|
|
@@ -271,7 +286,10 @@ export const listConnections = async (userId: string) => {
|
|
|
271
286
|
clientId: token.clientId,
|
|
272
287
|
clientName: nameById.get(token.clientId) ?? 'Unknown app',
|
|
273
288
|
scopes: token.scopes,
|
|
274
|
-
|
|
289
|
+
...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
|
|
290
|
+
readTenants(token)
|
|
291
|
+
),
|
|
292
|
+
tenantNames: token.tenantNames ?? {},
|
|
275
293
|
createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
|
|
276
294
|
// Set when the refresh token is exchanged, not when a tool runs — access
|
|
277
295
|
// tokens are validated statelessly, so the server never sees ordinary use.
|
package/src/oauth/tokens.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const ORIGINAL_ENV = process.env;
|
|
2
2
|
|
|
3
3
|
import jwt from 'jsonwebtoken';
|
|
4
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
4
5
|
import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
|
|
5
6
|
|
|
6
7
|
const RESOURCE = 'https://mcp.tumbaland.eu';
|
|
@@ -14,7 +15,11 @@ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
|
|
|
14
15
|
resource: RESOURCE,
|
|
15
16
|
issuer: ISSUER,
|
|
16
17
|
scopes: ['relationship:read', 'relationship:write'],
|
|
17
|
-
|
|
18
|
+
tenants: [PERSONAL_TENANT],
|
|
19
|
+
defaultTenant: PERSONAL_TENANT,
|
|
20
|
+
tenantNames: { [PERSONAL_TENANT]: 'My own data' },
|
|
21
|
+
clientId: 'client-1',
|
|
22
|
+
clientName: 'Claude',
|
|
18
23
|
...over
|
|
19
24
|
});
|
|
20
25
|
|
|
@@ -38,9 +43,21 @@ describe('mintAccessToken', () => {
|
|
|
38
43
|
expect(expiresIn).toBe(3600);
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
it('carries the
|
|
42
|
-
const claims = jwt.decode(
|
|
43
|
-
|
|
46
|
+
it('carries the granted tenants, so a token cannot wander outside them', () => {
|
|
47
|
+
const claims = jwt.decode(
|
|
48
|
+
mint({ tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' }).accessToken
|
|
49
|
+
) as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
expect(claims.tenants).toEqual([PERSONAL_TENANT, 'g1']);
|
|
52
|
+
expect(claims.defaultTenant).toBe('g1');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('carries what each tenant is called, since nothing downstream can look it up', () => {
|
|
56
|
+
const claims = jwt.decode(
|
|
57
|
+
mint({ tenants: ['g1'], defaultTenant: 'g1', tenantNames: { g1: 'Irja & Tom' } }).accessToken
|
|
58
|
+
) as Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
expect(claims.tenantNames).toEqual({ g1: 'Irja & Tom' });
|
|
44
61
|
});
|
|
45
62
|
|
|
46
63
|
it('gives every token a distinct id', () => {
|
|
@@ -59,7 +76,7 @@ describe('verifyAccessToken', () => {
|
|
|
59
76
|
userId: 'u1',
|
|
60
77
|
email: 'u1@example.com',
|
|
61
78
|
scopes: ['relationship:read', 'relationship:write'],
|
|
62
|
-
|
|
79
|
+
tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
|
|
63
80
|
});
|
|
64
81
|
});
|
|
65
82
|
|