diff --git a/docs/mcptoken-implementation.md b/docs/mcptoken-implementation.md new file mode 100644 index 0000000..93fdafb --- /dev/null +++ b/docs/mcptoken-implementation.md @@ -0,0 +1,78 @@ +# mcptoken + HTTP-mode mcplocal — implementation log + +Companion to the approved plan at `/home/michal/.claude/plans/lets-discuss-something-i-bright-lovelace.md`. +This file is updated as each milestone lands, so you can review what was actually done vs. what was planned. + +## Context (why) + +You're running your own vLLM inference outside Claude Code and want it to consume mcpctl over MCP with the same UX Claude gets: project-scoped server discovery, proxy models, the pipeline cache. Today `mcplocal` is systemd-only and serves STDIO — unreachable from off-host and unauthenticated. This work adds: + +1. A containerized, network-accessible `mcplocal` serving Streamable HTTP. +2. A new `McpToken` resource (CLI: `mcpctl get/create/delete mcptoken`) — project-scoped bearer tokens with the same RBAC stack as users. Hashed at rest; raw value shown once. +3. Tokens as a first-class RBAC subject kind (`McpToken:`), with a creator-permission ceiling so non-admins cannot mint escalated tokens. +4. k8s deploy (Service `mcp`, Ingress `mcp.ad.itaz.eu`, PVC-backed `FileCache`). +5. A CLI breaking change: `mcpctl create rbac --binding edit:servers` → `--roleBindings role:edit,resource:servers`. You explicitly asked for this; only one command uses it. +6. A product-grade `mcpctl test mcp ` verb for validating any Streamable-HTTP MCP endpoint, reused by smoke tests. + +## Branch + +All work lives on `feat/mcptoken` (off `main` at `3149ea3`). + +## Pre-work committed to main (outside this branch) + +Before starting the feature, we flushed your in-flight changes to main so they wouldn't travel with the branch: + +- **`3149ea3 fix: MCP proxy resilience — discovery cache, default liveness probes`** — per-server `tools/list` cache in `McpRouter` with positive+negative TTL so dead upstreams only stall the first call; default liveness probe (tools/list through the real production path) applied to any RUNNING instance without an explicit healthCheck. Already pushed to origin. + +## Status legend + +- ✅ done +- 🚧 in progress +- ⬜ not started + +## PR 1 — Schema + token helpers + mcpd CRUD routes ✅ + +| # | Step | Status | +|---|---|---| +| 1 | `McpToken` Prisma model + Project/User reverse relations; `AuditEvent.tokenName` / `tokenSha` + index | ✅ | +| 2 | `src/shared/src/tokens/index.ts` — `generateToken`, `hashToken`, `isMcpToken`, `timingSafeEqualHex`, `TOKEN_PREFIX` | ✅ | +| 3 | `src/mcpd/src/repositories/mcp-token.repository.ts` + new interfaces in `repositories/interfaces.ts` | ✅ | +| 4 | `src/mcpd/src/services/mcp-token.service.ts` — creator-ceiling via `rbacService.canAccess`/`canRunOperation`, raw token returned only once, auto-creates an `RbacDefinition` with subject `McpToken:` when bindings are non-empty | ✅ | +| 5 | `src/mcpd/src/routes/mcp-tokens.ts` — POST / GET / GET:id / DELETE:id + POST:id/revoke + GET /introspect | ✅ | +| 6 | Wired into `main.ts` — repo/service constructed, routes registered, `mcptokens` added to URL→permission map + name resolver; `/mcptokens/introspect` added to auth-skip list so mcplocal can call it with a raw McpToken bearer | ✅ | +| 7 | RBAC extensions: new subject kind `McpToken` in `rbac-definition.schema.ts`; `mcptokens` added to `RBAC_RESOURCES` and `RESOURCE_ALIASES`; `rbac.service.ts` threads optional `mcpTokenSha` through `canAccess`, `canRunOperation`, `getAllowedScope`, `getPermissions`; resolver matches `{kind:'McpToken', name: sha}` | ✅ | +| 8 | Unit tests — `tests/mcp-token-service.test.ts` covering: empty/clone modes, ceiling rejection, RbacDefinition auto-create with correct `McpToken:` subject, duplicate-name conflict, introspect valid/revoked/expired/unknown, revoke deletes the RbacDefinition. 11/11 green. Full mcpd suite still 648/648. | ✅ | + +### What this PR does NOT do yet (coming in PR 3) + +- The mcpd **auth middleware** does not yet dispatch on the token prefix. A raw `mcpctl_pat_…` bearer sent to any `/api/v1/*` endpoint (other than `/introspect`) is still rejected as an invalid session. That's intentional — PR 3 extends `middleware/auth.ts` to recognize both session bearers and McpToken bearers. +- No CLI yet. Tokens can be created only via `POST /api/v1/mcptokens` for now. + +## PR 2 — RBAC CLI migration + +_(blocked by PR 1 — parser is reused by PR 3)_ + +## PR 3 — CLI mcptoken verbs + mcpd auth dispatch + audit + +_(blocked)_ + +## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke + +_(blocked)_ + +## Design decisions recap (so you don't have to re-read the plan) + +| Decision | Choice | +|---|---| +| Transport | Streamable HTTP only | +| Binary shape | Same `@mcpctl/mcplocal` package, two entry files (`main.ts` STDIO, `serve.ts` HTTP) | +| Container runtime | Node (not bun-compiled) — mirrors mcpd | +| Cache | PVC at `/var/lib/mcplocal/cache` | +| Hostname | k8s Service `mcp`, Ingress `mcp.ad.itaz.eu` | +| Token format | `mcpctl_pat_<32-byte base62>`, stored as SHA-256, shown-once at create | +| Resource | `McpToken`, CLI noun `mcptoken`, one-project-per-token, FK cascade | +| Subject kind | New `McpToken:` | +| TTL | No default. Optional `--ttl 30d` / `never` / ISO date | +| Default bindings | `--rbac=empty` (default), `--rbac=clone`, `--bind ` — creator ceiling enforced server-side | +| Binding CLI | `--roleBindings role:view,resource:servers[,name:foo]` or `--roleBindings action:logs` | +| Project enforcement | Endpoint visibility only (no strict create-time check) — same mechanism Claude uses | diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 978036e..2186eb8 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { auditLogs AuditLog[] ownedProjects Project[] groupMemberships GroupMember[] + mcpTokens McpToken[] @@index([email]) } @@ -187,6 +188,7 @@ model Project { servers ProjectServer[] prompts Prompt[] promptRequests PromptRequest[] + mcpTokens McpToken[] @@index([name]) @@index([ownerId]) @@ -204,6 +206,36 @@ model ProjectServer { @@unique([projectId, serverId]) } +// ── MCP Tokens (bearer credentials for HTTP-mode mcplocal) ── +// +// Raw value format: `mcpctl_pat_<32 base62 chars>`. The raw value is shown +// exactly once at create time; only the SHA-256 hash is persisted. Tokens are +// scoped to exactly one project — they're only valid at +// `/projects//mcp`. Creator's RBAC is the ceiling; the service +// rejects bindings that exceed what the creator themselves can do. + +model McpToken { + id String @id @default(cuid()) + name String + projectId String + tokenHash String @unique + tokenPrefix String + ownerId String + description String @default("") + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + revokedAt DateTime? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + + @@unique([name, projectId]) + @@index([tokenHash]) + @@index([projectId]) + @@index([ownerId]) +} + // ── MCP Instances (running containers) ── model McpInstance { @@ -288,6 +320,8 @@ model AuditEvent { correlationId String? parentEventId String? userName String? + tokenName String? + tokenSha String? payload Json createdAt DateTime @default(now()) @@ -297,6 +331,7 @@ model AuditEvent { @@index([timestamp]) @@index([eventKind]) @@index([userName]) + @@index([tokenSha]) } // ── Backup Pending Queue ── diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 7afef23..95d9a34 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -18,6 +18,7 @@ import { UserRepository, GroupRepository, AuditEventRepository, + McpTokenRepository, } from './repositories/index.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; @@ -43,6 +44,7 @@ import { UserService, GroupService, AuditEventService, + McpTokenService, } from './services/index.js'; import type { RbacAction } from './services/index.js'; import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js'; @@ -62,6 +64,7 @@ import { registerUserRoutes, registerGroupRoutes, registerAuditEventRoutes, + registerMcpTokenRoutes, } from './routes/index.js'; import { registerPromptRoutes } from './routes/prompts.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; @@ -104,6 +107,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'mcp': 'servers', 'prompts': 'prompts', 'promptrequests': 'promptrequests', + 'mcptokens': 'mcptokens', }; const resource = resourceMap[segment]; @@ -116,6 +120,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] }; } + // Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token. + const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/); + if (revokeMatch?.[1]) { + return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] }; + } + // Special case: /api/v1/projects/:name/prompts/visible → view prompts const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/); if (visiblePromptsMatch?.[1]) { @@ -259,6 +269,7 @@ async function main(): Promise { const rbacDefinitionRepo = new RbacDefinitionRepository(prisma); const userRepo = new UserRepository(prisma); const groupRepo = new GroupRepository(prisma); + const mcpTokenRepo = new McpTokenRepository(prisma); // CUID detection for RBAC name resolution const CUID_RE = /^c[^\s-]{8,}$/i; @@ -267,6 +278,7 @@ async function main(): Promise { secrets: secretRepo, projects: projectRepo, groups: groupRepo, + mcptokens: mcpTokenRepo, }; // Migrate legacy 'admin' role → granular roles @@ -292,6 +304,7 @@ async function main(): Promise { const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator); const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); const rbacService = new RbacService(rbacDefinitionRepo, prisma); + const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService); const userService = new UserService(userRepo); const groupService = new GroupService(groupRepo, userRepo); const promptRepo = new PromptRepository(prisma); @@ -329,6 +342,8 @@ async function main(): Promise { const url = request.url; // Skip auth for health, auth, and root if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return; + // Introspection authenticates via the McpToken bearer itself — route handles its own auth. + if (url.startsWith('/api/v1/mcptokens/introspect')) return; if (!url.startsWith('/api/v1/')) return; // Run auth middleware @@ -393,6 +408,7 @@ async function main(): Promise { registerRbacRoutes(app, rbacDefinitionService); registerUserRoutes(app, userService); registerGroupRoutes(app, groupService); + registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo }); registerPromptRoutes(app, promptService, projectRepo); // ── Git-based backup ── diff --git a/src/mcpd/src/repositories/audit-event.repository.ts b/src/mcpd/src/repositories/audit-event.repository.ts index f64aaf6..4fe1bb7 100644 --- a/src/mcpd/src/repositories/audit-event.repository.ts +++ b/src/mcpd/src/repositories/audit-event.repository.ts @@ -30,6 +30,8 @@ export class AuditEventRepository implements IAuditEventRepository { correlationId: e.correlationId ?? null, parentEventId: e.parentEventId ?? null, userName: e.userName ?? null, + tokenName: e.tokenName ?? null, + tokenSha: e.tokenSha ?? null, payload: e.payload as Prisma.InputJsonValue, })); const result = await this.prisma.auditEvent.createMany({ data }); @@ -132,6 +134,8 @@ function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput { if (filter.serverName !== undefined) where.serverName = filter.serverName; if (filter.correlationId !== undefined) where.correlationId = filter.correlationId; if (filter.userName !== undefined) where.userName = filter.userName; + if (filter.tokenName !== undefined) where.tokenName = filter.tokenName; + if (filter.tokenSha !== undefined) where.tokenSha = filter.tokenSha; if (filter.from !== undefined || filter.to !== undefined) { const timestamp: Prisma.DateTimeFilter = {}; diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 8a9fa33..09fb235 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -15,3 +15,5 @@ export type { IGroupRepository, GroupWithMembers } from './group.repository.js'; export { GroupRepository } from './group.repository.js'; export type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js'; export { AuditEventRepository } from './audit-event.repository.js'; +export type { IMcpTokenRepository, McpTokenFilter, McpTokenWithRelations, CreateMcpTokenRepoInput } from './interfaces.js'; +export { McpTokenRepository } from './mcp-token.repository.js'; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 53b81b3..950c339 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -1,4 +1,4 @@ -import type { McpServer, McpInstance, AuditLog, AuditEvent, Secret, InstanceStatus } from '@prisma/client'; +import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client'; import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js'; @@ -57,6 +57,8 @@ export interface AuditEventFilter { serverName?: string; correlationId?: string; userName?: string; + tokenName?: string; + tokenSha?: string; from?: Date; to?: Date; limit?: number; @@ -74,6 +76,8 @@ export interface AuditEventCreateInput { correlationId?: string; parentEventId?: string; userName?: string; + tokenName?: string; + tokenSha?: string; payload: Record; } @@ -95,3 +99,37 @@ export interface IAuditEventRepository { listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise; countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise; } + +// ── MCP Tokens ── + +export interface McpTokenFilter { + projectId?: string; + ownerId?: string; + includeRevoked?: boolean; +} + +export interface CreateMcpTokenRepoInput { + name: string; + projectId: string; + ownerId: string; + tokenHash: string; + tokenPrefix: string; + description?: string; + expiresAt?: Date | null; +} + +export type McpTokenWithRelations = McpToken & { + project: { id: string; name: string }; + owner: { id: string; email: string }; +}; + +export interface IMcpTokenRepository { + findAll(filter?: McpTokenFilter): Promise; + findById(id: string): Promise; + findByHash(tokenHash: string): Promise; + findByNameAndProject(name: string, projectId: string): Promise; + create(data: CreateMcpTokenRepoInput): Promise; + revoke(id: string): Promise; + touchLastUsed(id: string): Promise; + delete(id: string): Promise; +} diff --git a/src/mcpd/src/repositories/mcp-token.repository.ts b/src/mcpd/src/repositories/mcp-token.repository.ts new file mode 100644 index 0000000..c2a845b --- /dev/null +++ b/src/mcpd/src/repositories/mcp-token.repository.ts @@ -0,0 +1,83 @@ +import type { PrismaClient } from '@prisma/client'; +import type { + IMcpTokenRepository, + McpTokenFilter, + McpTokenWithRelations, + CreateMcpTokenRepoInput, +} from './interfaces.js'; + +const INCLUDE_RELATIONS = { + project: { select: { id: true, name: true } }, + owner: { select: { id: true, email: true } }, +} as const; + +export class McpTokenRepository implements IMcpTokenRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(filter?: McpTokenFilter): Promise { + const where: Record = {}; + if (filter?.projectId !== undefined) where['projectId'] = filter.projectId; + if (filter?.ownerId !== undefined) where['ownerId'] = filter.ownerId; + if (!filter?.includeRevoked) where['revokedAt'] = null; + return this.prisma.mcpToken.findMany({ + where, + include: INCLUDE_RELATIONS, + orderBy: { createdAt: 'desc' }, + }) as Promise; + } + + async findById(id: string): Promise { + return this.prisma.mcpToken.findUnique({ + where: { id }, + include: INCLUDE_RELATIONS, + }) as Promise; + } + + async findByHash(tokenHash: string): Promise { + return this.prisma.mcpToken.findUnique({ + where: { tokenHash }, + include: INCLUDE_RELATIONS, + }) as Promise; + } + + async findByNameAndProject(name: string, projectId: string): Promise { + return this.prisma.mcpToken.findUnique({ + where: { name_projectId: { name, projectId } }, + include: INCLUDE_RELATIONS, + }) as Promise; + } + + async create(data: CreateMcpTokenRepoInput): Promise { + return this.prisma.mcpToken.create({ + data: { + name: data.name, + projectId: data.projectId, + ownerId: data.ownerId, + tokenHash: data.tokenHash, + tokenPrefix: data.tokenPrefix, + description: data.description ?? '', + expiresAt: data.expiresAt ?? null, + }, + include: INCLUDE_RELATIONS, + }) as Promise; + } + + async revoke(id: string): Promise { + return this.prisma.mcpToken.update({ + where: { id }, + data: { revokedAt: new Date() }, + include: INCLUDE_RELATIONS, + }) as Promise; + } + + async touchLastUsed(id: string): Promise { + await this.prisma.mcpToken.update({ + where: { id }, + data: { lastUsedAt: new Date() }, + }); + } + + async delete(id: string): Promise { + await this.prisma.mcpToken.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index 8880fa0..084f004 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -18,3 +18,5 @@ export { registerRbacRoutes } from './rbac-definitions.js'; export { registerUserRoutes } from './users.js'; export { registerGroupRoutes } from './groups.js'; export { registerAuditEventRoutes } from './audit-events.js'; +export { registerMcpTokenRoutes } from './mcp-tokens.js'; +export type { McpTokenRouteDeps } from './mcp-tokens.js'; diff --git a/src/mcpd/src/routes/mcp-tokens.ts b/src/mcpd/src/routes/mcp-tokens.ts new file mode 100644 index 0000000..af82fa1 --- /dev/null +++ b/src/mcpd/src/routes/mcp-tokens.ts @@ -0,0 +1,142 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { isMcpToken } from '@mcpctl/shared'; +import type { McpTokenService } from '../services/mcp-token.service.js'; +import { PermissionCeilingError } from '../services/mcp-token.service.js'; +import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; + +export interface McpTokenRouteDeps { + tokenService: McpTokenService; + projectRepo: IProjectRepository; +} + +export function registerMcpTokenRoutes(app: FastifyInstance, deps: McpTokenRouteDeps): void { + const { tokenService, projectRepo } = deps; + + // ── List ───────────────────────────────────────────────────────────── + app.get<{ Querystring: { projectId?: string; projectName?: string; includeRevoked?: string } }>( + '/api/v1/mcptokens', + async (request) => { + const { projectId, projectName, includeRevoked } = request.query; + + // Allow filtering by project name for CLI ergonomics. + let resolvedProjectId = projectId; + if (resolvedProjectId === undefined && projectName !== undefined) { + const project = await projectRepo.findByName(projectName); + if (project === null) throw new NotFoundError(`Project not found: ${projectName}`); + resolvedProjectId = project.id; + } + + const filter: { projectId?: string; includeRevoked?: boolean } = {}; + if (resolvedProjectId !== undefined) filter.projectId = resolvedProjectId; + if (includeRevoked === 'true') filter.includeRevoked = true; + + const rows = await tokenService.list(filter); + return rows.map(toListResponse); + }, + ); + + // ── Describe ───────────────────────────────────────────────────────── + app.get<{ Params: { id: string } }>('/api/v1/mcptokens/:id', async (request) => { + const row = await tokenService.getById(request.params.id); + return toListResponse(row); + }); + + // ── Create ─────────────────────────────────────────────────────────── + app.post('/api/v1/mcptokens', async (request, reply) => { + const userId = request.userId; + if (userId === undefined) { + reply.code(401); + return { error: 'Not authenticated' }; + } + + try { + // Accept projectName OR projectId for CLI ergonomics. + const body = (request.body ?? {}) as Record; + if (typeof body['projectName'] === 'string' && typeof body['projectId'] !== 'string') { + const project = await projectRepo.findByName(body['projectName']); + if (project === null) throw new NotFoundError(`Project not found: ${body['projectName']}`); + body['projectId'] = project.id; + } + + const result = await tokenService.create(userId, body); + reply.code(201); + return { + ...toListResponse(result.mcpToken), + token: result.raw, + }; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + if (err instanceof ConflictError) { + reply.code(409); + return { error: err.message }; + } + if (err instanceof PermissionCeilingError) { + reply.code(403); + return { error: err.message }; + } + throw err; + } + }); + + // ── Revoke (soft-delete) ──────────────────────────────────────────── + app.post<{ Params: { id: string } }>('/api/v1/mcptokens/:id/revoke', async (request) => { + const row = await tokenService.revoke(request.params.id); + return toListResponse(row); + }); + + // ── Delete (hard) ──────────────────────────────────────────────────── + app.delete<{ Params: { id: string } }>('/api/v1/mcptokens/:id', async (request, reply) => { + await tokenService.delete(request.params.id); + reply.code(204); + }); + + // ── Introspect ─────────────────────────────────────────────────────── + // Called by mcplocal's HTTP-mode auth preHandler to resolve a raw bearer + // to principal info. Accepts a McpToken bearer directly — bypasses the + // session-auth path. + app.get('/api/v1/mcptokens/introspect', async (request: FastifyRequest, reply: FastifyReply) => { + const header = request.headers.authorization; + if (header === undefined || !header.startsWith('Bearer ')) { + reply.code(401); + return { ok: false, error: 'Missing Authorization' }; + } + const token = header.slice(7); + if (!isMcpToken(token)) { + reply.code(401); + return { ok: false, error: 'Not a mcptoken bearer' }; + } + const result = await tokenService.introspectRaw(token); + if (!result.ok) { + reply.code(401); + } + return result; + }); +} + +function toListResponse(row: import('../repositories/interfaces.js').McpTokenWithRelations): Record { + return { + id: row.id, + name: row.name, + projectId: row.projectId, + projectName: row.project.name, + tokenPrefix: row.tokenPrefix, + ownerId: row.ownerId, + ownerEmail: row.owner.email, + description: row.description, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + lastUsedAt: row.lastUsedAt, + revokedAt: row.revokedAt, + status: statusOf(row), + }; +} + +function statusOf(row: import('../repositories/interfaces.js').McpTokenWithRelations): 'active' | 'revoked' | 'expired' { + if (row.revokedAt !== null) return 'revoked'; + if (row.expiresAt !== null && row.expiresAt < new Date()) return 'expired'; + return 'active'; +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index f9d724a..2841435 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -34,3 +34,5 @@ export { UserService } from './user.service.js'; export { GroupService } from './group.service.js'; export { AuditEventService } from './audit-event.service.js'; export type { AuditEventQueryParams } from './audit-event.service.js'; +export { McpTokenService, PermissionCeilingError } from './mcp-token.service.js'; +export type { CreateMcpTokenResult, IntrospectResult } from './mcp-token.service.js'; diff --git a/src/mcpd/src/services/mcp-token.service.ts b/src/mcpd/src/services/mcp-token.service.ts new file mode 100644 index 0000000..d022b82 --- /dev/null +++ b/src/mcpd/src/services/mcp-token.service.ts @@ -0,0 +1,222 @@ +import { generateToken, hashToken } from '@mcpctl/shared'; +import type { McpToken } from '@prisma/client'; +import type { IMcpTokenRepository, McpTokenWithRelations, McpTokenFilter } from '../repositories/interfaces.js'; +import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import { CreateMcpTokenSchema } from '../validation/mcp-token.schema.js'; +import { isResourceBinding, type RbacRoleBinding, type RbacSubject } from '../validation/rbac-definition.schema.js'; +import type { RbacService, Permission } from './rbac.service.js'; +import { ROLE_ACTIONS_FOR_CEILING } from './rbac.service.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +/** Thrown when the requesting user tries to mint a token with bindings they cannot grant themselves. */ +export class PermissionCeilingError extends Error { + constructor(message: string) { + super(message); + this.name = 'PermissionCeilingError'; + } +} + +export interface CreateMcpTokenResult { + /** The database row (with project/owner relations). */ + mcpToken: McpTokenWithRelations; + /** The raw bearer token — shown exactly once. */ + raw: string; +} + +export interface IntrospectResult { + ok: boolean; + tokenId?: string; + tokenName?: string; + tokenSha?: string; + projectId?: string; + projectName?: string; + ownerId?: string; + expired?: boolean; + revoked?: boolean; +} + +export class McpTokenService { + constructor( + private readonly tokenRepo: IMcpTokenRepository, + private readonly projectRepo: IProjectRepository, + private readonly rbacRepo: IRbacDefinitionRepository, + private readonly rbacService: RbacService, + ) {} + + async list(filter?: McpTokenFilter): Promise { + return this.tokenRepo.findAll(filter); + } + + async getById(id: string): Promise { + const row = await this.tokenRepo.findById(id); + if (row === null) throw new NotFoundError(`McpToken not found: ${id}`); + return row; + } + + /** Hash + lookup a raw bearer. Returns the row if valid and active; null if missing, revoked, or expired. */ + async introspectRaw(raw: string): Promise { + const hash = hashToken(raw); + const row = await this.tokenRepo.findByHash(hash); + if (row === null) return { ok: false }; + + const now = new Date(); + const revoked = row.revokedAt !== null; + const expired = row.expiresAt !== null && row.expiresAt < now; + + if (revoked || expired) { + return { + ok: false, + tokenId: row.id, + tokenName: row.name, + tokenSha: row.tokenHash, + revoked, + expired, + }; + } + + // Best-effort last-used tracking (don't block on this). + this.tokenRepo.touchLastUsed(row.id).catch(() => { /* ignore */ }); + + return { + ok: true, + tokenId: row.id, + tokenName: row.name, + tokenSha: row.tokenHash, + projectId: row.projectId, + projectName: row.project.name, + ownerId: row.ownerId, + expired: false, + revoked: false, + }; + } + + async create(creatorUserId: string, input: unknown): Promise { + const data = CreateMcpTokenSchema.parse(input); + + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + + const existing = await this.tokenRepo.findByNameAndProject(data.name, data.projectId); + if (existing !== null && existing.revokedAt === null) { + throw new ConflictError(`McpToken already exists: ${data.name} in project ${project.name}`); + } + + // Resolve the effective bindings: + // base = rbacMode === 'clone' ? snapshot(creator) : [] + // effective = base + explicit bindings + const basePerms = data.rbacMode === 'clone' + ? await this.rbacService.getPermissions(creatorUserId) + : []; + const baseBindings = basePerms.map(permissionToBinding); + const effectiveBindings: RbacRoleBinding[] = [...baseBindings, ...data.bindings]; + + // Creator ceiling: every effective binding must be within what creator can do. + // Cloned bindings are trivially satisfied; explicit ones may not be. + for (const binding of data.bindings) { + const violation = await this.checkCeiling(creatorUserId, binding); + if (violation !== null) throw new PermissionCeilingError(violation); + } + + // Generate the token + const { raw, hash, prefix } = generateToken(); + + // Normalize expiresAt + let expiresAt: Date | null = null; + if (data.expiresAt !== undefined && data.expiresAt !== null) { + expiresAt = typeof data.expiresAt === 'string' ? new Date(data.expiresAt) : data.expiresAt; + } + + const createArgs: { + name: string; + projectId: string; + ownerId: string; + tokenHash: string; + tokenPrefix: string; + description?: string; + expiresAt: Date | null; + } = { + name: data.name, + projectId: data.projectId, + ownerId: creatorUserId, + tokenHash: hash, + tokenPrefix: prefix, + expiresAt, + }; + if (data.description !== undefined) createArgs.description = data.description; + const row = await this.tokenRepo.create(createArgs); + + // If the token has bindings, auto-create an RbacDefinition so the token is a real RBAC principal. + if (effectiveBindings.length > 0) { + const subject: RbacSubject = { kind: 'McpToken', name: hash }; + await this.rbacRepo.create({ + name: rbacDefNameFor(row), + subjects: [subject], + roleBindings: effectiveBindings, + }); + } + + return { mcpToken: row, raw }; + } + + async revoke(id: string): Promise { + const existing = await this.getById(id); + const row = await this.tokenRepo.revoke(id); + // Remove the RBAC definition so the token's bindings stop resolving immediately. + await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ }); + return row; + } + + async delete(id: string): Promise { + const existing = await this.getById(id); + await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ }); + await this.tokenRepo.delete(id); + } + + private async deleteRbacDefinitionFor(row: McpToken): Promise { + const name = rbacDefNameFor(row); + const existing = await this.rbacRepo.findByName(name); + if (existing === null) return; + await this.rbacRepo.delete(existing.id); + } + + /** + * For a single requested binding, return null if the creator can grant it, + * or a human-readable reason string if they cannot. + */ + private async checkCeiling(creatorUserId: string, binding: RbacRoleBinding): Promise { + if (isResourceBinding(binding)) { + const grantedActions = ROLE_ACTIONS_FOR_CEILING[binding.role] ?? []; + for (const action of grantedActions) { + const ok = await this.rbacService.canAccess( + creatorUserId, + action, + binding.resource, + binding.name, + ); + if (!ok) { + return `Ceiling violation: you do not have permission '${action}' on ${binding.resource}${binding.name !== undefined ? `/${binding.name}` : ''}`; + } + } + return null; + } + // Operation binding + const ok = await this.rbacService.canRunOperation(creatorUserId, binding.action); + if (!ok) return `Ceiling violation: you cannot run operation '${binding.action}'`; + return null; + } +} + +function permissionToBinding(p: Permission): RbacRoleBinding { + if ('resource' in p) { + return p.name !== undefined + ? { role: p.role as RbacRoleBinding extends { role: infer R } ? R : never, resource: p.resource, name: p.name } as RbacRoleBinding + : { role: p.role, resource: p.resource } as RbacRoleBinding; + } + return { role: 'run', action: p.action }; +} + +function rbacDefNameFor(row: { id: string }): string { + // Must match the regex in CreateRbacDefinitionSchema (lowercase alphanumeric with hyphens). + return `mcptoken-${row.id.toLowerCase()}`; +} diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts index bbb437d..ddc7fe2 100644 --- a/src/mcpd/src/services/rbac.service.ts +++ b/src/mcpd/src/services/rbac.service.ts @@ -38,6 +38,9 @@ const ROLE_ACTIONS: Record = { expose: ['expose', 'view'], }; +/** Exported alias for permission-ceiling checks elsewhere (e.g. McpTokenService). */ +export const ROLE_ACTIONS_FOR_CEILING = ROLE_ACTIONS; + export class RbacService { constructor( private readonly rbacRepo: IRbacDefinitionRepository, @@ -50,8 +53,8 @@ export class RbacService { * If provided, name-scoped bindings only match when their name equals this. * If omitted (listing), name-scoped bindings still grant access. */ - async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise { - const permissions = await this.getPermissions(userId, serviceAccountName); + async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string, mcpTokenSha?: string): Promise { + const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha); const normalized = normalizeResource(resource); for (const perm of permissions) { @@ -73,8 +76,8 @@ export class RbacService { * Check whether a user is allowed to perform a named operation. * Operations require an explicit 'run' role binding with a matching action. */ - async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise { - const permissions = await this.getPermissions(userId, serviceAccountName); + async canRunOperation(userId: string, operation: string, serviceAccountName?: string, mcpTokenSha?: string): Promise { + const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha); for (const perm of permissions) { if ('action' in perm && perm.role === 'run' && perm.action === operation) { @@ -90,8 +93,8 @@ export class RbacService { * Returns wildcard:true if any matching binding is unscoped (no name constraint). * Returns wildcard:false with a set of allowed names if all bindings are name-scoped. */ - async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise { - const permissions = await this.getPermissions(userId, serviceAccountName); + async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string, mcpTokenSha?: string): Promise { + const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha); const normalized = normalizeResource(resource); const names = new Set(); @@ -113,13 +116,13 @@ export class RbacService { /** * Collect all permissions for a user across all matching RbacDefinitions. */ - async getPermissions(userId: string, serviceAccountName?: string): Promise { + async getPermissions(userId: string, serviceAccountName?: string, mcpTokenSha?: string): Promise { // 1. Resolve user email const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true }, }); - if (user === null && serviceAccountName === undefined) return []; + if (user === null && serviceAccountName === undefined && mcpTokenSha === undefined) return []; // 2. Resolve group names the user belongs to let groupNames: string[] = []; @@ -142,6 +145,7 @@ export class RbacService { if (s.kind === 'User') return user !== null && s.name === user.email; if (s.kind === 'Group') return groupNames.includes(s.name); if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName; + if (s.kind === 'McpToken') return mcpTokenSha !== undefined && s.name === mcpTokenSha; return false; }); diff --git a/src/mcpd/src/validation/mcp-token.schema.ts b/src/mcpd/src/validation/mcp-token.schema.ts new file mode 100644 index 0000000..b6884e9 --- /dev/null +++ b/src/mcpd/src/validation/mcp-token.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { RbacRoleBindingSchema } from './rbac-definition.schema.js'; + +export const McpTokenRbacMode = z.enum(['empty', 'clone']); +export type McpTokenRbacMode = z.infer; + +export const CreateMcpTokenSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + projectId: z.string().min(1), + description: z.string().optional(), + expiresAt: z.union([z.string().datetime(), z.date(), z.null()]).optional(), + rbacMode: McpTokenRbacMode.default('empty'), + /** Explicit bindings, added on top of the `rbacMode` base (empty or clone). */ + bindings: z.array(RbacRoleBindingSchema).default([]), +}); + +export type CreateMcpTokenInput = z.infer; diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index 2982b59..6594394 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; -export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -14,6 +14,7 @@ const RESOURCE_ALIASES: Record = { group: 'groups', prompt: 'prompts', promptrequest: 'promptrequests', + mcptoken: 'mcptokens', }; /** Normalize a resource name to its canonical plural form. */ @@ -22,7 +23,7 @@ export function normalizeResource(resource: string): string { } export const RbacSubjectSchema = z.object({ - kind: z.enum(['User', 'Group', 'ServiceAccount']), + kind: z.enum(['User', 'Group', 'ServiceAccount', 'McpToken']), name: z.string().min(1), }); diff --git a/src/mcpd/tests/mcp-token-service.test.ts b/src/mcpd/tests/mcp-token-service.test.ts new file mode 100644 index 0000000..d5f514a --- /dev/null +++ b/src/mcpd/tests/mcp-token-service.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpTokenService, PermissionCeilingError } from '../src/services/mcp-token.service.js'; +import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IMcpTokenRepository, McpTokenWithRelations } from '../src/repositories/interfaces.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; +import type { RbacService } from '../src/services/rbac.service.js'; +import { hashToken, isMcpToken, TOKEN_PREFIX } from '@mcpctl/shared'; + +const PROJECT = { id: 'cproj1', name: 'myproj' }; + +function makeRow(overrides: Partial = {}): McpTokenWithRelations { + return { + id: 'ctok1', + name: 'mytok', + projectId: PROJECT.id, + tokenHash: 'deadbeef', + tokenPrefix: 'mcpctl_pat_abcd', + ownerId: 'cuser1', + description: '', + createdAt: new Date(), + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + project: PROJECT, + owner: { id: 'cuser1', email: 'alice@example.com' }, + ...overrides, + }; +} + +function mockTokenRepo(): IMcpTokenRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByHash: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (input) => makeRow({ + name: input.name, + projectId: input.projectId, + tokenHash: input.tokenHash, + tokenPrefix: input.tokenPrefix, + ownerId: input.ownerId, + description: input.description ?? '', + expiresAt: input.expiresAt ?? null, + })), + revoke: vi.fn(async (id) => makeRow({ id, revokedAt: new Date() })), + touchLastUsed: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + }; +} + +function mockProjectRepo(): IProjectRepository { + return { + findById: vi.fn(async (id) => (id === PROJECT.id ? PROJECT : null)), + findByName: vi.fn(async (name) => (name === PROJECT.name ? PROJECT : null)), + // minimal stubs for the rest — not exercised in these tests + findAll: vi.fn(async () => []), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + attachServer: vi.fn(), + detachServer: vi.fn(), + listServers: vi.fn(async () => []), + } as unknown as IProjectRepository; +} + +function mockRbacRepo(): IRbacDefinitionRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => ({ id: 'rbac-1', name: 'x', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date() })), + update: vi.fn(), + delete: vi.fn(async () => {}), + }; +} + +function mockRbacService(overrides: Partial = {}): RbacService { + return { + canAccess: vi.fn(async () => true), + canRunOperation: vi.fn(async () => true), + getAllowedScope: vi.fn(async () => ({ wildcard: true, names: new Set() })), + getPermissions: vi.fn(async () => []), + ...overrides, + } as unknown as RbacService; +} + +describe('McpTokenService.create', () => { + let tokenRepo: ReturnType; + let projectRepo: IProjectRepository; + let rbacRepo: ReturnType; + let rbacService: RbacService; + let service: McpTokenService; + + beforeEach(() => { + tokenRepo = mockTokenRepo(); + projectRepo = mockProjectRepo(); + rbacRepo = mockRbacRepo(); + rbacService = mockRbacService(); + service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService); + }); + + it('creates a token with no bindings (rbacMode=empty, default)', async () => { + const result = await service.create('cuser1', { + name: 'mytok', + projectId: PROJECT.id, + }); + expect(result.raw).toMatch(new RegExp(`^${TOKEN_PREFIX}`)); + expect(isMcpToken(result.raw)).toBe(true); + expect(tokenRepo.create).toHaveBeenCalledTimes(1); + // Hash must be persisted, never raw + const args = vi.mocked(tokenRepo.create).mock.calls[0]![0]; + expect(args.tokenHash).toBe(hashToken(result.raw)); + expect(args.tokenPrefix).toBe(result.raw.slice(0, 16)); + // No RBAC definition should be created when there are no bindings + expect(rbacRepo.create).not.toHaveBeenCalled(); + }); + + it('creates an RbacDefinition with subject McpToken: when bindings are given', async () => { + const result = await service.create('cuser1', { + name: 'mytok', + projectId: PROJECT.id, + bindings: [{ role: 'view', resource: 'servers' }], + }); + expect(rbacRepo.create).toHaveBeenCalledTimes(1); + const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0]; + const subjects = defArgs.subjects as Array<{ kind: string; name: string }>; + expect(subjects).toEqual([{ kind: 'McpToken', name: hashToken(result.raw) }]); + expect(defArgs.roleBindings).toEqual([{ role: 'view', resource: 'servers' }]); + }); + + it('rejects bindings the creator does not have (ceiling violation)', async () => { + rbacService = mockRbacService({ + canAccess: vi.fn(async () => false), + } as Partial); + service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService); + + await expect( + service.create('cuser1', { + name: 'mytok', + projectId: PROJECT.id, + bindings: [{ role: 'edit', resource: 'servers' }], + }), + ).rejects.toThrow(PermissionCeilingError); + expect(tokenRepo.create).not.toHaveBeenCalled(); + }); + + it('clones the creator\'s permissions when rbacMode=clone', async () => { + rbacService = mockRbacService({ + getPermissions: vi.fn(async () => [ + { role: 'view', resource: 'servers' }, + { role: 'run', action: 'logs' }, + ]), + } as Partial); + service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService); + + await service.create('cuser1', { + name: 'mytok', + projectId: PROJECT.id, + rbacMode: 'clone', + }); + expect(rbacRepo.create).toHaveBeenCalledTimes(1); + const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0]; + expect(defArgs.roleBindings).toEqual([ + { role: 'view', resource: 'servers' }, + { role: 'run', action: 'logs' }, + ]); + }); + + it('throws NotFoundError if project does not exist', async () => { + await expect( + service.create('cuser1', { name: 'mytok', projectId: 'nope' }), + ).rejects.toThrow(NotFoundError); + }); + + it('throws ConflictError if active token with same name in same project exists', async () => { + vi.mocked(tokenRepo.findByNameAndProject).mockResolvedValueOnce(makeRow()); + await expect( + service.create('cuser1', { name: 'mytok', projectId: PROJECT.id }), + ).rejects.toThrow(ConflictError); + }); +}); + +describe('McpTokenService.introspectRaw', () => { + let tokenRepo: ReturnType; + let service: McpTokenService; + + beforeEach(() => { + tokenRepo = mockTokenRepo(); + service = new McpTokenService(tokenRepo, mockProjectRepo(), mockRbacRepo(), mockRbacService()); + }); + + it('returns ok=false for unknown tokens', async () => { + const result = await service.introspectRaw(`${TOKEN_PREFIX}unknown`); + expect(result.ok).toBe(false); + expect(result.tokenName).toBeUndefined(); + }); + + it('returns ok=true and principal info for active tokens, and updates lastUsedAt', async () => { + const raw = `${TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`; + const hash = hashToken(raw); + vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hash })); + const result = await service.introspectRaw(raw); + expect(result.ok).toBe(true); + expect(result.projectName).toBe(PROJECT.name); + expect(result.tokenName).toBe('mytok'); + expect(tokenRepo.touchLastUsed).toHaveBeenCalled(); + }); + + it('rejects revoked tokens', async () => { + const raw = `${TOKEN_PREFIX}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`; + vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), revokedAt: new Date() })); + const result = await service.introspectRaw(raw); + expect(result.ok).toBe(false); + expect(result.revoked).toBe(true); + }); + + it('rejects expired tokens', async () => { + const raw = `${TOKEN_PREFIX}cccccccccccccccccccccccccccccccc`; + const past = new Date(Date.now() - 60_000); + vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), expiresAt: past })); + const result = await service.introspectRaw(raw); + expect(result.ok).toBe(false); + expect(result.expired).toBe(true); + }); +}); + +describe('McpTokenService.revoke', () => { + it('marks revokedAt and removes the auto-created RbacDefinition', async () => { + const tokenRepo = mockTokenRepo(); + const rbacRepo = mockRbacRepo(); + const service = new McpTokenService(tokenRepo, mockProjectRepo(), rbacRepo, mockRbacService()); + + const row = makeRow(); + vi.mocked(tokenRepo.findById).mockResolvedValue(row); + vi.mocked(rbacRepo.findByName).mockResolvedValue({ + id: 'rbac-ctok1', name: 'mcptoken-ctok1', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date(), + }); + + await service.revoke('ctok1'); + + expect(tokenRepo.revoke).toHaveBeenCalledWith('ctok1'); + expect(rbacRepo.findByName).toHaveBeenCalledWith('mcptoken-ctok1'); + expect(rbacRepo.delete).toHaveBeenCalledWith('rbac-ctok1'); + }); +}); diff --git a/src/shared/src/index.ts b/src/shared/src/index.ts index 384dd73..5c4955a 100644 --- a/src/shared/src/index.ts +++ b/src/shared/src/index.ts @@ -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'; diff --git a/src/shared/src/tokens/index.ts b/src/shared/src/tokens/index.ts new file mode 100644 index 0000000..547a99e --- /dev/null +++ b/src/shared/src/tokens/index.ts @@ -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; + } +}