Skip to content

Use case — MFA / step-up

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

  • Authenticator — knows how to verify one factor (password, TOTP, passkey, email-OTP, …).
  • Rule — decides whether a factor is required for this attempt.
  • LoginFlow — the ordered list of (authenticator, rule) pairs.

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.

Composition

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-*.

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.