Skip to content

Use case — MFA / step-up

The library's authentication layer is built from three primitives that compose:

  • Step — knows how to verify one factor (password, TOTP, passkey, email-OTP, …).
  • Rule — decides whether a factor is required for this attempt.
  • LoginFlow — a Primary step plus an ordered list of Rules.

Each authenticator runs only when its rule says yes. So "password always, TOTP always" is one flow; "password always, captcha after 3 failures, TOTP if risk score is high" is another.

Use this page when the login decision depends on more than "password accepted": mandatory MFA, suspicious-attempt captcha, risk-based extra factors, or RP-requested ACR step-up. If you only need a username/password login, the default password primary step and the user-store pages are enough; adding rules before you need them mainly increases test and recovery surface.

Specs referenced on this page
Vocabulary refresher
  • MFA — Multi-Factor Authentication. The user proves more than one factor (something they know / have / are) before the OP issues tokens.
  • Step-up — when an RP needs higher assurance for a sensitive operation, it asks for acr_values=aalN. If the current session is below that, the OP runs an additional factor before issuing a freshly-elevated id_token. Defined by RFC 9470.
  • AAL (Authenticator Assurance Level) — NIST's three-tier ladder: AAL1 ≈ password, AAL2 ≈ password + something, AAL3 ≈ hardware-backed proof-of-possession. Many OPs and RPs use these labels in acr.
  • amr claim — RFC 8176 enumerates standard reference values (pwd, otp, mfa, hwk, face, fpt, …) so RPs can audit which factors actually ran.

Sources: examples/20-mfa-totp — password + always-TOTP; examples/21-risk-based-mfa — risk-driven step-up; examples/22-login-captcha — captcha after N failed attempts; examples/23-step-up — RFC 9470 ACR step-up; examples/27-durable-mfa-store — the shipped SQL factor stores, shared with the core OP tables in one database.

Composition

Login flow composition: a primary password step, a rules layer that decides which factor steps run, then token issuance.Login startPrimaryPasswordprimary stepRules evalRuleAlwaysevery attemptRuleAfterFailedAttemptscount ≥ 3RuleRiskscore ≥ HighRuleACRaal3 in acr_valuesStepTOTPStepCaptchaStepTOTPStepTOTPIssue tokens

LoginFlow is a struct with a Primary step and a list of Rules. Each rule is a Rule value built from a constructor like op.RuleAlways(step), op.RuleAfterFailedAttempts(n, step), op.RuleRisk(threshold, step), or op.RuleACR(acr, step).

Always TOTP

go
import (
  "github.com/libraz/go-oidc-provider/op"
)

flow := op.LoginFlow{
  Primary: op.PrimaryPassword{Store: st.UserPasswords()},
  Rules: []op.Rule{
    op.RuleAlways(op.StepTOTP{
      Store:         st.TOTPs(),
      EncryptionKey: keys.TOTPKey,
    }),
  },
}

op.New(
  /* ... */
  op.WithLoginFlow(flow),
)

Captcha after N failed attempts

go
flow := op.LoginFlow{
  Primary: op.PrimaryPassword{Store: st.UserPasswords()},
  Rules: []op.Rule{
    op.RuleAfterFailedAttempts(3, op.StepCaptcha{Verifier: myCaptchaVerifier}),
  },
}

op.New(
  /* ... */
  op.WithLoginFlow(flow),
  op.WithCaptchaVerifier(myCaptchaVerifier), // hCaptcha / Turnstile / etc.
)

The LoginAttemptObserver (passed via op.WithLoginAttemptObserver) counts failures per identifier. RuleAfterFailedAttempts reads that count.

Risk-based step-up

go
flow := op.LoginFlow{
  Primary: op.PrimaryPassword{Store: st.UserPasswords()},
  Rules: []op.Rule{
    op.RuleRisk(op.RiskScoreHigh, op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
  },
  Risk: myRiskAssessor, // RiskAssessor field on LoginFlow
}

op.New(
  /* ... */
  op.WithLoginFlow(flow),
)

The RiskAssessor returns a RiskScore per attempt. The library exposes the four-level ordered enum (RiskScoreNone < RiskScoreLow < RiskScoreMedium < RiskScoreHigh); your assessor translates whatever your provider returns onto it. RuleRisk(threshold, step) fires when the assessor's score meets or exceeds threshold.

RFC 9470 ACR step-up

When the RP requests a higher Authentication Context Class (acr_values=aal3), the OP runs the step-up factor regardless of session state:

go
flow := op.LoginFlow{
  Primary: op.PrimaryPassword{Store: st.UserPasswords()},
  Rules: []op.Rule{
    op.RuleACR("aal3", op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
  },
}

op.New(
  /* ... */
  op.WithLoginFlow(flow),
  op.WithACRPolicy(myACRPolicy), // op.ACRPolicy implementation
)

If the user already authenticated at aal2 earlier in the session, the RP requesting acr_values=aal3 triggers an interactive step-up: the OP runs passkeyAuth to lift the session to aal3 before redirecting back.

The resource-server side: op.StepUpChallenge

RFC 9470 has two halves. The OP half is above — honour acr_values / max_age and re-authenticate. The other half lives at the resource server: when an access token lacks the required strength or freshness for a sensitive call, the resource server answers 401 with a WWW-Authenticate: Bearer challenge carrying error="insufficient_user_authentication" and the acr_values / max_age it needs. The client then re-authorizes with those values, and the OP step-up above kicks in.

op.StepUpChallenge builds that header value. The OP itself never emits it — token validation and the 401 belong to your resource server, so the library stops at producing a correctly-formatted challenge string:

go
maxAge := int64(300)
challenge := op.StepUpChallenge("api", []string{"urn:acr:high", "urn:acr:mfa"}, &maxAge)
// challenge == `Bearer realm="api", error="insufficient_user_authentication",
//               acr_values="urn:acr:high urn:acr:mfa", max_age="300"`
w.Header().Set("WWW-Authenticate", challenge)
w.WriteHeader(http.StatusUnauthorized)

An empty realm, an empty acrValues slice, and a nil maxAge are each omitted; the mandatory error="insufficient_user_authentication" is always present. acrValues is encoded as a single space-delimited quoted string, mirroring the acr_values request parameter.

One-time factors are single-use

The one-time factors — email-OTP (op.StepEmailOTP), TOTP (op.StepTOTP), and recovery codes (op.StepRecoveryCode) — are single-use under concurrency: a code cannot be accepted twice. The store enforces this with an atomic compare-and-set that returns ErrAlreadyConsumed on replay, so two racing requests presenting the same code cannot both succeed. A custom factor store must make these consume operations a CAS (the in-memory reference shows the shape).

Terminal factor failures — an expired or already-consumed one-time code, lockout, a required reset, too many resends — are wrapped in the authn.ErrFactorAbort sentinel, which the authorize endpoint maps to HTTP 400, not 500: a spent code is a client-side condition, not a server fault.

Audit trail

Each authenticator step emits a structured event from the op.Audit* catalog — op.AuditLoginSuccess / op.AuditLoginFailed, op.AuditMFARequired / op.AuditMFASuccess / op.AuditMFAFailed, op.AuditStepUpRequired / op.AuditStepUpSuccess. Each event records:

  • factor (pwd, otp, webauthn, …)
  • aal (the AAL level reached)
  • acr (the ACR class value)
  • amr (RFC 8176 method references)

The events thread through op.WithAuditLogger (a *slog.Logger).

Where authenticators come from

The library ships ready-to-use steps for the common factors:

StepWhat it verifiesStorage interface
op.PrimaryPasswordUsername / email + passwordstore.UserPasswords()
op.PrimaryPasskeyWebAuthn / passkey as the primary factorstore.Passkeys()
op.StepTOTPRFC 6238 TOTP, AES-256-GCM at-rest secret encryptionstore.TOTPs()
op.StepEmailOTPEmail-delivered one-time codestore.EmailOTPs()
op.StepRecoveryCodeSingle-use recovery codesstore.RecoveryCodes()
op.StepCaptchahCaptcha / Turnstile / your verifiern/a

The storage behind each step is yours — the library never owns user records or password hashes. The reference inmem adapter is fine for examples and tests; in production you implement the op/store/* substores against your existing user table.

For a fully custom factor, implement op.ExternalStep (see op/step.go godoc) and add it to the rule list with a unique KindLabel. This is the pattern across every examples/2x-*.

For account-management flows, use the companion packages rather than constructing credential records yourself. op/passkeykit runs the WebAuthn registration ceremony from the same PrimaryPasskey configuration used at login. op/recoverykit creates and replaces recovery-code batches; show Result.Codes once and never persist or log them. op.NewEmailOTPAuthenticator(op.EmailOTPConfig{...}) builds the lower-level email-OTP authenticator when you use WithAuthenticators instead of LoginFlow. See 28-email-otp-recovery and 29-passkey.

Passkeys need a domain, not an IP

A WebAuthn Relying Party ID must be a domain name, and browsers reject an IP literal for it — so an OP developing against http://127.0.0.1 has no valid RP ID to pair with. Bind the issuer to localhost and opt in with op.WithAllowLocalhostLoopback(), which admits the textual host in the issuer as well as in redirect URIs. The carve-out is for a developer machine and nowhere else; production RPs belong behind https.

Setting AAGUIDAllowlist on op.PrimaryPasskey also switches the registration ceremony to direct attestation conveyance, because an allowlist compared against an unattested AAGUID proves nothing — a software authenticator could register by naming an approved model. Expect a user-agent attestation prompt, and a registration that cannot identify the model is refused rather than matched. Leave the allowlist empty to keep the prompt-free ceremony.

Enrolling a TOTP factor

op.StepTOTP verifies codes against a store.TOTPRecord the embedder has already persisted. The complementary registration path lives in the op/totpkit package: it owns secret generation, the otpauth:// provisioning URI rendered as a QR code, and the proof-of-possession step that marks an enrolment confirmed.

go
import (
  "github.com/libraz/go-oidc-provider/op/totpkit"
)

// Construct one codec at startup; share the same key bytes with
// op.StepTOTP{EncryptionKey: keys.TOTPKey} so verify and enrolment
// produce / consume the same AES-256-GCM blob shape.
codec, err := totpkit.NewCodec(keys.TOTPKey /*, previousKey, ... */)

// 1. After primary auth succeeds, kick off enrolment.
pending, err := totpkit.NewEnrolment(codec,
  user.Subject,        // OP-internal stable user ID (bound as AAD)
  "Example Identity",  // issuer label shown by the authenticator app
  user.Email,          // account label shown beneath the issuer
)
// pending.OTPAuthURI    — render this as a QR code in HTML
// pending.SecretBase32  — show this for "manual entry" UX
// pending.Record        — sealed TOTPRecord, NOT yet persistable

// 2. Stash `pending` in a short-lived enrolment session (server-side
//    row keyed by a cookie). Render the QR code and the manual-entry
//    secret to the user.

// 3. The user types the code their authenticator app displays.
record, err := totpkit.Confirm(codec, pending, submittedCode, time.Now())
// On totpkit.ErrCodeRejected, render the form again — `pending` is
// unmutated and the user retries. ErrDecrypt fires when the key has
// rotated past the codec's retention window.

// 4. Persist the confirmed record. From this moment op.StepTOTP
//    accepts codes against the same secret.
_ = storage.TOTPs().Put(ctx, record)

totpkit deliberately stays out of the HTTP surface — the embedder owns the HTML, the QR rendering, and the enrolment session. Both NewEnrolment and Confirm bind the subject as GCM additional-authenticated-data, so a row exfiltrated from one user's enrolment cannot be replayed under a different subject. The verify path uses the same AAD shape, so the binding holds across both ends.

For demo / CLI-only enrolment (terminal QR rendering, pre-confirmed seed records), see examples/internal/seedkit — it sits behind a //go:build example tag so the QR rendering library never enters the host module's go.sum.

Source: examples/23-step-up — in-process OP+RP demo that walks the full enrolment + RFC 9470 ACR step-up flow.