Use case — Comprehensive bundle
01-minimal is the smallest OP that works. 02-bundle is the same OP with the pieces a production deployment adds on top: a second login factor, a captcha rule, two kinds of client, a login UI served by the OP, shorter token lifetimes and structured logs.
Source:
examples/02-bundle/main.go
What it composes
| Concern | Option / type | What the example does |
|---|---|---|
| Primary login | op.LoginFlow{Primary: op.PrimaryPassword{...}} | Password against the in-memory user store. |
| Second factor | op.RuleAlways(op.StepTOTP{...}) | Every login also needs a TOTP code. |
| Captcha | op.RuleAfterFailedAttempts(5, op.StepCaptcha{...}) | Fires from the fifth failed attempt in a session; the verifier is a stub that always passes. |
| Clients | op.WithStaticClients(op.PublicClient{...}, op.ConfidentialClient{...}) | A browser app and a client_secret_basic backend client. |
| Profile | op.WithProfile(profile.OAuth21) | PKCE on every authorization-code request (the default when no profile is set). |
| Consent | op.WithFirstPartyClients(...) | The browser app skips the consent screen (First-party consent skip). |
| Login UI | op.WithUI(op.SPAUI{LoginMount, StaticDir}) | The OP serves the shared SPA bundle at /login (SPA / custom interaction). |
| Token lifetimes | op.WithAccessTokenTTL, op.WithRefreshTokenTTL | 2 minutes and 7 days, against defaults of 5 minutes and 30 days. |
| Scope titles | op.WithScope(op.PublicScope(...)) | Overrides for profile and email; see Public / internal scopes. |
| Logging | op.WithLogger(*slog.Logger) | A JSON slog handler on stdout. |
A ConfidentialClient that authenticates with client_secret_basic or client_secret_post must carry a Secret (hashed on registration, at least 32 bytes) or a pre-computed SecretHash; op.New refuses one with neither or with a shorter Secret. op.NewClientSecret mints a matching pair.
The login chain
Primary runs first. Each Rule then decides, from the state of the attempt, whether to add a step; a step already completed in the attempt is not run twice.
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleAlways(op.StepTOTP{
Store: st.TOTPs(),
EncryptionKey: keys.TOTPKey, // 32 bytes; AES-256-GCM at rest
}),
op.RuleAfterFailedAttempts(5, op.StepCaptcha{
Verifier: alwaysOKCaptcha{}, // production: Turnstile, hCaptcha, reCAPTCHA
}),
},
}The rules are checked again when /auth could answer from an existing session, and a step counts as done when the session's amr already records it. A session that passed TOTP carries otp, so RuleAlways does not ask for the code again on single sign-on; a session that holds only pwd is asked, the chain runs from the session's subject, and prompt=none answers interaction_required. Captcha rules are skipped in that check, and risk-driven rules fire only inside a full login chain. To ask for the second factor only for sensitive clients or scopes, swap RuleAlways for RuleClient, RuleScope or RuleACR; MFA / step-up covers the full rule vocabulary, including RuleRisk and LoginFlow.Risk.
Run it
cd examples/02-bundle && GOWORK=off go run -tags example .Run it from that directory, since the SPA bundle path is relative. The startup log prints an otpauth:// URI, a base32 secret and a terminal QR code for the seeded demo user; add it to an authenticator app, then open http://127.0.0.1:9090/, sign in as demo / demo, and enter the 6-digit code. The RP's /me page shows "amr": ["mfa","otp","pwd"].
Not in this example
| Need | Where to look |
|---|---|
| FAPI 2.0, OAuth 2.1 | Declaring a security profile, FAPI 2.0 Baseline |
| Risk-driven MFA | op.RuleRisk and LoginFlow.Risk, in MFA / step-up |
| Running behind a reverse proxy | op.WithTrustedProxies(cidrs ...), in Options |
| Scopes hidden from discovery | op.InternalScope, in Public / internal scopes |
| A real database | Persistent storage (SQL) |
Read next
- Minimal OP and RP pair, the starting point this page extends.
- MFA / step-up, the building blocks behind the rules.