---
name: ciba
description: >
  USE FOR anything touching CIBA backchannel auth — cibaService.js,
  cibaEnhanced.js, routes/ciba.js, CIBAPanel.js UI, CIBA grant type
  (urn:openid:params:grant-type:ciba), auth_req_id polling,
  binding_message, login_hint, acr_values for DaVinci, and
  CIBA-gated step-up for transfers.
  DO NOT USE FOR: OTP/device MFA enrollment (use pingone-mfa);
  HITL consent challenge mechanics (use hitl-consent);
  general OAuth PKCE/auth-code flows (use oauth-pingone).
argument-hint: "Describe the CIBA task — e.g. 'add step-up CIBA before transfers', 'debug polling loop', 'wire acr_values for DaVinci'"
---

# CIBA — Client-Initiated Backchannel Authentication

## When to Use

- Implementing or debugging CIBA backchannel authentication (`cibaService.js`, `cibaEnhanced.js`, `routes/ciba.js`)
- Working with `CIBAPanel.js` UI, CIBA grant type (`urn:openid:params:grant-type:ciba`), or `auth_req_id` polling
- Configuring `binding_message`, `login_hint`, or `acr_values` for DaVinci-based CIBA
- Implementing CIBA-gated step-up approval for transfers

## When NOT to Use

- OTP/device MFA enrollment (SMS, Email, TOTP, FIDO2) — use `pingone-mfa` instead
- HITL consent challenge mechanics (428 gating, `consentChallengeId`) — use `hitl-consent` instead
- General OAuth PKCE/auth-code flows — use `oauth-pingone` instead

## What CIBA is

CIBA (OIDC CIBA Core 1.0) lets the server trigger an authentication or
consent challenge against a user **without any browser redirect**. The flow is
purely server-to-server between the BFF and PingOne; the only user interaction
is out-of-band — PingOne delivers an approval step via email link or device push,
depending on what the DaVinci flow or PingOne MFA policy is configured to do.

This makes CIBA the right choice whenever a browser redirect would break the
user experience: inside an MCP agent chat, during a high-value transfer, or
anywhere a "Check your email or device" overlay is preferable to a page
reload.

### The three-step CIBA flow

```
1. INITIATE
   BFF  →  POST https://auth.pingone.{region}/{envId}/as/bc-authorize
           body: login_hint, scope, binding_message, [acr_values]
           auth: Basic (admin client_id:client_secret)
   PingOne → { auth_req_id, expires_in, interval }
   BFF stores auth_req_id in express-session.

2. OUT-OF-BAND APPROVAL
   PingOne sends email link or push to the user (DaVinci / MFA policy).
   User approves.  BFF is not involved at this step.

3. POLL / TOKEN RETRIEVAL
   BFF  →  POST .../as/token
           grant_type=urn:openid:params:grant-type:ciba
           auth_req_id=<id>
           auth: Basic (admin client_id:client_secret)
   While waiting: PingOne returns { error: 'authorization_pending' }
   On approval:   PingOne returns { access_token, id_token, refresh_token, ... }
   On denial:     PingOne returns { error: 'access_denied' }
```

---

## PingOne CIBA endpoint

```
POST https://auth.pingone.{region}/{envId}/as/bc-authorize
```

Built at runtime in `demo_api_server/config/oauth.js`:

```js
get cibaEndpoint() { return `${this._base}/bc-authorize`; }
// _base = `https://auth.pingone.${region}/${environmentId}/as`
```

### Request body (application/x-www-form-urlencoded)

| Parameter | Required | Notes |
|-----------|----------|-------|
| `login_hint` | yes | User's email address or PingOne `sub` |
| `scope` | yes | Space-separated scopes; defaults to `PINGONE_OIDC_DEFAULT_SCOPES_SPACE` |
| `binding_message` | yes | Short human-readable string shown in the approval prompt (max 256 chars in this repo) |
| `acr_values` | optional | ACR for step-up, e.g. `Multi_factor` or a DaVinci policy ID |
| `client_notification_token` | ping mode only | 64-byte hex token; generated by `cibaService._generateNotificationToken()` |
| `client_notification_endpoint` | ping mode only | URL PingOne POSTs to on approval |

### Success response

```json
{ "auth_req_id": "<opaque string>", "expires_in": 300, "interval": 5 }
```

`expires_in` and `interval` are used by the polling loop; the service falls
back to `300` / `5` if PingOne omits them.

### Authentication

`Authorization: Basic base64(client_id:client_secret)` using the **admin** client
credentials (`admin_client_id` / `admin_client_secret` from configStore).

---

## The three exported functions — cibaService.js

### `initiateBackchannelAuth(loginHint, bindingMessage, scope, acrValues)`

Sends `POST .../bc-authorize`.

- `loginHint` — user's email or sub
- `bindingMessage` — falls back to `configStore.getEffective('ciba_binding_message')` then `'Banking App Authentication'`
- `scope` — defaults to `PINGONE_OIDC_DEFAULT_SCOPES_SPACE`
- `acrValues` — optional; omitted from the request body when empty

Delivery mode is read from `configStore.getEffective('ciba_token_delivery_mode')` (default `'poll'`). When mode is `'ping'`, a `client_notification_token` is added to the request body; `client_notification_endpoint` is also added if `ciba_notification_endpoint` is set in configStore.

Returns `{ auth_req_id, expires_in, interval }`.

Logs `auth_lifecycle` events via `appEventService` on initiation and on `auth_req_id` receipt.

---

### `pollForTokens(authReqId)`

Single poll — sends `POST .../as/token` with
`grant_type=urn:openid:params:grant-type:ciba` and `auth_req_id`.

Returns the full token response `{ access_token, id_token, refresh_token, token_type, expires_in }` on success.

Throws with `err.response.data.error === 'authorization_pending'` while the
user has not yet acted. The caller is responsible for deciding whether to retry.

---

### `waitForApproval(authReqId, intervalSeconds = 5, maxAttempts = 60)`

Polling loop. Calls `_sleep(interval * 1000)` **before** each poll attempt.

Default ceiling: 60 attempts × 5 s = 5 minutes.

| PingOne error code | Behavior |
|--------------------|----------|
| `authorization_pending` | continue looping (user has not tapped yet) |
| `slow_down` | increase `interval` by 5 s, cap at 30 s, continue looping |
| `access_denied` | log `ciba/denied`, rethrow immediately |
| `expired_token` | log `ciba/denied`, rethrow immediately |
| `invalid_grant` | log `ciba/denied`, rethrow immediately |
| loop exhausted | log `ciba/timeout`, throw `'CIBA authentication timed out'` |

Logs `ciba/tokens-received` (with decoded JWT) on success.

---

### `isEnabled()`

```js
function isEnabled() {
  const envFlag = process.env.CIBA_ENABLED;
  if (envFlag !== undefined) return envFlag === 'true';
  return configStore.getEffective('ciba_enabled') === 'true';
}
```

`CIBA_ENABLED` env var takes precedence over the configStore flag. Both must
be the string `'true'` (not `true` or `1`). When disabled, the route handlers
return `503 { error: 'ciba_disabled' }`.

---

## cibaEnhanced.js — production-grade wrapper

`demo_api_server/services/cibaEnhanced.js` wraps `cibaService` with two
additional capabilities and re-exports all base functions.

### `initiateBackchannelAuthWithRetry(loginHint, bindingMessage, scope, acrValues, maxRetries = 3)`

Delegates to `cibaService.initiateBackchannelAuth`. On failure, retries up to
`maxRetries` times **only for transient/retryable errors**:

- Network errors: `ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`
- HTTP 5xx
- HTTP 429 (rate limit)

Non-retryable errors (4xx client errors, `access_denied`, etc.) throw immediately.
Backoff between retries: `min(1000 * 2^(attempt-1), 5000)` ms (exponential, capped at 5 s).
Throws an enhanced error with `.code = 'CIBA_INITIATION_FAILED'` on final failure.

### `pollWithStatus(authReqId, onStatusUpdate)`

Polling loop with a real-time callback. `onStatusUpdate` is called on every
iteration with `{ status, pollCount, elapsedSeconds, message? }`.

Status values emitted to the callback:

| `status` | When |
|----------|------|
| `'pending'` | `authorization_pending` from PingOne |
| `'slow_down'` | `slow_down` from PingOne; new `interval` is also reported |
| `'approved'` | tokens received |
| `'denied'` | `access_denied` from PingOne |
| `'expired'` | `expired_token` from PingOne |
| `'timeout'` | loop exhausted without approval |

Throws an enhanced error (`.code`, `.originalError`, `.statusCode`, `.errorData`)
on any terminal failure.

### `CIBAErrorType` enum

```js
{
  AUTHORIZATION_PENDING: 'authorization_pending',
  SLOW_DOWN: 'slow_down',
  ACCESS_DENIED: 'access_denied',
  EXPIRED_TOKEN: 'expired_token',
  INVALID_REQUEST: 'invalid_request',
  TIMEOUT: 'timeout',
  NETWORK_ERROR: 'network_error'
}
```

### `getUserFriendlyErrorMessage(error)`

Maps error codes to user-facing strings. Check this before displaying
error text in route handlers or the UI.

---

## Routes — routes/ciba.js

All routes are mounted under `/api/auth/ciba`.

### `GET /api/auth/ciba/status` — no auth required

Returns:

```json
{
  "enabled": true,
  "deliveryMode": "poll",
  "bindingMessage": "Banking App Authentication",
  "setupRequired": false,
  "setupSteps": []
}
```

When `setupRequired: true`, `setupSteps` contains the three-step PingOne setup
guide (grant type, token delivery mode, DaVinci/email config, `CIBA_ENABLED=true`).

---

### `POST /api/auth/ciba/initiate` — requires `authenticateToken`

Request body (all optional except when login_hint is needed and not in session):

```json
{
  "login_hint": "user@example.com",
  "scope": "openid profile email",
  "binding_message": "Approve $500 transfer",
  "acr_values": "Multi_factor"
}
```

`login_hint` is resolved in order:
1. `req.body.login_hint`
2. `req.user?.email`
3. `req.session?.oauthUser?.email`

`binding_message` is validated: must be a string, max 256 chars; control
characters are stripped to prevent log injection.

Session tracking: the new `auth_req_id` is stored in
`req.session.cibaRequests[auth_req_id]` (with `initiatedAt`, `expiresAt`,
`loginHint`, `scope`, `acr_values`, `binding_message`). Expired entries are
pruned on each initiation.

Returns:

```json
{
  "auth_req_id": "...",
  "expires_in": 300,
  "interval": 5,
  "login_hint_display": "jo***@example.com"
}
```

On failure: `502 { error, message }` — includes PingOne's own error code/description
when available.

---

### `GET /api/auth/ciba/poll/:authReqId` — requires `authenticateToken`

Calls `cibaService.pollForTokens(authReqId)`. The `authReqId` must exist in
`req.session.cibaRequests` (ownership check) and must not be past `expiresAt`.

On approval:
- Tokens are written to `req.session.oauthTokens` (BFF pattern — tokens never
  sent to browser). `grantedVia: 'ciba'` is recorded.
- `req.session.stepUpVerified` is set to `Date.now() + 5 * 60 * 1000` (5-min
  step-up validity window).
- Returns `{ status: 'approved', scope }`.

While waiting: returns `{ status: 'pending' }`.

On `slow_down`: returns `{ status: 'pending', slow_down: true, retry_after: 10 }`.

On denial or unknown error: `403 { status: 'denied', error, message }`.

On expiry (local check): `410 { error: 'request_expired', message }`.

---

### `POST /api/auth/ciba/cancel/:authReqId` — requires `authenticateToken`

Removes `authReqId` from `req.session.cibaRequests`. Returns `{ ok: true }`.

---

### `POST /api/auth/ciba/notify` — no auth (PingOne callback)

Ping-mode callback. PingOne POSTs here with the `client_notification_token` in
`Authorization: Bearer` and `auth_req_id` in the body. Currently acknowledges
with `204`. Full ping-mode support requires shared state (Redis) because
PingOne may hit any server instance. Poll mode is fully functional without this.

---

## Delivery modes

| Mode | Behavior |
|------|----------|
| `poll` (default) | BFF polls PingOne token endpoint at `interval`-second intervals until approval, denial, or timeout. No server-side callback endpoint needed. |
| `ping` | PingOne calls `POST /api/auth/ciba/notify` on approval. Requires shared session store (Redis/Upstash) across instances; `client_notification_token` is included in the bc-authorize request. |

Configured via `configStore.getEffective('ciba_token_delivery_mode')`.

---

## When CIBA is used in this repo

### High-value transfer step-up

`runtimeSettings.js` has `stepUpMethod: process.env.STEP_UP_METHOD || 'email'`.
When `stepUpMethod` is set to `'ciba'`, the transfer gate initiates a CIBA
challenge instead of an OIDC redirect. The `STEP_UP_TTL_MS` constant in
`routes/ciba.js` is `5 * 60 * 1000` ms — after a successful CIBA poll,
`req.session.stepUpVerified` is valid for 5 minutes.

Thresholds (from `transactionConsentChallenge.js`):
- `confirm_threshold_usd` (configStore) — default $250 — below this, no consent
  challenge at all.
- `confirm_stepup_threshold_usd` (configStore) — default $500 — above this,
  CIBA or MFA step-up is required.

### MCP agent flow

Without CIBA the MCP server must return a URL for the user to open in a browser
to re-authenticate. With CIBA the agent can initiate an out-of-band approval
while the user stays in the chat. See the MCP_FLOW / STEP_UP_FLOW constants in
`CIBAPanel.js` for the exact comparison the demo shows.

### Relationship to HITL

HITL (`transactionConsentChallenge.js`) and CIBA are **separate flows** that can
both apply to the same transaction path:

- HITL = server-bound OTP consent challenge (checkbox + OTP). Controlled by
  `ff_hitl_enabled` and `confirm_threshold_usd`.
- CIBA = backchannel PingOne authentication. Controlled by `CIBA_ENABLED` and
  `stepUpMethod`.

They are not mutually exclusive. A transfer above `confirm_stepup_threshold_usd`
with `stepUpMethod=ciba` and HITL enabled could trigger both.

---

## CIBAPanel.js — UI component

Location: `demo_api_ui/src/components/CIBAPanel.js`

A floating button in the bottom-right corner opens a slide-in drawer with five
tabs:

| Tab | Content |
|-----|---------|
| What is CIBA | Explainer with sequence diagram |
| Sign-in & roles | Admin vs customer, where each lands, agent vs login |
| Try It | Live CIBA demo: initiate a real request, watch polling loop, see countdown timer |
| How This App Uses It | MCP agent flow comparison, step-up transfer comparison |
| PingOne Setup | Five-step setup checklist |

The "Try It" tab calls `POST /api/auth/ciba/initiate` and then polls
`GET /api/auth/ciba/poll/:authReqId` every 5 seconds. The countdown timer
and poll log are shown in real time. The Cancel button calls
`POST /api/auth/ciba/cancel/:authReqId`.

When CIBA is not enabled (`cibaStatus.enabled === false`), the "Try It" tab
shows a warning with setup instructions instead of the form.

---

## DaVinci integration

When `acr_values` is included in the bc-authorize request it is forwarded
verbatim to PingOne. Two patterns are used:

1. **ACR string**: `acr_values=Multi_factor` — triggers whatever MFA policy
   PingOne has mapped to that ACR value.

2. **DaVinci policy ID**: `acr_values=<davinci-policy-id>` — routes the
   out-of-band approval through a DaVinci flow. This is how email-only CIBA
   is configured: the DaVinci flow sends an approval email with a magic link.
   No device MFA enrollment is required for this path.

From the CIBAPanel "PingOne Setup" tab:
> "In DaVinci, configure the CIBA flow to send an approval email (notification
> template with approve link). Users confirm in their inbox — you do not need
> PingOne MFA push or a registered authenticator app for that path."

The DaVinci policy ID comes from `process.env.PINGONE_MFA_POLICY_ID` or
`configStore.getEffective('pingone_mfa_policy_id')` — not hardcoded.

---

## configStore keys

| Key | Default | Notes |
|-----|---------|-------|
| `ciba_enabled` | `undefined` | Overridden by `CIBA_ENABLED` env var (takes precedence) |
| `ciba_token_delivery_mode` | `'poll'` | `'poll'` or `'ping'` |
| `ciba_binding_message` | `'Banking App Authentication'` | Shown to user in approval prompt |
| `ciba_notification_endpoint` | unset | Required for ping mode; URL PingOne POSTs to |
| `confirm_threshold_usd` | `250` | Below this no HITL/step-up |
| `confirm_stepup_threshold_usd` | `500` | Above this CIBA step-up is triggered |

Runtime step-up method is in `runtimeSettings.js`:
```js
stepUpMethod: process.env.STEP_UP_METHOD || 'email'
// Set to 'ciba' to use backchannel challenge instead of OIDC redirect
```

---

## Files to read before editing

| File | Why |
|------|-----|
| `demo_api_server/services/cibaService.js` | Core CIBA logic — all four exported functions |
| `demo_api_server/services/cibaEnhanced.js` | Production wrapper — retry + callback polling |
| `demo_api_server/routes/ciba.js` | All CIBA HTTP routes, session tracking, step-up TTL |
| `demo_api_server/config/oauth.js` | `cibaEndpoint` getter, admin client credentials |
| `demo_api_server/config/runtimeSettings.js` | `stepUpMethod`, `stepUpAcrValue`, thresholds |
| `demo_api_server/services/transactionConsentChallenge.js` | HITL flow — separate from CIBA but overlaps on high-value transfers |
| `demo_api_ui/src/components/CIBAPanel.js` | UI demo panel — "Try It" tab uses poll routes |
| `REGRESSION_PLAN.md` §1 | Check if CIBA routes are in the do-not-break list before editing auth paths |

---

## Common pitfalls

- ⚠️ `CIBA_ENABLED=true` must be the **string** `'true'` — not a boolean or `1`.
- ⚠️ The admin client (not the user client) sends the bc-authorize and token
  requests. If you see 401 errors check `admin_client_id` / `admin_client_secret`
  in configStore, not the user-facing client.
- ⚠️ PingOne's `bc-authorize` endpoint requires the CIBA grant type to be
  explicitly enabled on the application in the PingOne console. Missing this
  is the most common reason for `400 invalid_grant` on initiation.
- ⚠️ Ping delivery mode requires a shared session store (Redis/Upstash) so
  that `POST /api/auth/ciba/notify` can find the correct session on any
  server instance. Poll mode has no such requirement.
- ⚠️ `binding_message` is validated to 256 chars max and control characters
  are stripped in the route — do not bypass this in new routes.
- ❌ Do not expose `auth_req_id` responses to the browser with raw tokens — the
  poll route returns only `{ status: 'approved', scope }` and stores tokens
  server-side per BFF pattern.
