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) #

stable0.8.62
{
  {
    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,
    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) #

0.8.62
{
  {
    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) #

0.8.62
{
  { 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.

var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
// → { accessToken, idToken, refreshToken, tokenType, scope, expiresIn, raw }

b.auth.ciba.client.parseNotification(req, opts) #

0.8.62
{
  { 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).

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();
});

Last updated 2026-08-08T16:39:49.652Z by seeder.