Compare commits

...
1 Commits
Author SHA1 Message Date
hanzo-dev 89224d2824 feat(mfa): second-factor login gate — TOTP + recovery codes + WebAuthn passkeys
The multi-factor domain and the login gate that enforces it, ported from v1
object/mfa.go with ONE implementation of each function shared by the login gate
(internal/oidc) and the enrollment handlers.

- internal/mfa: what a factor is, TOTP (RFC 6238) verify, recovery codes
  (hashed at rest, one-time), org prompt/enabled predicates, mfa.Save (a
  column-scoped write), and the five frozen v1 enrollment paths (mount.go).
- login gate (login.go): a verified password proves ONE factor; gate() holds
  the sign-in and answers RequiredMfa / NextMfa (verbatim v1 wire strings);
  finishMfa answers an outstanding challenge, loading the user from the
  CHALLENGE subject and never the request body; grant() is the single minting
  path for password + passkey sign-ins; the "remember this device" window is
  preserved and fails closed on an unparsable deadline.
- challenge lifecycle (oidc/challenge.go + schema.Challenge): a server-side,
  single-use, owner-scoped row replacing v1's beego cookie session — a sibling
  of Token, never a Token with borrowed fields.
- WebAuthn ceremony (webauthn/ceremony.go): passkey sign-in + registration,
  each half gated on possession of the private key; sign-in is public,
  registration is bearer-bound (self-only by construction).
- authz: form-target routes (the frozen v1 MFA wire read through httpx.Form)
  and bearer-bound routes authorized by the Guard over the same buffered
  request; MFA is the one self-service write, safe because handlers are
  column-scoped (the request user value never reaches the store).
- httpx.Ok gains a variadic data2; store.GetOrganizationByName.
- deps: go-webauthn/webauthn, pquerna/otp, zap-proto/fiber/v3 (now direct).
2026-07-19 19:43:57 -07:00
15 changed files with 2229 additions and 63 deletions
+9 -1
View File
@@ -21,13 +21,17 @@ require (
)
require (
github.com/go-webauthn/webauthn v0.10.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/luxfi/crypto v1.20.1
github.com/pquerna/otp v1.5.0
github.com/zap-proto/fiber/v3 v3.2.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -35,9 +39,12 @@ require (
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanw/esbuild v0.28.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-webauthn/x v0.1.9 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hanzoai/dbx v1.16.0 // indirect
@@ -52,6 +59,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -59,7 +67,7 @@ require (
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/http v0.2.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
+12
View File
@@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -32,6 +34,10 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
@@ -40,6 +46,8 @@ github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2e
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -78,6 +86,8 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -85,6 +95,8 @@ github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJ
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+111 -9
View File
@@ -129,6 +129,54 @@ var publicPaths = map[string]bool{
"/v1/iam/oauth/logout": true, // end session
"/v1/iam/get-app-login": true, // pre-login app config (secrets masked)
"/v1/iam/auth/methods": true, // pre-login method list
// The passkey SIGN-IN ceremony. A passkey is what the caller signs in WITH,
// so no bearer can exist yet — gating these would make the credential
// unusable. Both halves gate themselves on possession of the private key:
// begin only issues a random challenge, and finish only completes when the
// authenticator's signature verifies against a stored public key. They are
// the passkey twin of /v1/iam/login, which is public for the same reason.
// The REGISTRATION ceremony (webauthn/signup/*) is NOT here — it requires a
// bearer, because it decides which account a new credential can open.
"/v1/iam/webauthn/signin/begin": true,
"/v1/iam/webauthn/signin/finish": true,
}
// formPaths is the CLOSED set of gated routes whose authorization target rides in
// the request FORM rather than a decoded JSON body — the frozen v1 MFA wire.
// v1 reads every MFA parameter through c.Ctx.Request.Form (controllers/mfa.go:36),
// the query merged with the body, and its two live clients disagree about which
// they use: the hanzo.id portal posts multipart, the console's BFF sends the
// query with an empty body. So these are raw handlers, and no decoded body ever
// reaches the op-invoke seam.
//
// The Guard authorizes them here, through the SAME httpx.Form call the handler
// binds (internal/mfa subject()) — one function over one buffered request. That
// is what keeps invariant 2: the target authorized cannot diverge from the target
// executed, because there is no second parse to diverge with.
var formPaths = map[string]bool{
"/v1/iam/mfa/setup/initiate": true,
"/v1/iam/mfa/setup/verify": true,
"/v1/iam/mfa/setup/enable": true,
"/v1/iam/delete-mfa": true,
"/v1/iam/set-preferred-mfa": true,
}
// bearerBound is the CLOSED set of gated routes whose subject IS the verified
// bearer: the handler reads it from From(ctx) and takes no (owner, name) at all,
// so there is no request-supplied target and the Guard only authenticates.
//
// Passkey registration is self-only BY CONSTRUCTION, and that is the whole point
// of binding it here. A target parameter would have to pass the tenant rule
// below, which lets an ORG ADMIN act on any member — and an org admin who can
// register a passkey onto a member's account owns that account silently, forever.
// So the ceremony never learns a target: it can only ever add a credential to the
// caller's own identity.
//
// A route belongs here ONLY if its handler resolves its subject from From(ctx)
// and nowhere else.
var bearerBound = map[string]bool{
"/v1/iam/webauthn/signup/begin": true,
"/v1/iam/webauthn/signup/finish": true,
}
// isPublic reports whether path is in the public allowlist. A trailing slash is
@@ -149,6 +197,29 @@ func isPublic(path string) bool {
// body decoded once by the op and is authorized at that seam.
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
// guards reports whether the Guard itself authorizes this request's target. Two
// classes address a target outside a decoded JSON body: a read (no body at all)
// and a v1 form route (its target rides in the form). A bearer-bound route
// carries no target to authorize. Everything else decodes a typed body, which
// the op-invoke seam authorizes on the exact value the handler binds.
func guards(method, path string) bool {
if bearerBound[path] {
return false
}
return isRead(method) || formPaths[path]
}
// target returns the (owner, name) a Guard-authorized request addresses, read
// exactly where its handler reads it: a read's rides in the query string; a v1
// form route's rides in the request form (query body), through the same
// httpx.Form the handler binds, so the two cannot come apart.
func target(c *zip.Ctx) (owner, name string) {
if formPaths[c.Path()] {
return httpx.Form(c, "owner"), httpx.Form(c, "name")
}
return c.Query("owner"), c.Query("name")
}
// Guard is the AUTHENTICATION middleware. Mount it ONCE and FIRST, via app.Use,
// so it wraps every route — the typed CRUD handlers and the framework's /mcp and
// /openapi surfaces alike. Public routes pass straight through; every other route
@@ -167,8 +238,11 @@ func Guard(db orm.DB) zip.Handler {
if err != nil {
return zip.ErrUnauthorized("authentication required")
}
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), c.Query("owner"), c.Query("name")) {
return zip.ErrForbidden("forbidden")
if guards(c.Method(), c.Path()) {
owner, name := target(c)
if !authorize(p, c.Method(), entityOf(c.Path()), owner, name) {
return zip.ErrForbidden("forbidden")
}
}
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
return c.Continue()
@@ -208,22 +282,34 @@ func Authorize(ctx context.Context, op zip.Op, in any) error {
// `owner` (named `name`) on the given entity? The order IS the policy:
//
// 1. SuperAdmin may do anything — the only cross-tenant scope.
//
// 2. A platform-owned resource (admin/built-in — the reserved owners the token
// verifier trusts to sign) is writable only by a SuperAdmin. This single
// rule is the signing-cert poisoning gate, the admin-scoped app/provider
// registration gate, AND the built-in-org gap, all at once: a built-in-org
// principal is not SuperAdmin (that is admin only), so it cannot write a
// built-in-owned signing cert either.
//
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
// empty or foreign owner is refused — the target org is bound to the
// principal, never trusted from the request.
//
// 4. Inside its own org, an org admin manages everything; a regular user may
// only READ its own user record (self-service). The users entity serves
// reads as GET and writes as POST, so gating the self clause to GET keeps a
// regular user from writing its own record — a raw entity write would
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
// is the Phase-5 provision-don't-promote concern; here it is closed by
// denial.
// only READ its own user record, or manage its own MFA (self-service). The
// users entity serves reads as GET and writes as POST, so gating the self
// clause to GET keeps a regular user from writing its own record — a raw
// entity write would otherwise let it carry isAdmin and self-promote.
// Privileged self-mutation is the Phase-5 provision-don't-promote concern;
// here it is closed by denial.
//
// The MFA entities are the ONE self-service WRITE, and they are safe only
// because those handlers are column-scoped: every one of them persists
// through mfa.Save, which overlays the multi-factor columns onto the STORED
// row, so the request's user value never reaches the store and cannot carry
// isAdmin. Widen those handlers to a whole-row write and this clause becomes
// the self-promotion path the users clause is narrow to avoid. The grant does
// NOT widen the users entity; enrolling a factor and editing a profile stay
// different rights.
func authorize(p *Principal, method, entity, owner, name string) bool {
if p.Super {
return true
@@ -237,7 +323,23 @@ func authorize(p *Principal, method, entity, owner, name string) bool {
if p.Admin {
return true
}
return method == "GET" && entity == "users" && name != "" && name == p.User
if name == "" || name != p.User {
return false // every remaining grant is self-service
}
return (method == "GET" && entity == "users") || isMfa(entity)
}
// isMfa reports whether entity is one of the multi-factor enrollment surfaces —
// the entity segment of the five frozen v1 paths (mfa/setup/*, delete-mfa,
// set-preferred-mfa). They are named here rather than derived so that adding a
// route under one of these entities is a deliberate act: everything they admit,
// a regular user may do to itself.
func isMfa(entity string) bool {
switch entity {
case "mfa", "delete-mfa", "set-preferred-mfa":
return true
}
return false
}
// owned is implemented by a typed input whose authorization target is NOT its
+27 -3
View File
@@ -22,9 +22,18 @@ type Response struct {
Data3 any `json:"data3,omitempty"`
}
// Ok writes 200 { status:"ok", data }.
func Ok(c *zip.Ctx, data any) error {
return c.JSON(200, Response{Status: "ok", Data: data})
// Ok writes 200 { status:"ok", data }, plus data2 when a second value is given —
// the shape of v1's ResponseOk(data ...interface{}) (controllers/util.go:43). The
// MFA gate is the one caller that needs both: it answers `data:"NextMfa"` with the
// allowed factors in data2, and the portal string-compares data, so the pair must
// ride one envelope. Variadic rather than a second Ok-like function: one helper,
// one way.
func Ok(c *zip.Ctx, data any, more ...any) error {
r := Response{Status: "ok", Data: data}
if len(more) > 0 {
r.Data2 = more[0]
}
return c.JSON(200, r)
}
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
@@ -33,6 +42,21 @@ func Err(c *zip.Ctx, msg string) error {
return c.JSON(200, Response{Status: "error", Msg: msg})
}
// Form returns a request parameter the way v1 reads every MFA parameter —
// c.Ctx.Request.Form.Get (controllers/mfa.go:36-38), Go's merge of the URL query
// with the posted form. The underlying FormValue searches QueryArgs → PostArgs →
// MultipartForm, which is that same precedence, so ONE call serves every live
// client of the frozen wire: the hanzo.id portal posts multipart FormData
// (web/src/backend/MfaBackend.ts), the console BFF sends the query with an empty
// body (console app/console/mfa/[action]/route.ts:76-87), and an SDK may send
// urlencoded.
//
// This is the ONLY way an MFA parameter is read, by the handler that executes it
// AND by the authz Guard that authorizes its (owner, name) — the same function
// over the same buffered request, so the value authorized cannot diverge from the
// value executed (internal/authz, invariant 2).
func Form(c *zip.Ctx, name string) string { return c.Fiber().FormValue(name) }
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
func Bearer(c *zip.Ctx) string {
const p = "Bearer "
+353
View File
@@ -0,0 +1,353 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package mfa is the multi-factor domain: what a factor IS, whether a passcode
// verifies, which factors a user has, whether the organization demands one, how
// that state is written, and the enrollment surface that drives it (mount.go).
// The login gate (internal/oidc) imports the same functions the enrollment
// handlers call, so there is exactly one implementation of each.
//
// Two secrets, two different invariants, and conflating them breaks either
// security or the product:
//
// - TotpSecret is a SYMMETRIC shared secret. The verifier needs it back in
// the clear to recompute the code, so it cannot be hashed. Its invariant is
// that it never crosses a response (users.redact strips it, and
// schema.MfaProps declares Secret json:"-"). It crosses the API exactly once,
// outbound, at enrollment — that IS the QR code — and never again.
// - A recovery code is a BEARER credential, verified by equality alone, so it
// is hashed at rest like a password (v1 stores it in the clear:
// object/mfa.go:81 compares `code == recoveryCode`).
//
// Ported from v1 object/mfa.go + object/mfa_totp.go. Radius and push are
// deliberately absent: no v2 provider transport serves them, and a factor listed
// as available but unservable is an unusable challenge.
package mfa
import (
"context"
"crypto/rand"
"encoding/base32"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// The factor types, verbatim from v1 (object/mfa.go:42-48). "app" is TOTP —
// the name is v1's and it is on the wire, so it does not get "improved".
const (
App = "app"
SMS = "sms"
Email = "email"
)
// Types lists the factors this package can project, in v1's order
// (object/mfa.go:102). It bounds AllProps: a factor absent here is never offered
// on a challenge.
var Types = []string{SMS, Email, App}
// The TOTP parameters. v1 pins them at object/mfa_totp.go:27 (30s period), :115
// (20-byte secret, six digits) and :62-68 (skew 1, SHA1) — the RFC 6238 defaults
// every authenticator app assumes. They are the wire format of a QR already in a
// user's phone, so they are fixed, not configurable.
const (
period = 30
secrets = 20
skew = 1
digits = otp.DigitsSix
algo = otp.AlgorithmSHA1
)
// issuerFallback labels the account in an authenticator app when the
// organization sets no display name (v1 object/mfa_totp.go:40).
const issuerFallback = "HanzoIAM"
// Enrollment is what a client needs to add an account to an authenticator and
// nothing more. It exists because schema.MfaProps — the STORED/READ projection —
// declares Secret and RecoveryCodes as json:"-", so returning one here would
// serialize an empty secret and an empty URL: a blank QR, no error, enrollment
// silently dead. The two directions are different values, so they are different
// types. This one is built, sent once, and never persisted.
type Enrollment struct {
MfaType string `json:"mfaType"`
Secret string `json:"secret"`
URL string `json:"url"`
RecoveryCodes []string `json:"recoveryCodes"`
MfaRememberInHours int `json:"mfaRememberInHours"`
}
// Issuer is the label an authenticator app shows for the account: the
// organization's display name, else its name, else the product (v1
// controllers/mfa.go:68-73).
func Issuer(org *schema.Organization) string {
if org != nil && org.DisplayName != "" {
return org.DisplayName
}
if org != nil && org.Name != "" {
return org.Name
}
return issuerFallback
}
// Enroll generates a fresh TOTP secret for userID ("owner/name") and the
// otpauth:// URL that encodes it. It persists NOTHING: enrollment is stateless
// and client-held until enable commits it (v1 object/mfa_totp.go:37-60).
func Enroll(userID, issuer string) (secret, url string, err error) {
if issuer == "" {
issuer = issuerFallback
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: userID,
Period: period,
SecretSize: secrets,
Digits: digits,
})
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// Verify reports whether passcode is currently valid for secret. It is the ONE
// TOTP verification point — enrollment's setup check and the login challenge
// call this same function, so they cannot drift apart (the users.VerifyPassword
// precedent). Skew 1 accepts the adjacent windows, tolerating clock drift
// (v1 object/mfa_totp.go:97-113).
func Verify(secret, passcode string) bool {
if secret == "" || passcode == "" {
return false
}
ok, err := totp.ValidateCustom(passcode, secret, time.Now().UTC(), totp.ValidateOpts{
Period: period,
Skew: skew,
Digits: digits,
Algorithm: algo,
})
return err == nil && ok
}
// recoveryBytes is the entropy behind one recovery code: 20 bytes → 32 base32
// characters, the same strength as the TOTP secret it backs up.
const recoveryBytes = 20
// MintRecovery returns one fresh recovery code, in the clear, for the user to
// write down. v1 mints exactly one (controllers/mfa.go:81-82) and the console
// reads only recoveryCodes[0], so one it is.
//
// v1 uses uuid.NewString(): a v4 UUID does carry 122 bits from crypto/rand, but
// it is a value formatted to be an identifier, not a secret. This asks
// crypto/rand for a secret directly.
func MintRecovery() (string, error) {
b := make([]byte, recoveryBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
}
// HashRecovery is the digest a recovery code is STORED as. A recovery code is a
// bearer credential verified by equality alone, so — unlike the TOTP secret,
// which the verifier needs back in the clear — it hashes like a password.
func HashRecovery(plain string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
return string(h), err
}
// UseRecovery consumes one of the user's recovery codes, reporting whether code
// matched. A hit is DELETED from u.RecoveryCodes in place — one-time use
// (v1 object/mfa.go:83) — and the caller persists the row.
//
// Stored codes are bcrypt digests, but every code migrated from v1 is PLAINTEXT
// (object/mfa.go:81 compares in the clear), so a stored value that is not a
// digest is compared literally. The algorithm is a property of the stored value,
// never a constant — the same rule the password path lives by. A legacy hit is
// spent and removed like any other, so the plaintext dies on first use.
func UseRecovery(u *schema.User, code string) bool {
if u == nil || code == "" {
return false
}
for i, stored := range u.RecoveryCodes {
if !recoveryMatches(stored, code) {
continue
}
u.RecoveryCodes = append(u.RecoveryCodes[:i:i], u.RecoveryCodes[i+1:]...)
return true
}
return false
}
// recoveryMatches compares one presented code against one stored value, choosing
// the comparison from what the value IS: a bcrypt digest is verified with
// bcrypt, a v1-era plaintext by equality.
func recoveryMatches(stored, code string) bool {
if isBcrypt(stored) {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(code)) == nil
}
return stored != "" && stored == code
}
// isBcrypt reports whether s is a bcrypt digest by its PHC-style prefix
// ($2a$/$2b$/$2y$). bcrypt.Cost is the library's own parser, so the answer comes
// from the format itself rather than a hand-rolled guess.
func isBcrypt(s string) bool {
_, err := bcrypt.Cost([]byte(s))
return err == nil
}
// Enabled reports whether the user has multi-factor sign-in on. The predicate is
// PreferredMfaType != "" and nothing else (v1 object/user.go:1641-1646): the
// per-factor Mfa*Enabled flags say which factors exist, not whether the gate
// runs, so reading one of those here would let a user with a stale flag skip the
// challenge.
func Enabled(u *schema.User) bool { return u != nil && u.PreferredMfaType != "" }
// Prompt reports whether the organization REQUIRES a factor the user has not
// enrolled yet — the sign-in must divert to enrollment before it can finish.
// The user's own MfaItems override the org's entirely when present (not merge:
// v1 object/organization.go:770-792, verbatim), so a per-user policy is a
// replacement, not an addition.
func Prompt(org *schema.Organization, u *schema.User) bool {
if org == nil || u == nil {
return false
}
items := org.MfaItems
if len(u.MfaItems) > 0 {
items = u.MfaItems
}
for _, item := range items {
if item == nil || item.Rule != "Required" {
continue
}
switch item.Name {
case Email:
if !u.MfaEmailEnabled {
return true
}
case SMS:
if !u.MfaPhoneEnabled {
return true
}
case App:
if u.TotpSecret == "" {
return true
}
}
}
return false
}
// Props projects one factor of the user for a client, ALWAYS masked. v1 takes a
// `masked bool` and its false branch returns the live TOTP secret / full phone /
// full email (object/mfa.go:108-205); every caller that reaches a response
// passes true, and the one that passes false does so to hand the secret to a
// verifier. Here the verifier reads u.TotpSecret directly, so the projection has
// no unmasked mode to misuse: Secret and RecoveryCodes are never populated, and
// they are json:"-" besides. users.redact is the backstop, not the primary.
func Props(u *schema.User, mfaType string) *schema.MfaProps {
p := &schema.MfaProps{MfaType: mfaType}
if u == nil {
return p
}
switch mfaType {
case SMS:
p.Enabled = u.MfaPhoneEnabled
if p.Enabled {
p.CountryCode = u.CountryCode
}
case Email:
p.Enabled = u.MfaEmailEnabled
case App:
p.Enabled = u.TotpSecret != ""
}
if !p.Enabled {
// v1 returns a bare {enabled,mfaType} for a disabled factor and does not
// mark it preferred — preserve that shape (object/mfa.go:113-117).
return &schema.MfaProps{MfaType: mfaType}
}
p.IsPreferred = u.PreferredMfaType == mfaType
return p
}
// AllProps projects every factor this package serves, masked, in v1's order
// (object/mfa.go:99-106).
func AllProps(u *schema.User) []*schema.MfaProps {
all := make([]*schema.MfaProps, 0, len(Types))
for _, t := range Types {
all = append(all, Props(u, t))
}
return all
}
// Copy overwrites dst's multi-factor state with src's, and nothing else. It is
// the ONE declaration of which columns ARE multi-factor state, so every writer
// agrees on the set by construction: users.SaveMfa copies a caller's factors
// onto the STORED row through this, which is what makes an MFA write
// column-scoped — the request's user value never reaches the store, so it cannot
// carry isAdmin along and self-promote (internal/authz:221-226 documents that
// exact trap). Disable is the same copy from a zero user, so "which columns to
// clear" cannot drift from "which columns to write".
//
// The set is v1's eleven (object/mfa.go:207-219) plus MfaRememberDeadline: v1
// omits the deadline from disable, which leaves a future "don't ask again"
// window alive across a disable → re-enable and skips the next challenge. It is
// dark in v1 only because every live organization leaves MfaRememberInHours at
// zero, which puts every deadline in the past. Carrying the deadline with the
// state it belongs to closes it.
func Copy(dst, src *schema.User) {
if dst == nil || src == nil {
return
}
dst.PreferredMfaType = src.PreferredMfaType
dst.RecoveryCodes = src.RecoveryCodes
dst.TotpSecret = src.TotpSecret
dst.MfaPhoneEnabled = src.MfaPhoneEnabled
dst.MfaEmailEnabled = src.MfaEmailEnabled
dst.MfaRadiusEnabled = src.MfaRadiusEnabled
dst.MfaRadiusUsername = src.MfaRadiusUsername
dst.MfaRadiusProvider = src.MfaRadiusProvider
dst.MfaPushEnabled = src.MfaPushEnabled
dst.MfaPushReceiver = src.MfaPushReceiver
dst.MfaPushProvider = src.MfaPushProvider
dst.MfaRememberDeadline = src.MfaRememberDeadline
}
// Disable turns multi-factor sign-in off, clearing every column that could keep
// a factor half-alive. Clearing PreferredMfaType alone would leave TotpSecret
// behind — a secret retained past the user's request to remove it, and a factor
// that silently returns the moment anything sets a preference again.
func Disable(u *schema.User) { Copy(u, &schema.User{}) }
// Save writes u's multi-factor state — and ONLY that — onto its stored row. It
// is the single write point for every MFA mutation: enroll, disable, prefer,
// spend a recovery code, remember a device. It lives beside Copy because the two
// halves of "which columns are MFA state" and "write those columns" must not be
// able to drift; internal/users keeps the whole-row CRUD, and the two never
// overlap.
//
// The scoping is what makes it safe. The caller's user value is never the thing
// stored: the row is loaded fresh and Copy overlays exactly the multi-factor
// columns, so an isAdmin, a balance, or a password digest arriving on an MFA
// request reaches nothing. Without that, MFA enrollment — which a regular user is
// allowed to do to itself — would be a raw self-write, and therefore a
// self-promotion path (internal/authz:221-226 documents that exact trap).
func Save(ctx context.Context, db orm.DB, u *schema.User) error {
if u == nil {
return errNoUser
}
stored, err := store.GetUserByName(ctx, db, u.Owner, u.Name)
if err != nil {
return err
}
if stored == nil {
return errNoUser
}
Copy(stored, u)
return stored.UpdateCtx(ctx)
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package mfa
import (
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// errNoUser is the ONE answer to every unresolvable enrollment subject —
// missing, malformed, or simply absent. The Guard has already bound (owner,
// name) to the caller's own identity, so there is nothing here for a prober to
// learn, and one message keeps it that way.
var errNoUser = errors.New("user doesn't exist")
// The enrollment surface: the five v1 paths, unchanged (routers/router.go:398-402).
// hanzo.id serves v1's portal and the console ships its own BFF, so the wire is
// frozen — the paths, the parameter names, and the envelope are all theirs.
//
// PARAMETERS RIDE THE REQUEST FORM. Every parameter is read through httpx.Form,
// which is v1's own c.Ctx.Request.Form.Get: the query merged with the body. That
// is not a convenience — it is the only read that serves both live clients, and
// they disagree. The hanzo.id portal posts multipart FormData
// (web/src/backend/MfaBackend.ts); the console's BFF sends the query with a
// deliberately EMPTY body (console app/console/mfa/[action]/route.ts:76-87),
// because v1's own authz filter can only derive owner/name from a query — a form
// body there yields an empty object and the self-service grant never matches,
// which is the "Unauthorized operation" the BFF exists to route around.
//
// These are therefore raw handlers, not typed ops: neither client sends JSON, so
// there is no decoded body for the op-invoke seam to authorize. The authz Guard
// authorizes them instead, reading (owner, name) through the SAME httpx.Form call
// the handlers bind — one function over one buffered request, so the value
// authorized is the value executed (internal/authz formPaths).
// The five frozen paths. internal/authz names these same constants in its
// form-route set, so the route that is mounted and the route that is authorized
// are the same string.
const (
PathInitiate = "/v1/iam/mfa/setup/initiate"
PathVerify = "/v1/iam/mfa/setup/verify"
PathEnable = "/v1/iam/mfa/setup/enable"
PathDelete = "/v1/iam/delete-mfa"
PathPrefer = "/v1/iam/set-preferred-mfa"
)
// Mount registers the enrollment surface on app.
func Mount(app *zip.App, db orm.DB) {
app.Post(PathInitiate, initiate(db))
app.Post(PathVerify, verify())
app.Post(PathEnable, enable(db))
app.Post(PathDelete, remove(db))
app.Post(PathPrefer, prefer(db))
}
// initiate generates a TOTP secret + its otpauth:// URL + one recovery code, and
// persists NOTHING (v1 controllers/mfa.go:35-87). Enrollment is stateless and
// client-held until enable commits it.
//
// This is the ONE response in the whole system that carries a TOTP secret, and it
// has to: the secret IS the QR code. It returns an Enrollment, not a
// schema.MfaProps — MfaProps declares Secret and RecoveryCodes json:"-" (the
// stored/read projection must never serialize them), so answering with one would
// send an empty secret and an empty URL: a blank QR, no error, and enrollment
// silently dead. Two directions, two types.
func initiate(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
u, err := subject(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
if httpx.Form(c, "mfaType") != App {
return httpx.Err(c, "invalid multi-factor authentication type")
}
ctx := c.Context()
org, err := store.GetOrganizationByName(ctx, db, u.Owner)
if err != nil {
return httpx.Err(c, err.Error())
}
secret, url, err := Enroll(u.Owner+"/"+u.Name, Issuer(org))
if err != nil {
return httpx.Err(c, err.Error())
}
plain, err := MintRecovery()
if err != nil {
return httpx.Err(c, err.Error())
}
hours := 0
if org != nil {
hours = org.MfaRememberInHours
}
return httpx.Ok(c, &Enrollment{
MfaType: App,
Secret: secret,
URL: url,
RecoveryCodes: []string{plain},
MfaRememberInHours: hours,
})
}
}
// verify checks a passcode against the caller's own pending secret and does NOT
// enable anything (v1 controllers/mfa.go:97-171). The secret is the one initiate
// just handed this client; nothing is read from or written to any row, so there
// is no state for the check to touch.
func verify() zip.Handler {
return func(c *zip.Ctx) error {
if httpx.Form(c, "mfaType") != App {
return httpx.Err(c, "invalid multi-factor authentication type")
}
secret, passcode := httpx.Form(c, "secret"), httpx.Form(c, "passcode")
if secret == "" {
return httpx.Err(c, "totp secret is missing")
}
if passcode == "" {
return httpx.Err(c, "missing auth type or passcode")
}
if !Verify(secret, passcode) {
return httpx.Err(c, "totp passcode error")
}
return httpx.Ok(c, "OK")
}
}
// enable commits the client-held enrollment: the secret, the recovery code
// (hashed), and the preference (v1 controllers/mfa.go:182-276 +
// object/mfa_totp.go:80-95).
//
// The secret comes FROM THE REQUEST, as in v1 — enrollment is stateless, so the
// caller chooses the secret it will later be challenged against. That is only
// safe because the write is bound to the authorized subject and scoped to the MFA
// columns: users.SaveMfa overlays them onto the STORED row, so nothing else on
// this request can reach the store.
func enable(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
u, err := subject(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
if httpx.Form(c, "mfaType") != App {
return httpx.Err(c, "invalid multi-factor authentication type")
}
secret := httpx.Form(c, "secret")
if secret == "" {
return httpx.Err(c, "totp secret is missing")
}
// v1 refuses an enable with no recovery code (controllers/mfa.go:257-260):
// enrolling a factor without the way back locks the user out the first
// time the phone is lost.
if httpx.Form(c, "recoveryCodes") == "" {
return httpx.Err(c, "recovery codes is missing")
}
// The recovery code the client holds is bcrypt-hashed on the way in. v1
// stores it in the clear (object/mfa.go:81 compares plaintext), so this
// is a deliberate divergence; UseRecovery still verifies a migrated
// plaintext row, chosen from what the stored value IS.
hash, err := HashRecovery(httpx.Form(c, "recoveryCodes"))
if err != nil {
return httpx.Err(c, err.Error())
}
u.TotpSecret = secret
u.RecoveryCodes = append(u.RecoveryCodes, hash)
// Only when empty: enabling a factor must not silently re-point a user's
// preferred one (v1 object/mfa_totp.go:85-87).
if u.PreferredMfaType == "" {
u.PreferredMfaType = App
}
if err := Save(c.Context(), db, u); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, "OK")
}
}
// remove turns every factor off and answers with the resulting (masked) factor
// list (v1 controllers/mfa.go:286-308).
func remove(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
u, err := subject(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
Disable(u)
if err := Save(c.Context(), db, u); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, AllProps(u))
}
}
// prefer points the user at one of its factors — the one the gate challenges
// first (v1 controllers/mfa.go:319-341).
func prefer(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
u, err := subject(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
mfaType := httpx.Form(c, "mfaType")
// Preferring a factor that is not enrolled would make Enabled true with
// nothing to verify against: every sign-in challenges, no answer passes.
if !Props(u, mfaType).Enabled {
return httpx.Err(c, "invalid multi-factor authentication type")
}
u.PreferredMfaType = mfaType
if err := Save(c.Context(), db, u); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, AllProps(u))
}
}
// subject loads the user an enrollment request addresses, from the SAME
// httpx.Form read the authz Guard authorized (owner, name) with. Reading them
// anywhere else here — a second parse, a different precedence — is how the
// authorized value and the executed value come apart.
func subject(c *zip.Ctx, db orm.DB) (*schema.User, error) {
owner, name := httpx.Form(c, "owner"), httpx.Form(c, "name")
if owner == "" || name == "" {
return nil, errNoUser
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return nil, err
}
if u == nil {
return nil, errNoUser
}
return u, nil
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// The challenge lifecycle: the ONE primitive for a sign-in that has proven one
// thing and must prove another before a token exists. The MFA gate mints one
// when a password verifies but the second factor is outstanding; the WebAuthn
// begin endpoints mint one when the options are issued but the assertion is
// outstanding. Both finish by taking it.
//
// v1 keeps this in a beego cookie session; v2 has no key/value session store, so
// the state is a server-side row (schema.Challenge) and the client holds only
// its opaque id. It lives beside the authorization code because it is the same
// shape of value — a short-lived, single-use, opaque bearer of the right to
// continue — and this package already owns that lifecycle. It is a SIBLING of
// Token, never a Token with borrowed fields: /token resolves a grant by Code, so
// a challenge filed there would sit on the redemption path wearing a fictional
// Application.
// challengeTTL bounds a half-finished ceremony. Five minutes is the
// authorization code's own bound (codeTTL) and the ceiling the WebAuthn timeout
// implies — long enough to read a code off a phone, short enough that an
// abandoned challenge is not a standing key to an account whose password is
// already known.
const challengeTTL = 5 * time.Minute
// The challenge kinds. Each names the proof still outstanding, and a taker
// demands its own kind: a WebAuthn registration challenge must never satisfy the
// MFA gate, which would turn "I started enrolling a passkey" into "I passed the
// second factor".
const (
KindMfa = "mfa"
KindRegistration = "registration"
KindAuthentication = "authentication"
)
// ErrChallenge is the ONE opaque failure for every way a challenge can be
// refused — unknown, expired, spent, or the wrong kind. They collapse to one
// answer so a prober cannot tell a spent challenge from a forged one.
var ErrChallenge = errors.New("the multi-factor session has expired")
// challengeOwner files every challenge under the reserved admin org. A challenge
// is the authorization server's own state, not a tenant record: it is never
// listed, never served by an entity route, and its subject is the only tenancy
// that matters (and rides inside it, verified). Naming the subject's org here
// would put a tenant slug in the key of a row nobody may read anyway.
const challengeOwner = "admin"
// MintChallenge persists a fresh challenge for subject ("owner/name") and
// returns its opaque id. payload is the kind's own state — go-webauthn
// SessionData JSON for a ceremony, the just-used verification type for the MFA
// gate. now is injected for testability.
func MintChallenge(ctx context.Context, db orm.DB, kind, subject, payload string, now time.Time) (string, error) {
id, err := newOpaqueToken()
if err != nil {
return "", err
}
c := orm.New[schema.Challenge](db)
c.Owner = challengeOwner
c.Name = id
c.CreatedTime = now.UTC().Format(time.RFC3339)
c.Kind = kind
c.Subject = subject
c.Payload = payload
c.ExpireIn = now.Add(challengeTTL).Unix()
c.SetId(challengeOwner + "/" + id)
if err := c.CreateCtx(ctx); err != nil {
return "", err
}
return id, nil
}
// TakeChallenge resolves and SPENDS a challenge of the given kind, returning it.
// Taking is the only read: a challenge that is found is immediately marked used,
// so a replay of the same id loses whether it races or follows. The caller gets
// the subject from the returned row and nowhere else — never from a request
// parameter (invariant 3), so a body naming another user cannot redirect the
// ceremony.
//
// Every refusal is ErrChallenge.
func TakeChallenge(ctx context.Context, db orm.DB, id, kind string, now time.Time) (*schema.Challenge, error) {
if id == "" {
return nil, ErrChallenge
}
c, err := orm.Get[schema.Challenge](db, challengeOwner+"/"+id)
if err != nil || c == nil {
return nil, ErrChallenge
}
if c.Used || c.Kind != kind || now.Unix() > c.ExpireIn {
return nil, ErrChallenge
}
c.Used = true
if err := c.UpdateCtx(ctx); err != nil {
return nil, ErrChallenge
}
return c, nil
}
// challengeCookie carries the challenge id to the client exactly the way v1
// carries its beego session: a host-only, HttpOnly cookie the browser returns on
// the finishing request. Every live client already sends credentials with these
// calls (web/src/auth/LoginPage.tsx:421,449 and the MFA form), so the frozen wire
// needs no new field. Script cannot read it; it is bound to the ceremony's own
// short life.
const challengeCookie = "hanzo_challenge"
// SetChallenge writes the challenge id for the finishing request to return.
// HttpOnly keeps script out of it; SameSite=Lax lets the portal's own POST carry
// it while refusing a cross-site one; the MaxAge matches the row's TTL so the
// browser forgets it exactly when the server does.
func SetChallenge(c *zip.Ctx, id string) {
c.Fiber().Cookie(&fiber.Cookie{
Name: challengeCookie,
Value: id,
Path: "/",
MaxAge: int(challengeTTL / time.Second),
HTTPOnly: true,
Secure: true,
SameSite: "Lax",
})
}
// ClearChallenge expires the cookie once its challenge is spent, so a finished
// ceremony leaves nothing behind to replay.
func ClearChallenge(c *zip.Ctx) {
c.Fiber().Cookie(&fiber.Cookie{
Name: challengeCookie,
Value: "",
Path: "/",
MaxAge: -1,
HTTPOnly: true,
Secure: true,
SameSite: "Lax",
})
}
// ReadChallenge returns the challenge id a finishing request presents: the body
// field when one is given (an SDK holding no cookie jar), else the cookie the
// browser returned. ONE function, ONE precedence, called once per request — the
// id is the bearer of the ceremony either way, and the row it names is
// single-use, short-lived, and carries its own subject, so neither source can
// widen what it proves.
func ReadChallenge(c *zip.Ctx, fromBody string) string {
if fromBody != "" {
return fromBody
}
return c.Fiber().Cookies(challengeCookie)
}
+302 -48
View File
@@ -5,11 +5,13 @@ package oidc
import (
"context"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/mfa"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/internal/users"
@@ -22,13 +24,21 @@ import (
// then exchanges it at /v1/iam/oauth/token. Login by EMAIL or USERNAME.
//
// This is the interactive-flow counterpart to the token endpoint: login mints
// the code, /token redeems it. Password verification is bcrypt (constant-time),
// never plaintext, and the hash never crosses a response.
// the code, /token redeems it. The password is verified against the row's own
// digest and never crosses a response.
//
// The route serves TWO requests, because v1 does (controllers/auth.go:905 and
// :1290): the credential post, and — when a challenge is outstanding — the
// second-factor post that finishes it. They are one endpoint because the client
// posts to one endpoint; they are separate branches because they prove different
// things. A code is minted only past the gate, on either path.
// PathLogin is the canonical credential-login endpoint.
const PathLogin = "/v1/iam/login"
// loginForm is the request body the SDK/portal posts.
// loginForm is the request body the SDK/portal posts. The first block is the
// credential post; MfaType/Passcode/RecoveryCode/EnableMfaRemember/Challenge are
// the second-factor post that answers a challenge this endpoint issued.
type loginForm struct {
Application string `json:"application"`
Organization string `json:"organization"`
@@ -45,8 +55,31 @@ type loginForm struct {
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
Resource string `json:"resource"`
// The second factor. Challenge names the outstanding ceremony; a browser
// returns it in the cookie the gate set and leaves this empty.
MfaType string `json:"mfaType"`
Passcode string `json:"passcode"`
RecoveryCode string `json:"recoveryCode"`
EnableMfaRemember bool `json:"enableMfaRemember"`
Challenge string `json:"challenge"`
}
// The gate's two answers, verbatim from v1 (object/mfa.go:50-54). They are the
// literal STRING the client compares against in the envelope's `data` — the
// portal (web/src/auth/LoginPage.tsx:248) and the console's iam-login.ts both
// branch on it — so they are wire format, not internal names. Any other shape
// and the client reads the answer as an authorization code and the factor is
// skipped.
const (
// RequiredMfa — the organization requires a factor this user has not
// enrolled; the client must divert to enrollment.
RequiredMfa = "RequiredMfa"
// NextMfa — the user has factors; data2 carries the allowed ones and the
// client must post one back. NO code is minted with this answer.
NextMfa = "NextMfa"
)
// MountLogin registers POST /v1/iam/login.
func MountLogin(app *zip.App, db orm.DB) {
app.Post(PathLogin, loginHandler(db))
@@ -58,10 +91,20 @@ func loginHandler(db orm.DB) zip.Handler {
if err := c.Bind(&f); err != nil {
return httpx.Err(c, "invalid request body")
}
ctx := c.Context()
// A post carrying no credential but naming an outstanding challenge is
// the second half of a sign-in this endpoint already gated. The user
// comes from the challenge, never from the body (invariant 3).
if f.Username == "" && f.Password == "" {
if id := ReadChallenge(c, f.Challenge); id != "" {
return finishMfa(c, db, id, f)
}
}
if f.Organization == "" || f.Username == "" || f.Password == "" {
return httpx.Err(c, "organization, username and password are required")
}
ctx := c.Context()
user, err := resolveLoginUser(ctx, db, f.Organization, f.Username)
if err != nil {
@@ -73,60 +116,271 @@ func loginHandler(db orm.DB) zip.Handler {
return httpx.Err(c, "the username or password is incorrect")
}
userID := user.Owner + "/" + user.Name
// type=login: a bare portal sign-in. Session issuance lands with the
// session layer; for now report success + the user id (the shape the
// portal expects for a non-OAuth sign-in).
if f.Type != "code" {
return httpx.Ok(c, userID)
}
// type=code: mint a PKCE-bound authorization code for the OAuth flow.
app, err := resolveLoginApp(ctx, db, f)
// The password proved ONE factor. Everything past this point is the
// second: the gate answers the request itself when a factor is
// outstanding, and only a fall-through reaches a token.
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application does not exist")
}
// Tenant isolation: the authenticated user's organization must be
// permitted for this application — its own org, a shared app, or an app
// that lets users choose their org. Without this a user in one tenant
// could obtain a token whose `organization` claim names another tenant.
if f.Organization != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return httpx.Err(c, "the user is not permitted to sign in to this application")
}
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
// the token endpoint re-checks it. A supplied-but-unregistered URI is
// refused — never minted against.
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
return httpx.Err(c, "invalid redirect_uri")
}
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
if f.CodeChallenge != "" && method != "S256" {
return httpx.Err(c, "only S256 PKCE is supported")
}
// A public client (no secret) must use PKCE — no downgrade.
if app.ClientSecret == "" && f.CodeChallenge == "" {
return httpx.Err(c, "PKCE is required for public clients")
}
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
// verificationType is the factor JUST used. A password proves none of
// the offerable factors, so it excludes nothing ("" — v1
// controllers/auth.go:905 passes the same).
gated, err := gate(c, db, user, org, "")
if err != nil {
return httpx.Err(c, err.Error())
}
// Bind the redirect_uri and nonce onto the code so the token exchange can
// re-verify the redirect and echo the nonce into the id_token.
code.RedirectUri = f.RedirectUri
code.Nonce = f.Nonce
if err := store.PersistToken(ctx, db, code); err != nil {
return httpx.Err(c, err.Error())
if gated {
return nil
}
// The SDK reads data as the authorization code to exchange at /token.
return httpx.Ok(c, code.Code)
return grant(c, db, user, f)
}
}
// gate is the second-factor decision — the ONE place a sign-in is held. It
// answers the request itself and reports true when it did; a false means this
// principal has proven everything it owes and the caller may mint.
//
// Every path that signs a user in calls this BEFORE minting: the credential post
// above, and — when they land — the social/OAuth branch where the account
// already exists (v1 controllers/auth.go:1054) and the Web3 branch (v1
// controllers/web3_auth.go:229). v1 shipped the social one late, in 843e74f4,
// because an account-takeover fix exposed that "sign in with Google" walked past
// the factor entirely. One function, every call site — a gate that exists in one
// branch is not a gate.
//
// verificationType names the factor the caller already proved, so the challenge
// never offers it back (a code texted to a phone must not be answerable by
// texting that phone again). "" excludes nothing.
func gate(c *zip.Ctx, db orm.DB, user *schema.User, org *schema.Organization, verificationType string) (bool, error) {
ctx := c.Context()
// The organization REQUIRES a factor this user has not enrolled: the answer
// is enrollment, not a challenge (v1 controllers/auth.go:515-520).
if mfa.Prompt(org, user) {
return true, httpx.Ok(c, RequiredMfa)
}
if !mfa.Enabled(user) {
return false, nil
}
// "Remember this device" — a deadline in the FUTURE skips the factor
// (v1 controllers/auth.go:523-527). Written by finishMfa with the same
// nowRFC3339 the parse below expects; a format the parser cannot read is
// treated as no deadline, so a bad value re-challenges rather than
// silently granting a permanent skip.
if remembered(user, nowFunc()) {
return false, nil
}
allow := allowList(user, org, verificationType)
if len(allow) == 0 {
// Every factor is either the one just used or not actually enrolled:
// there is nothing left to ask for (v1 falls through the same way).
return false, nil
}
id, err := MintChallenge(ctx, db, KindMfa, user.Owner+"/"+user.Name, verificationType, nowFunc())
if err != nil {
return true, err
}
SetChallenge(c, id)
// data is the STRING "NextMfa"; data2 carries the factors. No code is
// minted here — that is the whole point of the gate.
return true, httpx.Ok(c, NextMfa, allow)
}
// allowList is the factors a challenge may be answered with: enrolled, and not
// the one the caller just used (v1 controllers/auth.go:528-544). Each carries
// the org's remember window so the client can offer "don't ask again".
func allowList(user *schema.User, org *schema.Organization, verificationType string) []*schema.MfaProps {
hours := 0
if org != nil {
hours = org.MfaRememberInHours
}
allow := []*schema.MfaProps{}
for _, p := range mfa.AllProps(user) {
if !p.Enabled || p.MfaType == verificationType {
continue
}
p.MfaRememberInHours = hours
allow = append(allow, p)
}
return allow
}
// remembered reports whether the user's "don't ask again" window is still open.
// An unparsable or empty deadline is not a skip: this fails CLOSED, to the
// challenge.
func remembered(user *schema.User, now time.Time) bool {
if user.MfaRememberDeadline == "" {
return false
}
deadline, err := time.Parse(time.RFC3339, user.MfaRememberDeadline)
return err == nil && deadline.After(now)
}
// finishMfa answers an outstanding challenge. The user is loaded from the
// CHALLENGE's subject — never from the request — so a body naming another
// account cannot redirect the ceremony (invariant 3). Taking the challenge
// spends it, so a passcode replayed against the same id loses.
func finishMfa(c *zip.Ctx, db orm.DB, id string, f loginForm) error {
ctx := c.Context()
ch, err := TakeChallenge(ctx, db, id, KindMfa, nowFunc())
if err != nil {
return httpx.Err(c, err.Error())
}
ClearChallenge(c)
owner, name, _ := strings.Cut(ch.Subject, "/")
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return httpx.Err(c, err.Error())
}
if user == nil {
return httpx.Err(c, ErrChallenge.Error())
}
switch {
case f.Passcode != "":
// The challenge's payload is the factor already used to get here.
// Answering with that same factor proves nothing new (v1
// controllers/auth.go:1325-1328).
if f.MfaType == "" || f.MfaType == ch.Payload {
return httpx.Err(c, "invalid multi-factor authentication type")
}
if f.MfaType != mfa.App {
// Only TOTP has a verifier here. Refuse anything else rather than
// wave it through: a factor with no verification is not a factor.
return httpx.Err(c, "invalid multi-factor authentication type")
}
if !mfa.Verify(user.TotpSecret, f.Passcode) {
return httpx.Err(c, "the multi-factor authentication code is incorrect")
}
case f.RecoveryCode != "":
// A recovery code is one-time: the hit is removed and the row written
// whether or not the rest of the sign-in succeeds, so a code cannot be
// spent twice (v1 object/mfa.go:73-96).
if !mfa.UseRecovery(user, f.RecoveryCode) {
return httpx.Err(c, "the recovery code is incorrect")
}
if err := mfa.Save(ctx, db, user); err != nil {
return httpx.Err(c, err.Error())
}
default:
return httpx.Err(c, "missing passcode or recovery code")
}
if f.EnableMfaRemember {
if err := remember(ctx, db, user); err != nil {
return httpx.Err(c, err.Error())
}
}
return grant(c, db, user, f)
}
// remember opens the "don't ask again" window: now + the ORG's
// MfaRememberInHours (v1 controllers/auth.go:1350-1360). A zero window — every
// live organization today — yields a deadline already in the past, so the gate
// keeps challenging. That is the shipped behavior and it is preserved: turning a
// zero into "forever" would silently disable the factor for every tenant.
func remember(ctx context.Context, db orm.DB, user *schema.User) error {
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return err
}
hours := 0
if org != nil {
hours = org.MfaRememberInHours
}
// Written with the SAME format `remembered` parses — a mismatch here is a
// permanent skip or a permanent challenge, silently.
user.MfaRememberDeadline = nowFunc().UTC().Add(time.Duration(hours) * time.Hour).Format(time.RFC3339)
return mfa.Save(ctx, db, user)
}
// grant completes a sign-in that has passed the gate: the bare-session answer,
// or a PKCE-bound authorization code for the OAuth flow.
func grant(c *zip.Ctx, db orm.DB, user *schema.User, f loginForm) error {
ctx := c.Context()
userID := user.Owner + "/" + user.Name
// type=login: a bare portal sign-in. Session issuance lands with the
// session layer; for now report success + the user id (the shape the
// portal expects for a non-OAuth sign-in).
if f.Type != "code" {
return httpx.Ok(c, userID)
}
// type=code: mint a PKCE-bound authorization code for the OAuth flow.
app, err := resolveLoginApp(ctx, db, f)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application does not exist")
}
// Tenant isolation: the authenticated user's organization must be
// permitted for this application — its own org, a shared app, or an app
// that lets users choose their org. Without this a user in one tenant
// could obtain a token whose `organization` claim names another tenant.
// The org is the USER's own, from the loaded row, so a second-factor post
// (which carries no organization field) is checked exactly like the first.
if user.Owner != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return httpx.Err(c, "the user is not permitted to sign in to this application")
}
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
// the token endpoint re-checks it. A supplied-but-unregistered URI is
// refused — never minted against.
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
return httpx.Err(c, "invalid redirect_uri")
}
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
if f.CodeChallenge != "" && method != "S256" {
return httpx.Err(c, "only S256 PKCE is supported")
}
// A public client (no secret) must use PKCE — no downgrade.
if app.ClientSecret == "" && f.CodeChallenge == "" {
return httpx.Err(c, "PKCE is required for public clients")
}
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
if err != nil {
return httpx.Err(c, err.Error())
}
// Bind the redirect_uri and nonce onto the code so the token exchange can
// re-verify the redirect and echo the nonce into the id_token.
code.RedirectUri = f.RedirectUri
code.Nonce = f.Nonce
if err := store.PersistToken(ctx, db, code); err != nil {
return httpx.Err(c, err.Error())
}
// The SDK reads data as the authorization code to exchange at /token.
return httpx.Ok(c, code.Code)
}
// GrantWebauthn completes a passkey sign-in through the SAME grant the password
// path uses. v1 puts the OAuth params of a webauthn finish in the QUERY
// (controllers/webauthn.go:175-176 + web/src/auth/LoginPage.tsx:444), so they are
// read there and folded into the one form the grant understands — one minting
// path, not a second copy of the PKCE and redirect rules.
//
// Note the query key is `challengeMethod`, not `codeChallengeMethod`: that is
// what the portal sends on this route.
func GrantWebauthn(c *zip.Ctx, db orm.DB, u *schema.User) error {
return grant(c, db, u, loginForm{
Type: c.Query("responseType"),
ClientId: c.Query("clientId"),
RedirectUri: c.Query("redirectUri"),
State: c.Query("state"),
Scope: c.Query("scope"),
Nonce: c.Query("nonce"),
CodeChallenge: c.Query("codeChallenge"),
CodeChallengeMethod: c.Query("challengeMethod"),
Resource: c.Query("resource"),
})
}
// resolveLoginUser looks a user up by email (contains "@") or username, scoped
// to the org.
func resolveLoginUser(ctx context.Context, db orm.DB, org, identifier string) (*schema.User, error) {
+524
View File
@@ -0,0 +1,524 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/hanzoai/orm"
"github.com/pquerna/otp/totp"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/mfa"
"github.com/hanzoai/iam2/internal/schema"
)
// The MFA gate at login, driven through the REAL mounted router. The contract is
// not a status code: every one of these answers is a 200, because the envelope
// carries the outcome. What matters is WHICH answer, and — the point of the whole
// gate — whether a token row exists afterwards. A test that checked only the
// status would pass while every 2FA user signed in with a password alone.
// newApp mounts the OIDC surface on an EXISTING store, so a test can seed the
// same db the router serves (newServer opens its own).
func newApp(t *testing.T, db orm.DB) *zip.App {
t.Helper()
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
Mount(app, db)
return app
}
// enrolled seeds a user with a password AND a live TOTP factor, returning the
// TOTP secret.
func enrolled(t *testing.T, db orm.DB, name, password string) string {
t.Helper()
seedUser(t, db, name, name+"@hanzo.ai", password)
secret, _, err := mfa.Enroll("hanzo/"+name, "Hanzo")
if err != nil {
t.Fatal(err)
}
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
if err != nil {
t.Fatal(err)
}
u.TotpSecret = secret
u.PreferredMfaType = mfa.App
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
return secret
}
// tokens counts persisted token rows — the store-side proof that no credential
// was minted. The gate's whole job is that this stays zero until the second
// factor lands.
func tokens(t *testing.T, db orm.DB) int {
t.Helper()
n, err := orm.TypedQuery[schema.Token](db).Count(context.Background())
if err != nil {
t.Fatal(err)
}
return n
}
// passcode computes the code an authenticator would show right now.
func passcode(t *testing.T, secret string) string {
t.Helper()
code, err := totp.GenerateCode(secret, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
return code
}
// challengeOf extracts the challenge id the gate set as a cookie.
func challengeOf(t *testing.T, resp *http.Response) string {
t.Helper()
for _, ck := range resp.Cookies() {
if ck.Name == challengeCookie && ck.Value != "" {
return ck.Value
}
}
t.Fatal("the gate set no challenge cookie")
return ""
}
// TestEnrolledUserIsChallengedAndGetsNoToken is THE regression. Before the gate,
// login verified the password and minted a code directly: an enrolled user signed
// in with one factor and the second was never asked for. Not a missing feature —
// a silent downgrade of every 2FA account.
func TestEnrolledUserIsChallengedAndGetsNoToken(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "correct horse battery staple")
resp, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "correct horse battery staple",
"type": "code", "clientId": "hanzo-app",
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("gate answered an error: %v", m["msg"])
}
// `data` is the literal string the portal compares against. Any other shape
// and the client reads it as an authorization code.
if m["data"] != NextMfa {
t.Fatalf("data = %q, want %q — the client treats anything else as a code, so MFA is bypassed", m["data"], NextMfa)
}
// data2 carries the factors to choose from.
list, ok := m["data2"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("data2 = %#v, want exactly the one enrolled factor", m["data2"])
}
got := list[0].(map[string]any)
if got["mfaType"] != mfa.App || got["enabled"] != true {
t.Fatalf("offered factor = %#v, want the enabled app factor", got)
}
// The masked projection must not carry the shared secret out.
if s := string(body); strings.Contains(s, "secret") || strings.Contains(s, "recoveryCodes") {
t.Fatalf("the challenge leaked secret material: %s", s)
}
// THE assertion: nothing was minted.
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted at the challenge — the password alone bought a credential", n)
}
challengeOf(t, resp)
}
// TestChallengeAnsweredWithPasscodeMintsCode — the happy path: the second factor
// lands and the code appears.
func TestChallengeAnsweredWithPasscodeMintsCode(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret),
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("the correct passcode was refused: %v", m["msg"])
}
code, _ := m["data"].(string)
if code == "" || code == NextMfa || code == RequiredMfa {
t.Fatalf("data = %q, want an authorization code", m["data"])
}
tok, err := store2GetTokenByCode(db, code)
if err != nil || tok == nil {
t.Fatalf("the minted code resolves to no token row: %v", err)
}
if tok.User != "hanzo/alice" {
t.Fatalf("code bound to %q, want hanzo/alice", tok.User)
}
}
// TestWrongPasscodeMintsNothing — a failed second factor must leave the sign-in
// exactly where it was: nowhere.
func TestWrongPasscodeMintsNothing(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": mfa.App, "passcode": "000000",
}))
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("a wrong passcode was accepted: %#v", m)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted for a wrong passcode", n)
}
}
// TestChallengeIsSingleUse — a challenge is spent by the attempt that takes it,
// so a captured id cannot be replayed. The wrong passcode below spends it; the
// RIGHT passcode afterwards must still fail, on the challenge and not the code.
func TestChallengeIsSingleUse(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
first := map[string]any{"type": "code", "clientId": "hanzo-app", "challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret)}
if m := decode(t, mustBody(t, app, first)); m["status"] != "ok" {
t.Fatalf("first use failed: %v", m["msg"])
}
// Same id, same valid passcode, second time.
m := decode(t, mustBody(t, app, first))
if m["status"] != "error" {
t.Fatalf("a spent challenge was accepted again: %#v", m)
}
if m["msg"] != ErrChallenge.Error() {
t.Fatalf("msg = %q, want the challenge refusal %q", m["msg"], ErrChallenge.Error())
}
}
// TestChallengeBindsItsOwnSubject — invariant 3. A challenge minted for alice
// must resolve alice even when the body names mallory. The user comes from the
// verified server-side record, never from a request parameter.
func TestChallengeBindsItsOwnSubject(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
seedUser(t, db, "mallory", "mallory@hanzo.ai", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret),
// The body tries to redirect the ceremony at another account.
"username": "", "organization": "hanzo", "name": "mallory",
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("the ceremony failed: %v", m["msg"])
}
tok, err := store2GetTokenByCode(db, m["data"].(string))
if err != nil || tok == nil {
t.Fatal("no token row for the minted code")
}
if tok.User != "hanzo/alice" {
t.Fatalf("code bound to %q — the body redirected the challenge's subject", tok.User)
}
}
// TestRecoveryCodeIsAcceptedOnceAndStoredHashed proves three things at once: a
// recovery code answers the challenge, it is CONSUMED (a second use fails), and
// what sits in the row is a bcrypt digest — never the code itself.
func TestRecoveryCodeIsAcceptedOnceAndStoredHashed(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "pw")
plain, err := mfa.MintRecovery()
if err != nil {
t.Fatal(err)
}
hash, err := mfa.HashRecovery(plain)
if err != nil {
t.Fatal(err)
}
u := userRow(t, db, "alice")
u.RecoveryCodes = []string{hash}
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
if strings.Contains(hash, plain) {
t.Fatal("the stored value contains the plaintext recovery code")
}
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": id, "recoveryCode": plain,
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("the recovery code was refused: %v", m["msg"])
}
if code, _ := m["data"].(string); code == "" || code == NextMfa {
t.Fatalf("data = %q, want an authorization code", m["data"])
}
// Spent: the row no longer carries it.
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
t.Fatalf("recovery codes after use = %v, want none — a one-time code survived", got)
}
// And a second sign-in cannot reuse it.
resp2, _ := do(t, app, jsonReq("POST", PathLogin, login))
_, body2 := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp2), "recoveryCode": plain,
}))
if m2 := decode(t, body2); m2["status"] != "error" {
t.Fatalf("a spent recovery code signed in a second time: %#v", m2)
}
}
// TestLegacyPlaintextRecoveryCodeStillVerifies — every recovery code migrated
// from v1 is PLAINTEXT (object/mfa.go:81 compares in the clear). The algorithm is
// a property of the stored value, so a legacy row must still verify, and the
// plaintext must die on first use.
func TestLegacyPlaintextRecoveryCodeStillVerifies(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "pw")
const legacy = "0d5a7f0e-3a1e-4a1a-9f6c-2b1d3e4f5a6b" // a v1 uuid.NewString() code
u := userRow(t, db, "alice")
u.RecoveryCodes = []string{legacy}
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
}))
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp), "recoveryCode": legacy,
}))
if m := decode(t, body); m["status"] != "ok" {
t.Fatalf("a migrated v1 plaintext recovery code was refused: %v — every live 2FA user's way back is gone", m["msg"])
}
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
t.Fatalf("the legacy plaintext survived its use: %v", got)
}
}
// TestPasscodeRefusedWhenItRepeatsTheUsedFactor — v1 controllers/auth.go:1325.
// The factor already used to get here cannot answer for the one still owed.
func TestPasscodeRefusedWhenItRepeatsTheUsedFactor(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
u := userRow(t, db, "alice")
// A challenge whose payload says "the app factor was already used".
id, err := MintChallenge(context.Background(), db, KindMfa, "hanzo/"+u.Name, mfa.App, time.Now())
if err != nil {
t.Fatal(err)
}
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": mfa.App, "passcode": passcode(t, secret),
}))
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("the just-used factor answered its own challenge: %#v", m)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted", n)
}
}
// TestRememberDeadlineRoundTrips — the "don't ask again" window short-circuits
// the whole gate, so the value the writer writes must be the value the reader
// reads. A format mismatch is silent: a permanent skip, or a permanent challenge.
func TestRememberDeadlineRoundTrips(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
// An org with a real remember window (live orgs leave it at zero).
o := orm.New[schema.Organization](db)
o.Owner, o.Name, o.MfaRememberInHours = "admin", "hanzo", 24
o.SetId("admin/hanzo")
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatal(err)
}
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
"mfaType": mfa.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
}))
if m := decode(t, body); m["status"] != "ok" {
t.Fatalf("the passcode was refused: %v", m["msg"])
}
// The exact stored string must parse for the exact reader the gate uses.
stored := userRow(t, db, "alice").MfaRememberDeadline
if stored == "" {
t.Fatal("enableMfaRemember wrote no deadline")
}
if !remembered(userRow(t, db, "alice"), time.Now()) {
t.Fatalf("the gate cannot read back the deadline it wrote (%q) — the window is silently dead", stored)
}
// A future deadline SKIPS the challenge: the next password login mints.
_, body2 := do(t, app, jsonReq("POST", PathLogin, login))
m2 := decode(t, body2)
if m2["data"] == NextMfa {
t.Fatal("a live remember window still challenged")
}
if code, _ := m2["data"].(string); code == "" {
t.Fatalf("remembered login did not mint: %#v", m2)
}
// A PAST deadline challenges again.
u := userRow(t, db, "alice")
u.MfaRememberDeadline = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
_, body3 := do(t, app, jsonReq("POST", PathLogin, login))
if m3 := decode(t, body3); m3["data"] != NextMfa {
t.Fatalf("an expired remember window skipped the gate: %#v", m3)
}
}
// TestZeroRememberWindowStillChallenges pins the LIVE configuration: every
// organization today leaves MfaRememberInHours at zero, which puts the deadline
// in the past the instant it is written. "Fixing" a zero into an always-on skip
// would turn 2FA off for every tenant at once.
func TestZeroRememberWindowStillChallenges(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
"mfaType": mfa.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
}))
_, body := do(t, app, jsonReq("POST", PathLogin, login))
if m := decode(t, body); m["data"] != NextMfa {
t.Fatalf("a zero remember window skipped the gate: %#v — 2FA is off for every live org", m)
}
}
// TestOrgRequiredFactorPromptsEnrollment — v1 object/organization.go:770. The org
// demands a factor the user has not enrolled, so the answer is "go enroll", not a
// challenge it could never answer.
func TestOrgRequiredFactorPromptsEnrollment(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw") // no factor
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", "hanzo"
o.MfaItems = []*schema.MfaItem{{Name: mfa.App, Rule: "Required"}}
o.SetId("admin/hanzo")
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatal(err)
}
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
}))
m := decode(t, body)
if m["data"] != RequiredMfa {
t.Fatalf("data = %q, want %q", m["data"], RequiredMfa)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted while a required factor was missing", n)
}
}
// TestUnenrolledUserSignsInUnchanged — the gate must be invisible to everyone
// else. A user with no factor still logs in with a password, exactly as before.
func TestUnenrolledUserSignsInUnchanged(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "bob", "password": "pw", "type": "code", "clientId": "hanzo-app",
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("an unenrolled user was refused: %v", m["msg"])
}
if code, _ := m["data"].(string); code == "" || code == NextMfa || code == RequiredMfa {
t.Fatalf("data = %q, want an authorization code", m["data"])
}
}
// --- helpers ---
func mustBody(t *testing.T, app *zip.App, body any) []byte {
t.Helper()
_, b := do(t, app, jsonReq("POST", PathLogin, body))
return b
}
func userRow(t *testing.T, db orm.DB, name string) *schema.User {
t.Helper()
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
if err != nil {
t.Fatal(err)
}
return u
}
func store2GetTokenByCode(db orm.DB, code string) (*schema.Token, error) {
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
+2
View File
@@ -24,6 +24,7 @@ import (
"github.com/hanzoai/iam2/internal/certs"
"github.com/hanzoai/iam2/internal/invitations"
"github.com/hanzoai/iam2/internal/keys"
"github.com/hanzoai/iam2/internal/mfa"
"github.com/hanzoai/iam2/internal/oidc"
"github.com/hanzoai/iam2/internal/organizations"
"github.com/hanzoai/iam2/internal/permission"
@@ -58,6 +59,7 @@ func Mount(app *zip.App, db orm.DB) {
// from. One entry point wires the whole identity core.
oidc.Mount(app, db)
mfa.Mount(app, db)
users.Mount(app, db)
organizations.Mount(app, db)
applications.Mount(app, db)
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Challenge is a half-finished authentication ceremony: the server-side memory
// of a sign-in that has proven one thing and must prove another before a token
// exists. It is minted by the MFA gate at login (the password verified, the
// second factor outstanding) and by the WebAuthn begin endpoints (the options
// issued, the assertion outstanding), and it is consumed exactly once by the
// matching finish.
//
// v1 keeps this in a beego cookie session — MfaSessionUserId (object/mfa.go:51),
// "registration"/"authentication" (controllers/webauthn.go:68,162). v2 has no
// key/value session store, so the state is an owner-scoped row and the client
// holds only its opaque id.
//
// It is a SIBLING of Token, never a Token with borrowed fields. A Token is a
// grant: /v1/iam/oauth/token resolves one by Code and mints an access token from
// it. Filing a challenge there would put a row on that lookup whose Application
// and Scope are fictions, and a fiction on the redemption path is an access
// token waiting to be minted from a half-authenticated ceremony. The two are
// different values with different lifecycles, so they are different entities.
//
// Identity is the (Owner, Name) pair and the orm string key is "owner/name";
// Name is the opaque id the client returns. Subject is the "owner/name" of the
// principal the ceremony is for — the ONE place a finish learns whom it is
// acting as (invariant 3: never a request parameter). Payload is the kind's own
// state: the go-webauthn SessionData JSON for a ceremony, the just-used
// verification type for an MFA challenge (v1's "verificationCodeType" session
// key, controllers/auth.go:539). Used makes it one-shot; ExpireIn (unix) bounds
// it. Id carries orm:"index" because the client presents the id alone.
type Challenge struct {
orm.Model[Challenge]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime"`
Kind string `json:"kind"`
Subject string `json:"subject"`
Payload string `json:"payload"`
Used bool `json:"used"`
ExpireIn int64 `json:"expireIn"`
}
+9 -1
View File
@@ -1,7 +1,9 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package schema declares the thirteen IAM v2 identity entities on
// hanzoai/orm.
// hanzoai/orm, plus Challenge — the one record that is not an identity but the
// server-side memory of a half-finished sign-in (v1 keeps it in a cookie
// session, which v2 has no store for).
//
// Each entity embeds orm.Model[T]; every kind is registered exactly once in
// this file's init(). orm stores every entity as one row in a single
@@ -31,6 +33,11 @@ func Kinds() []string {
"roles", "permissions", "certs", "keys",
"webauthn_credentials", "sessions", "tokens", "audit_logs",
"invitations",
// challenges has no v1 table to compare against: v1 keeps a
// half-finished ceremony in a beego cookie session (object/mfa.go:51,
// controllers/webauthn.go:68), so there is nothing to migrate — the rows
// are minted fresh and expire within minutes.
"challenges",
}
}
@@ -48,4 +55,5 @@ func init() {
orm.Register[Token]("tokens")
orm.Register[AuditLog]("audit_logs")
orm.Register[Invitation]("invitations")
orm.Register[Challenge]("challenges")
}
+15
View File
@@ -51,6 +51,21 @@ func GetUserByName(_ context.Context, db orm.DB, owner, name string) (*schema.Us
return u, err
}
// GetOrganizationByName resolves a tenant by name. Organizations are filed under
// the reserved admin org (v1 object/organization.go composes "admin/<name>"), so
// the caller supplies only the tenant slug — a user's Owner IS this name.
// Returns (nil, nil) when absent.
func GetOrganizationByName(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
if name == "" {
return nil, nil
}
o, err := orm.TypedQuery[schema.Organization](db).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return o, err
}
// GetUserByEmail resolves a user by (owner, email) — the email-login identifier.
func GetUserByEmail(_ context.Context, db orm.DB, owner, email string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Email=", email).First()
+419
View File
@@ -0,0 +1,419 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package webauthn
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
wa "github.com/go-webauthn/webauthn/webauthn"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/oidc"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// The WebAuthn ceremony — the four v1 paths, unchanged (routers/router.go:393-396).
// The CRUD above is the passkey REGISTRY; this is how a passkey comes to exist and
// how it signs someone in.
//
// A passkey lives in exactly ONE home: the WebauthnCredential entity. v1 keeps
// the blob inline on the user row (user.WebauthnCredentials) and v2 retains that
// column for migration, but the ceremony reads and writes only the entity — two
// homes would mean a passkey registered here does not authenticate there.
//
// The begin endpoints answer RAW protocol JSON, not the {status,msg,data}
// envelope: the browser feeds the body straight to navigator.credentials, so a
// wrapper is a broken ceremony (v1 controllers/webauthn.go:69-70 does the same
// with c.Data["json"] + ServeJSON).
//
// The four routes split on WHO they are for, and the split is a trust boundary:
// - signup/* REGISTERS a credential and needs a bearer. Its subject is that
// bearer and nothing else, so it can only ever add a credential to the
// caller's own account (internal/authz bearerBound).
// - signin/* AUTHENTICATES with a credential, so no bearer can exist yet. Both
// halves are public and gate themselves on possession of the private key.
// The four frozen paths.
const (
PathSignupBegin = "/v1/iam/webauthn/signup/begin"
PathSignupFinish = "/v1/iam/webauthn/signup/finish"
PathSigninBegin = "/v1/iam/webauthn/signin/begin"
PathSigninFinish = "/v1/iam/webauthn/signin/finish"
)
// display names the relying party in the authenticator's prompt (v1 reads the
// beego appname).
const display = "Hanzo"
// MountCeremony registers the registration and authentication ceremonies.
func MountCeremony(app *zip.App, db orm.DB) {
app.Get(PathSignupBegin, signupBegin(db))
app.Post(PathSignupFinish, signupFinish(db))
app.Get(PathSigninBegin, signinBegin(db))
app.Post(PathSigninFinish, signinFinish(db))
}
// rp builds the relying party for the request's own host. The RPID is the host
// WITHOUT its port and the origin is the full scheme+host: an authenticator
// binds a credential to the RPID and the browser refuses any assertion whose
// origin does not match, so both must describe where the user actually is —
// which is why they come from the effective host and not from configuration
// (v1 object/user_webauthn.go:29-50).
func rp(c *zip.Ctx) (*wa.WebAuthn, error) {
host := httpx.EffectiveHost(c)
if host == "" {
return nil, errors.New("cannot determine the request host")
}
scheme := "https"
if c.Fiber().Protocol() == "http" {
scheme = "http"
}
id, _, _ := strings.Cut(host, ":")
return wa.New(&wa.Config{
RPDisplayName: display,
RPID: id,
RPOrigins: []string{scheme + "://" + host},
})
}
// principal is the webauthn.User adapter over a stored user. Its credentials come
// from the WebauthnCredential ENTITY rows — the one home — never from the user
// row's migration column.
type principal struct {
user *schema.User
creds []wa.Credential
}
// WebAuthnID is the handle the authenticator stores and hands back on a
// discoverable login: "owner/name" (v1 object/user_webauthn.go:54-56).
func (p *principal) WebAuthnID() []byte { return []byte(p.user.Owner + "/" + p.user.Name) }
func (p *principal) WebAuthnName() string { return p.user.Name }
func (p *principal) WebAuthnDisplayName() string { return p.user.DisplayName }
func (p *principal) WebAuthnIcon() string { return p.user.Avatar }
func (p *principal) WebAuthnCredentials() []wa.Credential { return p.creds }
// load builds the adapter for a user, reading its passkeys from the entity.
func load(ctx context.Context, db orm.DB, u *schema.User) (*principal, error) {
rows, err := orm.TypedQuery[schema.WebauthnCredential](db).
Filter("Owner=", u.Owner).Filter("User=", u.Owner+"/"+u.Name).GetAll(ctx)
if err != nil {
return nil, err
}
p := &principal{user: u}
for _, r := range rows {
p.creds = append(p.creds, credential(r))
}
return p, nil
}
// credential maps a stored row back to the go-webauthn value, re-nesting the
// Flags and Authenticator the entity flattens into columns.
func credential(r *schema.WebauthnCredential) wa.Credential {
c := wa.Credential{
ID: r.CredentialId,
PublicKey: r.PublicKey,
AttestationType: r.AttestationType,
Flags: wa.CredentialFlags{
UserPresent: r.UserPresent,
UserVerified: r.UserVerified,
BackupEligible: r.BackupEligible,
BackupState: r.BackupState,
},
Authenticator: wa.Authenticator{
AAGUID: r.Aaguid,
SignCount: r.SignCount,
CloneWarning: r.CloneWarning,
Attachment: protocol.AuthenticatorAttachment(r.Attachment),
},
}
for _, t := range r.Transport {
c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t))
}
return c
}
// row maps a go-webauthn credential onto the entity, flattening Flags and
// Authenticator. Name is the standard-base64 credential id — the same value v1
// keys a credential by, and the value a discoverable login resolves through.
func row(db orm.DB, owner, user string, c *wa.Credential) *schema.WebauthnCredential {
r := orm.New[schema.WebauthnCredential](db)
r.Owner = owner
r.Name = base64.StdEncoding.EncodeToString(c.ID)
r.CreatedTime = time.Now().UTC().Format(time.RFC3339)
r.User = user
r.CredentialId = c.ID
r.PublicKey = c.PublicKey
r.AttestationType = c.AttestationType
r.UserPresent = c.Flags.UserPresent
r.UserVerified = c.Flags.UserVerified
r.BackupEligible = c.Flags.BackupEligible
r.BackupState = c.Flags.BackupState
r.Aaguid = c.Authenticator.AAGUID
r.SignCount = c.Authenticator.SignCount
r.CloneWarning = c.Authenticator.CloneWarning
r.Attachment = string(c.Authenticator.Attachment)
for _, t := range c.Transport {
r.Transport = append(r.Transport, string(t))
}
r.SetId(webauthnCredentialId(r.Owner, r.Name))
return r
}
// signupBegin issues CredentialCreationOptions for the SIGNED-IN user and files
// the challenge. The subject is the verified bearer — never a request parameter
// — so a passkey can only ever be added to the caller's own account.
func signupBegin(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
p, err := self(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
obj, err := rp(c)
if err != nil {
return httpx.Err(c, err.Error())
}
options, session, err := obj.BeginRegistration(p, func(o *protocol.PublicKeyCredentialCreationOptions) {
// Exclude what this user already has: an authenticator that would
// otherwise silently replace its own credential for this account
// (v1 controllers/webauthn.go:50-59).
o.CredentialExcludeList = exclude(p)
o.AuthenticatorSelection.ResidentKey = protocol.ResidentKeyRequirementPreferred
o.Attestation = protocol.PreferNoAttestation
o.Extensions = protocol.AuthenticationExtensions{"credProps": true}
})
if err != nil {
return httpx.Err(c, err.Error())
}
return begin(c, db, oidc.KindRegistration, string(p.WebAuthnID()), session, options)
}
}
// signupFinish verifies the attestation and stores the passkey. The user comes
// from the CHALLENGE, so the credential lands on the account that started the
// ceremony even if the body claims otherwise.
func signupFinish(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
obj, session, ch, err := take(c, db, oidc.KindRegistration)
if err != nil {
return httpx.Err(c, err.Error())
}
// Bind the ceremony to the caller: the bearer that finishes must be the
// one that began, so a challenge cannot be handed to another account.
p, err := self(c, db)
if err != nil {
return httpx.Err(c, err.Error())
}
if string(p.WebAuthnID()) != ch.Subject {
return httpx.Err(c, oidc.ErrChallenge.Error())
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(c.Body()))
if err != nil {
return httpx.Err(c, err.Error())
}
cred, err := obj.CreateCredential(p, *session, parsed)
if err != nil {
return httpx.Err(c, err.Error())
}
if err := row(db, p.user.Owner, ch.Subject, cred).CreateCtx(c.Context()); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, "OK")
}
}
// signinBegin issues CredentialAssertionOptions. With no name it starts a
// DISCOVERABLE login — the authenticator picks the account — which is the
// passkey flow that reveals nothing about who exists.
func signinBegin(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
obj, err := rp(c)
if err != nil {
return httpx.Err(c, err.Error())
}
owner, name := c.Query("owner"), c.Query("name")
if name == "" {
options, session, err := obj.BeginDiscoverableLogin()
if err != nil {
return httpx.Err(c, err.Error())
}
return begin(c, db, oidc.KindAuthentication, "", session, options)
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return httpx.Err(c, err.Error())
}
if u == nil {
return httpx.Err(c, "the user doesn't exist")
}
p, err := load(c.Context(), db, u)
if err != nil {
return httpx.Err(c, err.Error())
}
if len(p.creds) == 0 {
return httpx.Err(c, "found no credentials for this user")
}
options, session, err := obj.BeginLogin(p)
if err != nil {
return httpx.Err(c, err.Error())
}
return begin(c, db, oidc.KindAuthentication, string(p.WebAuthnID()), session, options)
}
}
// signinFinish verifies the assertion and signs the user in, minting a code from
// the OAuth params in the QUERY (v1 controllers/webauthn.go:174-237).
//
// It does NOT re-gate on MFA, deliberately: a passkey IS a strong factor — it
// proves possession of a private key that never left the authenticator — so
// challenging it with a TOTP code would demand a second factor of a credential
// that is already two.
func signinFinish(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
obj, session, ch, err := take(c, db, oidc.KindAuthentication)
if err != nil {
return httpx.Err(c, err.Error())
}
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(c.Body()))
if err != nil {
return httpx.Err(c, err.Error())
}
var p *principal
var cred *wa.Credential
if ch.Subject != "" {
if p, err = subject(c.Context(), db, ch.Subject); err != nil {
return httpx.Err(c, err.Error())
}
cred, err = obj.ValidateLogin(p, *session, parsed)
} else {
// Discoverable: the authenticator names the credential, and the
// credential names its user. base64(rawID) IS the entity's Name.
cred, err = obj.ValidateDiscoverableLogin(func(rawID, _ []byte) (wa.User, error) {
found, err := byCredential(c.Context(), db, base64.StdEncoding.EncodeToString(rawID))
if err != nil {
return nil, err
}
p = found
return found, nil
}, *session, parsed)
}
if err != nil {
return httpx.Err(c, err.Error())
}
// Persist the advanced counter. Without this the stored SignCount never
// moves, every future assertion looks like a replay of the same value,
// and clone detection reports nothing forever.
if err := advance(c.Context(), db, p.user.Owner, cred); err != nil {
return httpx.Err(c, err.Error())
}
return oidc.GrantWebauthn(c, db, p.user)
}
}
// begin files the ceremony's session data as a challenge, hands its id to the
// client, and answers RAW protocol JSON — what navigator.credentials expects.
func begin(c *zip.Ctx, db orm.DB, kind, subject string, session *wa.SessionData, options any) error {
payload, err := json.Marshal(session)
if err != nil {
return httpx.Err(c, err.Error())
}
id, err := oidc.MintChallenge(c.Context(), db, kind, subject, string(payload), time.Now())
if err != nil {
return httpx.Err(c, err.Error())
}
oidc.SetChallenge(c, id)
return c.JSON(200, options)
}
// take spends the ceremony's challenge and rebuilds its session data. Taking is
// one-shot, so an assertion replayed against the same challenge loses.
func take(c *zip.Ctx, db orm.DB, kind string) (*wa.WebAuthn, *wa.SessionData, *schema.Challenge, error) {
obj, err := rp(c)
if err != nil {
return nil, nil, nil, err
}
ch, err := oidc.TakeChallenge(c.Context(), db, oidc.ReadChallenge(c, ""), kind, time.Now())
if err != nil {
return nil, nil, nil, err
}
oidc.ClearChallenge(c)
var session wa.SessionData
if err := json.Unmarshal([]byte(ch.Payload), &session); err != nil {
return nil, nil, nil, oidc.ErrChallenge
}
return obj, &session, ch, nil
}
// self resolves the ceremony's subject from the VERIFIED BEARER and nowhere else
// (invariant 3). Registration takes no target parameter at all, so an org admin
// cannot register a credential onto a member's account — which would be a silent,
// permanent takeover.
func self(c *zip.Ctx, db orm.DB) (*principal, error) {
p, ok := authz.From(c.Context())
if !ok || p.User == "" {
return nil, errors.New("please login first")
}
return subject(c.Context(), db, p.Org+"/"+p.User)
}
// subject loads the adapter for an "owner/name" id.
func subject(ctx context.Context, db orm.DB, id string) (*principal, error) {
owner, name, _ := strings.Cut(id, "/")
u, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return nil, err
}
if u == nil {
return nil, errors.New("the user doesn't exist")
}
return load(ctx, db, u)
}
// byCredential resolves the user a passkey belongs to, from the credential's own
// base64 id — the entity's Name (v1 GetUserByWebauthID).
func byCredential(ctx context.Context, db orm.DB, name string) (*principal, error) {
r, err := orm.TypedQuery[schema.WebauthnCredential](db).Filter("Name=", name).First()
if err != nil || r == nil {
return nil, errors.New("found no credentials for this user")
}
return subject(ctx, db, r.User)
}
// exclude lists the credentials this user already registered, so an authenticator
// does not quietly overwrite one of them (v1 object/user_webauthn.go:75-87).
func exclude(p *principal) []protocol.CredentialDescriptor {
list := []protocol.CredentialDescriptor{}
for _, c := range p.creds {
list = append(list, protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: c.ID,
})
}
return list
}
// advance writes back the counter and clone warning a successful assertion
// produced — the only mutable state a passkey has.
func advance(ctx context.Context, db orm.DB, owner string, c *wa.Credential) error {
name := base64.StdEncoding.EncodeToString(c.ID)
r, err := orm.Get[schema.WebauthnCredential](db, webauthnCredentialId(owner, name))
if err != nil || r == nil {
return nil // discoverable login on a foreign-owner row: nothing to advance
}
r.SignCount = c.Authenticator.SignCount
r.CloneWarning = c.Authenticator.CloneWarning
r.BackupState = c.Flags.BackupState
return r.UpdateCtx(ctx)
}
+4 -1
View File
@@ -53,8 +53,11 @@ type webauthnCredentialMutationResult struct {
WebauthnCredential *schema.WebauthnCredential `json:"webauthnCredential,omitempty"`
}
// Mount registers the passkey surface on app, closing over the entity store.
// Mount registers the passkey surface on app, closing over the entity store: the
// entity CRUD below, plus the ceremony (ceremony.go) that creates a passkey and
// signs a user in with one.
func Mount(app *zip.App, db orm.DB) {
MountCeremony(app, db)
zip.Get[listWebauthnCredentialsIn, listWebauthnCredentialsOut](app, "/v1/iam/webauthn-credentials", listWebauthnCredentials(db),
zip.WithOperationID("listWebauthnCredentials"),
zip.WithSummary("List webauthn credentials in an owner scope"),