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:
- Validate -
Attesto.CIBA.Request.validate/3checks the wire parameters (or the signed authentication request JWT) against the client's registered CIBA metadata. - Resolve - the host resolves the request's hint to an end-user
(
unknown_user_id/expired_login_hint_tokenon failure) and verifies anyuser_code(missing_user_code/invalid_user_code). - Issue -
issue/4mints theauth_req_id(256-bit CSPRNG, above the §7.3 recommended 160 bits; only its hash is stored) and creates thependingrecord. The host then starts the out-of-band authentication. - Decide -
approve/4/deny/3record the user's decision, returning the data a ping-mode host needs to deliver the §10.2 notification (which fires on approval AND denial). - Redeem -
redeem/4runs 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 anAttesto.CIBA.Grantfor 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.
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 pending → denied, 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
@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).
@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.
@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.
@type redeem_error() ::
:authorization_pending
| :slow_down
| :expired_token
| :access_denied
| :unauthorized_client
| :invalid_grant
Functions
@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 pending → approved. 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).
@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 pending → denied, 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}.
@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).
@spec grant_type() :: String.t()
The CIBA grant type URN (CIBA Core §4 / §10.1), for token-endpoint dispatch
and grant_types_supported metadata.
@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.
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.
@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- theauth_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/garbageauth_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.
@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.