Sake.Client.Auth (Sake v0.4.3)

Copy Markdown View Source

The MCP authorization flow, run by Sake.Client itself. Turn it on with auth: on Sake.Client.connect/2:

{:ok, session} =
  Sake.Client.connect("https://mcp.example.com/mcp",
    auth: [authorize: &Sake.Client.Auth.Browser.authorize/1]
  )

On a 401 the client discovers the authorization server (RFC 9728, then RFC 8414 or OpenID Connect Discovery), registers or identifies itself, sends the person to the authorization endpoint (PKCE with S256, a state, the RFC 8707 resource), checks the answer (state, and the RFC 9207 iss), exchanges the code for a token, and retries the request with it. From then on every request carries the token; an expired one is refreshed, a 401 tries the refresh token before the whole flow, and a 403 insufficient_scope asks again for the union of the scopes requested so far and the challenged ones (step-up), at most twice for one request. A 401 insufficient_user_authentication (RFC 9470: the person must sign in more strongly, or more recently) runs the person's grant again, the code flow or the device flow, with the challenge's acr_values and max_age, once for one request; a machine grant cannot, and ends with {:error, {:auth, {:insufficient_user_authentication, challenge}}}.

Options:

  • :authorize — the interactive step: a function that receives the authorization URL and returns {:ok, params}, the query parameters the authorization server sent to the redirect URI ("code", "state", "iss", or "error"), or {:error, reason}. Default Sake.Client.Auth.Browser.authorize/1; Sake.Client.Auth.Headless.authorize/1 is the one for an authorization server that approves without a person.
  • :redirect_uri — where the authorization server sends the answer. Default "http://127.0.0.1:33418/callback", a loopback address Sake.Client.Auth.Browser listens on.
  • :client_id, :client_secret — pre-registered credentials, used before anything else. :issuer names the authorization server they belong to; without it they are bound to the first one they are used with, and never offered to another. :token_endpoint_auth_method overrides the method chosen from the server's metadata.
  • :client_metadata_url — the HTTPS URL of the client's own Client ID Metadata Document, used as the client_id when the authorization server supports them. Its redirect_uris must include :redirect_uri.
  • :client_name — the name a dynamic registration (RFC 7591) gives; default the client_info name.
  • :store — {module, arg}, a Sake.Client.Auth.Store that keeps tokens and dynamic registrations across sessions. Without one, they live in the session only.
  • :static — true sends the token the :store holds as it is, for a token issued some other way (a test's, say): it is never refreshed, and a 401 or a 403 comes back to the caller instead of running the flow. Default false.

A person without a browser on the client's machine (a CLI over SSH, a headless agent, a device) authorizes with the device authorization grant (RFC 8628) in place of the code flow:

  • grant: :device — the client asks the authorization server's device_authorization_endpoint for a user code, shows it with :device_prompt (default Sake.Client.Auth.Device.prompt/1, which logs it; see there), and polls the token endpoint while the person enters the code and approves on any device where they can sign in. Polling honours the server's interval, and slow_down adds five seconds to it (RFC 8628 §3.5); a denial or an expiry ends it with {:error, {:auth, :access_denied}} or {:error, {:auth, :expired_token}}. A server whose metadata has no device endpoint ends it with {:error, {:auth, :no_device_endpoint}}. The tokens are refreshed as the code flow's are; a step-up, or a 401 the refresh token does not cure, runs the device flow again.

Machine grants, for a client with no person at a browser, run in place of the authorization code flow, with the same discovery and the same handling of expiry, 401 and step-up (the grant runs again, with the union of the scopes, where a person would have been asked):

  • grant: :client_credentials — the client acts as itself (the io.modelcontextprotocol/oauth-client-credentials extension). It needs :client_id and either :client_secret or :private_key.

  • grant: :jwt_bearer — the client presents a JWT it was given as the grant (RFC 7523 §2.1), a workload identity token from its platform, say. :assertion is the JWT, or a function of %{issuer: issuer, resource: resource} returning {:ok, jwt} (to ask the platform for one with the right audience). :client_id is optional.

  • grant: :enterprise — enterprise-managed authorization (io.modelcontextprotocol/enterprise-managed-authorization): the person's ID token from the host's single sign-on is exchanged at the enterprise identity provider for an ID-JAG (RFC 8693), which the MCP authorization server takes as a JWT bearer grant. It needs :client_id (and :client_secret or :private_key when the client is confidential there), :id_token (the ID token, or a function like :assertion's), and :idp: token_endpoint: or issuer: (to read it from the provider's metadata), and the client's client_id: and client_secret: at the provider (token_endpoint_auth_method: overrides the method its metadata suggests, "client_secret_jwt" included, and assertion_audience: is as below, for the provider).

    With SAML single sign-on, :saml_assertion (the base64url-encoded assertion, or a function like :assertion's) replaces :id_token, and :idp needs issuer:; idp: [saml_scope: [...]] is the scope of the SAML exchange alone (none by default; some providers want "offline_access" before they issue a refresh token). The assertion is first exchanged at the provider for a refresh token, which is then exchanged for the ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant §4.5). The refresh token is kept, in the session and the :store, and used for the next ID-JAGs until it expires or the provider refuses it; only then is an assertion asked for again.

DPoP (RFC 9449) binds the tokens to a key the client holds, so a token that leaks is useless without it:

  • :dpop — :auto (the default) uses DPoP with an authorization server whose metadata lists ES256 in dpop_signing_alg_values_supported; true sends proofs to any server; false never does. It needs the optional jose dependency: without it, :auto never uses DPoP, and true raises.

With DPoP, each authorization makes a new P-256 key, kept with the token (as PEM, in the :store too: a bound token is useless without it). Every token request carries a proof made with it, and every request to the MCP server sends the token with the DPoP scheme and a fresh proof bound to it. A resource whose metadata says dpop_bound_access_tokens_required gets DPoP with :auto whatever the authorization server's metadata lists; when the client cannot use it (dpop: false, or no jose), the flow stops at once with {:dpop_required, :dpop_off | :no_jose}. A server that asks for a nonce (use_dpop_nonce) gets the request again with its nonce, which the client then keeps sending; that retry does not count as an authorization.

:private_key is a PEM private key, or {pem, alg} to sign with another algorithm than its default (RS256 for RSA, ES256 for P-256): the client then authenticates with private_key_jwt (RFC 7523 §2.2), signing a short-lived assertion for every token request. It needs the optional jose dependency. token_endpoint_auth_method: "client_secret_jwt" signs the same assertion with HS256 over :client_secret instead (at least 32 bytes), which needs no jose; it is also chosen for a server that lists it as its only secret method. :assertion_audience sets the aud of both kinds of client assertion: :issuer (the default, the server's issuer identifier, as draft-ietf-oauth-rfc7523bis asks), :token_endpoint (the token endpoint's URL, which some servers want), or a string to send as it is. Machine grants get no refresh token: an expired token is replaced by running the grant again.

Pushed authorization requests (RFC 9126) keep the authorization request out of the browser:

  • :par — :auto (the default) pushes the request to the server's pushed_authorization_request_endpoint whenever its metadata lists one, authenticated as at the token endpoint and with a DPoP proof when the client uses DPoP (the code is then bound to the key), and opens the authorization endpoint with only the client_id and the request_uri it got back. false sends the request through the browser, unless the metadata says require_pushed_authorization_requests: such a server gets it pushed anyway. A push the server refuses ends the flow with {:error, {:auth, {:par_failed, status, body}}}.

OpenID Connect tells the client who signed in:

  • :openid — true adds the openid scope to the person's grants (the code flow and the device flow) with an authorization server whose metadata lists it, sends a nonce with an authorization request, and checks the id_token that comes back with the tokens: its signature (with a key from the server's jwks_uri), iss, aud (the client), exp, the nonce and at_hash; an ID token from a refresh must name the same person. An ID token that fails ends the flow with {:error, {:auth, {:invalid_id_token, reason}}}. id_token_claims/1 returns the verified claims. It needs the optional jose dependency. Default false.

Without auth:, a 401 is returned as {:error, {:unauthorized, %Sake.Auth.Challenge{}}} and the caller handles it. A failure inside the flow is {:error, {:auth, reason}}.

Summary

Functions

The verified claims of the ID token of a session's authorization, with openid: true: {:ok, claims} ("sub", the person's ID at the authorization server, "iss", "auth_time", ...), or :error when the session holds none (no auth:, no authorization yet, a server without OpenID Connect, a machine grant). For the profile and email claims, ask the server's userinfo endpoint with the token.

Types

t()

@type t() :: %Sake.Client.Auth{
  assertion: term(),
  assertion_audience: term(),
  authorize: term(),
  client_id: term(),
  client_issuer: term(),
  client_metadata_url: term(),
  client_name: term(),
  client_secret: term(),
  clients: term(),
  device_prompt: term(),
  dpop: term(),
  grant: term(),
  id_token: term(),
  idp: term(),
  idp_token: term(),
  nonces: term(),
  openid: term(),
  par: term(),
  private_key: term(),
  proof_key: term(),
  redirect_uri: term(),
  req: term(),
  saml_assertion: term(),
  sleep: term(),
  static: term(),
  store: term(),
  token: term(),
  token_endpoint_auth_method: term(),
  url: term()
}

Functions

id_token_claims(arg1)

@spec id_token_claims(%{auth: t() | nil}) :: {:ok, map()} | :error

The verified claims of the ID token of a session's authorization, with openid: true: {:ok, claims} ("sub", the person's ID at the authorization server, "iss", "auth_time", ...), or :error when the session holds none (no auth:, no authorization yet, a server without OpenID Connect, a machine grant). For the profile and email claims, ask the server's userinfo endpoint with the token.