Skip to content

Binding discovered OAuth metadata to its URL

A client that discovers an authorization server reads a metadata document and uses the issuer it names. RFC 8414 section 3.3 says that issuer must be the identifier the well-known URL was built from, or the document must not be used. MCP 2026-07-28's discovery page repeats the rule. RFC 9728 section 3.3 says the same of a protected resource's resource, below.

AuthorizationServerMetadata takes no URL, so its constructor cannot check that: a document naming any issuer parses. Read a document you fetched with from_discovery:

from agent_airlock.mcp_spec import AuthorizationServerMetadata, IssuerMismatchError
from agent_airlock.mcp_spec.oauth import validate_authorization_response_iss

discovery_url = "https://as.example/.well-known/oauth-authorization-server"
document = {
    "issuer": "https://as.example",
    "authorization_endpoint": "https://as.example/authorize",
    "token_endpoint": "https://as.example/token",
    "code_challenge_methods_supported": ["S256"],
}

try:
    metadata = AuthorizationServerMetadata.from_discovery(document, discovery_url=discovery_url)
except IssuerMismatchError:
    raise SystemExit("do not use this document")

# Later, on the redirect back. A raw `metadata.issuer` from the constructor is not safe here:
# a document can name any issuer, and once that value is the expected one this check cannot
# catch the mix-up.
validate_authorization_response_iss(
    {"code": "placeholder", "iss": "https://as.example"},
    expected_issuer=metadata.issuer,
)

A refusal says what is wrong and repeats neither URL. issuer_from_discovery_url and validate_metadata_issuer are the two steps on their own.

Which URL to pass

Pass the URL you built and requested, never the address a redirect ended on (response.url). The document at the end of an open redirect names that address, so it would pass.

These are the forms the MCP page has a client try, and the issuer each one comes from:

Well-known URL Issuer
https://as.example/.well-known/oauth-authorization-server https://as.example
https://as.example/.well-known/openid-configuration https://as.example
https://as.example/.well-known/oauth-authorization-server/tenant1 https://as.example/tenant1
https://as.example/.well-known/openid-configuration/tenant1 https://as.example/tenant1
https://as.example/tenant1/.well-known/openid-configuration https://as.example/tenant1

Anything else raises IssuerMismatchError: no well-known segment, two of them, oauth-authorization-server appended to a path, a query, a fragment, userinfo, an invalid port, a dot or empty segment or a path parameter, and whatever the metadata URL screen refuses (plain http off loopback, a character a URL must percent-encode). A client that falls back to the root well-known for a tenant issuer built that URL from the origin, so a document there naming the tenant is refused.

What it compares

Scheme, host, port and path as strings: no case folding, and :443 is not the same as no port. Trailing slashes are forgiven, through the normalizer the iss check uses, so the two checks cannot disagree. Nothing else is. That is one step more lenient than the text: RFC 8414 says "identical", and MCP 2026-07-28's authorization page rules out trailing-slash normalization for iss.

What it does not do

It binds a document to a URL. It does not decide which server to trust: a server's document about itself, at its own well-known URL, passes. The choice is yours, from the configured identifier or the resource's authorization_servers (RFC 9728), and the URL you pass should come from that choice. The check earns its keep where discovery is not fully in the hands of the party the document names: an open redirect on the well-known path, a stale or cached document, or a resource server steering discovery.

Protected resource metadata

Before it finds the authorization server, an MCP client reads the server's protected resource metadata (RFC 9728). RFC 9728 section 3.3 says the resource that document returns must be the resource identifier the client built the metadata URL from, or the document must not be used. Without that check a malicious MCP server names another server there, and a client that sends it as the RFC 8707 resource parameter gets a token minted for that server, which the malicious server then receives. That was CVE-2026-123272 in mcp-go and CVE-2026-123271 in ChatCLI.

ProtectedResourceMetadata takes no URL either, so read a fetched document with its own from_discovery:

from agent_airlock.mcp_spec import ProtectedResourceMetadata, ResourceMismatchError

server_url = "https://mcp.example.com/mcp"
document = {
    "resource": "https://mcp.example.com/mcp",
    "authorization_servers": ["https://as.example"],
}

try:
    metadata = ProtectedResourceMetadata.from_discovery(document, resource_url=server_url)
except ResourceMismatchError:
    raise SystemExit("do not use this document")

Pass the MCP server URL you connected to: you built /.well-known/oauth-protected-resource from it, or followed a resource_metadata URL from that server's 401, where RFC 9728 has the request URL. After a fall back to the root well-known URL, the identifier is the server's origin, and a document there that names the server's full URL is refused. Never pass response.url.

The comparison uses the token audience check's key, so on any two URLs both can read they agree: case in the scheme and host, a trailing slash and a default port are forgiven, and a different scheme, host, port, path or query is not. A resource must also pass the metadata URL screen and hold no userinfo. validate_protected_resource is the check on its own.

Whatever the document says, send the MCP server's own canonical URL as the resource parameter, as the MCP authorization page requires. ChatCLI's fix does that.