Skip to content

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 ​

ConcernOption / typeWhat the example does
Primary loginop.LoginFlow{Primary: op.PrimaryPassword{...}}Password against the in-memory user store.
Second factorop.RuleAlways(op.StepTOTP{...})Every login also needs a TOTP code.
Captchaop.RuleAfterFailedAttempts(5, op.StepCaptcha{...})Fires from the fifth failed attempt in a session; the verifier is a stub that always passes.
Clientsop.WithStaticClients(op.PublicClient{...}, op.ConfidentialClient{...})A browser app and a client_secret_basic backend client.
Profileop.WithProfile(profile.OAuth21)PKCE on every authorization-code request (the default when no profile is set).
Consentop.WithFirstPartyClients(...)The browser app skips the consent screen (First-party consent skip).
Login UIop.WithUI(op.SPAUI{LoginMount, StaticDir})The OP serves the shared SPA bundle at /login (SPA / custom interaction).
Token lifetimesop.WithAccessTokenTTL, op.WithRefreshTokenTTL2 minutes and 7 days, against defaults of 5 minutes and 30 days.
Scope titlesop.WithScope(op.PublicScope(...))Overrides for profile and email; see Public / internal scopes.
Loggingop.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.

go
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 ​

sh
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 ​

NeedWhere to look
FAPI 2.0, OAuth 2.1Declaring a security profile, FAPI 2.0 Baseline
Risk-driven MFAop.RuleRisk and LoginFlow.Risk, in MFA / step-up
Running behind a reverse proxyop.WithTrustedProxies(cidrs ...), in Options
Scopes hidden from discoveryop.InternalScope, in Public / internal scopes
A real databasePersistent storage (SQL)