Skip to content

Use case — MCP authorization server ​

The MCP authorization specification recommends that a client identify itself with an HTTPS URL instead of registering first. The URL is the client_id, and it points at a JSON metadata document the client publishes (OAuth Client ID Metadata Document). The OP fetches the document, validates it, and treats the client as a temporary client for as long as the cached copy lives. Nothing is stored.

op.WithClientIDMetadataDocuments turns this on for every endpoint (authorization, PAR, token, introspection, revocation). It is off by default, and without it the OP behaves exactly as before.

Use it when MCP clients, or other software that cannot be known in advance, need to sign in. Skip it when every client is registered by you: a static client or dynamic registration is easier to reason about.

Experimental — tracks an IETF draft

Client ID Metadata Document is an IETF draft, not a published RFC, and the MCP specification cites an earlier revision than the one implemented here. op.WithClientIDMetadataDocuments and its related symbols are marked Experimental: in godoc: a draft update may make a wire-incompatible change.

Wiring ​

go
import "github.com/libraz/go-oidc-provider/op"

op.New(
  op.WithIssuer("https://op.example.com"),
  /* required options */
  op.WithProtectedResources(op.ProtectedResource{
    Resource:        "https://op.example.com/mcp",
    ScopesSupported: []string{"mcp.read", "mcp.write"},
  }),
  op.WithClientIDMetadataDocuments(op.ClientIDMetadataDocuments{
    Allow: func(ctx context.Context, clientID string) bool {
      return strings.HasPrefix(clientID, "https://clients.example.org/")
    },
  }),
)

Discovery then carries client_id_metadata_document_supported: true, and token_endpoint_auth_methods_supported includes none. op.New refuses the configuration when a profile removes none, and refuses it together with a FAPI profile, because a self-declared client cannot meet FAPI's client requirements.

Allow is optional. When set, it is called with the client_id URL on every resolution, cache hits included, before anything is fetched; returning false makes the client unknown. A narrower allow-list takes effect at once. When it is nil, every URL that passes the URL rules is admitted.

The supported topology: co-hosted resource ​

An MCP client sends the MCP server's address as the RFC 8707 resource. The supported setup is the OP publishing that resource through op.WithProtectedResources, as above. A client identified by a metadata document may then request any resource you declared there; narrowing it down is left to the user's consent. An MCP server hosted elsewhere that publishes its own resource metadata is not covered by this release.

What a client may be ​

AspectAccepted
token_endpoint_auth_methodnone (the default) or private_key_jwt, within the methods your profile allows
grant_typesauthorization_code and refresh_token
response_typescode
PKCErequired, including for private_key_jwt
redirect_urisrequired, validated like dynamic registration, except that a localhost loopback URI is accepted
client_secret* membersrejected
Unknown membersignored

A document that breaks any rule is rejected as a whole. With private_key_jwt, the document must carry jwks or jwks_uri, and jwks must hold public keys only. scope in the document is intersected with the scopes your OP publishes; a document without scope gets all of them.

A client registered under the same ID, by static configuration or dynamic registration, always wins and nothing is fetched. Clients that use client credentials, device, CIBA or token exchange cannot be URL-identified.

What the user sees ​

URL-identified clients never get the first-party consent skip, and listing such a URL in op.WithFirstPartyClients fails at construction. The consent screen shows the host of the client_id URL (with the port when one is given) and the host of the requested redirect_uri. When the redirect is a loopback address, it also shows a warning. The client's logo is never fetched or shown.

Custom consent screens receive the same values in interaction.ClientView as MetadataDocumentHost, RedirectHost and RedirectLoopback, and should show them too. See custom consent UI.

Fetch and cache limits ​

These are fixed and not configurable:

LimitValue
URLhttps, a path other than /, no query, fragment, userinfo or dot segments, ASCII host, at most 255 bytes
Response200 only, application/json or application/*+json, no redirects
Body5 KiB
Timeout5 seconds
Networkprivate, loopback, link-local and metadata addresses refused when connecting
CacheCache-Control max-age clamped to 60 seconds to 24 hours; no-store counts as 60 seconds
Failuresan unusable document is remembered for 5 seconds; a full fetch gate is not remembered

Concurrent requests for one URL share a single fetch, and fetches share the process-wide outbound gate with JAR and client JWKS retrieval. A failed fetch surfaces to the client as an unknown client, and the reason (status, network, too_large, content_type, json or validation) goes to the audit stream as cimd.fetch_failed. When the gate is full the request fails with server_error.

If a document changes, the new content applies from the next fetch. Existing grants and refresh tokens are not revoked (why).

Local development ​

op.WithAllowPrivateNetwork(op.OutboundClientIDMetadata) admits private and loopback targets and the http scheme for client_id URLs, so a test can serve a document from httptest, and so can a deployment whose clients live on a private network. op.New logs a warning because Client ID Metadata Documents lets an unauthenticated caller choose the URL (SSRF exposure); restrict ClientIDMetadataDocuments.Allow when you enable it. The document's jwks_uri is a separate fetch governed by op.OutboundJWKS.