Packages
attesto_phoenix
2.3.0
2.3.0
2.2.0
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
lib/attesto_phoenix/event.ex
defmodule AttestoPhoenix.Event do
@moduledoc """
Neutral event struct and dispatcher for the optional `:on_event` callback.
An OAuth 2.0 / OIDC authorization server takes many decisions that an
operator may wish to record: a token was issued, a token request was denied,
a token was revoked (RFC 7009), a refresh token was rotated (RFC 6749 §6),
presented refresh-token reuse was detected (RFC 6819 §5.2.2.3), a bearer
token authenticated a request (RFC 6750), a request was rejected, or a client
was registered (RFC 7591). *Recording* those decisions (to a log, a database,
a SIEM) is host policy, not a concern of this library. This module therefore
does two things and nothing more:
1. Defines a closed set of event names and a generic payload struct that
carries only OAuth/OIDC vocabulary (subject, client id, scope, grant
type, result, request metadata).
2. Dispatches each event to the host's optional `:on_event` callback read
from `AttestoPhoenix.Config`. When the callback is unset the dispatch is
a no-op: the library emits, the host stores.
The dispatcher never raises on a missing callback (emission is optional),
never inspects or persists the event itself, and discards the callback's
return value so a storage decision can never alter the authorization-server
control flow that emitted the event.
## Configuration
The callback is the `:on_event` field of `AttestoPhoenix.Config`. It accepts
any of the `t:AttestoPhoenix.Config.callback/0` forms - an anonymous
function, a `{module, function}` pair, or a full `{module, function, args}`
tuple - and is invoked with the `%AttestoPhoenix.Event{}` struct. For the
`{module, function, args}` form the event is prepended to `args`:
config :my_app, AttestoPhoenix,
on_event: &MyApp.OAuth.handle_event/1
config :my_app, AttestoPhoenix,
on_event: {MyApp.OAuth, :handle_event}
config :my_app, AttestoPhoenix,
on_event: {MyApp.OAuth, :handle_event, [extra_context]}
"""
alias AttestoPhoenix.Config
@typedoc """
The closed set of authorization-server lifecycle events.
* `:token_issued` - an access (and optionally refresh) token was issued in
response to a successful grant (RFC 6749 §5.1). Its `:metadata` includes
`:token_type`, `:sender_constraint`, and `:cnf` so a host can audit whether
the issued access token is plain Bearer, DPoP-bound, or mTLS-bound.
* `:token_denied` - a token request was rejected (RFC 6749 §5.2). Its
`:metadata` includes `:client_id` when known, `:reason` as the structured
OAuth error atom, and the sender-constraint audit fields `:token_type`,
`:sender_constraint`, and `:cnf`.
* `:code_issued` - an authorization code was issued at the authorization
endpoint in response to a successful authorization request (RFC 6749
§4.1.2).
* `:authorization_denied` - the resource owner refused an authorization
request, reported to the client as `access_denied` (RFC 6749 §4.1.2.1).
* `:authorization_failed` - an authorization request was rejected before a
code was issued (RFC 6749 §4.1.2.1), whether reported as a direct error
page or by redirect.
* `:token_revoked` - a previously issued token was revoked (RFC 7009).
* `:refresh_issued` - an initial refresh token was issued alongside an
access token at the token endpoint (RFC 6749 §5.1, §6). Distinct from
`:refresh_rotated`: no predecessor was consumed, this is the first token
in a new rotation family.
* `:refresh_rotated` - a refresh token was exchanged and a new refresh token
issued, invalidating the presented one (RFC 6749 §6, RFC 6819 §5.2.2.3).
* `:refresh_reuse_detected` - an already-rotated refresh token was presented
again, indicating possible theft (RFC 6819 §5.2.2.3).
* `:auth_succeeded` - a presented access token authenticated a protected
resource request (RFC 6750 §2.1).
* `:auth_denied` - a protected resource request was rejected (RFC 6750 §3.1).
* `:client_registered` - a client was registered (RFC 7591).
"""
@type name ::
:token_issued
| :token_denied
| :code_issued
| :authorization_denied
| :authorization_failed
| :token_revoked
| :refresh_issued
| :refresh_rotated
| :refresh_reuse_detected
| :auth_succeeded
| :auth_denied
| :client_registered
# The closed set of recognized event names. Used to fail closed on an
# unrecognized name rather than emitting an event the host cannot interpret.
@names ~w(
token_issued
token_denied
code_issued
authorization_denied
authorization_failed
token_revoked
refresh_issued
refresh_rotated
refresh_reuse_detected
auth_succeeded
auth_denied
client_registered
)a
@typedoc """
A neutral authorization-server event.
Every field is optional because the populated subset depends on the event:
a `:token_denied` before client authentication has no `:subject`, a
`client_credentials` grant has no resource-owner `:subject`, and so on. The
library never fabricates a value it does not have.
* `:name` - the event name (one of `t:name/0`).
* `:subject` - the resource owner identifier, the `sub` claim (RFC 7519
§4.1.2) when one is present.
* `:client_id` - the OAuth client identifier (RFC 6749 §2.2).
* `:scope` - the granted or requested scope (RFC 6749 §3.3).
* `:grant_type` - the grant type of the request (RFC 6749 §1.3).
* `:result` - for denial events, a machine-readable reason term (typically an
RFC 6749 §5.2 error code such as `:invalid_client` or `:invalid_grant`).
* `:metadata` - a host-opaque map of request metadata (for example client IP
or request identifiers). For token issuance-like events (`:token_issued`,
`:refresh_issued`, and `:refresh_rotated`) and token denials
(`:token_denied`), this map also carries sender-constraint audit fields:
`:token_type` (`"Bearer"` or `"DPoP"`), `:sender_constraint` (`:none`,
`:dpop`, or `:mtls`), and `:cnf` (`%{"jkt" => thumbprint}`,
`%{"x5t#S256" => thumbprint}`, or `nil`). Denials additionally carry
`:reason` as the OAuth error atom and `:client_id` when known.
"""
@type t :: %__MODULE__{
name: name(),
subject: String.t() | nil,
client_id: String.t() | nil,
scope: String.t() | nil,
grant_type: String.t() | nil,
result: term() | nil,
metadata: map()
}
@enforce_keys [:name]
defstruct name: nil,
subject: nil,
client_id: nil,
scope: nil,
grant_type: nil,
result: nil,
metadata: %{}
@doc """
Builds an event struct for `name` from a payload of OAuth/OIDC fields.
`name` must be a recognized event name (`t:name/0`); an unrecognized name
raises `ArgumentError` so a typo fails closed instead of silently emitting an
uninterpretable event. `fields` is a map or keyword list whose recognized
keys (`:subject`, `:client_id`, `:scope`, `:grant_type`, `:result`,
`:metadata`) populate the struct. An unknown key raises `KeyError` via
`struct!/2` rather than being silently dropped.
## Examples
iex> AttestoPhoenix.Event.new(:token_issued, client_id: "abc", scope: "openid")
%AttestoPhoenix.Event{
name: :token_issued,
client_id: "abc",
scope: "openid",
metadata: %{}
}
"""
@spec new(name(), map() | keyword()) :: t()
def new(name, fields \\ %{})
def new(name, fields) when name in @names do
struct!(__MODULE__, Map.put(Map.new(fields), :name, name))
end
def new(name, _fields) do
raise ArgumentError,
"unrecognized AttestoPhoenix event name: #{inspect(name)}. " <>
"Recognized names: #{inspect(@names)}"
end
@doc """
Emits an event to the host's `:on_event` callback, if one is configured.
`config` is the `AttestoPhoenix.Config` for the request; the callback is read
from its `:on_event` field. `name` plus `fields` are passed to `new/2` (so an
unrecognized name raises). When `:on_event` is unset this is a no-op that
returns `:ok`.
Emission is observational: the callback's return value is discarded and `:ok`
is always returned, so a host's storage decision can never alter the
authorization-server control flow that emitted the event.
"""
@spec emit(Config.t(), name(), map() | keyword()) :: :ok
def emit(%Config{} = config, name, fields \\ %{}) when is_atom(name) do
dispatch(Config.on_event_fun(config), new(name, fields))
end
@doc """
Dispatches a pre-built event struct to a resolved `:on_event` callback.
`callback` is any `t:AttestoPhoenix.Config.callback/0` form, or `nil` for the
unconfigured case (a no-op returning `:ok`). Exposed for callers that already
hold a built struct and the resolved callback.
"""
@spec dispatch(Config.callback() | nil, t()) :: :ok
def dispatch(nil, %__MODULE__{}), do: :ok
def dispatch(callback, %__MODULE__{} = event) when is_function(callback, 1) do
_ = callback.(event)
:ok
end
def dispatch({module, function}, %__MODULE__{} = event) when is_atom(module) and is_atom(function) do
_ = apply(module, function, [event])
:ok
end
def dispatch({module, function, args}, %__MODULE__{} = event)
when is_atom(module) and is_atom(function) and is_list(args) do
_ = apply(module, function, [event | args])
:ok
end
@doc """
Returns the closed set of recognized event names.
"""
@spec names() :: [name(), ...]
def names, do: @names
end