@adminforth/mcp 1.1.0 → 1.1.1
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/apiTools.d.ts +6 -1
- package/dist/apiTools.js +195 -13
- package/dist/authSecretStore.d.ts +31 -0
- package/dist/authSecretStore.js +80 -11
- package/dist/custom/McpAuthorize.vue +147 -0
- package/dist/custom/McpSettings.vue +130 -56
- package/dist/custom/instructions.md +6 -0
- package/dist/custom/skills/analyze_data/SKILL.md +19 -0
- package/dist/custom/skills/fetch_data/SKILL.md +31 -0
- package/dist/custom/skills/mutate_data/SKILL.md +48 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +95 -24
- package/dist/inputSchema.d.ts +3 -0
- package/dist/inputSchema.js +63 -0
- package/dist/keyedLock.d.ts +8 -0
- package/dist/keyedLock.js +20 -0
- package/dist/mcpProtocol.d.ts +1 -1
- package/dist/mcpProtocol.js +8 -6
- package/dist/oauth.d.ts +96 -0
- package/dist/oauth.js +263 -0
- package/dist/oauthClientMetadata.d.ts +13 -0
- package/dist/oauthClientMetadata.js +140 -0
- package/dist/oauthEndpoints.d.ts +3 -0
- package/dist/oauthEndpoints.js +126 -0
- package/dist/oauthError.d.ts +9 -0
- package/dist/oauthError.js +11 -0
- package/dist/skills.d.ts +19 -0
- package/dist/skills.js +79 -0
- package/dist/types.d.ts +39 -1
- package/dist/urls.d.ts +13 -0
- package/dist/urls.js +13 -0
- package/package.json +4 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Endpoint request schemas are written for OpenAPI docs. MCP clients keep tool schemas in the model context,
|
|
2
|
+
// so annotations that do not help to build a call and definitions the tool never references are dropped.
|
|
3
|
+
const DOCUMENTATION_ONLY_KEYWORDS = new Set(['title', 'examples']);
|
|
4
|
+
// Keywords whose value maps names to subschemas: their keys are names, not keywords.
|
|
5
|
+
const SUBSCHEMA_MAP_KEYWORDS = new Set(['properties', 'patternProperties', '$defs']);
|
|
6
|
+
// Keywords whose value is data, not a schema.
|
|
7
|
+
const DATA_KEYWORDS = new Set(['enum', 'const', 'default']);
|
|
8
|
+
const DEFS_REF_PREFIX = '#/$defs/';
|
|
9
|
+
function isObject(value) {
|
|
10
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function stripDocumentationKeywords(node) {
|
|
13
|
+
if (Array.isArray(node))
|
|
14
|
+
return node.map(stripDocumentationKeywords);
|
|
15
|
+
if (!isObject(node))
|
|
16
|
+
return node;
|
|
17
|
+
return Object.fromEntries(Object.entries(node)
|
|
18
|
+
.filter(([keyword]) => !DOCUMENTATION_ONLY_KEYWORDS.has(keyword))
|
|
19
|
+
.map(([keyword, value]) => {
|
|
20
|
+
if (DATA_KEYWORDS.has(keyword))
|
|
21
|
+
return [keyword, value];
|
|
22
|
+
if (SUBSCHEMA_MAP_KEYWORDS.has(keyword) && isObject(value)) {
|
|
23
|
+
return [keyword, Object.fromEntries(Object.entries(value).map(([name, subschema]) => [name, stripDocumentationKeywords(subschema)]))];
|
|
24
|
+
}
|
|
25
|
+
return [keyword, stripDocumentationKeywords(value)];
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function collectDefRefs(node, refs) {
|
|
29
|
+
if (Array.isArray(node)) {
|
|
30
|
+
node.forEach((item) => collectDefRefs(item, refs));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(node))
|
|
34
|
+
return;
|
|
35
|
+
for (const [keyword, value] of Object.entries(node)) {
|
|
36
|
+
if (keyword === '$ref' && typeof value === 'string' && value.startsWith(DEFS_REF_PREFIX)) {
|
|
37
|
+
refs.add(value.slice(DEFS_REF_PREFIX.length));
|
|
38
|
+
}
|
|
39
|
+
else if (!DATA_KEYWORDS.has(keyword)) {
|
|
40
|
+
collectDefRefs(value, refs);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Shared $defs are attached to every endpoint schema, even to endpoints that use only some of them.
|
|
45
|
+
function pruneUnusedDefs(schema) {
|
|
46
|
+
const { $defs, ...rest } = schema;
|
|
47
|
+
if (!isObject($defs))
|
|
48
|
+
return schema;
|
|
49
|
+
const used = new Set();
|
|
50
|
+
const pending = new Set();
|
|
51
|
+
collectDefRefs(rest, pending);
|
|
52
|
+
for (const name of pending) {
|
|
53
|
+
if (used.has(name))
|
|
54
|
+
continue;
|
|
55
|
+
used.add(name);
|
|
56
|
+
collectDefRefs($defs[name], pending);
|
|
57
|
+
}
|
|
58
|
+
const usedDefs = Object.fromEntries(Object.entries($defs).filter(([name]) => used.has(name)));
|
|
59
|
+
return Object.keys(usedDefs).length > 0 ? { ...rest, $defs: usedDefs } : rest;
|
|
60
|
+
}
|
|
61
|
+
export function compactInputSchema(schema) {
|
|
62
|
+
return pruneUnusedDefs(stripDocumentationKeywords(schema));
|
|
63
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs tasks with the same key one at a time, within this process. A key is kept only while a task with it runs
|
|
3
|
+
* or waits, so keys taken by many different callers do not pile up in memory.
|
|
4
|
+
*/
|
|
5
|
+
export declare class KeyedLock {
|
|
6
|
+
private readonly tails;
|
|
7
|
+
run<T>(key: string, task: () => Promise<T>): Promise<T>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs tasks with the same key one at a time, within this process. A key is kept only while a task with it runs
|
|
3
|
+
* or waits, so keys taken by many different callers do not pile up in memory.
|
|
4
|
+
*/
|
|
5
|
+
export class KeyedLock {
|
|
6
|
+
tails = new Map();
|
|
7
|
+
async run(key, task) {
|
|
8
|
+
const result = (this.tails.get(key) ?? Promise.resolve()).then(task);
|
|
9
|
+
// The next task with this key waits for this one even when it fails.
|
|
10
|
+
const tail = result.catch(() => { });
|
|
11
|
+
this.tails.set(key, tail);
|
|
12
|
+
try {
|
|
13
|
+
return await result;
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
if (this.tails.get(key) === tail)
|
|
17
|
+
this.tails.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/mcpProtocol.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface McpServerPresentation {
|
|
|
12
12
|
serverInfo: McpServerInfo;
|
|
13
13
|
instructions: string;
|
|
14
14
|
}
|
|
15
|
-
export declare function createMcpServerPresentation(brandName: string,
|
|
15
|
+
export declare function createMcpServerPresentation(brandName: string, adminPanelUrl?: string, guidance?: string, readOnly?: boolean): McpServerPresentation;
|
|
16
16
|
export interface McpProtocolContext {
|
|
17
17
|
body: Record<string, any>;
|
|
18
18
|
headers: Record<string, any>;
|
package/dist/mcpProtocol.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
1
2
|
export const MODERN_PROTOCOL_VERSION = '2026-07-28';
|
|
2
3
|
export const LEGACY_PROTOCOL_VERSION = '2025-11-25';
|
|
3
4
|
const LEGACY_PROTOCOL_VERSIONS = new Set([
|
|
@@ -8,10 +9,7 @@ const LEGACY_PROTOCOL_VERSIONS = new Set([
|
|
|
8
9
|
const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';
|
|
9
10
|
const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities';
|
|
10
11
|
const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo';
|
|
11
|
-
export function createMcpServerPresentation(brandName,
|
|
12
|
-
const adminPanelUrl = adminPanelOrigin
|
|
13
|
-
? new URL(baseUrl || '/', adminPanelOrigin).toString()
|
|
14
|
-
: undefined;
|
|
12
|
+
export function createMcpServerPresentation(brandName, adminPanelUrl, guidance = '', readOnly = false) {
|
|
15
13
|
const adminPanel = `AdminForth admin panel for "${brandName}"${adminPanelUrl ? ` at ${adminPanelUrl}` : ''}`;
|
|
16
14
|
return {
|
|
17
15
|
serverInfo: {
|
|
@@ -21,7 +19,10 @@ export function createMcpServerPresentation(brandName, adminPanelOrigin, baseUrl
|
|
|
21
19
|
description: `${adminPanel}.`,
|
|
22
20
|
...(adminPanelUrl && { websiteUrl: adminPanelUrl }),
|
|
23
21
|
},
|
|
24
|
-
instructions:
|
|
22
|
+
instructions: [
|
|
23
|
+
`This is the ${adminPanel}. Use its tools to ${readOnly ? 'read' : 'read and update'} data allowed for the authenticated user.`,
|
|
24
|
+
guidance,
|
|
25
|
+
].filter(Boolean).join('\n\n'),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
function errorResponse(id, code, message, status = 400) {
|
|
@@ -67,8 +68,9 @@ function validateModernHeaders(body, headers) {
|
|
|
67
68
|
}
|
|
68
69
|
return null;
|
|
69
70
|
}
|
|
71
|
+
// The JSON round trip drops functions and undefined values that handler responses may carry, which YAML cannot serialize.
|
|
70
72
|
function serializeToolOutput(output) {
|
|
71
|
-
return typeof output === 'string' ? output : JSON.stringify(output
|
|
73
|
+
return typeof output === 'string' ? output : YAML.stringify(JSON.parse(JSON.stringify(output ?? null)));
|
|
72
74
|
}
|
|
73
75
|
export async function handleMcpProtocol(context) {
|
|
74
76
|
const { body, headers, serverInfo, instructions } = context;
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AdminUser, IAdminForth } from 'adminforth';
|
|
2
|
+
import type { McpAuthSecretStore } from './authSecretStore.js';
|
|
3
|
+
import type { McpOAuthClient } from './types.js';
|
|
4
|
+
import { type McpUrls } from './urls.js';
|
|
5
|
+
/** Endpoint paths relative to the AdminForth API prefix (`<baseUrl>/adminapi/v1`). */
|
|
6
|
+
export declare const OAUTH_PATHS: {
|
|
7
|
+
protectedResourceMetadata: string;
|
|
8
|
+
authorizationServerMetadata: string;
|
|
9
|
+
jwks: string;
|
|
10
|
+
authorize: string;
|
|
11
|
+
token: string;
|
|
12
|
+
authorization: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const CONSENT_PAGE_PATH = "/mcp-authorize";
|
|
15
|
+
interface AccessToken {
|
|
16
|
+
pk: string;
|
|
17
|
+
grantId: string;
|
|
18
|
+
aud: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* OAuth 2.1 authorization server for the MCP endpoint: authorization code flow with PKCE for public clients
|
|
22
|
+
* identified by Client ID Metadata Documents. Authorization requests and codes are signed JWTs, so only the
|
|
23
|
+
* resulting grants are stored, as records of the auth secret resource.
|
|
24
|
+
*/
|
|
25
|
+
export declare class McpOAuth {
|
|
26
|
+
private readonly adminforth;
|
|
27
|
+
private readonly store;
|
|
28
|
+
private readonly serverReadOnly;
|
|
29
|
+
readonly mcpUrl: string;
|
|
30
|
+
readonly resourceMetadataUrl: string;
|
|
31
|
+
private readonly issuer;
|
|
32
|
+
private readonly apiUrl;
|
|
33
|
+
private readonly consentPageUrl;
|
|
34
|
+
private readonly configuredClients;
|
|
35
|
+
private readonly fetchedClients;
|
|
36
|
+
/**
|
|
37
|
+
* `configuredClients` are trusted as is, without fetching their client metadata documents. `serverReadOnly` is
|
|
38
|
+
* the plugin readOnly option: it makes every connection read-only, whatever the user chooses on the consent page.
|
|
39
|
+
*/
|
|
40
|
+
constructor(adminforth: IAdminForth, store: McpAuthSecretStore, urls: McpUrls, configuredClients?: McpOAuthClient[], serverReadOnly?: boolean);
|
|
41
|
+
protectedResourceMetadata(): {
|
|
42
|
+
resource: string;
|
|
43
|
+
authorization_servers: string[];
|
|
44
|
+
bearer_methods_supported: string[];
|
|
45
|
+
};
|
|
46
|
+
authorizationServerMetadata(): {
|
|
47
|
+
issuer: string;
|
|
48
|
+
authorization_endpoint: string;
|
|
49
|
+
token_endpoint: string;
|
|
50
|
+
response_types_supported: string[];
|
|
51
|
+
grant_types_supported: string[];
|
|
52
|
+
code_challenge_methods_supported: string[];
|
|
53
|
+
token_endpoint_auth_methods_supported: string[];
|
|
54
|
+
client_id_metadata_document_supported: boolean;
|
|
55
|
+
authorization_response_iss_parameter_supported: boolean;
|
|
56
|
+
jwks_uri: string;
|
|
57
|
+
subject_types_supported: string[];
|
|
58
|
+
id_token_signing_alg_values_supported: string[];
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Validates the authorization request and returns the consent page URL to redirect the browser to.
|
|
62
|
+
* This endpoint needs no sign-in, so the client metadata document is fetched only once the user opens the
|
|
63
|
+
* consent page: otherwise anyone could make this server send requests to any URL passed as client_id.
|
|
64
|
+
*/
|
|
65
|
+
authorize(query: Record<string, unknown>): Promise<string>;
|
|
66
|
+
/** What the consent page shows about a pending authorization request. */
|
|
67
|
+
describeAuthorization(signedRequest: string): Promise<{
|
|
68
|
+
clientName: string;
|
|
69
|
+
clientHost: string | null;
|
|
70
|
+
redirectHost: string;
|
|
71
|
+
loopbackRedirect: boolean;
|
|
72
|
+
serverReadOnly: boolean;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Approves or denies a pending authorization request and returns where to send the browser. `readOnly` is the
|
|
76
|
+
* choice of the user on the consent page; the connection is read-only with it or with the plugin readOnly option.
|
|
77
|
+
*/
|
|
78
|
+
resolveAuthorization(signedRequest: string, approved: boolean, adminUser: AdminUser, readOnly?: boolean): Promise<string>;
|
|
79
|
+
exchangeToken(body: Record<string, unknown>): Promise<{
|
|
80
|
+
access_token: string;
|
|
81
|
+
token_type: string;
|
|
82
|
+
expires_in: number;
|
|
83
|
+
refresh_token: string;
|
|
84
|
+
}>;
|
|
85
|
+
/** Returns the grant an OAuth access token was issued for, or null for an invalid, expired or foreign token. */
|
|
86
|
+
verifyAccessToken(token: string): Promise<AccessToken | null>;
|
|
87
|
+
private checkResource;
|
|
88
|
+
private verifyAuthorizationRequest;
|
|
89
|
+
/** The consent page loads the client to describe it and again to resolve the request, hence the cache. */
|
|
90
|
+
private lookUpClient;
|
|
91
|
+
private redeemAuthorizationCode;
|
|
92
|
+
private refreshAccessToken;
|
|
93
|
+
private issueRefreshToken;
|
|
94
|
+
private tokenResponse;
|
|
95
|
+
}
|
|
96
|
+
export {};
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { fetchClientMetadata, isAllowedRedirectUri, isClientIdUrl, isLoopbackUrl, redirectUriMatches, } from './oauthClientMetadata.js';
|
|
3
|
+
import { OAuthError } from './oauthError.js';
|
|
4
|
+
import { MCP_PATH } from './urls.js';
|
|
5
|
+
const REQUEST_JWT_TYPE = 'mcp-oauth-request';
|
|
6
|
+
const CODE_JWT_TYPE = 'mcp-oauth-code';
|
|
7
|
+
const ACCESS_TOKEN_JWT_TYPE = 'mcp-oauth-access';
|
|
8
|
+
const REFRESH_TOKEN_JWT_TYPE = 'mcp-oauth-refresh';
|
|
9
|
+
const REQUEST_TTL = '10m';
|
|
10
|
+
const CODE_TTL = '5m';
|
|
11
|
+
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
12
|
+
// Every refresh issues a new token, so a connection expires only after this long without use.
|
|
13
|
+
const REFRESH_TOKEN_TTL = '7d';
|
|
14
|
+
const CLIENT_METADATA_TTL_MS = 10 * 60 * 1000;
|
|
15
|
+
/** Endpoint paths relative to the AdminForth API prefix (`<baseUrl>/adminapi/v1`). */
|
|
16
|
+
export const OAUTH_PATHS = {
|
|
17
|
+
protectedResourceMetadata: `${MCP_PATH}/oauth-protected-resource`,
|
|
18
|
+
// Clients derive authorization server metadata URL from the issuer; for an issuer with a path, the
|
|
19
|
+
// OpenID Connect Discovery form `<issuer>/.well-known/openid-configuration` is the only one not at the host root,
|
|
20
|
+
// so it works behind a proxy that forwards only the AdminForth path. The host-root forms are in oauthEndpoints.ts.
|
|
21
|
+
authorizationServerMetadata: `${MCP_PATH}/.well-known/openid-configuration`,
|
|
22
|
+
jwks: `${MCP_PATH}/oauth/jwks`,
|
|
23
|
+
authorize: `${MCP_PATH}/oauth/authorize`,
|
|
24
|
+
token: `${MCP_PATH}/oauth/token`,
|
|
25
|
+
authorization: `${MCP_PATH}/oauth/authorization`,
|
|
26
|
+
};
|
|
27
|
+
export const CONSENT_PAGE_PATH = '/mcp-authorize';
|
|
28
|
+
function requireParams(params, names) {
|
|
29
|
+
const missing = names.filter((name) => typeof params[name] !== 'string' || !params[name]);
|
|
30
|
+
if (missing.length) {
|
|
31
|
+
throw new OAuthError('invalid_request', `Missing or invalid parameters: ${missing.join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
return params;
|
|
34
|
+
}
|
|
35
|
+
function pkceChallenge(codeVerifier) {
|
|
36
|
+
return createHash('sha256').update(codeVerifier).digest('base64url');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* OAuth 2.1 authorization server for the MCP endpoint: authorization code flow with PKCE for public clients
|
|
40
|
+
* identified by Client ID Metadata Documents. Authorization requests and codes are signed JWTs, so only the
|
|
41
|
+
* resulting grants are stored, as records of the auth secret resource.
|
|
42
|
+
*/
|
|
43
|
+
export class McpOAuth {
|
|
44
|
+
adminforth;
|
|
45
|
+
store;
|
|
46
|
+
serverReadOnly;
|
|
47
|
+
mcpUrl;
|
|
48
|
+
resourceMetadataUrl;
|
|
49
|
+
issuer;
|
|
50
|
+
apiUrl;
|
|
51
|
+
consentPageUrl;
|
|
52
|
+
configuredClients;
|
|
53
|
+
fetchedClients = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* `configuredClients` are trusted as is, without fetching their client metadata documents. `serverReadOnly` is
|
|
56
|
+
* the plugin readOnly option: it makes every connection read-only, whatever the user chooses on the consent page.
|
|
57
|
+
*/
|
|
58
|
+
constructor(adminforth, store, urls, configuredClients = [], serverReadOnly = false) {
|
|
59
|
+
this.adminforth = adminforth;
|
|
60
|
+
this.store = store;
|
|
61
|
+
this.serverReadOnly = serverReadOnly;
|
|
62
|
+
this.configuredClients = new Map(configuredClients.map((client) => [client.clientId, client]));
|
|
63
|
+
this.apiUrl = urls.apiUrl;
|
|
64
|
+
this.mcpUrl = urls.mcpUrl;
|
|
65
|
+
this.issuer = this.mcpUrl;
|
|
66
|
+
this.resourceMetadataUrl = `${this.apiUrl}${OAUTH_PATHS.protectedResourceMetadata}`;
|
|
67
|
+
this.consentPageUrl = new URL(`${adminforth.config.baseUrl}${CONSENT_PAGE_PATH}`, this.apiUrl).href;
|
|
68
|
+
}
|
|
69
|
+
protectedResourceMetadata() {
|
|
70
|
+
return {
|
|
71
|
+
resource: this.mcpUrl,
|
|
72
|
+
authorization_servers: [this.issuer],
|
|
73
|
+
bearer_methods_supported: ['header'],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
authorizationServerMetadata() {
|
|
77
|
+
return {
|
|
78
|
+
issuer: this.issuer,
|
|
79
|
+
authorization_endpoint: `${this.apiUrl}${OAUTH_PATHS.authorize}`,
|
|
80
|
+
token_endpoint: `${this.apiUrl}${OAUTH_PATHS.token}`,
|
|
81
|
+
response_types_supported: ['code'],
|
|
82
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
83
|
+
code_challenge_methods_supported: ['S256'],
|
|
84
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
85
|
+
client_id_metadata_document_supported: true,
|
|
86
|
+
authorization_response_iss_parameter_supported: true,
|
|
87
|
+
// Required by OpenID Connect Discovery, which clients validate this document against. No ID tokens are
|
|
88
|
+
// issued, so the key set is empty.
|
|
89
|
+
jwks_uri: `${this.apiUrl}${OAUTH_PATHS.jwks}`,
|
|
90
|
+
subject_types_supported: ['public'],
|
|
91
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Validates the authorization request and returns the consent page URL to redirect the browser to.
|
|
96
|
+
* This endpoint needs no sign-in, so the client metadata document is fetched only once the user opens the
|
|
97
|
+
* consent page: otherwise anyone could make this server send requests to any URL passed as client_id.
|
|
98
|
+
*/
|
|
99
|
+
async authorize(query) {
|
|
100
|
+
const params = requireParams(query, [
|
|
101
|
+
'response_type', 'client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method',
|
|
102
|
+
]);
|
|
103
|
+
if (params.response_type !== 'code') {
|
|
104
|
+
throw new OAuthError('unsupported_response_type', 'Only response_type=code is supported');
|
|
105
|
+
}
|
|
106
|
+
if (params.code_challenge_method !== 'S256') {
|
|
107
|
+
throw new OAuthError('invalid_request', 'Only code_challenge_method=S256 is supported');
|
|
108
|
+
}
|
|
109
|
+
this.checkResource(query.resource);
|
|
110
|
+
const redirectUrl = URL.parse(params.redirect_uri);
|
|
111
|
+
if (!redirectUrl || !isAllowedRedirectUri(redirectUrl)) {
|
|
112
|
+
throw new OAuthError('invalid_request', 'redirect_uri must use https unless it points to localhost');
|
|
113
|
+
}
|
|
114
|
+
if (!this.configuredClients.has(params.client_id) && !isClientIdUrl(params.client_id)) {
|
|
115
|
+
throw new OAuthError('invalid_client', 'client_id must be the https URL of a client metadata document');
|
|
116
|
+
}
|
|
117
|
+
const request = {
|
|
118
|
+
clientId: params.client_id,
|
|
119
|
+
redirectUri: params.redirect_uri,
|
|
120
|
+
codeChallenge: params.code_challenge,
|
|
121
|
+
state: typeof query.state === 'string' ? query.state : undefined,
|
|
122
|
+
};
|
|
123
|
+
const signedRequest = this.adminforth.auth.issueJWT(request, REQUEST_JWT_TYPE, REQUEST_TTL);
|
|
124
|
+
return `${this.consentPageUrl}?request=${encodeURIComponent(signedRequest)}`;
|
|
125
|
+
}
|
|
126
|
+
/** What the consent page shows about a pending authorization request. */
|
|
127
|
+
async describeAuthorization(signedRequest) {
|
|
128
|
+
const { request, client, redirectUrl } = await this.verifyAuthorizationRequest(signedRequest);
|
|
129
|
+
return {
|
|
130
|
+
clientName: client.clientName,
|
|
131
|
+
// Null for a configured client whose client_id is not a metadata document URL.
|
|
132
|
+
clientHost: URL.parse(request.clientId)?.host ?? null,
|
|
133
|
+
redirectHost: redirectUrl.host,
|
|
134
|
+
loopbackRedirect: isLoopbackUrl(redirectUrl),
|
|
135
|
+
serverReadOnly: this.serverReadOnly,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Approves or denies a pending authorization request and returns where to send the browser. `readOnly` is the
|
|
140
|
+
* choice of the user on the consent page; the connection is read-only with it or with the plugin readOnly option.
|
|
141
|
+
*/
|
|
142
|
+
async resolveAuthorization(signedRequest, approved, adminUser, readOnly = false) {
|
|
143
|
+
// Denying redirects too, so the redirect_uri is checked against the client metadata either way.
|
|
144
|
+
const { request, client, redirectUrl: url } = await this.verifyAuthorizationRequest(signedRequest);
|
|
145
|
+
if (approved) {
|
|
146
|
+
const code = {
|
|
147
|
+
jti: randomUUID(),
|
|
148
|
+
pk: adminUser.pk,
|
|
149
|
+
clientId: request.clientId,
|
|
150
|
+
clientName: client.clientName,
|
|
151
|
+
redirectUri: request.redirectUri,
|
|
152
|
+
codeChallenge: request.codeChallenge,
|
|
153
|
+
readOnly: this.serverReadOnly || readOnly,
|
|
154
|
+
};
|
|
155
|
+
url.searchParams.set('code', this.adminforth.auth.issueJWT(code, CODE_JWT_TYPE, CODE_TTL));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
url.searchParams.set('error', 'access_denied');
|
|
159
|
+
}
|
|
160
|
+
if (request.state !== undefined) {
|
|
161
|
+
url.searchParams.set('state', request.state);
|
|
162
|
+
}
|
|
163
|
+
url.searchParams.set('iss', this.issuer);
|
|
164
|
+
return url.href;
|
|
165
|
+
}
|
|
166
|
+
async exchangeToken(body) {
|
|
167
|
+
if (body.grant_type === 'authorization_code')
|
|
168
|
+
return this.redeemAuthorizationCode(body);
|
|
169
|
+
if (body.grant_type === 'refresh_token')
|
|
170
|
+
return this.refreshAccessToken(body);
|
|
171
|
+
throw new OAuthError('unsupported_grant_type', 'Only authorization_code and refresh_token grants are supported');
|
|
172
|
+
}
|
|
173
|
+
/** Returns the grant an OAuth access token was issued for, or null for an invalid, expired or foreign token. */
|
|
174
|
+
async verifyAccessToken(token) {
|
|
175
|
+
const accessToken = await this.adminforth.auth.verify(token, ACCESS_TOKEN_JWT_TYPE, false);
|
|
176
|
+
return accessToken?.aud === this.mcpUrl ? accessToken : null;
|
|
177
|
+
}
|
|
178
|
+
checkResource(resource) {
|
|
179
|
+
if (resource !== undefined && resource !== this.mcpUrl) {
|
|
180
|
+
throw new OAuthError('invalid_target', `resource must be ${this.mcpUrl}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async verifyAuthorizationRequest(signedRequest) {
|
|
184
|
+
const request = await this.adminforth.auth.verify(signedRequest, REQUEST_JWT_TYPE, false);
|
|
185
|
+
if (!request) {
|
|
186
|
+
throw new OAuthError('invalid_request', 'Authorization request is invalid or expired, start connecting again');
|
|
187
|
+
}
|
|
188
|
+
const client = await this.lookUpClient(request.clientId);
|
|
189
|
+
const redirectUrl = new URL(request.redirectUri);
|
|
190
|
+
if (!client.redirectUris.some((registered) => redirectUriMatches(redirectUrl, registered))) {
|
|
191
|
+
throw new OAuthError('invalid_request', 'redirect_uri is not listed in the client metadata document');
|
|
192
|
+
}
|
|
193
|
+
return { request, client, redirectUrl };
|
|
194
|
+
}
|
|
195
|
+
/** The consent page loads the client to describe it and again to resolve the request, hence the cache. */
|
|
196
|
+
async lookUpClient(clientId) {
|
|
197
|
+
const configured = this.configuredClients.get(clientId);
|
|
198
|
+
if (configured)
|
|
199
|
+
return configured;
|
|
200
|
+
const cached = this.fetchedClients.get(clientId);
|
|
201
|
+
if (cached && cached.expiresAt > Date.now())
|
|
202
|
+
return cached.client;
|
|
203
|
+
const client = await fetchClientMetadata(clientId);
|
|
204
|
+
this.fetchedClients.set(clientId, { client, expiresAt: Date.now() + CLIENT_METADATA_TTL_MS });
|
|
205
|
+
return client;
|
|
206
|
+
}
|
|
207
|
+
async redeemAuthorizationCode(body) {
|
|
208
|
+
const params = requireParams(body, ['code', 'code_verifier', 'client_id', 'redirect_uri']);
|
|
209
|
+
this.checkResource(body.resource);
|
|
210
|
+
const code = await this.adminforth.auth.verify(params.code, CODE_JWT_TYPE, false);
|
|
211
|
+
if (!code) {
|
|
212
|
+
throw new OAuthError('invalid_grant', 'Authorization code is invalid or expired');
|
|
213
|
+
}
|
|
214
|
+
if (code.clientId !== params.client_id || code.redirectUri !== params.redirect_uri) {
|
|
215
|
+
throw new OAuthError('invalid_grant', 'Authorization code was issued for another client_id or redirect_uri');
|
|
216
|
+
}
|
|
217
|
+
if (pkceChallenge(params.code_verifier) !== code.codeChallenge) {
|
|
218
|
+
throw new OAuthError('invalid_grant', 'code_verifier does not match the code challenge');
|
|
219
|
+
}
|
|
220
|
+
const refreshToken = this.issueRefreshToken(code.jti);
|
|
221
|
+
const result = await this.store.createOAuthGrant({
|
|
222
|
+
id: code.jti,
|
|
223
|
+
userId: code.pk,
|
|
224
|
+
clientId: code.clientId,
|
|
225
|
+
clientName: code.clientName,
|
|
226
|
+
readOnly: code.readOnly,
|
|
227
|
+
}, refreshToken);
|
|
228
|
+
if (result.error) {
|
|
229
|
+
throw new OAuthError('invalid_grant', result.error);
|
|
230
|
+
}
|
|
231
|
+
return this.tokenResponse(code.pk, code.jti, refreshToken);
|
|
232
|
+
}
|
|
233
|
+
async refreshAccessToken(body) {
|
|
234
|
+
const params = requireParams(body, ['refresh_token', 'client_id']);
|
|
235
|
+
this.checkResource(body.resource);
|
|
236
|
+
// The signature tells a token this server issued from a made-up one: a made-up token knowing a grant id must
|
|
237
|
+
// not revoke that grant, and anonymous callers must not reach the store or its lock.
|
|
238
|
+
const presented = await this.adminforth.auth.verify(params.refresh_token, REFRESH_TOKEN_JWT_TYPE, false);
|
|
239
|
+
if (!presented) {
|
|
240
|
+
throw new OAuthError('invalid_grant', 'Refresh token is invalid or expired');
|
|
241
|
+
}
|
|
242
|
+
const { grantId } = presented;
|
|
243
|
+
const refreshToken = this.issueRefreshToken(grantId);
|
|
244
|
+
const userId = await this.store.rotateRefreshToken(grantId, params.refresh_token, params.client_id, refreshToken);
|
|
245
|
+
if (!userId) {
|
|
246
|
+
throw new OAuthError('invalid_grant', 'Refresh token was already used or its connection was revoked');
|
|
247
|
+
}
|
|
248
|
+
return this.tokenResponse(userId, grantId, refreshToken);
|
|
249
|
+
}
|
|
250
|
+
issueRefreshToken(grantId) {
|
|
251
|
+
const token = { grantId, nonce: randomBytes(16).toString('base64url') };
|
|
252
|
+
return this.adminforth.auth.issueJWT(token, REFRESH_TOKEN_JWT_TYPE, REFRESH_TOKEN_TTL);
|
|
253
|
+
}
|
|
254
|
+
tokenResponse(pk, grantId, refreshToken) {
|
|
255
|
+
const accessToken = { pk, grantId, aud: this.mcpUrl };
|
|
256
|
+
return {
|
|
257
|
+
access_token: this.adminforth.auth.issueJWT(accessToken, ACCESS_TOKEN_JWT_TYPE, ACCESS_TOKEN_TTL_SECONDS),
|
|
258
|
+
token_type: 'Bearer',
|
|
259
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
260
|
+
refresh_token: refreshToken,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpOAuthClient } from './types.js';
|
|
2
|
+
/** BlockList matches IPv4-mapped IPv6 addresses (::ffff:10.0.0.5) against the IPv4 ranges by itself. */
|
|
3
|
+
export declare function isSpecialPurposeAddress(address: string, family: number): boolean;
|
|
4
|
+
export declare function isClientIdUrl(clientId: string): boolean;
|
|
5
|
+
export declare function isLoopbackUrl(url: URL): boolean;
|
|
6
|
+
/** Native clients listen on a random loopback port, so RFC 8252 lets the port differ from the registered one. */
|
|
7
|
+
export declare function redirectUriMatches(requested: URL, registered: string): boolean;
|
|
8
|
+
export declare function isAllowedRedirectUri(redirectUri: URL): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Resolves an MCP client by its Client ID Metadata Document, the client_id being the document URL
|
|
11
|
+
* that passed isClientIdUrl.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetchClientMetadata(clientId: string): Promise<McpOAuthClient>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import dns from 'node:dns';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { logger } from 'adminforth';
|
|
5
|
+
import { OAuthError } from './oauthError.js';
|
|
6
|
+
const MAX_DOCUMENT_BYTES = 5 * 1024;
|
|
7
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
8
|
+
// client_name is shown on the consent page and stored as the connection name.
|
|
9
|
+
const MAX_CLIENT_NAME_LENGTH = 100;
|
|
10
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
11
|
+
const specialPurposeAddresses = new net.BlockList();
|
|
12
|
+
[
|
|
13
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
14
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
|
|
15
|
+
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
16
|
+
['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
17
|
+
].forEach(([network, prefix]) => specialPurposeAddresses.addSubnet(network, prefix, 'ipv4'));
|
|
18
|
+
[
|
|
19
|
+
['::', 128], ['::1', 128], ['64:ff9b::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],
|
|
20
|
+
['2002::', 16], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
|
21
|
+
].forEach(([network, prefix]) => specialPurposeAddresses.addSubnet(network, prefix, 'ipv6'));
|
|
22
|
+
/** BlockList matches IPv4-mapped IPv6 addresses (::ffff:10.0.0.5) against the IPv4 ranges by itself. */
|
|
23
|
+
export function isSpecialPurposeAddress(address, family) {
|
|
24
|
+
return specialPurposeAddresses.check(address, family === 6 ? 'ipv6' : 'ipv4');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* client_id is an attacker-chosen URL, so the fetch refuses hosts resolving to private, loopback and other
|
|
28
|
+
* special-purpose addresses. Checking in the socket lookup, not before the request, closes DNS rebinding.
|
|
29
|
+
*/
|
|
30
|
+
const lookupPublicAddress = (hostname, options, callback) => {
|
|
31
|
+
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
32
|
+
if (error) {
|
|
33
|
+
callback(error, []);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const specialAddress = addresses.find(({ address, family }) => isSpecialPurposeAddress(address, family));
|
|
37
|
+
if (specialAddress) {
|
|
38
|
+
callback(new Error(`${hostname} resolves to special-purpose address ${specialAddress.address}`), []);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (options.all) {
|
|
42
|
+
callback(null, addresses);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
export function isClientIdUrl(clientId) {
|
|
50
|
+
const url = URL.parse(clientId);
|
|
51
|
+
return url?.protocol === 'https:'
|
|
52
|
+
&& url.href === clientId
|
|
53
|
+
&& !url.username
|
|
54
|
+
&& !url.password
|
|
55
|
+
&& url.pathname !== '/'
|
|
56
|
+
&& !url.hash
|
|
57
|
+
&& !net.isIP(url.hostname)
|
|
58
|
+
&& !url.hostname.startsWith('[');
|
|
59
|
+
}
|
|
60
|
+
export function isLoopbackUrl(url) {
|
|
61
|
+
return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname);
|
|
62
|
+
}
|
|
63
|
+
/** Native clients listen on a random loopback port, so RFC 8252 lets the port differ from the registered one. */
|
|
64
|
+
export function redirectUriMatches(requested, registered) {
|
|
65
|
+
const registeredUrl = new URL(registered);
|
|
66
|
+
if (!isLoopbackUrl(registeredUrl))
|
|
67
|
+
return requested.href === registeredUrl.href;
|
|
68
|
+
return requested.protocol === registeredUrl.protocol
|
|
69
|
+
&& requested.hostname === registeredUrl.hostname
|
|
70
|
+
&& requested.pathname === registeredUrl.pathname
|
|
71
|
+
&& requested.search === registeredUrl.search
|
|
72
|
+
&& requested.hash === registeredUrl.hash;
|
|
73
|
+
}
|
|
74
|
+
export function isAllowedRedirectUri(redirectUri) {
|
|
75
|
+
return redirectUri.protocol === 'https:' || isLoopbackUrl(redirectUri);
|
|
76
|
+
}
|
|
77
|
+
function fetchJson(url) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const request = https.get(url, {
|
|
80
|
+
lookup: lookupPublicAddress,
|
|
81
|
+
headers: { Accept: 'application/json' },
|
|
82
|
+
}, (response) => {
|
|
83
|
+
if (response.statusCode !== 200) {
|
|
84
|
+
response.resume();
|
|
85
|
+
reject(new Error(`responded with HTTP ${response.statusCode}`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
response.on('data', (chunk) => {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > MAX_DOCUMENT_BYTES) {
|
|
93
|
+
request.destroy(new Error(`document is larger than ${MAX_DOCUMENT_BYTES} bytes`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
chunks.push(chunk);
|
|
97
|
+
});
|
|
98
|
+
response.on('end', () => {
|
|
99
|
+
try {
|
|
100
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
reject(error);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
// The `timeout` option of https.get fires only after a silent period, so a server sending a byte every few
|
|
108
|
+
// seconds would hold the request for hours. The deadline caps the whole request instead.
|
|
109
|
+
const deadline = setTimeout(() => request.destroy(new Error(`timed out after ${FETCH_TIMEOUT_MS} ms`)), FETCH_TIMEOUT_MS);
|
|
110
|
+
request.on('close', () => clearTimeout(deadline));
|
|
111
|
+
request.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolves an MCP client by its Client ID Metadata Document, the client_id being the document URL
|
|
116
|
+
* that passed isClientIdUrl.
|
|
117
|
+
*/
|
|
118
|
+
export async function fetchClientMetadata(clientId) {
|
|
119
|
+
let document;
|
|
120
|
+
try {
|
|
121
|
+
document = await fetchJson(clientId);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// The reason stays in the log: resolved addresses, HTTP statuses and parse errors quoting the response
|
|
125
|
+
// would tell whoever chose the client_id what this server can reach.
|
|
126
|
+
logger.warn(`AdminForthMcpPlugin: failed to fetch client metadata document ${clientId}: ${error.message}`);
|
|
127
|
+
throw new OAuthError('invalid_client', 'Could not fetch the client metadata document');
|
|
128
|
+
}
|
|
129
|
+
if (typeof document?.client_name !== 'string'
|
|
130
|
+
|| !Array.isArray(document.redirect_uris)
|
|
131
|
+
|| !document.redirect_uris.length
|
|
132
|
+
|| !document.redirect_uris.every((uri) => typeof uri === 'string' && URL.canParse(uri))
|
|
133
|
+
|| document.token_endpoint_auth_method !== 'none') {
|
|
134
|
+
throw new OAuthError('invalid_client', 'Client metadata document must have client_name, redirect_uris and token_endpoint_auth_method "none"');
|
|
135
|
+
}
|
|
136
|
+
if (document.client_id !== clientId) {
|
|
137
|
+
throw new OAuthError('invalid_client', 'client_id in the client metadata document does not match its URL');
|
|
138
|
+
}
|
|
139
|
+
return { clientId, clientName: document.client_name.slice(0, MAX_CLIENT_NAME_LENGTH), redirectUris: document.redirect_uris };
|
|
140
|
+
}
|