Consent policy and the default consent screen.
Why this is mandatory
Any client can register itself here (that is the point of DCR) and then send a
user, who is already logged in, to /oauth/authorize. Without a consent step
the authorization server would issue that client a token on the strength of the
user's session alone — the user's own cookie authorizing a stranger. That is the
confused-deputy problem the MCP specification calls out, and consent is the fix
the spec names.
So: consent is required for every :registered and :cimd client, and
consent: [enabled: false] only affects :preconfigured clients, which an
operator created deliberately.
Consent is remembered per {subject, client_id}, and a request for a scope
outside what was granted re-prompts rather than widening silently.
The form is CSRF-protected
The token is bound to the login state, so a POST is only accepted from the page this server rendered for this flow. Without it, an attacker's page could submit the approval on the user's behalf, which is the same hole with an extra step.
Replacing the screen
consent: [renderer: {MyAppWeb.MCPConsent, :render}]The renderer receives (conn, assigns) and must return a conn with a response
sent. assigns carries :client, :scope, :resource, :csrf_token,
:action (the POST target), :login_state, :subject. The built-in screen is
deliberately plain — it works, and it is not trying to be your design system.
Summary
Functions
A CSRF token bound to a login state. Random per flow, stored alongside the state, and compared in constant time.
Read a recorded consent.
Record consent, merging with anything granted before.
The built-in consent screen, as an HTML string.
Does this request need a consent screen?
Withdraw consent, e.g. from an account settings page.
Check a submitted CSRF token against the one issued for this flow.
Functions
@spec csrf_token() :: String.t()
A CSRF token bound to a login state. Random per flow, stored alongside the state, and compared in constant time.
@spec granted( Noizu.MCP.Auth.Server.Client.t(), String.t(), Noizu.MCP.Auth.Server.Config.t() ) :: {:ok, Noizu.MCP.Auth.Server.Store.Consent.t()} | {:error, :not_found}
Read a recorded consent.
@spec record( Noizu.MCP.Auth.Server.Client.t(), String.t(), [String.t()], String.t() | nil, Noizu.MCP.Auth.Server.Config.t() ) :: :ok
Record consent, merging with anything granted before.
The union is stored rather than the latest request, so an agent that legitimately narrows its ask on a later call is not re-prompted for scope it already has.
The built-in consent screen, as an HTML string.
Everything interpolated is escaped. Two of these values are attacker-chosen —
a self-registered client's client_name and the scope strings — so this is the
one place in the AS where an escaping slip becomes stored XSS on the host's own
origin.
@spec required?( Noizu.MCP.Auth.Server.Client.t(), String.t(), [String.t()], Noizu.MCP.Auth.Server.Config.t() ) :: :skip | {:prompt, :no_consent | :scope_broadened}
Does this request need a consent screen?
:skip when the client is exempt, or when the subject has already granted a
superset of the requested scope. {:prompt, reason} otherwise, where reason is
:no_consent or :scope_broadened — the second is why re-prompting matters:
a client that quietly asks for more later must be shown again.
@spec revoke(String.t(), String.t(), Noizu.MCP.Auth.Server.Config.t()) :: :ok
Withdraw consent, e.g. from an account settings page.
Check a submitted CSRF token against the one issued for this flow.
A flow with no stored token fails: an absent token must never read as "no CSRF protection needed".