Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56689b2d2e | ||
|
|
41cf48d253 | ||
|
|
ce1cc2625f | ||
|
|
bd23d64486 | ||
|
|
7f02a8e9a8 | ||
|
|
f92f4b0196 | ||
|
|
9f585264f0 | ||
|
|
6504da1976 | ||
|
|
dc3eaa9adc |
+123
-15
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user