Compare commits

..
Author SHA1 Message Date
zeekayandhanzo-dev 56689b2d2e seed: providers is policy
image / test (push) Failing after 1m52s
image / build (push) Skipped
Hanzo CI/CD / cicd (push) Failing after 1m18s
CI/CD / cicd (push) Failing after 42m32s
Which identity providers an app offers decides who may sign in — the
same kind of fact as enableWebAuthn, naming only provider records. The
provision document cannot say providers, so an app it registered could
never gain a social button from declared state: hanzo-cli sat
password-only while init_data.json said otherwise.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:31:34 -07:00
blue 41cf48d253 let a host supply the transport that carries a code
image / test (push) Failing after 3m1s
image / build (push) Skipped
Hanzo CI/CD / cicd (push) Failing after 3m24s
CI/CD / cicd (push) Failing after 18m16s
The delivery seam lives in internal/oidc, so a host binary that GRAFTS iam --
cloud embeds it with server.NewApp -- could not reach BindSender and therefore
could never deliver a code, leaving every code-shaped method dark in the one
deployment that has notify in the same process. Composition is what this package
is for, and which wire carries a code is the host's decision.

The two hosts differ in the only way that matters: how the TENANT travels. A
standalone iam reaches notify over the wire as a principal of one org, so its
transport is pinned to that org and refuses any other. A grafted iam shares a
process with notify and passes the org explicitly, so it serves every tenant
with no credential. One seam, two transports.
2026-08-06 21:29:58 -07:00
blue ce1cc2625f pin the sender to the one tenant its credential speaks for
image / test (push) Failing after 3m5s
image / build (push) Skipped
CI/CD / cicd (push) Failing after 4m55s
Hanzo CI/CD / cicd (push) Failing after 4m8s
notify derives the sending tenant from the VALIDATED PRINCIPAL and explicitly
not from any header the caller supplies (principal.OrgFrom, "never a client
header"), so a service credential can only ever send as its own org. This
process answers for every white-label identity host, so the X-Org-Id the client
was setting would have been ignored and every tenant's code would have gone out
through whichever org the credential belongs to -- delivered, and billed and
attributed to the wrong company, while reporting success.

Naming the org at construction makes that structural rather than remembered: a
send for anyone else is refused, and the refusal names both orgs. A brand whose
codes cannot be sent now says so on its login screen, which is the truth; the
alternative is a code that silently arrives from another company. An address
with no org is the same empty claim as no address at all, so it yields no client
and nothing binds.

Sending for more than one tenant needs a notify entry that takes an explicit org
from a cross-tenant service principal. That is a cloud change and a real
authorization decision, so it is named here rather than assumed.
2026-08-06 21:19:28 -07:00
blue bd23d64486 Merge: sign in and second-factor by email or SMS
image / test (push) Failing after 3m44s
image / build (push) Skipped
Hanzo CI/CD / cicd (push) Failing after 4m39s
CI/CD / cicd (push) Failing after 4m51s
2026-08-06 21:14:45 -07:00
blue 7f02a8e9a8 answer a second factor by text or email, and declare who may
The second-factor verifier accepted TOTP alone. SMS and Email were in the factor
vocabulary and enrollment could select them, but the gate refused both -- "a
factor with no verification is not a factor", which was the right call while
nothing could deliver a code and the wrong one the moment something can. They
now verify through the SAME one-time machinery a code sign-in uses: spent on
success, counted on a miss.

The destination is the address the ACCOUNT holds and never anything off the
request. Letting the caller name it turns the ceremony inside out -- someone
holding a first factor would have the code sent to an address they control and
answer their own challenge.

Code sign-in is per-app config, so a document can now say so. provision gained
codeSignin and the upsert gained enableCodeSignin, both POINTERS: the upsert
applies whatever it receives, so a plain bool would send false on every converge
of every app whose document never mentions the setting and switch the method off
across the fleet on the next unrelated reconcile. A new app stays password-only
until it asks, for the same fail-closed reason IsShared does.

Finally, `iam phones` converts stored numbers to the form sign-in compares
against. Numbers were persisted verbatim from four write sites for as long as
the column has existed, so a row in its original clothes never matches an
equality lookup and that person silently cannot sign in by text. It REPORTS by
default and writes only under --apply, because it is the one operation here that
rewrites a live user table and the count is what tells you the change is the
size you expected. Re-running is a no-op and a value with no digits is left for
a human rather than blanked.
2026-08-06 21:14:34 -07:00
blue f92f4b0196 sign in with an emailed or texted code, and deliver the code
Everything code-shaped was switched off because nothing was ever bound to the
delivery seam: BindSender was defined and never called, so DeliveryConfigured
answered false and the login descriptor correctly hid email sign-in, SMS
sign-in, and the email and SMS second factors. Both halves already existed --
IAM declared the seam, and notify's own send surface names this caller ("IAM
OTP") -- so this connects them rather than building anything new. Binding one
transport turns all four on at once, which is why availability is read from the
bound sender and never from an address.

The seam now carries the ORG. notify resolves the provider credential from the
tenant's own KMS entry, so a send without one cannot be routed and a send with
the wrong one reaches another account's Twilio. It is a parameter, not a
construction argument: one bound sender serves every tenant this process
answers for, and the value read is the record's own Owner so the code that was
persisted and the code that goes out are attributed identically.

A code is accepted where a password goes, in the SAME handler. Sign-in by email
or SMS proves possession of an address the account holds, which is one factor
exactly as a password is, so it joins one arm further in rather than at a second
endpoint -- the MFA gate, device approval and the PKCE tail are then true of it
by construction. Both arms now leave through afterFirstFactor, which also lets
the gate be told WHICH factor was already proven, so an emailed code is never
answered by demanding a second emailed code.

Two things had to change for a code to be a credential rather than a signup
gate. It is now spent on use, because a code that verifies twice is a
replayable credential; and wrong guesses are counted, because six digits live
for ten minutes is a million tries if nothing counts them. The bound is five,
not one: burning the code on the first miss is the tighter bound and the wrong
trade, since anyone who knows your address could then destroy your live code by
posting a wrong one.

Phone joins name and email as an identifier, last and only for something shaped
like a number. GetUserByPhone FAILS CLOSED when two rows in an org carry one
number -- Phone is indexed but not unique, so a First() would hand back an
arbitrary row and authenticate someone against another account. One
NormalizePhone runs at every write site and at the lookup, because a number
typed by a human and a number written by SCIM are the same number in different
clothes and equality cannot see through clothes. It infers no country code:
guessing +1 would claim a number in one country for a user in another.
2026-08-06 21:06:36 -07:00
zooqueenandhanzo-dev 9f585264f0 authz: which orgs you may act in is your memberships, not your owner half
image / build (push) Successful in 2m1s
image / test (push) Successful in 8m3s
Hanzo CI/CD / cicd (push) Successful in 13m55s
CI/CD / cicd (push) Successful in 14m4s
A person's account lives in ONE tenant; the organizations they work in
are a SET. The policy answered 'may you read this org?' with 'is this
org your account's owner?', so an org's own member — its own admin —
was refused the org they belong to. Every console reads that row for a
tenant's name and logo, so a second org was simply invisible: the
picker fell back to the signed-in PERSON and the org mark showed a
personal monogram.

The principal now carries its membership set (one indexed read on the
user key, empty on a store error so a blip narrows a decision and never
widens one), and the organizations clause asks it: belonging is
permission to SEE, owner/admin is permission to CHANGE. Nothing else
moves — membership grants no reach into users, certs, applications or
providers, and the reserved-owner gate is untouched for every other
entity.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 21:06:16 -07:00
zooqueenandhanzo-dev 6504da1976 authz: a client is one principal, however it presents its credential
image / build (push) Successful in 4m5s
image / test (push) Successful in 8m1s
Hanzo CI/CD / cicd (push) Successful in 13m26s
CI/CD / cicd (push) Successful in 16m36s
client_secret_basic built the app Principal; the bearer that SAME client
minted with client_credentials from that SAME secret fell through to a
subject-only principal — App empty, so every capability clause was
skipped, and Org set to the app row's OWNER half instead of the tenant
it serves, so even the tenant rule refused it. Both halves of its
authority were wrong at once.

Measured in production: hanzo-console is listed in IAM_ORG_ADMIN_APPS
precisely so a brand console can manage orgs during onboarding, and it
answered 403 to its own org read and to a membership grant on the
bearer while answering 200 to the same read over Basic. The console's
org identity fell back to a monogram and team writes could not land.

A machine subject that names a live application now resolves through
the same appPrincipal() the Basic path uses — one shape, one row. It
grants nothing new: still never Admin, never Super, still pinned to a
reserved signing owner, and a subject naming no live application stays
inert. Tests pin all three, and the first fails without the fix.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 18:56:25 -07:00
zooqueenandhanzo-dev dc3eaa9adc authz: ScopeFor honours the whole grant, not two shapes of it
image / test (push) Successful in 18m55s
Hanzo CI/CD / cicd (push) Successful in 12m42s
CI/CD / cicd (push) Successful in 12m42s
image / build (push) Successful in 7m26s
If authorize() admits this exact (owner, name) read, the owner it
admitted is the owner we query — the function's own comment already
said so. The re-narrowing to supers and app self-reads was a second,
stale copy of the policy: it predated the organizations exception, so
a member's GET of admin/<their org> was admitted by authorize() and
then refused by the re-narrowing with 'scoped to organization <org>'.
The native REST twin answered 200 for the same principal and row — one
policy, two answers, and the console's org identity (displayName,
logo) fell back to the person's monogram on the surface everyone uses.
Regression tests pin both directions: a member reads their own org's
row under the admin owner; a foreign member still cannot.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 18:22:10 -07:00
29 changed files with 1697 additions and 68 deletions
+123 -15
View File
@@ -61,6 +61,7 @@ import (
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
@@ -98,6 +99,46 @@ type Principal struct {
AppCert string
Admin bool
Super bool
// Orgs is the set of organizations this person may act in — their HOME org
// plus every org they hold a membership in, which is the SAME set the token's
// `orgs` claim carries. It exists because "the org you belong to" and "the org
// that owns your account" are different questions, and the policy used to
// answer the first with the second: an org's own member could not read that
// org's row unless it happened to be their account's owner. One person, many
// orgs is the product; this is where the policy learns it. Empty for an app
// principal (a machine's scope is its served tenant, never a membership).
Orgs map[string]string // org -> role (owner|admin|member)
}
// memberOf reports whether p may act in org through its HOME org or a
// membership. It is the ONE membership question the policy asks, so a clause
// never re-derives the set.
func (p *Principal) memberOf(org string) bool {
if org == "" {
return false
}
if org == p.Org {
return true
}
_, ok := p.Orgs[org]
return ok
}
// adminOf reports whether p may CHANGE org — its own org as an org admin, or an
// org it holds an owner/admin membership in. A plain member never qualifies:
// belonging to an org is permission to see it, not to edit it.
func (p *Principal) adminOf(org string) bool {
if org == "" {
return false
}
if org == p.Org && p.Admin {
return true
}
switch p.Orgs[org] {
case store.RoleOwner, store.RoleAdmin:
return true
}
return false
}
type ctxKey struct{}
@@ -203,11 +244,18 @@ func Deny(c *zip.Ctx, err error) error { return refuse(c, http.StatusForbidden,
// too: a grant honours the org it names and answers with THAT org's row, correctly
// attributed; everything else is refused. Neither branch can hand back a row the
// request did not ask for.
//
// The grant is honoured WHOLE. An earlier shape re-narrowed the honoured set to
// supers and app self-reads after authorize() had already admitted the read —
// a second copy of the policy, and a stale one: it predated the organizations
// exception (a tenant's own org row lives under the reserved admin owner), so a
// member's GET of admin/<their org> was admitted by the policy and then refused
// by this re-narrowing. The native REST twin, authorized by the Guard alone,
// answered 200 for the same principal and row — one policy, two answers. If
// authorize() says yes to this exact (owner, name) read, that IS the decision.
func ScopeFor(ctx context.Context, path, owner, name string) (string, error) {
if p, ok := From(ctx); ok && owner != "" && authorize(p, "GET", entityOf(path), owner, name) {
if p.Super || (p.App != "" && owner == p.AppOwner) {
return owner, nil
}
return owner, nil
}
return Scope(ctx, owner)
}
@@ -665,7 +713,15 @@ func authorize(p *Principal, method, entity, owner, name string) bool {
if p.App != "" {
return Allowed(p, CapOrgAdmin)
}
return name == p.Org && (isRead(method) || p.Admin)
// A person reads any org they BELONG to, and edits the ones they help run.
// Membership is the authority, not the account's owner half: a human's
// account lives in one IAM tenant while the orgs they work in are a set, so
// keying this on p.Org alone refused an org's own admin the org they
// administer — which is what made a second org invisible in every console.
if isRead(method) {
return p.memberOf(name)
}
return p.adminOf(name)
}
// A confidential client's authority is its capability allowlist and nothing
// else — never Super, never Admin; an unmapped entity or unset allowlist denies.
@@ -769,19 +825,62 @@ func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
if u.IsForbidden || u.IsDeleted {
return nil, errRevoked
}
return &Principal{Org: u.Owner, User: u.Name, Admin: u.IsAdmin, Super: u.Owner == adminOrg}, nil
return &Principal{
Org: u.Owner, User: u.Name, Admin: u.IsAdmin, Super: u.Owner == adminOrg,
Orgs: membershipRoles(ctx, db, u.Owner+"/"+u.Name),
}, nil
}
// No user row. A machine token's subject is "<appOwner>/<appName>" — org-scoped
// to the app's owner half, carrying no admin/super authority. Anything else — an
// opaque UUID subject with no live user row (a since-deleted user, or a forgery
// the trusted-key verify already blocks) — establishes NO principal, fail closed.
owner, _, hasSlash := strings.Cut(claims.Subject, "/")
// No user row. A machine token's subject is "<appOwner>/<appName>", which names
// an APPLICATION — so it resolves to the SAME confidential-client Principal the
// Basic path builds, from the same row. A client is one principal however it
// presents its credential: client_secret_basic on the request, or the bearer it
// minted with client_credentials from that identical secret. It was not, and the
// asymmetry was silent and total — a bearer took the branch below, arriving with
// App empty (so every capability clause was skipped: an allowlisted client could
// not exercise the one capability it is allowlisted FOR) and Org set to the app
// row's OWNER half rather than the tenant it SERVES (so even the tenant rule
// refused it). Both halves of its authority were wrong at once, which is why the
// console's org reads and membership grants answered 403 while the same client,
// on the same secret, was authorized over Basic.
//
// This grants nothing new: the Principal is built by the same helper, from the
// same row, and is still never Admin and never Super — its whole authority
// remains capFor()/Allowed(), pinned to a reserved signing owner.
owner, name, hasSlash := strings.Cut(claims.Subject, "/")
if !hasSlash || owner == "" {
return nil, errNoSubject
}
if name != "" {
if a, err := store.GetApplicationByName(ctx, db, owner, name); err == nil && a != nil {
return appPrincipal(a), nil
}
}
// A subject that names neither a live user nor a live application — an opaque
// UUID with no row (a since-deleted user, or a forgery the trusted-key verify
// already blocks) — is org-scoped only, carrying no admin, super, or app
// authority. Fail closed by construction: on the raw CRUD this authorizes to
// nothing.
return &Principal{Org: owner}, nil
}
// membershipRoles reads the org->role set a person may act in. A store error is
// not fatal: it yields an EMPTY set, which is the same authority the policy gave
// before memberships existed (home org only), so a store blip narrows a decision
// and never widens one. The read is one indexed query on the user key.
func membershipRoles(ctx context.Context, db orm.DB, user string) map[string]string {
rows, err := store.MembershipsByUser(ctx, db, user)
if err != nil || len(rows) == 0 {
return nil
}
out := make(map[string]string, len(rows))
for _, m := range rows {
if m != nil && m.Org != "" {
out[m.Org] = m.Role
}
}
return out
}
// app resolves an `Authorization: Basic <clientId>:<clientSecret>` credential into
// a confidential-client Principal — the transport every live server-side consumer
// authenticates with (RFC 6749 §2.3.1 client_secret_basic; cloud reads
@@ -809,11 +908,20 @@ func app(c *zip.Ctx, db orm.DB) (*Principal, bool) {
if subtle.ConstantTimeCompare([]byte(a.ClientSecret), []byte(secret)) != 1 {
return nil, false
}
// AppOwner is the app row's OWNING org (a.Owner: "admin"/"built-in" for a platform
// app), NOT a.Organization (the tenant it SERVES). cap.go pins every capability to
// this being a reserved signing owner, so a tenant-owned app named/clientId'd like
// a console holds nothing. Org carries the served tenant, as before.
return &Principal{App: a.Name, AppOwner: a.Owner, AppCert: a.Cert, Org: a.Organization}, true
return appPrincipal(a), true
}
// appPrincipal is the ONE shape a confidential client's authority takes, so the
// two ways it can present that authority — client_secret_basic on the request,
// or the bearer it minted with those same credentials — cannot resolve to
// different principals.
//
// AppOwner is the app row's OWNING org (a.Owner: "admin"/"built-in" for a
// platform app), NOT a.Organization (the tenant it SERVES). cap.go pins every
// capability to this being a reserved signing owner, so a tenant-owned app
// named/clientId'd like a console holds nothing. Org carries the served tenant.
func appPrincipal(a *schema.Application) *Principal {
return &Principal{App: a.Name, AppOwner: a.Owner, AppCert: a.Cert, Org: a.Organization}
}
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz_test
import "testing"
// ONE CLIENT, ONE PRINCIPAL, TWO TRANSPORTS.
//
// A confidential client may present its credential two ways: client_secret_basic
// on the request, or the bearer it minted from that identical secret with
// client_credentials. Both are the same identity, so both must resolve to the
// same authority. They did not: the Basic path built the app Principal (App,
// AppOwner, Org = the tenant served), while a machine BEARER fell through to a
// subject-only principal with App empty and Org set to the app row's OWNER half.
//
// The result was a client that could not exercise the capability it is
// allowlisted for. Measured against production: hanzo-console — listed in
// IAM_ORG_ADMIN_APPS precisely so a brand console can manage orgs during
// onboarding — answered 403 to its own org read and to a membership grant when
// it sent its bearer, and 200 to the same read over Basic.
func TestMachineBearerIsTheSamePrincipalAsBasic(t *testing.T) {
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
h := newHarness(t)
seedAppRow(t, h.db, "admin", "hanzo-console", "s3cret", signingKid)
// The subject a client_credentials token carries: "<appOwner>/<appName>".
bearer := h.token(t, "admin/hanzo-console")
const own = "/v1/iam/get-application?id=admin%2Fhanzo-console"
if got := h.do(t, "GET", own, bearer, nil); got != 200 {
t.Errorf("self-read over the machine bearer = %d, want 200 (Basic already answers 200)", got)
}
// The capability itself: an org read the allowlist exists to permit.
const org = "/v1/iam/get-organization?id=admin%2Fhanzo"
if got := h.do(t, "GET", org, bearer, nil); got == 403 {
t.Errorf("CapOrgAdmin read over the machine bearer = 403; the allowlisted capability must apply on both transports")
}
}
// The fix grants nothing new. A bearer whose subject names neither a live user
// nor a live application carries no app authority at all — the phantom subject
// stays inert, which is what keeps "mint a token for admin/<anything>" from
// being an escalation.
func TestMachineBearerForAnUnregisteredAppHasNoAuthority(t *testing.T) {
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
h := newHarness(t)
seedAppRow(t, h.db, "admin", "hanzo-console", "s3cret", signingKid)
ghost := h.token(t, "admin/not-an-app")
if got := h.do(t, "GET", "/v1/iam/get-application?id=admin%2Fhanzo-console", ghost, nil); got != 403 {
t.Errorf("unregistered subject reading another app = %d, want 403", got)
}
}
// A TENANT-owned application named like a platform one holds nothing on the
// bearer transport either: capabilities are pinned to a reserved signing owner,
// and that pin is in the shared principal both transports now build.
func TestMachineBearerForATenantOwnedLookalikeHoldsNothing(t *testing.T) {
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
h := newHarness(t)
seedAppRow(t, h.db, "hanzo", "hanzo-console", "s3cret", signingKid)
impostor := h.token(t, "hanzo/hanzo-console")
if got := h.do(t, "GET", "/v1/iam/get-organization?id=admin%2Fhanzo", impostor, nil); got != 403 {
t.Errorf("tenant-owned lookalike exercising CapOrgAdmin = %d, want 403", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package authz
import (
"testing"
"github.com/hanzoai/iam/pkg/store"
)
// ONE PERSON, MANY ORGS.
//
// A human's account lives in one IAM tenant; the organizations they work in are
// a SET. The policy used to answer "may you read this org?" with "is this org
// your account's owner?", so an org's own member — its own ADMIN — could not
// read the org they belong to. Every console that renders a tenant reads that
// row for its name and logo, so a second org was invisible: the picker fell back
// to the signed-in person, and the org mark showed a personal monogram.
func TestOrgReadFollowsMembershipNotTheAccountOwner(t *testing.T) {
// A person whose account lives in `hanzo`, who also belongs to `maxpower`.
dave := &Principal{
Org: "hanzo", User: "davelorenzini",
Orgs: map[string]string{"maxpower": store.RoleAdmin},
}
// A person in `hanzo` with no other membership.
stranger := &Principal{Org: "hanzo", User: "nobody"}
for _, tc := range []struct {
name string
p *Principal
method string
org string
want bool
}{
{"member reads the org they belong to", dave, "GET", "maxpower", true},
{"member reads their home org", dave, "GET", "hanzo", true},
{"org admin edits the org they administer", dave, "POST", "maxpower", true},
{"a non-member cannot read that org", stranger, "GET", "maxpower", false},
{"a non-member cannot edit that org", stranger, "POST", "maxpower", false},
{"home-org membership alone does not grant editing", stranger, "POST", "hanzo", false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := authorize(tc.p, tc.method, "organizations", "admin", tc.org); got != tc.want {
t.Errorf("authorize(%s %s) = %v, want %v", tc.method, tc.org, got, tc.want)
}
})
}
}
// Belonging is permission to SEE, never to edit. A plain member of an org may
// read it and nothing more — otherwise inviting someone to a workspace would
// hand them its settings.
func TestPlainMemberReadsButCannotWrite(t *testing.T) {
guest := &Principal{
Org: "hanzo", User: "guest",
Orgs: map[string]string{"maxpower": store.RoleMember},
}
if !authorize(guest, "GET", "organizations", "admin", "maxpower") {
t.Error("a member must be able to read the org they belong to")
}
if authorize(guest, "POST", "organizations", "admin", "maxpower") {
t.Error("a plain member must NOT be able to write the org they belong to")
}
}
// The membership set carries no authority outside organizations: it says which
// orgs you act in, not that you may reach another tenant's users or signing
// material.
func TestMembershipDoesNotLeakIntoOtherEntities(t *testing.T) {
dave := &Principal{
Org: "hanzo", User: "davelorenzini",
Orgs: map[string]string{"maxpower": store.RoleAdmin},
}
for _, entity := range []string{"users", "certs", "applications", "providers"} {
if authorize(dave, "GET", entity, "maxpower", "anything") {
t.Errorf("membership must not grant %s reads in another owner", entity)
}
}
}
+14 -2
View File
@@ -201,6 +201,11 @@ type registration struct {
// the default. Nil means "not stated, leave it".
ExpireInHours *float64 `json:"expireInHours"`
RefreshExpireInHours *float64 `json:"refreshExpireInHours"`
// EnableCodeSignin offers sign-in by an emailed or texted one-time code
// beside the password. A POINTER for the same reason as IsShared: a plain
// bool reads as false on every reconcile that says nothing and would switch
// the method off for every app whose caller never mentioned it.
EnableCodeSignin *bool `json:"enableCodeSignin"`
// Auth is the `Authorization: Bearer <token>` header, the unified service
// token this surface authenticates on. `json:"-"` keeps it off the body and
// out of the query string, so the header is the only way to present it.
@@ -278,6 +283,9 @@ func upsertApplication(db orm.DB) zip.TypedHandler[registration, reply] {
if in.IsShared != nil {
existing.IsShared = *in.IsShared
}
if in.EnableCodeSignin != nil {
existing.EnableCodeSignin = *in.EnableCodeSignin
}
existing.ExpireInHours = ttl(in.ExpireInHours, existing.ExpireInHours)
existing.RefreshExpireInHours = ttl(in.RefreshExpireInHours, existing.RefreshExpireInHours)
existing.EnablePassword = true
@@ -313,6 +321,10 @@ func upsertApplication(db orm.DB) zip.TypedHandler[registration, reply] {
a.RefreshExpireInHours = ttl(in.RefreshExpireInHours, 0)
// A new app is single-tenant unless it says otherwise — fail closed.
a.IsShared = in.IsShared != nil && *in.IsShared
// Same rule for code sign-in: a new app offers only the password until
// it asks for more, so an unstated setting is off rather than inherited
// from a default nobody wrote down.
a.EnableCodeSignin = in.EnableCodeSignin != nil && *in.EnableCodeSignin
a.Model = model
a.SetId("admin/" + in.Name)
if err := a.CreateCtx(ctx); err != nil {
@@ -389,7 +401,7 @@ func upsertUser(db orm.DB) zip.TypedHandler[person, reply] {
action = "updated"
existing.DisplayName = pick(in.DisplayName, existing.DisplayName)
existing.Email = pick(in.Email, existing.Email)
existing.Phone = pick(in.Phone, existing.Phone)
existing.Phone = store.NormalizePhone(pick(in.Phone, existing.Phone))
existing.IsAdmin = in.IsAdmin
if hash != "" {
existing.PasswordHash, existing.PasswordType, existing.PasswordSalt = hash, cred.TypeArgon2id, ""
@@ -412,7 +424,7 @@ func upsertUser(db orm.DB) zip.TypedHandler[person, reply] {
u := orm.New[schema.User](db)
model := u.Model
u.Owner, u.Name = in.Owner, name
u.DisplayName, u.Email, u.Phone, u.IsAdmin = in.DisplayName, in.Email, in.Phone, in.IsAdmin
u.DisplayName, u.Email, u.Phone, u.IsAdmin = in.DisplayName, in.Email, store.NormalizePhone(in.Phone), in.IsAdmin
if hash != "" {
u.PasswordHash, u.PasswordType = hash, cred.TypeArgon2id
}
+28
View File
@@ -188,6 +188,34 @@ func TestGetUsers_unpaged_hasNoData2(t *testing.T) {
}
}
// A tenant's org row lives under the reserved admin owner, and the policy's
// organizations exception admits a member's read of it. The compat surface must
// answer the SAME way the native REST twin does: ScopeFor honours the grant
// authorize() already made, instead of re-narrowing it to supers and app
// self-reads — the re-narrowing is exactly what made the console's
// get-organization read 403 for every signed-in member, so the org's
// displayName and logo fell back to the person's monogram.
func TestGetOrganization_memberReadsOwnOrg(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-organization?id=admin/hanzo", h.token(t, "hanzo/alice"))
if status != 200 {
t.Fatalf("member read of own org = %d, want 200; body=%s", status, body)
}
assertNoSecretLeak(t, body)
if !strings.Contains(body, "\"name\":\"hanzo\"") {
t.Fatalf("expected the hanzo org row; body=%s", body)
}
}
// The grant is exact: another tenant's member still cannot read this org.
func TestGetOrganization_foreignMemberStillRefused(t *testing.T) {
h := newHarness(t)
status, _ := h.get(t, "/v1/iam/get-organization?id=admin/hanzo", h.token(t, "orgb/bob"))
if status != 403 {
t.Fatalf("foreign member read of admin/hanzo = %d, want 403", status)
}
}
func TestGetOrganizations_super_listsAll_masked(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-organizations", h.token(t, "admin/root"))
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// Handler binds the invitations operations to one orm store.
@@ -96,7 +97,7 @@ func apply(dst *schema.Invitation, in *Input) {
dst.Application = in.Application
dst.Username = in.Username
dst.Email = in.Email
dst.Phone = in.Phone
dst.Phone = store.NormalizePhone(in.Phone)
dst.SignupGroup = in.SignupGroup
dst.DefaultCode = in.DefaultCode
dst.State = in.State
+153
View File
@@ -0,0 +1,153 @@
// Package notify carries a minted verification code to a person.
//
// It is the transport half of the seam `oidc.Sender` declares, and it is the only
// thing in this repository that speaks to hanzoai/notify. It deliberately does NOT
// import oidc: the seam is a one-method interface, so structural typing lets the
// composition root hand a *Client to oidc.BindSender with neither package knowing
// the other. Delivery mechanism on one side, OTP policy on the other.
//
// What actually delivers lives in cloud (`apps/notify`), which resolves the ORG's
// own provider credential out of KMS and picks Twilio/Plivo/mail accordingly. This
// client only names a tenant, a channel and a destination; it holds no credential
// of its own and knows no provider.
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client posts one message per code to notify's send surface.
type Client struct {
base string
token string
org string
http *http.Client
}
// New builds a client against notify's base URL — the cloud origin that serves
// /v1/notify, e.g. "https://api.hanzo.ai". token authenticates this service to
// that surface, and org is the ONE tenant that token is a principal of.
//
// org is required for a reason that is easy to get wrong. notify derives the
// sending tenant from the VALIDATED PRINCIPAL and explicitly not from any header
// the caller supplies (`principal.OrgFrom`, "never a client header"), so a
// service credential can only ever send as its own org. This process, though,
// answers for every white-label identity host — so a client that quietly posted
// on behalf of any tenant would put lux and zoo codes through hanzo's Twilio
// while reporting success. Naming the org here makes that structural: a send for
// anyone else is refused below rather than mis-routed.
//
// A blank base yields nil, and nil is the whole switch: the composition root
// binds only a non-nil client, so `DeliveryConfigured` stays false and every
// screen keeps hiding code sign-in. That is why availability is read from the
// BOUND SENDER and never from this address — an address is a claim that delivery
// exists, and this constructor is where the claim gets tested. A base with no org
// is the same kind of empty claim and yields nil too.
func New(base, token, org string) *Client {
base = strings.TrimRight(strings.TrimSpace(base), "/")
org = strings.TrimSpace(org)
if base == "" || org == "" {
return nil
}
return &Client{
base: base,
token: strings.TrimSpace(token),
org: org,
// A verification code is worthless late: the person is sitting on a login
// screen waiting for it. Bound the attempt so a wedged provider surfaces as
// a failed send the caller can report, rather than holding the request open.
http: &http.Client{Timeout: 10 * time.Second},
}
}
// sendRequest is notify's contract (cloud apps/notify `notifySend`), narrowed to
// the fields an OTP uses. Subject rides the email channel only.
type sendRequest struct {
To []string `json:"to"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
}
// Send delivers code to dest for org over channel.
//
// channel arrives in IAM's vocabulary ("email" or "phone") and leaves in
// notify's ("email" or "sms"). The two names are for the same thing and the
// translation belongs at exactly one boundary — this one — so neither side has
// to learn the other's word.
func (c *Client) Send(ctx context.Context, org, channel, dest, code string) error {
if c == nil {
return fmt.Errorf("notify: no client")
}
if org == "" {
// notify picks the provider credential by org. Without one it cannot route,
// and guessing a default would send this tenant's code through somebody
// else's account.
return fmt.Errorf("notify: org is required to route a verification code")
}
if org != c.org {
// REFUSE rather than mis-route. This credential is a principal of exactly
// one tenant and notify sends as the principal, so a code minted for
// another org would go out through THIS org's provider — delivered, and
// billed and attributed to the wrong tenant. A loud failure here surfaces
// as "codes cannot be delivered" on that brand's login screen, which is the
// truth; the alternative is a code that silently arrives from the wrong
// company. Sending for more than one tenant needs a notify entry that
// accepts an explicit org from a cross-tenant service principal.
return fmt.Errorf("notify: this credential sends only for org %q, not %q", c.org, org)
}
var path, subject string
switch channel {
case "email":
path, subject = "/v1/notify/send/email", "Your verification code"
case "phone", "sms":
path = "/v1/notify/send/sms"
default:
return fmt.Errorf("notify: unknown channel %q", channel)
}
body, err := json.Marshal(sendRequest{To: []string{dest}, Subject: subject, Body: message(code)})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// The tenant travels as the principal's org header, which is how every other
// caller of this surface names one.
req.Header.Set("X-Org-Id", org)
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
// Carry a bounded slice of the answer: a provider refusal ("unverified
// number", "no credential") is the one detail that makes this diagnosable,
// and it is the operator who reads it.
snippet, _ := io.ReadAll(io.LimitReader(res.Body, 512))
return fmt.Errorf("notify: send failed (%d): %s", res.StatusCode, strings.TrimSpace(string(snippet)))
}
return nil
}
// message is the text a person receives. It names no brand: this process answers
// for every white-label identity host, and a message that hardcoded one would put
// the wrong name in front of the others. The sender identity the recipient sees
// is the org's own provider — which is precisely the thing notify resolves per
// tenant — so the body only has to carry the code and say not to share it.
func message(code string) string {
return "Your verification code is " + code + ". It expires in 10 minutes. If you did not request it, ignore this message and do not share it with anyone."
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package notify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type captured struct {
path, org, auth string
body sendRequest
}
// spy stands in for cloud's notify surface and records the one request made.
func spy(t *testing.T, status int) (*Client, *captured) {
t.Helper()
got := &captured{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.path = r.URL.Path
got.org = r.Header.Get("X-Org-Id")
got.auth = r.Header.Get("Authorization")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &got.body)
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"status":"error","msg":"twilio: 21608 unverified number"}`))
}))
t.Cleanup(srv.Close)
return New(srv.URL, "tok", "hanzo"), got
}
// A blank address yields NO client, and that nil is the entire delivery switch:
// the composition root binds only a non-nil one, so DeliveryConfigured stays
// false and every screen keeps hiding the methods this process cannot finish.
func TestNoAddressMeansNoClient(t *testing.T) {
for _, addr := range []string{"", " "} {
if c := New(addr, "tok", "hanzo"); c != nil {
t.Errorf("New(%q) returned a client; an unset address must not look like delivery", addr)
}
}
if New("https://api.hanzo.ai", "", "hanzo") == nil {
t.Error("a real address must yield a client even with no token")
}
}
// IAM says "phone", notify says "sms". The translation happens at exactly this
// boundary so neither side has to learn the other's word.
func TestChannelNamesAreTranslatedAtTheBoundary(t *testing.T) {
for _, tc := range []struct{ channel, wantPath string }{
{"phone", "/v1/notify/send/sms"},
{"sms", "/v1/notify/send/sms"},
{"email", "/v1/notify/send/email"},
} {
c, got := spy(t, 200)
if err := c.Send(context.Background(), "hanzo", tc.channel, "dest", "123456"); err != nil {
t.Fatalf("Send(%q): %v", tc.channel, err)
}
if got.path != tc.wantPath {
t.Errorf("channel %q posted to %q, want %q", tc.channel, got.path, tc.wantPath)
}
}
}
// The tenant must ride the request: notify picks the provider credential by org,
// so a send that named none would be routed through nobody's account — or worse,
// a default one belonging to another tenant.
func TestOrgIsRequiredAndTravels(t *testing.T) {
c, got := spy(t, 200)
if err := c.Send(context.Background(), "", "email", "a@b.test", "123456"); err == nil {
t.Fatal("a send with no org must fail rather than be routed by guesswork")
}
if got.path != "" {
t.Fatal("a send with no org reached the network")
}
c, got = spy(t, 200)
if err := c.Send(context.Background(), "hanzo", "email", "a@b.test", "123456"); err != nil {
t.Fatalf("Send: %v", err)
}
if got.org != "hanzo" {
t.Errorf("X-Org-Id = %q, want hanzo", got.org)
}
if got.auth != "Bearer tok" {
t.Errorf("Authorization = %q, want the service token", got.auth)
}
}
// The code reaches the person, and the message names no brand — one process
// answers for every white-label identity host, so a hardcoded name would be the
// wrong one on most of them.
func TestMessageCarriesTheCodeAndNoBrand(t *testing.T) {
c, got := spy(t, 200)
if err := c.Send(context.Background(), "hanzo", "phone", "+14155550134", "246810"); err != nil {
t.Fatalf("Send: %v", err)
}
if !strings.Contains(got.body.Body, "246810") {
t.Errorf("body %q does not carry the code", got.body.Body)
}
if len(got.body.To) != 1 || got.body.To[0] != "+14155550134" {
t.Errorf("to = %v, want the one destination", got.body.To)
}
for _, brand := range []string{"Hanzo", "Lux", "Zoo"} {
if strings.Contains(got.body.Body, brand) {
t.Errorf("message names the brand %q; this process serves every identity host", brand)
}
}
// Subject rides email only — an SMS has nowhere to put one.
if got.body.Subject != "" {
t.Errorf("sms carried a subject: %q", got.body.Subject)
}
}
// A refusal from the provider must surface, with enough of the answer to be
// diagnosable — that detail is the difference between "SMS is broken" and
// "this number is unverified on the org's Twilio account".
func TestProviderRefusalSurfaces(t *testing.T) {
c, _ := spy(t, 400)
err := c.Send(context.Background(), "hanzo", "phone", "+14155550134", "123456")
if err == nil {
t.Fatal("a non-2xx answer must be reported as a failed send")
}
if !strings.Contains(err.Error(), "21608") {
t.Errorf("error %q drops the provider's reason", err)
}
}
// An unknown channel is refused rather than guessed into one of the two routes.
func TestUnknownChannelIsRefused(t *testing.T) {
c, got := spy(t, 200)
if err := c.Send(context.Background(), "hanzo", "carrier-pigeon", "dest", "123456"); err == nil {
t.Fatal("an unknown channel must not be sent")
}
if got.path != "" {
t.Fatal("an unknown channel reached the network")
}
}
// A credential is a principal of ONE tenant and notify sends as the principal,
// so a code minted for another org would go out through THIS org's provider --
// delivered, but billed and attributed to the wrong company. Refuse instead.
func TestSendingForAnotherTenantIsRefused(t *testing.T) {
c, got := spy(t, 200)
err := c.Send(context.Background(), "lux", "phone", "+14155550134", "123456")
if err == nil {
t.Fatal("a send for another tenant must be refused, not routed through this org's provider")
}
if !strings.Contains(err.Error(), "lux") || !strings.Contains(err.Error(), "hanzo") {
t.Errorf("error %q should name both the credential's org and the one asked for", err)
}
if got.path != "" {
t.Fatal("a cross-tenant send reached the network")
}
}
// An org with no address, or an address with no org, is not delivery. Both must
// yield nil so nothing is bound and the login screens keep hiding code sign-in.
func TestOrgIsPartOfTheDeliveryClaim(t *testing.T) {
if New("https://api.hanzo.ai", "tok", "") != nil {
t.Error("an address with no org looked like delivery")
}
if New("", "tok", "hanzo") != nil {
t.Error("an org with no address looked like delivery")
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"testing"
"github.com/hanzoai/iam/internal/mfa/factor"
)
// A code delivered to an address proves THAT channel, and the gate is told so.
// Without this the second factor offered after an emailed code could be another
// emailed code — a ceremony that proves nothing the first one did not.
func TestVerificationChannelNamesTheProvenFactor(t *testing.T) {
for _, tc := range []struct{ identifier, want string }{
{"someone@example.com", factor.Email},
{"+14155550134", factor.SMS},
{"4155550134", factor.SMS},
} {
if got := verificationChannel(tc.identifier); got != tc.want {
t.Errorf("verificationChannel(%q) = %q, want %q", tc.identifier, got, tc.want)
}
}
}
// The phone arm must not swallow ordinary usernames, and must not miss the shapes
// people actually type. It is a SHAPE test — whether the number names anyone is
// the lookup's business, not this function's.
func TestLooksLikePhone(t *testing.T) {
for _, tc := range []struct {
in string
want bool
}{
{"+14155550134", true},
{"+1 (415) 555-0134", true},
{"415-555-0134", true},
{"4155550134", true},
// Not phones: a username, an email, and a short numeric handle that a
// seven-digit floor deliberately keeps out of the phone lookup.
{"zeekay", false},
{"someone@example.com", false},
{"12345", false},
{"", false},
{"user123456789", false},
// A "+" is only meaningful leading; mid-string it is not phone punctuation.
{"415+555+0134", false},
} {
if got := looksLikePhone(tc.in); got != tc.want {
t.Errorf("looksLikePhone(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
// The attempt bound is what makes a six-digit code safe as a CREDENTIAL rather
// than merely as a signup gate. Five is a deliberate value: one would let anyone
// who knows your address destroy your live code by posting a wrong one.
func TestVerificationAttemptsAreBounded(t *testing.T) {
if verificationMaxAttempts <= 1 {
t.Fatalf("verificationMaxAttempts = %d: burning the code on the first miss hands a "+
"denial of service to anyone who knows the address", verificationMaxAttempts)
}
if verificationMaxAttempts > 10 {
t.Fatalf("verificationMaxAttempts = %d leaves too much of a six-digit space reachable",
verificationMaxAttempts)
}
}
+5 -5
View File
@@ -16,8 +16,8 @@ type fakeSender struct {
sent []string
}
func (f *fakeSender) Send(_ context.Context, channel, dest, code string) error {
f.sent = append(f.sent, channel+":"+dest+":"+code)
func (f *fakeSender) Send(_ context.Context, org, channel, dest, code string) error {
f.sent = append(f.sent, org+":"+channel+":"+dest+":"+code)
return f.err
}
@@ -114,13 +114,13 @@ func TestSendFailureIsReportedNotSwallowed(t *testing.T) {
f := &fakeSender{err: errors.New("twilio: 21608 unverified number")}
bindSender(t, f)
if err := sender.Send(context.Background(), "email", "someone@example.com", "123456"); err == nil {
if err := sender.Send(context.Background(), "hanzo", "email", "someone@example.com", "123456"); err == nil {
t.Fatal("a failing sender must surface its error to the endpoint")
}
if len(f.sent) != 1 {
t.Fatalf("sender was called %d times, want 1", len(f.sent))
}
if f.sent[0] != "email:someone@example.com:123456" {
t.Errorf("sender got %q — channel, destination and code must all reach it", f.sent[0])
if f.sent[0] != "hanzo:email:someone@example.com:123456" {
t.Errorf("sender got %q — org, channel, destination and code must all reach it", f.sent[0])
}
}
+133 -19
View File
@@ -11,6 +11,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/pkg/schema"
@@ -36,7 +37,11 @@ type loginForm struct {
Organization string `json:"organization"`
Username string `json:"username"` // email OR username
Password string `json:"password"`
Type string `json:"type"` // "code" (PKCE authorize) | "device" (RFC 8628 approval) | "login" (bare session)
// Code is a one-time code delivered to the identifier in Username, offered
// INSTEAD of Password. Present means "sign me in with this code"; the two are
// alternatives and a request carrying a code never reaches the password check.
Code string `json:"code"`
Type string `json:"type"` // "code" (PKCE authorize) | "device" (RFC 8628 approval) | "login" (bare session)
// UserCode is the RFC 8628 code the device displays, transcribed by the human
// approving it (type=device).
@@ -172,7 +177,10 @@ func loginHandler(db orm.DB) zip.Handler {
}
}
if f.Organization == "" || f.Username == "" || f.Password == "" {
// One credential is required, not specifically a password: a code stands in
// its place. Spelling this as "password == ''" refused every code sign-in
// here, before the arm that knows how to read one.
if f.Organization == "" || f.Username == "" || (f.Password == "" && f.Code == "") {
return httpx.Err(c, "organization, username and password are required")
}
@@ -180,6 +188,26 @@ func loginHandler(db orm.DB) zip.Handler {
if err != nil {
return httpx.Err(c, err.Error())
}
// A code in place of a password: the SAME door, one arm further in. Sign-in
// by email or SMS proves possession of an address the account already
// holds, which is one factor exactly as a password is, so it joins here
// rather than at a second endpoint — the MFA gate, the device approval and
// the PKCE tail below are then true of it by construction instead of by a
// second implementation that has to be kept in step.
if f.Code != "" {
ok, err := codeLogin(ctx, db, f, user)
if err != nil {
return httpx.Err(c, err.Error())
}
if !ok {
return httpx.Err(c, "the code is incorrect or has expired")
}
// The code proved the channel it arrived on, so the gate is told WHICH
// factor is already satisfied and offers a different one — an email code
// is never answered by demanding a second email code.
return afterFirstFactor(c, db, user, f, verificationChannel(f.Username))
}
// The hash algorithm is a property of the ROW, not a constant: use the
// user's PasswordType, falling back to the organization's (v1's
// object/check.go contract). Every live v1 row is argon2id — a bcrypt-only
@@ -198,26 +226,82 @@ func loginHandler(db orm.DB) zip.Handler {
return httpx.Err(c, "the username or password is incorrect")
}
// The password proved ONE factor. The gate holds the sign-in when a second
// factor is outstanding — before ANY token or device approval — and answers
// the request itself; a false means nothing more is owed. The verificationType
// is "" because a password proves none of the offerable factors.
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return httpx.Err(c, err.Error())
}
gated, err := gate(c, db, user, org, "")
if err != nil {
return httpx.Err(c, err.Error())
}
if gated {
return nil
}
return loginGrant(c, db, user, f)
// The password proves none of the offerable second factors, so the gate is
// told "" and may ask for any of them.
return afterFirstFactor(c, db, user, f, "")
}
}
// afterFirstFactor runs everything owed between "this is the user" and the grant.
// The gate holds the sign-in when a second factor is outstanding — before ANY
// token or device approval — and answers the request itself; false means nothing
// more is owed.
//
// proven names the factor the FIRST credential already satisfied, so the gate can
// exclude it (mfa_gate.allowList drops the matching factor): a password proves
// none and passes "", while an emailed or texted code proves that channel and must
// not be answered by demanding the same channel again. Both credential arms end
// here so the rules between proof and grant are stated once and cannot drift into
// being true of one arm and false of the other.
func afterFirstFactor(c *zip.Ctx, db orm.DB, user *schema.User, f loginForm, proven string) error {
ctx := c.Context()
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return httpx.Err(c, err.Error())
}
gated, err := gate(c, db, user, org, proven)
if err != nil {
return httpx.Err(c, err.Error())
}
if gated {
return nil
}
return loginGrant(c, db, user, f)
}
// verificationChannel names the factor a code delivered to identifier proves —
// the same two words the MFA factors use, so the value can be handed straight to
// the gate.
func verificationChannel(identifier string) string {
if strings.Contains(identifier, "@") {
return factor.Email
}
return factor.SMS
}
// codeLogin verifies a one-time code as the whole first factor.
//
// It is deliberately strict about WHEN it may run, because it is a way into an
// account that never involves a password:
//
// - the application must have code sign-in switched on (EnableCodeSignin), the
// same per-app policy the login descriptor advertises;
// - delivery must be configured, or this process could not have sent the code
// it is being asked to trust;
// - the code is spent by [ConsumeVerificationCode] whatever the outcome — one
// use on a hit, one counted guess on a miss.
//
// A missing user is NOT an early return. The code is consumed first regardless,
// so a caller cannot learn which addresses have accounts by watching whether a
// wrong code was counted, and the answer is the same opaque false either way.
func codeLogin(ctx context.Context, db orm.DB, f loginForm, user *schema.User) (bool, error) {
if !DeliveryConfigured() {
return false, nil
}
app, err := store.GetApplicationByClientId(ctx, db, f.ClientId)
if err != nil {
return false, err
}
if app == nil || !app.EnableCodeSignin {
return false, nil
}
ok, err := ConsumeVerificationCode(ctx, db, f.Username, f.Code)
if err != nil || !ok {
return false, err
}
return user != nil, nil
}
// loginGrant completes a sign-in that has passed the gate: a device approval, a
// bare portal session, or a PKCE-bound authorization code. It is the ONE minting
// tail every interactive path reaches — the credential post and the second-factor
@@ -311,9 +395,39 @@ func resolveLoginUser(ctx context.Context, db orm.DB, org, identifier string) (*
if strings.Contains(identifier, "@") {
return store.GetUserByEmail(ctx, db, org, identifier)
}
// Phone LAST, and only for something shaped like a phone number. It runs after
// name so a user literally named "12345" still wins their own row, and the
// shape gate keeps an ordinary username from turning into a phone lookup.
//
// GetUserByPhone refuses to pick between two rows carrying one number
// (ErrPhoneAmbiguous). That error is returned, not swallowed into "no such
// user": the caller must not authenticate anyone against a number that
// identifies two accounts.
if looksLikePhone(identifier) {
return store.GetUserByPhone(ctx, db, org, identifier)
}
return nil, nil
}
// looksLikePhone reports whether an identifier is worth a phone lookup: at least
// seven digits, and nothing but digits and the punctuation people put in phone
// numbers. It is a SHAPE test, not validation — the lookup itself decides whether
// the number names anyone. Seven is the shortest national subscriber number in
// general use, and requiring it keeps short numeric usernames out of this arm.
func looksLikePhone(s string) bool {
digits := 0
for i, r := range s {
switch {
case r >= '0' && r <= '9':
digits++
case r == '+' && i == 0, r == ' ', r == '-', r == '(', r == ')', r == '.':
default:
return false
}
}
return digits >= 7
}
// loginOrgPasswordType returns the organization's PasswordType — the fallback
// when a user row carries none. A missing org yields "" (the user's own type
// then decides; if neither is set, cred.Verify fails closed rather than guessing
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package oidc
import (
"testing"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/pkg/schema"
)
// A delivered second factor goes to the address the ACCOUNT holds. If the caller
// could name it, the ceremony inverts: someone holding a first factor would have
// the code sent to an address they control and answer their own challenge.
func TestMfaDestinationComesFromTheAccount(t *testing.T) {
u := &schema.User{Email: "ada@example.com", Phone: "+1 (415) 555-0134"}
if got := mfaDestination(u, factor.Email); got != "ada@example.com" {
t.Errorf("email destination = %q, want the account's address", got)
}
// Normalized, so it matches the record a code was actually sent against.
if got := mfaDestination(u, factor.SMS); got != "+14155550134" {
t.Errorf("sms destination = %q, want the canonical stored number", got)
}
}
// No address for a factor means the challenge cannot be answered over it. Empty
// is a refusal, not a lookup for "the code sent to nobody".
func TestMfaDestinationRefusesWhatTheAccountLacks(t *testing.T) {
for _, tc := range []struct {
name string
user *schema.User
mfaType string
}{
{"no phone on file", &schema.User{Email: "ada@example.com"}, factor.SMS},
{"no email on file", &schema.User{Phone: "+14155550134"}, factor.Email},
{"phone is punctuation", &schema.User{Phone: "n/a"}, factor.SMS},
{"TOTP is not a delivered factor", &schema.User{Email: "a@b.test"}, factor.App},
{"unknown factor", &schema.User{Email: "a@b.test"}, "carrier-pigeon"},
{"no user at all", nil, factor.Email},
} {
if got := mfaDestination(tc.user, tc.mfaType); got != "" {
t.Errorf("%s: destination = %q, want empty (a refusal)", tc.name, got)
}
}
}
+45 -6
View File
@@ -103,6 +103,23 @@ func allowList(user *schema.User, org *schema.Organization, verificationType str
return allow
}
// mfaDestination is where a delivered second factor is sent: the address the
// ACCOUNT holds, resolved from the factor type and from nothing the caller said.
// Empty means the user has no address for that factor, which is a refusal — a
// challenge cannot be answered over a channel the account does not own.
func mfaDestination(user *schema.User, mfaType string) string {
if user == nil {
return ""
}
switch mfaType {
case factor.SMS:
return store.NormalizePhone(user.Phone)
case factor.Email:
return user.Email
}
return ""
}
// 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 {
@@ -159,14 +176,36 @@ func finishMfa(c *zip.Ctx, db orm.DB, id string, f loginForm) error {
if f.MfaType == "" || f.MfaType == ch.Payload {
return httpx.Err(c, "invalid multi-factor authentication type")
}
if f.MfaType != factor.App {
// Only TOTP has a verifier here. Refuse anything else rather than wave it
// through: a factor with no verification is not a factor.
switch f.MfaType {
case factor.App:
if !factor.Verify(user.TotpSecret, f.Passcode) {
return httpx.Err(c, "the multi-factor authentication code is incorrect")
}
case factor.SMS, factor.Email:
// A texted or emailed second factor is a delivered code, verified by the
// same one-time machinery a code SIGN-IN uses: spent on success, counted
// on a miss. These were refused outright while nothing could deliver a
// code — correct then, since a factor with no verification is not a
// factor, and no longer true now that one can be sent.
//
// The destination is the user's OWN stored address and never anything
// off the request. Letting the caller name it would turn the second
// factor inside out: an attacker holding a first factor could have the
// code sent to an address they control and answer their own challenge.
dest := mfaDestination(user, f.MfaType)
if dest == "" || !DeliveryConfigured() {
return httpx.Err(c, "invalid multi-factor authentication type")
}
ok, err := ConsumeVerificationCode(ctx, db, dest, f.Passcode)
if err != nil {
return httpx.Err(c, err.Error())
}
if !ok {
return httpx.Err(c, "the multi-factor authentication code is incorrect")
}
default:
return httpx.Err(c, "invalid multi-factor authentication type")
}
if !factor.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.
+71 -4
View File
@@ -38,9 +38,17 @@ import (
// POST /v1/notify/send/{email,sms} and reads the org's own Twilio credential from
// KMS. Implementations carry the transport; nothing here knows or cares which.
type Sender interface {
// Send delivers code to dest over channel ("email" or "phone"). A non-nil
// error means the person did NOT receive it.
Send(ctx context.Context, channel, dest, code string) error
// Send delivers code to dest over channel ("email" or "phone") on behalf of
// org. A non-nil error means the person did NOT receive it.
//
// org is REQUIRED, and it is the tenant whose record this code was minted
// under (rec.Owner) — not a brand string and not a default. notify resolves
// the provider credential from the org's own KMS entry, so a send with the
// wrong org reaches the wrong account's Twilio and a send with none cannot be
// routed at all. It is a parameter rather than something the transport is
// constructed with because one bound sender serves every tenant this process
// answers for.
Send(ctx context.Context, org, channel, dest, code string) error
}
// sender is bound once at boot, before the server accepts a request, and read
@@ -187,7 +195,10 @@ func sendVerificationCode(db orm.DB) zip.Handler {
// that leaves a person waiting on a message that will never arrive.
return httpx.Err(c, "verification codes cannot be delivered: no notify service is configured")
}
if err := sender.Send(ctx, typ, dest, code); err != nil {
// rec.Owner, not org.Name read again: the code that was persisted and the
// code that goes out must be attributed to the SAME tenant, so they read
// the one value.
if err := sender.Send(ctx, rec.Owner, typ, dest, code); err != nil {
// Report the real outcome. Answering ok because the code was minted
// would recreate the same lie one layer down: the send is what the
// caller asked for, and it failed.
@@ -209,6 +220,62 @@ func generateCode(n int) (string, error) {
return fmt.Sprintf("%0*d", n, k), nil
}
// verificationMaxAttempts bounds wrong guesses against ONE delivered code.
//
// Five, not one. Burning the code on the first miss is the strongest bound and
// the wrong trade: anyone who knows your address can post a wrong code while your
// real one is live and destroy it, so a one-attempt rule hands out a denial of
// service to any stranger. Five leaves a typo survivable and still cuts the search
// space from a million to five.
const verificationMaxAttempts = 5
// ConsumeVerificationCode verifies code against the latest live record for
// receiver and SPENDS the outcome — the check side of the OTP surface for callers
// where the code IS the credential.
//
// It exists beside [CheckVerificationCode] because a code that authenticates must
// be accounted for and a code that merely gates a signup need not be. Verifying
// and spending are one operation here on purpose: split across two calls, every
// caller would have to remember to spend, and the one that forgot would leave a
// replayable login credential lying in the table.
//
// - a hit marks the record used, so the code is one-time
// - a miss counts, and the count is what makes the code unguessable; at
// [verificationMaxAttempts] the record is spent so the run cannot continue
//
// Reports whether the code was accepted. A spent, expired or absent record is a
// plain false: the caller must not distinguish them, or it answers "that address
// has a code outstanding" to anyone who asks.
func ConsumeVerificationCode(ctx context.Context, db orm.DB, receiver, code string) (bool, error) {
if receiver == "" || code == "" {
return false, nil
}
rec, err := store.GetLatestVerificationRecord(ctx, db, receiver)
if err != nil || rec == nil {
return false, err
}
if nowFunc().Unix()-rec.Time > int64(verificationCodeTTL/time.Second) {
return false, nil
}
if cred.ConstantTimeEqual(rec.Code, code) {
rec.IsUsed = true
if err := store.SaveVerificationRecord(ctx, db, rec); err != nil {
// The code was right, but a record that cannot be spent is a record that
// can be replayed. Refuse rather than admit a credential we cannot retire.
return false, err
}
return true, nil
}
rec.Attempts++
if rec.Attempts >= verificationMaxAttempts {
rec.IsUsed = true
}
if err := store.SaveVerificationRecord(ctx, db, rec); err != nil {
return false, err
}
return false, nil
}
// CheckVerificationCode reports whether code matches the latest unused,
// unexpired verification record sent to receiver — the check side of the OTP
// surface, which the signup email/phone gate calls ahead of account creation at
+2 -2
View File
@@ -15,9 +15,9 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
// The native front-door signup: POST /v1/iam/signup. The @hanzo/iam SDK + the
@@ -218,7 +218,7 @@ func signupHandler(db orm.DB) zip.Handler {
LastName: f.LastName,
Email: email,
EmailVerified: false,
Phone: f.Phone,
Phone: store.NormalizePhone(f.Phone),
CountryCode: f.CountryCode,
Affiliation: f.Affiliation,
Avatar: org.DefaultAvatar,
+5 -11
View File
@@ -664,17 +664,11 @@ func userClaims(ctx context.Context, db orm.DB, userID string) Identity {
// — so it spends the ORG POOL. That is already what account.Payer's shape rule
// concludes for a machine in EVERY org but one: the rule makes the signup org
// special and hands anyone in it a PERSONAL wallet. A machine has no person, so
// that wallet is one nothing funds: the console credits an ORG, and a staff grant
// defaults to the org pool, so "<signupOrg>/<appName>" sits at $0 while the org's
// balance is one key away. Hanzo's own first-party services all authenticate this
// way and all live in the signup org, so every one of them billed an unfunded
// wallet and 402'd against a funded org.
//
// The deposit side is NOT yet symmetric, and saying so is the point: a grant that
// explicitly NAMES an application resolves through principal.WalletFor, which asks
// Payer with no machine signal and therefore still addresses that same personal
// wallet — money put there is now money nothing spends. Closing that is a change
// where the grant is resolved, not here; this seam only fixes what the token says.
// that wallet is a ghost — no funding path can name "<signupOrg>/<appName>", an
// admin grant credits the pool and a deposit names a real member — leaving it $0
// forever while the org's balance sits one key away. Hanzo's own first-party
// services all authenticate this way and all live in the signup org, so every one
// of them billed a wallet that could not be funded and 402'd against a funded org.
//
// The claim is not new authority, it is the same answer stated where it cannot be
// lost: Payer only INFERRED machine-ness before, from a User.Type a user can set
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package provision
import (
"encoding/json"
"strings"
"testing"
)
// An undeclared setting must be OMITTED from the body, not sent as false.
//
// This is the whole reason the field is a pointer. The upsert applies any value
// it receives, so a plain bool would send false on every converge of every app
// whose document never mentions code sign-in — silently switching the method off
// across the fleet on the next unrelated reconcile.
func TestCodeSigninOmittedWhenUndeclared(t *testing.T) {
body, err := json.Marshal(Client{Organization: "hanzo", Name: "hanzo-id"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "enableCodeSignin") {
t.Errorf("an undeclared setting reached the wire: %s", body)
}
}
// Declaring it — either way — must travel, so a document can turn the method on
// AND can deliberately turn it back off.
func TestCodeSigninTravelsWhenDeclared(t *testing.T) {
for _, want := range []bool{true, false} {
v := want
body, err := json.Marshal(Client{Organization: "hanzo", Name: "hanzo-id", EnableCodeSignin: &v})
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["enableCodeSignin"] != want {
t.Errorf("enableCodeSignin = %v, want %v (body %s)", got["enableCodeSignin"], want, body)
}
}
}
// The document field reaches the wire field. Two names for one setting is how a
// declaration silently stops taking effect.
func TestCodeSigninCarriesFromDocumentToClient(t *testing.T) {
on := true
doc := `
orgs:
- name: hanzo
displayName: Hanzo
homepage: https://hanzo.ai
apps:
- app: id
type: spa
hosts: [hanzo.id]
cert: cert-hanzo
codeSignin: true
`
parsed, err := Parse([]byte(doc))
if err != nil {
t.Fatalf("parse: %v", err)
}
app := parsed.Orgs[0].Apps[0]
if app.CodeSignin == nil || *app.CodeSignin != on {
t.Fatalf("document codeSignin did not parse: %v", app.CodeSignin)
}
clients, err := Derive(parsed)
if err != nil {
t.Fatalf("derive: %v", err)
}
if len(clients) != 1 {
t.Fatalf("derived %d clients, want 1", len(clients))
}
if c := clients[0]; c.EnableCodeSignin == nil || !*c.EnableCodeSignin {
t.Errorf("codeSignin did not reach the registration: %v", c.EnableCodeSignin)
}
}
+16
View File
@@ -142,6 +142,10 @@ type App struct {
// type-derived set is a silent revocation of every grant the type does not
// name.
Grants []string `yaml:"grants"`
// CodeSignin offers sign-in by an emailed or texted one-time code beside the
// password. A POINTER so saying nothing preserves the app's current setting
// rather than turning the method off — see Client.EnableCodeSignin.
CodeSignin *bool `yaml:"codeSignin"`
// ExpireInHours and RefreshExpireInHours are this client's token lifetimes.
// Same names as the wire and the stored model, so one value has one name from
// document to registration.
@@ -182,6 +186,17 @@ type Client struct {
// every converge and reset every app's lifetimes to the default.
ExpireInHours *float64 `json:"expireInHours,omitempty"`
RefreshExpireInHours *float64 `json:"refreshExpireInHours,omitempty"`
// EnableCodeSignin offers sign-in by an emailed or texted one-time code
// alongside the password. A POINTER for the same reason the lifetimes are:
// an undeclared setting is OMITTED and the upsert preserves what the app has,
// where a plain bool would send false on every converge and silently switch
// the method off for every app whose document does not mention it.
//
// Declaring it true is a request, not a guarantee: the login descriptor also
// requires the server to have a delivery transport bound, so an app that asks
// for code sign-in on a deployment that cannot send one still advertises only
// what it can finish.
EnableCodeSignin *bool `json:"enableCodeSignin,omitempty"`
}
// App types. A document that names anything else is rejected at Derive rather
@@ -338,6 +353,7 @@ func deriveApp(org Org, a App) (Client, error) {
Cert: strings.TrimSpace(a.Cert),
ExpireInHours: stated(a.ExpireInHours),
RefreshExpireInHours: stated(a.RefreshExpireInHours),
EnableCodeSignin: a.CodeSignin,
}
if c.RedirectUris == nil && a.Type != TypeService {
return Client{}, fmt.Errorf("provision: app %s declares no hosts and type %q needs a redirect", id, a.Type)
+2 -2
View File
@@ -13,9 +13,9 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/internal/users"
)
const (
@@ -180,7 +180,7 @@ func applyToUser(in *scimUser, u *schema.User, allowAdmin bool) (password string
// declared mutability:readOnly in the /Schemas document (RFC 7643 §7: a readOnly
// attribute in a write is ignored) and projected on read only.
u.Email = primaryValue(in.Emails)
u.Phone = primaryValue(in.PhoneNumbers)
u.Phone = store.NormalizePhone(primaryValue(in.PhoneNumbers))
if v := primaryValue(in.Photos); v != "" {
u.Avatar = v
}
+7
View File
@@ -233,6 +233,13 @@ var appPolicyKeys = []string{
"orgChoiceMode",
"isShared",
"organization",
// Which identity providers an app offers is the same kind of fact as
// enableWebAuthn: it decides who may sign in, names only provider RECORDS
// (no redirect, no secret), and has no legitimate live drift. Without it,
// an app registered by the provision document (which cannot say providers)
// could never gain a social button from declared state — hanzo-cli sat
// password-only while init_data.json said otherwise.
"providers",
}
// upsert creates entity if (owner,name) is absent; otherwise counts it skipped
+20 -1
View File
@@ -34,6 +34,7 @@ import (
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/compare"
"github.com/hanzoai/iam/internal/notify"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/provision"
"github.com/hanzoai/iam/internal/routes"
@@ -57,7 +58,7 @@ func main() {
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(serveCmd(), compareCmd(), provisionCmd(), versionCmd())
root.AddCommand(serveCmd(), compareCmd(), provisionCmd(), phonesCmd(), versionCmd())
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintf(os.Stderr, "iam: %v\n", err)
@@ -114,6 +115,24 @@ func serve(ctx context.Context, storeBackend, dbPath, zapAddr, httpAddr, opsAddr
return fmt.Errorf("serve: %w", err)
}
// Bind the transport that carries a verification code to a person. Everything
// code-shaped is switched off until this line runs with a real address: email
// and SMS sign-in, and the email and SMS second factors, all read
// `oidc.DeliveryConfigured`, which reports on the BOUND SENDER rather than on
// configuration. So an unset IAM_NOTIFY_ADDR is not a half-configured state —
// notify.New returns nil, nothing is bound, and every screen goes on hiding
// the methods this process cannot complete. Setting it turns all four on at
// once, with no second switch to remember.
//
// Deliberately NOT fatal when unset: password and social sign-in are complete
// without it, and an identity service that refuses to boot because it cannot
// send SMS is worse than one that honestly offers fewer methods.
if n := notify.New(os.Getenv("IAM_NOTIFY_ADDR"), os.Getenv("IAM_NOTIFY_TOKEN"), os.Getenv("IAM_NOTIFY_ORG")); n != nil {
oidc.BindSender(n)
} else {
fmt.Fprintln(os.Stderr, "iam: IAM_NOTIFY_ADDR/IAM_NOTIFY_ORG unset — email/SMS codes and their second factors stay off")
}
// Bootstrap the config (orgs/apps/providers/certs) from init_data.json — the
// same file the the legacy surface iam uses — so a fresh store comes up with the real
// application/provider/cert set instead of empty. New-only + idempotent.
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package main
import (
"context"
"fmt"
"github.com/hanzoai/orm"
"github.com/spf13/cobra"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
)
// phonesCmd converts stored phone numbers to the one canonical form sign-in
// compares against.
//
// Phone numbers were persisted verbatim from four write sites for as long as the
// column has existed, so the table holds the same number written several ways.
// Sign-in by SMS resolves a user with an EQUALITY lookup on the normalized value,
// which means a row left in its original clothes simply never matches and that
// person silently cannot sign in by text. Those rows are what this converts.
//
// It is safe to re-run: NormalizePhone is idempotent, so a second pass over an
// already-converted table changes nothing and reports zero. It is also safe to
// stop and resume — each row is written on its own, and a row already converted
// is skipped rather than rewritten.
//
// --dry-run is the default posture for a reason: this is the one operation here
// that rewrites rows in a live user table, and the count it prints is what tells
// you whether the change about to be made is the size you expected.
func phonesCmd() *cobra.Command {
var storeBackend, dbPath string
var apply bool
cmd := &cobra.Command{
Use: "phones",
Short: "Convert stored phone numbers to the canonical sign-in form",
RunE: func(cmd *cobra.Command, _ []string) error {
db, err := openStore(storeBackend, dbPath)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
return normalizePhones(cmd.Context(), cmd, db, apply)
},
}
f := cmd.Flags()
f.StringVar(&storeBackend, "store", "sqlite", "storage backend: sqlite | sql | datastore")
f.StringVar(&dbPath, "db", "data/iam.db", "SQLite database path (store=sqlite)")
// Opt IN to writing. A backfill that wrote by default would be one typo away
// from rewriting a production user table nobody meant to touch.
f.BoolVar(&apply, "apply", false, "write the changes (default: report only)")
return cmd
}
// normalizePhones reports — and with apply, performs — the conversion.
//
// A row whose number is ALREADY canonical is untouched, so the reported count is
// the count of real changes rather than the size of the table. A number that
// normalizes to empty (punctuation only, no digits) is left exactly as it is:
// blanking it would destroy the original without putting anything usable in its
// place, and a human should look at those.
func normalizePhones(ctx context.Context, cmd *cobra.Command, db orm.DB, apply bool) error {
users, err := orm.TypedQuery[schema.User](db).GetAll(ctx)
if err != nil {
return fmt.Errorf("read users: %w", err)
}
out := cmd.OutOrStdout()
var changed, unusable int
for _, u := range users {
if u.Phone == "" {
continue
}
norm := store.NormalizePhone(u.Phone)
if norm == u.Phone {
continue
}
if norm == "" {
unusable++
fmt.Fprintf(out, " %s/%s %q -> (no digits; left as is)\n", u.Owner, u.Name, u.Phone)
continue
}
changed++
fmt.Fprintf(out, " %s/%s %q -> %q\n", u.Owner, u.Name, u.Phone, norm)
if !apply {
continue
}
u.Phone = norm
if err := u.UpdateCtx(ctx); err != nil {
return fmt.Errorf("update %s/%s: %w", u.Owner, u.Name, err)
}
}
verb := "would convert"
if apply {
verb = "converted"
}
fmt.Fprintf(out, "phones: %s %d of %d rows", verb, changed, len(users))
if unusable > 0 {
fmt.Fprintf(out, "; %d carry no digits and were left alone", unusable)
}
fmt.Fprintln(out)
if !apply && changed > 0 {
fmt.Fprintln(out, "phones: re-run with --apply to write")
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package main
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/orm"
"github.com/spf13/cobra"
"github.com/hanzoai/iam/pkg/schema"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/server"
)
func seedUser(t *testing.T, db orm.DB, owner, name, phone string) {
t.Helper()
row := orm.New[schema.User](db)
model := row.Model
row.Owner, row.Name, row.Phone = owner, name, phone
row.Model = model
row.SetId(owner + "/" + name)
if err := row.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed %s/%s: %v", owner, name, err)
}
}
func phoneOf(t *testing.T, db orm.DB, owner, name string) string {
t.Helper()
u, err := store.GetUserByName(context.Background(), db, owner, name)
if err != nil || u == nil {
t.Fatalf("read %s/%s: %v", owner, name, err)
}
return u.Phone
}
func backfillDB(t *testing.T) orm.DB {
t.Helper()
sdb, err := server.OpenSQLite(filepath.Join(t.TempDir(), "backfill.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = sdb.Close() })
return sdb
}
func run(t *testing.T, db orm.DB, apply bool) string {
t.Helper()
cmd := &cobra.Command{}
var out bytes.Buffer
cmd.SetOut(&out)
if err := normalizePhones(context.Background(), cmd, db, apply); err != nil {
t.Fatalf("normalizePhones(apply=%v): %v", apply, err)
}
return out.String()
}
// The default posture REPORTS and writes nothing. This is the property that makes
// the count trustworthy before anyone points it at a production user table.
func TestBackfillDryRunWritesNothing(t *testing.T) {
db := backfillDB(t)
seedUser(t, db, "hanzo", "ada", "+1 (415) 555-0134")
out := run(t, db, false)
if got := phoneOf(t, db, "hanzo", "ada"); got != "+1 (415) 555-0134" {
t.Fatalf("dry run mutated the row: %q", got)
}
if !strings.Contains(out, "would convert 1") {
t.Errorf("report did not state the pending change:\n%s", out)
}
if !strings.Contains(out, "--apply") {
t.Errorf("report did not say how to perform it:\n%s", out)
}
}
// With --apply the row becomes exactly what the sign-in lookup compares against.
func TestBackfillApplyCanonicalizes(t *testing.T) {
db := backfillDB(t)
seedUser(t, db, "hanzo", "ada", "+1 (415) 555-0134")
seedUser(t, db, "hanzo", "grace", "415-555-0199")
run(t, db, true)
if got := phoneOf(t, db, "hanzo", "ada"); got != "+14155550134" {
t.Errorf("ada = %q, want the canonical form", got)
}
if got := phoneOf(t, db, "hanzo", "grace"); got != "4155550199" {
t.Errorf("grace = %q, want the canonical form", got)
}
// The converted row is now reachable by the lookup sign-in actually uses —
// the point of the whole exercise.
u, err := store.GetUserByPhone(context.Background(), db, "hanzo", "+1 415 555 0134")
if err != nil || u == nil || u.Name != "ada" {
t.Fatalf("converted row not reachable by sign-in lookup: %v %v", u, err)
}
}
// Re-running is a no-op. NormalizePhone is idempotent, so a second pass must
// report zero rather than rewrite the table again.
func TestBackfillIsSafeToRerun(t *testing.T) {
db := backfillDB(t)
seedUser(t, db, "hanzo", "ada", "+1 (415) 555-0134")
run(t, db, true)
out := run(t, db, true)
if !strings.Contains(out, "converted 0") {
t.Errorf("a second pass was not a no-op:\n%s", out)
}
}
// A value with no digits is LEFT ALONE. Blanking it would destroy the original
// and put nothing usable in its place; a human should look at those.
func TestBackfillLeavesUnusableValuesIntact(t *testing.T) {
db := backfillDB(t)
seedUser(t, db, "hanzo", "ada", "n/a")
seedUser(t, db, "hanzo", "grace", "")
out := run(t, db, true)
if got := phoneOf(t, db, "hanzo", "ada"); got != "n/a" {
t.Errorf("unusable value was rewritten to %q", got)
}
if !strings.Contains(out, "no digits") {
t.Errorf("report did not flag the unusable row:\n%s", out)
}
if got := phoneOf(t, db, "hanzo", "grace"); got != "" {
t.Errorf("empty phone became %q", got)
}
}
+7
View File
@@ -32,4 +32,11 @@ type VerificationRecord struct {
Code string `json:"code"`
Time int64 `json:"time"`
IsUsed bool `json:"isUsed"`
// Attempts counts wrong codes submitted against this record. A six-digit code
// live for ten minutes is a million guesses if nothing counts them, which is
// fine for a code that only gates a signup and NOT fine for one that is a
// login credential on its own. Bounding the count is what makes the two uses
// the same strength. Absent on rows written before this field existed, which
// reads as zero — the right starting value.
Attempts int `json:"attempts,omitempty"`
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package store_test
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/pkg/model"
"github.com/hanzoai/iam/pkg/store"
"github.com/hanzoai/iam/server"
)
func phoneDB(t *testing.T) orm.DB {
t.Helper()
sdb, err := server.OpenSQLite(filepath.Join(t.TempDir(), "phone.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = sdb.Close() })
return sdb
}
// The number reaches its owner, however the person typed it — the whole point of
// normalizing on both sides of the comparison.
func TestGetUserByPhoneMatchesAnyFormatting(t *testing.T) {
db := phoneDB(t)
addUser(t, db, &model.User{Owner: "hanzo", Name: "ada", Phone: "+14155550134"})
for _, typed := range []string{"+14155550134", "+1 (415) 555-0134", "+1-415-555-0134"} {
got, err := store.GetUserByPhone(context.Background(), db, "hanzo", typed)
if err != nil {
t.Fatalf("GetUserByPhone(%q): %v", typed, err)
}
if got == nil || got.Name != "ada" {
t.Errorf("GetUserByPhone(%q) = %v, want ada", typed, got)
}
}
}
// THE security property. Phone is indexed but NOT unique, so two rows in one org
// can carry one number. Returning either of them would authenticate a person
// against somebody else's account, so the lookup must refuse instead of choose.
func TestGetUserByPhoneRefusesAnAmbiguousNumber(t *testing.T) {
db := phoneDB(t)
addUser(t, db, &model.User{Owner: "hanzo", Name: "ada", Phone: "+14155550134"})
addUser(t, db, &model.User{Owner: "hanzo", Name: "grace", Phone: "+14155550134"})
got, err := store.GetUserByPhone(context.Background(), db, "hanzo", "+14155550134")
if !errors.Is(err, store.ErrPhoneAmbiguous) {
t.Fatalf("err = %v, want ErrPhoneAmbiguous — a number naming two accounts must name none", err)
}
if got != nil {
t.Fatalf("a user was returned for an ambiguous number: %s", got.Name)
}
}
// A blank phone must never match the many rows that legitimately store none.
// Without the guard this is an authentication oracle: an empty identifier would
// resolve to an arbitrary account.
func TestGetUserByPhoneIgnoresBlankInput(t *testing.T) {
db := phoneDB(t)
addUser(t, db, &model.User{Owner: "hanzo", Name: "ada"})
addUser(t, db, &model.User{Owner: "hanzo", Name: "grace"})
for _, blank := range []string{"", " ", "+", "()- ."} {
got, err := store.GetUserByPhone(context.Background(), db, "hanzo", blank)
if err != nil {
t.Fatalf("GetUserByPhone(%q): %v", blank, err)
}
if got != nil {
t.Fatalf("blank phone %q resolved to %s — an empty identifier must match nobody", blank, got.Name)
}
}
}
// The lookup is org-scoped: one tenant's number never reaches another's account.
func TestGetUserByPhoneIsOrgScoped(t *testing.T) {
db := phoneDB(t)
addUser(t, db, &model.User{Owner: "hanzo", Name: "ada", Phone: "+14155550134"})
got, err := store.GetUserByPhone(context.Background(), db, "lux", "+14155550134")
if err != nil {
t.Fatalf("GetUserByPhone: %v", err)
}
if got != nil {
t.Fatalf("cross-tenant resolve: org lux reached %s", got.Name)
}
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package store
import "testing"
// One number typed five ways is one value, or an equality lookup can never match
// what a human writes against what SCIM wrote.
func TestNormalizePhone(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"+14155550134", "+14155550134"},
{"+1 (415) 555-0134", "+14155550134"},
{"+1-415-555-0134", "+14155550134"},
{" +1 415.555.0134 ", "+14155550134"},
{"4155550134", "4155550134"},
{"(415) 555-0134", "4155550134"},
// Blank-ish input must NOT become a value: a phone lookup for "" would
// match the many rows that legitimately store no phone at all.
{"", ""},
{" ", ""},
{"+", ""},
{"()- .", ""},
// A "+" is only a country-code marker in the leading position; anywhere
// else it is punctuation and is dropped like the rest.
{"415+555+0134", "4155550134"},
} {
if got := NormalizePhone(tc.in); got != tc.want {
t.Errorf("NormalizePhone(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The normalizer must not invent a country code. Guessing "+1" would silently
// claim a number in one country for a user in another.
func TestNormalizePhoneDoesNotInferACountryCode(t *testing.T) {
if got := NormalizePhone("4155550134"); got == "+14155550134" {
t.Fatalf("NormalizePhone invented a country code: %q", got)
}
}
// Normalizing twice is normalizing once — the property the backfill relies on to
// be safe to re-run, and the reason a value written through a normalizing write
// site is already canonical.
func TestNormalizePhoneIsIdempotent(t *testing.T) {
for _, in := range []string{"+1 (415) 555-0134", "4155550134", "", "+"} {
once := NormalizePhone(in)
if twice := NormalizePhone(once); twice != once {
t.Errorf("NormalizePhone not idempotent for %q: %q then %q", in, once, twice)
}
}
}
+89
View File
@@ -210,6 +210,85 @@ func GetUserByEmail(_ context.Context, db orm.DB, owner, email string) (*schema.
return u, err
}
// ErrPhoneAmbiguous reports that a phone number identifies more than one account
// in an org, so it identifies nobody. Returned instead of a user, on purpose —
// see [GetUserByPhone].
var ErrPhoneAmbiguous = errors.New("phone number matches more than one account")
// NormalizePhone reduces a phone number to the ONE form this system stores and
// compares: a leading "+" if the caller gave one, then digits, nothing else.
//
// It exists because a phone typed by a human ("(415) 555-0134", "415-555-0134",
// "+1 415 555 0134") and a phone written by SCIM are the same number in different
// clothes, and an equality lookup cannot see through clothes. Applying it on WRITE
// and on READ is what makes one number one value; applying it on only one side
// would be a lookup that silently never matches.
//
// It deliberately does NOT infer a country code. Guessing "+1" for a bare
// ten-digit string would silently claim a number in one country for a user in
// another, and this function has no idea which country anyone is in. A number
// stored without a country code matches only a number typed without one, which is
// the honest behaviour; making country codes canonical is a data decision for
// whoever collects the number, not a guess for a string function.
func NormalizePhone(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
var b strings.Builder
for i, r := range s {
switch {
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '+' && i == 0:
b.WriteRune(r)
}
}
out := b.String()
if out == "+" {
return ""
}
return out
}
// GetUserByPhone resolves a user by (owner, phone) — the SMS-login identifier.
//
// It FAILS CLOSED on ambiguity, and that is the whole reason it is not a copy of
// [GetUserByEmail]. `schema.User.Phone` is indexed but NOT unique, so two rows in
// one org can carry the same number; a `.First()` would then hand back an
// arbitrary one of them and the caller would authenticate a person against
// somebody else's account. That is the same defect class as the cross-org
// collision the login path already refuses to rely on. A number that names two
// accounts names none, so this returns [ErrPhoneAmbiguous] and lets the caller
// refuse rather than choose.
//
// phone is normalized here so every caller compares the same way; a blank or
// punctuation-only argument returns (nil, nil) rather than matching the many rows
// that legitimately store no phone at all.
func GetUserByPhone(ctx context.Context, db orm.DB, owner, phone string) (*schema.User, error) {
phone = NormalizePhone(phone)
if phone == "" {
return nil, nil
}
// Two, not one: enough to detect a collision, and bounded so a shared number
// on many rows cannot pull them all into memory.
us, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Phone=", phone).Limit(2).GetAll(ctx)
if err == orm.ErrNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
switch len(us) {
case 0:
return nil, nil
case 1:
return us[0], nil
default:
return nil, ErrPhoneAmbiguous
}
}
// GetTokenByCode resolves a token row by its authorization code. Returns
// (nil, nil) when no row carries the code.
func GetTokenByCode(_ context.Context, db orm.DB, code string) (*schema.Token, error) {
@@ -543,6 +622,16 @@ func GetLatestVerificationRecord(_ context.Context, db orm.DB, receiver string)
return rec, err
}
// SaveVerificationRecord persists a change to an existing verification record —
// spending it, or counting a wrong guess against it. The row is addressed by the
// (owner, name) id it was created under, so this updates rather than inserts.
func SaveVerificationRecord(ctx context.Context, db orm.DB, rec *schema.VerificationRecord) error {
if rec == nil {
return nil
}
return rec.UpdateCtx(ctx)
}
// PersistFederationState creates a fresh in-flight federation transaction. The
// id is (owner, name); the caller sets Name to the opaque `state` token before
// persisting. Mirrors PersistToken — the orm.Model is preserved while the
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package server
import (
"context"
"testing"
)
type hostSender struct{ orgs []string }
func (h *hostSender) Send(_ context.Context, org, _, _, _ string) error {
h.orgs = append(h.orgs, org)
return nil
}
// A HOST outside this module must be able to supply the transport. The seam
// lives in an internal package, so without this re-export a grafted IAM could
// never deliver a code and every code-shaped method would stay dark in the one
// deployment that has notify in the same process.
func TestAHostCanBindDelivery(t *testing.T) {
var s Sender = &hostSender{}
BindSender(s)
t.Cleanup(func() { BindSender(nil) })
if !DeliveryConfigured() {
t.Fatal("binding a sender did not switch delivery on")
}
BindSender(nil)
if DeliveryConfigured() {
t.Fatal("unbinding did not switch delivery off — the predicate must follow the sender")
}
}
+32
View File
@@ -20,6 +20,7 @@ import (
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/internal/featurestore"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/seed"
_ "github.com/hanzoai/iam/pkg/schema" // registers the entity kinds
@@ -102,3 +103,34 @@ func OpenSQLite(path string) (orm.DB, error) {
func Seed(ctx context.Context, db orm.DB, initDataPath string) (*seed.Summary, error) {
return seed.FromInitData(ctx, db, initDataPath)
}
// Sender delivers one minted verification code. It is re-exported here because
// the seam itself lives in an internal package: a HOST binary that grafts IAM
// (cloud embeds it with server.NewApp) has to be able to supply the transport,
// and internal/oidc is by definition unreachable from outside this module.
//
// Composition is exactly what this package is for, and delivery is composition:
// which wire carries a code is the host's decision, never IAM's.
type Sender = oidc.Sender
// BindSender installs the delivery transport for email and SMS codes. Call it at
// boot, before serving.
//
// Everything code-shaped stays OFF until this is called with a non-nil sender:
// email sign-in, SMS sign-in, and the email and SMS second factors all read one
// predicate, and that predicate reports on the BOUND SENDER rather than on any
// configuration. So there is no half-configured state to reason about — a host
// that can deliver binds one and all four turn on together.
//
// The two hosts differ in the only way that matters, which is how the TENANT
// travels. A standalone iam reaches notify over the wire as a principal of one
// org, so its transport is pinned to that org and refuses any other. A grafted
// iam is in the same process as notify and can pass the org explicitly, so it
// serves every tenant with no credential at all. One seam, two transports —
// which is what a seam is for.
func BindSender(s Sender) { oidc.BindSender(s) }
// DeliveryConfigured reports whether a verification code can actually reach a
// person, so a host can assert on its own wiring. It answers from the bound
// sender, never from configuration.
func DeliveryConfigured() bool { return oidc.DeliveryConfigured() }