Skip to content

The pi.flow Protocol

pi.flow is PingOne's own interactive sign-on protocol, emulated here as a real, working state machine (packages/api/src/lib/authFlow/emulatedProvider.ts) rather than a static mock. This page describes the shape of the protocol; see Architecture for how it fits alongside the alternative PingFederate-backed provider.

The basic exchange

  1. GET /:environmentId/as/authorize with response_mode=pi.flow (this emulator only implements that one response mode) starts a flow and returns { id, status } as a plain 200 JSON response — no browser redirect happens at this step.
  2. POST /:environmentId/flows/:flowId submits whatever the current status is waiting on. The request body's shape depends entirely on status — the caller is expected to know what a given status requires (see below).
  3. GET /:environmentId/flows/:flowId re-fetches the flow's current state without submitting anything — used to pick up fresh embedded data (e.g. WebAuthn ceremony options) without advancing the flow.
  4. Once the flow reaches status: "COMPLETED", the response carries authorizeResponse — an authorization code (or, for an implicit-style request, access_token/id_token directly) plus the original state.

Statuses

Status Meaning
USERNAME_PASSWORD_REQUIRED Submit {username, password} — or {credential} (a WebAuthn AuthenticationResponseJSON) for usernameless passkey sign-in on this same status.
IDENTIFIER_REQUIRED An identifier-first flow's initial step — submit {username} alone before password is even asked for.
VERIFICATION_CODE_REQUIRED Email/SMS verification code step.
MFA_REQUIRED / MFA_OTP_REQUIRED MFA device selection / OTP entry.
PASSKEY_REGISTRATION_REQUIRED The client should call navigator.credentials.create() using the embedded WebAuthn options, then submit {credential} — or {skip: true} to decline enrollment.
PASSKEY_REQUIRED The client should call navigator.credentials.get() using the embedded WebAuthn options, then submit {credential}.
PASSWORD_RESET_REQUIRED Forced password reset before the flow can continue.
PROFILE_UPDATE_REQUIRED Progressive profiling — submit whatever profile fields were requested.
AGREEMENT_REQUIRED Submit {accept: true} for a terms-of-service/agreement gate.
IDENTITY_PROVIDER_REQUIRED The flow embeds redirect target(s) for federated sign-in instead of continuing locally (_embedded.identityProviders).
DEVICE_USER_CODE_REQUIRED / DEVICE_CONSENT_REQUIRED RFC 8628 device authorization grant's browser-side verification steps.
COMPLETED Terminal — authorizeResponse is populated.

_embedded

Depending on status, a flow response can carry one of:

  • { user: { id } } — present once a user is associated with the flow (login succeeded, or the moment a self-registration creates the new account).
  • { identityProviders: [...] } — for IDENTITY_PROVIDER_REQUIRED.
  • { webauthn: ... } — the WebAuthn ceremony options for PASSKEY_REGISTRATION_REQUIRED / PASSKEY_REQUIRED (and, if passkey-after-login enrollment is enabled for the environment, embedded speculatively on USERNAME_PASSWORD_REQUIRED too, to support autofill).

WebAuthn specifics

Both passkey statuses need the calling page's Origin header — rpID and the expected origin are resolved per-request (lib/webauthn.ts's resolveRp), not hardcoded, since one emulator instance backs many real subdomains. This applies to both POST /flows/:id (submitting a ceremony response) and GET /flows/:id (re-fetching fresh, unused challenge options, e.g. after a page reload).

Beyond plain pi.flow

A few related flow-shaped protocols reuse the same /flows/:id machinery under the hood (AuthorizationFlowProvider in lib/authFlow/types.ts):

  • DaVinci-hosted flows — if the target application has a DaVinci flow policy assigned, real PingOne (and this emulator) returns a different envelope instead of the plain {id, status} shape: interactionId, interactionToken, connectionId, capabilityName. Subsequent steps go through POST /davinci/connections/:connectionId/capabilities/:capabilityName instead of POST /flows/:id.
  • Device authorization grant (RFC 8628) — POST /as/device_authorization starts a device's polling record; the user's own browser-side verification (GET /device) reuses /flows/:id with no client/redirect context until the user submits their user_code.
  • SAML — POST /saml20/idp/sso starts a browser-side flow resolved by SP entity id instead of an OAuth client_id, also reusing /flows/:id; GET /saml20/resume builds the final SAML Response assertion.