17 -- Multi-Issuer Front Door

Prerequisite: 16 -- Front-Door Multi-Tenant You will need: MCP Hangar 1.4.0, two OIDC issuers that mint JWTs with a tenant_id claim and aud set to your resource URI Time: ~15 minutes Adds: Multi-issuer OAuth trust, RFC 8707 resource-bound audience, RFC 9728 multi-issuer discovery

The Problem

Recipe 16 fronted untrusted agents that all came from a single IdP. Real SaaS is messier: each customer brings their own identity provider. Customer A authenticates agents through Keycloak; Customer B uses Auth0. You still want one Hangar front door, but it now has to trust two authorization servers at once -- and it must not silently accept a token from some third issuer that nobody onboarded.

A single auth.oidc.issuer cannot express that. You need a list of trusted issuers, each with its own JWKS, and a way to prove -- auditably -- that every accepted token was minted for this Hangar and not replayed from another resource. Hangar 1.4 adds a multi-issuer trust list plus RFC 8707 audience binding: when you set auth.oidc.resource_uri, the value you advertise as the RFC 9728 resource is the exact value enforced as each token's aud.

The Config

# config.yaml -- Recipe 17: Multi-Issuer Front Door

tool_access:
  mode: front_door                         # face untrusted callers, fail-closed

auth:                                       # validate JWTs; Hangar does not issue them
  enabled: true
  allow_anonymous: false
  oidc:
    enabled: true
    resource_uri: https://hangar.example.com   # NEW: advertised AND enforced as aud (RFC 8707)
    tenant_claim: tenant_id                 # default claim -> CallerIdentity.tenant_id
    issuers:                                # NEW: trust list; overrides legacy single `issuer`
      - issuer: https://issuer-a.example.com      # Customer A (e.g. Keycloak)
        audience: https://hangar.example.com      # ignored while resource_uri is set
        jwks_uri: https://issuer-a.example.com/jwks
      - issuer: https://issuer-b.example.com      # Customer B (e.g. Auth0)
        audience: https://hangar.example.com      # ignored while resource_uri is set
        jwks_uri: https://issuer-b.example.com/jwks
        groups_claim: roles                 # per-issuer override; tenant_claim inherits top-level

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

    tool_access:                            # per-tenant (member-scope) policy
      member:
        "tenant:a":
          allow_list: [charge]
        "tenant:b":
          allow_list: [charge, refund]

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

Because resource_uri is set, each issuer's audience is overridden at validation time: every token -- from issuer-a or issuer-b -- must carry aud: https://hangar.example.com. The per-issuer audience lines are kept documented above but are inert until you remove resource_uri.

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 requires an authenticated tenant from one of the trusted issuers.

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

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

    Expected output:

    {
      "resource": "https://hangar.example.com",
      "authorization_servers": [
        "https://issuer-a.example.com",
        "https://issuer-b.example.com"
      ]
    }

    This is the RFC 9728 document. resource comes from auth.oidc.resource_uri; authorization_servers lists every entry in auth.oidc.issuers, not a single one. A client discovers both authorization servers it may legitimately get a token from.

  3. Confirm unauthenticated 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="https://hangar.example.com/.well-known/oauth-protected-resource", ApiKey

    The WWW-Authenticate header points the agent back at the discovery endpoint. (Without OIDC configured the challenge would be just Bearer, ApiKey.)

  4. Call a tool as a tenant from issuer-a

    Obtain a JWT from Customer A's IdP whose iss is https://issuer-a.example.com, aud is https://hangar.example.com, and tenant_id is tenant:a, then list tools.

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

    Expected output for tenant:a (allow_list of charge):

    "charge"
  5. Call a tool as a tenant from issuer-b

    A JWT from Customer B's IdP -- different iss (https://issuer-b.example.com), different JWKS -- is validated the same way. With aud: https://hangar.example.com and tenant_id: tenant:b:

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

    Expected output for tenant:b (allow_list of charge, refund):

    "charge"
    "refund"

    One front door, two authorization servers, two tenants -- each resolved to its own member-scope policy from the same back end.

  6. Reject a token from an unknown issuer (fail-closed)

    Mint a structurally valid JWT from an issuer you never onboarded -- say https://rogue.example.com -- and try to use it.

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

    Expected output:

    HTTP/1.1 401 Unauthorized

    The request is rejected with a 401 (never a 500) before the token reaches any signature validator -- the iss claim is not in the trust list, so there is no JWKS to even check it against. The same fate awaits a token with a missing, empty, or non-string iss. Critically, the error does not enumerate which issuers are trusted: an attacker probing the front door learns nothing about your trust list.

What Just Happened

auth.oidc.issuers replaces the single-issuer model with an ordered trust list. It takes precedence over the legacy top-level auth.oidc.issuer, so once a list is present Hangar trusts exactly those entries and nothing else. Each entry carries its own jwks_uri, which is how two completely independent IdPs (Keycloak, Auth0) can be validated side by side without sharing keys.

Validation is routed by the iss claim. When a bearer token arrives, Hangar first reads iss and looks it up in the trust list. If iss is missing, empty, not a string, or not a configured issuer, the request fails closed with a 401 and the token never reaches a signature or audience check -- that is what makes step 6 a clean rejection rather than a crash. The set of trusted issuers is deliberately kept out of the error response.

The audience binding is the audit story. Because auth.oidc.resource_uri is set to https://hangar.example.com, every token's aud claim is validated against that single URI regardless of each issuer entry's own audience (RFC 8707 resource indicators). The value you publish in the RFC 9728 metadata as resource is therefore the exact value you enforce as aud. A token Customer A minted for some other API -- even from a trusted issuer -- will not be accepted here, and the discovery document, the WWW-Authenticate challenge, and the enforced audience all name the same resource URI. Remove resource_uri and validation falls back to each issuer's configured audience instead.

Per-issuer claim mappings inherit from the top level. issuer-b overrides groups_claim: roles, but it omits tenant_claim, so it inherits tenant_claim: tenant_id from auth.oidc. Both issuers therefore resolve the tenant from tenant_id into CallerIdentity.tenant_id, which the front-door access resolver uses to apply the member-scope policy -- exactly as in Recipe 16. Hangar remains a resource server throughout: it validates these JWTs, it does not issue them, mint refresh tokens, or perform dynamic client registration.

Key Config Reference

KeyTypeDefaultDescription
tool_access.modestringegressTopology mode: egress or front_door
auth.oidc.enabledboolfalseEnable OIDC/JWT validation
auth.oidc.resource_uristring""Public URI advertised as resource; also enforced as JWT aud when set
auth.oidc.issuerslist[]Multi-issuer trust entries; overrides legacy auth.oidc.issuer
auth.oidc.issuers[].issuerstring--Trusted iss value matched against the token
auth.oidc.issuers[].audiencestring--Expected aud for this issuer; ignored while resource_uri is set
auth.oidc.issuers[].jwks_uristring--JWKS endpoint used to verify this issuer's signatures
auth.oidc.tenant_claimstringtenant_idJWT claim mapped to tenant_id; inherited by issuer entries that omit it

What's Next

For the full OIDC configuration schema, claim mappings, and multi-issuer trust semantics, see the Authentication & Authorization guide. For the front-door topology model, fail-closed access resolution, and the RFC 9728 / RFC 8707 discovery endpoints, see the Front-Door Mode guide.