@gmag11/nodered-mcp-server 1.0.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/LICENSE +201 -0
- package/README.md +162 -0
- package/index.js +133 -0
- package/package.json +58 -0
- package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
- package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
- package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
- package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
- package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
- package/resources/skills/nodered-mustache/SKILL.md +588 -0
- package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
- package/resources/skills/nodered-node-reference/examples/common.json +113 -0
- package/resources/skills/nodered-node-reference/examples/network.json +107 -0
- package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
- package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
- package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
- package/resources/skills/nodered-patterns/SKILL.md +414 -0
- package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
- package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
- package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
- package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
- package/resources/skills/nodered-subflows/SKILL.md +261 -0
- package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
- package/src/auth/api-key-verifier.js +36 -0
- package/src/auth/composite-verifier.js +59 -0
- package/src/auth/config.js +106 -0
- package/src/auth/oauth-clients-store.js +107 -0
- package/src/auth/oauth-provider.js +149 -0
- package/src/auth/oauth-token-store.js +312 -0
- package/src/nodered/auth.js +158 -0
- package/src/nodered/client.js +199 -0
- package/src/nodered/comms-client.js +500 -0
- package/src/renderer/colors.js +161 -0
- package/src/renderer/geometry.js +115 -0
- package/src/renderer/html-builder.js +571 -0
- package/src/renderer/index.js +51 -0
- package/src/renderer/ir-builder.js +161 -0
- package/src/renderer/layout.js +126 -0
- package/src/renderer/mermaid-builder.js +109 -0
- package/src/renderer/svg-builder.js +228 -0
- package/src/schemas/responses.js +283 -0
- package/src/server.js +844 -0
- package/src/skills/loader.js +84 -0
- package/src/staging-store.js +258 -0
- package/src/tools/add-nodes-to-group.js +216 -0
- package/src/tools/connect-nodes.js +115 -0
- package/src/tools/constants.js +45 -0
- package/src/tools/create-flow.js +87 -0
- package/src/tools/create-node.js +126 -0
- package/src/tools/create-subflow-instance.js +123 -0
- package/src/tools/create-subflow.js +101 -0
- package/src/tools/delete-context.js +60 -0
- package/src/tools/delete-flow.js +81 -0
- package/src/tools/delete-group.js +116 -0
- package/src/tools/delete-node.js +73 -0
- package/src/tools/delete-subflow.js +103 -0
- package/src/tools/deploy.js +94 -0
- package/src/tools/disconnect-nodes.js +158 -0
- package/src/tools/export-flow.js +161 -0
- package/src/tools/export-subflow.js +78 -0
- package/src/tools/flow-utils.js +376 -0
- package/src/tools/get-config-nodes.js +86 -0
- package/src/tools/get-context.js +76 -0
- package/src/tools/get-flow-diagram.js +99 -0
- package/src/tools/get-flow-nodes.js +116 -0
- package/src/tools/get-flows.js +74 -0
- package/src/tools/get-node-detail.js +77 -0
- package/src/tools/get-node-type-detail.js +92 -0
- package/src/tools/get-palette-nodes.js +63 -0
- package/src/tools/get-staging-status.js +34 -0
- package/src/tools/get-subflow-detail.js +110 -0
- package/src/tools/get-subflows.js +105 -0
- package/src/tools/import-flow.js +310 -0
- package/src/tools/inject-message.js +117 -0
- package/src/tools/install-node.js +31 -0
- package/src/tools/read-debug-messages.js +155 -0
- package/src/tools/refresh-staging.js +62 -0
- package/src/tools/remove-nodes-from-group.js +162 -0
- package/src/tools/render-staging.js +69 -0
- package/src/tools/response-utils.js +42 -0
- package/src/tools/search-nodes.js +134 -0
- package/src/tools/uninstall-node.js +31 -0
- package/src/tools/update-flow.js +95 -0
- package/src/tools/update-group.js +77 -0
- package/src/tools/update-node.js +132 -0
- package/src/tools/update-subflow.js +84 -0
- package/src/transport/http.js +252 -0
- package/src/transport/stdio.js +16 -0
- package/src/transport/ws-server.js +223 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON file-backed OAuth client registry.
|
|
3
|
+
*
|
|
4
|
+
* Implements the SDK's client store interface:
|
|
5
|
+
* - getClient(clientId): retrieves a registered client
|
|
6
|
+
* - registerClient(clientData): registers a new OAuth client
|
|
7
|
+
*
|
|
8
|
+
* Uses atomic writes (temp file + rename) to prevent corruption.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
12
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** Default clients store path when MCP_OAUTH_CLIENTS_FILE is not set. */
|
|
16
|
+
const DEFAULT_CLIENTS_PATH = 'oauth-clients.json';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a JSON file-backed OAuth client registry.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null} filePath - Path to the JSON clients file (null = use default)
|
|
22
|
+
* @returns {Promise<ClientsStore>}
|
|
23
|
+
*/
|
|
24
|
+
export async function createClientsStore(filePath) {
|
|
25
|
+
const resolvedPath = path.resolve(filePath || DEFAULT_CLIENTS_PATH);
|
|
26
|
+
const clients = await loadClients(resolvedPath);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
/**
|
|
30
|
+
* Retrieve a registered OAuth client by ID.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} clientId
|
|
33
|
+
* @returns {Promise<object|null>} The client object or null if not found
|
|
34
|
+
*/
|
|
35
|
+
async getClient(clientId) {
|
|
36
|
+
return clients[clientId] || null;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a new OAuth client (Dynamic Client Registration).
|
|
41
|
+
*
|
|
42
|
+
* Generates client_id and client_secret cryptographically.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} clientData
|
|
45
|
+
* @param {string} clientData.client_name
|
|
46
|
+
* @param {string[]} clientData.redirect_uris
|
|
47
|
+
* @returns {Promise<object>} The registered client
|
|
48
|
+
*/
|
|
49
|
+
async registerClient(clientData) {
|
|
50
|
+
const clientId = randomUUID();
|
|
51
|
+
const clientSecret = randomBytes(32).toString('hex');
|
|
52
|
+
|
|
53
|
+
const client = {
|
|
54
|
+
client_id: clientId,
|
|
55
|
+
client_secret: clientSecret,
|
|
56
|
+
client_name: clientData.client_name,
|
|
57
|
+
redirect_uris: clientData.redirect_uris,
|
|
58
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
clients[clientId] = client;
|
|
62
|
+
await saveClients(resolvedPath, clients);
|
|
63
|
+
|
|
64
|
+
return client;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load clients from the JSON file, or return empty object if the file
|
|
71
|
+
* does not exist.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} filePath
|
|
74
|
+
* @returns {Promise<Record<string, object>>}
|
|
75
|
+
*/
|
|
76
|
+
async function loadClients(filePath) {
|
|
77
|
+
try {
|
|
78
|
+
const data = await readFile(filePath, 'utf-8');
|
|
79
|
+
return JSON.parse(data);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.code === 'ENOENT') {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Atomically save clients to the JSON file.
|
|
90
|
+
*
|
|
91
|
+
* Writes to a temp file first, then renames it over the target.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} filePath
|
|
94
|
+
* @param {Record<string, object>} clients
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
async function saveClients(filePath, clients) {
|
|
98
|
+
const tmpPath = filePath + '.tmp';
|
|
99
|
+
await writeFile(tmpPath, JSON.stringify(clients, null, 2), 'utf-8');
|
|
100
|
+
await rename(tmpPath, filePath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {object} ClientsStore
|
|
105
|
+
* @property {(clientId: string) => Promise<object|null>} getClient
|
|
106
|
+
* @property {(clientData: object) => Promise<object>} registerClient
|
|
107
|
+
*/
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 server provider implementing the MCP SDK's OAuthServerProvider interface.
|
|
3
|
+
*
|
|
4
|
+
* Coordinates between:
|
|
5
|
+
* - ClientsStore: registered OAuth clients
|
|
6
|
+
* - TokenStore: authorization codes, access/refresh tokens
|
|
7
|
+
*
|
|
8
|
+
* Supports authorization code flow with PKCE (S256).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { InvalidGrantError, InvalidClientError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates an OAuth server provider compatible with the SDK's mcpAuthRouter().
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {import('./oauth-clients-store.js').ClientsStore} opts.clientsStore
|
|
19
|
+
* @param {import('./oauth-token-store.js').OAuthTokenStore} opts.tokenStore
|
|
20
|
+
* @returns {import('@modelcontextprotocol/sdk/server/auth/provider.js').OAuthServerProvider}
|
|
21
|
+
*/
|
|
22
|
+
export function createOAuthProvider({ clientsStore, tokenStore }) {
|
|
23
|
+
return {
|
|
24
|
+
// Expose clientsStore as required by the interface
|
|
25
|
+
get clientsStore() {
|
|
26
|
+
return clientsStore;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Begin the authorization flow.
|
|
31
|
+
*
|
|
32
|
+
* Generates an authorization code bound to the PKCE challenge,
|
|
33
|
+
* then redirects the client with the code and state.
|
|
34
|
+
*
|
|
35
|
+
* This is a programmatic authorization — no consent screen.
|
|
36
|
+
* For production, you may want to add a consent UI here.
|
|
37
|
+
*/
|
|
38
|
+
async authorize(client, params, res) {
|
|
39
|
+
// Validate client
|
|
40
|
+
if (!client) {
|
|
41
|
+
throw new InvalidClientError('Unknown client');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const state = params.state;
|
|
45
|
+
const scopes = params.scopes || ['*'];
|
|
46
|
+
const codeChallenge = params.codeChallenge;
|
|
47
|
+
const redirectUri = params.redirectUri;
|
|
48
|
+
|
|
49
|
+
// Generate authorization code
|
|
50
|
+
const code = randomBytes(32).toString('hex');
|
|
51
|
+
|
|
52
|
+
// Store code with challenge for later verification
|
|
53
|
+
await tokenStore.saveAuthorizationCode(
|
|
54
|
+
code,
|
|
55
|
+
client.client_id,
|
|
56
|
+
codeChallenge,
|
|
57
|
+
redirectUri,
|
|
58
|
+
scopes,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Build redirect URL with authorization code
|
|
62
|
+
const redirectUrl = new URL(redirectUri);
|
|
63
|
+
redirectUrl.searchParams.set('code', code);
|
|
64
|
+
if (state) {
|
|
65
|
+
redirectUrl.searchParams.set('state', state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
res.redirect(302, redirectUrl.href);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Retrieve the PKCE code challenge for an authorization code.
|
|
73
|
+
* Does NOT consume the code — the SDK calls this before exchangeAuthorizationCode.
|
|
74
|
+
*/
|
|
75
|
+
async challengeForAuthorizationCode(_client, authorizationCode) {
|
|
76
|
+
const challenge = await tokenStore.getCodeChallenge(authorizationCode);
|
|
77
|
+
if (!challenge) {
|
|
78
|
+
throw new InvalidGrantError('Authorization code is invalid or expired');
|
|
79
|
+
}
|
|
80
|
+
return challenge;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Exchange an authorization code for access and refresh tokens.
|
|
85
|
+
*/
|
|
86
|
+
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri) {
|
|
87
|
+
// Consume the authorization code
|
|
88
|
+
const entry = await tokenStore.consumeAuthorizationCode(authorizationCode);
|
|
89
|
+
if (!entry) {
|
|
90
|
+
throw new InvalidGrantError('Authorization code is invalid or expired');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate client matches
|
|
94
|
+
if (entry.clientId !== client.client_id) {
|
|
95
|
+
throw new InvalidGrantError('Authorization code was issued to a different client');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Validate redirect URI if provided
|
|
99
|
+
if (redirectUri && entry.redirectUri !== redirectUri) {
|
|
100
|
+
throw new InvalidGrantError('Redirect URI mismatch');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Issue tokens
|
|
104
|
+
const tokens = await tokenStore.issueTokens(client.client_id, entry.scopes);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
access_token: tokens.accessToken,
|
|
108
|
+
token_type: 'bearer',
|
|
109
|
+
expires_in: tokens.expiresIn,
|
|
110
|
+
refresh_token: tokens.refreshToken,
|
|
111
|
+
scope: entry.scopes.join(' '),
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Exchange a refresh token for new tokens.
|
|
117
|
+
*/
|
|
118
|
+
async exchangeRefreshToken(client, refreshToken, scopes) {
|
|
119
|
+
const result = await tokenStore.exchangeRefreshToken(refreshToken);
|
|
120
|
+
if (!result) {
|
|
121
|
+
throw new InvalidGrantError('Refresh token is invalid or expired');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const effectiveScopes = scopes && scopes.length > 0 ? scopes : ['*'];
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
access_token: result.accessToken,
|
|
128
|
+
token_type: 'bearer',
|
|
129
|
+
expires_in: result.expiresIn,
|
|
130
|
+
refresh_token: result.refreshToken,
|
|
131
|
+
scope: effectiveScopes.join(' '),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Verify an access token. Delegates to token store.
|
|
137
|
+
*/
|
|
138
|
+
async verifyAccessToken(token) {
|
|
139
|
+
return tokenStore.verifyAccessToken(token);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Revoke an access or refresh token.
|
|
144
|
+
*/
|
|
145
|
+
async revokeToken(_client, request) {
|
|
146
|
+
await tokenStore.revokeToken(request.token);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON file-backed OAuth token and authorization code store.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Authorization codes (temporary, with PKCE challenge)
|
|
6
|
+
* - Access tokens (with clientId, scopes, expiry)
|
|
7
|
+
* - Refresh tokens
|
|
8
|
+
* - Token revocation
|
|
9
|
+
*
|
|
10
|
+
* Uses atomic writes (temp file + rename) to prevent corruption.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
17
|
+
|
|
18
|
+
/** Default token store path when MCP_OAUTH_TOKENS_FILE is not set. */
|
|
19
|
+
const DEFAULT_TOKENS_PATH = 'oauth-tokens.json';
|
|
20
|
+
|
|
21
|
+
/** Access token lifetime: 1 hour */
|
|
22
|
+
const ACCESS_TOKEN_TTL_SEC = 3600;
|
|
23
|
+
|
|
24
|
+
/** Refresh token lifetime: 30 days */
|
|
25
|
+
const REFRESH_TOKEN_TTL_SEC = 30 * 24 * 3600;
|
|
26
|
+
|
|
27
|
+
/** Authorization code lifetime: 10 minutes */
|
|
28
|
+
const AUTH_CODE_TTL_SEC = 600;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a JSON file-backed OAuth token store.
|
|
32
|
+
*
|
|
33
|
+
* @param {string|null} filePath - Path to the JSON tokens file (null = use default)
|
|
34
|
+
* @returns {Promise<OAuthTokenStore>}
|
|
35
|
+
*/
|
|
36
|
+
export async function createTokenStore(filePath) {
|
|
37
|
+
const resolvedPath = path.resolve(filePath || DEFAULT_TOKENS_PATH);
|
|
38
|
+
const data = await loadTokens(resolvedPath);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
// ── Authorization codes ──────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Store an authorization code with its PKCE challenge.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} code
|
|
47
|
+
* @param {string} clientId
|
|
48
|
+
* @param {string} codeChallenge
|
|
49
|
+
* @param {string} redirectUri
|
|
50
|
+
* @param {string[]} scopes
|
|
51
|
+
*/
|
|
52
|
+
async saveAuthorizationCode(code, clientId, codeChallenge, redirectUri, scopes) {
|
|
53
|
+
data.codes = data.codes || {};
|
|
54
|
+
data.codes[code] = {
|
|
55
|
+
clientId,
|
|
56
|
+
codeChallenge,
|
|
57
|
+
redirectUri,
|
|
58
|
+
scopes,
|
|
59
|
+
expiresAt: Math.floor(Date.now() / 1000) + AUTH_CODE_TTL_SEC,
|
|
60
|
+
};
|
|
61
|
+
await saveTokens(resolvedPath, data);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Look up the PKCE challenge for an authorization code WITHOUT consuming it.
|
|
66
|
+
* Returns null if the code is unknown or expired.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} code
|
|
69
|
+
* @returns {Promise<string|null>}
|
|
70
|
+
*/
|
|
71
|
+
async getCodeChallenge(code) {
|
|
72
|
+
data.codes = data.codes || {};
|
|
73
|
+
const entry = data.codes[code];
|
|
74
|
+
if (!entry) return null;
|
|
75
|
+
if (entry.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
76
|
+
delete data.codes[code];
|
|
77
|
+
await saveTokens(resolvedPath, data);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return entry.codeChallenge;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Retrieve and consume an authorization code.
|
|
85
|
+
* Returns null if the code is unknown or expired.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} code
|
|
88
|
+
* @returns {Promise<object|null>}
|
|
89
|
+
*/
|
|
90
|
+
async consumeAuthorizationCode(code) {
|
|
91
|
+
data.codes = data.codes || {};
|
|
92
|
+
const entry = data.codes[code];
|
|
93
|
+
if (!entry) return null;
|
|
94
|
+
|
|
95
|
+
// Check expiry
|
|
96
|
+
if (entry.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
97
|
+
delete data.codes[code];
|
|
98
|
+
await saveTokens(resolvedPath, data);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Consume the code (single-use)
|
|
103
|
+
delete data.codes[code];
|
|
104
|
+
await saveTokens(resolvedPath, data);
|
|
105
|
+
|
|
106
|
+
return entry;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// ── Access tokens ─────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Issue a new access token (and optional refresh token).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} clientId
|
|
115
|
+
* @param {string[]} scopes
|
|
116
|
+
* @returns {Promise<{ accessToken: string, refreshToken?: string, expiresIn: number }>}
|
|
117
|
+
*/
|
|
118
|
+
async issueTokens(clientId, scopes) {
|
|
119
|
+
data.tokens = data.tokens || {};
|
|
120
|
+
|
|
121
|
+
const accessToken = randomBytes(32).toString('hex');
|
|
122
|
+
const refreshToken = randomBytes(32).toString('hex');
|
|
123
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
124
|
+
|
|
125
|
+
data.tokens[accessToken] = {
|
|
126
|
+
clientId,
|
|
127
|
+
scopes,
|
|
128
|
+
expiresAt: nowSec + ACCESS_TOKEN_TTL_SEC,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Store refresh token mapping
|
|
132
|
+
data.refreshTokens = data.refreshTokens || {};
|
|
133
|
+
data.refreshTokens[refreshToken] = {
|
|
134
|
+
accessToken,
|
|
135
|
+
clientId,
|
|
136
|
+
scopes,
|
|
137
|
+
expiresAt: nowSec + REFRESH_TOKEN_TTL_SEC,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
await saveTokens(resolvedPath, data);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
accessToken,
|
|
144
|
+
refreshToken,
|
|
145
|
+
expiresIn: ACCESS_TOKEN_TTL_SEC,
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Verify an access token. Returns the AuthInfo on success.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} token
|
|
153
|
+
* @returns {Promise<import('./types.js').AuthInfo>}
|
|
154
|
+
* @throws {import('@modelcontextprotocol/sdk/server/auth/errors.js').InvalidTokenError}
|
|
155
|
+
*/
|
|
156
|
+
async verifyAccessToken(token) {
|
|
157
|
+
data.tokens = data.tokens || {};
|
|
158
|
+
const entry = data.tokens[token];
|
|
159
|
+
|
|
160
|
+
if (!entry) {
|
|
161
|
+
throw new InvalidTokenError('Token not found');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (entry.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
165
|
+
throw new InvalidTokenError('Token has expired');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
token,
|
|
170
|
+
clientId: entry.clientId,
|
|
171
|
+
scopes: entry.scopes,
|
|
172
|
+
expiresAt: entry.expiresAt,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// ── Refresh tokens ────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Exchange a refresh token for a new access token.
|
|
180
|
+
* The old access token is revoked and the refresh token is rotated.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} refreshToken
|
|
183
|
+
* @returns {Promise<{ accessToken: string, refreshToken: string, expiresIn: number }|null>}
|
|
184
|
+
*/
|
|
185
|
+
async exchangeRefreshToken(refreshToken) {
|
|
186
|
+
data.refreshTokens = data.refreshTokens || {};
|
|
187
|
+
data.tokens = data.tokens || {};
|
|
188
|
+
|
|
189
|
+
const rtEntry = data.refreshTokens[refreshToken];
|
|
190
|
+
if (!rtEntry) return null;
|
|
191
|
+
|
|
192
|
+
// Check refresh token expiry
|
|
193
|
+
if (rtEntry.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
194
|
+
delete data.refreshTokens[refreshToken];
|
|
195
|
+
await saveTokens(resolvedPath, data);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Revoke old access token
|
|
200
|
+
const oldAccessToken = rtEntry.accessToken;
|
|
201
|
+
if (oldAccessToken && data.tokens[oldAccessToken]) {
|
|
202
|
+
delete data.tokens[oldAccessToken];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Issue new tokens
|
|
206
|
+
const newAccessToken = randomBytes(32).toString('hex');
|
|
207
|
+
const newRefreshToken = randomBytes(32).toString('hex');
|
|
208
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
209
|
+
|
|
210
|
+
data.tokens[newAccessToken] = {
|
|
211
|
+
clientId: rtEntry.clientId,
|
|
212
|
+
scopes: rtEntry.scopes,
|
|
213
|
+
expiresAt: nowSec + ACCESS_TOKEN_TTL_SEC,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Rotate refresh token
|
|
217
|
+
delete data.refreshTokens[refreshToken];
|
|
218
|
+
data.refreshTokens[newRefreshToken] = {
|
|
219
|
+
accessToken: newAccessToken,
|
|
220
|
+
clientId: rtEntry.clientId,
|
|
221
|
+
scopes: rtEntry.scopes,
|
|
222
|
+
expiresAt: nowSec + REFRESH_TOKEN_TTL_SEC,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
await saveTokens(resolvedPath, data);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
accessToken: newAccessToken,
|
|
229
|
+
refreshToken: newRefreshToken,
|
|
230
|
+
expiresIn: ACCESS_TOKEN_TTL_SEC,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// ── Revocation ────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Revoke an access token or refresh token.
|
|
238
|
+
* Does nothing if the token is already unknown.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} token
|
|
241
|
+
* @returns {Promise<void>}
|
|
242
|
+
*/
|
|
243
|
+
async revokeToken(token) {
|
|
244
|
+
let changed = false;
|
|
245
|
+
|
|
246
|
+
// Check access tokens
|
|
247
|
+
data.tokens = data.tokens || {};
|
|
248
|
+
if (data.tokens[token]) {
|
|
249
|
+
delete data.tokens[token];
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check refresh tokens
|
|
254
|
+
data.refreshTokens = data.refreshTokens || {};
|
|
255
|
+
if (data.refreshTokens[token]) {
|
|
256
|
+
const rtEntry = data.refreshTokens[token];
|
|
257
|
+
// Also revoke the associated access token
|
|
258
|
+
if (rtEntry.accessToken && data.tokens[rtEntry.accessToken]) {
|
|
259
|
+
delete data.tokens[rtEntry.accessToken];
|
|
260
|
+
}
|
|
261
|
+
delete data.refreshTokens[token];
|
|
262
|
+
changed = true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (changed) {
|
|
266
|
+
await saveTokens(resolvedPath, data);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Load token data from the JSON file, or return empty structure
|
|
274
|
+
* if the file does not exist.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} filePath
|
|
277
|
+
* @returns {Promise<object>}
|
|
278
|
+
*/
|
|
279
|
+
async function loadTokens(filePath) {
|
|
280
|
+
try {
|
|
281
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
282
|
+
return JSON.parse(raw);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err.code === 'ENOENT') {
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Atomically save token data to the JSON file.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} filePath
|
|
295
|
+
* @param {object} data
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
async function saveTokens(filePath, data) {
|
|
299
|
+
const tmpPath = filePath + '.tmp';
|
|
300
|
+
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
301
|
+
await rename(tmpPath, filePath);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @typedef {object} OAuthTokenStore
|
|
306
|
+
* @property {(code: string, clientId: string, codeChallenge: string, redirectUri: string, scopes: string[]) => Promise<void>} saveAuthorizationCode
|
|
307
|
+
* @property {(code: string) => Promise<object|null>} consumeAuthorizationCode
|
|
308
|
+
* @property {(clientId: string, scopes: string[]) => Promise<{ accessToken: string, refreshToken?: string, expiresIn: number }>} issueTokens
|
|
309
|
+
* @property {(token: string) => Promise<import('./types.js').AuthInfo>} verifyAccessToken
|
|
310
|
+
* @property {(refreshToken: string) => Promise<{ accessToken: string, refreshToken: string, expiresIn: number }|null>} exchangeRefreshToken
|
|
311
|
+
* @property {(token: string) => Promise<void>} revokeToken
|
|
312
|
+
*/
|