Implementing End to End Multi Factor Authentication Flows
Most identity providers hardcode "how login works." O2ID instead lets you define it: a Flow is a tenant-authored graph of steps — identify the user, verify a credential, branch on context, collect enrollment, finish with an outcome — that O2ID walks one node at a time.
In this post you'll configure a real login flow that asks for a password, then TOTP — enrolling on the spot if a user hasn't set it up yet — and sign in as two different users through O2ID's actual login portal, no CLI standing in for the browser.
What you'll build
A login flow with two steps:
- Password — verified against the user's O2ID credentials.
- TOTP — mandatory for everyone. The first time a user signs in, they're walked through enrollment (scan a QR code, enter a code) right there in the browser; every time after, they're just asked for a code.
Five failed attempts at either step denies the login.
Before you begin
This assumes O2ID is running locally on http://localhost:8080, you're
already authenticated with o2idctl, and jq is installed. You'll also
want an authenticator app on your phone — Google Authenticator, Authy,
1Password, or anything else that scans a TOTP QR code — since both users
below enroll for real.
We'll use oidcdebugger.com to complete the OAuth flow in a real browser without writing any application code, the same way the shared-applications tutorial does.
Step 1: Create a TOTP authenticator
First, confirm totp is available as a built-in authenticator type:
o2idctl authenticators list types
Then configure a tenant instance of it. --field issuer is what shows up
before the colon in the user's authenticator app — set it to your own
deployment's name instead of the default O2ID. --field progressiveEnrollment=true is what lets a user with no verified
enrollment yet get enrolled in place, instead of the login step just
rejecting their (nonexistent) code:
AUTH_JSON=$(o2idctl authenticators create --type totp --display-name "Company TOTP" \
--field issuer=Acme --field progressiveEnrollment=true)
AUTHENTICATOR_ID=$(printf '%s' "$AUTH_JSON" | jq -r .id)
The part after the colon fills in automatically at enrollment time, from
the signed-in user's username — so this entry will read Acme:alice, not
a generic label shared by every enrollee.
You'll reference $AUTHENTICATOR_ID from the flow definition in the next
step.
Step 2: Write the flow definition
Each node has the same shape: type, the control-flow fields that type
uses (onSuccess/onFailure, or branches/else for a condition), and a
properties object for anything node-type-specific. See Flow Definition
Schema for the full reference.
cat > login-flow.json <<EOF
{
"start": "identify",
"nodes": {
"identify": {
"type": "authenticator",
"onSuccess": "password",
"onFailure": "identify",
"properties": {
"authenticatorId": "identifier"
}
},
"password": {
"type": "authenticator",
"onSuccess": "totp",
"onFailure": "password",
"properties": {
"authenticatorId": "password",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"totp": {
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"authenticatorId": "$AUTHENTICATOR_ID",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"allow": { "type": "outcome", "properties": { "result": "allow" } },
"deny": { "type": "outcome", "properties": { "result": "deny" } }
}
}
EOF
There's no separate enrollment node or condition branch here — the totp
node handles both cases itself. Because $AUTHENTICATOR_ID was configured
with progressiveEnrollment=true, a user with no verified enrollment yet
gets enrolled the first time they land on totp; a user who's already
enrolled just gets asked for a code, straight away.
Step 3: Validate, create, and activate the flow
Check the definition before it's ever stored:
o2idctl flows validate --definition-file login-flow.json
Then create and activate it:
FLOW_JSON=$(o2idctl flows create --name "Login" --trigger login --definition-file login-flow.json)
FLOW_ID=$(printf '%s' "$FLOW_JSON" | jq -r .id)
o2idctl flows activate "$FLOW_ID"
validate checks that every node reference resolves, every referenced
authenticator ID exists, and every node is reachable. activate makes it
the active flow for the login trigger.
Step 4: Create two test users
o2idctl users create --email alice@example.com --password Str0ngPassw0rd9
o2idctl users create --email bob@example.com --password Str0ngPassw0rd9
Neither has TOTP enrolled yet. You'll enroll both for real in the browser below — no CLI enrollment step needed.
Step 5: Create an OAuth client for the walkthrough
APP_JSON=$(o2idctl applications create --name "Login Flow Demo" \
--callback-url https://oidcdebugger.com/debug --trusted)
CLIENT_ID=$(printf '%s' "$APP_JSON" | jq -r .clientId)
--trusted skips the consent screen so the walkthrough stays focused on
the login steps themselves — this app has no client secret, so it
authenticates with PKCE. Print the authorize URL you'll use below:
echo "http://localhost:8080/t/system/oauth2/authorize"
(Replace system with your own tenant slug if you're not using the
bootstrap tenant.)
Step 6: Sign in as Alice — enroll TOTP live in the browser
Open https://oidcdebugger.com and configure:
| Field | Value |
|---|---|
| Authorize URI | the URL printed above |
| Client ID | $CLIENT_ID |
| Redirect URI | https://oidcdebugger.com/debug |
| Scope | openid |
| Response Type | code |
| Use PKCE | ✓ Enabled |
Click Send Request. You're redirected to O2ID's login portal — an
email field, nothing else yet. Enter alice@example.com and continue.
Now a password field. Enter Str0ngPassw0rd9 and continue.
Since Alice has no verified TOTP enrollment yet, the totp node's
progressiveEnrollment kicks in instead of asking for a code outright: you
land on a "Set up your authenticator app" screen — a real QR code,
plus the secret as text for manual entry. Scan it with your authenticator
app, enter the 6-digit code it shows, and continue.
That's it — you're redirected back to oidcdebugger.com with an
authorization code. Exchange it for a token the same way as the
shared-applications
tutorial:
enter the token URI (http://localhost:8080/t/system/oauth2/token) into
oidcdebugger.com and exchange.
Step 7: Sign in as Bob — his own, independent enrollment
Repeat Step 6 with bob@example.com / Str0ngPassw0rd9. Same
enrollment screen, but a different QR code and secret — each user's
enrollment is entirely their own.
Step 8: Sign in as Alice again — verification, not enrollment
Start a fresh authorize request the same way, and sign in as Alice again.
This time, after the password screen, you'll see a plain 6-digit code
field instead of the QR/enrollment screen — she's enrolled now, so the
totp node just asks for a code instead of enrollment setup material.
Open your authenticator app, enter the current code, and continue.
Try entering a wrong code a few times instead: the same screen re-renders
with an inline "invalid code" message rather than starting the flow over,
and after five wrong attempts in a row you land on a plain denial screen
instead of a token — maxAttempts/onLockout doing exactly what they say.
What you learned
- Every flow node has the same shape —
type, control-flow fields, and apropertiesobject for its own configuration. - An authenticator's
progressiveEnrollmentproperty is what routes a user to enrollment the first time and straight to verification every time after — no separate condition or node needed. o2idctl flows validate --definition-filechecks a definition before you ever create it./oauth2/authorizestarts a real flow run and O2ID's login portal renders it generically, node by node — the QR/enrollment screen, the code field, and the plain password field are all the same renderer responding to whatever the current node happens to be.
To go further, see Flows and TOTP Authenticator for the full reference on each piece used here.