248 lines
5.3 KiB
JavaScript
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,
|
|
};
|