Attesto.CIBA (Attesto v1.2.1)

Copy Markdown View Source

OpenID Connect Client-Initiated Backchannel Authentication (CIBA Core 1.0) - the conn-free core.

CIBA is the device grant's sibling: an asynchronous grant where the client never redirects the user's browser. The client POSTs a backchannel authentication request naming the end-user through a hint, the OP authenticates the user out-of-band on their authentication device, and the client obtains tokens from the token endpoint with the "urn:openid:params:grant-type:ciba" grant - by polling (poll mode) or after an OP → client notification (ping mode).

Like Attesto.DeviceCode, every decision here is data-only: this module reads no Plug.Conn, no clock except the passed :now, and drives all state through an Attesto.CIBAStore. The endpoint flow decomposes as:

  1. Validate - Attesto.CIBA.Request.validate/3 checks the wire parameters (or the signed authentication request JWT) against the client's registered CIBA metadata.
  2. Resolve - the host resolves the request's hint to an end-user (unknown_user_id / expired_login_hint_token on failure) and verifies any user_code (missing_user_code / invalid_user_code).
  3. Issue - issue/4 mints the auth_req_id (256-bit CSPRNG, above the §7.3 recommended 160 bits; only its hash is stored) and creates the pending record. The host then starts the out-of-band authentication.
  4. Decide - approve/4 / deny/3 record the user's decision, returning the data a ping-mode host needs to deliver the §10.2 notification (which fires on approval AND denial).
  5. Redeem - redeem/4 runs the token-endpoint state machine with the exact §11 error vocabulary (authorization_pending / slow_down / expired_token / access_denied / invalid_grant), consuming the request single-use and yielding an Attesto.CIBA.Grant for the host's usual token-minting path.

Delivery modes

Poll and ping records both enforce the §7.3 minimum token-request interval frozen into the record at issue time (§10.2: a ping client that polls "must be treated as if it had been registered to use the Poll mode"). Push mode is accepted at validation for completeness - the token response would be delivered by the host over its own transport - but FAPI-CIBA §5.2.1 forbids push mode; a FAPI deployment simply never registers a push client.

Summary

Types

What approve/4 / deny/3 return: the data the host needs to deliver the ping-mode notification (CIBA Core §10.2) - an HTTP POST to the client's registered backchannel_client_notification_endpoint carrying Authorization: Bearer <client_notification_token> and the JSON body {"auth_req_id": ...}. client_notification_token is nil for poll mode (no notification is sent).

The full CIBA error taxonomy. Backchannel authentication endpoint errors (§13) come from Attesto.CIBA.Request.validate/3, from the host's hint / user-code resolution, or from client authentication; token endpoint errors (§11) come from redeem/4.

What issue/4 hands back: the §7.3 authentication request acknowledgement.

Functions

Record a successful end-user authentication + consent for a pending authentication request (CIBA Core §10: the "authentication result is ready" transition).

Record a denial (the user refused, failed authentication, or the OP denied) for a pending authentication request: atomic pendingdenied, so the client's next token request receives access_denied (§11).

The HTTP status a backchannel authentication endpoint error is rendered with (CIBA Core §13): 401 for invalid_client, 403 for access_denied, 400 for everything else. Token-endpoint errors (§11) are all 400 per RFC 6749 §5.2 and do not use this mapping (access_denied is 400 there).

The CIBA grant type URN (CIBA Core §4 / §10.1), for token-endpoint dispatch and grant_types_supported metadata.

Issue an auth_req_id for a validated authentication request whose hint the host has resolved to an end-user (CIBA Core §7.3).

Non-consuming lookup of an authentication request, for the authentication-device UI to show the user what they are approving (client, scope, binding_message) and for the host to route the flow. Returns {:error, :invalid_auth_req_id} for malformed input (before any store call) and :error for an unknown id.

Redeem an auth_req_id at the token endpoint (grant_type=urn:openid:params:grant-type:ciba), running the CIBA Core §10.1/§11 state machine.

Validate a backchannel authentication request's wire parameters against the authenticated client's registered CIBA metadata. See Attesto.CIBA.Request.validate/3 for the parameter/option contract.

Types

decision()

@type decision() :: %{
  client_id: String.t(),
  delivery_mode: Attesto.CIBA.Request.delivery_mode(),
  client_notification_token: String.t() | nil
}

What approve/4 / deny/3 return: the data the host needs to deliver the ping-mode notification (CIBA Core §10.2) - an HTTP POST to the client's registered backchannel_client_notification_endpoint carrying Authorization: Bearer <client_notification_token> and the JSON body {"auth_req_id": ...}. client_notification_token is nil for poll mode (no notification is sent).

error()

@type error() ::
  :invalid_request
  | :invalid_scope
  | :expired_login_hint_token
  | :unknown_user_id
  | :unauthorized_client
  | :missing_user_code
  | :invalid_user_code
  | :invalid_binding_message
  | :invalid_client
  | :access_denied
  | :expired_token
  | :authorization_pending
  | :slow_down
  | :invalid_grant

The full CIBA error taxonomy. Backchannel authentication endpoint errors (§13) come from Attesto.CIBA.Request.validate/3, from the host's hint / user-code resolution, or from client authentication; token endpoint errors (§11) come from redeem/4.

issue_attrs()

@type issue_attrs() :: %{
  :subject => String.t(),
  optional(:resource) => [String.t()],
  optional(:dpop_jkt) => String.t() | nil
}

issued()

@type issued() :: %{
  auth_req_id: String.t(),
  expires_in: pos_integer(),
  interval: pos_integer() | nil
}

What issue/4 hands back: the §7.3 authentication request acknowledgement.

redeem_error()

@type redeem_error() ::
  :authorization_pending
  | :slow_down
  | :expired_token
  | :access_denied
  | :unauthorized_client
  | :invalid_grant

Functions

approve(store, auth_req_id, approval, opts \\ [])

@spec approve(module(), String.t(), map(), keyword()) ::
  {:ok, decision()}
  | {:error,
     :not_found
     | :already_decided
     | :expired
     | :invalid_auth_req_id
     | :invalid_subject
     | :subject_mismatch}

Record a successful end-user authentication + consent for a pending authentication request (CIBA Core §10: the "authentication result is ready" transition).

approval carries :subject (required - and it MUST be the same end-user the request was issued for; approving as anyone else fails with :subject_mismatch, fail-closed), and optionally :acr (the satisfied Authentication Context Class Reference; FAPI-CIBA §5.2.2 requires returning acr when the client requested one), :scope (what the user actually granted), :claims, and :auth_time (unix seconds, default :now).

Atomic pendingapproved. Returns {:ok, decision} with the ping-mode notification data (§10.2 - the notification fires on approval and denial alike), or {:error, reason} (:not_found / :already_decided / :expired / :invalid_auth_req_id / :invalid_subject / :subject_mismatch).

deny(store, auth_req_id, opts \\ [])

@spec deny(module(), String.t(), keyword()) ::
  {:ok, decision()}
  | {:error, :not_found | :already_decided | :expired | :invalid_auth_req_id}

Record a denial (the user refused, failed authentication, or the OP denied) for a pending authentication request: atomic pendingdenied, so the client's next token request receives access_denied (§11).

Returns {:ok, decision} with the ping-mode notification data - the §10.2 notification is sent for denials too - or {:error, reason}.

error_status(error)

@spec error_status(error()) :: 400 | 401 | 403

The HTTP status a backchannel authentication endpoint error is rendered with (CIBA Core §13): 401 for invalid_client, 403 for access_denied, 400 for everything else. Token-endpoint errors (§11) are all 400 per RFC 6749 §5.2 and do not use this mapping (access_denied is 400 there).

grant_type()

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

The CIBA grant type URN (CIBA Core §4 / §10.1), for token-endpoint dispatch and grant_types_supported metadata.

issue(store, request, attrs, opts \\ [])

@spec issue(module(), Attesto.CIBA.Request.t(), issue_attrs(), keyword()) ::
  {:ok, issued()} | {:error, :invalid_subject}

Issue an auth_req_id for a validated authentication request whose hint the host has resolved to an end-user (CIBA Core §7.3).

attrs carries what only the host knows: :subject (required - §7.1 requires the user to be identified BEFORE the acknowledgement is returned; a hint that resolves to no user is the host's unknown_user_id), and the optional :resource (RFC 8707) / :dpop_jkt (RFC 9449 §10 pre-binding).

Options: :expires_in (seconds, default 120 per FAPI-CIBA's short-lifetime guidance), :max_expires_in (cap applied to the client's requested_expiry, default 600), :interval (the §7.3 minimum token-request interval, default 5), and :now.

Returns {:ok, %{auth_req_id: ..., expires_in: ..., interval: ...}} - the §7.3 acknowledgement fields. interval is nil for a push-mode request (there is no token-endpoint polling to pace); the caller omits it from the JSON response.

lookup(store, auth_req_id)

@spec lookup(module(), String.t()) ::
  {:ok, map()} | :error | {:error, :invalid_auth_req_id}

Non-consuming lookup of an authentication request, for the authentication-device UI to show the user what they are approving (client, scope, binding_message) and for the host to route the flow. Returns {:error, :invalid_auth_req_id} for malformed input (before any store call) and :error for an unknown id.

redeem(store, auth_req_id, params, opts \\ [])

@spec redeem(module(), String.t(), map(), keyword()) ::
  {:ok, Attesto.CIBA.Grant.t()} | {:error, redeem_error()}

Redeem an auth_req_id at the token endpoint (grant_type=urn:openid:params:grant-type:ciba), running the CIBA Core §10.1/§11 state machine.

params carries the requesting client's :client_id (matched against the issue-time binding) and any :dpop_jkt (RFC 9449 holder-of-key, matched against a pre-bound key). Option: :now.

Returns {:ok, %Attesto.CIBA.Grant{}} once the user has authenticated and the request is single-use consumed, or {:error, reason} where reason is the exact §11 code the token endpoint renders verbatim:

  • :authorization_pending - the user has not yet been authenticated.
  • :slow_down - the client polled faster than the interval it was told at issuance (the client must add >=5s to its interval, §11).
  • :expired_token - the auth_req_id's lifetime elapsed (this wins over a stale approval).
  • :access_denied - the user denied the request (or the OP did).
  • :unauthorized_client - the request was registered for push-mode delivery, which (CIBA Core §11) MUST NOT be redeemed at the token endpoint; the token comes over the host's push transport instead.
  • :invalid_grant - unknown/garbage auth_req_id, a client mismatch ("issued to another Client", §11), DPoP mismatch, or an already-consumed request.

All record validation - expiry, client binding, DPoP binding, push-mode rejection, and terminal status - runs on a NON-mutating read BEFORE any poll-interval throttling. slow_down is a variant of authorization_pending (§11): it is returned only when the request is genuinely still pending, so it can never mask an expired, denied, approved, consumed, wrong-client, wrong-DPoP, or push-mode outcome, and an unauthorized caller can never mutate another client's throttle state.

validate_request(client, params, opts \\ [])

@spec validate_request(Attesto.CIBA.Request.client(), map(), keyword()) ::
  {:ok, Attesto.CIBA.Request.t()} | {:error, Attesto.CIBA.Request.error()}

Validate a backchannel authentication request's wire parameters against the authenticated client's registered CIBA metadata. See Attesto.CIBA.Request.validate/3 for the parameter/option contract.