17 -- Multi-Issuer Front Door

Prerequisite: 16 -- Front-Door Multi-Tenant You will need: MCP Hangar 1.6.0 or later, 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
        allowed_tenants: ["tenant:a"]       # NEW: the only tenant this issuer may name
      - 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
        allowed_tenants: ["tenant:b"]
  role_assignments:                         # NEW: one tenant-scoped grant per tenant
    - principal: "group:agents"
      role: service-account                 # invoke tools; no hangar_* management tools
      scope: "tenant:tenant:a"              # `tenant:` + the tenant id, here `tenant:a`
    - principal: "group:agents"
      role: service-account
      scope: "tenant:tenant:b"

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.

Bind each issuer to its tenants. Since 2.34.2 each issuer entry takes allowed_tenants. A bound issuer's token is refused with 401 unless its tenant claim is a string in the list, and a token naming no tenant is refused too. Without it, an issuer could sign a token naming any tenant -- issuer-b could mint tenant_id: tenant:a -- and its sub and groups would become the same principal and groups as issuer-a's. With two or more issuers and any of them unbound, boot logs one oidc_issuers_not_bound_to_tenants warning naming them. The list must name at least one tenant; an empty list, a bare string or the key with no value refuses the configuration, and so does the key set at the top level of auth.oidc.

Grant roles at tenant scope. A caller with no role sees its tenant's tool names and is refused every call (tool:invoke permission required). The binding above confines tenant-scoped roles only: a role assigned at scope global to a subject or to group:<name> still reaches a token with that subject or group from any trusted issuer. So with more than one issuer, grant OIDC users and groups only tenant:<id> roles, as above, and do not list one tenant under two issuers. A role's scope is tenant: followed by the tenant id the token carries; this recipe's tenant ids are tenant:a and tenant:b, which makes the scopes tenant:tenant:a and tenant:tenant:b. Each IdP is expected to put its agents in an agents group (issuer-b reads groups from roles).

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, tenant_id is tenant:a and groups includes agents, then list tools.

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

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

    "charge"
    "hangar_whoami"

    hangar_whoami is listed to every authenticated caller; calling it shows the principal, tenant and the service-account binding at tenant:tenant:a the call was judged by.

  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, tenant_id: tenant:b and agents in its roles claim:

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

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

    "charge"
    "refund"
    "hangar_whoami"

    One front door, two authorization servers, two tenants -- each resolved to its own member-scope policy from the same back end. A token from issuer-b that names tenant_id: tenant:a is refused with 401: tenant:a is not in issuer-b's allowed_tenants.

  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
    {"error":"authentication_failed","message":"Untrusted JWT issuer","details":{"auth_method":"jwt"}}

    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.

allowed_tenants binds each issuer to the tenants it may name. Without it, issuer-b could sign a token whose tenant_id is tenant:a, and Hangar would treat the caller as tenant a. With it (core 2.34.2 and later), such a token is refused with 401, as is a token from a bound issuer that names no tenant. With an entry left unbound, boot logs oidc_issuers_not_bound_to_tenants once, naming the open issuers. The binding covers tenant-scoped roles only: a role granted at scope global to a subject or group:<name> reaches a token with that subject or group from either issuer, so grant OIDC users and groups tenant:<id> roles in a recipe like this one.

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.issuers[].allowed_tenantslist--(2.34.2) Tenants this issuer's tokens may name; a token naming another tenant, or none, is refused with 401
auth.role_assignments[].scopestringglobaltenant:<tenant id> confines the grant to that tenant. Use it with more than one issuer
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.