CIBA (decoupled auth)
CIBA is the OpenID Connect spec for flows where the device that initiates the authentication isn't the device that completes it. Canonical use cases: a call-center agent confirming a customer identity by pushing a prompt to the customer's phone; a TPM-less POS terminal asking the user's wallet to authorize a purchase; an IVR step-up that requires the customer's mobile-app fingerprint.
The relying party (RP): 1. POSTs auth_req_id request to the IdP's backchannel_authentication_endpoint with login_hint / login_hint_token / id_token_hint identifying the user, plus scope / acr_values / requested_expiry / binding_message. 2. Receives { auth_req_id, expires_in, interval }. 3. Waits for token delivery via the operator-chosen mode:
- **poll**: RP polls /token with grant_type= urn:openid:params:grant-type:ciba + auth_req_id every interval seconds; gets authorization_pending, slow_down, or the tokens. - **ping**: IdP POSTs { auth_req_id } to the RP's client_notification_endpoint; the RP's handler then calls /token to fetch. - **push**: IdP POSTs { auth_req_id, access_token, id_token, refresh_token, ... } directly. The client_notification_token registered with the IdP authenticates each callback.
This module provides:
b.auth.ciba.client.create({ ... }) .startAuthentication({ loginHint, scope, bindingMessage, ... }) .pollToken({ authReqId }) .receivePingNotification(req) // ping mode handler .receivePushNotification(req) // push mode handler
Composes b.auth.oauth for client_assertion / token-endpoint plumbing (so JWT-bearer client auth, mTLS client auth, and PAR alongside CIBA all share one set of audited credentials).
b.auth.ciba.client.create(opts) #
{
{
issuer: string, // OIDC issuer URL — required
clientId: string, // RP client_id — required
clientAuth: "secret"|"jwt"|"mtls", // token-endpoint auth
clientSecret?: string, // when clientAuth = "secret"
clientAssertionSigner?: fn(payload)→jwt, // when clientAuth = "jwt"
backchannelAuthenticationEndpoint?: string, // optional — discovered when omitted
tokenEndpoint?: string, // optional — discovered
scope?: string|string[],
deliveryMode: "poll"|"ping"|"push",
clientNotificationToken?: string, // fixed token RP mints once + registers with IdP
httpClientOpts?: object, // options merged INTO each request
http?: { client?: object, allowedHosts?: string[] }, // the client each request is made THROUGH, and its host pin
allowHttp?: boolean,
}
}
Build a CIBA-aware OIDC RP. Operators wire the resulting object's methods onto routes that drive the decoupled-auth flow.
var ciba = b.auth.ciba.client.create({
issuer: "https://idp.example.com",
clientId: "rp-1",
clientAuth: "secret",
clientSecret: process.env.CIBA_CLIENT_SECRET,
scope: ["openid", "profile"],
deliveryMode: "poll",
});
var ticket = await ciba.startAuthentication({
loginHint: "alice@example.com",
bindingMessage: "Authorize wire transfer of $4,200",
acrValues: ["urn:mace:incommon:iap:silver"],
});
// → { authReqId, expiresIn, interval }
var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
// → { accessToken, idToken, ... } once user approves
b.auth.ciba.client.startAuthentication(opts) #
{
{
loginHint?: string,
loginHintToken?: string,
idTokenHint?: string,
scope?: string|string[],
bindingMessage?: string,
acrValues?: string|string[],
requestedExpiry?: number,
userCode?: string,
}
}
POST to the IdP's backchannel_authentication_endpoint and return a ticket with authReqId + expiresIn + interval. At least one of loginHint / loginHintToken / idTokenHint must be supplied to identify the user.
var ticket = await ciba.startAuthentication({
loginHint: "alice@example.com",
bindingMessage: "Authorize wire transfer of $4,200",
});
// → { authReqId, expiresIn, interval }
b.auth.ciba.client.pollToken(opts) #
{
{ authReqId: string }
}
Poll the IdP's /token endpoint with grant_type=ciba. Returns the tokens once the user approves; throws AuthError with code auth-ciba/authorization_pending or auth-ciba/slow_down while waiting. Operators wrap with their preferred backoff. A slow_down error carries nextIntervalSec, the seconds to wait before the next poll.
Four codes end the request, and polling stops on any of them: auth-ciba/access_denied when the user denied the request, auth-ciba/expired_token when the authReqId expired, auth-ciba/invalid_grant when the IdP rejects the authReqId as unknown, already used or revoked, and auth-ciba/transaction_failed when the IdP reports an unexpected condition. The suffix on these is the IdP's own error value, and the parsed error body is on err.cibaError. An authReqId that is not a non-empty string raises auth-ciba/no-auth-req-id.
var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
// → { accessToken, idToken, refreshToken, tokenType, scope, expiresIn, raw }
b.auth.ciba.client.parseNotification(req, opts) #
{
{ body?: object } // pre-parsed body; defaults to req.body
}
Parse + authenticate an IdP-initiated callback to the RP's client_notification_endpoint. Validates the bearer client_notification_token (timing-safe equality) before surfacing the body. Use the returned authReqId to drive the RP-side flow:
- In **ping** mode the body is { auth_req_id }. Call pollToken({ authReqId }) afterwards. - In **push** mode the body carries the full token-response object; no follow-up call needed.
Async because a pushed id_token is verified (signature + iss/aud/exp) via the composed inner OAuth client before it is returned — the verified claims are surfaced as claims. A present-but-invalid id_token throws auth-ciba/id-token-invalid (the notification-token bearer authenticates the caller, never the token itself).
The call refuses before it reads the body when the request cannot be authenticated: auth-ciba/bad-notification-req for a req carrying no headers, auth-ciba/missing-bearer for an absent Authorization: Bearer, auth-ciba/bad-bearer for an empty bearer or no configured expected token, and auth-ciba/wrong-bearer when the presented client_notification_token does not match. Then auth-ciba/bad-notification-body when the body is a string that does not parse as JSON within the 64 KiB cap, auth-ciba/no-notification-body for a request the middleware left without a parsed body, and auth-ciba/no-auth-req-id-in-body when that body carries no auth_req_id.
app.post("/ciba/notify", async function (req, res) {
var info = await ciba.parseNotification(req, { body: req.body });
// → { authReqId, accessToken, idToken, claims, ... }
res.statusCode = 204; res.end();
});