catence 0.2.1-beta.16 → 0.2.1-beta.18
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/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +341 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/elt/ingestion/importer.js +4 -0
- package/dist/interfaces/cli/commands.js +49 -5
- package/dist/interfaces/http/cli-options.d.ts +35 -2
- package/dist/interfaces/http/cli-options.js +119 -3
- package/dist/interfaces/http/oauth.d.ts +36 -0
- package/dist/interfaces/http/oauth.js +175 -0
- package/dist/interfaces/http/server.d.ts +45 -1
- package/dist/interfaces/http/server.js +400 -32
- package/dist/interfaces/mcp/main.js +21 -1
- package/dist/interfaces/mcp/server.d.ts +12 -1
- package/dist/interfaces/mcp/server.js +18 -4
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +4 -0
- package/docs/deployment/docker.md +48 -10
- package/docs/deployment/local-mcp.md +116 -8
- package/package.json +7 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { readMcpOauthSettings } from '../../runtime/index.js';
|
|
3
4
|
export const SERVE_USAGE = `Usage: catence serve [options]
|
|
4
5
|
|
|
5
6
|
Run Catence as a local Streamable HTTP MCP server.
|
|
@@ -12,11 +13,33 @@ Options:
|
|
|
12
13
|
--auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;
|
|
13
14
|
generate/inspect it with 'catence-data mcp enable|token'.
|
|
14
15
|
Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.
|
|
16
|
+
--mcp-keys-file <path> Per-account MCP key registry for the HTTP gate. Any active key in it
|
|
17
|
+
switches authentication on; manage keys with 'catence-data mcp key'.
|
|
18
|
+
Defaults to CATENCE_MCP_KEYS_FILE or <catalog-home>/mcp-keys.json.
|
|
19
|
+
--oauth Run the OAuth 2.1 authorization server (issuer = --public-url) so
|
|
20
|
+
ChatGPT/Codex-style MCP clients connect with a per-account consent
|
|
21
|
+
login. Requires --public-url. Defaults to CATENCE_OAUTH_ENABLED=1/true.
|
|
22
|
+
The Console settings file overrides this at runtime.
|
|
23
|
+
--public-url <url> Public https URL of this server (no query string or fragment), e.g.
|
|
24
|
+
the tunnel URL. Required when --oauth is enabled.
|
|
25
|
+
Defaults to CATENCE_PUBLIC_URL.
|
|
26
|
+
--oauth-settings-file <path>
|
|
27
|
+
Console-managed OAuth settings that override --oauth/--public-url
|
|
28
|
+
at runtime. Defaults to CATENCE_OAUTH_SETTINGS_FILE or
|
|
29
|
+
<catalog-home>/oauth/settings.json.
|
|
15
30
|
-h, --help Show this help text.`;
|
|
16
31
|
/** The canonical token location inside a catalog home. */
|
|
17
32
|
export function defaultBearerTokenPath(catalogRoot) {
|
|
18
33
|
return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-token`;
|
|
19
34
|
}
|
|
35
|
+
/** The canonical per-account MCP key registry location inside a catalog home. */
|
|
36
|
+
export function defaultMcpKeysPath(catalogRoot) {
|
|
37
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-keys.json`;
|
|
38
|
+
}
|
|
39
|
+
/** The canonical Console-managed OAuth settings location inside a catalog home. */
|
|
40
|
+
export function defaultOauthSettingsPath(catalogRoot) {
|
|
41
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/oauth/settings.json`;
|
|
42
|
+
}
|
|
20
43
|
/** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
|
|
21
44
|
export function loadBearerToken(filePath) {
|
|
22
45
|
let token;
|
|
@@ -81,7 +104,7 @@ export function activeBearerToken(filePath) {
|
|
|
81
104
|
return 'invalid';
|
|
82
105
|
}
|
|
83
106
|
}
|
|
84
|
-
export function mcpExposureStatus(filePath, endpointBase) {
|
|
107
|
+
export function mcpExposureStatus(filePath, endpointBase, oauth) {
|
|
85
108
|
const token = readBearerToken(filePath);
|
|
86
109
|
const base = endpointBase.replace(/\/+$/, '');
|
|
87
110
|
return {
|
|
@@ -90,8 +113,52 @@ export function mcpExposureStatus(filePath, endpointBase) {
|
|
|
90
113
|
endpoint: `${base}/mcp`,
|
|
91
114
|
healthEndpoint: `${base}/health`,
|
|
92
115
|
...(token !== null ? { token } : {}),
|
|
116
|
+
...(oauth !== undefined ? { oauth } : {}),
|
|
93
117
|
};
|
|
94
118
|
}
|
|
119
|
+
/** True when CATENCE_OAUTH_ENABLED is 1/true. */
|
|
120
|
+
export function oauthEnabledFromEnv(env = process.env) {
|
|
121
|
+
const value = env.CATENCE_OAUTH_ENABLED;
|
|
122
|
+
return value === '1' || value === 'true';
|
|
123
|
+
}
|
|
124
|
+
/** The OAuth exposure reported by `catence-data mcp status`, read from the environment. */
|
|
125
|
+
export function oauthStatusFromEnv(env = process.env) {
|
|
126
|
+
const publicUrl = env.CATENCE_PUBLIC_URL || undefined;
|
|
127
|
+
return { enabled: oauthEnabledFromEnv(env), ...(publicUrl !== undefined ? { publicUrl } : {}) };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The effective OAuth exposure: the Console settings file when a valid one
|
|
131
|
+
* exists (a malformed one fails closed to disabled), else the environment.
|
|
132
|
+
* `source` records where the effective values come from.
|
|
133
|
+
*/
|
|
134
|
+
export function oauthStatusFromSettings(settingsFile, env = process.env) {
|
|
135
|
+
const { state, settings } = readMcpOauthSettings(settingsFile);
|
|
136
|
+
if (state === 'ok' && settings !== null) {
|
|
137
|
+
return { enabled: settings.enabled, ...(settings.publicUrl ? { publicUrl: settings.publicUrl } : {}), source: 'console' };
|
|
138
|
+
}
|
|
139
|
+
if (state === 'invalid')
|
|
140
|
+
return { enabled: false, source: 'disabled' };
|
|
141
|
+
const fromEnv = oauthStatusFromEnv(env);
|
|
142
|
+
return { ...fromEnv, source: fromEnv.enabled ? 'environment' : 'disabled' };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Validate the public URL: an absolute https URL with no query string or
|
|
146
|
+
* fragment (it becomes the advertised OAuth issuer), trailing slashes stripped.
|
|
147
|
+
*/
|
|
148
|
+
function parsePublicUrl(value, option) {
|
|
149
|
+
let url;
|
|
150
|
+
try {
|
|
151
|
+
url = new URL(value);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error(`${option} must be a valid absolute URL.`);
|
|
155
|
+
}
|
|
156
|
+
if (url.protocol !== 'https:')
|
|
157
|
+
throw new Error(`${option} must use https: the OAuth issuer is advertised publicly.`);
|
|
158
|
+
if (url.search || url.hash)
|
|
159
|
+
throw new Error(`${option} must not contain a query string or fragment.`);
|
|
160
|
+
return value.replace(/\/+$/, '');
|
|
161
|
+
}
|
|
95
162
|
function requiredValue(arguments_, index, option) {
|
|
96
163
|
const value = arguments_[index + 1];
|
|
97
164
|
if (!value || value.startsWith('-'))
|
|
@@ -112,10 +179,15 @@ export function parseServeCliOptions(arguments_) {
|
|
|
112
179
|
let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
|
|
113
180
|
const allowedOrigins = [];
|
|
114
181
|
let authTokenFile = process.env.CATENCE_MCP_TOKEN_FILE || undefined;
|
|
182
|
+
let mcpKeysFile = process.env.CATENCE_MCP_KEYS_FILE || undefined;
|
|
183
|
+
let oauthSettingsFile = process.env.CATENCE_OAUTH_SETTINGS_FILE || undefined;
|
|
184
|
+
let oauth = oauthEnabledFromEnv();
|
|
185
|
+
let publicUrl = process.env.CATENCE_PUBLIC_URL || undefined;
|
|
186
|
+
let publicUrlSource = 'CATENCE_PUBLIC_URL';
|
|
115
187
|
for (let index = 0; index < arguments_.length; index++) {
|
|
116
188
|
const argument = arguments_[index];
|
|
117
189
|
if (argument === '-h' || argument === '--help')
|
|
118
|
-
return { home, host, port, allowedOrigins, authTokenFile, help: true };
|
|
190
|
+
return { home, host, port, allowedOrigins, authTokenFile, mcpKeysFile, oauthSettingsFile, oauth, publicUrl, help: true };
|
|
119
191
|
if (argument === '--home') {
|
|
120
192
|
home = requiredValue(arguments_, index, '--home');
|
|
121
193
|
index++;
|
|
@@ -138,6 +210,28 @@ export function parseServeCliOptions(arguments_) {
|
|
|
138
210
|
throw new Error('--auth-token-file requires a value.');
|
|
139
211
|
continue;
|
|
140
212
|
}
|
|
213
|
+
if (argument === '--mcp-keys-file') {
|
|
214
|
+
mcpKeysFile = requiredValue(arguments_, index, '--mcp-keys-file');
|
|
215
|
+
index++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (argument.startsWith('--mcp-keys-file=')) {
|
|
219
|
+
mcpKeysFile = argument.slice('--mcp-keys-file='.length);
|
|
220
|
+
if (!mcpKeysFile)
|
|
221
|
+
throw new Error('--mcp-keys-file requires a value.');
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (argument === '--oauth-settings-file') {
|
|
225
|
+
oauthSettingsFile = requiredValue(arguments_, index, '--oauth-settings-file');
|
|
226
|
+
index++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (argument.startsWith('--oauth-settings-file=')) {
|
|
230
|
+
oauthSettingsFile = argument.slice('--oauth-settings-file='.length);
|
|
231
|
+
if (!oauthSettingsFile)
|
|
232
|
+
throw new Error('--oauth-settings-file requires a value.');
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
141
235
|
if (argument === '--host') {
|
|
142
236
|
host = requiredValue(arguments_, index, '--host');
|
|
143
237
|
index++;
|
|
@@ -170,8 +264,30 @@ export function parseServeCliOptions(arguments_) {
|
|
|
170
264
|
allowedOrigins.push(origin);
|
|
171
265
|
continue;
|
|
172
266
|
}
|
|
267
|
+
if (argument === '--oauth') {
|
|
268
|
+
oauth = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (argument === '--public-url') {
|
|
272
|
+
publicUrl = requiredValue(arguments_, index, '--public-url');
|
|
273
|
+
publicUrlSource = '--public-url';
|
|
274
|
+
index++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (argument.startsWith('--public-url=')) {
|
|
278
|
+
publicUrl = argument.slice('--public-url='.length);
|
|
279
|
+
if (!publicUrl)
|
|
280
|
+
throw new Error('--public-url requires a value.');
|
|
281
|
+
publicUrlSource = '--public-url';
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
173
284
|
throw new Error(`Unknown option: ${argument}`);
|
|
174
285
|
}
|
|
175
|
-
|
|
286
|
+
if (publicUrl !== undefined)
|
|
287
|
+
publicUrl = parsePublicUrl(publicUrl, publicUrlSource);
|
|
288
|
+
if (oauth && publicUrl === undefined) {
|
|
289
|
+
throw new Error('--oauth requires --public-url (or CATENCE_PUBLIC_URL): the OAuth issuer is the public URL of this server.');
|
|
290
|
+
}
|
|
291
|
+
return { home, host, port, allowedOrigins, authTokenFile, mcpKeysFile, oauthSettingsFile, oauth, publicUrl, help: false };
|
|
176
292
|
}
|
|
177
293
|
//# sourceMappingURL=cli-options.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Express } from 'express';
|
|
2
|
+
/** What the HTTP gate needs from the lazily built OAuth sub-app. */
|
|
3
|
+
export type OAuthAppHandle = {
|
|
4
|
+
/** The Express app; mounted by path delegation for /.well-known/oauth-* and /oauth/*. */
|
|
5
|
+
app: Express;
|
|
6
|
+
/** Verify a bearer access token; null when unknown, expired, or revoked. */
|
|
7
|
+
verifyAccessToken: (token: string) => Promise<{
|
|
8
|
+
userId?: string;
|
|
9
|
+
} | null>;
|
|
10
|
+
/** The RFC 9728 metadata URL advertised in the 401 WWW-Authenticate challenge. */
|
|
11
|
+
protectedResourceMetadataUrl: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The OAuth 2.1 authorization server for `catence serve --oauth`, built on the
|
|
15
|
+
* mcp-oauth-server package over the file-backed model in core/runtime/oauth.
|
|
16
|
+
*
|
|
17
|
+
* Layout of the public surface (issuer = publicUrl, resource = publicUrl/mcp):
|
|
18
|
+
*
|
|
19
|
+
* GET /.well-known/oauth-authorization-server RFC 8414 AS metadata
|
|
20
|
+
* GET /.well-known/oauth-protected-resource[/mcp] RFC 9728 PRM metadata
|
|
21
|
+
* * /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke
|
|
22
|
+
* (mcpAuthRouter mounted at the root with baseUrl = publicUrl + /oauth)
|
|
23
|
+
* GET /oauth/consent login + consent form (round-trips the authorize params)
|
|
24
|
+
* POST /oauth/confirm authenticateHandler: bcrypt login, then issues the code
|
|
25
|
+
* POST /oauth/deny redirects to the client with error=access_denied
|
|
26
|
+
*
|
|
27
|
+
* The consent round-trip is the lib's contract: the authorize handler 302s to
|
|
28
|
+
* authorizationUrl with the full authorize request as query params, and
|
|
29
|
+
* authenticateHandler re-validates those same params from the confirm POST
|
|
30
|
+
* body — so the consent form echoes them as hidden fields next to the
|
|
31
|
+
* username/password inputs. Credentials and tokens are never logged.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createOAuthApp(options: {
|
|
34
|
+
catalogRoot: string;
|
|
35
|
+
publicUrl: string;
|
|
36
|
+
}): Promise<OAuthAppHandle>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { authenticateHandler, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthRouter, OAuthServer, redirectUriMatches, } from 'mcp-oauth-server';
|
|
4
|
+
import { createFileOAuthServerModel, resolveCatalogPaths, verifyAccountPassword } from '../../runtime/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* The OAuth 2.1 authorization server for `catence serve --oauth`, built on the
|
|
7
|
+
* mcp-oauth-server package over the file-backed model in core/runtime/oauth.
|
|
8
|
+
*
|
|
9
|
+
* Layout of the public surface (issuer = publicUrl, resource = publicUrl/mcp):
|
|
10
|
+
*
|
|
11
|
+
* GET /.well-known/oauth-authorization-server RFC 8414 AS metadata
|
|
12
|
+
* GET /.well-known/oauth-protected-resource[/mcp] RFC 9728 PRM metadata
|
|
13
|
+
* * /oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke
|
|
14
|
+
* (mcpAuthRouter mounted at the root with baseUrl = publicUrl + /oauth)
|
|
15
|
+
* GET /oauth/consent login + consent form (round-trips the authorize params)
|
|
16
|
+
* POST /oauth/confirm authenticateHandler: bcrypt login, then issues the code
|
|
17
|
+
* POST /oauth/deny redirects to the client with error=access_denied
|
|
18
|
+
*
|
|
19
|
+
* The consent round-trip is the lib's contract: the authorize handler 302s to
|
|
20
|
+
* authorizationUrl with the full authorize request as query params, and
|
|
21
|
+
* authenticateHandler re-validates those same params from the confirm POST
|
|
22
|
+
* body — so the consent form echoes them as hidden fields next to the
|
|
23
|
+
* username/password inputs. Credentials and tokens are never logged.
|
|
24
|
+
*/
|
|
25
|
+
export async function createOAuthApp(options) {
|
|
26
|
+
const publicUrl = options.publicUrl.replace(/\/+$/, '');
|
|
27
|
+
const issuerUrl = new URL(publicUrl);
|
|
28
|
+
const resourceServerUrl = new URL(`${publicUrl}/mcp`);
|
|
29
|
+
const oauthBaseUrl = new URL(`${publicUrl}/oauth`);
|
|
30
|
+
const accountPaths = resolveCatalogPaths(options.catalogRoot);
|
|
31
|
+
const provider = new OAuthServer({
|
|
32
|
+
issuerUrl,
|
|
33
|
+
authorizationUrl: new URL(`${publicUrl}/oauth/consent`),
|
|
34
|
+
resourceServerUrl,
|
|
35
|
+
scopesSupported: ['mcp:tools'],
|
|
36
|
+
clientIdMetadataDocuments: true,
|
|
37
|
+
model: createFileOAuthServerModel(path.join(options.catalogRoot, 'oauth')),
|
|
38
|
+
});
|
|
39
|
+
const oauthMetadata = createOAuthMetadata({ provider, baseUrl: oauthBaseUrl });
|
|
40
|
+
const protectedResourceMetadata = {
|
|
41
|
+
resource: resourceServerUrl.href,
|
|
42
|
+
authorization_servers: [issuerUrl.href],
|
|
43
|
+
scopes_supported: ['mcp:tools'],
|
|
44
|
+
bearer_methods_supported: ['header'],
|
|
45
|
+
};
|
|
46
|
+
const protectedResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
|
|
47
|
+
// Behind the tunnel the peer is the tunnel itself, so X-Forwarded-For is
|
|
48
|
+
// always set; express-rate-limit v8 refuses to run with it while
|
|
49
|
+
// `trust proxy` is false. Key the limiters on the socket address instead.
|
|
50
|
+
const rateLimitValidate = { xForwardedForHeader: false, forwardedHeader: false };
|
|
51
|
+
const app = express();
|
|
52
|
+
app.disable('x-powered-by');
|
|
53
|
+
// The issuer carries no path, so the canonical RFC 8414/RFC 9728 locations
|
|
54
|
+
// are the bare well-known paths; the lib's router serves the same documents
|
|
55
|
+
// at the /oauth-suffixed variants. These routes shadow nothing the lib needs.
|
|
56
|
+
const serveMetadata = (document) => (_request, response) => {
|
|
57
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
58
|
+
response.status(200).json(document);
|
|
59
|
+
};
|
|
60
|
+
app.get('/.well-known/oauth-authorization-server', serveMetadata(oauthMetadata));
|
|
61
|
+
app.get('/.well-known/oauth-protected-resource', serveMetadata(protectedResourceMetadata));
|
|
62
|
+
app.get('/.well-known/oauth-protected-resource/mcp', serveMetadata(protectedResourceMetadata));
|
|
63
|
+
app.use(mcpAuthRouter({
|
|
64
|
+
provider,
|
|
65
|
+
baseUrl: oauthBaseUrl,
|
|
66
|
+
resourceServerUrl,
|
|
67
|
+
authorizationOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
68
|
+
tokenOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
69
|
+
clientRegistrationOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
70
|
+
revocationOptions: { rateLimit: { validate: rateLimitValidate } },
|
|
71
|
+
}));
|
|
72
|
+
app.get('/oauth/consent', async (request, response) => {
|
|
73
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
74
|
+
const clientId = typeof request.query.client_id === 'string' ? request.query.client_id : '';
|
|
75
|
+
const client = clientId ? await provider.getClient(clientId).catch(() => undefined) : undefined;
|
|
76
|
+
if (!client) {
|
|
77
|
+
response.status(400).json({ error: 'invalid_request', error_description: 'Unknown client_id.' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
response.status(200).type('html').send(renderConsentPage(request.query, client.client_name ?? client.client_id));
|
|
81
|
+
});
|
|
82
|
+
// authenticateHandler returns an Express Router: it must be mounted with
|
|
83
|
+
// app.use so the mount path is stripped before the router matches its
|
|
84
|
+
// internal POST / route (app.post(path, router) leaves req.url intact and
|
|
85
|
+
// the router 404s).
|
|
86
|
+
app.use('/oauth/confirm', authenticateHandler({
|
|
87
|
+
provider,
|
|
88
|
+
rateLimit: { windowMs: 15 * 60 * 1000, max: 20, validate: rateLimitValidate },
|
|
89
|
+
getUser: async (request) => {
|
|
90
|
+
const username = typeof request.body?.username === 'string' ? request.body.username : '';
|
|
91
|
+
const password = typeof request.body?.password === 'string' ? request.body.password : '';
|
|
92
|
+
if (!username || !password)
|
|
93
|
+
return null;
|
|
94
|
+
const valid = await verifyAccountPassword(accountPaths, username, password);
|
|
95
|
+
return valid ? username : null;
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
app.post('/oauth/deny', express.urlencoded({ extended: false }), async (request, response) => {
|
|
99
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
100
|
+
const body = (request.body ?? {});
|
|
101
|
+
const clientId = typeof body.client_id === 'string' ? body.client_id : '';
|
|
102
|
+
const redirectUri = typeof body.redirect_uri === 'string' ? body.redirect_uri : '';
|
|
103
|
+
const state = typeof body.state === 'string' ? body.state : undefined;
|
|
104
|
+
const client = clientId ? await provider.getClient(clientId).catch(() => undefined) : undefined;
|
|
105
|
+
const redirectRegistered = client !== undefined && redirectUri !== '' && client.redirect_uris.some((registered) => redirectUriMatches(redirectUri, registered));
|
|
106
|
+
if (!client || !redirectRegistered) {
|
|
107
|
+
response.status(400).json({ error: 'invalid_request', error_description: 'Unknown client_id or unregistered redirect_uri.' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const target = new URL(redirectUri);
|
|
111
|
+
target.searchParams.set('error', 'access_denied');
|
|
112
|
+
if (state)
|
|
113
|
+
target.searchParams.set('state', state);
|
|
114
|
+
target.searchParams.set('iss', issuerUrl.href);
|
|
115
|
+
response.redirect(302, target.toString());
|
|
116
|
+
});
|
|
117
|
+
const verifyAccessToken = async (token) => {
|
|
118
|
+
try {
|
|
119
|
+
const authInfo = await provider.verifyAccessToken(token);
|
|
120
|
+
return { userId: authInfo.userId };
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Unknown, expired, or revoked tokens all fail closed to null.
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
return { app, verifyAccessToken, protectedResourceMetadataUrl };
|
|
128
|
+
}
|
|
129
|
+
/** The authorize request parameters the consent form must echo back. */
|
|
130
|
+
const CONSENT_PARAMETERS = ['client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'state', 'scope', 'resource'];
|
|
131
|
+
function escapeHtml(value) {
|
|
132
|
+
return value
|
|
133
|
+
.replace(/&/g, '&')
|
|
134
|
+
.replace(/</g, '<')
|
|
135
|
+
.replace(/>/g, '>')
|
|
136
|
+
.replace(/"/g, '"')
|
|
137
|
+
.replace(/'/g, ''');
|
|
138
|
+
}
|
|
139
|
+
function renderConsentPage(query, clientName) {
|
|
140
|
+
const hiddenInputs = CONSENT_PARAMETERS
|
|
141
|
+
.filter((name) => typeof query[name] === 'string' && query[name] !== '')
|
|
142
|
+
.map((name) => ` <input type="hidden" name="${name}" value="${escapeHtml(query[name])}">`)
|
|
143
|
+
.join('\n');
|
|
144
|
+
const redirectUri = typeof query.redirect_uri === 'string' ? query.redirect_uri : '';
|
|
145
|
+
let redirectHost = redirectUri;
|
|
146
|
+
try {
|
|
147
|
+
redirectHost = new URL(redirectUri).host;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Display the raw value when it does not parse; the confirm step rejects it.
|
|
151
|
+
}
|
|
152
|
+
return `<!doctype html>
|
|
153
|
+
<html lang="en">
|
|
154
|
+
<head>
|
|
155
|
+
<meta charset="utf-8">
|
|
156
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
157
|
+
<title>Catence — authorize ${escapeHtml(clientName)}</title>
|
|
158
|
+
</head>
|
|
159
|
+
<body>
|
|
160
|
+
<h1>Authorize ${escapeHtml(clientName)}</h1>
|
|
161
|
+
<p><strong>${escapeHtml(clientName)}</strong> is asking to access your Catence data${redirectHost ? ` (redirecting to ${escapeHtml(redirectHost)})` : ''}.</p>
|
|
162
|
+
<form method="post">
|
|
163
|
+
${hiddenInputs}
|
|
164
|
+
<p><label>Username <input type="text" name="username" autocomplete="username" required></label></p>
|
|
165
|
+
<p><label>Password <input type="password" name="password" autocomplete="current-password" required></label></p>
|
|
166
|
+
<p>
|
|
167
|
+
<button type="submit" formaction="/oauth/confirm">Approve</button>
|
|
168
|
+
<button type="submit" formaction="/oauth/deny">Deny</button>
|
|
169
|
+
</p>
|
|
170
|
+
</form>
|
|
171
|
+
</body>
|
|
172
|
+
</html>
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths, type ConsoleAccountRole } from '../../runtime/index.js';
|
|
3
3
|
export type CatenceHttpServerOptions = {
|
|
4
4
|
/** A shared catalog for public servers. All data APIs then require athleteId. */
|
|
5
5
|
catalogPaths?: CatalogPaths;
|
|
@@ -21,6 +21,43 @@ export type CatenceHttpServerOptions = {
|
|
|
21
21
|
* exposure such as a Cloudflare Tunnel to an MCP client.
|
|
22
22
|
*/
|
|
23
23
|
bearerTokenFile?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Per-account MCP key registry for the gate. Any active key in it arms
|
|
26
|
+
* authentication and resolves to that account's athlete scope; keys are
|
|
27
|
+
* managed with `catence-data mcp key` and the /api/v1/mcp-keys routes.
|
|
28
|
+
*/
|
|
29
|
+
mcpKeysFile?: string;
|
|
30
|
+
/**
|
|
31
|
+
* OAuth bearer-token verifier (Task B plugs in the MCP OAuth server). Its
|
|
32
|
+
* presence arms the gate when oauthEnabled() is unset or true; verified
|
|
33
|
+
* usernames resolve through the same Console accounts as MCP keys.
|
|
34
|
+
*/
|
|
35
|
+
oauthVerifier?: (token: string) => Promise<{
|
|
36
|
+
username: string;
|
|
37
|
+
} | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Live check whether the OAuth 2.1 server is effectively enabled. Wired by
|
|
40
|
+
* the server itself when OAuth is configurable (the `oauth` startup option
|
|
41
|
+
* or an oauthSettingsFile); its truth arms the gate together with the
|
|
42
|
+
* oauthVerifier.
|
|
43
|
+
*/
|
|
44
|
+
oauthEnabled?: () => boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Run the OAuth 2.1 authorization server (issuer = publicUrl) for
|
|
47
|
+
* ChatGPT/Codex-style clients. The Express sub-app is built lazily on first
|
|
48
|
+
* effective use and serves /.well-known/oauth-* and /oauth/* ahead of the
|
|
49
|
+
* gate; its verifyAccessToken is wired in as the oauthVerifier. The
|
|
50
|
+
* oauthSettingsFile overrides this at runtime.
|
|
51
|
+
*/
|
|
52
|
+
oauth?: {
|
|
53
|
+
publicUrl: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Console-managed OAuth settings file for the gate and the
|
|
57
|
+
* /api/v1/mcp-oauth-settings routes. A valid file overrides the `oauth`
|
|
58
|
+
* startup option at runtime; a malformed one fails closed to disabled.
|
|
59
|
+
*/
|
|
60
|
+
oauthSettingsFile?: string;
|
|
24
61
|
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
25
62
|
startSync?: typeof startDetachedSync;
|
|
26
63
|
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
@@ -28,6 +65,13 @@ export type CatenceHttpServerOptions = {
|
|
|
28
65
|
/** Internal compatibility hook for tests: override OpenCode Go model discovery. */
|
|
29
66
|
mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
|
|
30
67
|
};
|
|
68
|
+
/** The authenticated identity behind one request and the athletes it may touch. */
|
|
69
|
+
export type CatencePrincipal = {
|
|
70
|
+
kind: 'admin-token' | 'key' | 'oauth';
|
|
71
|
+
username?: string;
|
|
72
|
+
role: ConsoleAccountRole;
|
|
73
|
+
athleteIds: string[] | 'all';
|
|
74
|
+
};
|
|
31
75
|
/**
|
|
32
76
|
* Create Catence's local Streamable HTTP server.
|
|
33
77
|
*
|