Noizu.MCP.Auth.Server.Consent (Noizu MCP v0.1.6)

Copy Markdown View Source

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

csrf_token()

@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.

granted(client, subject, config)

Read a recorded consent.

record(client, subject, scope, resource, config)

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.

render_html(assigns)

@spec render_html(map()) :: String.t()

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.

required?(client, subject, scope, config)

@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.

revoke(subject, client_id, config)

@spec revoke(String.t(), String.t(), Noizu.MCP.Auth.Server.Config.t()) :: :ok

Withdraw consent, e.g. from an account settings page.

valid_csrf?(payload, submitted)

@spec valid_csrf?(map(), term()) :: boolean()

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".