AshAuthentication. Phoenix. Oauth2Server. BearerPlug
(ash_authentication_oauth2_server v0.3.0)
Copy Markdown
View Source
Resource-server side bearer token validation.
Validates an Authorization: Bearer <jwt> header against the configured
authorization server. On success, loads the user via Ash.get/3 on the
configured user_resource and sets it as the conn's actor.
Usage
pipeline :mcp_protected do
plug AshAuthentication.Phoenix.Oauth2Server.BearerPlug,
oauth2_server: MyApp.Oauth2Server
endOptions
:oauth2_server(required) — yourOauth2Serverconfig module:required?(defaulttrue) — whenfalse, missing/invalid tokens pass through unchanged instead of returning 401. Useful for routes that should serve unauthenticated users with a different (e.g. session-based) signal.:scope— a scope string (or list of scope strings) advertised in the 401 challenge asscope="…". The MCP spec recommends this so clients know which scopes to request during initial authorization instead of asking for everything inscopes_supported. This is advisory — it does not enforce anything; pair withAshAuthentication.Phoenix.Oauth2Server.RequireScopePlugfor enforcement.
Failure behavior
Per RFC 6750 §3, a missing or invalid token results in 401 with a
WWW-Authenticate: Bearer resource_metadata="..." header pointing at
the protected-resource metadata endpoint (plus scope="…" when the
:scope option is set), so MCP-style clients can auto-discover the
authorization server and the scopes they should request.
What ends up on the conn
On success two things are set, and downstream code reads from each for different purposes:
Ash.PlugHelpers.get_actor(conn)— the user record loaded viaAsh.get/3on the configureduser_resourceusing the token'ssubclaim. Use this for "who is this" (Ash policies, tenant resolution, ownership checks).conn.assigns.oauth_claims— the verified JWT claims map. Use this for "what is this bearer allowed to do" — most importantly the scope claim:scopes = conn.assigns.oauth_claims["scope"] |> String.split(" ", trim: true)Other useful claims:
client_id(which OAuth client minted this),aud(which resource),jti(unique token id).
Note that scopes are conn-scoped, not actor-scoped. The same
user with two access tokens minted for two different clients ends
up with the same actor but different oauth_claims["scope"]. This
is the right OAuth semantic — the access token is a delegated grant
from user → client, distinct from the user's own permissions.
Gating an action on a scope
Use AshAuthentication.Phoenix.Oauth2Server.RequireScopePlug after
this plug — it 403s with the RFC 6750 insufficient_scope challenge
shape that step-up-capable clients understand:
pipeline :mcp_read do
plug AshAuthentication.Phoenix.Oauth2Server.BearerPlug,
oauth2_server: MyApp.Oauth2Server,
scope: "mcp.read"
plug AshAuthentication.Phoenix.Oauth2Server.RequireScopePlug,
oauth2_server: MyApp.Oauth2Server,
scope: "mcp.read"
endReading scopes inside an Ash policy
If you'd rather gate at the resource layer, copy oauth_claims into
the actor's metadata or the action context before invoking the
action. For example, a tiny plug between BearerPlug and your
controller:
plug fn conn, _ ->
Ash.PlugHelpers.update_context(conn, fn ctx ->
Map.put(ctx || %{}, :oauth_scopes,
conn.assigns.oauth_claims["scope"]
|> String.split(" ", trim: true))
end)
endthen in your resource:
policies do
policy action(:read) do
authorize_if expr(^context(:oauth_scopes) |> contains("mcp.read"))
end
end