@bevel-software/platform-core-backend 0.9.1 → 0.11.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/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +173 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +182 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
- package/src/version.ts +28 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
6
|
+
import { createMcpRoutes } from '../mcp.routes.js';
|
|
7
|
+
import { MCP_LOOPBACK_TOKEN_TTL_MS } from '../mcp.service.js';
|
|
8
|
+
import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
|
|
9
|
+
import { createTokenVerifier } from '../../tool-auth/tool-auth.middleware.js';
|
|
10
|
+
import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
|
|
11
|
+
import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Coverage for POST /api/mcp/local-token — the OAuth-access-token → internal
|
|
15
|
+
* loopback-token exchange the LOCAL MCP server uses to reach the
|
|
16
|
+
* keys+internal-tokens-only `/api/agent/*` surface. Locks down:
|
|
17
|
+
*
|
|
18
|
+
* - a verified OAuth token mints an internal token that the tool-auth
|
|
19
|
+
* verifier resolves to the SAME identity createSession's loopback bearer
|
|
20
|
+
* gets (right userId, externalProxy → source 'external'), with the shared
|
|
21
|
+
* MCP_LOOPBACK_TOKEN_TTL_MS lifetime;
|
|
22
|
+
* - an expired/revoked OAuth token re-challenges (401 + resource_metadata),
|
|
23
|
+
* mirroring McpAuthMiddleware's OAuth branch — including a grant the
|
|
24
|
+
* provider verifies but reports as out of lifetime, which must never
|
|
25
|
+
* become a 200 carrying a dead token;
|
|
26
|
+
* - every other credential shape (connection key, JWT) is 403 — those need
|
|
27
|
+
* no exchange, so accepting them would mint a second credential from a
|
|
28
|
+
* first;
|
|
29
|
+
* - missing/garbage auth is 401 with the discovery challenge.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const RESOURCE_METADATA_URL = 'https://bevel.example/.well-known/oauth-protected-resource/api/mcp';
|
|
33
|
+
|
|
34
|
+
// Stand-in for routes this suite never exercises.
|
|
35
|
+
const fakeAuth: RequestHandler = (req, _res, next) => {
|
|
36
|
+
req.userId = 'user-A';
|
|
37
|
+
next();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let httpServer: HttpServer | undefined;
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
if (httpServer) {
|
|
44
|
+
await new Promise<void>((resolve) => httpServer!.close(() => resolve()));
|
|
45
|
+
httpServer = undefined;
|
|
46
|
+
}
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function makeOAuthProvider(
|
|
51
|
+
verify?: (t: string) => Promise<{ extra?: Record<string, unknown>; expiresAt?: number }>,
|
|
52
|
+
) {
|
|
53
|
+
return {
|
|
54
|
+
looksLikeAccessToken: (t: string) => typeof t === 'string' && t.startsWith('bevel-mcp_'),
|
|
55
|
+
verifyAccessToken: vi.fn(
|
|
56
|
+
verify ??
|
|
57
|
+
(async () => {
|
|
58
|
+
throw new InvalidTokenError('unknown token');
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
} as unknown as BevelOAuthProvider;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function mount(oauth: BevelOAuthProvider): Promise<{
|
|
65
|
+
baseUrl: string;
|
|
66
|
+
internalTokens: InternalTokenService;
|
|
67
|
+
}> {
|
|
68
|
+
const internalTokens = new InternalTokenService({ secret: 'test-secret' });
|
|
69
|
+
const externalApiKeyService = {
|
|
70
|
+
looksLikeExternalApiKey: (t: string) => typeof t === 'string' && t.startsWith('bevel_'),
|
|
71
|
+
} as unknown as IExternalApiKeyService;
|
|
72
|
+
// createMcpRoutes only touches mcpService.onSessionEvicted at construction.
|
|
73
|
+
const mcpService = { onSessionEvicted: () => {} } as never;
|
|
74
|
+
const stub = {} as never;
|
|
75
|
+
|
|
76
|
+
const app = express();
|
|
77
|
+
app.use(express.json());
|
|
78
|
+
app.use(
|
|
79
|
+
'/api',
|
|
80
|
+
createMcpRoutes(
|
|
81
|
+
mcpService,
|
|
82
|
+
externalApiKeyService,
|
|
83
|
+
fakeAuth,
|
|
84
|
+
fakeAuth,
|
|
85
|
+
stub,
|
|
86
|
+
internalTokens,
|
|
87
|
+
oauth,
|
|
88
|
+
RESOURCE_METADATA_URL,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
httpServer = await new Promise<HttpServer>((resolve) => {
|
|
93
|
+
const s = app.listen(0, () => resolve(s));
|
|
94
|
+
});
|
|
95
|
+
const port = (httpServer.address() as { port: number }).port;
|
|
96
|
+
return { baseUrl: `http://127.0.0.1:${port}`, internalTokens };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function exchange(baseUrl: string, authorization?: string): Promise<Response> {
|
|
100
|
+
return fetch(`${baseUrl}/api/mcp/local-token`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: authorization ? { Authorization: authorization } : {},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('POST /mcp/local-token', () => {
|
|
107
|
+
it('exchanges a valid OAuth access token for an internal token with the loopback identity + TTL', async () => {
|
|
108
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
109
|
+
extra: { userId: 'user-5', userEmail: 'eve@example.com' },
|
|
110
|
+
// A grant with plenty of life left: the loopback constant is the binding cap.
|
|
111
|
+
expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
|
|
112
|
+
}));
|
|
113
|
+
const { baseUrl, internalTokens } = await mount(oauth);
|
|
114
|
+
|
|
115
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_valid123');
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body = (await res.json()) as { token: string; expiresInMs: number };
|
|
119
|
+
// Same TTL constant createSession's loopback bearer uses — the grant
|
|
120
|
+
// above outlives it, so the constant is the cap that binds.
|
|
121
|
+
expect(body.expiresInMs).toBe(MCP_LOOPBACK_TOKEN_TTL_MS);
|
|
122
|
+
// The minted token verifies to the resolved user with the externalProxy
|
|
123
|
+
// flag — identical shape to the hosted session's loopback bearer.
|
|
124
|
+
expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
|
|
125
|
+
// …and the tool-auth verifier resolves it to source 'external', exactly
|
|
126
|
+
// how /api/agent/* will treat the local server.
|
|
127
|
+
const verify = createTokenVerifier(
|
|
128
|
+
{ looksLikeExternalApiKey: () => false } as unknown as IExternalApiKeyService,
|
|
129
|
+
internalTokens,
|
|
130
|
+
);
|
|
131
|
+
await expect(verify(body.token)).resolves.toEqual({
|
|
132
|
+
ok: true,
|
|
133
|
+
auth: { source: 'external', userId: 'user-5', scope: 'write' },
|
|
134
|
+
});
|
|
135
|
+
expect((oauth as any).verifyAccessToken).toHaveBeenCalledWith('bevel-mcp_valid123');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The binding that keeps the exchange from OUTLIVING its grant: an access
|
|
140
|
+
* token with less life left than the loopback constant caps the minted
|
|
141
|
+
* token's TTL at that remainder — otherwise a nearly-expired OAuth grant
|
|
142
|
+
* would buy five more hours of internal-token access.
|
|
143
|
+
*/
|
|
144
|
+
it('caps expiresInMs at the access token\'s remaining lifetime when that is shorter', async () => {
|
|
145
|
+
const remainingSeconds = 90;
|
|
146
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
147
|
+
extra: { userId: 'user-5' },
|
|
148
|
+
expiresAt: Math.floor(Date.now() / 1000) + remainingSeconds,
|
|
149
|
+
}));
|
|
150
|
+
const { baseUrl, internalTokens } = await mount(oauth);
|
|
151
|
+
|
|
152
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_shortlived');
|
|
153
|
+
|
|
154
|
+
expect(res.status).toBe(200);
|
|
155
|
+
const body = (await res.json()) as { token: string; expiresInMs: number };
|
|
156
|
+
// The returned number is the ACTUAL lifetime: at most the remainder,
|
|
157
|
+
// and nowhere near the 5h constant. (A tolerance below, for the
|
|
158
|
+
// seconds-granularity of expiresAt and the time the request takes.)
|
|
159
|
+
expect(body.expiresInMs).toBeLessThanOrEqual(remainingSeconds * 1000);
|
|
160
|
+
expect(body.expiresInMs).toBeGreaterThan((remainingSeconds - 10) * 1000);
|
|
161
|
+
expect(internalTokens.verify(body.token)).toEqual({ userId: 'user-5', externalProxy: true });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A provider that reports no expiry (the AuthInfo field is optional) falls
|
|
166
|
+
* back to the constant alone — absence must not read as "expires now".
|
|
167
|
+
*/
|
|
168
|
+
it('falls back to the loopback constant when the provider reports no expiresAt', async () => {
|
|
169
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
170
|
+
extra: { userId: 'user-5' },
|
|
171
|
+
}));
|
|
172
|
+
const { baseUrl } = await mount(oauth);
|
|
173
|
+
|
|
174
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_noexpiry');
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
const body = (await res.json()) as { expiresInMs: number };
|
|
178
|
+
expect(body.expiresInMs).toBe(MCP_LOOPBACK_TOKEN_TTL_MS);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The other end of the lifetime binding: a grant the provider verifies but
|
|
183
|
+
* reports as ALREADY out of lifetime must be a 401, not a 200 carrying a
|
|
184
|
+
* token that is dead on arrival — the caller would read that as success and
|
|
185
|
+
* fail somewhere far from the cause.
|
|
186
|
+
*/
|
|
187
|
+
it('401s — never 200 with a dead token — when the verified grant has no remaining lifetime', async () => {
|
|
188
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
189
|
+
extra: { userId: 'user-5' },
|
|
190
|
+
expiresAt: Math.floor(Date.now() / 1000) - 60,
|
|
191
|
+
}));
|
|
192
|
+
const { baseUrl } = await mount(oauth);
|
|
193
|
+
|
|
194
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_outlived');
|
|
195
|
+
|
|
196
|
+
expect(res.status).toBe(401);
|
|
197
|
+
expect(res.headers.get('www-authenticate')).toContain(
|
|
198
|
+
`resource_metadata="${RESOURCE_METADATA_URL}"`,
|
|
199
|
+
);
|
|
200
|
+
await expect(res.json()).resolves.toEqual(
|
|
201
|
+
expect.objectContaining({ error: expect.stringMatching(/invalid|expired|revoked/i) }),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('401s on a garbage (non-finite) expiresAt rather than minting with a NaN TTL', async () => {
|
|
206
|
+
const oauth = makeOAuthProvider(async () => ({
|
|
207
|
+
extra: { userId: 'user-5' },
|
|
208
|
+
expiresAt: Number.NaN,
|
|
209
|
+
}));
|
|
210
|
+
const { baseUrl } = await mount(oauth);
|
|
211
|
+
|
|
212
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_garbage-expiry');
|
|
213
|
+
|
|
214
|
+
expect(res.status).toBe(401);
|
|
215
|
+
expect(res.headers.get('www-authenticate')).toContain('resource_metadata=');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('401s with the resource_metadata challenge on an expired/revoked OAuth token', async () => {
|
|
219
|
+
const { baseUrl } = await mount(makeOAuthProvider()); // default verify throws InvalidTokenError
|
|
220
|
+
|
|
221
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_expired');
|
|
222
|
+
|
|
223
|
+
expect(res.status).toBe(401);
|
|
224
|
+
expect(res.headers.get('www-authenticate')).toContain(
|
|
225
|
+
`resource_metadata="${RESOURCE_METADATA_URL}"`,
|
|
226
|
+
);
|
|
227
|
+
await expect(res.json()).resolves.toEqual(
|
|
228
|
+
expect.objectContaining({ error: expect.stringMatching(/invalid|expired|revoked/i) }),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('500s — not 401s — when OAuth verification fails on a backend error', async () => {
|
|
233
|
+
const { baseUrl } = await mount(
|
|
234
|
+
makeOAuthProvider(async () => {
|
|
235
|
+
throw new Error('db down');
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
239
|
+
|
|
240
|
+
const res = await exchange(baseUrl, 'Bearer bevel-mcp_token');
|
|
241
|
+
|
|
242
|
+
expect(res.status).toBe(500);
|
|
243
|
+
err.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('403s a connection key — a key holder needs no exchange', async () => {
|
|
247
|
+
const { baseUrl } = await mount(makeOAuthProvider());
|
|
248
|
+
|
|
249
|
+
const res = await exchange(baseUrl, 'Bearer bevel_connectionkey');
|
|
250
|
+
|
|
251
|
+
expect(res.status).toBe(403);
|
|
252
|
+
await expect(res.json()).resolves.toEqual(
|
|
253
|
+
expect.objectContaining({ error: expect.stringMatching(/OAuth access tokens only/i) }),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('403s a JWT', async () => {
|
|
258
|
+
const { baseUrl } = await mount(makeOAuthProvider());
|
|
259
|
+
|
|
260
|
+
const res = await exchange(baseUrl, 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
|
|
261
|
+
|
|
262
|
+
expect(res.status).toBe(403);
|
|
263
|
+
await expect(res.json()).resolves.toEqual(
|
|
264
|
+
expect.objectContaining({ error: expect.stringMatching(/OAuth access tokens only/i) }),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('403s an internal token — it IS the exchange output, never its input', async () => {
|
|
269
|
+
const { baseUrl, internalTokens } = await mount(makeOAuthProvider());
|
|
270
|
+
|
|
271
|
+
const res = await exchange(baseUrl, `Bearer ${internalTokens.mint({ userId: 'user-A' })}`);
|
|
272
|
+
|
|
273
|
+
expect(res.status).toBe(403);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('401s with the challenge when the Authorization header is missing', async () => {
|
|
277
|
+
const { baseUrl } = await mount(makeOAuthProvider());
|
|
278
|
+
|
|
279
|
+
const res = await exchange(baseUrl);
|
|
280
|
+
|
|
281
|
+
expect(res.status).toBe(401);
|
|
282
|
+
expect(res.headers.get('www-authenticate')).toContain('resource_metadata=');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('401s with the challenge on a garbage bearer token', async () => {
|
|
286
|
+
const { baseUrl } = await mount(makeOAuthProvider());
|
|
287
|
+
|
|
288
|
+
const res = await exchange(baseUrl, 'Bearer total-garbage');
|
|
289
|
+
|
|
290
|
+
expect(res.status).toBe(401);
|
|
291
|
+
expect(res.headers.get('www-authenticate')).toContain('resource_metadata=');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('401s on a non-Bearer scheme', async () => {
|
|
295
|
+
const { baseUrl } = await mount(makeOAuthProvider());
|
|
296
|
+
|
|
297
|
+
const res = await exchange(baseUrl, 'Basic abc:123');
|
|
298
|
+
|
|
299
|
+
expect(res.status).toBe(401);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
|
|
|
2
2
|
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
3
3
|
import type { AuthService } from '../auth/auth.service.js';
|
|
4
4
|
import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
|
|
5
|
+
import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
|
|
5
6
|
import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
|
|
6
7
|
import '../tool-auth/external-api-key.interface.js'; // Express Request augmentation (req.externalApiKeyId)
|
|
7
8
|
|
|
@@ -13,7 +14,17 @@ import '../tool-auth/external-api-key.interface.js'; // Express Request augmenta
|
|
|
13
14
|
* 2. An MCP OAuth access token (Bearer `bevel-mcp_…`) — issued by our own
|
|
14
15
|
* authorization server after the /connect consent flow, resolved via
|
|
15
16
|
* BevelOAuthProvider.
|
|
16
|
-
* 3.
|
|
17
|
+
* 3. An internal token (Bearer `<tenant>-int_…`) — minted server-side only
|
|
18
|
+
* (createSession's loopback bearer, the /mcp/local-token exchange). The
|
|
19
|
+
* local MCP server (hexis-mcp) exchanges its OAuth grant for one and
|
|
20
|
+
* then uses it EVERYWHERE a connection key goes — the agent REST surface
|
|
21
|
+
* already accepts it, and refusing it here made OAuth-mode hexis-mcp
|
|
22
|
+
* fail at exactly one hop: registering this endpoint as its remote
|
|
23
|
+
* manual. Accepting it adds no new mint path — only this server creates
|
|
24
|
+
* them, always for an already-verified user. Only the `externalProxy`
|
|
25
|
+
* shape is accepted: a plain in-process internal token is the loopback
|
|
26
|
+
* surface's credential and must not double as an MCP caller.
|
|
27
|
+
* 4. A regular JWT — same shape the web app uses. Lets a logged-in user
|
|
17
28
|
* hit the MCP endpoint from their browser if we ever need it
|
|
18
29
|
* (e.g. an in-app debugger). Keeps the surface from forking.
|
|
19
30
|
*
|
|
@@ -30,6 +41,7 @@ export function createMcpAuthMiddleware(
|
|
|
30
41
|
externalApiKeyService: IExternalApiKeyService,
|
|
31
42
|
oauthProvider: BevelOAuthProvider,
|
|
32
43
|
resourceMetadataUrl: string,
|
|
44
|
+
internalTokens: InternalTokenService,
|
|
33
45
|
) {
|
|
34
46
|
const wwwAuthenticate = `Bearer realm="bevel-mcp", resource_metadata="${resourceMetadataUrl}"`;
|
|
35
47
|
const unauthorized = (res: Response, error: string) => {
|
|
@@ -111,6 +123,54 @@ export function createMcpAuthMiddleware(
|
|
|
111
123
|
return;
|
|
112
124
|
}
|
|
113
125
|
|
|
126
|
+
// Internal token — server-minted, shape-routed like the branches above so
|
|
127
|
+
// it never reaches jsonwebtoken. `verify` returns null for invalid or
|
|
128
|
+
// expired ones (a clean 401); the user row is loaded so `req.userEmail`
|
|
129
|
+
// carries the same truth every other branch provides — the tool handlers
|
|
130
|
+
// downstream resolve access against it.
|
|
131
|
+
//
|
|
132
|
+
// ONLY the `externalProxy` shape is admitted: that claim marks the
|
|
133
|
+
// loopback identity of an external caller (createSession's session
|
|
134
|
+
// bearer, the /mcp/local-token exchange), which is the one internal-token
|
|
135
|
+
// kind that has any business arriving here as an MCP client. A plain
|
|
136
|
+
// in-process internal token — the per-run credential the agent factory
|
|
137
|
+
// mints for its own code-mode client — is a loopback-surface credential,
|
|
138
|
+
// and accepting it would let it open an MCP session (createSession would
|
|
139
|
+
// even mint it a fresh externalProxy bearer, upgrading it).
|
|
140
|
+
if (internalTokens.looksLikeInternalToken(token)) {
|
|
141
|
+
// `verify` answers null for the invalid/expired cases it can see coming,
|
|
142
|
+
// but a malformed token of plausible shape can still THROW from inside
|
|
143
|
+
// it (e.g. `timingSafeEqual` on same-length strings whose byte lengths
|
|
144
|
+
// differ). That is the caller's bad token, not a backend failure — 401,
|
|
145
|
+
// never a 500 or an unhandled throw into the error handler.
|
|
146
|
+
let claim: ReturnType<InternalTokenService['verify']>;
|
|
147
|
+
try {
|
|
148
|
+
claim = internalTokens.verify(token);
|
|
149
|
+
} catch {
|
|
150
|
+
claim = null;
|
|
151
|
+
}
|
|
152
|
+
if (!claim || claim.externalProxy !== true) {
|
|
153
|
+
unauthorized(res, 'Invalid or expired internal token');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let user;
|
|
157
|
+
try {
|
|
158
|
+
user = await authService.getUserById(claim.userId);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error('[mcp-auth] internal-token user lookup failed:', err);
|
|
161
|
+
res.status(500).json({ error: 'Authentication backend unavailable' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!user) {
|
|
165
|
+
unauthorized(res, 'Invalid or expired internal token');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
req.userId = user.id;
|
|
169
|
+
req.userEmail = user.email;
|
|
170
|
+
next();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
114
174
|
// JWT path — same logic as `createAuthMiddleware` in modules/auth, kept
|
|
115
175
|
// duplicated rather than imported because that one writes its own 401
|
|
116
176
|
// body shape and we want the WWW-Authenticate header set.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import express, { type Request, type RequestHandler } from 'express';
|
|
2
2
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
3
4
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
5
|
import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
6
|
import {
|
|
@@ -8,7 +9,9 @@ import {
|
|
|
8
9
|
TokenStillActiveError,
|
|
9
10
|
} from '../tool-auth/external-api-key.errors.js';
|
|
10
11
|
import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
|
|
11
|
-
import type {
|
|
12
|
+
import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
|
|
13
|
+
import { MCP_LOOPBACK_TOKEN_TTL_MS, type McpService } from './mcp.service.js';
|
|
14
|
+
import type { BevelOAuthProvider } from './oauth/bevel-oauth-provider.js';
|
|
12
15
|
import type { ILlmUsageMeter } from '../tool-auth/llm-usage-meter.js';
|
|
13
16
|
import '../tool-auth/external-api-key.interface.js'; // req.externalApiKeyId augmentation
|
|
14
17
|
|
|
@@ -37,10 +40,14 @@ function extractBearer(req: Request): string {
|
|
|
37
40
|
* POST /mcp/external-api-keys — mint a new key (returns plaintext ONCE)
|
|
38
41
|
* DELETE /mcp/external-api-keys/:id — revoke
|
|
39
42
|
*
|
|
43
|
+
* POST /mcp/local-token — exchange an MCP OAuth access token
|
|
44
|
+
* for a loopback internal token
|
|
45
|
+
*
|
|
40
46
|
* The `/mcp` endpoints accept either a connection key or a JWT (see
|
|
41
47
|
* McpAuthMiddleware). The `/mcp/external-api-keys/*` endpoints accept only the JWT —
|
|
42
48
|
* minting/revoking via a connection key would let a leaked key roll itself
|
|
43
|
-
* over and stay alive forever.
|
|
49
|
+
* over and stay alive forever. `/mcp/local-token` accepts ONLY an MCP OAuth
|
|
50
|
+
* access token — every other credential already opens the surface it bridges to.
|
|
44
51
|
*/
|
|
45
52
|
export function createMcpRoutes(
|
|
46
53
|
mcpService: McpService,
|
|
@@ -48,6 +55,11 @@ export function createMcpRoutes(
|
|
|
48
55
|
mcpAuthMiddleware: RequestHandler,
|
|
49
56
|
jwtAuthMiddleware: RequestHandler,
|
|
50
57
|
llmUsageService: ILlmUsageMeter,
|
|
58
|
+
internalTokens: InternalTokenService,
|
|
59
|
+
oauthProvider: BevelOAuthProvider,
|
|
60
|
+
// RFC 9728 pointer carried on this router's own 401 challenges (the
|
|
61
|
+
// local-token exchange), same value McpAuthMiddleware advertises.
|
|
62
|
+
resourceMetadataUrl: string,
|
|
51
63
|
): express.Router {
|
|
52
64
|
const router = express.Router();
|
|
53
65
|
|
|
@@ -259,5 +271,128 @@ export function createMcpRoutes(
|
|
|
259
271
|
}
|
|
260
272
|
});
|
|
261
273
|
|
|
274
|
+
// ── Local-server token exchange (OAuth-access-token-only) ──────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Exchange an MCP OAuth access token for a short-lived internal token.
|
|
278
|
+
*
|
|
279
|
+
* Why it exists: the LOCAL MCP server's REST reads — the all-tools manual,
|
|
280
|
+
* `list_local_tools`, the plugin archive — live on `/api/agent/*`, which
|
|
281
|
+
* accepts connection keys and internal tokens ONLY; an MCP OAuth access
|
|
282
|
+
* token deliberately 401s there. Hosted OAuth sessions cross that gap
|
|
283
|
+
* inside `McpService.createSession`, which mints a loopback internal token
|
|
284
|
+
* for the resolved user. This endpoint is the same exchange for an external
|
|
285
|
+
* caller: the one bridge that lets a local server configured via the
|
|
286
|
+
* deployment's MCP OAuth (instead of a connection key) reach those reads.
|
|
287
|
+
*
|
|
288
|
+
* Why it is NOT a widening of the trust boundary: the caller must present a
|
|
289
|
+
* VERIFIED OAuth grant for this exact user — the same credential that
|
|
290
|
+
* already drives full tool execution through the hosted `/mcp` endpoint.
|
|
291
|
+
* The minted token is identical in shape to createSession's loopback bearer
|
|
292
|
+
* (`{ userId, externalProxy: true }` → resolved as `source: 'external'` by
|
|
293
|
+
* the tool-auth verifier, admitted to the external surface, refused from
|
|
294
|
+
* internal-only tools) and carries the same TTL — CAPPED to the presented
|
|
295
|
+
* access token's remaining lifetime, so the exchange can never mint a
|
|
296
|
+
* credential that outlives its grant. Nothing becomes reachable that the
|
|
297
|
+
* grant did not already reach — only the credential's spelling changes.
|
|
298
|
+
*
|
|
299
|
+
* Auth semantics mirror McpAuthMiddleware's OAuth branch: an
|
|
300
|
+
* invalid/expired/revoked token is a 401 re-challenging with
|
|
301
|
+
* `resource_metadata` (RFC 9728) so the client can re-authorize; a backend
|
|
302
|
+
* failure during verification is a 500. A connection key, internal token,
|
|
303
|
+
* or JWT is a 403 — those credentials need no exchange, so accepting them
|
|
304
|
+
* here would only manufacture a second credential from a first.
|
|
305
|
+
*
|
|
306
|
+
* Response: `{ token, expiresInMs }`.
|
|
307
|
+
*/
|
|
308
|
+
router.post('/mcp/local-token', async (req, res) => {
|
|
309
|
+
const wwwAuthenticate = `Bearer realm="bevel-mcp", resource_metadata="${resourceMetadataUrl}"`;
|
|
310
|
+
const unauthorized = (error: string) => {
|
|
311
|
+
res.setHeader('WWW-Authenticate', wwwAuthenticate);
|
|
312
|
+
res.status(401).json({ error });
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const header = req.headers.authorization;
|
|
316
|
+
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
|
317
|
+
unauthorized('Missing or invalid Authorization header');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const token = extractBearer(req);
|
|
321
|
+
|
|
322
|
+
if (!oauthProvider.looksLikeAccessToken(token)) {
|
|
323
|
+
// A recognizable non-OAuth credential gets an explicit 403: a
|
|
324
|
+
// connection key or internal token already opens `/api/agent/*`
|
|
325
|
+
// directly, and a JWT holder mints a connection key from the settings
|
|
326
|
+
// UI — none of them has anything to exchange.
|
|
327
|
+
if (
|
|
328
|
+
externalApiKeyService.looksLikeExternalApiKey(token) ||
|
|
329
|
+
internalTokens.looksLikeInternalToken(token) ||
|
|
330
|
+
token.startsWith('eyJ')
|
|
331
|
+
) {
|
|
332
|
+
res.status(403).json({
|
|
333
|
+
error:
|
|
334
|
+
'This endpoint exchanges MCP OAuth access tokens only. Connection keys, ' +
|
|
335
|
+
'internal tokens, and JWTs need no exchange — use them directly.',
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Unrecognizable bearer — re-challenge so an OAuth-capable client can
|
|
340
|
+
// discover the authorization server and obtain a real access token.
|
|
341
|
+
unauthorized('Invalid access token');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const info = await oauthProvider.verifyAccessToken(token);
|
|
347
|
+
const userId = String(info.extra?.userId ?? '');
|
|
348
|
+
if (!userId) {
|
|
349
|
+
unauthorized('Invalid access token');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// The minted token must never OUTLIVE the grant that authorized it: an
|
|
353
|
+
// OAuth access token revoked-by-expiry would otherwise leave a live
|
|
354
|
+
// internal token behind for the rest of the loopback TTL. Bind the TTL
|
|
355
|
+
// to whichever ends first — the constant, or the access token's own
|
|
356
|
+
// remaining lifetime (AuthInfo.expiresAt is epoch SECONDS, optional; a
|
|
357
|
+
// provider that reports none falls back to the constant alone).
|
|
358
|
+
const grantRemainingMs =
|
|
359
|
+
typeof info.expiresAt === 'number' ? info.expiresAt * 1000 - Date.now() : undefined;
|
|
360
|
+
// A grant with no life left mints NOTHING: a 200 carrying an
|
|
361
|
+
// already-dead token would read as success to the caller, whose first
|
|
362
|
+
// real request then fails somewhere far from the cause. It is the same
|
|
363
|
+
// 401 an expired token gets from the verifier, challenge and all — and
|
|
364
|
+
// a non-finite expiresAt (a provider handing back garbage) is refused
|
|
365
|
+
// the same way rather than turned into a TTL. Deliberately STRICTER
|
|
366
|
+
// than the verifier at the boundary: AuthInfo floors the expiry to
|
|
367
|
+
// whole seconds, so a grant inside its final partial second computes
|
|
368
|
+
// as spent here even though the verifier (which compares the stored
|
|
369
|
+
// millisecond timestamp) just accepted it — but that sub-second
|
|
370
|
+
// remainder could only mint a token that is dead before its first use,
|
|
371
|
+
// and refusing it is exactly this guard's job.
|
|
372
|
+
if (grantRemainingMs !== undefined && !(Number.isFinite(grantRemainingMs) && grantRemainingMs > 0)) {
|
|
373
|
+
unauthorized('Invalid, expired, or revoked access token');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const ttlMs =
|
|
377
|
+
grantRemainingMs === undefined
|
|
378
|
+
? MCP_LOOPBACK_TOKEN_TTL_MS
|
|
379
|
+
: Math.min(MCP_LOOPBACK_TOKEN_TTL_MS, grantRemainingMs);
|
|
380
|
+
const minted = internalTokens.mint({ userId, externalProxy: true }, ttlMs);
|
|
381
|
+
// The ACTUAL lifetime, not the constant — the caller schedules its
|
|
382
|
+
// proactive renewal off this number.
|
|
383
|
+
res.json({ token: minted, expiresInMs: ttlMs });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
// Same split as McpAuthMiddleware: a bad token is a clean 401 with the
|
|
386
|
+
// discovery challenge; a backend failure is a 500 — the credential may
|
|
387
|
+
// be fine, we just can't check it right now.
|
|
388
|
+
if (err instanceof InvalidTokenError) {
|
|
389
|
+
unauthorized('Invalid, expired, or revoked access token');
|
|
390
|
+
} else {
|
|
391
|
+
console.error('[mcp] local-token exchange failed:', err);
|
|
392
|
+
res.status(500).json({ error: 'Authentication backend unavailable' });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
262
397
|
return router;
|
|
263
398
|
}
|
|
@@ -76,8 +76,13 @@ const LOOPBACK_TIMEOUT_MS = 15_000;
|
|
|
76
76
|
* first thing to die under a session's normal lifecycle. A continuously-active
|
|
77
77
|
* session CAN outlive it — its tool calls then fail at the loopback and the
|
|
78
78
|
* client recovers by re-initializing, which mints a fresh token.
|
|
79
|
+
*
|
|
80
|
+
* Exported because `POST /api/mcp/local-token` (mcp.routes.ts) performs the
|
|
81
|
+
* same OAuth-access-token → internal-token exchange for the LOCAL MCP server,
|
|
82
|
+
* and must mint the exact same shape and lifetime — one constant, two
|
|
83
|
+
* consumers, so the two bridges can never drift apart.
|
|
79
84
|
*/
|
|
80
|
-
const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
|
|
85
|
+
export const MCP_LOOPBACK_TOKEN_TTL_MS = 5 * 60 * 60 * 1000;
|
|
81
86
|
|
|
82
87
|
const callTemplateSerializer = new CallTemplateSerializer();
|
|
83
88
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createUpdateCheckRoutes } from '../update-check.routes.js';
|
|
6
|
+
import type { UpdateCheckService } from '../update-check.service.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/update-check — admin-only. Non-admins get the same 403 the setup
|
|
10
|
+
* routes give (the frontend only asks on behalf of admins anyway), and the
|
|
11
|
+
* service is never consulted for them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let server: HttpServer | null = null;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
server?.close();
|
|
18
|
+
server = null;
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function listen(
|
|
23
|
+
service: Pick<UpdateCheckService, 'check'>,
|
|
24
|
+
opts: { email?: string; admins?: string[] } = {},
|
|
25
|
+
): string {
|
|
26
|
+
const app = express();
|
|
27
|
+
const middleware: express.RequestHandler = (req, _res, next) => {
|
|
28
|
+
req.userEmail = opts.email ?? 'user@example.com';
|
|
29
|
+
next();
|
|
30
|
+
};
|
|
31
|
+
const adminAccess = {
|
|
32
|
+
isAdmin: async (email: string | undefined) =>
|
|
33
|
+
(opts.admins ?? ['admin@example.com']).includes(email ?? ''),
|
|
34
|
+
};
|
|
35
|
+
app.use('/api', middleware, createUpdateCheckRoutes(service, adminAccess));
|
|
36
|
+
server = app.listen(0);
|
|
37
|
+
const { port } = server.address() as AddressInfo;
|
|
38
|
+
return `http://127.0.0.1:${port}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('GET /update-check', () => {
|
|
42
|
+
it('answers the full shape for an admin', async () => {
|
|
43
|
+
const check = vi.fn().mockResolvedValue({
|
|
44
|
+
updateAvailable: true,
|
|
45
|
+
current: '0.9.1',
|
|
46
|
+
latest: '0.10.0',
|
|
47
|
+
notesUrl: 'https://github.com/Bevel-Software/Hexis/releases/tag/v0.10.0',
|
|
48
|
+
});
|
|
49
|
+
const base = listen({ check }, { email: 'admin@example.com' });
|
|
50
|
+
const res = await fetch(`${base}/api/update-check`);
|
|
51
|
+
expect(res.status).toBe(200);
|
|
52
|
+
expect(await res.json()).toEqual({
|
|
53
|
+
updateAvailable: true,
|
|
54
|
+
current: '0.9.1',
|
|
55
|
+
latest: '0.10.0',
|
|
56
|
+
notesUrl: 'https://github.com/Bevel-Software/Hexis/releases/tag/v0.10.0',
|
|
57
|
+
});
|
|
58
|
+
expect(check).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('403s a non-admin without consulting the service', async () => {
|
|
62
|
+
const check = vi.fn();
|
|
63
|
+
const base = listen({ check }, { email: 'user@example.com' });
|
|
64
|
+
const res = await fetch(`${base}/api/update-check`);
|
|
65
|
+
expect(res.status).toBe(403);
|
|
66
|
+
expect(await res.json()).toEqual({ error: 'Admins only' });
|
|
67
|
+
expect(check).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|