feat(mcpd): McpToken schema + CRUD routes + introspection
Adds a new McpToken Prisma model (project-scoped, SHA-256 hashed at rest, optional expiry, revocable) plus backing repository, service, and REST routes. Tokens are a first-class RBAC subject: new 'McpToken' kind is added to the subject enum and the service auto-creates an RbacDefinition with subject McpToken:<sha> when bindings are provided. Creator-permission ceiling: the service rejects any requested binding the creator cannot already satisfy themselves (re-uses rbacService.canAccess / canRunOperation). rbacMode=clone snapshots the creator's full permissions into the token. Routes: POST /api/v1/mcptokens create (returns raw token once) GET /api/v1/mcptokens list (filter by project) GET /api/v1/mcptokens/:id describe (no secret in response) POST /api/v1/mcptokens/:id/revoke soft-delete + remove RbacDef DELETE /api/v1/mcptokens/:id hard-delete GET /api/v1/mcptokens/introspect validate raw bearer (used by mcplocal) Extends AuditEvent with optional tokenName/tokenSha fields (indexed) so token-driven activity can be filtered later. Adds token helpers in @mcpctl/shared: TOKEN_PREFIX='mcpctl_pat_', generateToken, hashToken, isMcpToken, timingSafeEqualHex. Follow-up PRs add the auth-hook dispatch on the prefix, the CLI verbs, and the HTTP-mode mcplocal that calls /introspect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,4 @@ export * from './validation/index.js';
|
||||
export * from './constants/index.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './secrets/index.js';
|
||||
export * from './tokens/index.js';
|
||||
|
||||
41
src/shared/src/tokens/index.ts
Normal file
41
src/shared/src/tokens/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export const TOKEN_PREFIX = 'mcpctl_pat_';
|
||||
|
||||
// base62 alphabet (URL/header safe, no ambiguous chars across all positions)
|
||||
const BASE62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
export interface GeneratedToken {
|
||||
/** The raw token — `mcpctl_pat_<32 base62 chars>`. Shown once at create time; never stored. */
|
||||
raw: string;
|
||||
/** SHA-256 hex digest of the raw value. Persist this, not the raw value. */
|
||||
hash: string;
|
||||
/** First 16 chars of the raw token, safe to display (e.g. in `mcpctl get mcptoken`). */
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function generateToken(): GeneratedToken {
|
||||
const bytes = randomBytes(24);
|
||||
let body = '';
|
||||
for (const b of bytes) body += BASE62[b % 62];
|
||||
const raw = TOKEN_PREFIX + body;
|
||||
return { raw, hash: hashToken(raw), prefix: raw.slice(0, 16) };
|
||||
}
|
||||
|
||||
export function hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
export function isMcpToken(bearer: string): boolean {
|
||||
return bearer.startsWith(TOKEN_PREFIX);
|
||||
}
|
||||
|
||||
/** Constant-time compare two equal-length hex strings. Returns false on length mismatch. */
|
||||
export function timingSafeEqualHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user