Current section
Files
Jump to
Current section
Files
lib/attesto/plug/require_scopes.ex
if Code.ensure_loaded?(Plug.Conn) do
defmodule Attesto.Plug.RequireScopes do
@moduledoc """
Authorize a request against the scopes on the verified token.
Runs after `Attesto.Plug.Authenticate` (which assigns the verified
claims): it reads the `scope` claim, splits it, and checks that the
granted set covers every required scope via `Attesto.Scope`. On
success the conn passes through; otherwise it answers 403
`insufficient_scope` (RFC 6750 §3.1).
plug Attesto.Plug.RequireScopes, ["documents.read"]
Options. The first argument may be a bare list of required scopes, or a
keyword list with:
* `:scopes` (required) - the list of required concrete scopes.
* `:claims_key` - the `conn.assigns` key the claims were put under
(default `:attesto_claims`, matching `Attesto.Plug.Authenticate`).
A request that reaches this plug without verified claims (the
authentication plug did not run or did not assign them) is treated as
unauthenticated and answered 401.
Part of the optional `Attesto.Plug` layer; compiles only with `Plug`.
"""
@behaviour Plug
alias Attesto.Plug.OAuthError
alias Attesto.Scope
@default_claims_key :attesto_claims
@impl Plug
def init(scopes) when is_list(scopes) do
{required, claims_key} = normalize(scopes)
if required == [] do
raise ArgumentError,
"Attesto.Plug.RequireScopes requires a non-empty list of scopes; an endpoint " <>
"must declare what it requires."
end
%{required: required, catalog: Scope.new_catalog(required), claims_key: claims_key}
end
@impl Plug
def call(conn, %{required: required, catalog: catalog, claims_key: claims_key}) do
case conn.assigns[claims_key] do
%{"scope" => scope} = claims when is_binary(scope) ->
granted = String.split(scope, ~r/\s+/, trim: true)
if Scope.grants_all?(catalog, granted, required),
do: conn,
else: OAuthError.insufficient_scope(conn, required, scheme_of(claims))
%{} = claims ->
# Authenticated but no scope claim: cannot satisfy any requirement.
OAuthError.insufficient_scope(conn, required, scheme_of(claims))
_ ->
OAuthError.unauthorized(conn, :bearer, "invalid_token", description: "request is not authenticated")
end
end
# The challenge scheme must match how the client authenticated (RFC
# 9449 §7.1): a DPoP-bound token carries a `cnf.jkt`, so its
# insufficient_scope challenge is a `DPoP` challenge, not `Bearer`. A
# bearer or mTLS-bound token (no `cnf.jkt`) gets the `Bearer` challenge.
defp scheme_of(%{"cnf" => %{"jkt" => jkt}}) when is_binary(jkt), do: :dpop
defp scheme_of(_claims), do: :bearer
defp normalize(scopes) do
if Keyword.keyword?(scopes) and scopes != [] do
{Keyword.get(scopes, :scopes, []), Keyword.get(scopes, :claims_key, @default_claims_key)}
else
{scopes, @default_claims_key}
end
end
end
end