Skip to main content
Version: Latest (v0.0.1)

Flows

A flow is a tenant-defined identity journey. Flows are how O2ID models the steps, decisions, and outcomes that make up experiences such as login, step-up checks, factor enrollment, account recovery, or application-specific identity verification.

Instead of wiring identity behavior directly into an application, define a flow as a graph: identify the user, verify a credential or factor, branch on user or tenant context, collect required enrollment, or finish with an outcome.

Clients never receive the full graph. They start a flow run, receive the current nodeView, submit the values that node asks for, and receive the next nodeView. O2ID owns the decisions about what step comes next.

Why use flows

With flows, a tenant can:

  • Define the tenant's login journey.
  • Require TOTP only for users who have it enrolled.
  • Send users without TOTP through enrollment before continuing.
  • Route successful steps through conditions before deciding the next step.
  • Limit attempts and send repeated failures to a deny outcome.
  • Reuse the same flow-run API for other tenant-defined identity triggers.

How a flow runs

A flow definition is a graph of named nodes:

{
"start": "identify",
"nodes": {
"identify": {
"type": "authenticator",
"onSuccess": "password",
"onFailure": "identify",
"properties": {
"authenticatorId": "identifier"
}
},
"password": {
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "password",
"properties": {
"authenticatorId": "password"
}
},
"allow": {
"type": "outcome",
"properties": {
"result": "allow"
}
}
}
}

When a client starts a flow run, O2ID returns only the current nodeView. The client renders that view, submits the requested values, and repeats until the flow reaches an outcome.

Nodes that need user input return a nodeView. Decision-only nodes are handled by O2ID and skipped over automatically until the run reaches another input node or an outcome.

For the full node schema, see Flow Definition Schema.

The example above uses a separate identifier authenticator in front of the password step — useful once you want to act on the resolved user (branch on user.enrolledFactors, etc.) before checking the password, as in Password plus TOTP below. For a plain password login with nothing in between, a single node handles both — see Basic password login, which is also what O2ID's implicit default login flow uses.

Examples

Basic password login

This is the smallest useful login flow: a single node asks for identifier and password together (a traditional combined login form), then allow or deny. This is also exactly the shape of O2ID's implicit default login flow — the one used automatically when a tenant hasn't activated any flow for the login trigger.

{
"start": "login",
"nodes": {
"login": {
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "login",
"properties": {
"authenticatorId": "password",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"allow": {
"type": "outcome",
"properties": {
"result": "allow"
}
},
"deny": {
"type": "outcome",
"properties": {
"result": "deny"
}
}
}
}

Use a separate identifier authenticator in front of it instead — as in How a flow runs above — when you need to act on the resolved user (branch on user.enrolledFactors, tenant policy, etc.) before the password step, as the examples below do.

Password plus TOTP

This flow verifies a password first. If the user has a verified TOTP enrollment, it asks for a TOTP code. If not, it allows the login after the password step.

{
"start": "identify",
"nodes": {
"identify": {
"type": "authenticator",
"onSuccess": "password",
"onFailure": "identify",
"properties": {
"authenticatorId": "identifier"
}
},
"password": {
"type": "authenticator",
"onSuccess": "mfa_check",
"onFailure": "password",
"properties": {
"authenticatorId": "password",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"mfa_check": {
"type": "condition",
"branches": [
{
"if": "'totp' in user.enrolledFactors",
"then": "totp"
}
],
"else": "allow"
},
"totp": {
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"authenticatorId": "<totp-authenticator-id>",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"allow": {
"type": "outcome",
"properties": {
"result": "allow"
}
},
"deny": {
"type": "outcome",
"properties": {
"result": "deny"
}
}
}
}

Use a real configured TOTP authenticator ID for <totp-authenticator-id>. See TOTP Authenticator for creating the tenant authenticator and enrolling users.

Conditional success path

If a successful authenticator step needs conditional routing, point onSuccess at a named condition node.

Condition nodes use Google Common Expression Language (CEL) expressions in their if fields.

{
"totp": {
"type": "authenticator",
"onSuccess": "post_totp_check",
"onFailure": "totp",
"properties": {
"authenticatorId": "<totp-authenticator-id>",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"post_totp_check": {
"type": "condition",
"branches": [
{
"if": "step.totp.attempts > 1",
"then": "deny"
}
],
"else": "allow"
}
}

This keeps the authenticator node simple and makes the policy decision visible as its own named step.

Named outputs

Any node can declare outputs: named CEL expressions, computed once that node succeeds and readable from later nodes at step.<nodeId>.outputs.<name>, instead of every downstream node reaching into that node's raw result/attempts shape. Reusing the totp node above:

{
"totp": {
"type": "authenticator",
"onSuccess": "post_totp_check",
"onFailure": "totp",
"properties": {
"authenticatorId": "<totp-authenticator-id>",
"maxAttempts": 5,
"onLockout": "deny"
},
"outputs": {
"attemptsRemaining": "5 - step.totp.attempts"
}
},
"post_totp_check": {
"type": "condition",
"branches": [
{
"if": "step.totp.outputs.attemptsRemaining < 4",
"then": "deny"
}
],
"else": "allow"
}
}

Federated login via OIDC

A connection node redirects the user-agent to an external identity provider and resumes the flow once they return. connectionId names a connection of a federation-capable connection type — see Managing Connections for creating one (e.g. oidc). Unlike every other node type, onFailure is required: an external provider's exchange failing always needs somewhere explicit to go.

{
"start": "external",
"nodes": {
"external": {
"type": "connection",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"connectionId": "<connection-id>"
}
},
"allow": {
"type": "outcome",
"properties": {
"result": "allow"
}
},
"deny": {
"type": "outcome",
"properties": {
"result": "deny"
}
}
}
}

The first time a given external identity signs in, O2ID creates a new local user for them — it never links to an existing local account by email, even if the provider asserts a verified one. A returning external identity always resolves back to that same user.

Create and activate a flow

note

o2idctl flows list returns null on a fresh tenant, but signing in already works — O2ID falls back to an implicit, unstored Basic password login for the login trigger only until you activate your own, which replaces it outright.

Save the definition as login-flow.json, then create it:

o2idctl flows create --name "Login" --trigger login --definition-file login-flow.json

O2ID reserves system trigger values for identity journeys that the platform knows how to invoke directly.

TriggerUse
loginTenant login journey

Use o2idctl flows meta to discover the system trigger values exposed by the running O2ID instance.

For other identity journeys, choose a tenant-defined trigger name such as step-up, account-recovery, or age-verification, then start the flow with the same trigger name. To see the triggers already configured in a tenant, list its flows:

o2idctl flows list

Validate the stored definition:

o2idctl flows validate <flow-id>

To check a definition before it's ever created — useful while iterating on login-flow.json itself — validate it directly instead:

o2idctl flows validate --definition-file login-flow.json

Both forms run the same checks (see Validation) and return the same result shape; the only difference is whether the definition is already stored under a flow ID.

Activate it:

o2idctl flows activate <flow-id>

activate enables the flow and makes it the active flow for its trigger.

The browser login portal

A signed-out user hitting /oauth2/authorize is redirected to O2ID's login portal, which drives the flow-run API (POST /flow-runs, POST /flow-runs/{id}/submit) — it starts a run, renders whatever nodeView comes back (an email field, a password or code field, a TOTP enrollment screen), submits what the user enters, and repeats until the run reaches an outcome. It never sees the flow graph itself, only the current step, so any flow shape configured for a trigger renders through the same generic UI.

There's no o2idctl command that drives a flow run field by field — running one requires a real client, not a terminal one field at a time. Try it end to end through the login portal itself (sign in as a user against the trigger you just activated), or call the flow-run API directly from your own client/test harness.

By default this runs the login trigger. To have /oauth2/authorize launch a different trigger instead, add prompt=<trigger> to the authorize request:

GET /t/{tenant}/oauth2/authorize?...&prompt=age-verification

For this to work unauthenticated, the target flow's definition must set "allowUnauthenticated": true (see Flow Definition Schema) — otherwise /flow-runs rejects it, the same way it would for any other trigger a tenant hasn't explicitly opted in.

none, consent, and select_account are reserved rather than treated as trigger names — they're standard OIDC prompt values O2ID doesn't implement yet, and a request using any of them falls back to login so a tenant-defined trigger sharing one of those names can't collide with real prompt semantics once they exist.

Discover supported flow features

Use metadata to discover the node types and context variables supported by the running O2ID instance:

o2idctl flows meta

The API also exposes a downloadable schema document:

GET /flows/meta/schema.json