Packages
attesto_phoenix
0.6.2
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/schema/authorization.ex
defmodule AttestoPhoenix.Schema.Authorization do
@moduledoc """
Ecto schema for the single-use authorization codes backing an
`Attesto.CodeStore`.
This is the persistent record shape behind the authorization-code grant
(RFC 6749 §4.1). The store layer mints one row per code at the
authorization endpoint and consumes it at the token endpoint; this module
only describes the row and translates it to and from the protocol struct
`Attesto.AuthorizationCode.Grant`. All protocol decisions (code
generation and hashing, PKCE verification, DPoP/mTLS binding checks,
expiry, single-use semantics) live in `attesto`; nothing here re-derives
them.
## What is stored, and what is not
Only the *hash* of the code is persisted (`:code_hash`), never the
plaintext code handed to the client. The plaintext is a bearer secret
(RFC 6749 §10.5): a database disclosure must not yield a usable code, so
the column is the output of `Attesto.Secret.hash/1` and is the unique
lookup key.
The remaining columns are the authorization-request context that must be
reproduced at redemption time:
* `:client_id` - the client the code was issued to (RFC 6749 §4.1.3:
the code MUST be redeemed by that same client).
* `:subject` - the resource owner the code authenticates.
* `:scope` - the granted scope, a list of scope tokens.
* `:redirect_uri` - the registered redirect URI, compared by exact
string match at redemption (RFC 6749 §3.1.2 / §4.1.3).
* `:code_challenge` / `:code_challenge_method` - the PKCE challenge and
its transform (RFC 7636). Only `S256` is a valid method.
* `:cnf` - the optional confirmation/key-binding map (RFC 7800). When
present it holds a `jkt` (DPoP key thumbprint, RFC 9449 §6) and/or an
`x5t#S256` (mTLS certificate thumbprint, RFC 8705 §3.1); a bound code
MUST be redeemed presenting the same binding.
* `:nonce` - the OIDC request `nonce` (OpenID Connect Core §3.1.2.1),
round-tripped into the eventual ID Token.
* `:claims` - an opaque map of additional request context carried from
the authorization request to redemption.
## Lifecycle columns
* `:expires_at` - absolute expiry as a `utc_datetime`. Authorization
codes are short-lived (RFC 6749 §4.1.2 recommends a maximum of ten
minutes).
* `:consumed_at` - set when the code is spent. The single-use contract
(RFC 6749 §4.1.2) is enforced by the store's atomic take; this column
is an audit marker for a consumed-then-rejected redemption, not a
second gate.
* `:inserted_at` - insertion timestamp.
## Record bridge
`Attesto.CodeStore` exchanges plain maps with a `:code_hash`, a
`:data` map, and an integer `:expires_at` (unix seconds). `from_record/2`
builds an Ecto changeset from such a map for insertion, and `to_record/1`
rebuilds the map from a loaded row so the protocol layer can hydrate the
authorization-code grant from `record.data`.
## Table name and prefix
The table is `attesto_authorization_codes` by default and is namespaced
by the optional schema prefix passed via `from_record/2`'s `:prefix`
option (or the schema-wide prefix configured through
`AttestoPhoenix.Config`), letting a host isolate the
authorization-server tables in their own schema.
"""
use Ecto.Schema
import Ecto.Changeset
@default_table "attesto_authorization_codes"
# RFC 7636 §4.3: the only code-challenge transform a compliant server
# accepts. `plain` is forbidden so an intercepted authorization request
# cannot downgrade PKCE.
@code_challenge_method_s256 "S256"
@typedoc "A persisted authorization-code row."
@type t :: %__MODULE__{
code_hash: String.t() | nil,
client_id: String.t() | nil,
subject: String.t() | nil,
scope: [String.t()] | nil,
redirect_uri: String.t() | nil,
code_challenge: String.t() | nil,
code_challenge_method: String.t() | nil,
cnf: map() | nil,
nonce: String.t() | nil,
claims: map() | nil,
expires_at: DateTime.t() | nil,
consumed_at: DateTime.t() | nil,
inserted_at: DateTime.t() | nil
}
@typedoc """
The plain map exchanged with `Attesto.CodeStore`: the code hash, the
opaque grant `:data`, and the absolute expiry in unix seconds.
"""
@type store_record :: %{
required(:code_hash) => String.t(),
required(:data) => map(),
required(:expires_at) => integer()
}
@primary_key false
schema @default_table do
field :code_hash, :string
field :client_id, :string
field :subject, :string
field :scope, {:array, :string}, default: []
field :redirect_uri, :string
field :code_challenge, :string
# No default: a code issued without a PKCE challenge (the host relaxed PKCE
# for a confidential client; see Attesto.AuthorizationRequest's :require_pkce)
# must persist with a NULL method, not a spurious "S256" for a challenge that
# is not there. When a challenge IS present, from_record/2 sets the method.
field :code_challenge_method, :string
field :cnf, :map
field :nonce, :string
field :claims, :map, default: %{}
field :expires_at, :utc_datetime
field :consumed_at, :utc_datetime
field :inserted_at, :utc_datetime
end
@required [
:code_hash,
:client_id,
:subject,
:redirect_uri,
:expires_at
]
# PKCE is optional at persistence: a confidential client the host exempted
# from PKCE (Attesto.AuthorizationRequest's :require_pkce) issues a code with
# no challenge/method. When present they are still constrained (the method to
# S256, see validate_inclusion below); when absent the columns are NULL.
@optional [:scope, :cnf, :nonce, :claims, :consumed_at, :code_challenge, :code_challenge_method]
@doc """
The default table name for this schema.
"""
@spec table() :: String.t()
def table, do: @default_table
@doc """
The only accepted PKCE code-challenge method (RFC 7636 §4.3, `S256`).
"""
@spec code_challenge_method() :: String.t()
def code_challenge_method, do: @code_challenge_method_s256
@doc """
Builds an insertable changeset from a `Attesto.CodeStore` record map.
`record` is the map the protocol layer persists: a `:code_hash`, the
opaque grant `:data`, and an integer `:expires_at` in unix seconds. The
fields inside `:data` (client, subject, scope, redirect URI, PKCE
challenge, optional DPoP thumbprint, OIDC nonce, and request claims) are
spread across the row's columns so they can be queried and audited
individually while still round-tripping losslessly via `to_record/1`.
Options:
* `:prefix` - the Ecto schema prefix (database schema) to write the row
into. Defaults to no prefix.
* `:now` - the insertion clock as a `DateTime`. Defaults to
`DateTime.utc_now/0`. Provided for deterministic tests.
Validation is fail-closed: a missing required field (hash, client,
subject, redirect URI, or expiry) is rejected rather than defaulted. PKCE
is optional at persistence (a confidential client the host exempted from
PKCE via `Attesto.AuthorizationRequest`'s `:require_pkce` issues a code with
no challenge); when a `code_challenge_method` is present it is constrained to
`S256` (RFC 7636 §4.3), and a challenge-less code stores a NULL method.
"""
@spec from_record(store_record(), keyword()) :: Ecto.Changeset.t()
def from_record(record, opts \\ []) when is_map(record) and is_list(opts) do
now = Keyword.get(opts, :now, DateTime.utc_now()) |> DateTime.truncate(:second)
prefix = Keyword.get(opts, :prefix)
data = Map.get(record, :data, %{})
attrs = %{
code_hash: Map.get(record, :code_hash),
client_id: Map.get(data, :client_id),
subject: Map.get(data, :subject),
scope: Map.get(data, :scope, []),
redirect_uri: Map.get(data, :redirect_uri),
code_challenge: Map.get(data, :code_challenge),
code_challenge_method: code_challenge_method_for(data),
cnf: cnf_from_data(data),
nonce: Map.get(data, :nonce),
claims: Map.get(data, :claims, %{}),
expires_at: unix_to_datetime(Map.get(record, :expires_at)),
inserted_at: now
}
%__MODULE__{}
|> Ecto.put_meta(prefix: prefix)
|> cast(attrs, @required ++ @optional ++ [:inserted_at])
|> validate_required(@required ++ [:inserted_at])
|> validate_inclusion(:code_challenge_method, [@code_challenge_method_s256])
|> unique_constraint(:code_hash, name: :attesto_authorization_codes_code_hash_index)
end
@doc """
Rebuilds the `Attesto.CodeStore` record map from a loaded row.
The columns are folded back into the opaque grant `:data` map in exactly
the shape the protocol layer expects, and the
`:expires_at` `utc_datetime` is converted back to unix seconds. The
protocol layer re-checks expiry after taking the record, so a row that is
past `:expires_at` is still returned here and rejected downstream.
"""
@spec to_record(t()) :: store_record()
def to_record(%__MODULE__{} = row) do
%{
code_hash: row.code_hash,
data: %{
client_id: row.client_id,
subject: row.subject,
scope: row.scope || [],
redirect_uri: row.redirect_uri,
code_challenge: row.code_challenge,
code_challenge_method: row.code_challenge_method,
dpop_jkt: dpop_jkt_from_cnf(row.cnf),
nonce: row.nonce,
claims: row.claims || %{}
},
expires_at: datetime_to_unix(row.expires_at)
}
end
# RFC 7800: the `cnf` member carries the key the token (and here, the
# code) is bound to. RFC 9449 §6 names the DPoP thumbprint `jkt`; the
# store's grant `:data` carries it flat as `:dpop_jkt`, so promote it
# into a `cnf` map for column storage. A code with no binding stores no
# `cnf` (NULL), never an empty map, so "unbound" and "bound to nothing"
# cannot be confused.
defp cnf_from_data(data) do
case Map.get(data, :dpop_jkt) do
nil -> nil
jkt when is_binary(jkt) -> %{"jkt" => jkt}
end
end
# RFC 7636 §4.3: the challenge method is meaningful only when a challenge is
# present. A code issued without a challenge (PKCE relaxed for a confidential
# client; see Attesto.AuthorizationRequest's :require_pkce) persists with a
# NULL method, not a spurious "S256"; a code with a challenge defaults the
# method to S256 when the grant did not carry one (S256 is the only method
# accepted at issuance).
defp code_challenge_method_for(data) do
case Map.get(data, :code_challenge) do
nil -> nil
_challenge -> Map.get(data, :code_challenge_method, @code_challenge_method_s256)
end
end
defp dpop_jkt_from_cnf(nil), do: nil
defp dpop_jkt_from_cnf(%{"jkt" => jkt}) when is_binary(jkt), do: jkt
defp dpop_jkt_from_cnf(%{jkt: jkt}) when is_binary(jkt), do: jkt
defp dpop_jkt_from_cnf(_cnf), do: nil
defp unix_to_datetime(nil), do: nil
defp unix_to_datetime(seconds) when is_integer(seconds) do
DateTime.from_unix!(seconds, :second)
end
defp datetime_to_unix(nil), do: nil
defp datetime_to_unix(%DateTime{} = dt), do: DateTime.to_unix(dt, :second)
end