Architecture

TL;DR — AuthPlane is one Go binary with two HTTP servers: :9000 for public OAuth traffic, :9001 for the Admin API and Admin UI. Inside, it’s a hexagonal (ports & adapters) design: HTTP handlers call input ports, services run business logic, output ports abstract storage and key material, adapters plug in SQLite/Postgres/Keyfile/Vault. No framework, stdlib net/http. This page is the mental model — one read makes the rest of the docs make sense.

What runs where

flowchart TD
    Client["MCP Client<br/>(Claude Desktop, Cursor, …)"]
    Browser["User Browser"]

    subgraph AS9000["authserver :9000"]
        Discovery["Discovery<br/>AS meta<br/>JWKS"]
        OAuth["OAuth<br/>Authorize<br/>Token<br/>Introspect<br/>Revoke"]
        LoginConsent["Login/Consent"]
        Services["Services<br/>Authorize · Token · DCR/CIMD · Consent<br/>UserAuth · JWKS · Admin · Audit<br/>Introspection · Connect · BrokerIssuer<br/>TokenExchange · JWTBearer · AgentIdent."]
        Store["Store<br/>SQLite<br/>Postgres<br/>AES enc"]
        Signing["Signing<br/>Keyfile<br/>Postgres<br/>Vault XT"]

        Discovery --> Services
        OAuth --> Services
        LoginConsent --> Services
        Services --> Store
        Services --> Signing
    end

    subgraph AS9001["authserver :9001"]
        Admin["Admin API (REST) + Admin UI (React SPA, embedded)<br/>Clients · Users · Resources · Providers · Grants<br/>Issuances · Signing Keys · Audit · System"]
    end

    Client -->|"1. Discover PRM on your MCP server<br/>2. Discover AS metadata<br/>3. Register client (DCR or CIMD)<br/>4. Login<br/>5. Consent<br/>6. Exchange code for tokens"| Browser
    Client --> AS9000
    Browser --> AS9000
  • :9000 — public OAuth endpoints (/oauth/authorize, /oauth/token, /oauth/register, /oauth/revoke, /oauth/introspect), discovery (/.well-known/oauth-authorization-server, /.well-known/openid-configuration, /.well-known/jwks.json), health, and the login/consent HTML pages. RFC 9728 protected-resource metadata is not served here — the SDK on your MCP server publishes it at /.well-known/oauth-protected-resource/<resource-path>.
  • :9001 — Admin REST API under /admin/* and the built-in React Admin UI at /admin/ui/. Requires AUTHPLANE_ADMIN_API_KEY (API) or session login (UI). Not intended for public exposure — front with a reverse proxy or Kubernetes NetworkPolicy in production.

Hexagonal architecture

Ports & adapters, strict dependency rules — no framework, stdlib net/http.

flowchart TD
    L1["api/http/ · api/admin/ · web/admin/<br/>OAuth handlers · Admin REST+UI · React SPA<br/>Primary adapters (inbound)"]
    L2["internal/ports/input/<br/>What the world asks us to do<br/>Input ports (interfaces)"]
    L3["internal/services/<br/>Orchestrates domain operations<br/>Business logic"]
    L4["internal/ports/output/<br/>What we need from the world<br/>Output ports (interfaces)"]
    L5["internal/adapters/sqlite/ keyfile/ oidc/<br/>internal/adapters/postgres/ cimd/<br/>internal/adapters/aesmaster/ connector/ hcvault/<br/>Secondary adapters (outbound)"]
    L6["internal/domain/<br/>Pure business types — stdlib only<br/>Domain entities + errors"]

    L1 --- L2 --- L3 --- L4 --- L5 --- L6

Dependency rules

PackageCan importCannot import
internal/domain/Go stdlib, gofrs/uuidEverything else
internal/ports/domain/adapters/, services/, config/
internal/services/ports/, domain/, crypto/adapters/ directly
api/ (handlers)ports/input/, domain/, config/adapters/, services/
internal/adapters/ports/output/, domain/Other adapters
cmd/Everything

Only cmd/authserver/serve.go is allowed to know about concrete adapters. It’s the orchestrator: it constructs adapters, wires them into services through output ports, and hands the services to HTTP handlers through input ports. Handlers never import services directly.

Why this matters: swapping SQLite for Postgres is a one-line config change and a different adapter registration in serve.go. Business logic never had to know which storage was underneath.

Request flow

An authorization-code + PKCE request end to end:

sequenceDiagram
    participant Client
    participant Handler as api/http
    participant Service as internal/services
    participant Store as SQLite adapter
    participant Crypto as crypto/
    participant Signing as keyfile / Vault Transit

    Client->>Handler: 1. GET /oauth/authorize?client_id=…&code_challenge=…&resource=…<br/>oauthHandler (api/http)
    Handler->>Service: AuthorizePort.StartAuthorization (input port)<br/>AuthorizeService
    Service->>Store: ClientStore.GetByID (output port → SQLite adapter)
    Service->>Store: SessionStore.Create (output port → SQLite adapter)
    Handler-->>Client: 302 to /login

    Client->>Handler: 2. POST /login (email + password)<br/>loginHandler
    Handler->>Service: UserAuthPort.Authenticate<br/>UserAuthService
    Service->>Store: UserStore.GetByEmail (output port → SQLite adapter)
    Service->>Crypto: bcrypt.Compare
    Handler-->>Client: 302 to /consent

    Client->>Handler: 3. POST /consent (approve)<br/>consentHandler
    Handler->>Service: ConsentPort.GrantConsent<br/>ConsentService
    Service->>Store: ConsentStore.Create (output port → SQLite adapter)
    Handler-->>Client: 302 back to /oauth/authorize → 302 to client with ?code=…

    Client->>Handler: 4. POST /oauth/token (code + code_verifier)<br/>oauthHandler
    Handler->>Service: TokenPort.ExchangeCode<br/>TokenService
    Service->>Store: SessionStore.ConsumeAuthCode (atomic, output port → SQLite adapter)
    Service->>Crypto: PKCE verify (crypto/)
    Service->>Signing: JWT sign (crypto/ → keyfile or Vault Transit adapter)
    Service->>Store: TokenStore.Create (output port → SQLite adapter)
    Handler-->>Client: 200 { access_token, refresh_token }

Every subsequent request from the MCP client presents Authorization: Bearer <access_token> (or Authorization: DPoP <access_token> + a DPoP proof header) directly to your MCP server. AuthPlane is out of the request path from here on — your server validates the JWT locally against the cached JWKS.

Domain model

Every entity is a pure Go type in internal/domain/. Cross-domain imports are forbidden — domain/client cannot import domain/token.

EntityPackagePurpose
Clientdomain/client/OAuth client, with DCR/CIMD state machine
Userdomain/user/Local user (email/password or federated OIDC)
TokenFamilydomain/token/Groups refresh tokens for reuse detection
RefreshTokendomain/token/Individual refresh token in a family
AuthSessiondomain/session/In-flight authorization (code + PKCE state)
Grantdomain/consent/User’s consent decision for a client + scopes
AuditEventdomain/audit/Security audit log entry
Resourcedomain/resource/Unified Mint/Broker resource (resources table)
BrokerProviderdomain/resource/Upstream OAuth provider (broker_providers)
ConsentGrantdomain/resource/Per-(user, agent, resource) consent attestation
BrokerGrantdomain/resource/Per-(user, provider) upstream grant (encrypted refresh)
Issuancedomain/resource/Forensic audit row for every Mint or Broker issuance

Domain errors live in a single file — internal/domain/errors.go. Each carries an OAuth error code for wire-level mapping:

ErrorOAuth codeMeaning
ErrInvalidGrantinvalid_grantExpired code, wrong verifier, or refresh reuse
ErrInvalidClientinvalid_clientUnknown client, wrong secret
ErrInvalidScopeinvalid_scopeScope not declared on the target resource server
ErrCodeConsumedinvalid_grantAuth code replay
ErrFamilyRevokedinvalid_grantRefresh token theft detected
ErrInvalidPKCEinvalid_grantPKCE verification failed
ErrRateLimitedslow_downToo many requests

The full catalog is in Reference: Errors.

Unified Resource model

Every resource AuthPlane speaks to is one row in the resources table, discriminated by backend_kind:

  • mint — AuthPlane issues an AS-signed JWT for a downstream MCP server. The historical “resource server” path. Used by single-mcp, direct-fanout, folded-resource, and the mint variant of gateway topologies.
  • broker — AuthPlane vends an upstream-provider access token via RFC 8693 token exchange, gated by the three-bound consent model. Used by broker-mcp and the broker variant of gateway topologies.

BrokerProvider rows define the upstream OAuth providers; one Broker resource references one provider via broker_provider_id. The BrokerProtocol output port is satisfied by adapters at internal/brokerproto/{oauth,api_key,service_account}.

Connect flow (user-facing)

User goes to /connect/{provider} → AuthPlane runs an OAuth handshake with the upstream provider → callback stores an encrypted row in broker_grants. That row is what powers subsequent token vending.

Exchange flow (MCP server-facing)

Your MCP server calls POST /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange and resource=<provider-slug>. AuthPlane dispatches to BrokerIssuer, which enforces three bounds:

requested_scopes ⊆ consent_grants.scopes    (per-agent attestation)
                 ⊆ broker_grants.scopes_granted  (per-provider grant)

If any bound fails, AuthPlane responds with error=consent_required and a consent_url — either /connect/{provider} (upstream re-auth needed) or /authorize?resource=… (AS-side re-consent needed). The SDK translates this into an MCP JSON-RPC -32042 UrlElicitationRequiredError so your tool code doesn’t have to.

Upstream refresh grants are encrypted at rest with AES-256-GCM or HashiCorp Vault Transit — never plaintext on disk. See Concepts: Token Vault and Guides: Wire up the Token Vault.

Storage

Both backends implement the same output port interfaces — the storage driver is selected at startup via config.

SQLite (default) — Pure Go via modernc.org/sqlite (no CGO). WAL mode enabled by default for concurrent reads. Recommended for single-instance deployments. Data lives under /data in the container.

PostgreSQL — via jackc/pgx/v5. Required for multi-instance HA. Migrations managed by authserver migrate. Uses LISTEN/NOTIFY for cross-instance signing-key rotation.

Storage details, migrations, and backup procedures live in Operate.

Observability

Every request is instrumented:

  • Structured logs (slog) with trace_id, span_id, request_id on every line
  • OpenTelemetry traces (optional) spanning HTTP → service → adapter
  • Prometheus metrics on /metrics — token issuance, auth denials, latency, DPoP proofs validated/rejected, key rotations, JWKS cache hit rate, and more

Complete list in Guides: Monitoring and Reference: Metrics.

Docker packaging

The production image uses a multi-stage build:

  1. golang:1.25-alpine — compiles the binary with CGO_ENABLED=0
  2. gcr.io/distroless/static-debian12:nonroot — runtime (no shell, no package manager)

Runs as UID 65534 (nonroot), exposes ports 9000 and 9001, weighs under 50 MB compressed. Same image works from local docker run to production Kubernetes.