22 -- External Multi-Tenant OIDC Front Door

Prerequisite: 16 -- Front-Door Multi-Tenant You will need: MCP Hangar 1.6.0, Docker (for a local, test-only Keycloak), jq, curl Time: ~30 minutes Adds: An end-to-end external OIDC front door -- local Keycloak dev realm, two tenants with distinct tool surfaces, cross-tenant isolation proof, RFC 9728 discovery, and token-validation troubleshooting

The Problem

Recipe 16 showed the front-door primitives against an abstract IdP. This recipe walks the whole thing end to end with a real, local OpenID Provider so you can run it on your laptop and prove the guarantees: two external tenants authenticate through OIDC, are identified per tenant from a JWT claim, and each sees and invokes only the tools its tenant is allowed. Neither tenant can observe the other's tools, and an unauthenticated or wrong-audience caller is rejected before it reaches a back end.

Hangar is an OAuth Resource Server here. It validates the JWTs your identity provider issues -- signature, issuer, audience, expiry -- and maps the tenant claim to an effective tenant. It never issues tokens, mints refresh tokens, or performs dynamic client registration; that is Keycloak's job.

Test-only Keycloak. The Keycloak realm, clients, users, and passwords below are a disposable local development profile. The realm is created fresh at container start, secrets come from environment variables you set locally, and token lifetimes are deliberately short. Never import a production realm into this profile, never commit a client secret, and never reuse these throwaway passwords anywhere real.

The Config

# config.yaml -- Recipe 22: External Multi-Tenant OIDC Front Door

tool_access:
  mode: front_door                          # opt-in; unauthenticated callers denied

auth:                                        # validate JWTs; Hangar does not issue them
  enabled: true
  allow_anonymous: false
  oidc:
    enabled: true
    issuer: http://localhost:8080/realms/hangar-dev    # local test-only Keycloak realm
    jwks_uri: http://localhost:8080/realms/hangar-dev/protocol/openid-connect/certs
    resource_uri: http://localhost:8000                # advertised (RFC 9728) AND enforced as aud
    audience: http://localhost:8000                    # inert while resource_uri is set; kept explicit
    tenant_claim: tenant_id                            # JWT claim -> CallerIdentity.tenant_id
  role_assignments:                          # a validated token grants no permission by itself
    - principal: "group:agents"              # matched against the token's `groups` claim
      role: service-account                  # invoke tools; no hangar_* management tools

mcp_servers:
  payments:
    mode: remote
    endpoint: http://localhost:8081/mcp
    description: "Payments backend"

    tool_access:                            # per-tenant (member-scope) policy
      member:
        "tenant:acme":
          allow_list: [charge]              # acme may only charge
        "tenant:globex":
          allow_list: [charge, refund]      # globex may charge and refund

  billing:
    mode: remote
    endpoint: http://localhost:8082/mcp
    description: "Billing backend"

    tool_access:
      member:
        "tenant:acme":
          deny_list: [invoice]              # acme must be named to be kept out
        "tenant:globex":
          allow_list: [invoice]             # globex may invoice

Save this as ~/.config/mcp-hangar/config.yaml or pass it with --config.

A member entry only narrows what one tenant gets; it never widens it, and a tenant with no entry on a server is not restricted there at all. Without the tenant:acme entry on billing, acme would see and call invoice. Name every tenant you mean to keep out; a server-level tools: deny cannot do it, because globex's member entry could not add the tool back.

The role matters as much: a caller with no role is refused every call (tool:invoke permission required), and a broader role such as developer also lists the hangar_* management tools in this tenant's tools/list.

Because resource_uri is set, every accepted token's aud claim is validated against http://localhost:8000 (RFC 8707 resource indicators): the value Hangar advertises as the RFC 9728 resource is the exact value it enforces as aud.

Bootstrap the Local Keycloak (test-only)

Run a throwaway Keycloak in development mode. Set the admin and realm secrets in your shell first so nothing sensitive is written to disk:

export KC_BOOTSTRAP_ADMIN_USERNAME=admin
export KC_BOOTSTRAP_ADMIN_PASSWORD=dev-only-change-me   # local throwaway, not for prod

docker run --rm --name keycloak -p 8080:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD \
  quay.io/keycloak/keycloak:26.0 start-dev

Then create a test-only realm hangar-dev, two confidential clients, and two users -- one per tenant -- with a protocol mapper that puts a hardcoded tenant_id claim into each user's access token. Using the admin CLI baked into the image:

KC=http://localhost:8080

# Authenticate the admin CLI (session token is short-lived).
docker exec -i keycloak /opt/keycloak/bin/kcadm.sh config credentials \
  --server "$KC" --realm master \
  --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD"

# Test-only realm with short token lifetime (no long-lived credentials).
docker exec -i keycloak /opt/keycloak/bin/kcadm.sh create realms \
  -s realm=hangar-dev -s enabled=true -s accessTokenLifespan=300

# One confidential client per tenant (direct-grant enabled for the demo).
for T in acme globex; do
  docker exec -i keycloak /opt/keycloak/bin/kcadm.sh create clients -r hangar-dev \
    -s clientId="agent-$T" -s enabled=true -s publicClient=false \
    -s directAccessGrantsEnabled=true \
    -s 'defaultClientScopes=["hangar-audience"]'
done

Two things make the tokens usable by Hangar:

  1. The tenant_id claim. Add a hardcoded-claim protocol mapper to each client (tenant:acme for the acme client, tenant:globex for the globex client) so every access token carries tenant_id. Hangar reads this into CallerIdentity.tenant_id; it never trusts a client-supplied tenant.

  2. The aud claim. Add an audience mapper (client scope hangar-audience above) that sets aud to http://localhost:8000 -- the same value as auth.oidc.resource_uri. A token minted for any other audience is rejected.

  3. The groups claim. Add a mapper that puts agents into a groups claim on both clients, so the role_assignments entry above grants them service-account.

Create the two demo users (throwaway passwords, test realm only):

docker exec -i keycloak /opt/keycloak/bin/kcadm.sh create users -r hangar-dev \
  -s username=acme-agent -s enabled=true
docker exec -i keycloak /opt/keycloak/bin/kcadm.sh set-password -r hangar-dev \
  --username acme-agent --new-password dev-only-acme

docker exec -i keycloak /opt/keycloak/bin/kcadm.sh create users -r hangar-dev \
  -s username=globex-agent -s enabled=true
docker exec -i keycloak /opt/keycloak/bin/kcadm.sh set-password -r hangar-dev \
  --username globex-agent --new-password dev-only-globex

Try It

  1. Start Hangar in front-door mode

    mcp-hangar --config ~/.config/mcp-hangar/config.yaml serve \
      --http --host 0.0.0.0 --port 8000

    With tool_access.mode: front_door and auth.allow_anonymous: false, every tool call now requires an authenticated tenant.

  2. Discover the OAuth resource metadata (no token needed)

    curl -s http://localhost:8000/.well-known/oauth-protected-resource | jq

    Expected output:

    {
      "resource": "http://localhost:8000",
      "authorization_servers": ["http://localhost:8080/realms/hangar-dev"]
    }

    This is the RFC 9728 Protected Resource Metadata document. resource comes from auth.oidc.resource_uri; authorization_servers lists your issuer. An agent uses this to discover where it must go to get a token.

  3. Confirm anonymous calls are denied (fail-closed)

    curl -s -i http://localhost:8000/mcp \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \
      | grep -i "^HTTP\|^WWW-Authenticate"

    Expected output:

    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource", ApiKey

    The WWW-Authenticate challenge points the agent back at the discovery endpoint, so a well-behaved client can bootstrap into the OAuth flow.

  4. Get a token for each tenant

    Use Keycloak's direct-grant endpoint (demo shortcut; a real agent would run the standard OAuth flow). Substitute each client's secret:

    token() {
      curl -s http://localhost:8080/realms/hangar-dev/protocol/openid-connect/token \
        -d grant_type=password -d "client_id=agent-$1" \
        -d "client_secret=$2" -d "username=$1-agent" -d "password=dev-only-$1" \
        | jq -r .access_token
    }
    ACME_JWT=$(token acme "$ACME_CLIENT_SECRET")
    GLOBEX_JWT=$(token globex "$GLOBEX_CLIENT_SECRET")
  5. Each tenant sees a different, flat tool surface

    In front-door mode external agents see flat back-end tool names, not the hangar_* control-plane API. The answer is a Server-Sent Events stream, so strip the data: prefix before jq. List tools as acme:

    curl -s http://localhost:8000/mcp \
      -H "Authorization: Bearer $ACME_JWT" \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \
      | sed -n 's/^data: //p' | jq '.result.tools[].name' | sort

    Expected for tenant:acme (allow_list of charge on payments only):

    "charge"

    Now list tools as globex:

    curl -s http://localhost:8000/mcp \
      -H "Authorization: Bearer $GLOBEX_JWT" \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \
      | sed -n 's/^data: //p' | jq '.result.tools[].name' | sort

    Expected for tenant:globex (charge, refund on payments; invoice on billing):

    "charge"
    "invoice"
    "refund"
  6. The same isolation holds on server/discover

    The SEP-2575 server/discover entry point is scoped to the caller's tenant from the identity context, exactly like tools/list:

    curl -s http://localhost:8000/server/discover \
      -H "Authorization: Bearer $ACME_JWT" | jq '.tools[].name'

    It is meant to show acme only charge -- one round-trip advertising the tenant's allowed tools without a separate tools/list. On 2.24.0 it answers "tools": [] for every authenticated front-door caller, so use tools/list for the surface until that is fixed.

  7. Prove tenants cannot observe or invoke each other's tools

    invoice and refund never appear in acme's list. Confirm acme also cannot call them even by guessing the name:

    curl -s http://localhost:8000/mcp \
      -H "Authorization: Bearer $ACME_JWT" \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"refund","arguments":{}},"id":1}' \
      | sed -n 's/^data: //p' | jq '.error.code // .result.isError'

    Expected output:

    -32601

    refund is absent from acme's per-tenant map, so the call is rejected with -32601 (method not found) and no back end is ever contacted. The projection hides the tool and the call path refuses it -- see below.

  8. Reject a wrong-audience / unknown-issuer token (fail-closed)

    A structurally valid JWT minted for another audience -- or by an issuer you never configured -- is rejected. For example, a token whose aud is not http://localhost:8000:

    curl -s -i http://localhost:8000/mcp \
      -H "Authorization: Bearer $WRONG_AUD_JWT" \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \
      | grep -i "^HTTP"

    Expected output:

    HTTP/1.1 401 Unauthorized

    Audience and issuer are checked during validation. A token from an issuer that is not configured never reaches a signature check at all -- there is no JWKS to verify it against -- and the error does not enumerate what is trusted.

  9. Withdraw a tool globally, then per tenant

    Pull refund for all tenants at runtime (needs an admin key with the lifecycle action on mcp_servers):

    curl -s -X POST \
      http://localhost:8000/api/admin/tools/payments/refund/withdraw \
      -H "X-API-Key: <admin-key>" \
      -H "Content-Type: application/json" \
      -d '{}' | jq

    Expected output:

    {"withdrawn": true, "mcp_server": "payments", "tool": "refund", "kind": "tool", "tenant_id": null}

    Sending {} (no tenant_id) withdraws globally; globex's tools/list no longer includes refund. Add {"tenant_id": "tenant:globex"} to scope a withdrawal to one tenant, and restore with the same body to reverse it.

What Just Happened

Setting tool_access.mode: front_door flips Hangar from trusted-egress to an untrusted front door with a fail-closed default: a caller with no tenant identity is denied every tool before any server- or group-level policy is even consulted. That is why the anonymous tools/list in step 3 returns 401 rather than an empty list.

The tenant is never client-supplied. It comes from the JWT tenant_claim (default tenant_id), which Hangar reads into CallerIdentity.tenant_id after validating the token, and then uses to resolve the member-scope policy. acme and globex get different effective tool sets from the same back ends because their tenant IDs select different tool_access.member entries.

There are two layers, and they are not the same thing:

  • Projection is a UX / discovery view. The flat tools/list and server/discover surfaces are built per request from the tenant's allowed, non-withdrawn tools. This is what an agent sees. Two tenants never share a view: acme's map has no refund or invoice entry to begin with.
  • The call path is authoritative. Every invocation is routed through the same enforcement path (resolver policy check, then withdrawal check) regardless of what the projection showed. Even a tool guessed by name (step 8) is re-checked and refused. A tool that was withdrawn between list and call is denied at call time, and the back end is never invoked. Discovery hides tools; authorization enforces the decision.

RFC 9728 makes the front door discoverable: the /.well-known/oauth-protected-resource document and the WWW-Authenticate challenge advertise the resource URI and issuer. Because resource_uri is set, that same URI is enforced as each token's aud (RFC 8707), so a token minted for another API is not accepted here. To trust more than one issuer at once, add an auth.oidc.issuers trust list as in 17 -- Multi-Issuer Front Door.

Throughout, Hangar stays a Resource Server. Keycloak issues and signs the tokens; Hangar only validates them.

Troubleshooting Token Validation

SymptomLikely causeFix
401, message Invalid JWT audiencetoken aud does not equal auth.oidc.resource_uriadd/repair the Keycloak audience mapper so aud is http://localhost:8000
401, message Untrusted JWT issuertoken iss is not the configured issueralign auth.oidc.issuer with the realm URL in the token's iss
401, message JWT token has expiredclock skew or long-lived tokenshorten accessTokenLifespan; mint a fresh token
401 even with a valid-looking token, log oidc_config_incompleteno auth.oidc.issuer (and no issuers list) at startupset the issuer before enabling OIDC
Token accepted but tenant is empty / denied everywheretenant_id claim absent from the tokenadd the hardcoded-claim mapper; confirm the claim name matches tenant_claim

Token lifetime is also capped server-side: Hangar rejects a token whose exp - iat exceeds max_token_lifetime_seconds (default 3600), overridable via the MCP_JWT_MAX_TOKEN_LIFETIME environment variable. Decode a token locally with jq to inspect its claims:

echo "$ACME_JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, exp, tenant_id}'

Audit Evidence

Every decision is observable, which is what makes the two-tenant proof auditable:

  • HTTP responses are the first-line evidence. Anonymous and wrong-audience calls return 401 with an authentication_failed body; an authenticated but disallowed call returns a JSON-RPC -32601 (projection miss) or a CallToolResult with isError: true (denied at the enforcement path). No denied call reaches a back end.
  • Structured logs record the wiring and the per-request surface: startup emits oidc_auth_enabled; each flat tool call logs front_door_tool_call with the tenant_id, tool and outcome, and a refusal logs batch_call_refused with its reason. At DEBUG, startup also logs standalone_member_tool_access_policy_set (one per tenant policy loaded). Correlate these to show that acme and globex resolved to different surfaces from the same server.

Run the two-tenant walkthrough, capture the tools/list output for each token plus the matching log lines, and you have a reproducible demonstration that the tenants are isolated and that access decisions fail closed.

Key Config Reference

KeyTypeDefaultDescription
tool_access.modestringegressTopology mode: egress or front_door
auth.enabledboolfalseMaster switch for authentication
auth.allow_anonymousboolfalseIf true, unauthenticated requests run as anonymous
auth.oidc.enabledboolfalseEnable OIDC/JWT validation
auth.oidc.issuerstring""Trusted iss; advertised as an authorization server
auth.oidc.jwks_uristringautoJWKS endpoint; auto-discovered from issuer if unset
auth.oidc.audiencestring""Expected aud; inert while resource_uri is set
auth.oidc.resource_uristring""Public URI advertised as resource; also enforced as aud when set
auth.oidc.tenant_claimstringtenant_idJWT claim mapped to CallerIdentity.tenant_id
mcp_servers.<id>.tool_access.member.<tenant>.allow_listlist[]Tools this tenant may call on that server
mcp_servers.<id>.tool_access.member.<tenant>.deny_listlist[]Tools this tenant may not call on that server

What's Next

For multi-issuer trust (a distinct IdP per customer) and RFC 8707 audience binding across issuers, continue with 17 -- Multi-Issuer Front Door. For the conceptual model, the egress-vs-front-door comparison, and the full endpoint and config reference, see the Front-Door Mode guide; for the OIDC schema and claim mappings, see the Authentication & Authorization guide.