An OAuth client of this authorization server, and the validation that admits one.
Three kinds, because MCP hosts meet clients three ways:
:registered— arrived through RFC 7591 dynamic registration. What Claude Desktop and Claude Code do by default.:cimd— itsclient_idis an https URL serving a client-id metadata document. Nothing is stored until the document is fetched, and the fetch is SSRF-guarded (Noizu.MCP.Auth.Server.CIMD).:preconfigured— created by the operator out of band. The only kind that may skip the consent screen, because a human already approved it.
Secrets
secret_hash is a PBKDF2 string from Noizu.MCP.Auth.Server.Secret. The
plaintext exists exactly once, in the registration response; it is never stored
and cannot be recovered. A client whose token_endpoint_auth_method is "none"
has no secret at all — which is the common case, since a desktop app cannot keep
one, and PKCE rather than a secret is what binds the code to the caller.
Summary
Functions
True when the client is not disabled.
Token-endpoint authentication methods this server supports.
Authenticate a client at the token endpoint.
True when a cached CIMD document has gone stale and should be re-fetched.
Build a client from a fetched client-id metadata document.
Build a client from an RFC 7591 registration request.
Grant types this server supports.
Client kinds.
True for a client that keeps no secret.
True when this client must be consented to before any IdP redirect or code issuance.
Response types this server supports.
True when the client registered for a grant type.
The RFC 7591 §3.2.1 registration response body. secret is the plaintext from
from_registration/2, or nil for a public client.
Types
@type kind() :: :registered | :cimd | :preconfigured
@type t() :: %Noizu.MCP.Auth.Server.Client{ cimd_etag: String.t() | nil, cimd_expires_at: DateTime.t() | nil, cimd_fetched_at: DateTime.t() | nil, client_id: String.t(), client_id_kind: kind(), client_name: String.t() | nil, client_uri: String.t() | nil, disabled_at: DateTime.t() | nil, grant_types: [String.t()], inserted_at: DateTime.t() | nil, logo_uri: String.t() | nil, metadata: map(), policy_uri: String.t() | nil, redirect_uris: [String.t()], response_types: [String.t()], scope: [String.t()], secret_hash: String.t() | nil, software_id: String.t() | nil, software_version: String.t() | nil, token_endpoint_auth_method: String.t(), tos_uri: String.t() | nil, updated_at: DateTime.t() | nil, upstream_client_ref: String.t() | nil }
Functions
True when the client is not disabled.
@spec auth_methods() :: [String.t()]
Token-endpoint authentication methods this server supports.
@spec authenticate(t(), String.t() | nil, keyword()) :: :ok | {:error, Noizu.MCP.Auth.Server.Errors.t()}
Authenticate a client at the token endpoint.
The comparison is constant-time, and a client with no stored secret still burns the same work, so "no such client" is not measurably faster than "wrong secret".
A "none" client authenticates by presenting no secret at all — and by PKCE,
which every client must satisfy. Presenting a secret for a "none" client is an
error rather than something to ignore: it means the caller has the wrong idea
about who it is.
@spec cimd_stale?(t(), DateTime.t()) :: boolean()
True when a cached CIMD document has gone stale and should be re-fetched.
@spec from_cimd(String.t(), map(), Noizu.MCP.Auth.Server.Config.t()) :: {:ok, t()} | {:error, Noizu.MCP.Auth.Server.Errors.t()}
Build a client from a fetched client-id metadata document.
client_id is the URL the document was fetched from. The document must assert
the same client_id (RFC-draft CIMD §client_id self-assertion): without
that check, one URL could serve a document claiming to be another client and
inherit its consent.
A CIMD client is always public — there is no registration step in which a secret could be exchanged.
@spec from_registration(map(), Noizu.MCP.Auth.Server.Config.t()) :: {:ok, t(), String.t() | nil} | {:error, Noizu.MCP.Auth.Server.Errors.t()}
Build a client from an RFC 7591 registration request.
Returns {:ok, client, plaintext_secret | nil} — the plaintext is returned
only here, for the response body, and is never persisted.
Validation is deliberately strict: redirect_uris is required and each entry
must pass RedirectURI.validate/2 (https, or loopback http; no fragment, no
userinfo, no wildcard), and with dcr: [allowed_redirect_hosts: [...]] the host
must match on a label boundary — otherwise anyone can register
https://evil-claude.ai/cb and be handed codes meant for claude.ai.
@spec grant_types() :: [String.t()]
Grant types this server supports.
@spec kinds() :: [kind()]
Client kinds.
True for a client that keeps no secret.
@spec requires_consent?(t(), Noizu.MCP.Auth.Server.Config.t()) :: boolean()
True when this client must be consented to before any IdP redirect or code issuance.
Registered and CIMD clients always must: nobody vetted them, and skipping
consent for a self-registered client is exactly the confused-deputy hole the
MCP spec calls out. A preconfigured client may skip it, since an operator
created it deliberately — and only then does consent: [enabled: false] apply.
@spec response_types() :: [String.t()]
Response types this server supports.
True when the client registered for a grant type.
The RFC 7591 §3.2.1 registration response body. secret is the plaintext from
from_registration/2, or nil for a public client.