Sake's authorization server makes your Phoenix (or Plug) app itself the
OAuth 2.1 authorization server for its own MCP endpoint: your login, your
users, no third-party identity provider. See guides/quickstart.md for the
five-step setup; this guide is the flow end to end, and the pieces you can
replace or extend.
The flow, as a person sees it
- A client (Claude Code, Claude Desktop, a custom agent) is pointed at
your MCP endpoint. It gets a 401 with a
WWW-Authenticateheader naming the endpoint's metadata URL. - The client fetches
/.well-known/oauth-protected-resource/...and then the authorization server's own/.well-known/oauth-authorization-server(served bySake.Auth.WellKnown), learning the authorize/token URLs and the scopes your servers declare. - The client registers itself — dynamically (see DCR below), or by presenting a Client ID Metadata Document — and opens the authorize URL in a browser.
- The person must be signed in.
Sake.Auth.Authorizereads the principal through yourSake.Auth.Subjectmodule; for a signed-out person it answers a 401 page ("Sign in first"). To send the person to your login page instead, put your own plug in front of the route — the generatedrequire_authenticated_userplug in a Phoenix app does exactly that, and returns the person to the authorize URL after login. A subject module that definesauthorize_resource?/2may refuse the endpoint the client asked for (seeguides/tenants.md); the client then getsaccess_denied. - The consent page (
Sake.Auth.Consent.Default, or your own) shows the client's name, the endpoint URL (the resource) and the scopes it asked for. Approving redirects back to the client with an authorization code. - The client exchanges the code (with its PKCE verifier) for an access
token and a refresh token at
/oauth/token. - The access token is a bearer token on every MCP request from then on; the refresh token gets a new pair when the access token expires.
The three client kinds
A Sake.Auth.Client comes from one of three places (kind: records
which):
- Client ID Metadata Document (CIMD) — the client's
client_idis an HTTPS URL;Sake.Auth.CIMD.fetch/2downloads the JSON document there and builds a client from it, caching the result for itsmax-age(clamped to one minute–one day). No registration step at all. - Dynamically registered (DCR) —
POST /oauth/register(Sake.Auth.DCR, RFC 7591) creates a public client from a JSON body; put a rate limit in front of it, since it is unauthenticated by design. - Host-registered — your own code calls
Sake.Auth.Clients.create/2for a CLI or a partner integration you control;confidential: truereturns a client secret once, for a client that can keep one.
CIMD trust policies
auth: [
...,
cimd: [policy: :allow_all] # the default: fetch any HTTPS client_id URL
cimd: [policy: {:allow, ["claude.ai"]}] # only these hosts
cimd: [policy: {MyApp.CIMDPolicy, :allowed?}] # apply(module, fun, [uri])
cimd: false # refuse every CIMD client_id
]Every fetch is guarded regardless of policy: HTTPS only (unless
allow_insecure: true), no credentials in the URL, the host's resolved
addresses must all be public unicast (no loopback, link-local, private,
multicast, reserved or IPv4-translating ranges), no redirects are
followed, the body is capped at 64 KB, and the whole download must finish
within 5 seconds. A document may list at most 10 redirect URIs, and its
optional fields must have their JSON types. The cache holds at most 1000
documents. See guides/security.md for what this guard does and does not
protect against.
DCR and rate limiting
Sake.Auth.DCR accepts any POST /oauth/register and hands back a public
client, with no authentication of the caller — that is the point of
dynamic registration, and also its risk: an unrate-limited registration
endpoint lets anyone mint clients. Put a rate limit in front of it (by
IP, in your router's pipeline) the same way you would any other
unauthenticated write endpoint. Set dcr: false in the auth config to
turn registration off entirely (CIMD and host-registered clients still
work).
Token lifetimes and rotation
access_token_ttl (default 3,600 seconds) and refresh_token_ttl
(default 30 days) are configurable per Sake.Auth.Config. Every refresh
rotates both tokens (reuse detection): presenting a refresh token a second
time revokes its whole family (every token issued from the same original
code or refresh) rather than just failing that one request — a stolen,
already-used refresh token cannot be replayed to get a fresh pair once the
legitimate client has moved on. Two concurrent refreshes of the same token
both fail closed (the store consumes it atomically); a refresh token
presented by the wrong client_id is burned (marked used) even though the
request is denied, so it cannot be retried against the right client
either. A client may use only the grant types it registered: a client
without refresh_token in its grant_types gets unauthorized_client
for a refresh (dynamic registration grants both when the client names
none).
The consent page and how to replace it
The default page (Sake.Auth.Consent.Default) ships its own inline style
sheet and needs nothing from your app. Replace it with consent: MyAppWeb.MCPConsent in the auth config; your module implements the
Sake.Auth.Consent behaviour: render/1 (client name, host, redirect
host, whether the redirect is a loopback address, the signed-in person's
display string, {scope, description} pairs, the resource URL, an
opaque request blob for a hidden field, the form's action, a CSRF
token — empty when your pipeline runs no CSRF protection — and your
issuer's display name) and render_error/1 (title, message, status) for the
handful of error pages (unknown client, bad redirect, expired request).
Both pages are sent with x-frame-options: DENY and
content-security-policy: frame-ancestors 'none', so no other site can
frame them.
Consent is remembered by default (remember_consent: true), per person,
client and resource: approving a client once for an endpoint skips the
page on a later request for the same endpoint and the same or a narrower
set of scopes, until the grant is revoked. A request for another endpoint
asks again. Set it to false to ask every time.
The grants API for a settings page
Sake.Auth.list_grants(config, subject_id)
# => [%{client_id:, client_name:, scopes:, resource:, granted_at:, family_ids: [...]}]
Sake.Auth.revoke_grant(config, subject_id, client_id)
# revokes every token family the client holds for that person, and forgets its consentsThe revocation endpoint (POST /oauth/revoke) also forgets the consents
the person gave the revoking client, so its next connection asks again.
list_grants/2 groups a person's live (unexpired, unused) refresh tokens
by client — one row per client, even if it holds tokens for more than one
resource or scope set — for an "apps connected to your account" page.
Sake.Auth.prune/1
Sake.Auth.prune(config)Deletes expired codes and tokens, and remembered consents older than the
refresh token lifetime that no live token family still uses. Schedule it — a cron job, an Oban
worker, a periodic Task — since neither store does this on its own; the
ETS store especially will otherwise keep every expired row in memory
forever.
The Ecto migration
defmodule MyApp.Repo.Migrations.AddSakeAuth do
use Ecto.Migration
def up, do: Sake.Auth.Migration.up()
def down, do: Sake.Auth.Migration.down()
endBoth take prefix:. It creates sake_oauth_clients, sake_oauth_codes,
sake_oauth_tokens and a consents table — string primary keys throughout
(hashes and generated IDs, never the raw secrets). Point the auth config
at it with store: {Sake.Auth.Store.Ecto, repo: MyApp.Repo}.
The Ecto store's put_token never resurrects a token: it upserts on the
token's id with on_conflict: {:replace_all_except, [:revoked_at, :used_at]}, so re-inserting a stale copy of a record (a retried write, a
race with a concurrent revoke) replaces every column except those two —
a token already marked revoked or used stays that way no matter what a
later write for the same id says about it.
The ETS store for development
The default, {Sake.Auth.Store.ETS, []}, needs no database: an Agent
owns a set of ETS tables, which also serializes the operations that must
be atomic (consuming a refresh token, revoking a family) through the
Agent's mailbox rather than a database transaction. It is what mix dev
and this package's own test suite use — nothing survives a restart, which
is exactly what you want in development and exactly what you do not want
once real people have connected real clients to a deployed server.