Files
memind/mindspace-mcp-scoped-token.mjs
2026-07-27 15:34:35 +08:00

248 lines
5.3 KiB
JavaScript

import crypto from 'node:crypto';
const TOKEN_PREFIX = 'msm1';
const TOKEN_AUDIENCE = 'mindspace-mcp';
const DEFAULT_TTL_SECONDS = 15 * 60;
const MAX_TTL_SECONDS = 60 * 60;
function scopedTokenError(message, code) {
return Object.assign(new Error(message), { code });
}
function requiredString(value, field) {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw scopedTokenError(
`${field} is required`,
'invalid_mcp_scope',
);
}
return normalized;
}
function normalizeTools(tools) {
const normalized = [
...new Set(
(Array.isArray(tools) ? tools : [])
.map((tool) => String(tool ?? '').trim())
.filter(Boolean),
),
].sort();
if (normalized.length === 0) {
throw scopedTokenError(
'tools scope is required',
'invalid_mcp_scope',
);
}
return normalized;
}
function normalizeSecret(secret) {
const normalized = String(secret ?? '').trim();
if (normalized.length < 16) {
throw scopedTokenError(
'MindSpace MCP token secret must contain at least 16 characters',
'invalid_mcp_token_secret',
);
}
return normalized;
}
function encodeJson(value) {
return Buffer.from(
JSON.stringify(value),
'utf8',
).toString('base64url');
}
function decodeJson(value) {
try {
return JSON.parse(
Buffer.from(
String(value ?? ''),
'base64url',
).toString('utf8'),
);
} catch {
throw scopedTokenError(
'Malformed MindSpace MCP token payload',
'invalid_mcp_token',
);
}
}
function signPayload(payloadSegment, secret) {
return crypto
.createHmac('sha256', normalizeSecret(secret))
.update(
`${TOKEN_PREFIX}.${payloadSegment}`,
'utf8',
)
.digest('base64url');
}
function signaturesMatch(left, right) {
const leftBuffer = Buffer.from(
String(left ?? ''),
'utf8',
);
const rightBuffer = Buffer.from(
String(right ?? ''),
'utf8',
);
return (
leftBuffer.length === rightBuffer.length &&
crypto.timingSafeEqual(
leftBuffer,
rightBuffer,
)
);
}
export function mintMindSpaceMcpScopedToken({
secret,
userId,
sessionId,
packageId,
workspaceRef,
tools,
ttlSeconds = DEFAULT_TTL_SECONDS,
now = Date.now(),
tokenId = crypto.randomUUID(),
} = {}) {
const issuedAt = Math.floor(Number(now) / 1000);
const normalizedTtl = Math.min(
MAX_TTL_SECONDS,
Math.max(60, Number(ttlSeconds) || 0),
);
const payload = {
v: 1,
aud: TOKEN_AUDIENCE,
sub: requiredString(userId, 'userId'),
sessionId: requiredString(
sessionId,
'sessionId',
),
packageId: requiredString(
packageId,
'packageId',
),
workspaceRef: requiredString(
workspaceRef,
'workspaceRef',
),
tools: normalizeTools(tools),
iat: issuedAt,
exp: issuedAt + normalizedTtl,
jti: requiredString(tokenId, 'tokenId'),
};
const payloadSegment = encodeJson(payload);
const signature = signPayload(
payloadSegment,
secret,
);
return `${TOKEN_PREFIX}.${payloadSegment}.${signature}`;
}
export function verifyMindSpaceMcpScopedToken({
token,
secret,
tool = null,
now = Date.now(),
clockSkewSeconds = 30,
} = {}) {
const segments = String(token ?? '').split('.');
if (
segments.length !== 3 ||
segments[0] !== TOKEN_PREFIX
) {
throw scopedTokenError(
'Malformed MindSpace MCP token',
'invalid_mcp_token',
);
}
const expectedSignature = signPayload(
segments[1],
secret,
);
if (
!signaturesMatch(
segments[2],
expectedSignature,
)
) {
throw scopedTokenError(
'Invalid MindSpace MCP token signature',
'invalid_mcp_token',
);
}
const payload = decodeJson(segments[1]);
const currentSeconds =
Math.floor(Number(now) / 1000);
const skew = Math.max(
0,
Number(clockSkewSeconds) || 0,
);
if (
payload?.v !== 1 ||
payload?.aud !== TOKEN_AUDIENCE
) {
throw scopedTokenError(
'Invalid MindSpace MCP token audience',
'invalid_mcp_token',
);
}
if (
!Number.isFinite(Number(payload.iat)) ||
!Number.isFinite(Number(payload.exp)) ||
Number(payload.iat) > currentSeconds + skew ||
Number(payload.exp) < currentSeconds - skew
) {
throw scopedTokenError(
'Expired or not-yet-valid MindSpace MCP token',
'expired_mcp_token',
);
}
const claims = {
userId: requiredString(payload.sub, 'userId'),
sessionId: requiredString(
payload.sessionId,
'sessionId',
),
packageId: requiredString(
payload.packageId,
'packageId',
),
workspaceRef: requiredString(
payload.workspaceRef,
'workspaceRef',
),
tools: normalizeTools(payload.tools),
issuedAt: Number(payload.iat) * 1000,
expiresAt: Number(payload.exp) * 1000,
tokenId: requiredString(
payload.jti,
'tokenId',
),
};
const normalizedTool = String(tool ?? '').trim();
if (
normalizedTool &&
!claims.tools.includes(normalizedTool)
) {
throw scopedTokenError(
`MindSpace MCP tool is not allowed: ${normalizedTool}`,
'mcp_tool_forbidden',
);
}
return claims;
}
export const mindSpaceMcpScopedTokenInternals = {
DEFAULT_TTL_SECONDS,
MAX_TTL_SECONDS,
TOKEN_AUDIENCE,
TOKEN_PREFIX,
normalizeTools,
};