Managoat.OAuth (managoat_oauth v0.1.1)

Copy Markdown View Source

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-XXXX user 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.

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:

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

approve_device_grant(config, input, subject, opts \\ [])

@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.

authorize(config, subject, params, opts \\ [])

@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.

clients(config)

The instance's registered public clients, normalised.

deny_device_grant(config, input, subject, opts \\ [])

@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.

device_interval_seconds()

@spec device_interval_seconds() :: pos_integer()

Seconds a device-grant poller must wait between polls.

exchange(config, params, opts \\ [])

@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_user_code(code)

@spec format_user_code(String.t()) :: String.t()

Format a stored user code for humans: "BCDFGHJK" → "BCDF-GHJK".

get_client(config, id)

@spec get_client(Managoat.OAuth.Config.t(), term()) ::
  Managoat.OAuth.Clients.client() | nil

The client with id, or nil.

get_device_grant_for_approval(config, input)

@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_user_code(input)

@spec normalize_user_code(String.t()) :: String.t()

Normalize what a human typed: case, the display dash, stray spaces.

pkce_verify(verifier, challenge)

@spec pkce_verify(term(), term()) :: boolean()

S256: base64url(sha256(verifier)) == challenge, constant-time.

poll_device_grant(config, device_code, opts \\ [])

@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

prune_expired(config)

@spec prune_expired(Managoat.OAuth.Config.t()) :: non_neg_integer()

Delete codes and device grants past their expiry (a sweep). Returns the count.

redirect_origins(config)

@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.

start_device_grant(config)

@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.

token_ttl_seconds()

@spec token_ttl_seconds() :: pos_integer()

Seconds a token minted by exchange/3 lives.

validate_request(config, params)

@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.