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}. DefaultSake.Client.Auth.Browser.authorize/1;Sake.Client.Auth.Headless.authorize/1is 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 addressSake.Client.Auth.Browserlistens on.:client_id,:client_secret— pre-registered credentials, used before anything else.:issuernames 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_methodoverrides 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 theclient_idwhen the authorization server supports them. Itsredirect_urismust include:redirect_uri.:client_name— the name a dynamic registration (RFC 7591) gives; default theclient_infoname.:store—{module, arg}, aSake.Client.Auth.Storethat keeps tokens and dynamic registrations across sessions. Without one, they live in the session only.:static—truesends the token the:storeholds 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. Defaultfalse.
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'sdevice_authorization_endpointfor a user code, shows it with:device_prompt(defaultSake.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'sinterval, andslow_downadds 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 (theio.modelcontextprotocol/oauth-client-credentialsextension). It needs:client_idand either:client_secretor: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.:assertionis the JWT, or a function of%{issuer: issuer, resource: resource}returning{:ok, jwt}(to ask the platform for one with the right audience).:client_idis 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_secretor:private_keywhen the client is confidential there),:id_token(the ID token, or a function like:assertion's), and:idp:token_endpoint:orissuer:(to read it from the provider's metadata), and the client'sclient_id:andclient_secret:at the provider (token_endpoint_auth_method:overrides the method its metadata suggests,"client_secret_jwt"included, andassertion_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:idpneedsissuer:;idp: [saml_scope: [...]]is thescopeof 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 indpop_signing_alg_values_supported;truesends proofs to any server;falsenever does. It needs the optionaljosedependency: without it,:autonever uses DPoP, andtrueraises.
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'spushed_authorization_request_endpointwhenever 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 theclient_idand therequest_uriit got back.falsesends the request through the browser, unless the metadata saysrequire_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—trueadds theopenidscope to the person's grants (the code flow and the device flow) with an authorization server whose metadata lists it, sends anoncewith an authorization request, and checks theid_tokenthat comes back with the tokens: its signature (with a key from the server'sjwks_uri),iss,aud(the client),exp, thenonceandat_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/1returns the verified claims. It needs the optionaljosedependency. Defaultfalse.
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
@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
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.