Sake.Auth.External behaviour (Sake v0.4.3)

Copy Markdown View Source

An endpoint whose tokens come from an authorization server Sake does not run: your app's own OAuth provider (ExOauth2Provider, Boruta, a hand-rolled one) or an external identity provider (Keycloak, Auth0, Okta, Entra ID, Cognito, WorkOS). Sake is then only the resource server: it checks the tokens that server issues and enforces the scopes.

Name each authorization server in the Sake child, and point an endpoint at it by name:

{Sake,
 secret_key_base: ...,
 external_auth: [
   keycloak: [
     issuer: "https://sso.example.com/realms/acme",
     verify: :jwt,
     servers: [MyApp.MCP]
   ]
 ]}

sake_endpoint "/mcp", server: MyApp.MCP, auth: :keycloak

Sake.Auth (auth: [...]) is not needed at all; with it, one endpoint may take Sake's own tokens (auth: true) and another an external server's.

Options

  • :issuer (required) — the authorization server's issuer identifier, exactly as its tokens' iss and its metadata say it (Auth0's ends with a slash). The protected resource metadata names it in authorization_servers, so MCP clients discover it as they would Sake's own.
  • :verify (required) — how a token is checked:
    • :jwt — a JWT access token (RFC 9068), verified with the issuer's keys: :jwks (a JWK Set map) or :jwks_uri, or else the jwks_uri of the issuer's OpenID Connect or RFC 8414 metadata. Needs the optional jose dependency.
    • {:introspection, opts} — token introspection (RFC 7662) at endpoint:, authenticated as the resource with client_id: and client_secret: (auth_method: :basic, the default, or :post). An active answer is cached for cache: seconds (default 60, 0 turns the cache off), never past the token's exp, keyed by a digest of the token.
    • a module that implements verify_token/2, or a function of two arguments ({module, function} or a capture): your own code, for a provider that runs in your app.
  • :audiences — the aud values a token must carry one of. By default the endpoint's own URL, the resource the client asked for (RFC 8707). :any turns the audience check off; see the security note in the external authorization guide before you do.
  • :typ — the typ header a JWT access token must have: default "at+jwt" (RFC 9068). Many identity providers issue plain "JWT" access tokens; say so, or :any.
  • :principal — what a token stands for, as the endpoint's ctx.scope: a function of the verified claims (string keys) that returns {:ok, principal} or :error (a function of one argument, or {module, function}). By default the claims themselves.
  • :servers — the servers whose scopes the resource metadata lists in scopes_supported and challenges name, as for Sake.Auth.
  • :resources — the endpoint paths this server protects (with :param segments), so the protected resource metadata at each path names the right issuer. Needed whenever Sake knows more than one authorization server (Sake.Auth and this one, or several external ones).
  • :dpop — as for Sake.Auth: true (the default, when jose is there), false, or [nonce: boolean, required: boolean]. A token bound to a key (cnf.jkt) is only ever accepted with a proof made with that key.
  • :extensions — the authorization extensions to declare in the endpoint's MCP capabilities (:client_credentials, :enterprise), when this server offers their grants. Default none.
  • :url — the origin MCP clients reach this app's endpoints at ("https://mcp.example.com"), for the resource URL the protected resource metadata names, the default audience and DPoP proofs. Without it, the origin the request arrived with: give it when a proxy in front of the app sends another host (see "Behind a proxy" in the security guide).
  • :resource_name — the protected resource metadata's resource_name.
  • :allow_insecure — allow http URLs (issuer, keys, introspection endpoint), for development and tests.

The token

Whichever way it was verified, a token becomes the same Sake.Auth.TokenRecord a token of Sake.Auth does, in ctx.token: scopes from scope (or scp, a string or a list), client_id from client_id (or azp), subject_id from sub, jkt from cnf.jkt, expires_at from exp, acr from acr and auth_time from auth_time (seconds since the epoch; RFC 9470: how and when the person signed in, which a tool's authentication: requirement is checked against), and issuer and claims besides. A token whose cnf binds it any other way than jkt (a client certificate, x5t#S256, RFC 8705) is refused, since Sake cannot check that binding. A token without sub, or whose sub is its client (RFC 9068 §2.2, the client credentials grant), is a machine client's: subject_id is nil. Scope checks, step-up challenges, middleware, and task ownership work as for Sake's own tokens; tasks and request states are bound to the issuer as well as the subject, so the same sub from another issuer is someone else.

Each refusal is the telemetry event [:sake, :auth, :external, :refused], with the configuration's name, its issuer and the reason (:invalid_audience, :expired, :bad_signature, :inactive, :unsupported_confirmation, ...), for finding out why a token is not accepted.

Summary

Callbacks

Verifies a token for a provider that runs in your app. Return the token's claims, in the shape of an introspection answer (RFC 7662 §2.2, string or atom keys): sub, client_id, scope (a string or a list), exp (seconds or a DateTime), aud, cnf with jkt for a DPoP-bound token, and acr and auth_time (seconds or a DateTime) for a tool that requires a strong or recent sign-in. {:ok, claims, principal} gives the principal as well, when the query that found the token loaded it, and skips principal:. :error refuses the token.

Functions

The external authorization server configured under name; raises when there is none.

Types

t()

@type t() :: %Sake.Auth.External{
  allow_insecure: term(),
  audiences: term(),
  dpop: term(),
  extensions: term(),
  issuer: term(),
  jwks: term(),
  jwks_uri: term(),
  name: term(),
  principal: term(),
  resource_name: term(),
  resources: term(),
  secret: term(),
  servers: term(),
  typ: term(),
  url: term(),
  verify: term()
}

Callbacks

verify_token(token, conn)

@callback verify_token(token :: String.t(), conn :: Plug.Conn.t()) ::
  {:ok, map()} | {:ok, map(), term()} | :error

Verifies a token for a provider that runs in your app. Return the token's claims, in the shape of an introspection answer (RFC 7662 §2.2, string or atom keys): sub, client_id, scope (a string or a list), exp (seconds or a DateTime), aud, cnf with jkt for a DPoP-bound token, and acr and auth_time (seconds or a DateTime) for a tool that requires a strong or recent sign-in. {:ok, claims, principal} gives the principal as well, when the query that found the token loaded it, and skips principal:. :error refuses the token.

Functions

fetch!(name)

@spec fetch!(atom()) :: t()

The external authorization server configured under name; raises when there is none.