MCP Nexus is a self-hosted control plane for MCP servers — "Kubernetes for
MCP." Agents connect to one endpoint; Nexus discovers services, installs the
right MCP servers, aggregates them behind a namespaced router, secures access
with auth/RBAC, and heals/updates them via a reconcile loop.
This first commit is design-phase only (no runnable code yet):
- README.md project front door + module map
- docs/ARCHITECTURE.md target design: tenets, two-plane split, reconcile
loop, domain types, storage, security, deployment
- docs/ROADMAP.md phased delivery (foundations -> walking skeleton ->
discover+install -> secure -> operate -> extend/scale)
- docs/modules/01-20 one design doc per module, all cross-linked
Backend stack decision: Go (single static binary, embedded SQLite + embedded
React dashboard). Repo initialized in /root with a whitelist .gitignore so
only project files are tracked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
Authentication
Module 06 · Plane: Data · Roadmap phase: 3 Part of MCP Nexus architecture.
Purpose
Authentication turns an inbound connection at the API Gateway into a verified principal — a stable identity for either a human user (dashboard/API operator) or an AI agent (Claude, Cursor, …). It answers exactly one question: "Who is calling?" It does not decide what they may do; that is RBAC. AuthN is the seam through which every request passes before it can be scoped, routed, or audited (Architecture §5, §9). It is pluggable by tenet #3: API keys first (Phase 3), OAuth2/OIDC and JWT sessions next, and LDAP/Active Directory/SAML (enabling SSO) later.
Responsibilities
- Own a set of pluggable AuthN providers that each attempt to resolve request credentials into a
Principal. Starter provider: API keys. Follow-ons: OAuth2, OpenID Connect (authorization-code + PKCE), JWT session validation, then LDAP, Active Directory, and SAML (SSO/enterprise IdP federation). - Extract credentials from the request in a provider-appropriate way:
Authorization: Bearer <token>,X-API-Key, a session cookie, or an MCPinitializehandshake carrying an agent credential. - Establish and validate sessions: mint short-lived JWT access tokens (with a refresh path) after an interactive login; validate their signature, expiry, audience, and issuer on every request.
- Issue, hash, store, list, and revoke API keys; keys are stored only as salted hashes, shown in plaintext exactly once at creation.
- Attach the resolved
Principal(with its provider, credential id, and asserted identity claims) to the request context so downstream RBAC, Router, and Audit all read one canonical identity. - Map an agent's presented credential to its AgentProfile identity so profile→role resolution can proceed.
- Emit an audit event for every authentication decision — success, failure, and revocation — with principal, provider, and source address (Architecture §9).
Non-goals
- Authorization. Deciding which tools/namespaces a principal may see or call is RBAC; this module only proves identity.
- The agent-facing identity binding. Modeling per-agent policy objects is AI Agent Profiles; AuthN merely resolves a credential to the profile's identity.
- Credential storage for upstream services. MCP-server secrets live in the Secrets Manager; AuthN secures access to Nexus, not Nexus's access to backends.
- Transport security. TLS termination is the Gateway / Security baseline.
- Plugin transport. Out-of-process auth-backend plugins are wired via the Plugin System in Phase 5; this module defines only the in-process interface.
Interfaces
// A resolved, verified identity. Attached to every request context after AuthN.
type Principal struct {
ID string // stable identity key, e.g. "user:alice", "agent:claude-01"
Kind PrincipalKind // KindUser | KindAgent | KindService
Provider string // "apikey", "oidc", "jwt", "ldap", "saml"
Claims map[string]string // sub, email, groups, agent_id, ...
Expires time.Time // zero for non-expiring credentials (e.g. API key)
}
type PrincipalKind string
const (
KindUser PrincipalKind = "user"
KindAgent PrincipalKind = "agent"
KindService PrincipalKind = "service"
)
// A pluggable authentication backend (tenet #3).
type Provider interface {
Name() string // "apikey", "oidc", ...
// Authenticate inspects the request; ok=false if this provider does not
// recognize the credential, err for a malformed/invalid one.
Authenticate(ctx context.Context, r *http.Request) (Principal, bool, error)
}
// The authenticator runs providers in order and returns the first match.
type Authenticator interface {
Register(p Provider) error
Resolve(ctx context.Context, r *http.Request) (Principal, error) // ErrUnauthenticated if none match
}
// API-key lifecycle. Keys are stored hashed; plaintext is returned once.
type KeyManager interface {
Issue(ctx context.Context, subject string, opts KeyOpts) (plaintext string, id string, err error)
Verify(ctx context.Context, presented string) (Principal, error)
Revoke(ctx context.Context, id string) error
List(ctx context.Context, subject string) ([]KeyMeta, error)
}
HTTP/API surface (behind the Gateway; TLS-terminated):
POST /api/v1/auth/login— interactive login → sets session cookie / returns JWT.POST /api/v1/auth/refresh— exchange a refresh token for a fresh access token.POST /api/v1/auth/logout— invalidate the current session.GET /api/v1/auth/oidc/callback— OAuth2/OIDC authorization-code redirect target.GET /api/v1/auth/saml/acs— SAML assertion consumer service (SSO, later).POST /api/v1/auth/keys·GET /api/v1/auth/keys·DELETE /api/v1/auth/keys/{id}— API-key CRUD (RBAC-gated).
MCP handshake: an agent presents its credential in the initialize request (bearer token or API key header on the Streamable HTTP transport); the resolved Principal is pinned to the MCP session for its lifetime.
Request-resolution flow (data plane, per request):
1. Gateway receives request (MCP over Streamable HTTP, or dashboard/API call).
2. Authenticator.Resolve iterates registered providers in priority order:
apikey → jwt/session → oidc → (ldap/ad/saml later)
3. First provider that recognizes the credential returns (Principal, true).
- none recognize it → ErrUnauthenticated → 401 (audited)
- one recognizes but it's bad → error → 401 (audited, no fall-through)
4. Principal attached to request context (id, kind, provider, claims).
5. For agents: Principal.ID feeds AgentProfile.Identify (Module 14).
6. RBAC (Module 08) reads the Principal to scope the request.
7. Audit records the decision: {principal, provider, source_ip, outcome}.
Data
- Reads/writes the Identity store (Architecture §8):
Users,AgentProfiles,Roles, and API keys (stored as{id, subject, hash, salt, created, last_used, expires, revoked}). - Reads OIDC/SAML provider config (issuer URL, client id, JWKS endpoint, metadata) from the Config store; the OAuth client secret is a ref into the Secrets Manager, never inlined.
- Writes authentication events to the Audit store (append-only).
- JWT signing keys live in the Secrets store; the JWKS for verifying externally issued tokens is fetched and cached from the IdP.
Dependencies
- Sits behind the Gateway, which invokes
Resolveas the first middleware on the data-plane hot path. - Feeds the
Principalto RBAC for authorization and to AI Agent Profiles for identity→profile binding. - OAuth client secrets and JWT signing keys come from Secrets.
- Every decision is recorded per Security §9 audit rules.
- Backend providers are plugin points via the Plugin System (Phase 5).
Failure modes & handling
| Failure | Behavior |
|---|---|
| No provider recognizes the credential | ErrUnauthenticated → 401; audited as a failed attempt with source IP. |
| Malformed/expired JWT | 401; client directed to /auth/refresh or re-login; never falls through to another provider as valid. |
| IdP (OIDC/SAML) unreachable | Interactive login fails closed; already-issued, still-valid JWTs continue to work until expiry (no data-plane outage). |
| JWKS fetch fails | Serve from cached keys; refuse tokens signed by unknown kid rather than trusting blindly. |
| API key leaked/compromised | Immediate Revoke; hash comparison is constant-time; keys are rate-limited and can carry an expiry. |
| Brute-force credential guessing | Per-source and per-subject rate limiting (Security §9) + exponential backoff; repeated failures alert via Notifications. |
| Clock skew on token validation | Bounded leeway (e.g. ±60s) on nbf/exp; beyond that, reject. |
Security notes
Honors Architecture §9. Credentials are never logged and never written into DiscoveredResource, config, or audit payloads — only credential ids and outcomes are recorded. API keys are stored as salted hashes (argon2id/bcrypt), compared in constant time, and shown in plaintext once. JWTs are short-lived, signed with a key from Secrets, and validated for signature, iss, aud, and expiry on every request. OAuth flows use authorization-code + PKCE; client secrets are secret-refs. All auth traffic is TLS-only (fail closed if terminated plaintext). Sessions bind to the Principal for their lifetime so a mid-session privilege change forces re-resolution. Fail closed everywhere: an unresolvable request is unauthenticated, never anonymous-with-access.
Open questions
- Should agent credentials be first-class API keys scoped to an AgentProfile, or a distinct credential type with its own lifecycle?
- mTLS as an additional agent authentication provider for zero-shared-secret deployments?
- Session store: stateless JWT only, or a server-side session table to enable instant global revocation (at a hot-path lookup cost)?
- How to federate group/role claims from multiple IdPs into one RBAC role space without collisions?
- Do we support step-up auth (re-authentication) for high-risk control-plane mutations?
Milestone
Delivered in Phase 3 (Secure — data-plane hardening). Thin slice first: API-key authentication end to end — issue a key, present it on the MCP handshake and the control API, resolve a Principal, and audit the decision — so two differently keyed agents can be told apart. OIDC/OAuth + JWT sessions follow within the phase; LDAP/AD/SAML SSO are deferred (Roadmap "not early"). Contributes to the Phase 3 exit proof: two agents with different identities connect and are distinguished before RBAC scopes them.