Shared plumbing for the authorization-server endpoints: parameter reading,
client authentication, uniform error rendering, cache-control: no-store,
CORS, and the rate-limit hook.
Kept in one place because these are the details that go wrong quietly. A
token response cached by an intermediary, an error page that echoes a
parameter, a 500 where a 400 was meant — each is a small omission in one
endpoint and a real hole across five.
Summary
Functions
Append query parameters to a URL, preserving whatever is already there.
A JSON response that may be cached (metadata, JWKS).
Client credentials from the request: HTTP Basic first (RFC 6749 §2.3.1
prefers it), then client_id/client_secret in the body.
Normalize plug options into a Config.
Permissive CORS for the machine endpoints.
A JSON OAuth error response.
A rendered HTML error — the only correct answer at the authorization
endpoint when the client_id is unresolved or the redirect_uri is invalid.
Redirect an error back to an already-validated redirect_uri, per
RFC 6749 §4.1.2.1.
A JSON response with cache-control: no-store.
405 with an allow header.
cache-control: no-store plus pragma, for HTTP/1.0 intermediaries.
Answer a preflight with 204, or nil when this is not a preflight.
Run the host's rate-limit hook. Returns {:error, conn} with a 429 and a
retry-after header when the caller is over its limit.
Every parameter for this request: query string merged under the body.
A 302 to location, with no body worth reading.
Functions
Append query parameters to a URL, preserving whatever is already there.
@spec cacheable_json(Plug.Conn.t(), pos_integer(), map(), non_neg_integer()) :: Plug.Conn.t()
A JSON response that may be cached (metadata, JWKS).
@spec client_credentials(Plug.Conn.t(), map()) :: {:ok, {String.t(), String.t() | nil}} | {:error, Noizu.MCP.Auth.Server.Errors.t()}
Client credentials from the request: HTTP Basic first (RFC 6749 §2.3.1
prefers it), then client_id/client_secret in the body.
{:ok, {client_id, secret_or_nil}}, or {:error, %Errors{}} for a malformed
Basic header or a request with no client_id at all.
@spec config(term()) :: Noizu.MCP.Auth.Server.Config.t()
Normalize plug options into a Config.
forward may hand over a keyword list (evaluated at compile time) or an
already-built Config; both work, and building it here means a
misconfiguration raises at boot rather than on the first request.
@spec cors(Plug.Conn.t()) :: Plug.Conn.t()
Permissive CORS for the machine endpoints.
* is correct here: these endpoints authenticate with a client secret, a PKCE
verifier or a bearer — never with a cookie — so a browser attaching an origin
gains nothing it did not already have. allow-credentials is deliberately
absent, which is what keeps that true.
@spec error_json(Plug.Conn.t(), Noizu.MCP.Auth.Server.Errors.t()) :: Plug.Conn.t()
A JSON OAuth error response.
invalid_client additionally carries a WWW-Authenticate challenge, per
RFC 6749 §5.2, when the client tried Basic authentication.
@spec error_page(Plug.Conn.t(), Noizu.MCP.Auth.Server.Errors.t()) :: Plug.Conn.t()
A rendered HTML error — the only correct answer at the authorization
endpoint when the client_id is unresolved or the redirect_uri is invalid.
Redirecting in that case would make this endpoint an open redirector, and
would hand the error (with any state) to whoever supplied the URI. Nothing
from the request is echoed into the page.
@spec error_redirect( Plug.Conn.t(), String.t(), Noizu.MCP.Auth.Server.Errors.t(), Noizu.MCP.Auth.Server.Config.t() ) :: Plug.Conn.t()
Redirect an error back to an already-validated redirect_uri, per
RFC 6749 §4.1.2.1.
Only call this once RedirectURI has matched the URI against the resolved
client's registrations. iss rides along (RFC 9207) so a client can tell
which authorization server answered.
@spec json(Plug.Conn.t(), pos_integer(), map()) :: Plug.Conn.t()
A JSON response with cache-control: no-store.
Mandatory on every token, registration and revocation response: a cached token response is a credential sitting in a proxy.
@spec method_not_allowed(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
405 with an allow header.
@spec no_store(Plug.Conn.t()) :: Plug.Conn.t()
cache-control: no-store plus pragma, for HTTP/1.0 intermediaries.
@spec preflight(Plug.Conn.t(), String.t()) :: Plug.Conn.t() | nil
Answer a preflight with 204, or nil when this is not a preflight.
@spec rate_limit(Plug.Conn.t(), Noizu.MCP.Auth.Server.Config.t(), atom()) :: :ok | {:error, Plug.Conn.t()}
Run the host's rate-limit hook. Returns {:error, conn} with a 429 and a
retry-after header when the caller is over its limit.
The hook is host-owned on purpose — the library has no business deciding what "too many" means for your deployment, and both of our apps already run Hammer.
@spec read_params(Plug.Conn.t()) :: {:ok, Plug.Conn.t(), map()}
Every parameter for this request: query string merged under the body.
Handles a conn that has been through Plug.Parsers and one that has not (a
bare Bandit mount, or a scope with no parsers) — the endpoints must work
either way, since a host that pipes /oauth through :api gets parsers and
one that forwards from a bare scope does not.
@spec redirect(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
A 302 to location, with no body worth reading.