AshAuthentication. Phoenix. Oauth2Server. BearerPlug
(ash_authentication_oauth2_server v0.1.1)
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.
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, so MCP-style clients can
auto-discover the authorization server.
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
The minimum useful pattern is a plug that 403s when a required scope isn't present. Drop one of these into your pipeline after this plug, or write it inline in your controller:
defmodule MyAppWeb.RequireScope do
@behaviour Plug
import Plug.Conn
@impl true
def init(scope) when is_binary(scope), do: scope
@impl true
def call(conn, scope) do
scopes =
conn.assigns
|> Map.get(:oauth_claims, %{})
|> Map.get("scope", "")
|> String.split(" ", trim: true)
if scope in scopes do
conn
else
conn |> send_resp(403, "") |> halt()
end
end
end
pipeline :mcp_read do
plug AshAuthentication.Phoenix.Oauth2Server.BearerPlug,
oauth2_server: MyApp.Oauth2Server
plug MyAppWeb.RequireScope, "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