An OAuth 2.0 authorization server for public clients, as a use macro.
Two grants, and only these two:
- authorization code + PKCE (S256) for browser apps on another origin. There is no client secret; the exact-match redirect-URI allowlist and the PKCE verifier are what bind a code to the app that started the flow.
- device authorization (RFC 8628 shape) for a CLI that cannot hold
a password: a high-entropy device code the polling machine keeps and
a short
XXXX-XXXXuser code a human types into the host's approval page.
This library owns the grant state machine and nothing about who the user
is or what a token is. The host mints the token, decides whether a
subject may hold one, and writes the audit trail, through the four
callbacks of Managoat.OAuth.Host. That split is what keeps it small
enough to be worth having beside Boruta.
Defining an instance
In a module of the host's own (MyApp.OAuth, say):
use Managoat.OAuth, otp_app: :my_app, host: MyApp.OAuth.Host
# config/config.exs
config :my_app, MyApp.OAuth,
repo: MyApp.Repo,
clients: [%{id: "my-spa", name: "My SPA", redirect_uris: ["https://spa.example/"]}]The instance reads repo:, clients: and an optional prefix: from the
host's own otp_app under its own module name, the way an Ecto.Repo
does, so the library never reads configuration that is not its own.
There is no default repo: Managoat.OAuth.Config raises a message naming
the key. clients may be atom- or string-keyed maps (a JSON registry
decodes straight into it) and defaults to none, which refuses every
authorization request.
The tables
Two tables, oauth_authorization_codes and oauth_device_grants, created
by Managoat.OAuth.Migration from a migration of the host's own whose
up/0 calls Managoat.OAuth.Migration.up/1 and whose down/0 calls
Managoat.OAuth.Migration.down/1 (the README shows one).
The subject column is user_id by default and has no foreign key; a host
that wants one adds it in the same migration.
The functions an instance gets
Every function below is generated on the instance module, with the
arities shown. subject is an opaque binary the host understands (a user
id); the library stores it and hands it back, never joins it.
clients/0,redirect_origins/0,get_client/1validate_request/1,authorize/2,3,exchange/1,2,pkce_verify/2start_device_grant/0,format_user_code/1,normalize_user_code/1,get_device_grant_for_approval/1,approve_device_grant/2,3,deny_device_grant/2,3,poll_device_grant/1,2prune_expired/0,token_ttl_seconds/0,device_interval_seconds/0
The opts keyword on the mutating functions is passed through to the
host untouched, so a host can carry attribution (actor, request_ip)
from its web layer to its audit trail without the library knowing what
those keys mean.
The two orderings the state machine keeps
A grant is consumed only if the host accepts the subject and issues the token, in this precise sense:
poll_device_grant/2asksManagoat.OAuth.Host.subject_allowed?/1before marking the grant used; a refused subject leaves the grant approved and unconsumed, and the poll answers{:error, :access_denied}.exchange/2marks the code used before callingManagoat.OAuth.Host.issue_token/3; a failed mint answers{:error, :server_error}with the code consumed. A code is proof of one consent, and a retry after a server-side failure is a second consent's job.
Summary
Functions
The signed-in subject approves the grant behind a typed user code: binds
the subject to it so the next poll mints a token. Conditional on the grant
still being pending and unexpired, so approve/deny/expiry cannot race.
Calls the host's audit/3 with :device_approved.
The subject consented: issue a code for this request. Returns
{:ok, raw_code}; the raw code goes to the redirect and is never stored,
only its hash is. Calls the host's audit/3 with :authorized.
The instance's registered public clients, normalised.
The signed-in subject denies the grant: the poller gets access_denied.
Calls the host's audit/3 with :device_denied.
Seconds a device-grant poller must wait between polls.
Exchange a code for a token. params is the token request: code,
code_verifier, client_id, redirect_uri. Returns
{:ok, %{access_token: token, expires_in: seconds, api_key: host_token}},
{:error, :invalid_grant} for every way a grant can be wrong (unknown,
used, expired, wrong client, wrong redirect, wrong verifier), or
{:error, :server_error} when the host could not mint — with the code
consumed, see the moduledoc.
Format a stored user code for humans: "BCDFGHJK" → "BCDF-GHJK".
The client with id, or nil.
The pending, unexpired grant for a typed user code — what an approval page
shows before the subject decides. {:ok, grant} or {:error, :not_found}
(one answer for unknown, expired and decided alike).
Normalize what a human typed: case, the display dash, stray spaces.
S256: base64url(sha256(verifier)) == challenge, constant-time.
The poller asks with its device code. One of
Delete codes and device grants past their expiry (a sweep). Returns the count.
The distinct origins (scheme://host[:port]) of every registered client's
redirect URIs — what a consent page's form-action CSP must allow, since
a successful consent POST redirects the browser to the app's origin.
Start a device grant. Returns {:ok, %{device_code, user_code, expires_in, interval}}: device_code is raw (only its hash is stored) and stays on
the polling machine; user_code is formatted XXXX-XXXX for a human to
type. Deliberately unaudited: nothing has happened to any subject yet.
Seconds a token minted by exchange/3 lives.
Validate an authorization request's identity part — the bits that decide
whether the host may redirect at all. {:ok, client} or {:error, reason}:
:unknown_client, :redirect_uri_mismatch, :invalid_code_challenge,
:unsupported_code_challenge_method.
Functions
@spec approve_device_grant(Managoat.OAuth.Config.t(), String.t(), binary(), keyword()) :: :ok | {:error, :not_found}
The signed-in subject approves the grant behind a typed user code: binds
the subject to it so the next poll mints a token. Conditional on the grant
still being pending and unexpired, so approve/deny/expiry cannot race.
Calls the host's audit/3 with :device_approved.
@spec authorize(Managoat.OAuth.Config.t(), binary(), map(), keyword()) :: {:ok, String.t()} | {:error, atom() | Ecto.Changeset.t()}
The subject consented: issue a code for this request. Returns
{:ok, raw_code}; the raw code goes to the redirect and is never stored,
only its hash is. Calls the host's audit/3 with :authorized.
@spec clients(Managoat.OAuth.Config.t()) :: [Managoat.OAuth.Clients.client()]
The instance's registered public clients, normalised.
@spec deny_device_grant(Managoat.OAuth.Config.t(), String.t(), binary(), keyword()) :: :ok | {:error, :not_found}
The signed-in subject denies the grant: the poller gets access_denied.
Calls the host's audit/3 with :device_denied.
@spec device_interval_seconds() :: pos_integer()
Seconds a device-grant poller must wait between polls.
@spec exchange(Managoat.OAuth.Config.t(), map(), keyword()) :: {:ok, %{access_token: String.t(), expires_in: pos_integer(), api_key: term()}} | {:error, :invalid_grant | :server_error}
Exchange a code for a token. params is the token request: code,
code_verifier, client_id, redirect_uri. Returns
{:ok, %{access_token: token, expires_in: seconds, api_key: host_token}},
{:error, :invalid_grant} for every way a grant can be wrong (unknown,
used, expired, wrong client, wrong redirect, wrong verifier), or
{:error, :server_error} when the host could not mint — with the code
consumed, see the moduledoc.
Format a stored user code for humans: "BCDFGHJK" → "BCDF-GHJK".
@spec get_client(Managoat.OAuth.Config.t(), term()) :: Managoat.OAuth.Clients.client() | nil
The client with id, or nil.
@spec get_device_grant_for_approval(Managoat.OAuth.Config.t(), String.t()) :: {:ok, Managoat.OAuth.DeviceGrant.t()} | {:error, :not_found}
The pending, unexpired grant for a typed user code — what an approval page
shows before the subject decides. {:ok, grant} or {:error, :not_found}
(one answer for unknown, expired and decided alike).
Normalize what a human typed: case, the display dash, stray spaces.
S256: base64url(sha256(verifier)) == challenge, constant-time.
@spec poll_device_grant(Managoat.OAuth.Config.t(), String.t(), keyword()) :: {:ok, %{access_token: String.t(), api_key: term()}} | {:error, :authorization_pending | :slow_down | :access_denied | :expired_token | :invalid_grant | :server_error}
The poller asks with its device code. One of:
{:ok, %{access_token, api_key}}— approved, the subject accepted by the host, the grant consumed and a token minted (once: the conditional update that marks the grant used means two concurrent polls cannot both win){:error, :authorization_pending}— nobody has decided yet{:error, :slow_down}— polled faster than the advertised interval{:error, :access_denied}— denied, or the host refused the subject (the grant stays approved and unconsumed){:error, :expired_token}— the grant timed out{:error, :invalid_grant}— unknown or already-consumed code{:error, :server_error}— the host could not mint
@spec prune_expired(Managoat.OAuth.Config.t()) :: non_neg_integer()
Delete codes and device grants past their expiry (a sweep). Returns the count.
@spec redirect_origins(Managoat.OAuth.Config.t()) :: [String.t()]
The distinct origins (scheme://host[:port]) of every registered client's
redirect URIs — what a consent page's form-action CSP must allow, since
a successful consent POST redirects the browser to the app's origin.
@spec start_device_grant(Managoat.OAuth.Config.t()) :: {:ok, %{ device_code: String.t(), user_code: String.t(), expires_in: pos_integer(), interval: pos_integer() }} | {:error, :server_error}
Start a device grant. Returns {:ok, %{device_code, user_code, expires_in, interval}}: device_code is raw (only its hash is stored) and stays on
the polling machine; user_code is formatted XXXX-XXXX for a human to
type. Deliberately unaudited: nothing has happened to any subject yet.
@spec token_ttl_seconds() :: pos_integer()
Seconds a token minted by exchange/3 lives.
@spec validate_request(Managoat.OAuth.Config.t(), map()) :: {:ok, Managoat.OAuth.Clients.client()} | {:error, atom()}
Validate an authorization request's identity part — the bits that decide
whether the host may redirect at all. {:ok, client} or {:error, reason}:
:unknown_client, :redirect_uri_mismatch, :invalid_code_challenge,
:unsupported_code_challenge_method.
An error here must render, never redirect: a redirect to an unregistered URI is exactly the open redirector the allowlist exists to prevent.