@growth-labs/mcp-auth
v0.3.0
Published
Authentication and identity layer for MCP services built on Growth Labs substrate. Resolves an inbound HTTP request into a typed Actor object that downstream code uses for authorization, audit, and per-user scoping.
Readme
@growth-labs/mcp-auth
Authentication and identity layer for MCP services built on Growth Labs
substrate. Resolves an inbound HTTP request into a typed Actor object that
downstream code uses for authorization, audit, and per-user scoping.
This package is generic substrate — it makes no assumptions about which realm, issuer, or service is using it. Realm-specific configuration (which OAuth issuer to verify against, which token map to use) is injected by the consuming service.
Install
pnpm add @growth-labs/mcp-authRequired peer runtime: a Cloudflare-Workers-compatible JS runtime (Workers, Node 22+) with Web Crypto and Fetch APIs available.
Concepts
Actor
The single output of every successful auth check.
interface Actor {
id: string // 'user:grizzle' | 'service:hermes-runner'
kind: 'user' | 'service'
email?: string
display_name?: string
roles: Role[] // 'operator' | 'submitter' | 'viewer' | 'service'
source: AuthSource // 'token-map' | 'oauth-jwt' | 'service-binding'
authenticated_at: number // ms epoch
raw_token_id?: string // sha256(token), 16 hex chars
}AuthResolver
(request, env) => Promise<Actor | null>. Resolvers compose: first one to
return a non-null Actor wins, others short-circuit.
Two ship:
tokenMapResolver({ envVarName })— reads a JSON token map from the named env var and resolves bearer tokens to Actors. Used for service tokens.oauthJwtResolver({ issuer, audience, ...claimOverrides? })— verifies an OAuth access-token JWT against the issuer's JWKS, validates iss/aud/exp, and maps payload claims to an Actor withsource: 'oauth-jwt'. Used for human-user OAuth flows.
createCompositeResolver(sources) (added in 0.3.0) composes an ordered list
of heterogeneous credential sources into a single resolver. Accepts any mix
of TokenMapResolverOptions (env-var JSON token-map) and
SecretsStoreResolverEntry (Cloudflare Secrets Store binding paired with a
fixed actor identity). First match wins. Used when a service accepts both
realm-specific service tokens AND a shared agent token whose value lives
only in Cloudflare Secrets Store.
const resolver = createCompositeResolver([
{ envVarName: 'FOUNDRY_MCP_TOKENS_JSON' },
{
binding: env.AGENT_RW_TOKEN, // Cloudflare Secrets Store binding
actor: { id: 'agent:fulcrum-agent', kind: 'service', roles: ['operator'] },
},
])Secrets Store reads are cached per-request inside a WeakMap<Request, ...>,
so multiple auth checks within one request invoke binding.get() once per
(request, binding). The incoming bearer is compared constant-time against
the resolved secret via the exported timingSafeEqual(a, b) helper. Actors
resolved via Secrets Store carry source: 'secrets-store' and a
raw_token_id (sha256(bearer)[..16]) for audit correlation.
AuditWriter
The package emits audit events; storage is realm-specific. Implement
AuditWriter.write(event: AuthAuditEvent) against whatever storage you use
(D1, Logpush, Tail Worker, etc.).
Usage
import {
mcpAuthMiddleware,
oauthJwtResolver,
requireRole,
tokenMapResolver,
} from '@growth-labs/mcp-auth'
const auth = mcpAuthMiddleware({
resolvers: [
oauthJwtResolver({
issuer: 'https://auth.your-realm.tld',
audience: 'https://mcp.your-service.tld/mcp',
}),
tokenMapResolver({ envVarName: 'FOUNDRY_MCP_TOKENS_JSON' }), // realm-specific service tokens
],
required: true,
audit: {
async write(event) {
// persist to your realm's audit table
},
},
})
const operatorOnly = requireRole('operator')mcpAuthMiddleware populates ctx.actor and audits every attempt. On no
actor with required: true it returns a 401 with
WWW-Authenticate: Bearer realm="<realm>", error="invalid_token" and the
documented envelope:
{
"error": {
"code": "unauthenticated" | "forbidden" | "invalid-token",
"message": "...",
"request_id": "req_..."
}
}Token map shape
The token-map resolver expects a JSON object keyed by bearer token or by
hashTokenMapKey(token). Prefer hashTokenMapKey(token) for service
secrets; it returns sha256:<raw_token_id> so the public audit
raw_token_id value is never itself a valid bearer token.
{
"sha256:e7b412c5f301a6de": {
"id": "user:grizzle",
"kind": "user",
"email": "[email protected]",
"display_name": "Grizzle",
"roles": ["operator"]
},
"sha256:2f85c14f548c92df": {
"id": "service:hermes-runner",
"kind": "service",
"roles": ["service"]
}
}Set the JSON via wrangler secret put <ENV_VAR_NAME>. The token value never
appears in logs, errors, or audit rows — only the SHA-256 hash truncated to
16 hex chars (raw_token_id) is recorded.
Token rotation = redeploy with a new map. v1 does not hot-reload.
Error envelope
Errors returned by middleware:
| Status | code | When |
|--------|--------------------|-------------------------------------|
| 401 | unauthenticated | No actor + required: true |
| 403 | forbidden | requireRole / requireAnyRole failed |
Token leakage
The package is structurally hardened against leakage: the bearer token's
plaintext is never written to logs, error messages, or audit rows. A
property test in __tests__/token-leakage.test.ts enforces this across
every code path.
Versioning
v1 surface is stable. Adding a new resolver, audit event field, or role value requires a spec amendment.
Public API
// Types
export type { Actor, AuditWriter, AuthAuditEvent, AuthContext, AuthResolver, AuthSource, Middleware, Role }
// Resolvers
export { tokenMapResolver, oauthJwtResolver, createCompositeResolver }
export type {
TokenMapResolverOptions,
OAuthJwtResolverConfig,
CompositeResolverSource,
SecretsStoreResolverEntry,
SecretsStoreActorIdentity,
SecretsStoreBinding,
}
// Middleware
export { mcpAuthMiddleware, requireRole, requireAnyRole }
export type { McpAuthMiddlewareOptions }
// Errors
export { errorResponse }
export type { ErrorCode }
// Utilities
export { generateToken, hashTokenId, hashTokenMapKey, timingSafeEqual }
export type { GenerateTokenOptions }