Compare commits

...
95 Commits
Author SHA1 Message Date
zeekayandClaude Opus 4.8 5a5e3e6700 build(iam2): digest-pin alpine + golang base images (DEK trust path)
build / docker (push) Successful in 1m15s
alpine:latest is the runtime base providing the sqlcipher CLI the migrator's
--wal-inclusive path pipes the raw 32-byte DEK to on stdin, so the base image
sits in the decryption-key trust path. A floating :latest is not acceptable for
a one-shot migration of irreplaceable auth data — a moved/trojaned base could
exfiltrate every credential. Pin both bases to current manifest-list digests
(golang was version- but not digest-pinned; alpine was fully floating).

  alpine:latest  @ sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
  golang:1.26.4  @ sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609

RED re-verify of v1.32.6 (migrator WAL guards) LOW finding; canary runs this
exact image. No functional change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:40:08 -07:00
zeekayandClaude Opus 4.8 8fedffc1c0 security(iam2): ONE header-immune host accessor — SIWE domain/CSRF via c.Host(), fail-loud issuer
build / docker (push) Successful in 1m10s
Close the X-Forwarded-Host footgun repo-wide and make "never echo the request
host into `iss`" structural in every issuer mode. One host accessor now: the
header-immune zip.Ctx.Host() (zip has no trusted-proxy knob, so a client
X-Forwarded-Host is ignored), the same seam the OIDC issuer resolver uses.

Part A — internal/oidc/issuer.go (issuer resolver uniformity):
- Fix #1: gate the host-relative dev issuer behind an explicit opt-in
  (IAM_DEV_HOST_RELATIVE=1). Absent it, empty IAM_ISSUER + unset map is a HARD
  boot error (errNoIssuer) instead of a silent fail-open host echo. issuerFor
  echoes the host ONLY inside the opt-in branch; every other branch returns a
  config value or the fixed devFallbackIssuer, so the no-echo property is
  structural, not just emergent from construction.
- Fix #3: https-validate a non-empty IAM_ISSUER at construction, the same bar
  map entries already clear (checked first, before the map).

Part B — retire httpx.EffectiveHost (internal/httpx/response.go, internal/wallet/*):
- EffectiveHost read X-Forwarded-Host DIRECTLY, bypassing zip's (absent)
  proxy-trust, so TrustProxy=false did NOT protect it. It fed the SIWE `domain`
  (the EIP-4361/CAIP-122 anti-phishing binding a user signs) and the CSRF
  same-origin check. A client who could inject X-Forwarded-Host could steer the
  signed domain (cross-brand phishing) and pass the same-origin check cross-site
  (wallet-linking CSRF).
- Route all three wallet call-sites (nonce domain, verify domain, same-origin
  check) through c.Host(), then DELETE EffectiveHost so exactly one host
  accessor remains repo-wide. c.Host() is the true routed brand host the ingress
  preserves — exactly what the SIWE domain must bind to — and immune to the
  header, so the binding and CSRF check are correct AND unspoofable.

Tests: issuer boot-error/opt-in/https (Part A) + SIWE domain & CSRF
header-immunity driven end-to-end, plus a legit same-brand login still
succeeds (Part B). A mutation reintroducing the old X-Forwarded-Host behavior
fails all four Part B guards, including a demonstrated cross-site wallet-link
CSRF — proving they are non-vacuous.

CGO_ENABLED=0 GOWORK=off go build ./... && go vet ./... && go test ./... : green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:43:54 -07:00
zeekayandClaude Opus 4.8 a825390cfc fix(migrate-v1): fail-closed WAL safety (copy fence + WAL-blind default block); ship sqlcipher + migrator
build / docker (push) Successful in 1m35s
Close the two HIGH silent-data-loss vectors RED flagged in the WAL-inclusive
Casdoor->iam2 migrator, plus the release-image sqlcipher gap. A silent bug here
loses production auth data at cutover, so both guards fail closed.

HIGH #1 (wal.go) — non-atomic multi-file copy of a possibly-live shard. The
WAL-inclusive path copied iam.db, -wal and -shm as three separate reads; a
checkpoint firing mid-copy moves committed frames out of the -wal we already read
into a main db we did not (then TRUNCATEs the -wal), silently dropping those rows.
Add a read-consistency fence: snapshot (existence,size,mtime) of all three files
before the copy and re-snapshot after; if anything moved, the source was written
during the window and the copied triple may be internally inconsistent -> abort
loudly. Pass => a consistent point-in-time image (zero frames lost); fail => a
hard refusal. Never a silent drop. Plus a defensive post-export drain check: the
copy's -wal must be emptied by wal_checkpoint(TRUNCATE) or the run aborts.

HIGH #2 (encrypted.go, main.go) — WAL-blind default was a silent lossy default.
The default checkpointed path only printed a non-blocking warning; forgetting
--wal-inclusive silently dropped every uncheckpointed-WAL row and reported
success. Now the default path HARD-FAILS on any shard carrying a non-empty -wal,
naming --wal-inclusive (capture the rows) or the new --ignore-wal (intentionally
drop them, with a loud warning). Checked before the dest store is opened, so a
refusal writes nothing.

Dockerfile — build /migrate-v1 alongside /iam2 and apk add sqlcipher (alpine
ships SQLCipher 4.x: 4.5.6 stable / 4.6.x edge, v4 format matches the production
data and the pure-Go codec) so ONE image serves both the server and the migration
Job; fix the stale image.source label iam2 -> iam.

Tests (real C sqlcipher present, the e2e is NOT skipped): concurrent-writer /
moving-WAL abort; default-path -wal hard-fail + --ignore-wal override; guard unit
matrix. Full tree green. Both new guards proven load-bearing via neuter-and-fail
negative controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 10:19:19 -07:00
zeekay c9f0ad7156 Merge remote-tracking branch 'origin/main' into feat/migrate-v1-wal-inclusive 2026-07-22 10:00:26 -07:00
zeekayandClaude Opus 4.8 989cf963ee security(iam2): per-host OIDC issuer resolver for single multi-tenant instance
build / docker (push) Successful in 1m12s
iam2 runs as ONE instance behind the ingress for every brand host (hanzo.id,
lux.id, id.zoo.network, pars.id, and their iam.* aliases). A single pinned
IAM_ISSUER made every non-matching brand emit the wrong `iss`, failing all their
RP validations. This adds a fail-closed, config-driven issuer resolver so each
brand emits its OWN pinned issuer from the SAME instance.

- internal/oidc/issuer.go: the ONE resolver. newIssuerResolver parses
  IAM_ISSUER (default) + IAM_ISSUER_MAP (JSON host->issuer) once into an
  immutable value. issuerFor(host): configured brand -> its pinned issuer;
  unknown/spoofed host -> the pinned default (fail-closed, never echoes the
  host); no config at all -> dev host-relative from the TRUSTED host. A map
  without a default, malformed JSON, or a non-https/empty entry is a hard
  startup error (fail LOUD, never silently mint under the wrong iss).
- tokenIssuer and federationBaseURL now both route through resolveIssuer(c.Host()).
  c.Host() is zip's X-Forwarded-Host-immune accessor, so the issuer is ALWAYS a
  trusted CONFIG value — a client header can only SELECT a configured brand,
  never inject an arbitrary/foreign iss. This is the single source of truth:
  discovery `issuer`, the derived `jwks_uri`, token `iss`, userinfo, device
  verify, and the federation callback origin all resolve identically per brand.
- main.go serve(): InitIssuerResolver() pins the map before the listener opens.
- Backward compatible: unset IAM_ISSUER_MAP == today's single-issuer behavior.

Tests: table resolution (brands, aliases, case/whitespace/port/trailing-dot),
attacker-host-never-echoed (incl. suffix/prefix confusion), backward-compat,
hard-error config, and HTTP e2e proving token `iss` == discovery `issuer` ==
`jwks_uri` base per brand and that a spoofed Host/X-Forwarded-Host fails closed.
go build/vet/test all green (CGO_ENABLED=0 GOWORK=off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:33:14 -07:00
zeekayandClaude Opus 4.8 3dbfb5a964 security(iam2): owner-pin app capabilities + mint; enforce global clientId uniqueness
build / docker (push) Successful in 1m10s
Closes the app-capability/mint escalation RED found after the prior pass: an
app principal's authority was keyed on its bare NAME, and the mint gate trusted
the resolved row's clientId as globally unique — neither the app's OWNING org nor
clientId uniqueness was enforced. Once public signup opens, a tenant could
signup -> onboard -> register <theirOrg>/hanzo-console (or an app whose clientId
collides with a mint-allow-listed one) and Basic-auth as it to inherit platform
capabilities / mint tokens for any user.

The fix is ONE conceptual change — pin the app's OWNING org to a reserved platform
signing owner (store.IsSigningCertOwner) — applied at the two gates an app's
authority flows through:

- [CRITICAL A] Principal carries AppOwner (set from the app row's Owner, not its
  served Organization). authz.Allowed — the single funnel for EVERY app capability
  (CapOrgAdmin/CapUserAdmin in authz, CapKeyMint/CapServiceAccountRead in
  serviceaccounts) — grants nothing unless AppOwner is admin/built-in. The NAME
  allowlist is thereby reserved to the admin-owned app, as its comment always
  claimed but never enforced.
- [HIGH B] oidc mintAllowed/adminMintAllowed take the resolved app and require the
  same owner-pin, so a colliding-clientId tenant app mints nothing. Additionally:
  clientId is now globally unique — enforced at the applications create/update
  write (ensureClientIdUnique), the JSON-document store's equivalent of the
  (owner,name) natural key since it has no column for a DB UNIQUE index — and
  store.GetApplicationByClientId resolves DETERMINISTICALLY, admin-preferring, so a
  stray duplicate resolves to the platform row on every backend (the First()
  no-ORDER-BY vector, unspecified on Postgres, is closed).
- [INFO] raw entity CRUD now gates the reserved-owner clause on store.IsReservedOrg
  (admin/built-in/app), the SAME predicate signup/onboarding use, so an "app"-org
  user is platform-reserved consistently across every surface.

All legit allow-listed/minter apps are admin-owned (seed Owner="admin"), so no
legitimate grant regresses. PoC tests for both attacks now DENY (incl. RED's four
assertions verbatim), and the full suite is green. [LOW] verified read-only against
the live iam-init-data ConfigMap: no admin/built-in/app-org app relies on
client_credentials/password, and all 80 seeded clientIds are already globally
unique (no dedup migration needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 03:46:20 -07:00
zeekayandClaude Opus 4.8 2cb582a71e security(iam2): close signup→admin-org SuperAdmin escalation; DRY the reserved-org gate
The 7-round Casdoor audit's invariant 1 (no admin-org self-signup) was the one
gap in iam2's clean-room model: onboarding and federated provisioning refused a
reserved system org, but POST /v1/iam/signup did not. Its only org gate was the
same-org/shared/org-choice tenant check — so a signup-enabled app that is admin-org,
shared, or org-choice admitted `organization=admin`, and a user under "admin" IS a
SuperAdmin (authz derives Super from owner == "admin"). Reachability depends on the
deploy-time init_data (no signup app ships in-repo), so this is closed structurally.

- store.IsReservedOrg — ONE reserved-org predicate {admin, built-in, app}, composed
  on IsSigningCertOwner so a new signing owner is covered for free. Replaces the
  package-private oidc.reservedOrgs map; onboard + federation now consult it too, so
  the reserved set can never drift across the three self-service surfaces.
- signup: refuse a reserved org before the tenant gate, independent of the app —
  byte-identical message to the tenant refuse, so there is no reserved-vs-tenant oracle.
- token endpoint (invariant 5, defense in depth): publicTokenEndpointForbidden refuses
  a reserved-Organization app (admin/built-in/app) on client_credentials + password,
  composing the existing <org>-iam gate — an admin-org app cannot mint on the public
  endpoint, structurally.
- signup.go: corrected a stale "bcrypt" comment (the canonical users.Create path
  stamps argon2id).

Tests: store.IsReservedOrg; signup refusal through shared / admin-org / org-choice
apps (+ no-oracle proof, + legitimate-tenant regression); token-endpoint reserved-org
refusal on both machine grants (+ tenant-org still-works regression); and the
lux.cloud signup-readiness proof (public PKCE lux-cloud client → PLAIN non-admin user
in the lux org, argon2id, EmailVerified=false).

Patch bump v1.32.2 -> v1.32.3 (tag cut on merge, post-RED, owner-gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 01:24:37 -07:00
zeekayandClaude Opus 4.8 b661f2e319 feat(migrate-v1): WAL-inclusive extraction for the encrypted sharded source
The encrypted-source path decrypted only each shard's CHECKPOINTED main db
(sqlcipher.DecryptFile), so rows still in an uncheckpointed `-wal` were
invisible. On the real production store most org shards carry 600-800KB of
uncheckpointed WAL, so a cutover built on the checkpointed image alone
undercounts users and locks recent signups out.

Add `--wal-inclusive` (with `--sqlcipher-bin`, default `sqlcipher`): per shard,
copy iam.db(+-wal/-shm) into a fresh 0700 temp, derive the DEK with the SAME
pure-Go recipe (deriveDEK, now shared with the checkpointed path), then drive
the C sqlcipher binary to `wal_checkpoint(TRUNCATE)` + `sqlcipher_export` a
plaintext copy, which the existing Migrate engine reads. The checkpointed path
stays the default so nothing regresses.

Safety: the DEK reaches the child ONLY on stdin (raw x'..' key), never on argv
or in logs; script bytes + hex are zeroed and child stderr is scrubbed of the
hex. A non-zero exit (-bail) or a non-SQLite export (wrong key) fails LOUDLY;
--wal-inclusive with an absent binary errors at preflight (never silent
fallback). The plaintext temp dir is shredded on every path incl. error.

Tests (CGO_ENABLED=0): a self-exec fake-sqlcipher proves key-off-argv,
plaintext validation, non-zero-exit and missing-binary fail loud, and shred;
and a real-C-sqlcipher end-to-end builds an org shard whose user lives ONLY in
an uncheckpointed -wal, proving the default path MISSES it while
--wal-inclusive recovers it and its golden argon2id digest verifies (skipped
when the binary is absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:02:48 -07:00
zeekayandClaude Opus 4.8 dee9bdd85d feat(store): export in-process Project reads + model.Project for embedders
build / docker (push) Successful in 1m15s
Add pkg/store — the in-process project store surface for a host binary that
EMBEDS iam2 (hanzoai/cloud) rather than talking to it over HTTP. GetProjects /
GetOrganizationProjects / GetProject / AddProject / DeleteProject take an explicit
orm.DB (v2 has no package-global engine) and reproduce the ONE project CRUD path's
orm calls, preserving the "owner/name" id semantics, so an embedder reads/writes
the SAME `projects` rows the mounted /v1/iam/projects surface serves. GetProject
returns (nil,nil) on miss — the embedder pre-check convention the retired iam-v1
object store used.

Also alias model.Project = schema.Project (mirroring model.OrgRef) so embedders
share the ONE project type, never a local clone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:41:39 -07:00
zeekayandClaude Opus 4.8 a5e8201deb feat(migrate-v1): read encrypted, sharded production source directly
Add a pure-Go encrypted-source path to the Phase-5 cutover migrator so the
whole fork -> clean migration is one command. The plaintext --src path is
unchanged.

New --src-datadir reads the SQLCipher/envelope-encrypted, sharded prod layout
in place: the GLOBAL shard (<dir>/iam.db; certs/apps/orgs; principal
global/"iam") first, then every <dir>/orgs/<slug>/iam.db (users; principal
org/<slug>) in sorted order. Each shard is decrypted with the proven recipe --
sqlite.DeriveKey (HKDF) -> read .dek sidecar -> sqlite.UnwrapDEK (AES-256-GCM)
-> sqlcipher.DecryptFile (pure-Go page codec) -> a 0600 temp -> the existing
Migrate engine (modernc, read-only) -> shred. Shards merge because Migrate
upserts by natural key.

Driver collision: DeriveKey/UnwrapDEK/PrincipalAAD are pure funcs in the ROOT
hanzoai/sqlite package that migrate.go already imports for the "sqlite" driver;
promoting that to a named import registers no new driver (one modernc
registrant under CGO_ENABLED=0), and hanzoai/sqlcipher registers none. So the
decrypt and the read live in one binary -- no os/exec helper, crypto stays in
hanzoai/sqlite. Documented in encrypted.go.

Safety: master key read from a NAMED env var (--src-master-key-env, default
IAM_KMS_MASTER_KEY), never an arg, never logged; wrong key fails loud at
UnwrapDEK before any write; temps shredded on every path incl. error; a
--note logs that the run captures checkpointed state (no -wal merge).

Tests (CGO_ENABLED=0): build a real encrypted fixture in-test via a reserved
canvas (DecryptFile the C libsqlcipher interop vector -> reserved plaintext;
modernc preserves the 80-byte reserve; EncryptFile our own schema) and run the
full DeriveKey->Unwrap->DecryptFile->migrate chain across a global + two org
shards -- the golden argon2id digest verifies under internal/cred after
decryption, wrong master fails loud with no writes, temps are shredded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:43:28 -07:00
zeekayandClaude Opus 4.8 a5d5929c0b feat(migrate-v1): Phase-5 legacy Casdoor -> clean IAM data migrator
Reads the legacy Casdoor-fork SQLite iam.db and upserts every identity
record into the clean IAM v2 orm store, preserving credentials and signing
keys byte-for-byte (the cutover gate).

- cmd/migrate-v1: standalone `migrate-v1 --src <iam.db> --dest <data-dir>
  [--dry-run] [--only ...]`. Opens the source read-only; opens the dest
  through the SAME store.Open the server uses (schema parity).
- Column mapping is discovered, not assumed: PRAGMA table_info + sqlite_master,
  matched to clean schema fields by a normalized key (lowercase, strip
  underscores) so xorm snake_case lines up with camelCase json tags. The one
  rename normalization can't bridge — legacy `password` column -> clean
  PasswordHash — is declared explicitly, so no user's hash is dropped.
- Credential/key material (PasswordHash/Type/Salt, Cert.PrivateKey/Certificate)
  is reconstructed as JSON and unmarshaled, never passed through a lossy typed
  conversion. Natural key is owner/name (== the clean OIDC sub); the legacy
  per-row UUID has no clean home and is reported as a gap.
- Idempotent: create/overwrite-if-changed/no-op, so re-runs don't duplicate or
  churn. --dry-run reports counts + a secret-redacted sample without writing.
- Entity order: organizations -> certs -> applications -> providers -> users
  -> roles -> permissions. Membership has no legacy table (noted, skipped).
- store.Open extracted from main.go so the server and the migrator share one
  store-open path.

Tests (CGO_ENABLED=0): synthetic legacy iam.db proves PasswordHash and
Cert.PrivateKey land verbatim, cred.Verify succeeds against the migrated
argon2id golden vector (own type + org fallback), re-run is a pure no-op, and
--dry-run writes nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:28:37 -07:00
zeekayandClaude Opus 4.8 abbacae449 feat(model): export OrgRef so consumers can drop the dead iam-v1
build / docker (push) Successful in 1m38s
cloud reads the `orgs` claim (the tenancy set) off a v2 token as []OrgRef,
today importing it from the dead github.com/hanzoai/iam-v1. Export the same
shape from v2's consumer surface so iam-v1 can die:

- schema.OrgRef{Org string `json:"org"`; Role string `json:"role,omitempty"`}
  — the claim-side projection of a Membership row (Membership is how the
  relation is stored, OrgRef is how it travels in a JWT). Same JSON tags as
  iam-v1, so a token minted by v2 and read by a consumer round-trips
  byte-for-byte.
- schema.OrgRefsFromMemberships / (*Membership).AsOrgRef — the ONE way to
  build the orgs-claim slice from stored memberships.
- pkg/model.OrgRef aliases schema.OrgRef (one canonical type, no drift),
  the ONE import path for external consumers: github.com/hanzoai/iam/pkg/model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:04:04 -07:00
zeekayandClaude Opus 4.8 7a536e2574 feat(workspace): Workspace first-class entity — Organization → Workspace → Project
Add Workspace between Organization and Project, mirroring the Project entity
across every layer of IAM v2 (native orm, no Casdoor/xorm):

- schema.Workspace (orm.Model[Workspace]) — same shape as Project plus a
  Bucket field: IAM records the storage binding, storage owns the physical
  bucket name. Registered as kind "workspaces" in schema.go.
- internal/workspaces: the ONE workspace CRUD path (List/Get/Create/Update/
  Delete), a 1:1 mirror of internal/projects, mounted after the Guard.
- compat: get-organization-workspaces (ScopeSwitcher read, ?organization=,
  handler-authorized via authz.Scope) + add-/delete-workspace verbs, the same
  seam add-/delete-project use.
- Project gains a Workspace FK (empty ⇒ org-level, backward compatible),
  settable through the same projects.Input/apply path as every other field.

Native tests (real mounted router): lifecycle, bucket+isDefault round-trip,
Project Workspace-FK round-trip, write-needs-admin, cross-tenant isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:03:52 -07:00
zeekayandClaude cfb14c6061 ci: publish to ghcr.io/hanzoai/iam (repo renamed from iam2 — canonical)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 09:46:32 -07:00
zeekayandClaude e0f660109f refactor(module): promote module path hanzoai/iam2 → hanzoai/iam (canonical)
build / docker (push) Successful in 1m10s
The clean-room rewrite takes the canonical hanzoai/iam name; the retired Casdoor
fork now lives at hanzoai/iam-v1. Module path + self-imports rewritten; builds green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 08:56:32 -07:00
hanzo-dev 0fc493a287 Merge fix/federation-mfa-gate: close the federated-login MFA bypass + unlink
Two commits: (1) a redirect-flow 2FA resume so a federated login runs the same
second-factor gate a password login does — the callback parks a subject-pinned
KindFederation LoginChallenge and mints only after the factor verifies at
POST /v1/iam/oauth/federation/mfa; (2) POST /v1/iam/unlink, the account-linking
law's fail-closed inverse (self or SuperAdmin, app-permission-gated). go build,
vet, and full test ./... green.
2026-07-19 21:20:19 -07:00
hanzo-dev ace20fe3d7 feat(oidc): POST /v1/iam/unlink — remove a federated link, fail-closed
Port the account-linking law's inverse from feat/social onto main's connector
model: the account holder (self) or a SuperAdmin may clear one provider link; an
org admin may not (unlinking is unpicking a sign-in method, not tenant admin). A
self-unlink additionally needs the application's CanUnlink flag, so an org that
mandates federated sign-in cannot let users strand themselves; a SuperAdmin is the
platform recovery path and is not bound by it. Re-linking still runs the full
verified-subject / verified-email law, so unlink only ever LEAVES an account
unlinked.

- Reads/writes the link through the ONE connectorFor registry (never reflecting
  the provider type onto a Go field name — the bug that made v1's GitLab unlink
  silently no-op). Self-authenticates via callerOf (session/bearer) on the public
  group, exactly as get-account/userinfo do (oidc cannot import authz).

Tests: self-unlink clears the connector when permitted; cross-user is refused and
the target's link survives; a forbidding app blocks self-unlink; an
unauthenticated request is refused and clears nothing.
2026-07-19 21:19:44 -07:00
hanzo-dev 1e0e42d46a fix(federation): gate federated login through the MFA second factor
A 2FA-enrolled user signing in through Google/GitHub skipped the factor a password
login demands: the callback minted the authorization code directly. Now that the
MFA gate is live, close the bypass with a redirect-flow 2FA resume.

- The callback, after linkOrProvision resolves the user, runs the second-factor
  gate BEFORE minting: if the user owes a factor (factor.Enabled and not
  remembered) it mints NOTHING — it parks the resume in a single-use, expiring,
  subject-pinned LoginChallenge (the SAME lifecycle the password gate uses, new
  KindFederation) and redirects the browser to the hosted 2FA page. An org that
  REQUIRES an unenrolled factor fails closed.
- POST /v1/iam/oauth/federation/mfa is the resume: it spends the challenge, loads
  the PINNED user from its subject (never the request), verifies the factor
  through the shared factor.Verify / factor.UseRecovery seam, and only then mints
  the code for the PINNED authorize request. The challenge id rides the httpOnly,
  SameSite=Lax cookie, so a cross-site POST carries none and fails closed.
- federationMint is the ONE mint path both the no-factor completion and the
  post-2FA resume reach; the resume body carries the factor and NOTHING else, so
  the target user and redirect_uri are structurally unswappable mid-flow.

Tests (federation_mfa_test.go): an enrolled user via federation gets NO code
without the factor then resumes to one; an unenrolled user flows through
unchanged; the challenge is single-use and expiring; user + redirect_uri pinning
holds against a steering body; a missing challenge fails closed; recovery codes
resume too. go build/vet/test ./... green.
2026-07-19 21:19:44 -07:00
hanzo-dev 090eaac837 Merge feat/org-accounts: confidential-client capabilities + service accounts + memberships
Re-implemented on main's current architecture (not merged from feat/org): app
principals whose entire authority is a capability allowlist (closes v1's global-
admin-client hole), service-account identities with argon2id key secrets returned
once, and the User×Org×Role membership relation. Uses main's internal/cred (no
duplicate). The orgs token claim is a noted follow-up (needs the jwt Subject work).
go build/vet/test ./... green.
2026-07-19 20:55:32 -07:00
hanzo-dev 12d8e21f9e feat(org): confidential-client capabilities + service accounts + memberships
Three org/tenancy primitives main lacked, re-implemented on main's current
architecture (zip-group Route, main's internal/cred). Closes v1's "every client
credential is a global admin" hole.

- Confidential-client capabilities: authz.Principal gains App (the application
  NAME when the request authenticated via client_secret_basic); authz.app resolves
  `Authorization: Basic <clientId:secret>` against a constant-time secret compare;
  cap.go is the allowlist model (Cap{Name,Env}, Allowed, BoundToOrg, capFor). An
  app principal is NEVER Admin and NEVER Super — its ENTIRE authority is its
  capability allowlist, keyed on the admin-owned application name, so a leaked
  credential can neither cross a tenant nor reach signing material, and an unmapped
  entity or unset allowlist denies. httpx.Basic is the one RFC 7617 parser.
- Service accounts (/v1/iam/service-accounts): agent/bot identities as
  Type=service-account user rows — no password, key secret stored ONLY as an
  argon2id digest (internal/cred), the raw secret returned exactly once at mint and
  never again. create/rotate/revoke need the mint capability (app) or org-admin
  over the target org (human); list takes the read-only capability and is
  tenant-bound by the <org>-<app> name. keys.Mint exported as the one minting
  primitive.
- Memberships (/v1/iam/memberships): the (User x Org x Role) relation + store ops
  (EnsureMembership idempotent + no-downgrade, MembershipsByUser/ByOrg, home-org
  BackfillMemberships). Registered as the `memberships` kind. NOTE: emitting the
  `orgs` token claim needs the jwt Subject work (deliberately NOT landed here); the
  relation ships stored + queryable, the claim is a follow-up.

Structural: memberships/serviceaccounts Route on the AUTHED group and self-
authorize via the Principal/capabilities the Guard attached; their query-targeted
reads join handlerAuthorizedPrefixes so the Guard authenticates and the handler
org-scopes (no revived publicPaths).

Tests: authz capability policy (app acts only on its allowlisted entity, never
crosses to signing material, a non-allowlisted client is inert, never Super/Admin;
Allowed/BoundToOrg/capFor fail-secure); service-account mint (argon2id digest,
one-time, rotation retires the prior secret) + admin/read gates (capability-gated,
tenant-bound list); membership Ensure idempotency + no-downgrade + by-user/by-org +
backfill. go build/vet/test ./... all green.
2026-07-19 20:55:23 -07:00
hanzo-dev 2d9b6994da Merge feat/mfa-gate: login-time second-factor gate + recovery + challenge lifecycle
Re-implemented on main's current architecture: extends internal/mfa (no fork) via
a decomplected leaf domain internal/mfa/factor that the gate and enrollment share,
adds schema.LoginChallenge distinct from the web3 Challenge, gates every
interactive sign-in before minting. Recovery codes now hashed at rest. 12 gate
tests + full go test ./... green.
2026-07-19 20:44:29 -07:00
hanzo-dev 5c6b0c5c9b feat(mfa): login-time second-factor gate + recovery codes + challenge lifecycle
The MFA GATE that main's TOTP enrollment surface never had: a verified password
proves ONE factor, and the sign-in is held until a SECOND lands — before any
token or device approval. Built on main's existing internal/mfa + schema, not a
second mfa package.

- internal/mfa/factor: the pure multi-factor DOMAIN decomplected out of the
  enrollment surface — Verify (the ONE TOTP check both enrollment and the gate
  call), UseRecovery (bcrypt-or-legacy-plaintext, one-time), Enabled/Prompt/
  AllProps/Props, Copy/Save (column-scoped, so an MFA write can't carry isAdmin).
  A LEAF (imports only store+schema, never authz/oidc), which is what lets the
  gate use it without an authz→oidc→mfa→authz import cycle.
- internal/oidc/mfa_gate.go: gate() answers RequiredMfa (org demands an unenrolled
  factor) / NextMfa (data2 carries the choosable factors, no code minted) —
  verbatim v1 wire strings; finishMfa() verifies the factor and loads the user
  from the CHALLENGE subject, never the request body; the "remember this device"
  window fails closed on an unparsable deadline and preserves the zero-window =
  always-challenge behavior every live org relies on.
- internal/oidc/challenge.go + schema.LoginChallenge (kind login_challenges): a
  server-side, single-use, owner-scoped row replacing v1's beego cookie session —
  distinct from the web3 Challenge (that is a pre-auth nonce; this is bound to a
  known subject). TakeChallenge spends on read, so a captured id never replays.
- login.go: the gate runs for EVERY interactive sign-in; loginGrant is the one
  minting tail (device approval / session / code) both the credential post and
  the second-factor finish reach — so a second factor over a device approval
  lands at approveDevice, not a token. httpx.Ok gains a variadic data2.
- HARDENING: enrollment now stores recovery codes as bcrypt digests
  (factor.HashRecoveryCodes), never the plaintext bearer credential; UseRecovery
  still verifies v1-era plaintext rows so no live 2FA user loses their way back.

Tests: internal/oidc/login_mfa_test.go (12 cases — the enrolled-user-challenged
regression, single-use, subject-binding, recovery hashed + legacy plaintext,
repeat-factor refusal, remember-window round-trip + zero-window, org-required
enrollment, unenrolled-unchanged). go build ./..., go vet ./..., full go test
./... green; main's TotP enrollment tests unchanged.
2026-07-19 20:44:19 -07:00
hanzo-dev 10ad2f9b01 Merge feat/device-flow: RFC 8628 device authorization grant
Re-implemented on main's current architecture (public zip-group + the one token
endpoint + the login endpoint), not merged from feat/token. Confidential clients
authenticate at the device request and the poll (§3.1/§3.4); public device
clients bind by client_id. 15 device tests + full go test ./... green.
2026-07-19 20:23:35 -07:00
hanzo-dev 4bcad0093b feat(oidc): RFC 8628 device authorization grant
Browserless CLI sign-in (`hanzo login` on a GPU box, over ssh, in CI),
re-implemented on main's current architecture: routeDevice on the public group,
the poll dispatched from the one token endpoint beside the existing
introspection/revocation, approval on the existing login endpoint. No revived
publicPaths — reachability is group membership.

- POST /v1/iam/oauth/device mints a device_code + a 40-bit unambiguous user_code
  (RFC 8628 §6.1 alphabet, uniform draw) and returns the verification URIs; the
  pending grant is a Token row (Code=device_code, UserCode=user_code, User empty
  until approved), not process-local state — it survives restart and replicas.
- POST /v1/iam/login {type:"device"} binds the approver onto the row against the
  identity the credential check just proved; approval is org-scoped (a foreign
  tenant is refused, a SuperAdmin crosses deliberately) and the user_code refusal
  is non-differential (unknown/expired/used are indistinguishable — no oracle).
- grant_type=urn:...:device_code polls the token endpoint: authorization_pending
  until approved, then mints exactly once through the shared issueTokens.
- CLIENT AUTHENTICATION (RFC 8628 §3.1 request + §3.4 poll -> RFC 6749 §3.2.1):
  a confidential client (registered secret) authenticates its secret at BOTH
  legs, checked before the pending/mint split so an unauthenticated confidential
  poll never learns the grant state; a public device client is bound by client_id
  alone. Kind guards both ways: an auth code can't redeem at the device grant
  (no PKCE/redirect there) and a device code can't redeem at the auth-code grant
  (no human approved).
- schema.Token gains UserCode (indexed); store.GetTokenByUserCode +
  store.IsSuperAdmin (the reserved-org predicate, below the authz seam);
  discovery advertises device_authorization_endpoint + the device grant.

Tests: internal/oidc device_test.go (15 cases incl. confidential-client auth,
tenant boundary, non-differential refusal, both cross-grant redemption guards);
the pre-existing error-taxonomy test updated (device_code is now implemented, so
its "unsupported" example moves to RFC 7523 jwt-bearer). go build ./... and full
go test ./... green.
2026-07-19 20:23:23 -07:00
hanzo-dev 18ea27c06b Merge feat/wallet: native multi-chain wallet sign-in (CAIP-122)
Rebased onto main and integrated onto the current structural-auth model:
wallet.Route registers GET /v1/iam/web3/nonce + POST /v1/iam/web3/verify on the
public route group (before the Guard), authz.Optional resolves a public route's
optional caller, and schema registers the Challenge + Wallet kinds. The feature
is entirely net-new (no web3 surface existed on main); go build ./... and
go test ./... are green.
2026-07-19 20:02:17 -07:00
hanzo-dev 348752cbe8 feat(wallet): native multi-chain wallet sign-in (CAIP-122)
Keyless wallet login for IAM v2 over github.com/luxwallet/connect/go — the SAME
VerifyProof the TypeScript SDK runs, so Go and TS verify identically.

- GET /v1/iam/web3/nonce mints a single-use CAIP-122 challenge; POST
  /v1/iam/web3/verify verifies a signed proof and IS the login. Both are
  anonymous by construction (public allowlist) — they precede any token.
- internal/wallet: HTTP shell (wallet.go) + chain-agnostic core (verify.go, no
  zip.Ctx, unit-testable) + transactional store (store.go). The nonce is BURNED
  before any crypto runs, so a captured proof cannot be redeemed twice; the burn
  and the (chain,address) link close their races with transactions, since orm
  has no conditional UPDATE or UNIQUE constraint.
- schema.Challenge (web3_nonce) binds the signed Domain for phishing defense and
  re-checks Chain at verify; schema.Wallet is the (Chain,Address)->user side
  table (globally unique pair), address stored exactly as the verifier
  canonicalized it (EVM lowercased, case-sensitive chains trimmed only).
- oidc.MintFor/ResolveApp (mint.go): the shared tail of every interactive login
  — tenant isolation, redirect binding, PKCE, code mint — so wallet sign-in
  inherits the same rules as password login instead of restating them.
- authz.Optional resolves a public route's optional caller (fail-closed, nil
  when anonymous or the bearer does not verify); store.GetOrganizationByName;
  drift-compare mappings for web3_nonce and wallet_link.
- deps: luxwallet/connect/go and its luxfi / secp256k1 / base58 / curve25519
  graph; golang.org/x/crypto 0.52.0->0.53.0 and siblings bumped by that graph.
2026-07-19 20:01:47 -07:00
zeekay 9384751b5e feat(cred): hash all new passwords with argon2id (SOTA), one way
build / docker (push) Successful in 1m15s
Password VERIFICATION was already scheme-aware (argon2id for v1 rows, bcrypt
for iam2-minted rows), but new/updated passwords were hashed with bcrypt.
Switch the ONE hashing path to argon2id — the state-of-the-art scheme (PHC
winner) — so every credential iam2 mints is the strongest one, one way to
hash everywhere.

- cred.Hash: new argon2id (PHC) hasher, OWASP-aligned params (64 MiB, 2
  passes, p=1); params + per-hash salt ride in the digest so Verify reads
  them back. cred is the single place hashing lives.
- users.Create/Update + bootstrap.upsertUser route through cred.Hash and
  stamp PasswordType=argon2id; signup inherits it via the canonical path.
- Verify stays scheme-aware, so pre-existing bcrypt/argon2id rows keep
  validating — no forced reset. New rows are argon2id.

Tests updated to expect the argon2id stamp; full suite green.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:34:33 -07:00
zeekay f9c74210f2 fix(federation): close red-team CRITICAL — reserved-org SuperAdmin mint + SSRF
build / docker (push) Successful in 1m6s
Red-team found a critical privilege escalation in the v0.15.0 federation
broker: social login could mint a SuperAdmin (or take over a cross-tenant
account). Two root causes, both closed:

F1(A) — Application.Organization was attacker-controlled and unauthorized:
apps Create/Update copied Organization from the body while the authz seam
gated only the top-level Owner. Add authz.CanSetOrg (one policy: super may
set any org; anyone else only their own, never a reserved platform org or
another tenant) and enforce it on the Organization field at app write.

F1(B) — federation link/provision had no reserved-org guard: it minted the
IdP identity into app.Organization with no check, so Organization="admin"
yielded Owner="admin" = SuperAdmin. Add the reserved-org refusal (never
admin/built-in/app) + the login.go-style app-org-legitimacy check to
linkOrProvision/provisionFederatedUser — re-asserted at the mint boundary.

F2 — SSRF: requireSafeURL permitted any https host (private/link-local/
169.254.169.254/ULA) and http-loopback. Now refuses private/loopback/
link-local/metadata addresses AT DIAL TIME (after DNS resolution), so a
tenant-writable IssuerUrl/Custom*Url can't drive an internal fetch.

Red's 3 PoCs are added and now REFUSE (federation refused, no admin-org
user provisioned, linkOrProvision errors); a legitimate platform-app-for-a-
tenant case and the SSRF-refusal case are covered. Full suite green.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:01:10 -07:00
z 022ddc67ec ci: use GH_PAT directly for private-dep fetch + ghcr, matching cloud
The || fallback chain resolved to a token that cannot read the private transitive
dep hanzoai/dbx (via hanzoai/orm) — go mod download 404'd on it. Pin GH_PAT (the
org admin:org+write:packages secret hanzoai/cloud uses to fetch the SAME private
cross-repo modules) directly, for both the ghcr login and the GIT_AUTH_TOKEN build
secret. No fallback: the automatic GITHUB_TOKEN only reads its own repo, so it can
never fetch dbx.
2026-07-17 14:48:53 -07:00
z 2bcf45cc8a ci: run the build on the ARC fleet, not frozen GitHub-hosted
build / docker (push) Successful in 1m18s
The build used runs-on: ubuntu-latest — GitHub-hosted, which is billing-frozen for
the org, so the run never STARTED and thus never produced an image (the earlier
failures were the frozen queue, not the build). Route to hanzo-build-linux-amd64,
the ARC self-hosted scale set every working hanzo build uses. Combined with the
private-dep + ghcr-write fixes, this build can finally publish ghcr.io/hanzoai/iam2.
2026-07-17 14:42:21 -07:00
z b1ef416e0d ci: fix the image build — private-dep auth + ghcr write permission
build / docker (push) Successful in 1m10s
Every iam2 image build has failed, so no image was ever published and iam2 could
not be rolled. Two causes, both fixed by mirroring hanzoai/cloud's proven pattern:

  * go mod download had no credentials for iam2's PRIVATE modules (hanzoai/orm,
    hanzoai/sqlite), so it failed before compiling. The Dockerfile now sets
    GOPRIVATE=github.com/hanzoai/* and mounts a GIT_AUTH_TOKEN secret to rewrite
    github.com to an authenticated fetch; the workflow passes the token as that
    build secret. Without a token it is a no-op, so a public build still works.

  * The automatic GITHUB_TOKEN is denied write to ghcr.io/hanzoai/* — the same
    permission_denied: write_package cloud hit. Add permissions: packages: write
    and prefer GH_PAT for the registry login.

A green build here publishes ghcr.io/hanzoai/iam2:<tag>, the prerequisite for the
iam2 canary deployment.
2026-07-17 14:36:06 -07:00
zeekay ae25191765 fix(seed): mint signing keys for keyless reserved-org certs (JWKS was empty)
build / docker (push) Failing after 11s
The shadow-canary deploy in prod surfaced this: init_data seeds signing
certs (owner=admin, RS256) with NO key material — a signing key cannot ride
the init_data.json ConfigMap (it's a secret). iam2's JWKS filters to certs
that carry key material, so it published {keys:[]} → iam2 could neither sign
tokens nor be trusted by relying parties. The legacy Beego iam avoids this
by generating+persisting a keypair on first boot; iam2 didn't.

Fix: at seed time, for a reserved-org (JWKS-eligible) signing cert with a
recognized algorithm and no key material, generate a keypair (RSA for
RS256/512, ECDSA for ES256/384/512) and PEM-encode the private half — the
same first-boot provisioning the legacy iam does. Gated to reserved-org
certs; SSL, tenant-owned, ML-DSA/unrecognized, and already-keyed certs are
left untouched. Safe every boot: the key is minted in memory and the
new-only upsert persists it exactly once, so a re-seed keeps the existing
row (stable kid + key, no rotation).

Tests: keyless reserved RS256/ES256 certs get parseable keys; SSL + tenant
certs stay keyless; an explicit key is never overwritten.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:33:37 -07:00
zeekayandClaude Opus 4.8 417c9de375 feat(federation): standards-based OIDC/OAuth2 identity-federation broker
build / docker (push) Failing after 12s
iam2 completes Google/GitHub (and the existing provider set) social sign-in as a
standard OIDC/OAuth2 Relying Party — the one remaining login-backend gap before
hanzo.id cuts over from Casdoor. No Casdoor verbs, no tokens-in-query, no legacy
/oauth/* paths (HIP-0111).

- authorize `?provider=<name>` (after client_id + EXACT redirect_uri + PKCE
  validation) stashes the app-leg request in a single-use, expiring,
  browser-bound FederationState and 302s to the IdP with an IdP-leg S256 PKCE
  verifier and (OIDC) a nonce.
- fixed public callback /v1/iam/oauth/callback burns the transaction (expiry +
  browser-binding cookie), exchanges the IdP code, and VERIFIES the response:
  OIDC id_token signature (discovered JWKS, alg pinned to RS/ES), iss, aud, exp,
  nonce; GitHub userinfo + verified primary email.
- links by provider subject, else by VERIFIED email, else provisions a new user
  (never isAdmin, no password); mints iam2's own PKCE/redirect/nonce-bound
  authorization code so the SPA's existing code->token exchange is unchanged.
- new schema.FederationState entity + store helpers + a connector registry that
  passes the EXACT lowercase json field name (dodging orm's LowercaseFirst
  footgun that would break the GitHub connector lookup).

Fail-closed on every path; no IdP tokens persisted; no secrets logged. 15 new
tests drive the real mounted routes against httptest mock IdPs (real discovery/
JWKS/RS256 id_token + GitHub userinfo, no live calls): happy path + code
exchange, link-by-verified-email, relogin-by-subject no-dup, unknown/replayed
state, missing/wrong browser cookie, nonce/signature/alg-none/audience
rejection, unverified-email no-autolink, non-allowlisted redirect refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:27:18 -07:00
zeekay 16fda7dda6 feat(server): expose standalone Handler for wildcard sub-mount (cloud embed)
build / docker (push) Failing after 12s
Add server.NewApp(db) + server.Handler(db) — build the whole IAM v2 surface
as one self-contained zip.App and adapt it to a net/http handler. This is
the drop-in shape hanzoai/cloud uses to swap the legacy Beego IAM catch-all
for iam2: mounted behind the /v1/iam/* (and root /.well-known/*) wildcards,
the specific self-service routes the account fold layers in front still win
by Fiber specificity, so the swap is collision-free — identical topology to
the Beego mount it replaces.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:29:34 -07:00
zeekay 9d2f83679d feat(projects): organization-scoped projects entity — C3 console parity
build / docker (push) Failing after 12s
Add the `projects` entity (v2 kind, org-owned like users/roles) with the
full typed REST CRUD (internal/projects) plus the three Casdoor verbs the
console ScopeSwitcher / Projects page hard-codes through the /org/iam proxy:

  - get-organization-projects  (?organization=, handler-authorized: any org
    member may list its org's projects — the switcher is shown to everyone,
    not only admins — scoped by authz.Scope so no param widens the tenant)
  - add-project / delete-project (typed ops → app.Authorize gates the write
    to an org-admin of that org, the same clause as add-role)

CRUD lives once in internal/projects; the verb aliases reuse it via New —
no CRUD reimplemented in compat. get-organization-projects is registered in
authz.handlerAuthorizedPrefixes because its target rides in ?organization=,
which the Guard cannot pre-authorize generically (the read analogue of
SCIM's path-targeted authorization).

Tests drive the real router: lifecycle (add → member-list → delete), the
write-needs-admin gate (regular user 403 on write, 200 on list), and
cross-tenant scoping (a hanzo user asking ?organization=orgb never sees
orgb's projects).

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:19:44 -07:00
zeekay 0107baaf00 feat(mfa): TOTP multi-factor enrollment (RFC 6238) — C4 parity
build / docker (push) Failing after 11s
Port the account security page's MFA flow to iam2: initiate → verify →
enable, plus delete-mfa and set-preferred-mfa. Serves the console's
existing /v1/iam/mfa/setup/{initiate,verify,enable} + /v1/iam/delete-mfa +
/v1/iam/set-preferred-mfa contract.

Enrollment is self-service, mounted AFTER the Guard so it acts on the
authenticated caller's own user (authz.From). The handshake is stateless:
initiate mints a TOTP secret + otpauth URL + recovery code the client
holds; verify checks a passcode against the echoed secret; enable commits
it to the user row. Touching another user's MFA requires admin authority
over that org (authz.Can) — the same seam SCIM writes use — because the
general user-write policy correctly refuses a non-admin writing a user row.

Tests drive the real mounted router: full enroll lifecycle (a generated
TOTP code verifies and persists), bad-code rejection, the cross-user admin
gate (regular user 403, org-admin/super 200), and bearer-required.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:12:35 -07:00
zeekayandClaude Opus 4.8 864c3a41ba feat(iam2): operator bootstrap upsert (parity C5 — restores IAM CR reconciliation)
build / docker (push) Failing after 12s
Parity audit C5: the K8s operator (operator-core/src/iam_admin.rs) reconciles an IAM
CR's spec.applications[]/users[] by POSTing to /v1/iam/admin/{applications,users}/upsert
— wiring the service-account OAuth apps KMS/signers authenticate with, no human admin.
iam2 didn't serve these, so an embed would lose IAM reconciliation.

New internal/bootstrap: both idempotent upsert endpoints, keyed by the natural key
(apps: admin/name; users: owner/name) — create OR update, so a ~30s reconcile is a
no-op once converged. Auth is the unified SERVICE TOKEN presented as Bearer,
validated constant-time against HANZO_API_KEY / KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN
(the same pipeline the old iam used); unset → fail closed. Registered on the PUBLIC
group (self-authenticated, not a bearer principal). Response matches operator-core's
parser: {status:"ok", action:"created"|"updated", data:{...}}. A missing clientSecret
preserves the existing one (no rotation storm); user passwords are bcrypt-hashed.

Tests: app create→idempotent-update (secret preserved), user create (password hashed),
service-token required (no/wrong token → 401).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:42:54 -07:00
zeekayandClaude Opus 4.8 7f350483ee fix(iam2): Casdoor write-verb aliases (parity C2 — restores console admin mutations)
build / docker (push) Failing after 11s
Parity audit C2: the console admin BFF forwards literal Casdoor verbs, but compat
served only add-organization/add-user/update-user/update-application — so the
console's Users/Roles/Providers/Apps/Tenants admin MUTATIONS all 404'd. Added the
missing verb aliases over the SAME entity CRUD the REST routes use (one path,
wrapped in the casibase {status,data} envelope; each a TYPED zip.Post so the ONE
app.Authorize seam authorizes the decoded target — no CRUD reimplemented):
delete-user, add-/delete-application, add-/update-/delete-provider,
add-/update-/delete-role, update-/delete-organization.

Minimal exports to reuse the entity logic: applications.Delete, roles.New,
providers.Add/Update/Delete. Tests: delete-user lifecycle, add-provider (super-only,
non-super 403 — platform-owned), add-role (org-admin, tenant-owned), update-org.

With C1 (issue-user-token) the console's authenticated + admin surface is now fully
served by iam2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:34:57 -07:00
zeekay 8653396356 iam2(compat): extend write-path parity to providers, roles, applications (+ compat writes test) 2026-07-16 17:08:27 -07:00
zeekayandClaude Opus 4.8 4e8062dd0e fix(iam2): restore issue-user-token as the compat shim (parity C1 — unblocks the console)
Parity audit found the P0 gap: the console's ENTIRE authenticated surface calls
POST /v1/iam/issue-user-token (identity.ts issueUserToken → adminBearer → the
bearer-proxy behind every /v1/* BFF call + the /admin/iam,/org/iam,/admin/kms,/ai
proxies). v0.7.0 retired that verb for the RFC 8693 token-exchange grant, but the
console has ZERO token-exchange usage — so the swap would take the whole console dark.

Restored as a COMPAT SHIM over the exact same machinery token-exchange uses
(authorizeMinter allow-list + reserved-org gate + SignUserToken + audit): a
confidential, allow-listed client mints a token bound to the ?id=<owner>/<name>
target user (optional ?aud= resource), returning {accessToken, expiresIn}. Same
authority, same red-hardened controls; equivalent to token-exchange minus the
subject_token proof (the console has the user's id, not a token — the reason the
shim exists). RFC 8693 stays the canonical forward path; the console migrates to it
and this shim retires. Tests: mints the target user's verifiable token, off-allowlist
403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:17:23 -07:00
zeekayandClaude Opus 4.8 209348ad03 test(iam2): end-to-end journey suite — the behavioral parity proof
internal/e2e boots the WHOLE mounted router (routes.Route) and drives the real
client flows in sequence, asserting the response contract each depends on — the
proof that the old Casdoor IAM's clients work against iam2, beyond the per-package
unit tests:
- OIDC: discovery (self-consistent + RFC 7662/7009/8414 endpoints advertised) →
  JWKS → PKCE login → authorization_code → userinfo (owner+isAdmin) → introspect
  (active) → revoke (→ inactive).
- Non-interactive grants: password (RFC 6749 §4.3) + RFC 8693 token exchange.
- Admin console Casdoor surface: get-account (security contract), get-organizations
  (OrgSwitcher, SuperAdmin sees all), get-users (owner-scoped, no secret leak).
- SCIM 2.0 provisioning: create → get → delete.

All green. This harness also verifies any parity gap-fixes going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:05:46 -07:00
hanzo-dev 22f1324017 refactor(iam2): rename every sub-package Mount(app,db)→Route — one Mount, the public entry
build / docker (push) Failing after 11s
Completes the group refactor: the flat Mount(app *zip.App, db) convention is
gone from internal/*. Every registration func is now Route, mirroring commerce's
Route(zip.Router) idiom:

  - 13 entity CRUD packages + compat + scim: Mount → Route (still *App — the
    typed-op projection into REST/OpenAPI/MCP needs it).
  - internal helpers organizations.mount → route, compat.mountWrites → routeWrites.
  - the route table internal/routes: Mount → Route (server.Mount embeds it).

server.Mount(app, db) stays the ONE Mount — the frozen public embed entry the
host (cloud) calls; its signature is unchanged, only its body now calls
routes.Route. feature.Mount(app, store) is the feature-seam interface method (a
different signature), untouched.

Pure rename — no route, path, behavior, or auth outcome changes. Callers updated
(main.go, server.go, authz/compat/scim tests). gofmt clean, go build ./... green,
go test ./... green. feature/, pkg/model/, internal/featurestore/ untouched.
2026-07-16 14:40:11 -07:00
hanzo-dev e8474f198e refactor(iam2): structural auth via zip groups — delete publicPaths, oidc.Mount→Route(zip.Router)
Auth is now decided by WHICH GROUP a route is registered on, not a hand-
maintained allow-list. routes.Mount wires two phases around one seam:

  - PUBLIC group (app.Group("") + oidc.Route + /healthz) registered BEFORE
    app.Use(authz.Guard): a matched public route terminates fiber's middleware
    walk, so the Guard never runs on it. Membership here IS "public".
  - app.Use(authz.Guard) is the ONE authentication seam; every route after it
    (typed entity CRUD, Casdoor verb aliases, SCIM, and the framework's /mcp +
    /openapi projections) requires a verified bearer.

A public route can no longer be accidentally gated, nor an authed route
accidentally public — the front-door bug the old publicPaths list had to patch
is structurally impossible.

- Delete authz.publicPaths + isPublic entirely; drop the isPublic checks from
  Guard and Authorize (every typed op is authed by construction). Keep Can/
  IsSuper/pathAuthorized (SCIM) intact.
- oidc.Mount(*App) → oidc.Route(zip.Router); MountToken/Login/FrontDoor/
  IntrospectRevoke/IssueToken → unexported route*(zip.Router) helpers on the
  public group. Absolute paths preserved (root + /v1/iam), so every URL is
  byte-identical (discovery, JWKS, AS-metadata, oauth/*, login, front door,
  introspect/revoke, key minters).
- Replace the isPublic unit test with an end-to-end structural boundary proof:
  TestPublicRoutesNeedNoBearer extended to the full front door + AS metadata;
  TestUnauthenticatedWriteIs401 / TestCrossOrgWriteIs403 /
  TestFrameworkSideDoorsAreGated prove authed + /mcp + /openapi stay gated.

server.Mount, feature/, pkg/model/, internal/featurestore/ untouched.
gofmt clean, go build ./... green, go test ./... green.
2026-07-16 14:33:37 -07:00
hanzo-dev 8dc1bec3c2 feat(iam2): seam GetProvider — SP-inbound SAML/OAuth (corporate IdP login)
build / docker (push) Failing after 11s
feature.Store gains GetProvider(owner,name) over the core's store.GetProvider,
and pkg/model aliases schema.Provider. Unblocks hanzoiam/saml SP-initiated login
(Hanzo as Service Provider to an external IdP). Completes the seam: the union of
what SCIM/SAML/LDAP need.
2026-07-16 14:27:45 -07:00
hanzo-dev 62bc23808f feat(iam2): seam password channel — SetPassword/VerifyPassword
build / docker (push) Failing after 11s
feature.Store gains SetPassword (core hashes plaintext once, discards it) and
VerifyPassword (argon2id v1 / bcrypt v2, per the org's password type). Hashing
and verification stay in the ONE place (internal/users) — enterprise modules
never see a digest. Unblocks hanzoiam/ldap bind + hanzoiam/scim password attr.
2026-07-16 14:22:59 -07:00
zeekayandClaude Opus 4.8 cc53b8ecb4 docs(iam2): MIGRATION §2.1 — the RFC/IETF-standard surface (HIP-0111), all shipped
Document the RFC-standard endpoint surface (OAuth grants incl. token-exchange,
introspection/revocation, AS-metadata, UserInfo=get-account contract, SCIM 2.0),
the deploy env, and the remaining client-migration for cutover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:15:50 -07:00
zeekayandClaude Opus 4.8 e18300ca59 feat(iam2): UserInfo carries the get-account security contract (isAdmin + type)
build / docker (push) Failing after 11s
OIDC UserInfo (/v1/iam/oauth/userinfo) now emits `isAdmin` (the gateway
admin-guard's SuperAdmin-predicate input, with owner==adminOrg) and `type` (the
console's anonymous-user check), read from the loaded user record — authoritative,
never a token claim, matching the authz Principal. Emitted regardless of scope
(identity, not profile), as an EXPLICIT bool so the admin-guard reads a definite
false rather than inferring it from a missing key.

This makes standard OIDC UserInfo a drop-in for the retired Casdoor get-account
security contract (HIP-0111): a consumer reads sub/owner/organization/email/
email_verified/isAdmin/type off the RFC endpoint, so the gateway admin-guard +
console resolveUser can migrate off get-account to /oauth/userinfo. (The migration
of those clients is the follow-on; UserInfo now carries what they need.)

Tests: admin-guard contract (isAdmin:true + type present at openid-only scope),
non-admin explicit-false, existing scope-gating unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:14:19 -07:00
zeekayandClaude Opus 4.8 8b06a3d89a fix(iam2): close red-team CRITICAL — SCIM writes bypassed the authz seam
build / docker (push) Failing after 10s
Red review of v0.8.0 found the SCIM write path never reached authz.Authorize (the
op-invoke seam where the admin/self policy lives) — SCIM registers RAW handlers and
calls users.API directly, so the only authz was authz.Scope (pins the org, NOT the
admin flag). Proven exploits (all now closed, red's 6 proof tests green):
- a regular org member self-promoted to org-admin in one PUT (isAdmin extension)
- reset an org-admin's password via PATCH → full admin account takeover
- created/deleted users; a machine token (no user row) got full user-write
Plus a HIGH (PUT/PATCH rebuilt from a blank record → stripped MFA enrollment +
resurrected soft-deleted accounts), MEDIUMs (count<=0 → orm Limit(0) → unbounded
dump; client-supplied isAdmin), a LOW (raw err.Error() on 500).

Fixes (one policy, one place):
- authz.Can(ctx, method, entity, owner, name) exposes the SAME predicate the op
  seam applies, for raw handlers. Every SCIM write (POST/PUT/PATCH/DELETE) and the
  list/get reads now gate through it → regular user 403, org-admin/super admitted.
  authz.IsSuper gates the privileged isAdmin field (provision-don't-promote: only a
  super may set it).
- PUT/PATCH now read-modify-write the FULL current row (overlay mapped attrs onto
  cur), preserving MFA/IsDeleted/Type/Groups/Ldap — never rebuilt from blank.
- count<=0 clamps to the default page size (never unbounded).
- active is *bool (RFC 7643 default-true when omitted); generic 500 (no detail).

Tests: red's red_bypass_test.go (6, all green) + regression matrix (org-admin
allowed, isAdmin super-only, PATCH preserves MFA, PUT no-resurrect/no-MFA-strip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:16:20 -07:00
zeekayandClaude Opus 4.8 edf00f55ca feat(iam2): SCIM 2.0 Users provisioning (RFC 7644/7643) — the standard entity surface
build / docker (push) Failing after 13s
Per HIP-0111 (RFC-only), identity provisioning is SCIM 2.0 — the replacement for the
Casdoor entity verbs (get-users/get-user/add-user/update-user/delete-user). New
internal/scim serves /v1/iam/scim/v2:
- Users: GET list (owner-scoped, filter `userName|emails eq "x"`, startIndex/count
  pagination, ListResponse envelope), POST create (password write-only → hashed via
  the canonical users.API), GET/PUT/PATCH/DELETE on the two-segment item path
  {owner}/{name} (the SCIM id is the natural key "owner/name", appended verbatim by
  clients — no slash-in-id encoding trap). PATCH implements the RFC 7644 §3.5.2 op
  subset (add/replace/remove on active/displayName/password/name.*/emails/phones,
  plus path-less merge) — read-modify-write so a partial change never blanks the row.
- ServiceProviderConfig advertises patch+filter+changePassword; SCIM Error envelope
  with scimType; application/scim+json handled content-type-independently.
- SECURITY: every user projected through schema.User.Mask() (no hash/secret ever
  crosses a response); password is write-only. Authorization: the SCIM subtree is a
  new "path-authorized" category in the Guard — authenticated (bearer required) but
  the handler scopes via authz.Scope on the PATH target (SCIM ids ride the path, not
  the query), so a non-super is pinned to its own org and can't reach another tenant
  by spelling its id.

Tests (real router): create→get→delete lifecycle, ListResponse + owner-scoping
(super sees all, org-admin own-org-only), userName filter, PATCH deactivate, no
secret leak, cross-tenant re-scope→404 (no leak), requires-auth, SPConfig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:59:10 -07:00
zeekayandClaude Opus 4.8 c59e472cfe feat(iam2): RFC 8693 Token Exchange grant; retire the issue-user-token verb
build / docker (push) Failing after 11s
Per HIP-0111 (RFC-standard only), the Casdoor issue-user-token verb is replaced by
the standard OAuth 2.0 Token Exchange grant on /v1/iam/oauth/token:

- grant_type=urn:ietf:params:oauth:grant-type:token-exchange. An allow-listed
  confidential client presents a subject_token identifying the end user and receives
  an access token bound to that user (subject + owner = the user's, so a resource
  server scopes on the validated owner claim), re-scoped via RFC 8707 resource/
  audience to a downstream server, azp = the acting client. RFC 8693 §2.2 response
  (access_token/issued_token_type/token_type/expires_in/scope).
- Reuses the red-hardened controls verbatim: mint allow-list matched by the globally
  unique clientId ONLY (the closed CRITICAL), reserved-org (admin/built-in) subject
  gated behind the separate IAM_ADMIN_MINT_ALLOWED_APPS capability, audit on every
  exchange, and the trusted-cert signing. Stronger than the retired verb: the caller
  must prove the subject with a verifiable subject_token, not just name an ?id=.
- /v1/iam/issue-user-token is GONE (removed from routes + the authz allowlist);
  discovery advertises the token-exchange grant. The `hk-` Cloud API-key primitives
  (mint/revoke-user-keys) stay — a product credential with no RFC, flagged for a
  product call — over the same authorizeMinter seam.

Tests (token_exchange_test): mints for a subject + verifies claims/aud, off-allowlist
403, the name-collision priv-esc regression guard (moved from the red-team file),
reserved-org needs the admin capability (and admits with it), invalid subject_token →
invalid_grant, public client 401, audit emitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:23:52 -07:00
zeekayandClaude Opus 4.8 375af4808f feat(iam2): RFC 7662 introspection + RFC 7009 revocation + RFC 8414 AS metadata
build / docker (push) Failing after 10s
Standard token-management endpoints on the OAuth surface, per HIP-0111 (RFC-only):
- POST /v1/iam/oauth/introspect (RFC 7662): client-authenticated (confidential,
  constant-time). Active iff the grant row still exists (revocation-aware, the same
  liveness check userinfo makes) AND the JWT verifies; returns the standard claims
  (active/scope/client_id/sub/aud/exp/iat/nbf/iss/jti + owner/organization). An
  inactive/absent token returns {active:false} only.
- POST /v1/iam/oauth/revoke (RFC 7009): a confidential client revokes a token issued
  to IT — an access token deletes that grant row, a refresh token revokes the whole
  rotation family. Unknown/other-client tokens are a silent 200 (no oracle, §2.2).
- GET /.well-known/oauth-authorization-server (RFC 8414), root + v1: the discovery
  document at the OAuth well-known path (superset). discovery now advertises
  introspection_endpoint + revocation_endpoint.
All three added to authz's public allowlist (they self-authenticate the client, not
a bearer). Tests: active→claims, revoked→inactive + bearer dies at userinfo, unknown
token→200, confidential-auth required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:24:16 -07:00
z aa09dff4ee fix(iam2): feature test nopStore implements GetCert (unbreak c42fa75)
build / docker (push) Failing after 11s
2026-07-16 11:09:10 -07:00
z c42fa75996 feat(iam2): add GetCert to the feature.Store seam (SAML metadata signing / LDAP) 2026-07-16 10:58:24 -07:00
z 85e8948913 feat(iam2): the feature seam — enterprise modules plug in via Store, core stays clean
The plug point for the hanzoiam/* enterprise modules (SCIM/SAML/LDAP), so the
proprietary clean-room core never carries Casdoor's Apache-2.0 code:
- pkg/model: PUBLIC identity DTOs (User/Application/Organization) as ALIASES of
  the internal schema — a module shares ONE type with the core, no mapping, and
  never imports internal/.
- feature: the Store interface (the union of object.* calls the copied Casdoor
  code makes) + Feature{Name,Mount} + Register/MountAll. Dependency flows one way
  (module → feature); the core never imports a module.
- internal/featurestore: Store impl over the orm store, so a module reads/writes
  the SAME identity data as the core (one store, no second copy).
- server.Mount now feature.MountAll(app, featurestore.New(db)) — no-op until a
  host registers a module, fail-fast if a registered one can't mount.

Test: a registered feature mounts + is listed; the store is injected. Full suite green.
2026-07-16 10:57:01 -07:00
hanzo-dev 0eaa444ba9 feat(iam2): close the front-door API gap — signin/whoami/onboard/update-preferences/linked-accounts + 4 casdoor write-aliases
The 5 session/identity front-door routes + 4 Casdoor write verbs the console/
gateway call but iam2 didn't serve. Composed from existing pieces, one authz seam,
no logic duplicated.

Front door (oidc.MountFrontDoor, public + self-resolving via callerOf):
- POST /v1/iam/signin — the code→session exchange: redeem+burn the authorization
  code (store), sessions.Set, return the get-account envelope (shared helper
  accountEnvelopeFor). The console's post<Account>('iam/signin',{code,state}).
- GET  /v1/iam/whoami — lightweight caller identity (owner/name/id/isAdmin).
- POST /v1/iam/onboard — first-run org: organizations.Create + in-place user
  org-move to admin; returns the console's {org}/{error} contract.
- POST /v1/iam/update-preferences — self, shallow-merge into
  Properties["hanzo.preferences"] (v1 me_preferences contract).
- GET  /v1/iam/linked-accounts — the caller's non-empty connector columns.

Casdoor write-aliases (compat/writes.go, typed ops → the ONE Authorize seam):
- add-organization → organizations.Create, add-user/update-user → users
  Create/Update, update-application → applications.Update (now exported). Casibase
  {status,data:<masked entity>} envelope; authz identical to the REST twins.

authz: the whole front-door surface (incl. the already-registered-but-gated
get-account/signup/send-verification-code) is added to publicPaths — each handler
resolves+self-scopes the caller, so they are reachable with a session cookie yet
never act on anyone else. Without this they 401'd at the Guard through routes.Mount.

Tests: oidc/frontdoor_e2e_test.go (signin→session→get-account, replay refused,
whoami, preferences round-trip, onboard create+move, linked-accounts) and
compat/writes_test.go (write-alias authz super/org-admin/cross-tenant + no-leak,
front-door public-through-Guard). gofmt clean, go build + go test ./... green.
2026-07-16 10:51:04 -07:00
zeekayandClaude Opus 4.8 62d881c772 feat(iam2): grant_type=password + IAM_ISSUER pin; ONE token endpoint (no access_token alias)
build / docker (push) Failing after 11s
- Password grant (RFC 6749 §4.3) on the standard /v1/iam/oauth/token — the durable
  first-party console session. Confidential clients only (a public client + password
  grant is a phishing footgun), app must have password login enabled, credentials
  verified through the SAME algorithm-aware per-row path the login form uses
  (argon2id every live v1 row, bcrypt new); one opaque invalid_grant for both
  unknown-user and bad-password (no enumeration oracle); mints via the ONE shared
  issueTokens path (access + id_token on openid + rotating refresh on offline_access).
- IAM_ISSUER pin: tokenIssuer honors the IAM_ISSUER env (e.g. https://hanzo.id) so a
  deployment serving both hanzo.id and iam.hanzo.ai emits ONE stable `iss` the embedded
  KMS + every resource server validate against — also closes the red INFO finding that
  X-Forwarded-Host could steer `iss`. Unset → host-relative (dev).
- ONE token endpoint: /v1/iam/oauth/token (the RFC / discovery token_endpoint). The
  Casdoor `access_token` alias is NOT served — no backwards-compat duplicate spelling;
  the clients are fixed to the standard path (console session.ts + gateway admin-guard
  + waitlist-guard), not the backend shimmed. discovery advertises `password`.

Tests: password grant mints a verifiable user token (offline_access→refresh,
openid→id_token), wrong/unknown → opaque invalid_grant, public client → 401.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:05:11 -07:00
zeekayandClaude Opus 4.8 72e7203a56 fix(iam2): close red-team CRITICAL in the mint primitives — allow-list by clientId only
build / docker (push) Failing after 10s
Red review found a proven privilege escalation: mintAllowed matched the app's
per-owner-unique Name as well as its global clientId, so a tenant org-admin could
register an app named `hanzo-console` in their OWN org (chosen secret + the platform
cert name read from the public JWKS) and mint a fully-valid owner="admin" SuperAdmin
token — collapsing the entire tenant boundary. Fixes (iam2 is pre-prod → fixes forward):

- CRITICAL: mintAllowed matches the GLOBALLY-unique clientId ONLY (dropped the
  app.Name match). The red-team PoC is kept as a permanent regression guard, flipped
  to assert the collision is now refused (403, no token).
- MEDIUM (defense-in-depth): a RESERVED-org (admin/built-in) target now requires a
  SEPARATE capability (IAM_ADMIN_MINT_ALLOWED_APPS), so even a valid general minter
  can't reach a SuperAdmin identity — a leaked general-minter secret is contained. The
  console, which legitimately drives admin.hanzo.ai, holds both capabilities (proven by
  a test: unset admin list → 403, set → 200).
- LOW: emit a best-effort AuditLog on every issue/mint/revoke (who minted for whom) —
  the escalation was previously invisible.
- LOW: issue-user-token is POST-only now (was GET+POST; a mint/bearer must never ride
  a cacheable GET where client_secret could reach logs).

Verified-safe by red (unchanged): the read-modify-write preserves PasswordHash +
isAdmin; forbidden/deleted targets refused pre-mint; constant-time secret compare;
public-client rejection; GetSigningCert's reserved-owner kid restriction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:10:47 -07:00
zandhanzo-dev 41d7be2fce docs+comments: retire stale 'lands later' residual — §4 front-door is complete
The front-door + session layer are wired, so the comments that said the
credential/session paths 'land with a later increment' were stale (AI-residual).
Rewrote frontdoor.go + getaccount.go headers to state what IS, and closed §4 in
MIGRATION.md with the durable-session mechanism. No code change.
2026-07-15 22:57:50 -07:00
zandhanzo-dev 4cfd2c0b41 oidc/sessions: wire the portal session layer — §4 front-door residual CLOSED
login (type=login) now issues a signed session cookie; get-account resolves the
caller by cookie FIRST (the portal + gateway-admin-guard path) then bearer (the
API path) — two credentials, one identity via callerOf. Revocable: the cookie's
sid is registered in the Session row and checked on every resolve.

- sessions.Set/Resolve over the cookie primitives; key derived from the platform
  signing cert (store.PlatformSigningCert) — no new secret to provision.
- registerSID/sidActive mirror the Sessions.Create persist path (one way to
  write a session).

Fix caught by the e2e: Set passed ttl=0 to Issue, expiring the payload on mint;
now bounds both the signed expiry and cookie MaxAge by one sessionTTL (14d).

Tests: login→cookie→get-account resolves alice (redacted, no bearer); a FORGED
cookie stays anonymous. Full suite green (authz/compat/cred/oidc/schema/seed/
sessions). §4 residual (get-account+signup+send-verification-code+session) done —
iam2 is Phase-4 shadow-embed ready.
2026-07-15 22:57:50 -07:00
zeekayandClaude Opus 4.8 db4256d9b9 feat(iam2): mint-user-keys + revoke-user-keys — complete the confidential-primitives family
build / docker (push) Failing after 10s
Extends issue-user-token with the console API-keys page's two primitives, over the
SAME confidential-client seam (one authorizeMinter: client_secret_basic/_post +
constant-time verify + the fail-closed IAM_KEY_MINT_ALLOWED_APPS allow-list) and
the same ?id=<owner>/<name> target resolution — so all three primitives share one
auth path, one target path, one error envelope (no divergence).

- POST /v1/iam/mint-user-keys → {status:ok, data:{accessKey}}: (re)generates the
  target user's durable `hk-` Cloud API key (schema.User.AccessKey), persisted via a
  read-modify-write that preserves every other field; the console's getUserKey reads
  it back from get-user.
- POST /v1/iam/revoke-user-keys → {status:ok, data:{affected:true}}: clears the key
  (AccessKey + AccessSecret + hash).
- Both public-path (self-authenticate the client, not a bearer) + fail-closed.

Tests: mint generates a persisted hk- key, revoke clears it, off-allowlist 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:50:24 -07:00
zeekayandClaude Opus 4.8 ebaa28f0f9 feat(iam2): issue-user-token — confidential-client on-behalf-of-user mint (THE console blocker)
build / docker (push) Failing after 10s
Every console admin call (IAM + KMS proxies) and the keyless-AI proxy mint their
upstream bearer at POST /v1/iam/issue-user-token; absent, /admin/* and /ai 502
before any verb. Implemented to the contract verified against the authoritative
consumer (console src/lib/server/identity.ts):

- Confidential-client auth (client_secret_basic or _post, constant-time) + a
  capability allow-list (IAM_KEY_MINT_ALLOWED_APPS, FAIL-CLOSED — an unset list
  permits nothing; this hands out a user's full authority).
- Acts on-behalf-of the ?id=<owner>/<name> target user: the minted access token's
  subject AND owner claim are the TARGET USER's (not the client's), so a resource
  server that scopes on the validated owner claim (cloud SanitizeIdentity) scopes
  to the user's tenant. azp records the minting client. Signed under the client's
  trusted signing cert + canonical issuer, so the same JWKS verifies it — the token
  is indistinguishable from one the user obtained directly.
- Audience (RFC 8707): ?aud= resource wins (the admin path pins <brand>-cloud so a
  reserved-admin operator's token is accepted); default = the target user's own app.
- Envelope {status:"ok", data:{accessToken, expiresIn}} — the exact camelCase shape
  identity.ts consumes. Persists the token by hash (revocable, userinfo-resolvable).
- Not Bearer-gated (authenticates the CLIENT, not an end-user) → listed in authz's
  public allowlist; the handler does its own, tighter auth.

New jwt.Signer.SignUserToken (decoupled from schema — the handler passes the values
it authorized). Tests (9): mints the target user's authority + verifies under JWKS,
aud override, default aud, wrong secret 401, off-allowlist 403, empty-allowlist
fail-closed, no-auth 401, unknown user error, forbidden user 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:44:51 -07:00
hanzo-dev 30974847b7 oidc: implement /v1/iam/signup + /v1/iam/send-verification-code — close §4 front-door residual
The last two native front-door endpoints HIP-0111 §4 gates cutover on. Both
return the casibase {status,msg,data} envelope and mount in MountFrontDoor next
to get-account.

signup (POST /v1/iam/signup, JSON): mirrors v1 controllers/account.go Signup —
resolve app (clientId or name) → enforce EnableSignUp + EnablePassword + tenant
isolation → username policy (object/check.go CheckUserSignup) → uniqueness →
org PasswordOptions complexity (object/check_password_complexity.go) → create.
Creation goes through the ONE canonical path (users.New(db).Create): bcrypt-hash
once, PasswordType=bcrypt (what internal/cred verifies for new rows), return the
row REDACTED. No plaintext ever stored — proven by test reading the row back.

send-verification-code (POST /v1/iam/send-verification-code, multipart/form-data
per §4, read via fiber FormValue): validates dest+type+applicationId
(form.VerificationForm.CheckParameter), mints an unbiased crypto/rand 6-digit
OTP, persists it as the new `verifications` entity, and reports ok honestly.
Email/SMS delivery is hanzoai/notify's concern (not wired into iam2 yet) — a
documented seam, never a faked "sent". CheckVerificationCode completes the
constant-time, expiry-gated validation surface.

New entity: schema.VerificationRecord (kind `verifications`) — v1's
`verification` table, the 14th identity kind; store.AddVerificationRecord +
GetLatestVerificationRecord.

Deliberate seams vs v1 (missing iam2 deps, not shortcuts): signup lands the user
in the app's existing org (v1's TenantOrgForSignup founder-org mint needs an
org-create helper + Org.Parent, unmodeled); no captcha verification (no captcha
provider modeled); phone E.164 normalization not ported.

gofmt clean, go build/vet green, go test ./... green (incl -race on the new
handlers). MIGRATION.md §4/§6 updated to reality.
2026-07-15 22:35:53 -07:00
zeekayandClaude Opus 4.8 87f000fb45 feat(iam2): Casdoor verb-alias read layer + one-contract schema.Mask redaction
build / docker (push) Failing after 9s
The #1 cutover blocker: every live console/gateway/portal client hard-codes the
Casdoor verb spellings (get-users, get-organizations, get-user?id=…) in the v1
{status,data,data2} envelope, but iam2's native surface is REST — so a backend
swap 404s every console IAM page. internal/compat serves those verbs as READ
aliases over the SAME orm store, redaction, and authz as the REST handlers
(generic listHandler[T]/getHandler[T]; paginate only when both p+pageSize;
data2=total).

Redaction is consolidated to ONE contract: schema.Mask() per entity, returning a
masked COPY (never mutates the receiver — the login-verify path must never see a
blanked hash). users.redact/organizations.masked deleted; every read handler +
compat call .Mask(). This CLOSES latent leaks (red review):
applications/providers returned clientSecret raw on get/list; Application's
enriched joins (OrganizationObj, Providers[].Provider, CertObj) carried a linked
entity's own secret past the top-level mask; User.Mask now also blanks the live
VerificationCode.

Guard read-authorization understands the Casdoor ?id=<owner>/<name> shape
(authz.ReadTarget, exported) so non-super org-admins aren't 403'd on id-reads;
explicit owner/name still win and the handler re-scopes every query owner through
authz.Scope, so a ?owner=x&id=y/z split cannot cross tenants.

Tests: schema/mask_test (per-entity no-leak + no-mutation, enriched joins),
compat/aliases_test (v1 envelope, pagination, no-secret-leak through the REAL
router, owner-scoping, cross-tenant + regular-user denial, ?id= fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:31:00 -07:00
z f7eb859a6f sessions: tamper-evident session-cookie primitives (front-door §4 session layer)
The portal session the native front-door sets on a bare (type=login) sign-in and
that get-account resolves the caller from — the connective piece so get-account
serves the portal + gateway-admin-guard SESSION path, not just the bearer/API
path. Signed base64url(payload).base64url(HMAC-SHA256) carrying {owner, name,
application, sid, exp}; the signature is what makes `owner` (the admin-guard's
global-admin input) unforgeable. HMAC key derived from the platform signing
cert (domain-separated) — no new secret to provision, survives restarts. SID is
a 256-bit random id for Session-row revocation once wired into login SET.

Tests: round-trip, FORGED owner=admin rejected (the security property), wrong
key, expiry, malformed, SID uniqueness/size. Pure — no db, unit-testable.
2026-07-15 22:23:45 -07:00
z ac54e88b50 oidc: implement /v1/iam/get-account (bearer path) — front-door residual §4
get-account is a SECURITY contract: the gateway admin-guard derives the
global-admin (SuperAdmin) predicate from the `owner` it returns. Implemented
to match v1's envelope exactly — {status, sub, name, data:<user>, data2:<org>} —
resolving the caller from the bearer access token (shared with userinfo's
verifyToken), redacting every secret, and returning {status:"error"} for
anonymous/invalid callers (200, casibase convention → admin-guard reads
error → not-admin, fail-closed).

DRY: promotes the secret-strip to canonical schema.User.Redact() +
schema.Organization.Redact(); the existing users.redact / organizations.masked
now delegate to them (one definition each). Wired into MountFrontDoor.

Tests: bearer → redacted account (owner correct, 6 secret fields stripped);
anonymous/garbage → error + no data leak; Redact keeps owner+isAdmin, strips
secrets in place. Full iam2 suite green.

The portal session-cookie resolution path plugs into the same handler with no
shape change once the session layer lands (remaining §4 residual: session
issuance, signup, send-verification-code).
2026-07-15 22:06:08 -07:00
zeekayandClaude Opus 4.8 55eacee44c docs(iam2): MIGRATION.md to reality — orm not base, drift-gate dropped, argon2id RESOLVED
Corrects stale scaffold framing: storage is orm/hanzoai/sqlite (not base),
inter-service is zap-proto (not luxfi/zap), the drift gate is DROPPED (parity =
tests + golden vectors + parity audit + shadow deploy), and the argon2id
credential landmine is marked RESOLVED with the golden-vector proof. Preserves
the front-door residual + the three v1 security contracts. Adds the native
git.hanzo.ai+GitOps build/deploy section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:12:02 -07:00
zeekayandClaude Opus 4.8 82c1753986 test(iam2): golden vector — iam2 verifies v1's OWN argon2id digest (cutover parity)
The parity proof that matters: a PHC digest produced by hanzoai/iam's actual
Argon2idCredManager (GetHashedPassword, DefaultParams), captured verbatim and
asserted to verify under iam2's cred.Verify. Not a digest iam2 generated itself —
the exact bytes v1 writes.

Pins a REAL cross-version risk found while doing this: v1 resolves
argon2id v0.0.0-20211130144151 while iam2 pins v1.0.0. The PHC string is
self-describing (m=65536,t=1,p=N + salt + key), so either version verifies the
other's digest — this test proves it and fails loudly if a future bump breaks it.

Also asserts the shape (a v1 param change is caught here, not in prod), the full
row→algorithm path (user type wins over org fallback), and that the shipped bug
stays dead: the v1 argon2id digest must NOT verify under bcrypt.

Test passwords only — no live user's digest is ever committed (a real hash is an
offline-attackable secret).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:02:00 -07:00
zeekayandClaude Opus 4.8 3ff1bffb8e fix(iam2): resolve the password algorithm per row — argon2id, not bcrypt-only
Closes the cutover blocker recorded in a649a70: users.VerifyPassword called
bcrypt unconditionally and is the only path credential login takes, but EVERY
live v1 row is argon2id (org PasswordType is rewritten to argon2id on
create/update; UpdateUserPassword stamps it per user). bcrypt handed an argon2id
PHC digest returns ErrHashTooShort — so at cutover 100% of credential logins
would fail, for every existing user, immediately.

Ports v1's contract (object/check.go): the hash algorithm is a property of the
STORED ROW — user.PasswordType, falling back to organization.PasswordType — never
a constant.

- internal/cred: pure Verify(passwordType, plaintext, hashed) dispatching
  argon2id (github.com/alexedwards/argon2id — the same lib + PHC format v1's
  Argon2idCredManager writes) and bcrypt. Resolve(userType, orgType) implements
  the row→org fallback. Verify-ONLY (never hashes; upgrade-on-login stays a
  separate deliberate decision). Fails CLOSED on unknown/empty/malformed —
  including the legacy salt schemes and `plain` — because a silent pass on an
  unrecognized scheme is an auth bypass.
- users.VerifyPassword(u, plaintext, orgPasswordType) is now algorithm-aware.
- oidc/login.go resolves the org's PasswordType (store.GetOrganizationByName)
  and passes it, so the fallback is live on the real login path.

Tests (6/6): a REAL argon2id PHC digest verifies + wrong password rejected;
bcrypt unregressed; cross-scheme (argon2id digest under bcrypt and vice versa)
fails closed — the actual bypass shape; garbage/unknown/empty fail closed;
per-row-then-org resolution. Full suite green (authz, cred, oidc, seed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:41:30 -07:00
hanzo-dev a649a70730 iam2: record the argon2id blocker — bcrypt-only verify fails every live login
users.VerifyPassword calls bcrypt unconditionally and is the only path login
takes. Every live v1 row is argon2id (sanitizeOrgPasswordType rewrites
""/bcrypt/plain -> argon2id on create AND update; CreatePersonalOrganization
inserts it; init_data.json declares it). bcrypt handed an argon2id PHC string
returns ErrHashTooShort, so on cutover every existing user's login fails.

v1 resolves the algorithm per row (check.go: user.PasswordType, else
organization.PasswordType, then cred.GetCredManager). The algorithm is a
property of the stored row, never a constant.

Also records what the front-door port must honour: get-account backs the
gateway's SuperAdmin + waitlist predicates, send-verification-code is
multipart, and native userinfo/logout are aliases of the oauth handlers.
2026-07-15 12:35:34 -07:00
hanzo-dev d07fde4f14 iam2: record the front-door residual that gates cutover (HIP-0111 §6)
The OIDC surface is complete; the portal's native front door is not. signup,
send-verification-code, get-account, userinfo, logout are absent — a backend
swap without them takes hanzo.id's signup, verification, account and sign-out
with it. Gated on them regardless of drift.
2026-07-15 12:17:25 -07:00
hanzo-dev dbc9fbe5da iam2: close the cert private-key leak — scope reads by bearer, mask secrets
GET /v1/iam/certs?owner=<own-org> returned every tenant's certs with
privateKey serialized: a GET binds no query, so certs.List saw in.Owner==""
and listed all, and the response carried the admin signing key — any org admin
could forge tokens for any account. The read twin of the write divergence:
authorize one value, execute another.

One way for each concern: authz.Scope resolves a listing's owner from the
VERIFIED bearer (never a request parameter), and schema.Cert.Mask is the one
place a cert sheds secrets before it crosses the API — the signing key lives in
the store and signs in process; relying parties read the public half from the
JWKS (RFC 7517).

Also serve the JWKS at the root well-known path (RFC 8414) alongside the
/v1/iam one, matching live v1 — the gateway defaults to root.

Tests assert the response BODY, and are proven non-vacuous: neutering Mask or
Scope each fails a distinct case.
2026-07-15 12:15:01 -07:00
hanzo-dev 9c69096b44 Merge fix/authz-op-seam-divergence: authorize decoded op input (close users-owner + MCP target divergence) 2026-07-15 11:45:14 -07:00
Hanzo CI 0b6c7cd909 iam2 authz: authorize the decoded op input, not a re-parsed body
The Phase-3 guard re-parsed the authorization target from the raw request
body in middleware, divergently from where each handler binds it. For the
users entity — the one input that nests its record under `user` — the guard
read the top-level owner while the handler bound user.owner, so an org admin
could mask {owner:<own-org>, user:{owner:admin, isAdmin:true}} and write a
platform SuperAdmin (owner=="admin" IS the predicate). The same divergence let
an MCP tools/call mask the target in params.arguments.

Decomplect into two orthogonal seams:
  - Guard (app.Use) authenticates every request and authorizes reads, whose
    target rides in the query string.
  - Authorize (app.Authorize) authorizes writes at the framework's op-invoke
    seam, on the DECODED typed input — the exact value the handler binds — so
    REST and MCP authorize what actually executes, by construction. There is no
    second parse of the body to diverge from.

The one nested-owner input declares its target via an owned interface
(users.CreateInput/UpdateInput.AuthzTarget); the handler binds through the
same method, so the value authorized is the value written in one code path.
Every other entity files its owner at the top level, read reflectively, so an
attacker-supplied nested sub-struct is never mistaken for the target.

Requires zap-proto/zip v1.8.3 (the op-invoke Authorizer hook + App.Prepare).

Tests: the deferred /mcp and /openapi routes are installed for real, the real
op ids are used (post_v1_iam_certs, post_v1_iam_users), and both mask envelopes
(REST users owner-mask + MCP arguments-mask) assert 403/isError AND zero
admin-owned rows persisted — querying the store, not just the status code.
2026-07-15 11:45:14 -07:00
hanzo-dev c93fca8a57 Merge Phase 3: authz gate over the entity CRUD 2026-07-15 10:35:09 -07:00
hanzo-dev 9b779d284f iam2 Phase 3: authz gate over the entity CRUD
One authz middleware (internal/authz), mounted first via app.Use in
routes.Mount, verifies the bearer and enforces tenant scoping before any CRUD
handler runs. Closes the Phase-1 gap where the users/certs/applications/... CRUD
was unauthenticated: an unverified caller could overwrite an admin-owned signing
cert and forge any token.

Policy — three scopes, never conflated:
  - SuperAdmin (owner == "admin", a live admin-org user): the only cross-tenant
    scope; required to write any admin/built-in-owned resource. This one rule is
    the signing-cert poisoning gate, admin app/provider registration, and the
    built-in-org gap at once.
  - Org admin (IsAdmin): manages only its own org's resources.
  - Regular user: reads only its own user record.

The principal's org is taken from the token SUBJECT (the user's own owner/name),
never the owner/organization claim (the app's org, which diverges for a shared
app) — so a tenant user signing in through a shared admin-org app is not
SuperAdmin. Bearer verification reuses oidc.VerifyToken (exported): the same
algorithm allowlist, trusted signing-cert kid resolution, and expiry checks
every OIDC route uses. The reserved-owner set reuses store.IsSigningCertOwner.
Fail closed: only the OIDC/OAuth + front-door allowlist is public; the framework
/mcp and /openapi projections are gated too, and MCP is disabled on the
standalone binary.

Tests (internal/authz): the full policy matrix as a unit table, plus end-to-end
through the real mounted router — unauthenticated 401, cross-org 403, the
poisoning gate 403 across every cert-write verb, SuperAdmin cert rotation +
cross-org 2xx, org-admin own-org 2xx / foreign 403, regular self-read admitted /
writes and others 403, public routes open, bad bearers
(expired/HMAC/none/forged-kid/wrong-key/revoked) 401, owner-claim non-escalation,
phantom-admin subject no authority, /mcp + /openapi gated.

GOWORK=off go build ./... && go vet ./... && go test -race ./... all green.
2026-07-15 10:34:49 -07:00
zeekayandClaude Opus 4.8 84b3beda05 ci: native git.hanzo.ai (Gitea) portable build — self-contained, off GitHub reusable
Per the directive: canonical build/deploy is git.hanzo.ai (Gitea) + Hanzo GitOps;
GitHub is mirror-only. Replace the GitHub-specific reusable-workflow caller
(uses: hanzoai/.github/...@main — which caused startup_failure on this new
private repo, and is a GitHub-ism Gitea can't resolve) with a SELF-CONTAINED
docker buildx build+push that runs on a Gitea act_runner natively — and equally
on any standard runner. Same portable file at .gitea/workflows/build.yaml (native)
and .github/workflows/build.yml (mirror).

Registry + creds come from Actions secrets (REGISTRY_USER/REGISTRY_TOKEN,
KMS-provisioned), ghcr.io fallback during the mirror transition. Tags: v* → the
tag; branch → sha-<7>. amd64, pure-Go, GOEXPERIMENT=jsonv2.

Still ops-gated to actually run: native act_runner registered to git.hanzo.ai +
Hanzo GitOps deployed/pointed at it + git.hanzo.ai push creds (all cluster/gitea
admin). This makes iam2 READY for the native pipeline the moment those exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:02:13 -07:00
hanzo-dev 41b031a59b iam2 Phase 2: OIDC/OAuth2 server, reconciled onto CI-pinned main
feat/oidc-server @1f67eb1: discovery, JWKS, authorize, token (auth-code+PKCE/
refresh/client_credentials), userinfo, logout, login; RS256/ES + ML-DSA-65;
token-forgery + cross-tenant defenses. Crypto via luxfi/crypto/pq/mldsa/mldsa65
(circl indirect); zip v1.8.2 (Redirect); published deps, no replaces.
2026-07-15 09:49:46 -07:00
hanzo-dev 03bff4a006 iam2 Phase 2: in-tree OIDC/OAuth2 server (discovery, JWKS, authorize, token x3, userinfo, logout, login; RS256/ES/ML-DSA-65; forgery + cross-tenant defenses) 2026-07-15 09:43:14 -07:00
zeekayandClaude Opus 4.8 6ce8b4ed1e ci: match visor's exact build.yml (isolate startup_failure) + fix stale README
The extra platforms input was the only delta from the working visor caller;
drop it to match exactly. Also correct README status (orm not base, OAuth core
live not Phase-0, zap-proto not luxfi/zap) — the rebase had pulled in the old
scaffold docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:19:03 -07:00
zeekayandClaude Opus 4.8 c7bb8e0a6b ci: Hanzo CI — Dockerfile + build workflow, pin published deps (buildable in CI)
iam2 can now build on the Hanzo self-hosted ARC runners (hanzo-build-linux-amd64),
the same pipeline every hanzo service uses — the path to a real
ghcr.io/hanzoai/iam2 image and production deploy.

- go.mod: dropped the local replace directives (=> ../orm, ../../zap-proto/zip)
  and pinned the PUBLISHED versions (orm v0.6.1, zip v1.6.0). The local replaces
  were dev-only during the migration and don't exist in a CI container; verified
  iam2 builds + all tests pass against the published deps (no luxfi rot).
- Dockerfile: multi-stage golang:1.26.4 → alpine, CGO_ENABLED=0 (pure-Go;
  hanzoai/sqlite's modernc engine needs no cgo), GOEXPERIMENT=jsonv2 per
  SCALE_STANDARD, version via ldflags, non-root uid 1000, /data volume, serves
  ZAP :9653 + HTTP :8080; CMD bootstraps from --init-data.
- .github/workflows/build.yml: calls the shared hanzoai/.github docker-build.yml,
  image ghcr.io/hanzoai/iam2, linux/amd64, hanzo runners only (never cross-org).
- .dockerignore: clean build context.

Verified: the exact Dockerfile go build (CGO_ENABLED=0 GOEXPERIMENT=jsonv2
-trimpath -ldflags) compiles a working binary; oidc+seed suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:16:31 -07:00
hanzo-dev 1f67eb18fc fix(iam2): enforce tenant scope on credential login (cross-tenant token issuance)
POST /v1/iam/login authenticated the user in the caller-supplied organization but
resolved the app by an independent clientId, with no check that the user's org may
use that app. Because a token's owner/organization claim is set to the app's org,
a user with valid credentials in org B could obtain a token whose organization
claim names org A — cross-tenant issuance any relying party that authorizes on the
organization claim would honor.

Require the authenticated user's org to match the application's org, unless the app
is shared (IsShared) or lets users choose their org (OrgChoiceMode) — the standard
org-gated sign-in rule. Shadow-first + the drift-gated cutover make an
over-restriction observable in parity testing before it can reach production.

Tests: a valid org-B user is refused a code for a single-tenant org-A app; a shared
app still accepts a cross-org user. Full suite green + race-clean.
2026-07-15 03:51:27 -07:00
hanzo-dev 43181d3dfc fix(iam2): trust signing certs only under reserved platform owners (token-forgery defense)
Signing-cert resolution (JWKS publish, token signing, and bearer verification) now
trusts a cert only when it is owned by a reserved platform org (admin/built-in),
not any cert that happens to carry the kid's name.

Without this, because a cert is resolved by name across all owners, a caller who
can create a cert row (the entity CRUD is not yet authz-gated — Phase 3) could
register a cert named e.g. cert-hanzo under their own org with their own key,
then mint a JWT with kid=cert-hanzo and forged claims; verification (and the
published JWKS) could resolve THEIR key and accept the forgery platform-wide.

- store.GetSigningCert resolves a kid only among signingCertOwners; FindCertByName
  (global-by-name) removed.
- JWKS excludes any cert not owned by a platform signing owner.
- signerFor resolves the app's cert through the same trusted path, so signing,
  JWKS, and verification stay consistent.

Tests: a tenant cert with a colliding name neither verifies a forged token nor
appears in the JWKS; a non-platform cert is never trusted even as the sole
name-holder. Full suite green + race-clean.
2026-07-15 03:42:53 -07:00
hanzo-dev 047511497b test(iam2): logout redirect-safety — no open redirect without a verified id_token_hint
Covers the end-session endpoint's redirect guard: no redirect without a
post_logout_redirect_uri; refuse to redirect when the id_token_hint is
absent/unverified or the URI is not registered by the hint's client; honor +
echo state only for a signature-verified hint whose client registered the URI.
2026-07-15 03:34:25 -07:00
hanzo-dev c7f329853d feat(iam2): Phase 2 — in-tree OIDC/OAuth2 server (authorize, token, userinfo, JWKS)
Complete the OIDC/OAuth2 identity core at the canonical /v1/iam/* paths, matching
the live hanzo.id surface so existing clients verify tokens and run the flows
unchanged. Additive to Phase 0-1; v1 stays authoritative until cutover.

- Discovery at /.well-known and /v1/iam/.well-known/openid-configuration; issuer
  host-relative and consistent with the tokens' iss.
- JWKS publishes every signing cert's public key — RSA/RS256 (the interop path
  every existing verifier reads), EC ES256/384/512, and post-quantum ML-DSA-65
  behind the same seam — keyed by kid with x5c, ETag + 60s cache. Fixes the empty
  JWKS that left RS256 verifiers unable to resolve a key.
- authorize validates client_id + EXACT redirect_uri before any redirect
  (open-redirect defense), normalizes/enforces S256 PKCE, then delegates to the
  hosted login which mints the PKCE-bound code.
- token: authorization_code (single-use, redirect+nonce bound, PKCE-for-public
  enforced), refresh_token (opaque, rotation + reuse detection + family
  revocation), client_credentials; client_secret_basic/post; RFC 6749 error
  taxonomy (invalid_client 401 + WWW-Authenticate, else 400); no-store.
- userinfo authenticates the bearer by hash lookup (revocation) AND signature,
  returns scope-gated claims. id_token minted on openid, nonce echoed.
- Tokens persisted as SHA-256 hashes only; ML-DSA-65 is a real circl-backed
  jwt.SigningMethod, inert unless a cert selects it.

TDD: 85 tests/subtests green — discovery shape, JWKS (RSA/ML-DSA/ETag/dedup/TLS
exclusion), ES256 + full ML-DSA-65 round-trip, authorize validation, code/refresh/
client_credentials flows, PKCE tamper, reuse detection, error taxonomy, tenant
isolation. go vet clean.
2026-07-15 03:28:45 -07:00
hanzo-dev 86d217ca8b iam2: health probe → /healthz (root, unversioned — orthogonal to the API, matches k8s + operator) 2026-07-15 02:38:01 -07:00
hanzo-dev f966b775fa iam2: rip nested /v1/iam/v2 → canonical /v1/iam (no version nesting in API paths) 2026-07-15 02:35:08 -07:00
bd5881025a fix(iam2): persist credential hashes — json:"-" silently dropped them (login broke) (#9)
CRITICAL: orm serializes every entity to its JSON data column via json.Marshal,
so a field tagged json:"-" is never STORED (not just hidden from responses). The
Casdoor-ported schema used json:"-" on PasswordHash/PasswordSalt/AccessSecretHash
to keep them off API responses (xorm stored via DB columns) — but under orm's
JSON storage that meant they were never persisted. Every retrieved user had an
empty hash → VerifyPassword always failed → login could NEVER succeed.

Fix: give the persisted-credential fields real json tags (passwordHash,
passwordSalt, accessSecretHash) so orm stores them; the users API redact()
already strips every secret from responses (defense the security relies on now).
Verified: login by email → status ok; wrong password → rejected; user CRUD
response still carries NO hash.

Regression test TestPasswordHashPersists proves the hash survives a store
round-trip AND verifies (and wrong-pw fails). 26 oidc tests green.

Follow-up: the nested MFA Secret/SecretKey fields (inside sub-structs) have the
same json:"-" issue — persist them when MFA lands. Root cause is orm conflating
storage-serialization with wire-serialization; orm should honor a storage tag so
this class can't recur (tracked separately).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:11:52 -07:00
af96110913 feat(iam2): public server package — embeddable in hanzoai/cloud (shadow-first) (#8)
The one call a host binary makes to embed iam2: server.Mount(app, db) registers
the full IAM v2 surface (OIDC discovery/JWKS, get-app-login, auth/methods, token,
login, entity CRUD) onto the host's zip app over its orm.DB. Plus server.OpenSQLite
+ server.Seed(initDataPath) for boot bootstrap.

This is how iam2 goes live embedded in cloud (the deployed multi-mode zip binary
that already embeds Casdoor via iamserver.Run) — zip-native, lean, no separate
pod. SHADOW-FIRST by contract: the host chooses the mount prefix; mounted under a
shadow prefix iam2 runs ALONGSIDE the live Casdoor /v1/iam/* with zero impact,
and is flipped onto the canonical paths only after verification. Never
blind-replace live auth.

Build + full suite green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:34:17 -07:00
580ba8eab6 feat(iam2): Phase 2 increment 5 — credential login (POST /v1/iam/login) (#7)
The interactive-flow counterpart to the token endpoint: login verifies the
password (bcrypt, constant-time via users.VerifyPassword) and mints a PKCE-bound
authorization code the SDK exchanges at /token. Login by EMAIL or USERNAME,
org-scoped (tenant isolation). 25/25 oidc tests.

- internal/oidc/login.go: POST /v1/iam/login. Resolves user by email (contains
  "@") or username within the org; one opaque "username or password is incorrect"
  for both no-user and bad-password (no account-existence oracle); type=code →
  MintCode (PKCE, S256-only) → persist → return the code in the Response
  envelope; type=login → success + userId (session issuance is the next layer).
  The password hash never crosses a response.
- internal/store: GetUserByName + GetUserByEmail (org-scoped).

Tests: TestLoginToTokenFlow proves login(by email)→bcrypt-verify→mint code→
redeem at /token→correct user binding; tenant-isolation (wrong org → not found)
+ by-username + by-email lookup. Live: login/token error paths correct
(opaque failure, invalid_grant, single-use replay rejected).

Known follow-up: the happy-path over HTTP needs the users-CRUD create shape and
the login email-lookup reconciled (unit tests seed the user via orm directly and
pass — the logic is proven; the CRUD-seeding integration is a data-shape detail).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:45:41 -07:00
b2af182643 feat(iam2): port Casdoor init_data bootstrap — seed the real config on boot (#6)
Ports old-iam's InitFromFile: iam2 self-seeds orgs/apps/providers/certs from
the SAME init_data.json Casdoor uses, so a fresh store (embedded in cloud or
standalone) comes up with the real config instead of empty. New-only +
idempotent; ${VAR} substituted from env (KMS-synced secret injection, same as
Casdoor).

- internal/seed: FromInitData(ctx, db, path) + Apply — upsert via orm.GetOrCreate
  (Get→skip-if-exists, else create), generic-safe field copy through a JSON
  round-trip that preserves the wired Model.
- main.go serve --init-data <path>: seed on boot, log the counts.

Verified against the REAL universe init_data.json: seeds 9 orgs, 79 apps, 7
providers, 6 certs; then get-app-login(hanzo-console) → status ok, org hanzo,
4 providers; auth/methods → github+google oauth; lux-id → org lux. So the
config layer "just works" — the path to embedding iam2 in cloud.

Tests: seed round-trip + env-substitution + new-only idempotency, all green.
Remaining for full prod parity: users/password hashes (sensitive — import
separately), the authorize/login endpoints, ML-DSA JWT + JWKS.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:29:14 -07:00
4f9b09338a feat(iam2): Phase 2 increment 4 — token endpoint + RS256 JWT signing (#5)
Wires the authorization_code grant end to end. 23/23 oidc tests (PKCE 7,
code 10, JWT 5, e2e 1); token error paths verified live.

- internal/oidc/jwt.go — RS256 signer from the Cert entity PEM (PKCS#1/#8);
  Claims = registered + scope + owner + email; kid header from cert name;
  aud = clientId (validators fail closed on aud). ML-DSA-65 rides the same
  Signer later via luxfi/crypto.
- internal/oidc/token.go — POST /v1/iam/oauth/token: authorization_code only
  (refresh/client_credentials → unsupported; implicit permanently off). Reads
  params query→form→basic; client_id match + confidential-secret check (both
  constant-time; public/PKCE client allowed no secret); RedeemCode guard
  (replay/expiry/client/PKCE) → IssueAccessToken → RS256 JWT → SaveToken.
  Unknown code answers a generic invalid_grant (no oracle).
- internal/store — GetTokenByCode, GetCert, PersistToken, SaveToken.

E2E test proves mint→persist→get-by-code→redeem→sign→save→verify-claims and
that replay after persist fails with ErrCodeUsed. Served locally: token error
paths match RFC 6749 §5.2; discovery advertises the endpoint.

iam2 not in prod — verified by the adversarial suite + live smoke, the exact
logic a red pass scrutinizes. Next: authorize + login/session feed the mint
side; ML-DSA-65 method; real JWKS from the Cert public keys.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:07:52 -07:00
e346ec266a feat(iam2): Phase 2 increment 3 — PKCE S256 + authorization-code lifecycle (#4)
The security-critical heart of the OAuth2 code flow, built with an adversarial
test suite (17/17 pass). iam2 is not in prod, so this pre-prod code is verified
by tests + constant-time construction, the exact logic a red pass scrutinizes.

- internal/oidc/pkce.go — RFC 7636 S256 only. ComputeS256Challenge (matches the
  RFC Appendix B vector) + VerifyPKCE: constant-time (subtle.ConstantTimeCompare),
  "plain" permanently refused (incl. empty method), verifier-with-no-challenge
  fails closed, missing-verifier rejected.
- internal/oidc/code.go — the authorization-code lifecycle over the Token entity:
  MintCode (256-bit crypto/rand code, binds app+user+PKCE+scope+resource, 5-min
  TTL, refuses to store plain), RedeemCode (fail-closed: unknown → used(replay) →
  expired → client-mismatch(constant-time) → PKCE), IssueAccessToken (mints the
  access token + marks the code one-shot used).

Threat surface proven: RFC vector, replay (ErrCodeUsed), expiry, client mismatch,
wrong verifier, plain downgrade, public-client-must-present-verifier, verifier-
without-challenge. 17/17 tests green; go build clean.

Next: wire RedeemCode into POST /v1/iam/oauth/token inside a store transaction
(mark-used atomic with issue), + JWT signing (RS256/ML-DSA-65 from the Cert
entity) for the access-token body. authorize/login/session feed the mint side.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:31:48 -07:00
74b2dd183a feat(iam2): Phase 2 increment 2 — store layer + get-app-login + auth/methods (#3)
The read-only front door the @hanzo/iam <Login> calls to self-configure —
pure orm reads, no crypto.

- internal/store: the object layer over orm.TypedQuery (replaces v1 xorm
  ormer.Engine). GetApplicationByClientId / GetApplicationByName / GetProvider
  / EnrichProviders. Uses the PascalCase-no-space filter convention
  (Filter("ClientId=", v)) matching the Phase-1 CRUD.
- internal/oidc/frontdoor.go:
  - GET /v1/iam/get-app-login — resolve app by clientId, MASK client secrets
    (browser-facing), enrich each provider link with its shared record.
  - GET /v1/iam/auth/methods — the SDK self-config endpoint (does NOT exist in
    v1): {password, code, webauthn, web3, signup, oauth[]}. A provider shows
    only when configured (real clientId; web3 is native so always on) — the
    guard that keeps an unconfigured button from dead-ending.

Verified end-to-end (served locally, seeded app+providers): get-app-login
status=ok with clientSecret masked to ""; auth/methods returns
oauth=[{provider-github,GitHub,logo}] + web3=true + password/code/webauthn/signup.
Found+fixed an orm filter bug (trailing space "clientId =" → never matched;
correct is "ClientId=").

Next (auth-critical, blue/red): session (cookie↔sessions), token endpoint
(PKCE single-use+replay), ML-DSA-65 JWT signing, real JWKS, login/signup,
social hop, web3 SIWx via hanzoai/wallet over ZAP.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:21:03 -07:00
908d38ca0e feat(iam2): Phase 2 increment 1 — OIDC discovery + JWKS foundations (#2)
Starts the OIDC/OAuth2 server on zip + orm (the unified Go auth backend).
Raw zip handlers (query/header/cookie/form/redirect reach), not typed
generics — the auth surface needs them.

- internal/httpx: the Casdoor-compatible Response envelope (status/msg/data…)
  the @hanzo/iam SDK + hanzo.id portal consume unchanged, + Bearer/EffectiveHost.
- internal/oidc: /.well-known/openid-configuration (host-relative issuer,
  advertises only canonical /v1/iam/oauth/*, PKCE S256, owner claim, no implicit)
  + /v1/iam/.well-known/jwks (well-formed empty set; certs wire in at token
  signing). Canonical paths, single source of truth.

Verified: go build + vet clean; served locally → discovery HTTP 200 valid
OIDC doc (issuer iam.hanzo.ai), JWKS 200 {"keys":[]}.

Next increments (auth-critical, blue/red): token endpoint (PKCE authorization_code
single-use + replay guard), ML-DSA-65 hybrid JWT signing, authorize/userinfo/logout,
front-door login/get-app-login/auth-methods, social hop, web3 SIWx, CORS.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:07:38 -07:00
30e29ade93 feat(iam2): Phase 1 — full entity fields + typed CRUD handlers (#1)
Replace the Phase-0 13-struct stubs with the field-complete v2 forms of
every Casdoor-fork identity entity, re-expressed on hanzoai/orm, and add
one typed zip CRUD package per entity wired through routes.Mount(app, db).

Schema (internal/schema): full fields for users, organizations,
applications, providers, roles, permissions, certs, keys,
webauthn_credentials, sessions, tokens, audit_logs, invitations. Shared
value types (ThemeData, MfaItem) are declared once. orm.Register stays
centralized in schema.go (one place; a second call panics on duplicate
kind) alongside Kinds(). Permission's Casbin-model column is carried as
AuthzModel (json:"model") because the embedded orm.Model[Permission]
mixin owns the Go identifier Model.

Handlers (internal/<entity>): one owner-scoped CRUD package per entity,
each exposing a uniform Mount(app, db). Reads project to REST GET + MCP,
writes to POST/PUT/DELETE. Users hashes the plaintext password with
bcrypt exactly once and redacts all secret material on read; every
handler resolves rows by the (owner, name) natural key.

x/crypto promoted to a direct require for bcrypt. orm+zip foundation
(relative replaces, no base) is unchanged.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:45:38 -07:00
189 changed files with 33697 additions and 262 deletions
+6
View File
@@ -0,0 +1,6 @@
data/
*.db
*.db-*
.git/
.claude/
node_modules/
+57
View File
@@ -0,0 +1,57 @@
# Native Hanzo CI — git.hanzo.ai (Gitea Actions). Self-contained: plain docker
# buildx build+push, NO GitHub-specific reusable workflow, so it runs on a Gitea
# act_runner (and equally on any standard runner). Hanzo GitOps then reconciles
# the image tag onto the cluster.
#
# Registry: pushes to the Hanzo container registry. REGISTRY + IMAGE + creds come
# from repo/org Actions secrets (REGISTRY_USER / REGISTRY_TOKEN), provisioned from
# KMS — never inline. Falls back to ghcr.io during the mirror transition.
name: build
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve image + version
id: meta
run: |
echo "registry=${REGISTRY:-ghcr.io}" >> "$GITHUB_OUTPUT"
echo "image=${REGISTRY:-ghcr.io}/hanzoai/iam2" >> "$GITHUB_OUTPUT"
ref="${GITHUB_REF##*/}"
case "$GITHUB_REF" in
refs/tags/v*) ver="$ref" ;; # v0.1.0
*) ver="sha-$(echo "$GITHUB_SHA" | cut -c1-7)" ;;
esac
echo "version=$ver" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- name: Registry login
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.registry }}
username: ${{ secrets.REGISTRY_USER || github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
- name: Build + push (amd64; pure-Go, jsonv2 per SCALE_STANDARD)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
target: STANDARD
push: true
build-args: |
GO_EXPERIMENT=jsonv2
VERSION=${{ steps.meta.outputs.version }}
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
+74
View File
@@ -0,0 +1,74 @@
# Native Hanzo CI — git.hanzo.ai (Gitea Actions). Self-contained: plain docker
# buildx build+push, NO GitHub-specific reusable workflow, so it runs on a Gitea
# act_runner (and equally on any standard runner). Hanzo GitOps then reconciles
# the image tag onto the cluster.
#
# Registry: pushes to the Hanzo container registry. REGISTRY + IMAGE + creds come
# from repo/org Actions secrets (REGISTRY_USER / REGISTRY_TOKEN), provisioned from
# KMS — never inline. Falls back to ghcr.io during the mirror transition.
name: build
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
packages: write # the automatic GITHUB_TOKEN is denied ghcr write without this
jobs:
docker:
# The ARC self-hosted scale set, as every working hanzo build uses. GitHub-hosted
# `ubuntu-latest` is billing-frozen for the org, so a run on it never starts —
# which is why every prior iam2 build silently failed to produce an image.
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- name: Resolve image + version
id: meta
run: |
echo "registry=${REGISTRY:-ghcr.io}" >> "$GITHUB_OUTPUT"
echo "image=${REGISTRY:-ghcr.io}/hanzoai/iam" >> "$GITHUB_OUTPUT"
ref="${GITHUB_REF##*/}"
case "$GITHUB_REF" in
refs/tags/v*) ver="$ref" ;; # v0.1.0
*) ver="sha-$(echo "$GITHUB_SHA" | cut -c1-7)" ;;
esac
echo "version=$ver" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- name: Registry login
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.registry }}
# GH_PAT (admin:org + write:packages) — the automatic GITHUB_TOKEN is
# denied write to ghcr.io/hanzoai/* (permission_denied: write_package),
# the same reason hanzoai/cloud logs in with GH_PAT.
username: hanzo-dev
password: ${{ secrets.GH_PAT }}
- name: Build + push (amd64; pure-Go, jsonv2 per SCALE_STANDARD)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
target: STANDARD
push: true
build-args: |
GO_EXPERIMENT=jsonv2
VERSION=${{ steps.meta.outputs.version }}
# iam2's private modules (hanzoai/orm → hanzoai/dbx, hanzoai/sqlite) are
# fetched inside the build via this token — the Dockerfile mounts it as
# GIT_AUTH_TOKEN. GH_PAT (admin:org read) is what hanzoai/cloud uses to
# fetch the same private cross-repo modules; the automatic GITHUB_TOKEN
# cannot (it only reads the repo it runs in), which is why dbx 404'd.
secrets: |
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
+2
View File
@@ -10,6 +10,8 @@ iam-v2
*.db
*.db-shm
*.db-wal
# committed test fixtures (encrypted-source migrator canvas vector)
!cmd/migrate-v1/testdata/*.db
# env / local
.env
+64
View File
@@ -0,0 +1,64 @@
# Hanzo IAM v2 — proprietary identity service (zip + orm, no Casdoor).
# Multi-stage Go build → distroless-style alpine. Pure-Go (CGO_ENABLED=0);
# hanzoai/sqlite uses the modernc engine so no cgo/musl toolchain is needed.
FROM golang:1.26.4@sha256:f96cc555eb8db430159a3aa6797cd5bae561945b7b0fe7d0e284c63a3b291609 AS build
WORKDIR /src
# Cache the module graph before copying the source. iam2 imports private hanzoai
# modules (hanzoai/orm, hanzoai/sqlite), so mark them private (direct fetch, no
# sumdb) and — when a GIT_AUTH_TOKEN is mounted — rewrite github.com to an
# authenticated fetch so `go mod download` can read them. Same pattern as
# hanzoai/cloud; without the token it is a no-op (a public-only build still works).
ENV GOPRIVATE=github.com/hanzoai/*
COPY go.mod go.sum ./
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
fi && \
go mod download
COPY . .
# Per SCALE_STANDARD.md §2 — every Go production Dockerfile that emits JSON to a
# client builds with GOEXPERIMENT=jsonv2 (zip's edge JSON path).
ARG GO_EXPERIMENT=jsonv2
ENV GOEXPERIMENT=${GO_EXPERIMENT}
ARG VERSION=dev
# Two binaries from one build stage: the server (/out/iam2) and the Phase-5
# cutover migrator (/out/migrate-v1) the migration Job runs. Both are pure-Go
# (CGO_ENABLED=0 + the same GOEXPERIMENT) and share the one module download above.
# The migrator carries no version symbol, so it is stamped -s -w only.
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/iam2 . \
&& CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w" \
-o /out/migrate-v1 ./cmd/migrate-v1
FROM alpine:latest@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS STANDARD
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam"
LABEL org.opencontainers.image.title="Hanzo IAM v2"
# sqlcipher is the C SQLCipher 4.x shell the migrator's --wal-inclusive path drives
# to checkpoint each shard's uncheckpointed -wal before extraction; alpine ships
# SQLCipher 4.x (4.5.6 on the stable branch, 4.6.x on edge), whose v4 on-disk
# format matches the production data and the pure-Go codec. The server never calls
# it — it rides along so this ONE image serves both the server and the migrator Job.
# alpine is digest-pinned: this runtime base is in the migrator's DEK trust path (it
# provides the sqlcipher the raw decryption key is piped to), so a floating :latest is
# not acceptable for a one-shot migration of irreplaceable auth data (RED, v1.32.6).
RUN apk add --no-cache ca-certificates sqlcipher && update-ca-certificates \
&& adduser -D -u 1000 hanzo \
&& mkdir -p /data && chown -R hanzo:hanzo /data
USER 1000
WORKDIR /
COPY --from=build --chown=hanzo:hanzo /out/iam2 /iam2
COPY --from=build --chown=hanzo:hanzo /out/migrate-v1 /migrate-v1
# Serves the IAM v2 API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
# config with --init-data /etc/iam/init_data.json (mounted from the same
# init_data ConfigMap the Casdoor iam uses; ${VAR} creds from the KMS-synced env).
EXPOSE 8080 9653
ENTRYPOINT ["/iam2"]
CMD ["serve", "--db", "/data/iam2.db", "--http", "http://:8080", "--zap", ":9653"]
+149 -53
View File
@@ -1,8 +1,11 @@
# IAM v2 Migration
Casdoor fork (`hanzoai/iam`: Beego + xorm, Apache-2.0) → `hanzoai/iam2`:
clean-room, proprietary, on the native Hanzo stack. Phased and drift-gated —
the identity binary is never rewritten in one shot.
clean-room, proprietary, on the native Hanzo stack. Phased and additive — the
identity binary is never rewritten in one shot, and v1 stays live and
authoritative until the supervised cutover. Parity is proven by tests + golden
vectors captured from v1's own code + a route-level parity audit, and by a
shadow deployment against real traffic — not by a swap on faith.
## §1 Why
@@ -15,66 +18,159 @@ own framework — we own it, and it collapses to one way of doing each thing.
- **HTTP** — `github.com/zap-proto/zip` (typed `zip.Get[In,Out]` handlers on the
`zap-proto/fiber/v3` engine, specificity routing, OpenAPI 3.1 at the edge).
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache) over
`github.com/hanzoai/base` (collections, realtime, replicate-to-S3). SQLite —
never Postgres for the local/default path.
- **Authz** — `github.com/hanzoai/authz`, one canonical policy engine, called
over ZAP RPC. No in-process copy.
- **OIDC/OAuth2** — in-tree port (no external OIDC library). ML-DSA-65 hybrid
JWT signing; JWKS cache.
- **Inter-service** — `github.com/luxfi/zap` binary RPC. HTTPS is the external
edge only; all service↔service is ZAP (platform law).
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache). Default
is embedded SQLite (`hanzoai/sqlite`, pure-Go, WAL) — never Postgres. The same
`orm.DB` abstraction pluggably targets `hanzoai/sql` / `hanzoai/datastore` over
ZAP (`--store sql|datastore`), so iam2 gains ZAP-native persistence + snapshots
with zero code change once orm's ZAP backend is enabled.
- **OIDC/OAuth2** — in-tree (no external OIDC library). RS256 today; ML-DSA-65
hybrid JWT signing + real JWKS from the Cert entity.
- **Password verify** — algorithm resolved from the stored row (`internal/cred`):
argon2id (every live v1 row) + bcrypt (new iam2 rows), verify-only, fail-closed.
- **Inter-service** — `zap-proto` binary RPC. HTTPS is the external edge only; all
service↔service is ZAP (platform law).
- **Authz** — `github.com/hanzoai/authz` policy engine (`internal/authz` gate).
## §2.1 RFC/IETF-standard surface — no Casdoor verbs (HIP-0111)
The wire contract is RFC/OpenID-standard only; there are no Casdoor verb aliases
(`get-users`, `add-user`, `get-account`, `issue-user-token`, …) and no `access_token`
duplicate of the token endpoint. Each capability is served by its standard, all
shipped (iam2 tags):
| Capability | Standard | Endpoint | Tag |
|-----------|----------|----------|-----|
| Authorize / token | RFC 6749 (code+PKCE, refresh, client_credentials, **password**) | `/v1/iam/oauth/{authorize,token}` | v0.5.0 |
| Delegation / on-behalf-of | **RFC 8693 Token Exchange** (replaces `issue-user-token`) | `grant_type=…token-exchange` | v0.7.0 |
| Introspection / revocation | RFC 7662 / RFC 7009 | `/v1/iam/oauth/{introspect,revoke}` | v0.6.0 |
| AS metadata / discovery / JWKS | RFC 8414 / OIDC Discovery / RFC 7517 | `/.well-known/*` | v0.6.0 |
| Account claims | **OIDC UserInfo** (carries owner/organization/email/isAdmin/type — the get-account contract) | `/v1/iam/oauth/userinfo` | v0.9.0 |
| Identity provisioning | **SCIM 2.0** (RFC 7644/7643; replaces get-/add-/update-/delete-user) | `/v1/iam/scim/v2/Users` | v0.8.0 (v0.8.1 authz fix) |
| Resource indicators / issuer pin | RFC 8707 + `IAM_ISSUER` | token `aud`/`iss` | v0.5.0 |
| Social sign-in / federation | **OIDC/OAuth2 Relying Party** (Authorization-Code + PKCE; Google = OIDC Discovery, GitHub = OAuth2 + userinfo) | authorize `?provider=<name>``/v1/iam/oauth/callback` | v0.15.0 |
Deploy env: `IAM_ISSUER=https://<brand-id>`, `IAM_KEY_MINT_ALLOWED_APPS` (token
exchange + `hk-` key mint) and `IAM_ADMIN_MINT_ALLOWED_APPS` (reserved-org targets)
— both matched by the globally-unique clientId only.
**Federation (social sign-in), v0.15.0.** iam2 completes a Google/GitHub sign-in
as a standard OIDC/OAuth2 Relying Party — no Casdoor verbs, no tokens-in-query.
The authorize endpoint, once it has validated the client and its EXACT
redirect_uri, treats a `?provider=<providerName>` request as a federation
kickoff: it stashes the app-leg request in a single-use, expiring,
browser-bound `FederationState` (state = an opaque 256-bit row key; a `hanzo_fed`
HttpOnly+Secure+SameSite=Lax cookie binds it to the initiating browser) and 302s
to the IdP with iam2's callback as the redirect_uri, an IdP-leg S256 PKCE
verifier, and (OIDC) a nonce. The fixed public callback `/v1/iam/oauth/callback`
resolves + burns the transaction (expiry + browser-binding checked), exchanges
the IdP code, and VERIFIES the response — for OIDC the id_token signature
(against the discovered JWKS, alg pinned to RS/ES), issuer, audience (= our
client id), expiry, and nonce; for GitHub the userinfo + a GitHub-verified
primary email. It then LINKS or PROVISIONS a local user (match by provider
subject, else by VERIFIED email, else provision — never `isAdmin`, federated
accounts carry no password) and mints iam2's OWN authorization code bound to the
original PKCE/redirect/nonce, so the relying party's existing PKCE code→token
exchange completes unchanged. Provider credentials/endpoints come from the
existing `providers` rows (`clientId`/`clientSecret`/`type`/`scopes`/`issuerUrl`
or the `custom*Url` overrides); the linked subject is persisted on the User's
per-connector column (`google`/`github`/…).
Remaining for cutover: migrate the clients (console `IamAdminApi`/`identity.ts`,
gateway admin-guard, portal) off the Casdoor verbs onto these standards via
`@hanzo/iam` (+ a SCIM client + token-exchange), then retire `internal/compat` and
`get-account`. iam2 already serves everything the clients need in standard form.
## §3 Phases
| Phase | Scope | Gate to exit |
|------:|-------|--------------|
| 0 | Scaffold: Base boots, v2 collection namespace claimed, `/v1/iam/v2/health`, `compare` CLI. | Binary builds and boots. |
| 1 | Entity schemas (fields + indexes) + CRUD handlers on `zip` + `orm`, per resource. | Per-entity field parity vs v1; handlers pass tests. |
| 2 | In-tree OIDC/OAuth2 server: `/v1/iam/oauth/*`, `/v1/iam/.well-known/*`, JWT (ML-DSA-65), JWKS. | Token/userinfo/authorize parity vs v1. |
| 3 | Authz via `hanzoai/authz` over ZAP RPC; retire in-process authz. | Policy decisions match v1. |
| 4 | Parity: run `iam2 compare` continuously against a v1 read replica. | **drift = 0** (or a known v1-only residual v2 does not model). |
| 5 | Cutover: import v1 data, promote `iam2` to the `iam` mount, archive the fork. | Green in prod; rollback path proven. |
| Phase | Scope | Exit |
|------:|-------|------|
| 1 | Entity schemas (full fields) + owner-scoped CRUD on `zip`+`orm`, 13 identity entities. | ✅ Field-complete vs v1; handlers tested. |
| 2 | In-tree OIDC/OAuth2: discovery, JWKS, authorize, token (PKCE S256 + JWT), refresh, userinfo, logout; front-door login/get-app-login/auth-methods. | ✅ Core flow (login→code→token→JWT) tested; front-door residual in progress (below). |
| 3 | Authz via `hanzoai/authz` gate over the entity CRUD. | ✅ In `internal/authz`. |
| | ~~Drift gate~~ **DROPPED.** Parity is proven by tests + golden vectors (a real v1 argon2id digest verifies) + a route-level parity audit + a shadow deployment — not a row-count diff. The read-only `compare` CLI remains as a diagnostic, not a gate. | — |
| 4 | **Bootstrap + embed.** Seed the real config (orgs/apps/providers/certs) from the same `init_data.json` v1 uses (`internal/seed` — 79 apps / 9 orgs). Embed in `hanzoai/cloud` via `server.Mount`, SHADOW-FIRST (own prefix, alongside live Casdoor, non-destructive). | Shadow serves real `get-app-login`/login against seeded config. |
| 5 | **Cutover.** Import the user rows (password hashes verify as-is — see §5), flip iam2 onto the canonical `/v1/iam/*`, archive the fork. | Green in prod; rollback proven. |
Phases 04 are additive and non-destructive — v1 stays live and authoritative
until Phase 5. Routes carry a `/v1/iam/v2/*` prefix through the transition so
they are orthogonal to the live `/v1/iam/*` mount; the prefix collapses at §6.
## §4 Front-door residual (gates cutover)
## §4 Domain model (v1 xorm table → v2 Base collection)
The OIDC/OAuth2 protocol surface is complete. HIP-0111 §6's *native front-door*
what the hosted `hanzo.id` portal itself calls, distinct from the OIDC surface
client apps use — is now complete: `get-app-login`, `login`, `auth/methods`,
`userinfo`, `logout`, `refresh`, `authorize`, `get-account`,
`send-verification-code`, `signup`. A backend swap without these takes the
portal's account page, email verification, and signup with it, so cutover was
gated on them. Serve under `/v1/iam/*` (no `/api/`, no new prefix).
Thirteen identity entities. Field-completeness is mandatory — a dropped column
is lost auth data.
The **durable session** is wired (`internal/sessions`): a bare `login`
(type=login) issues a signed, revocable session cookie (`hanzo_session`, HMAC
keyed off the platform signing cert — no new secret), and `get-account` resolves
the caller by cookie first (the portal + admin-guard path) then bearer (the API
path) — two credentials, one identity. The cookie's `sid` is registered in the
`Session` row and re-checked on every resolve, so logout/rotation revokes it.
§4 is closed; iam2 is Phase-4 shadow-embed ready.
| v1 table (xorm) | v2 collection (Base) | Base kind |
|-----------------------|------------------------|-----------|
| `user` | `users` | auth |
| `organization` | `organizations` | base |
| `application` | `applications` | base |
| `provider` | `providers` | base |
| `role` | `roles` | base |
| `permission` | `permissions` | base |
| `cert` | `certs` | base |
| `key` | `keys` | base |
| `webauthn_credential` | `webauthn_credentials` | base |
| `session` | `sessions` | base |
| `token` | `tokens` | base |
| `record` | `audit_logs` | base |
| `invitation` | `invitations` | base |
The `signup`/`send-verification-code` pair carries two deliberate seams vs v1,
each a missing iam2 dependency, not a shortcut: (1) signup lands the user in the
app's **existing** org — v1's founder-org mint (`TenantOrgForSignup`) needs an
org-create helper + the `Org.Parent` tenant model iam2 has not modeled yet;
(2) `send-verification-code` persists a verifiable OTP (the `verifications`
entity) but the email/SMS **delivery** is owned by `hanzoai/notify`, not wired
into iam2 — the endpoint reports `ok` honestly and never fakes a "sent" claim.
**Deliberately not modeled by iam2** (they belong to commerce/other services,
not identity): `payment`, `plan`, `product`, `subscription`, `pricing`,
`model`, `adapter`, `enforcer`, `syncer_*`.
Three facts the port must honour, each verified against live v1:
- **`get-account` is a security contract, not a convenience.** The gateway's
admin-guard derives the **SuperAdmin predicate** from it
(`gateway/cmd/admin-guard/main.go`); waitlist-guard derives **approval**. Its
response shape (owner/isAdmin/… + no secret material) must match exactly.
- **`send-verification-code` takes `multipart/form-data`, not JSON.**
- Native **`userinfo`/`logout` are aliases** of the `oauth/*` handlers
(`routers/router.go` + `authz_filter.go` collapse them) — register the alias,
never fork a second implementation.
## §5 Drift gate
## §5 Credential parity (the cutover landmine, RESOLVED)
`iam2 compare --legacy <v1-dsn>` opens the v1 database **read-only** (only
`SELECT COUNT(*)`), opens the v2 Base store read-only, and prints per-entity
row counts plus absolute drift. This is the gate that keeps cutover honest:
drift must be 0 before Phase 5 import goes live. No writes, no DDL, ever.
Every live v1 row is **argon2id** (`object/organization.go sanitizeOrgPasswordType`
rewrites `""`/`bcrypt`/`plain``argon2id`; `UpdateUserPassword` stamps it per
user). A bcrypt-only verifier handed an argon2id PHC digest returns
`ErrHashTooShort`**100% of logins fail at cutover.** Fixed: `internal/cred`
resolves the algorithm **from the row** (`user.PasswordType` → fallback
`organization.PasswordType`), matching v1's `object/check.go`, and verifies
argon2id + bcrypt, verify-only, fail-closed on any unknown scheme. Proven by a
**golden vector** — a digest produced by v1's *own* `Argon2idCredManager`
verifies under iam2 (`internal/cred/golden_v1_test.go`), across the v0→v1.0.0
library-version gap. So existing users' hashes verify unchanged at import — no
password reset, no re-hash on read.
## §6 Cutover
## §6 Domain model (v1 xorm table → v2 orm kind)
At Phase 5, with drift proven 0: import v1 rows into v2 collections, drop the
`/v2` route prefix so `iam2` answers on `/v1/iam/*`, repoint the `iam` image /
operator CR / DNS to `iam2`, and archive `hanzoai/iam`. One identity binary,
one way, no Casdoor.
Fourteen identity entities. Field-completeness is mandatory — a dropped column is
lost auth data.
| v1 table (xorm) | v2 orm kind |
|-----------------------|------------------------|
| `user` | `users` (auth) |
| `organization` | `organizations` |
| `application` | `applications` |
| `provider` | `providers` |
| `role` | `roles` |
| `permission` | `permissions` |
| `cert` | `certs` |
| `key` | `keys` |
| `webauthn_credential` | `webauthn_credentials` |
| `session` | `sessions` |
| `token` | `tokens` |
| `record` | `audit_logs` |
| `invitation` | `invitations` |
| `verification` | `verifications` |
**Deliberately NOT modeled by iam2** (they belong to other services or are
replaced by `hanzoai/authz`): `payment`, `plan`, `product`, `subscription`,
`pricing`, `model`, `adapter`, `enforcer`, `syncer_*`, LDAP.
## §7 Build & deploy
Builds CGO-free (`hanzoai/sqlite` is pure-Go), pinned to published `hanzoai/orm`
+ `zap-proto/zip` (no local replaces). Native CI at `.gitea/workflows/build.yaml`
(git.hanzo.ai act_runner) + a mirror `.github/workflows/build.yml`, both
self-contained (no reusable-workflow dependency). Canonical pipeline is
**git.hanzo.ai + Hanzo GitOps**; GitHub is a downstream mirror.
+9 -7
View File
@@ -12,24 +12,26 @@ identity binary carries no upstream copyright or license obligations.
| Concern | Component | Notes |
|----------------|-----------|-------|
| HTTP | [`zap-proto/zip`](https://github.com/zap-proto/zip) | Typed handlers (`zip.Get[In,Out]`) on the `zap-proto/fiber/v3` engine; specificity routing; OpenAPI 3.1 |
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) over [`hanzoai/base`](https://github.com/hanzoai/base) | Typed Go records + KV cache; collections + realtime + replicate-to-S3; SQLite (no Postgres) |
| Storage | [`hanzoai/orm`](https://github.com/hanzoai/orm) (embedded SQLite via hanzoai/sqlite) | Typed Go records + KV cache; typed Go records + KV cache; embedded SQLite (no Postgres), ZAP backends pluggable |
| Authorization | [`hanzoai/authz`](https://github.com/hanzoai/authz) | One canonical policy engine, called over ZAP RPC |
| OIDC / OAuth2 | in-tree | ML-DSA-65 hybrid JWT; no external OIDC library |
| Inter-service | `luxfi/zap` | Binary RPC. HTTPS is the external surface only |
| Inter-service | `zap-proto` | Binary RPC. HTTPS is the external surface only |
## Status
Phase 0. The binary boots Base, registers the v2 collection schema, serves
`/v1/iam/v2/health`, and ships a read-only drift CLI. Cutover off `hanzoai/iam`
is gated on `iam2 compare` reading **drift = 0** against a v1 replica.
OAuth2/OIDC core is live and tested (login → PKCE code → token → JWT): OIDC
discovery + JWKS, get-app-login + auth/methods, credential login (bcrypt,
email/username), the token endpoint (RS256 JWT), and init_data bootstrap that
seeds the real config (79 apps / 9 orgs). Embeddable via `server.Mount`. Builds
on Hanzo CI (`ghcr.io/hanzoai/iam2`).
See [MIGRATION.md](./MIGRATION.md) for the full phased plan.
See [MIGRATION.md](./MIGRATION.md) for the phased plan.
## Build & run
```sh
go build ./...
go run . serve # Base + v2 schema + /v1/iam/v2/health
go run . serve --init-data init_data.json # seed config + serve OIDC/login
go run . compare --legacy postgres://…/iam # read-only v1 ↔ v2 drift report
```
+311
View File
@@ -0,0 +1,311 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package main
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"os"
"path/filepath"
"testing"
"github.com/hanzoai/sqlcipher"
hsqlite "github.com/hanzoai/sqlite"
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/store"
)
// sqlcipherInteropKey is the fixed raw key testdata/c-4.5.6.db was written under
// by the real C libsqlcipher 4.5.6 (see testdata/README.txt). The test decrypts
// that vector ONLY to obtain a reserved-page (header byte 20 == 80) plaintext
// canvas, which pure Go cannot originate; the canvas's own schema is discarded.
var sqlcipherInteropKey = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return b
}
// reservedCanvas returns a fresh reserved-page PLAINTEXT SQLite database: the
// DecryptFile output of the C-written interop vector. modernc preserves its
// 80-byte reserve on write, so seeding a schema into it and re-EncryptFile'ing
// yields an encrypted shard the test fully controls — exercising the real
// DeriveKey→UnwrapDEK→DecryptFile decrypt chain without any prod data.
func reservedCanvas(t *testing.T) []byte {
t.Helper()
enc, err := os.ReadFile(filepath.Join("testdata", "c-4.5.6.db"))
if err != nil {
t.Fatalf("read canvas fixture: %v", err)
}
var plain bytes.Buffer
if err := sqlcipher.DecryptFile(&plain, bytes.NewReader(enc), sqlcipher.RawKey(sqlcipherInteropKey), sqlcipher.Params{}); err != nil {
t.Fatalf("decrypt canvas fixture: %v", err)
}
if b := plain.Bytes(); len(b) < 21 || b[20] != sqlcipher.Reserve {
t.Fatalf("canvas is not reserved (header byte 20 = %d, want %d)", plain.Bytes()[20], sqlcipher.Reserve)
}
return plain.Bytes()
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
// dropAllTables clears the canvas's inherited schema so the test starts clean.
func dropAllTables(t *testing.T, db *sql.DB) {
t.Helper()
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
t.Fatalf("list canvas tables: %v", err)
}
var names []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
t.Fatalf("scan table name: %v", err)
}
names = append(names, n)
}
rows.Close()
for _, n := range names {
mustExec(t, db, `DROP TABLE IF EXISTS "`+n+`"`)
}
}
// writeEncryptedShard produces one encrypted, envelope-wrapped shard at dbPath
// (+ dbPath+".dek"): seed a schema into a reserved canvas, EncryptFile it under a
// fresh random DEK, then WRAP that DEK under the KEK derived from (master, pt,
// pid) — the exact inverse of decryptToTemp, so migrate-v1's encrypted path reads
// it back byte-for-byte.
func writeEncryptedShard(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string, seed func(*sql.DB)) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dbPath, reservedCanvas(t), 0o600); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", "file:"+dbPath)
if err != nil {
t.Fatalf("open canvas: %v", err)
}
dropAllTables(t, db)
seed(db)
if err := db.Close(); err != nil {
t.Fatalf("close canvas: %v", err)
}
plain, err := os.ReadFile(dbPath)
if err != nil {
t.Fatal(err)
}
if plain[20] != sqlcipher.Reserve {
t.Fatalf("modernc dropped the reserve (byte 20 = %d): cannot EncryptFile", plain[20])
}
dek := make([]byte, 32)
if _, err := rand.Read(dek); err != nil {
t.Fatal(err)
}
var enc bytes.Buffer
if err := sqlcipher.EncryptFile(&enc, bytes.NewReader(plain), sqlcipher.RawKey(dek), nil, sqlcipher.Params{}); err != nil {
t.Fatalf("encrypt shard: %v", err)
}
if err := os.WriteFile(dbPath, enc.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
kek, err := hsqlite.DeriveKey(master, pt, pid)
if err != nil {
t.Fatalf("derive KEK: %v", err)
}
wrapped, err := hsqlite.WrapDEK(kek, dek, hsqlite.PrincipalAAD(pt, pid))
if err != nil {
t.Fatalf("wrap DEK: %v", err)
}
if err := os.WriteFile(dbPath+".dek", wrapped, 0o600); err != nil {
t.Fatal(err)
}
}
// buildEncryptedDatadir lays out a sharded encrypted source: a GLOBAL shard
// (two orgs + a cert) and two PER-ORG shards (hanzo/z, acme/root), each user
// carrying the golden argon2id digest. It returns the datadir and the cert's
// PEM material so the caller can assert verbatim key survival.
func buildEncryptedDatadir(t *testing.T, master []byte) (datadir, certPEM, keyPEM string) {
t.Helper()
datadir = t.TempDir()
certPEM, keyPEM = genRSAPEM(t)
// GLOBAL shard: orgs + cert, principal (global, "iam").
writeEncryptedShard(t, filepath.Join(datadir, "iam.db"), master, hsqlite.PrincipalGlobal, globalPrincipalID, func(db *sql.DB) {
mustExec(t, db, `CREATE TABLE "organization"(owner text, name text, created_time text, display_name text, password_type text, init_score integer)`)
mustExec(t, db, `INSERT INTO "organization" VALUES(?,?,?,?,?,?)`, "admin", "hanzo", "2020-01-02T03:04:05Z", "Hanzo", "argon2id", 100)
mustExec(t, db, `INSERT INTO "organization" VALUES(?,?,?,?,?,?)`, "admin", "acme", "2020-01-03T03:04:05Z", "Acme", "argon2id", 0)
mustExec(t, db, `CREATE TABLE "cert"(owner text, name text, created_time text, type text, crypto_algorithm text, bit_size integer, certificate text, private_key text)`)
mustExec(t, db, `INSERT INTO "cert" VALUES(?,?,?,?,?,?,?,?)`, "admin", "cert-hanzo", "2020-01-02T03:04:05Z", "x509", "RS256", 2048, certPEM, keyPEM)
})
// PER-ORG shard hanzo: user z (own argon2id type), principal (org, "hanzo").
writeEncryptedShard(t, filepath.Join(datadir, "orgs", "hanzo", "iam.db"), master, hsqlite.PrincipalOrg, "hanzo", func(db *sql.DB) {
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, password_salt text, email text, display_name text, is_admin integer)`)
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?,?,?,?,?,?)`,
"hanzo", "z", "2020-01-02T03:04:05Z", "uuid-0001", goldenDigest, "argon2id", "the-salt", "z@hanzo.ai", "Z", 1)
})
// PER-ORG shard acme: user root, principal (org, "acme") — proves shard MERGE
// and per-org KEK isolation (its DEK is wrapped under acme's KEK, not hanzo's).
writeEncryptedShard(t, filepath.Join(datadir, "orgs", "acme", "iam.db"), master, hsqlite.PrincipalOrg, "acme", func(db *sql.DB) {
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, password_salt text, email text, display_name text, is_admin integer)`)
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?,?,?,?,?,?)`,
"acme", "root", "2020-01-02T03:04:05Z", "uuid-0002", goldenDigest, "argon2id", "salt2", "root@acme.io", "Root", 1)
})
return datadir, certPEM, keyPEM
}
// TestEncryptedSource_GoldenChain is the end-to-end credential-parity proof for
// the ENCRYPTED, SHARDED source path: DeriveKey → UnwrapDEK → DecryptFile →
// Migrate → cred.Verify, across a global shard and two org shards, with the
// golden argon2id digest verifying under the clean verifier after it entered the
// clean store ONLY by being decrypted from an encrypted shard. It also proves the
// per-shard decrypted temps are shredded.
func TestEncryptedSource_GoldenChain(t *testing.T) {
ctx := context.Background()
master := make([]byte, 32)
if _, err := rand.Read(master); err != nil {
t.Fatal(err)
}
datadir, _, keyPEM := buildEncryptedDatadir(t, master)
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
workDir := t.TempDir()
dest := t.TempDir()
if err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, walMode{}); err != nil {
t.Fatalf("runEncrypted: %v", err)
}
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
if err != nil {
t.Fatalf("reopen dest: %v", err)
}
defer dst.Close()
// ---- Global shard: both orgs + the cert's signing key landed. ----
org, err := store.GetOrganizationByName(ctx, dst, "hanzo")
if err != nil || org == nil {
t.Fatalf("org hanzo not migrated from global shard: %v", err)
}
if org.PasswordType != "argon2id" {
t.Errorf("org.PasswordType = %q, want argon2id", org.PasswordType)
}
if acme, err := store.GetOrganizationByName(ctx, dst, "acme"); err != nil || acme == nil {
t.Fatalf("org acme not migrated from global shard: %v", err)
}
cert, err := store.GetCert(ctx, dst, "admin", "cert-hanzo")
if err != nil || cert == nil {
t.Fatalf("cert not migrated from global shard: %v", err)
}
if cert.PrivateKey != keyPEM {
t.Fatalf("cert.PrivateKey NOT verbatim through encrypt→decrypt→migrate:\n got %q\nwant %q", cert.PrivateKey, keyPEM)
}
// ---- Org shard hanzo: THE non-negotiable golden argon2id verify. ----
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
if err != nil || u == nil {
t.Fatalf("user hanzo/z not migrated from org shard: %v", err)
}
if u.PasswordHash != goldenDigest {
t.Fatalf("user.PasswordHash NOT verbatim:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
}
typ := cred.Resolve(u.PasswordType, org.PasswordType)
if !cred.Verify(typ, goldenPassword, u.PasswordHash) {
t.Fatal("cred.Verify REJECTED the argon2id hash decrypted from the encrypted shard — login would fail at cutover")
}
if cred.Verify(typ, "wrong-password", u.PasswordHash) {
t.Fatal("cred.Verify ACCEPTED a wrong password against the decrypted hash")
}
// ---- Org shard acme MERGED into the same store; its user verifies too. ----
root, err := store.GetUserByName(ctx, dst, "acme", "root")
if err != nil || root == nil {
t.Fatalf("user acme/root not migrated (shards did not merge): %v", err)
}
if !cred.Verify(cred.Resolve(root.PasswordType, "argon2id"), goldenPassword, root.PasswordHash) {
t.Fatal("cred.Verify REJECTED the acme user's decrypted hash")
}
// ---- Every decrypted temp was shredded: the work-dir is empty. ----
left, err := os.ReadDir(workDir)
if err != nil {
t.Fatal(err)
}
if len(left) != 0 {
t.Errorf("work-dir not clean after run — %d decrypted temp(s) left: %v", len(left), left)
}
}
// TestEncryptedSource_WrongMasterFailsLoud proves a wrong master key fails at
// UnwrapDEK and NEVER proceeds to write garbage: the run errors and the dest
// store is left empty.
func TestEncryptedSource_WrongMasterFailsLoud(t *testing.T) {
ctx := context.Background()
master := make([]byte, 32)
if _, err := rand.Read(master); err != nil {
t.Fatal(err)
}
datadir, _, _ := buildEncryptedDatadir(t, master)
// A different, valid-shaped key — must not unwrap any shard's DEK.
wrong := make([]byte, 32)
wrong[0] = master[0] ^ 0xff
copy(wrong[1:], master[1:])
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(wrong))
dest := t.TempDir()
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", t.TempDir(), dest, false, nil, walMode{})
if err == nil {
t.Fatal("wrong master key must fail loudly, got nil error")
}
// Nothing was written: the dest store has no users.
dst, oerr := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
if oerr != nil {
t.Fatalf("reopen dest: %v", oerr)
}
defer dst.Close()
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u != nil {
t.Fatal("a wrong master key still wrote a user — must abort before any write")
}
}
// TestEncryptedSource_BadMasterKeyEnv rejects a malformed key env WITHOUT ever
// echoing the value.
func TestEncryptedSource_BadMasterKeyEnv(t *testing.T) {
for _, tc := range []struct{ name, val string }{
{"empty", ""},
{"not-hex", "zznothex"},
{"short", "00112233"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", tc.val)
if _, err := loadMasterKey("MIGRATE_V1_TEST_MASTER_KEY"); err == nil {
t.Fatalf("%s master key must be rejected", tc.name)
}
})
}
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// This file adds the ENCRYPTED, SHARDED source path to migrate-v1. Production
// IAM stores its identity data as SQLCipher-encrypted SQLite with envelope
// encryption, sharded:
//
// <datadir>/iam.db GLOBAL shard: certs, applications, organizations
// <datadir>/iam.db.dek wrapped-DEK sidecar for the global shard
// <datadir>/orgs/<slug>/iam.db PER-ORG shard: that org's users/apps/...
// <datadir>/orgs/<slug>/iam.db.dek wrapped-DEK sidecar for the org shard
//
// Each shard is decrypted to a 0600 temp with the SAME pure-Go recipe:
//
// 1. kek = sqlite.DeriveKey(master, principalType, principalID) (HKDF-SHA256)
// 2. blob = read <db>.dek (wrapped DEK)
// 3. dek = sqlite.UnwrapDEK(kek, blob, sqlite.PrincipalAAD(...)) (AES-256-GCM)
// 4. sqlcipher.DecryptFile(tmp, db, sqlcipher.RawKey(dek), {}) (page codec)
//
// then fed to the EXISTING Migrate engine (opened read-only with modernc) and
// SHREDDED. Shards merge into one --dest store because Migrate upserts by
// natural key (owner/name) and is idempotent.
//
// DRIVER-COLLISION RESOLUTION (why this is one binary, no os/exec helper):
// DeriveKey, UnwrapDEK, PrincipalAAD and the PrincipalGlobal/PrincipalOrg consts
// are PURE functions in the ROOT github.com/hanzoai/sqlite package — the SAME
// package migrate.go already imports (blank) for the "sqlite" database/sql
// driver. Promoting that dependency to a named import here registers NO new
// driver: under CGO_ENABLED=0 hanzoai/sqlite's !cgo backend and orm's store both
// route through modernc's SINGLE sql.Register("sqlite", …), so there is exactly
// one registrant and no "Register called twice" panic. github.com/hanzoai/sqlcipher
// registers no database/sql driver at all (it is a pure page/file codec). So the
// decrypt and the plaintext read coexist in one process with zero collision, and
// the crypto stays in hanzoai/sqlite — never re-implemented here.
package main
import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/hanzoai/orm"
"github.com/hanzoai/sqlcipher"
hsqlite "github.com/hanzoai/sqlite"
"github.com/hanzoai/iam/internal/store"
)
// globalPrincipalID is the KEK principal id for the cross-org GLOBAL shard
// (certs, applications, organizations). It MUST byte-match the fork so a DEK
// wrapped under the production master key unwraps here: the Casdoor fork pins it
// as a const in object/ormer.go — `const globalPrincipalID = "iam"`. A drift
// here silently fails UnwrapDEK on the global shard, so it is duplicated as a
// deliberate, documented constant rather than imported (the fork is not a dep).
const globalPrincipalID = "iam"
// encShard is one encrypted SQLite shard to decrypt and migrate: its on-disk
// path (and implied `<path>.dek` sidecar) plus the (principalType, principalID)
// whose KEK wraps its DEK.
type encShard struct {
label string // human label for the per-shard report ("global", "org:<slug>")
path string // the encrypted db; its wrapped-DEK sidecar is path + ".dek"
pt hsqlite.PrincipalType
pid string
}
// runEncrypted migrates the sharded ENCRYPTED source at datadir into the clean
// --dest store: global shard first (orgs/certs/apps), then every orgs/<slug>
// shard (users), each decrypted to a shredded temp and merged by upsert. It is
// the encrypted-source sibling of run() and shares the exact same Migrate engine
// and store-open path, so an encrypted cutover and a plaintext one produce a
// byte-identical clean store.
//
// The wal mode selects HOW each shard is turned into a plaintext temp: the
// default DecryptFile path decrypts only the CHECKPOINTED main-db image (fast,
// pure-Go, but blind to rows still in the shard's uncheckpointed -wal), while
// wal.enabled drives the C sqlcipher binary to checkpoint each shard's WAL into
// the plaintext copy first — the COMPLETE extraction a real cutover needs.
func runEncrypted(ctx context.Context, datadir, keyEnv, workDir, dest string, dryRun bool, only []string, wal walMode) error {
// Fail before touching any shard if --wal-inclusive was requested but the C
// sqlcipher binary is missing — never silently fall back to the checkpointed
// (WAL-blind) path.
if err := preflightWAL(wal); err != nil {
return err
}
master, err := loadMasterKey(keyEnv)
if err != nil {
return err
}
defer zero(master) // scrub the master key from memory when done
shards, err := discoverShards(datadir)
if err != nil {
return err
}
// The default (checkpointed) path reads ONLY each shard's checkpointed main db
// and is blind to rows still in an uncheckpointed -wal. Running it against a
// shard that carries a non-empty -wal would SILENTLY drop those (most-recent)
// rows and still report success — the exact silent-data-loss default RED
// flagged. Refuse, fail-closed, unless the operator opts into capturing the WAL
// (--wal-inclusive) or explicitly into dropping it (--ignore-wal). Checked
// before the dest store is even opened, so a refusal writes nothing.
if !wal.enabled {
if err := guardCheckpointedWAL(shards, wal.ignoreWAL); err != nil {
return err
}
}
dst, err := store.Open("sqlite", storePath(dest))
if err != nil {
return fmt.Errorf("open clean store: %w", err)
}
defer dst.Close()
extraction := "checkpointed MAIN db only (guarded: refuses a non-empty uncheckpointed -wal unless --ignore-wal)"
switch {
case wal.enabled:
extraction = "WAL-INCLUSIVE — each shard's -wal is checkpointed into the plaintext copy via C sqlcipher before migrating"
case wal.ignoreWAL:
extraction = "checkpointed MAIN db only, --ignore-wal — any uncheckpointed -wal rows are INTENTIONALLY DROPPED"
}
fmt.Fprintf(os.Stdout,
"migrate-v1: encrypted source %q — %d shard(s); extraction: %s.\n",
datadir, len(shards), extraction)
for _, sh := range shards {
results, err := migrateEncryptedShard(ctx, sh, master, workDir, dst, dryRun, only, wal)
if err != nil {
return fmt.Errorf("shard %s (%s): %w", sh.label, sh.path, err)
}
fmt.Fprintf(os.Stdout, "\n=== shard %s (%s) ===", sh.label, sh.path)
printReport(os.Stdout, results, dryRun)
}
return nil
}
// loadMasterKey reads the 64-hex KMS master key from the NAMED env var and
// decodes it to the 32 raw bytes DeriveKey expects. The key value is NEVER
// echoed — not in an error, not in a log — only its length is ever reported.
func loadMasterKey(env string) ([]byte, error) {
raw := strings.TrimSpace(os.Getenv(env))
if raw == "" {
return nil, fmt.Errorf("master key: env %s is empty (expected 64 hex chars)", env)
}
key, err := hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("master key from %s: not valid hex", env) // never print the value
}
if len(key) != 32 {
return nil, fmt.Errorf("master key from %s: must be 32 bytes (64 hex chars), got %d", env, len(key))
}
return key, nil
}
// discoverShards enumerates the encrypted shards in dependency order: the global
// db first, then every orgs/<slug>/iam.db (slugs sorted for deterministic runs).
// A missing global db is fatal (the layout root is wrong); a missing orgs/ dir is
// fine (global-only datadir); an org dir without an iam.db is skipped, not fatal.
func discoverShards(datadir string) ([]encShard, error) {
global := filepath.Join(datadir, "iam.db")
if _, err := os.Stat(global); err != nil {
return nil, fmt.Errorf("global shard %s: %w", global, err)
}
shards := []encShard{{label: "global", path: global, pt: hsqlite.PrincipalGlobal, pid: globalPrincipalID}}
orgsDir := filepath.Join(datadir, "orgs")
entries, err := os.ReadDir(orgsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return shards, nil // global-only layout is valid
}
return nil, fmt.Errorf("read orgs dir %s: %w", orgsDir, err)
}
slugs := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
slugs = append(slugs, e.Name())
}
}
sort.Strings(slugs)
for _, slug := range slugs {
p := filepath.Join(orgsDir, slug, "iam.db")
if _, err := os.Stat(p); err != nil {
continue // an org dir carrying no iam.db is not a shard
}
shards = append(shards, encShard{label: "org:" + slug, path: p, pt: hsqlite.PrincipalOrg, pid: slug})
}
return shards, nil
}
// guardCheckpointedWAL is the fail-closed gate for the default (checkpointed)
// extraction. That path reads only a shard's checkpointed main db, so any shard
// carrying a non-empty uncheckpointed -wal would have those rows SILENTLY dropped.
// It stats every shard's <path>-wal and, if any is non-empty, either aborts with an
// actionable error (the default — never silently lose data) or, when the operator
// passed --ignore-wal, proceeds after LOUDLY warning exactly which shards' rows are
// being dropped. An absent or empty -wal is clean (nothing to lose).
func guardCheckpointedWAL(shards []encShard, ignore bool) error {
var dirty []string
for _, sh := range shards {
if fi, err := os.Stat(sh.path + "-wal"); err == nil && fi.Size() > 0 {
dirty = append(dirty, fmt.Sprintf("%s (%s-wal: %d bytes)", sh.label, sh.path, fi.Size()))
}
}
if len(dirty) == 0 {
return nil
}
if ignore {
fmt.Fprintf(os.Stderr,
"migrate-v1: WARNING --ignore-wal: %d shard(s) carry a non-empty uncheckpointed -wal whose rows will NOT be migrated (intentionally dropped): %s\n",
len(dirty), strings.Join(dirty, "; "))
return nil
}
return fmt.Errorf(
"%d shard(s) carry a non-empty uncheckpointed -wal that the default checkpointed path would SILENTLY DROP: %s; re-run with --wal-inclusive to migrate those rows, or --ignore-wal to intentionally drop them",
len(dirty), strings.Join(dirty, "; "))
}
// migrateEncryptedShard turns one shard into a shredded plaintext temp and runs
// the Migrate engine over it. Which extraction is used depends on wal: the
// default DecryptFile path (checkpointed main db only) or the WAL-inclusive C
// sqlcipher path (checkpoints -wal first). Either way the plaintext temp holds
// credential material, so it is shredded on EVERY path including error (deferred
// immediately after creation), and both converge on the SAME read-only modernc
// open + Migrate engine.
func migrateEncryptedShard(ctx context.Context, sh encShard, master []byte, workDir string, dst orm.DB, dryRun bool, only []string, wal walMode) ([]*EntityResult, error) {
var (
srcPath string
cleanup func()
)
if wal.enabled {
plainPath, tmpDir, err := checkpointShardToPlaintext(sh, master, workDir, wal.bin)
if err != nil {
return nil, err
}
srcPath, cleanup = plainPath, func() { shredDir(tmpDir) }
} else {
tmp, err := decryptToTemp(sh, master, workDir)
if err != nil {
return nil, err
}
srcPath, cleanup = tmp, func() { shred(tmp) }
}
defer cleanup()
src, err := openLegacy(srcPath) // read-only modernc open — the SAME reader the plaintext path uses
if err != nil {
return nil, err
}
defer src.Close()
if err := src.PingContext(ctx); err != nil {
return nil, fmt.Errorf("open decrypted shard read-only: %w", err)
}
return Migrate(ctx, src, dst, only, options{dryRun: dryRun})
}
// deriveDEK runs the pure-Go envelope recipe that recovers a shard's 32-byte
// SQLCipher DEK from the KMS master key: derive the shard's KEK, read its wrapped
// -DEK sidecar, unwrap it under the principal-binding AAD. It is the SINGLE place
// the DEK is computed — BOTH the checkpointed DecryptFile path and the
// WAL-inclusive C-sqlcipher path call it, so the key is never re-derived two ways
// that could drift. A WRONG master key fails LOUDLY here at UnwrapDEK (the
// AES-256-GCM auth tag rejects a KEK derived from garbage). The caller owns the
// returned DEK and MUST zero it; deriveDEK never logs the key or the DEK.
func deriveDEK(sh encShard, master []byte) ([]byte, error) {
kek, err := hsqlite.DeriveKey(master, sh.pt, sh.pid)
if err != nil {
return nil, fmt.Errorf("derive KEK: %w", err)
}
defer zero(kek)
wrapped, err := os.ReadFile(sh.path + ".dek")
if err != nil {
return nil, fmt.Errorf("read wrapped-DEK sidecar %s.dek: %w", sh.path, err)
}
dek, err := hsqlite.UnwrapDEK(kek, wrapped, hsqlite.PrincipalAAD(sh.pt, sh.pid))
if err != nil {
return nil, fmt.Errorf("unwrap DEK (wrong master key, or corrupt/foreign sidecar): %w", err)
}
return dek, nil
}
// decryptToTemp runs the (checkpointed) envelope-decrypt recipe for one shard and
// writes the plaintext SQLite bytes to a fresh 0600 temp under workDir (OS temp
// when empty), returning the temp path. A wrong DEK or a corrupt page fails at
// DecryptFile (per-page HMAC → sqlcipher.ErrKey). It never returns a temp on
// error, and never logs the key or the DEK. NOTE: this reads only the shard's
// CHECKPOINTED main db — rows in its uncheckpointed -wal are invisible; the
// WAL-inclusive path (checkpointShardToPlaintext) is the complete extraction.
func decryptToTemp(sh encShard, master []byte, workDir string) (string, error) {
dek, err := deriveDEK(sh, master)
if err != nil {
return "", err
}
defer zero(dek)
in, err := os.Open(sh.path)
if err != nil {
return "", err
}
defer in.Close()
out, err := os.CreateTemp(workDir, "iam-migrate-*.db") // 0600
if err != nil {
return "", fmt.Errorf("create work temp: %w", err)
}
tmp := out.Name()
// sqlcipher.Params{} = SQLCipher 4 defaults (4096-byte pages), which the CGO
// production backend writes. DecryptFile reads the salt from page 1 of the
// source and emits a plaintext db any SQLite build (here: modernc) can open.
if err := sqlcipher.DecryptFile(out, in, sqlcipher.RawKey(dek), sqlcipher.Params{}); err != nil {
out.Close()
shred(tmp)
return "", fmt.Errorf("decrypt shard: %w", err)
}
if err := out.Close(); err != nil {
shred(tmp)
return "", err
}
return tmp, nil
}
// zero scrubs key material from a byte slice.
func zero(b []byte) {
for i := range b {
b[i] = 0
}
}
// shred overwrites a decrypted temp with zeros and removes it. The temp holds
// plaintext credential material (password digests, signing keys), so it must not
// survive the run. Best-effort by design: a stat/open failure still attempts the
// remove, so a shred never blocks the migration.
func shred(path string) {
if fi, err := os.Stat(path); err == nil && fi.Size() > 0 {
if f, err := os.OpenFile(path, os.O_WRONLY, 0o600); err == nil {
buf := make([]byte, 32*1024)
remaining := fi.Size()
for remaining > 0 {
n := int64(len(buf))
if n > remaining {
n = remaining
}
if _, werr := f.Write(buf[:n]); werr != nil {
break
}
remaining -= n
}
_ = f.Sync()
_ = f.Close()
}
}
_ = os.Remove(path)
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Command migrate-v1 is the Phase-5 cutover migrator: it reads the legacy
// Casdoor-fork identity store (a SQLite iam.db) and writes every identity record
// into the clean-room IAM v2 store, PRESERVING credentials and signing keys
// byte-for-byte. A wrong password hash locks a user out; a wrong signing cert
// breaks every live token and the JWKS — so correctness, not cleverness, is the
// whole job.
//
// Usage:
//
// migrate-v1 --src /path/to/legacy/iam.db --dest /path/to/clean/data-dir \
// [--dry-run] [--only users,orgs,apps,certs,providers]
//
// The source is opened READ-ONLY. The destination is opened through the exact
// store-open path the server uses (store.Open), so the migrated store is
// byte-for-byte the store the server will serve. Every entity is UPSERTed by its
// natural key (owner/name): the tool is idempotent — re-running is a no-op, and
// --dry-run reports counts + a redacted sample without writing.
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"text/tabwriter"
_ "github.com/hanzoai/iam/internal/schema" // registers the v2 entity kinds
"github.com/hanzoai/iam/internal/store"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
fs := flag.NewFlagSet("migrate-v1", flag.ContinueOnError)
var (
srcPath = fs.String("src", "", "path to a PLAINTEXT legacy Casdoor SQLite iam.db (opened read-only); mutually exclusive with --src-datadir")
srcDatadir = fs.String("src-datadir", "", "root of the ENCRYPTED sharded source (<dir>/iam.db + <dir>/orgs/*/iam.db, each with a .dek sidecar); mutually exclusive with --src")
masterKeyEnv = fs.String("src-master-key-env", "IAM_KMS_MASTER_KEY", "NAME of the env var holding the 64-hex KMS master key (read for --src-datadir; never taken as an arg or logged)")
workDir = fs.String("work-dir", "", "directory for decrypted temp files (default: OS temp); each is created 0600 and shredded after use")
dest = fs.String("dest", "", "clean IAM v2 data-dir (the store is <dest>/iam2.db) or a .db path")
dryRun = fs.Bool("dry-run", false, "count + sample per entity without writing")
only = fs.String("only", "", "comma list of entities: users,orgs,apps,certs,providers,roles,permissions (default all)")
walInclusive = fs.Bool("wal-inclusive", false, "encrypted source only: checkpoint each shard's uncheckpointed -wal into the plaintext copy via the C sqlcipher binary before migrating (COMPLETE extraction; default reads only the checkpointed main db and misses WAL rows)")
ignoreWAL = fs.Bool("ignore-wal", false, "encrypted source only: in the DEFAULT (checkpointed) path, proceed even when a shard carries a non-empty uncheckpointed -wal, INTENTIONALLY dropping those rows (mutually exclusive with --wal-inclusive; without either, a non-empty -wal is a hard error)")
sqlcipherBin = fs.String("sqlcipher-bin", "sqlcipher", "path to (or name on PATH of) the C sqlcipher binary used by --wal-inclusive")
)
if err := fs.Parse(os.Args[1:]); err != nil {
os.Exit(2)
}
switch {
case *srcPath != "" && *srcDatadir != "":
fmt.Fprintln(os.Stderr, "migrate-v1: --src and --src-datadir are mutually exclusive")
fs.Usage()
os.Exit(2)
case *srcPath == "" && *srcDatadir == "":
fmt.Fprintln(os.Stderr, "migrate-v1: one of --src (plaintext) or --src-datadir (encrypted sharded) is required")
fs.Usage()
os.Exit(2)
case *dest == "":
fmt.Fprintln(os.Stderr, "migrate-v1: --dest is required")
fs.Usage()
os.Exit(2)
case *walInclusive && *srcDatadir == "":
fmt.Fprintln(os.Stderr, "migrate-v1: --wal-inclusive applies only to the encrypted sharded source (--src-datadir)")
fs.Usage()
os.Exit(2)
case *ignoreWAL && *srcDatadir == "":
fmt.Fprintln(os.Stderr, "migrate-v1: --ignore-wal applies only to the encrypted sharded source (--src-datadir)")
fs.Usage()
os.Exit(2)
case *walInclusive && *ignoreWAL:
fmt.Fprintln(os.Stderr, "migrate-v1: --wal-inclusive (capture the WAL) and --ignore-wal (drop the WAL) are mutually exclusive")
fs.Usage()
os.Exit(2)
}
var err error
if *srcDatadir != "" {
wal := walMode{enabled: *walInclusive, ignoreWAL: *ignoreWAL, bin: *sqlcipherBin}
err = runEncrypted(ctx, *srcDatadir, *masterKeyEnv, *workDir, *dest, *dryRun, splitOnly(*only), wal)
} else {
err = run(ctx, *srcPath, *dest, *dryRun, splitOnly(*only))
}
if err != nil {
fmt.Fprintf(os.Stderr, "migrate-v1: %v\n", err)
os.Exit(1)
}
}
func run(ctx context.Context, srcPath, dest string, dryRun bool, only []string) error {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("source iam.db: %w", err)
}
src, err := openLegacy(srcPath)
if err != nil {
return err
}
defer src.Close()
if err := src.PingContext(ctx); err != nil {
return fmt.Errorf("open legacy iam.db read-only: %w", err)
}
dst, err := store.Open("sqlite", storePath(dest))
if err != nil {
return fmt.Errorf("open clean store: %w", err)
}
defer dst.Close()
results, err := Migrate(ctx, src, dst, only, options{dryRun: dryRun})
if err != nil {
return err
}
printReport(os.Stdout, results, dryRun)
return nil
}
// openLegacy opens the legacy iam.db strictly read-only via a file: URI, so the
// migrator can never mutate the source (and can run against a live-ish copy).
func openLegacy(path string) (*sql.DB, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+abs+"?mode=ro")
if err != nil {
return nil, fmt.Errorf("open legacy iam.db: %w", err)
}
return db, nil
}
// storePath maps the --dest data-dir to the SQLite file the server uses
// (<dest>/iam2.db), or takes dest verbatim when it already names a .db file.
func storePath(dest string) string {
if strings.HasSuffix(dest, ".db") {
return dest
}
return filepath.Join(dest, "iam2.db")
}
// splitOnly parses the comma-separated --only value.
func splitOnly(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// printReport writes the per-entity summary: rows read vs written, skips with
// reasons, and legacy columns that had no clean-schema home (the pre-flip gap
// list). In --dry-run it also prints the redacted sample per entity.
func printReport(w *os.File, results []*EntityResult, dryRun bool) {
mode := "MIGRATE"
if dryRun {
mode = "DRY-RUN (no writes)"
}
fmt.Fprintf(w, "\niam2 migrate-v1 — %s\n\n", mode)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "entity\tlegacy_table\tread\tcreated\tupdated\tunchanged\tskipped")
var tr, tc, tu, tun, ts int
for _, r := range results {
table := r.Table
if r.TableMissing {
table = "(missing)"
}
fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\n",
r.Entity, table, r.Read, r.Created, r.Updated, r.Unchanged, r.Skipped)
tr, tc, tu, tun, ts = tr+r.Read, tc+r.Created, tu+r.Updated, tun+r.Unchanged, ts+r.Skipped
}
fmt.Fprintf(tw, "TOTAL\t\t%d\t%d\t%d\t%d\t%d\n", tr, tc, tu, tun, ts)
tw.Flush()
for _, r := range results {
if len(r.Reasons) > 0 {
fmt.Fprintf(w, "\n%s notes:\n", r.Entity)
for reason, n := range r.Reasons {
fmt.Fprintf(w, " %-28s %d\n", reason, n)
}
}
if len(r.UnmappedCols) > 0 {
fmt.Fprintf(w, "\n%s legacy columns with no clean-schema field (not migrated):\n %s\n",
r.Entity, strings.Join(r.UnmappedCols, ", "))
}
if dryRun && r.Sample != "" {
fmt.Fprintf(w, "\n%s sample (redacted):\n%s\n", r.Entity, r.Sample)
}
}
fmt.Fprintln(w)
}
+621
View File
@@ -0,0 +1,621 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"unicode"
"github.com/hanzoai/orm"
// Registers the "sqlite" database/sql driver name — the SAME package orm's
// store routes through, so importing it here is a no-op second reference,
// never a second sql.Register (which would panic). Under CGO_ENABLED=0 the
// registration is modernc's; the source iam.db is opened read-only with it.
_ "github.com/hanzoai/sqlite"
"github.com/hanzoai/iam/internal/schema"
)
// EntityResult is the per-entity outcome of a migration pass.
type EntityResult struct {
Entity string // canonical entity name (e.g. "users")
Table string // resolved legacy table name (empty if missing)
TableMissing bool // the legacy DB has no table for this entity
Read int // rows read from the legacy table
Created int // rows created in the clean store
Updated int // rows whose clean row differed and was overwritten
Unchanged int // rows already byte-identical (idempotent no-op)
Skipped int // rows skipped (see Reasons)
Reasons map[string]int // reason -> count (skips, coercions, defaults)
UnmappedCols []string // legacy columns with no clean-schema field
Sample string // a redacted sample row (dry-run only)
}
// options controls a migration pass.
type options struct {
dryRun bool
}
// entitySpec binds a clean entity type to its legacy table(s) and the two
// name-mismatch escape hatches: colAliases (a clean field fed by a differently
// named legacy column) and sensitive (fields masked in any printed sample).
// selectors are the --only names that pick this spec.
type entitySpec struct {
name string
selectors []string
tables []string
colAliases map[string][]string
sensitive map[string]bool
run func(context.Context, *sql.DB, orm.DB, entitySpec, options) (*EntityResult, error)
}
// specs is the ordered entity registry. Order is dependency order:
// organizations own everything; certs sign the tokens applications mint;
// applications reference certs; providers are linked by applications; users
// live under organizations; roles/permissions reference users. A downstream
// entity is never migrated before the entity it points at.
func specs() []entitySpec {
return []entitySpec{
{
name: "organizations",
selectors: []string{"organizations", "organization", "orgs", "org"},
tables: []string{"organization", "organizations"},
sensitive: set("passwordSalt", "masterPassword", "defaultPassword",
"masterVerificationCode", "passwordObfuscatorKey", "kerberosKeytab"),
run: runner[schema.Organization](),
},
{
name: "certs",
selectors: []string{"certs", "cert", "certificates"},
tables: []string{"cert", "certs"},
// PrivateKey + AccessSecret are the JWKS signing material and ACME
// credential — copied verbatim, never printed.
sensitive: set("privateKey", "accessSecret"),
run: runner[schema.Cert](),
},
{
name: "applications",
selectors: []string{"applications", "application", "apps", "app"},
tables: []string{"application", "applications"},
sensitive: set("clientSecret"),
run: runner[schema.Application](),
},
{
name: "providers",
selectors: []string{"providers", "provider"},
tables: []string{"provider", "providers"},
sensitive: set("clientSecret", "clientSecret2"),
run: runner[schema.Provider](),
},
{
name: "users",
selectors: []string{"users", "user"},
tables: []string{"user", "users"},
// THE credential-critical mapping: Casdoor stores the password DIGEST
// in a column literally named `password`; the clean schema renamed the
// field to PasswordHash (json "passwordHash"). Normalization can't bridge
// that rename, so it is declared explicitly. Miss this and every user's
// hash is dropped — 100% login failure at cutover.
colAliases: map[string][]string{"passwordHash": {"password"}},
sensitive: set("passwordHash", "passwordSalt", "accessSecret",
"accessSecretHash", "accessToken", "originalToken",
"originalRefreshToken", "totpSecret", "recoveryCodes"),
run: runner[schema.User](),
},
{
name: "roles",
selectors: []string{"roles", "role"},
tables: []string{"role", "roles"},
run: runner[schema.Role](),
},
{
name: "permissions",
selectors: []string{"permissions", "permission", "perms"},
tables: []string{"permission", "permissions"},
run: runner[schema.Permission](),
},
}
}
// runner binds a clean entity type T to the generic engine.
func runner[T any]() func(context.Context, *sql.DB, orm.DB, entitySpec, options) (*EntityResult, error) {
return func(ctx context.Context, src *sql.DB, dst orm.DB, spec entitySpec, opt options) (*EntityResult, error) {
return migrateEntity[T](ctx, src, dst, spec, opt)
}
}
// Migrate runs the selected entities (empty only == all) against dst in
// dependency order. It is the pure engine: callers open src/dst and print the
// results, so it is directly testable without touching the filesystem.
func Migrate(ctx context.Context, src *sql.DB, dst orm.DB, only []string, opt options) ([]*EntityResult, error) {
chosen, err := selectSpecs(only)
if err != nil {
return nil, err
}
out := make([]*EntityResult, 0, len(chosen))
for _, spec := range chosen {
res, err := spec.run(ctx, src, dst, spec, opt)
if err != nil {
return out, fmt.Errorf("migrate %s: %w", spec.name, err)
}
out = append(out, res)
}
return out, nil
}
// selectSpecs resolves the --only selectors to specs (in registry order). An
// unrecognized selector is an error — a typo must never silently skip an entity.
func selectSpecs(only []string) ([]entitySpec, error) {
all := specs()
if len(only) == 0 {
return all, nil
}
want := map[string]bool{}
for _, o := range only {
o = strings.ToLower(strings.TrimSpace(o))
if o != "" {
want[o] = true
}
}
matched := map[string]bool{}
var chosen []entitySpec
for _, spec := range all {
for _, sel := range spec.selectors {
if want[sel] {
chosen = append(chosen, spec)
matched[sel] = true
break
}
}
}
for sel := range want {
if !matched[sel] {
return nil, fmt.Errorf("unknown --only entity %q (valid: users, orgs, apps, certs, providers, roles, permissions)", sel)
}
}
return chosen, nil
}
// migrateEntity reads every row of the legacy table for T and upserts it into
// the clean store, mapping legacy columns to clean fields by normalized name
// (plus the spec's explicit column aliases). Credential and key material is
// copied verbatim — the row is reconstructed as JSON and unmarshaled into T, so
// bytes never pass through a lossy typed conversion.
func migrateEntity[T any](ctx context.Context, src *sql.DB, dst orm.DB, spec entitySpec, opt options) (*EntityResult, error) {
res := &EntityResult{Entity: spec.name, Reasons: map[string]int{}}
table, err := resolveTable(ctx, src, spec.tables)
if err != nil {
return nil, err
}
if table == "" {
res.TableMissing = true
return res, nil
}
res.Table = table
cols, err := tableColumns(ctx, src, table)
if err != nil {
return nil, fmt.Errorf("read columns of %q: %w", table, err)
}
fields := entityFields[T]()
matched, consumed := matchColumns(fields, cols, spec.colAliases)
for _, c := range cols {
if !consumed[c] {
res.UnmappedCols = append(res.UnmappedCols, c)
}
}
if !hasField(matched, "name") {
return nil, fmt.Errorf("legacy table %q has no column mapping to 'name' — cannot key rows", table)
}
quoted := make([]string, len(matched))
for i, f := range matched {
quoted[i] = quoteIdent(f.column)
}
query := "SELECT " + strings.Join(quoted, ", ") + " FROM " + quoteIdent(table)
rows, err := src.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("select %q: %w", table, err)
}
defer rows.Close()
for rows.Next() {
vals := make([]sql.NullString, len(matched))
dest := make([]any, len(matched))
for i := range vals {
dest[i] = &vals[i]
}
if err := rows.Scan(dest...); err != nil {
return nil, fmt.Errorf("scan %q: %w", table, err)
}
res.Read++
row := make(map[string]json.RawMessage, len(matched))
for i, f := range matched {
raw, ok := rawForField(f, vals[i])
if !ok {
if f.isJSON && vals[i].Valid {
if t := strings.TrimSpace(vals[i].String); t != "" && t != "null" && !json.Valid([]byte(t)) {
res.Reasons["invalid_json:"+f.jsonName]++
}
}
continue
}
row[f.jsonName] = raw
}
owner := jsonUnquote(row["owner"])
name := jsonUnquote(row["name"])
if name == "" {
res.Skipped++
res.Reasons["empty_name"]++
continue
}
if owner == "" {
owner = "admin"
row["owner"] = json.RawMessage(`"admin"`)
res.Reasons["owner_defaulted_admin"]++
}
id := owner + "/" + name
blob, err := json.Marshal(row)
if err != nil {
return nil, fmt.Errorf("marshal %s: %w", id, err)
}
action, err := upsert[T](dst, id, blob, opt.dryRun)
if err != nil {
return nil, fmt.Errorf("upsert %s: %w", id, err)
}
switch action {
case actionCreate:
res.Created++
case actionUpdate:
res.Updated++
case actionUnchanged:
res.Unchanged++
}
if opt.dryRun && res.Sample == "" && action != actionUnchanged {
res.Sample = redactSample(id, action, row, spec.sensitive)
}
}
return res, rows.Err()
}
const (
actionCreate = "create"
actionUpdate = "update"
actionUnchanged = "unchanged"
)
// upsert creates a row when absent, overwrites it when the clean row differs,
// and is a true no-op when it already matches (idempotent re-run). In dry-run it
// resolves the action without ever writing. The natural key is the id
// (owner/name); the OIDC `sub` the clean iam mints is owner/name too, so the
// legacy per-row UUID never enters the key.
func upsert[T any](dst orm.DB, id string, blob []byte, dry bool) (string, error) {
existing, err := orm.Get[T](dst, id)
if errors.Is(err, orm.ErrNotFound) {
if dry {
return actionCreate, nil
}
if _, _, err := orm.GetOrCreate[T](dst, id, apply[T](blob)); err != nil {
return "", err
}
return actionCreate, nil
}
if err != nil {
return "", err
}
changed, err := wouldChange(existing, blob)
if err != nil {
return "", err
}
if !changed {
return actionUnchanged, nil
}
if dry {
return actionUpdate, nil
}
if _, err := orm.GetOrUpdate[T](dst, id, apply[T](blob)); err != nil {
return "", err
}
return actionUpdate, nil
}
// apply returns a mutator that overlays the legacy row's JSON onto a clean
// entity. It sets only the fields present in blob (the orm.Model key/timestamps
// are absent from blob, so they are never disturbed).
func apply[T any](blob []byte) func(*T) {
return func(d *T) { _ = json.Unmarshal(blob, d) }
}
// wouldChange reports whether overlaying blob onto existing changes its
// serialized form. Because blob carries only domain fields, the orm.Model
// key/timestamps are held constant, so a re-run with identical source data is a
// true no-op (no write, no UpdatedAt churn).
func wouldChange[T any](existing *T, blob []byte) (bool, error) {
before, err := json.Marshal(existing)
if err != nil {
return false, err
}
clone := new(T)
if err := json.Unmarshal(before, clone); err != nil {
return false, err
}
if err := json.Unmarshal(blob, clone); err != nil {
return false, err
}
after, err := json.Marshal(clone)
if err != nil {
return false, err
}
return !bytes.Equal(before, after), nil
}
// fieldSpec is one clean-schema field and the legacy column feeding it.
type fieldSpec struct {
jsonName string
goName string
kind reflect.Kind
isJSON bool
column string // resolved legacy column (set by matchColumns)
}
// entityFields reflects T's stored fields. The embedded orm.Model[T] is skipped
// (its promoted json keys id/createdAt/updatedAt/deleted are the storage key and
// stamps, never sourced from the legacy row), as are json:"-" and unnamed
// (json:"") fields.
func entityFields[T any]() []fieldSpec {
t := reflect.TypeFor[T]()
var out []fieldSpec
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.Anonymous {
continue // embedded orm.Model[T]
}
name := strings.Split(f.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
out = append(out, fieldSpec{
jsonName: name,
goName: f.Name,
kind: f.Type.Kind(),
isJSON: isJSONKind(f.Type.Kind()),
})
}
return out
}
// isJSONKind reports whether a field serializes as JSON text in a legacy column
// (slices, maps, structs, pointers) rather than a scalar.
func isJSONKind(k reflect.Kind) bool {
switch k {
case reflect.Slice, reflect.Array, reflect.Map, reflect.Struct, reflect.Ptr, reflect.Interface:
return true
}
return false
}
// matchColumns pairs each clean field with a legacy column. It matches on a
// normalized key (lowercase, underscores/dashes stripped) so xorm's snake_case
// columns line up with camelCase json tags regardless of the exact mapper
// ("created_time"⇔"createdTime", "git_hub"⇔"github"), then falls back to the
// spec's explicit aliases. A column is consumed by at most one field.
func matchColumns(fields []fieldSpec, cols []string, aliases map[string][]string) (matched []fieldSpec, consumed map[string]bool) {
byNorm := make(map[string]string, len(cols))
for _, c := range cols {
byNorm[normalize(c)] = c
}
consumed = map[string]bool{}
for _, f := range fields {
cands := []string{normalize(f.jsonName), normalize(f.goName)}
for _, a := range aliases[f.jsonName] {
cands = append(cands, normalize(a))
}
for _, cand := range cands {
col, ok := byNorm[cand]
if !ok || consumed[col] {
continue
}
f.column = col
matched = append(matched, f)
consumed[col] = true
break
}
}
return matched, consumed
}
func hasField(matched []fieldSpec, jsonName string) bool {
for _, f := range matched {
if f.jsonName == jsonName {
return true
}
}
return false
}
// rawForField converts a scanned legacy value into the JSON encoding for the
// clean field, or reports (nil,false) to omit it (NULL, empty, or the field's
// zero value — omitempty makes absent and zero identical, keeping the blob
// minimal and re-runs exactly idempotent). JSON-typed columns are passed through
// verbatim when valid; scalars are re-encoded through their Go kind.
func rawForField(f fieldSpec, ns sql.NullString) (json.RawMessage, bool) {
if !ns.Valid {
return nil, false
}
s := ns.String
if f.isJSON {
t := strings.TrimSpace(s)
if t == "" || t == "null" || !json.Valid([]byte(t)) {
return nil, false
}
return json.RawMessage(t), true
}
switch f.kind {
case reflect.String:
if s == "" {
return nil, false
}
b, err := json.Marshal(s)
if err != nil {
return nil, false
}
return b, true
case reflect.Bool:
if s == "1" || strings.EqualFold(s, "true") {
return json.RawMessage("true"), true
}
return nil, false // false is the zero value; omit
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
if fv, ferr := strconv.ParseFloat(strings.TrimSpace(s), 64); ferr == nil {
n = int64(fv)
} else {
return nil, false
}
}
if n == 0 {
return nil, false
}
return json.RawMessage(strconv.FormatInt(n, 10)), true
case reflect.Float32, reflect.Float64:
fv, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil || fv == 0 {
return nil, false
}
b, err := json.Marshal(fv)
if err != nil {
return nil, false
}
return b, true
}
return nil, false
}
// normalize collapses a column or field name to its comparison key: lowercase
// with underscores, dashes, and spaces removed.
func normalize(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '_' || r == '-' || r == ' ' {
continue
}
b.WriteRune(unicode.ToLower(r))
}
return b.String()
}
// jsonUnquote decodes a JSON string value, returning "" for anything else.
func jsonUnquote(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return ""
}
return s
}
// resolveTable returns the first candidate table that exists in the legacy DB
// (matched on the normalized name), or "" when none do.
func resolveTable(ctx context.Context, db *sql.DB, candidates []string) (string, error) {
rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table'`)
if err != nil {
return "", fmt.Errorf("list tables: %w", err)
}
defer rows.Close()
existing := map[string]string{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return "", err
}
existing[normalize(n)] = n
}
if err := rows.Err(); err != nil {
return "", err
}
for _, c := range candidates {
if actual, ok := existing[normalize(c)]; ok {
return actual, nil
}
}
return "", nil
}
// tableColumns returns the column names of a legacy table via PRAGMA.
func tableColumns(ctx context.Context, db *sql.DB, table string) ([]string, error) {
rows, err := db.QueryContext(ctx, "PRAGMA table_info("+quoteIdent(table)+")")
if err != nil {
return nil, err
}
defer rows.Close()
var cols []string
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return nil, err
}
cols = append(cols, name)
}
return cols, rows.Err()
}
// quoteIdent double-quotes a SQLite identifier (table names come from
// sqlite_master, not user input, but quoting keeps odd names safe).
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// set builds a lookup set from keys.
func set(keys ...string) map[string]bool {
m := make(map[string]bool, len(keys))
for _, k := range keys {
m[k] = true
}
return m
}
// redactSample renders a one-row sample for --dry-run with secret fields masked
// (a password digest or private key must never reach a log or a terminal).
func redactSample(id, action string, row map[string]json.RawMessage, sensitive map[string]bool) string {
view := make(map[string]any, len(row))
for k, v := range row {
if sensitive[k] {
view[k] = fmt.Sprintf("<redacted:%d bytes>", len(v))
continue
}
view[k] = json.RawMessage(v)
}
b, err := json.MarshalIndent(map[string]any{"id": id, "action": action, "fields": view}, " ", " ")
if err != nil {
return ""
}
return string(b)
}
+440
View File
@@ -0,0 +1,440 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/pem"
"path/filepath"
"reflect"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Golden argon2id digest, mirrored VERBATIM from internal/cred/golden_v1_test.go.
// It was produced by v1's own Argon2idCredManager. Proving cred.Verify succeeds
// against the MIGRATED hash — a hash that entered the clean store only through
// this migrator — is the full end-to-end credential-parity assertion.
const (
goldenPassword = "golden-test-password-1"
goldenDigest = "$argon2id$v=19$m=65536,t=1,p=2$oOen09XtFBqKnv2/K4q5mQ$iZKRwt09CdXDXr4E1CQtRoF/nWzgI810tMFUUiKHugo"
)
// newLegacyDB builds a tiny synthetic legacy Casdoor iam.db with snake_case
// columns (as xorm emits) — including the credential-critical `password` column
// and the Casdoor `id` UUID that the clean schema has no home for.
func newLegacyDB(t *testing.T, certPEM, keyPEM string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "iam.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open legacy: %v", err)
}
defer db.Close()
stmts := []string{
`CREATE TABLE organization (
owner text, name text, created_time text, display_name text,
password_type text, init_score integer, is_personal integer)`,
`CREATE TABLE cert (
owner text, name text, created_time text, type text,
crypto_algorithm text, bit_size integer, certificate text, private_key text)`,
`CREATE TABLE application (
owner text, name text, created_time text, display_name text,
organization text, cert text, client_id text, client_secret text,
enable_password integer, redirect_uris text)`,
`CREATE TABLE provider (
owner text, name text, created_time text, category text, type text,
client_id text, client_secret text, user_mapping text)`,
`CREATE TABLE user (
owner text, name text, created_time text, updated_time text, id text,
password text, password_type text, password_salt text, email text,
display_name text, is_admin integer, signup_application text, github text)`,
`CREATE TABLE role (
owner text, name text, created_time text, display_name text,
users text, is_enabled integer)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("create table: %v\n%s", err, s)
}
}
exec := func(q string, args ...any) {
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("insert: %v", err)
}
}
exec(`INSERT INTO organization VALUES(?,?,?,?,?,?,?)`,
"admin", "hanzo", "2020-01-02T03:04:05Z", "Hanzo", "argon2id", 100, 0)
exec(`INSERT INTO cert VALUES(?,?,?,?,?,?,?,?)`,
"admin", "cert-hanzo", "2020-01-02T03:04:05Z", "x509", "RS256", 2048, certPEM, keyPEM)
exec(`INSERT INTO application VALUES(?,?,?,?,?,?,?,?,?,?)`,
"admin", "app-hanzo", "2020-01-02T03:04:05Z", "Hanzo App", "hanzo", "cert-hanzo",
"client-abc", "secret-xyz", 1, `["https://hanzo.ai/callback"]`)
exec(`INSERT INTO provider VALUES(?,?,?,?,?,?,?,?)`,
"admin", "provider-github", "2020-01-02T03:04:05Z", "OAuth", "GitHub",
"gh-id", "gh-secret", `{"id":"id","username":"login"}`)
// User z: own password_type=argon2id.
exec(`INSERT INTO user VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
"hanzo", "z", "2020-01-02T03:04:05Z", "2020-02-02T03:04:05Z", "uuid-0001",
goldenDigest, "argon2id", "the-salt", "z@hanzo.ai", "Z", 1, "app-hanzo", "z-gh")
// User fallback: EMPTY password_type — verification must fall back to the org's.
exec(`INSERT INTO user VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
"hanzo", "fallback", "2020-01-03T03:04:05Z", "2020-02-03T03:04:05Z", "uuid-0002",
goldenDigest, "", "", "fallback@hanzo.ai", "Fallback", 0, "app-hanzo", "")
exec(`INSERT INTO role VALUES(?,?,?,?,?,?)`,
"hanzo", "role-admin", "2020-01-02T03:04:05Z", "Admins", `["hanzo/z"]`, 1)
return path
}
func genRSAPEM(t *testing.T) (certPEM, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key),
}))
// A stand-in certificate PEM — content is opaque to the migrator; what matters
// is that the multi-line PEM survives byte-for-byte.
certPEM = "-----BEGIN CERTIFICATE-----\nMIIB=stub=cert=material=\nfor=jwks=parity=test\n-----END CERTIFICATE-----\n"
return certPEM, keyPEM
}
func openDest(t *testing.T) (orm.DB, string) {
t.Helper()
dir := t.TempDir()
db, err := store.Open("sqlite", filepath.Join(dir, "iam2.db"))
if err != nil {
t.Fatalf("open dest: %v", err)
}
t.Cleanup(func() { db.Close() })
return db, dir
}
func TestMigrate_PreservesCredentialsAndKeysVerbatim(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM := genRSAPEM(t)
srcPath := newLegacyDB(t, certPEM, keyPEM)
src, err := sql.Open("sqlite", srcPath)
if err != nil {
t.Fatalf("open src: %v", err)
}
defer src.Close()
dst, _ := openDest(t)
results, err := Migrate(ctx, src, dst, nil, options{})
if err != nil {
t.Fatalf("migrate: %v", err)
}
byEntity := indexResults(results)
// ---- Organization: password_type must survive (it is the fallback scheme). ----
org, err := store.GetOrganizationByName(ctx, dst, "hanzo")
if err != nil || org == nil {
t.Fatalf("org not migrated: %v", err)
}
if org.PasswordType != "argon2id" {
t.Errorf("org.PasswordType = %q, want argon2id", org.PasswordType)
}
if org.CreatedTime != "2020-01-02T03:04:05Z" {
t.Errorf("org.CreatedTime = %q, want the v1 stamp verbatim", org.CreatedTime)
}
if org.InitScore != 100 {
t.Errorf("org.InitScore = %d, want 100", org.InitScore)
}
// ---- Cert: PrivateKey + Certificate byte-for-byte (JWKS parity). ----
cert, err := store.GetCert(ctx, dst, "admin", "cert-hanzo")
if err != nil || cert == nil {
t.Fatalf("cert not migrated: %v", err)
}
if cert.PrivateKey != keyPEM {
t.Fatalf("cert.PrivateKey NOT verbatim:\n got %q\nwant %q", cert.PrivateKey, keyPEM)
}
if cert.Certificate != certPEM {
t.Fatalf("cert.Certificate NOT verbatim:\n got %q\nwant %q", cert.Certificate, certPEM)
}
if cert.BitSize != 2048 || cert.CryptoAlgorithm != "RS256" {
t.Errorf("cert metadata drift: bitSize=%d alg=%q", cert.BitSize, cert.CryptoAlgorithm)
}
// ---- User z: PasswordHash verbatim, and cred.Verify succeeds end-to-end. ----
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
if err != nil || u == nil {
t.Fatalf("user z not migrated: %v", err)
}
if u.PasswordHash != goldenDigest {
t.Fatalf("user.PasswordHash NOT verbatim:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
}
if u.PasswordType != "argon2id" {
t.Errorf("user.PasswordType = %q, want argon2id", u.PasswordType)
}
if u.PasswordSalt != "the-salt" {
t.Errorf("user.PasswordSalt = %q, want the-salt", u.PasswordSalt)
}
if !u.IsAdmin {
t.Error("user.IsAdmin = false, want true (bool 1 -> true)")
}
if u.GitHub != "z-gh" {
t.Errorf("user.GitHub = %q, want z-gh (federated connector column)", u.GitHub)
}
if u.Email != "z@hanzo.ai" {
t.Errorf("user.Email = %q", u.Email)
}
// THE assertion that matters: the migrated hash verifies under the clean
// verifier, resolving the scheme from the row exactly as login does.
typ := cred.Resolve(u.PasswordType, org.PasswordType)
if !cred.Verify(typ, goldenPassword, u.PasswordHash) {
t.Fatal("cred.Verify REJECTED the migrated argon2id hash — login would fail at cutover")
}
if cred.Verify(typ, "wrong-password", u.PasswordHash) {
t.Fatal("cred.Verify ACCEPTED a wrong password against the migrated hash")
}
// ---- User fallback: empty type resolves through the org's argon2id. ----
fb, err := store.GetUserByName(ctx, dst, "hanzo", "fallback")
if err != nil || fb == nil {
t.Fatalf("user fallback not migrated: %v", err)
}
if fb.PasswordType != "" {
t.Errorf("fallback user should keep empty PasswordType, got %q", fb.PasswordType)
}
fbType := cred.Resolve(fb.PasswordType, org.PasswordType)
if fbType != "argon2id" {
t.Fatalf("org fallback resolve = %q, want argon2id", fbType)
}
if !cred.Verify(fbType, goldenPassword, fb.PasswordHash) {
t.Fatal("org-fallback verification of migrated hash failed")
}
// ---- Application + Provider round-trip (incl. a JSON-typed column). ----
app, err := store.GetApplicationByName(ctx, dst, "admin", "app-hanzo")
if err != nil || app == nil {
t.Fatalf("app not migrated: %v", err)
}
if app.ClientId != "client-abc" || app.Cert != "cert-hanzo" || app.Organization != "hanzo" {
t.Errorf("app fields drift: clientId=%q cert=%q org=%q", app.ClientId, app.Cert, app.Organization)
}
if len(app.RedirectUris) != 1 || app.RedirectUris[0] != "https://hanzo.ai/callback" {
t.Errorf("app.RedirectUris JSON column not decoded: %#v", app.RedirectUris)
}
prov, err := store.GetProvider(ctx, dst, "admin", "provider-github")
if err != nil || prov == nil {
t.Fatalf("provider not migrated: %v", err)
}
wantMap := map[string]string{"id": "id", "username": "login"}
if !reflect.DeepEqual(prov.UserMapping, wantMap) {
t.Errorf("provider.UserMapping = %#v, want %#v", prov.UserMapping, wantMap)
}
// ---- The Casdoor `id` UUID has NO clean home: it must be flagged as a gap. ----
if got := byEntity["users"]; got == nil || !contains(got.UnmappedCols, "id") {
t.Errorf("expected legacy user column 'id' reported as unmapped (the old OIDC sub), got %v",
gapCols(got))
}
// ---- Row counts: everything read was created. ----
assertCounts(t, byEntity["users"], 2, 2, 0, 0)
assertCounts(t, byEntity["organizations"], 1, 1, 0, 0)
assertCounts(t, byEntity["certs"], 1, 1, 0, 0)
assertCounts(t, byEntity["applications"], 1, 1, 0, 0)
assertCounts(t, byEntity["providers"], 1, 1, 0, 0)
}
func TestMigrate_Idempotent(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM := genRSAPEM(t)
srcPath := newLegacyDB(t, certPEM, keyPEM)
src, err := sql.Open("sqlite", srcPath)
if err != nil {
t.Fatalf("open src: %v", err)
}
defer src.Close()
dst, _ := openDest(t)
if _, err := Migrate(ctx, src, dst, nil, options{}); err != nil {
t.Fatalf("first pass: %v", err)
}
firstCounts := kindCounts(ctx, t, dst)
// Second pass must be a pure no-op: nothing created, nothing updated.
results, err := Migrate(ctx, src, dst, nil, options{})
if err != nil {
t.Fatalf("second pass: %v", err)
}
for _, r := range results {
if r.Created != 0 || r.Updated != 0 {
t.Errorf("%s: re-run not idempotent (created=%d updated=%d), want 0/0",
r.Entity, r.Created, r.Updated)
}
if r.Read != r.Unchanged {
t.Errorf("%s: read=%d but unchanged=%d — re-run should classify every row unchanged",
r.Entity, r.Read, r.Unchanged)
}
}
// No duplicate rows: per-kind counts are identical after the second pass.
secondCounts := kindCounts(ctx, t, dst)
if !reflect.DeepEqual(firstCounts, secondCounts) {
t.Errorf("row counts changed on re-run: %v -> %v", firstCounts, secondCounts)
}
// And the hash is still exact after two passes.
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
if err != nil || u == nil || u.PasswordHash != goldenDigest {
t.Fatalf("hash drifted after re-run: %v", err)
}
}
func TestMigrate_DryRunWritesNothing(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM := genRSAPEM(t)
srcPath := newLegacyDB(t, certPEM, keyPEM)
src, err := sql.Open("sqlite", srcPath)
if err != nil {
t.Fatalf("open src: %v", err)
}
defer src.Close()
dst, _ := openDest(t)
results, err := Migrate(ctx, src, dst, nil, options{dryRun: true})
if err != nil {
t.Fatalf("dry-run: %v", err)
}
// Dry-run predicts creates but writes nothing.
for _, r := range results {
if r.TableMissing {
continue
}
if r.Created != r.Read {
t.Errorf("%s: dry-run should predict create for every row (read=%d created=%d)",
r.Entity, r.Read, r.Created)
}
}
counts := kindCounts(ctx, t, dst)
for kind, n := range counts {
if n != 0 {
t.Errorf("dry-run wrote %d rows of kind %q, want 0", n, kind)
}
}
// Second (real) run after a dry-run still creates everything.
real, err := Migrate(ctx, src, dst, nil, options{})
if err != nil {
t.Fatalf("real after dry: %v", err)
}
for _, r := range indexResultsSlice(real) {
if r.TableMissing {
continue
}
if r.Created != r.Read {
t.Errorf("%s: real run after dry-run should create every row", r.Entity)
}
}
}
func TestMigrate_OnlySelectsSubset(t *testing.T) {
ctx := context.Background()
certPEM, keyPEM := genRSAPEM(t)
srcPath := newLegacyDB(t, certPEM, keyPEM)
src, err := sql.Open("sqlite", srcPath)
if err != nil {
t.Fatalf("open src: %v", err)
}
defer src.Close()
dst, _ := openDest(t)
results, err := Migrate(ctx, src, dst, []string{"users", "orgs"}, options{})
if err != nil {
t.Fatalf("migrate subset: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 entities migrated, got %d", len(results))
}
// certs must NOT have been touched.
if c, _ := store.GetCert(ctx, dst, "admin", "cert-hanzo"); c != nil {
t.Error("--only users,orgs should not migrate certs")
}
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u == nil {
t.Error("--only users,orgs must migrate users")
}
// An unknown selector is a hard error, never a silent skip.
if _, err := Migrate(ctx, src, dst, []string{"widgets"}, options{}); err == nil {
t.Error("unknown --only entity must error")
}
}
// --- helpers ---
func indexResults(rs []*EntityResult) map[string]*EntityResult {
m := map[string]*EntityResult{}
for _, r := range rs {
m[r.Entity] = r
}
return m
}
func indexResultsSlice(rs []*EntityResult) []*EntityResult { return rs }
func assertCounts(t *testing.T, r *EntityResult, read, created, updated, unchanged int) {
t.Helper()
if r == nil {
t.Fatalf("nil result")
}
if r.Read != read || r.Created != created || r.Updated != updated || r.Unchanged != unchanged {
t.Errorf("%s counts = read %d/created %d/updated %d/unchanged %d, want %d/%d/%d/%d",
r.Entity, r.Read, r.Created, r.Updated, r.Unchanged, read, created, updated, unchanged)
}
}
func kindCounts(ctx context.Context, t *testing.T, db orm.DB) map[string]int64 {
t.Helper()
out := map[string]int64{}
for _, kind := range schema.Kinds() {
n, err := db.Query(kind).Count(ctx)
if err != nil {
t.Fatalf("count %s: %v", kind, err)
}
if n > 0 {
out[kind] = int64(n)
}
}
return out
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
func gapCols(r *EntityResult) []string {
if r == nil {
return nil
}
return r.UnmappedCols
}
+10
View File
@@ -0,0 +1,10 @@
c-4.5.6.db is a real database written by the C libsqlcipher 4.5.6 library,
copied verbatim from github.com/hanzoai/sqlcipher@v0.1.0/testdata. It is keyed
with the raw key 000102...1e1f (see enc_test.go: sqlcipherInteropKey).
The encrypted-source migrator test uses it ONLY as a source of SQLCipher's
80-byte per-page reserve (SQLite header byte 20): DecryptFile yields a reserved
plaintext canvas, modernc PRESERVES that reserve when the test overwrites the
schema, and EncryptFile can then produce an encrypted shard the test fully
controls. Pure Go cannot ORIGINATE reserved pages, so this vector bootstraps
them. Its own schema/contents are irrelevant — the test wipes them.
Binary file not shown.
+292
View File
@@ -0,0 +1,292 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// WAL-INCLUSIVE extraction. The default encrypted path (encrypted.go, via
// sqlcipher.DecryptFile) decrypts only the CHECKPOINTED main-db image of a
// shard — any rows still sitting in that shard's uncheckpointed `-wal` are
// invisible to it. On the live production store most org shards carry hundreds
// of KB of uncheckpointed WAL, so a cutover built on the checkpointed image
// alone undercounts users and locks the most-recent signups out. This file adds
// the COMPLETE extraction: it drives the C `sqlcipher` binary (SQLCipher 4.x,
// present on the fork's IAM pod at /usr/bin/sqlcipher) to checkpoint each
// shard's WAL into a plaintext copy before the Migrate engine reads it.
//
// Why not hanzoai/sqlite's keyed open: it force-sets journal_mode=WAL (a WRITE)
// on open and fails "disk I/O error (10)" on a copied shard, so it cannot do a
// WAL-inclusive read. The C sqlcipher shell has no such constraint, so the WAL
// merge is delegated to it. The crypto KEY still comes from the SAME pure-Go
// derive+unwrap the checkpointed path uses (encrypted.go deriveDEK) — never
// re-derived here.
//
// KEY HANDLING (non-negotiable): the 32-byte DEK reaches the child ONLY inside
// the SQL script on STDIN, as a raw x'…' key — NEVER on argv, NEVER logged. The
// script bytes and the hex are zeroed after the child returns, and the child's
// stderr is scrubbed of the hex before it can enter an error string.
package main
import (
"bytes"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// walMode selects and configures the WAL-inclusive extraction path. The zero
// value (enabled=false, ignoreWAL=false) is the default checkpointed path guarded
// against a non-empty uncheckpointed WAL, so nothing regresses.
type walMode struct {
enabled bool // --wal-inclusive: checkpoint each shard's -wal into the plaintext copy (complete extraction)
ignoreWAL bool // --ignore-wal: in the default path, proceed even if a shard has a non-empty -wal, INTENTIONALLY dropping those rows
bin string // C sqlcipher binary (path, or a name resolved on PATH)
}
// sqliteHeader is the 16-byte magic every SQLite database file begins with. A
// valid plaintext export MUST start with it; a wrong key makes sqlcipher_export
// produce nothing or garbage, and catching that here is what stops a garbage
// store from ever being written.
var sqliteHeader = []byte("SQLite format 3\x00")
// preflightWAL verifies the sqlcipher binary is resolvable BEFORE any shard is
// touched, so --wal-inclusive can never silently degrade to the WAL-blind
// checkpointed path when the binary is absent.
func preflightWAL(w walMode) error {
if !w.enabled {
return nil
}
if strings.TrimSpace(w.bin) == "" {
return fmt.Errorf("--wal-inclusive: no sqlcipher binary configured (use --sqlcipher-bin)")
}
if _, err := exec.LookPath(w.bin); err != nil {
return fmt.Errorf("--wal-inclusive requires the C sqlcipher binary %q on PATH: %w", w.bin, err)
}
return nil
}
// checkpointShardToPlaintext produces a WAL-INCLUSIVE plaintext copy of one
// encrypted shard and returns its path plus the temp dir to shred. It copies the
// shard's iam.db (+ -wal/-shm, if present) into a fresh 0700 dir so checkpointing
// operates on a COPY and never the live file, derives the DEK with the shared
// pure-Go recipe, then drives the C sqlcipher shell to checkpoint(TRUNCATE) the
// WAL into the copy and sqlcipher_export a plaintext db. On ANY error it shreds
// the temp dir (which may already hold decrypted pages) before returning.
func checkpointShardToPlaintext(sh encShard, master []byte, workDir, bin string) (plainPath, tmpDir string, err error) {
dir, err := os.MkdirTemp(workDir, "iam-migrate-wal-*")
if err != nil {
return "", "", fmt.Errorf("create work temp dir: %w", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
shredDir(dir)
return "", "", fmt.Errorf("lock down work temp dir: %w", err)
}
// The temp dir holds a decrypted copy + the plaintext export, so it must be
// shredded on ANY error path. An explicit ok flag (not the named err/tmpDir
// returns, which an error `return "", "", err` would blank out first) drives
// the cleanup: shred unless we hand the dir back to the caller on success.
ok := false
defer func() {
if !ok {
shredDir(dir)
}
}()
srcCopy := filepath.Join(dir, "src.db")
plain := filepath.Join(dir, "plain.db")
// Both paths are embedded (single-quoted) in the sqlcipher script; a quote or
// newline in them would break the ATTACH/open, so reject it rather than emit a
// malformed statement. os.MkdirTemp's own suffix never contains these, so this
// only guards a hostile --work-dir.
if strings.ContainsAny(srcCopy, "'\n") || strings.ContainsAny(plain, "'\n") {
return "", "", fmt.Errorf("work-dir path contains an unsupported character (%q or newline)", "'")
}
// READ-CONSISTENCY FENCE (HIGH #1). A shard is three on-disk files — iam.db,
// -wal, -shm — and copying them as three separate reads is only sound if the
// source does not change across the copy window. If a checkpoint fires mid-copy
// it moves committed frames out of the -wal we already read into a main db we
// did not (then TRUNCATEs the -wal), and those rows vanish from the copied set
// with no error. So snapshot the (existence, size, mtime) identity of all three
// files BEFORE the copy and re-snapshot AFTER: if anything moved, the source was
// written during the window and the copied triple may be internally
// inconsistent — abort LOUDLY rather than checkpoint a mismatched main/-wal
// pair. At cutover the source is a frozen VolumeSnapshot with IAM writes frozen,
// so this never trips in practice; it is the defense-in-depth that turns
// "silently copy a live shard" into a hard refusal. Reads never advance mtime,
// so our own copy cannot false-trip the fence; the bracket is exactly the read
// window, so any change inside it is caught and any change outside it is
// irrelevant to the bytes we captured.
before := statShard(sh.path)
if walCopyHook != nil {
walCopyHook(sh.path) // test seam only (nil in production): inject a writer to prove the fence aborts
}
if err := copyFile(sh.path, srcCopy); err != nil {
return "", "", fmt.Errorf("copy shard main db: %w", err)
}
for _, suf := range []string{"-wal", "-shm"} {
if _, statErr := os.Stat(sh.path + suf); statErr == nil {
if err := copyFile(sh.path+suf, srcCopy+suf); err != nil {
return "", "", fmt.Errorf("copy shard %q: %w", suf, err)
}
}
}
if after := statShard(sh.path); after != before {
return "", "", fmt.Errorf("shard %s changed on disk during copy (source not quiescent — iam.db/-wal/-shm existence, size, or mtime moved): refusing to migrate a possibly-inconsistent snapshot that could silently drop committed WAL rows; run against a frozen snapshot with IAM writes frozen, or a shard with no live writer", sh.label)
}
dek, err := deriveDEK(sh, master)
if err != nil {
return "", "", err
}
defer zero(dek)
if err := runSQLCipherExport(bin, srcCopy, plain, dek); err != nil {
return "", "", err
}
// Defensive drain check (RED LOW). After wal_checkpoint(TRUNCATE) the copy's
// -wal must be fully drained into its main db; any bytes still there mean the
// checkpoint could not apply every frame (a busy checkpoint), and the plaintext
// export — taken from the main db — would then be missing rows. This asserts the
// OUTCOME of the checkpoint (WAL emptied) directly, which is strictly stronger
// than parsing the PRAGMA's busy column and is version-independent. On a private
// copy no other connection holds, it never trips; it is the belt to -bail's
// suspenders.
if fi, statErr := os.Stat(srcCopy + "-wal"); statErr == nil && fi.Size() > 0 {
return "", "", fmt.Errorf("shard %s: wal_checkpoint(TRUNCATE) left %d bytes uncheckpointed in the WAL — the export would miss those rows", sh.label, fi.Size())
}
if err := validatePlaintextSQLite(plain); err != nil {
return "", "", err
}
ok = true
return plain, dir, nil
}
// shardStat is the on-disk identity of one shard file: whether it exists, its
// size, and its mtime as UnixNano. All fields are comparable, so a [3]shardStat of
// (iam.db, -wal, -shm) can be compared with != to detect ANY change — including a
// -wal that appears or disappears — across the copy window.
type shardStat struct {
exists bool
size int64
mtimeNs int64
}
// statShard snapshots the identity of a shard's three on-disk files: the main db
// at base, base+"-wal", and base+"-shm". A missing file is the zero shardStat
// (exists=false), so a -wal materializing or being truncated away mid-copy is
// itself a detected change.
func statShard(base string) [3]shardStat {
var s [3]shardStat
for i, suf := range []string{"", "-wal", "-shm"} {
if fi, err := os.Stat(base + suf); err == nil {
s[i] = shardStat{exists: true, size: fi.Size(), mtimeNs: fi.ModTime().UnixNano()}
}
}
return s
}
// walCopyHook is a TEST SEAM. When non-nil it is invoked exactly once, immediately
// after the pre-copy identity snapshot, so a test can deterministically inject a
// concurrent writer and prove the read-consistency fence aborts. It is nil in
// production — no behavior, no cost. (Same pattern as the stdlib's testHook vars.)
var walCopyHook func(shardBase string)
// runSQLCipherExport drives the C sqlcipher shell to checkpoint srcCopy's WAL
// into its main db and export a plaintext db to plainPath. The DEK is written as
// a raw x'…' hex key INSIDE the stdin script — never on argv. The exact SQL is
// the checkpoint-then-export recipe the migrator was validated against:
//
// PRAGMA key = "x'<dek-hex>'";
// PRAGMA cipher_page_size = 4096;
// PRAGMA wal_checkpoint(TRUNCATE);
// ATTACH DATABASE '<plainPath>' AS plaintext KEY '';
// SELECT sqlcipher_export('plaintext');
// DETACH DATABASE plaintext;
//
// -bail makes the shell stop and exit non-zero on the first error, so a wrong
// key (which errors on the first read of an encrypted page: "file is not a
// database") fails LOUDLY instead of yielding a partial export we might mistake
// for success. The script bytes and the hex are scrubbed on return; the child's
// stderr is scrubbed of the hex before it can reach an error string.
func runSQLCipherExport(bin, srcCopy, plainPath string, dek []byte) error {
hexDEK := make([]byte, hex.EncodedLen(len(dek)))
hex.Encode(hexDEK, dek)
defer zero(hexDEK)
var script bytes.Buffer
script.WriteString(`PRAGMA key = "x'`)
script.Write(hexDEK)
script.WriteString("'\";\n")
script.WriteString("PRAGMA cipher_page_size = 4096;\n")
script.WriteString("PRAGMA wal_checkpoint(TRUNCATE);\n")
script.WriteString("ATTACH DATABASE '" + plainPath + "' AS plaintext KEY '';\n")
script.WriteString("SELECT sqlcipher_export('plaintext');\n")
script.WriteString("DETACH DATABASE plaintext;\n")
scriptBytes := script.Bytes()
defer zero(scriptBytes) // scrub the DEK-bearing script from memory
// The db path on argv is NOT secret; the key rides stdin only.
cmd := exec.Command(bin, "-bail", srcCopy)
cmd.Stdin = bytes.NewReader(scriptBytes)
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
runErr := cmd.Run()
// Defensively scrub the DEK hex from any child diagnostics before surfacing it.
safe := bytes.TrimSpace(bytes.ReplaceAll(stderr.Bytes(), hexDEK, []byte("<redacted-key>")))
if runErr != nil {
return fmt.Errorf("sqlcipher WAL-inclusive export failed (wrong master key or corrupt shard?): %v: %s", runErr, safe)
}
return nil
}
// validatePlaintextSQLite fails loudly unless path is a non-empty SQLite database
// (starts with the 16-byte SQLite magic). This is the gate that keeps a wrong key
// — whose export is empty or non-SQLite — from ever reaching the Migrate engine.
func validatePlaintextSQLite(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("plaintext export missing (decrypt/export likely failed): %w", err)
}
defer f.Close()
head := make([]byte, len(sqliteHeader))
if _, err := io.ReadFull(f, head); err != nil || !bytes.Equal(head, sqliteHeader) {
return fmt.Errorf("plaintext export at %s is not a valid SQLite database (wrong key or failed export)", filepath.Base(path))
}
return nil
}
// copyFile copies src to a fresh 0600 dst (O_EXCL: the temp dir is fresh, so a
// pre-existing dst would mean a collision we want to fail on).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// shredDir shreds every file in dir (each may hold plaintext credential material)
// and removes the dir. Best-effort by design, like shred: a failure never blocks
// the migration.
func shredDir(dir string) {
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if !e.IsDir() {
shred(filepath.Join(dir, e.Name()))
}
}
}
_ = os.RemoveAll(dir)
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Tests for the two silent-data-loss guards RED flagged on the WAL-inclusive
// migrator:
//
// - HIGH #1 read-consistency fence: a shard mutated during the multi-file copy
// window must ABORT (never silently checkpoint a mismatched main/-wal pair).
// - HIGH #2 WAL-blind default: the default checkpointed path must HARD-FAIL on a
// shard carrying a non-empty uncheckpointed -wal unless --wal-inclusive (capture
// it) or --ignore-wal (intentionally drop it).
package main
import (
"context"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/hanzoai/iam/internal/store"
)
// mustWriteFile writes data to path, creating parent dirs — a tiny fixture helper
// for the shards these guard tests only ever stat (contents are irrelevant to the
// guard, which keys off the -wal's size).
func mustWriteFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
// TestGuardCheckpointedWAL exercises the HIGH #2 gate as a pure function across
// its three inputs: a clean/empty -wal passes, a non-empty -wal hard-fails with an
// actionable message naming both escape hatches, and --ignore-wal proceeds past it.
func TestGuardCheckpointedWAL(t *testing.T) {
dir := t.TempDir()
cleanPath := filepath.Join(dir, "clean", "iam.db")
mustWriteFile(t, cleanPath, []byte("main")) // no -wal at all
emptyWALPath := filepath.Join(dir, "emptywal", "iam.db")
mustWriteFile(t, emptyWALPath, []byte("main"))
mustWriteFile(t, emptyWALPath+"-wal", nil) // 0-byte -wal: nothing to lose
dirtyPath := filepath.Join(dir, "dirty", "iam.db")
mustWriteFile(t, dirtyPath, []byte("main"))
mustWriteFile(t, dirtyPath+"-wal", []byte("uncheckpointed frames"))
clean := encShard{label: "global", path: cleanPath}
emptyWAL := encShard{label: "org:emptywal", path: emptyWALPath}
dirty := encShard{label: "org:dirty", path: dirtyPath}
t.Run("clean_and_empty_wal_pass", func(t *testing.T) {
if err := guardCheckpointedWAL([]encShard{clean, emptyWAL}, false); err != nil {
t.Fatalf("a missing/empty -wal must pass the guard, got: %v", err)
}
})
t.Run("non_empty_wal_hard_fails", func(t *testing.T) {
err := guardCheckpointedWAL([]encShard{clean, dirty}, false)
if err == nil {
t.Fatal("a non-empty uncheckpointed -wal must hard-fail without a flag")
}
for _, want := range []string{"org:dirty", "SILENTLY DROP", "--wal-inclusive", "--ignore-wal"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("hard-fail message missing %q: %v", want, err)
}
}
})
t.Run("ignore_wal_proceeds", func(t *testing.T) {
if err := guardCheckpointedWAL([]encShard{clean, dirty}, true); err != nil {
t.Fatalf("--ignore-wal must proceed past a non-empty -wal, got: %v", err)
}
})
}
// TestDefaultPath_NonEmptyWAL_Integration drives the full runEncrypted default
// path against a REAL pure-Go-encrypted shard that carries a non-empty
// uncheckpointed -wal: without a flag it must abort and write nothing; with
// --ignore-wal it proceeds and migrates the checkpointed main-db user. No C
// sqlcipher is needed — the default path never invokes it.
func TestDefaultPath_NonEmptyWAL_Integration(t *testing.T) {
ctx := context.Background()
master := randKey(t)
datadir := t.TempDir()
shardPath := writeGlobalUserShard(t, datadir, master) // real encrypted global shard: user hanzo/z (goldenDigest)
// A non-empty -wal beside the shard. Its bytes are never parsed by the default
// DecryptFile path (which reads only the main db); the guard keys off its size.
if err := os.WriteFile(shardPath+"-wal", []byte("frames the checkpointed path cannot see"), 0o600); err != nil {
t.Fatal(err)
}
const env = "MIGRATE_V1_TEST_MASTER_KEY"
t.Setenv(env, hex.EncodeToString(master))
t.Run("default_hard_fails_and_writes_nothing", func(t *testing.T) {
dest := t.TempDir()
err := runEncrypted(ctx, datadir, env, t.TempDir(), dest, false, nil, walMode{})
if err == nil {
t.Fatal("default path must refuse a shard with a non-empty uncheckpointed -wal")
}
if !strings.Contains(err.Error(), "--wal-inclusive") || !strings.Contains(err.Error(), "--ignore-wal") {
t.Errorf("error must name both escape hatches, got: %v", err)
}
assertDestEmpty(t, ctx, dest)
})
t.Run("ignore_wal_proceeds_and_migrates_main", func(t *testing.T) {
dest := t.TempDir()
if err := runEncrypted(ctx, datadir, env, t.TempDir(), dest, false, nil, walMode{ignoreWAL: true}); err != nil {
t.Fatalf("--ignore-wal must proceed, got: %v", err)
}
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
if err != nil {
t.Fatalf("reopen dest: %v", err)
}
defer dst.Close()
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
if err != nil || u == nil {
t.Fatalf("--ignore-wal did not migrate the checkpointed main-db user: %v", err)
}
if u.PasswordHash != goldenDigest {
t.Fatalf("main-db user hash not verbatim through --ignore-wal:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
}
})
}
// TestWALInclusive_SourceNotQuiescent_Aborts proves the HIGH #1 read-consistency
// fence: if the live source shard is mutated during the copy window — a concurrent
// commit growing the -wal, or a concurrent checkpoint moving the main db's mtime —
// the run ABORTS loudly, writes nothing, and shreds the work-dir. It never
// silently checkpoints a main/-wal pair copied at different instants. The
// concurrent writer is injected deterministically via the walCopyHook test seam,
// fired immediately after the pre-copy snapshot; the fence aborts before the C
// sqlcipher binary is ever invoked, so its behavior is irrelevant here.
func TestWALInclusive_SourceNotQuiescent_Aborts(t *testing.T) {
ctx := context.Background()
t.Cleanup(func() { walCopyHook = nil })
cases := []struct {
name string
mutate func(t *testing.T, base string) // perturb the live source (base = <datadir>/iam.db)
}{
{
name: "wal_grows_during_copy", // a concurrent commit appends frames to -wal
mutate: func(t *testing.T, base string) {
f, err := os.OpenFile(base+"-wal", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.Write([]byte("newly committed frames")); err != nil {
t.Fatal(err)
}
},
},
{
name: "main_mtime_moves_during_copy", // a concurrent checkpoint rewrites the main db
mutate: func(t *testing.T, base string) {
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(base, future, future); err != nil {
t.Fatal(err)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
master := randKey(t)
datadir := t.TempDir()
shardPath := writeGlobalUserShard(t, datadir, master)
// Pre-seat a small -wal so the "grows" case appends and the "mtime" case
// leaves a stable -wal that must NOT itself trip the fence.
if err := os.WriteFile(shardPath+"-wal", []byte("frame0"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
walCopyHook = func(base string) { tc.mutate(t, base) }
t.Cleanup(func() { walCopyHook = nil })
workDir := t.TempDir()
dest := t.TempDir()
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil,
walMode{enabled: true, bin: os.Args[0]})
if err == nil {
t.Fatal("a source mutated during the copy window must abort — never silently proceed")
}
if !strings.Contains(err.Error(), "not quiescent") && !strings.Contains(err.Error(), "changed on disk") {
t.Errorf("abort must name the non-quiescence, got: %v", err)
}
assertDestEmpty(t, ctx, dest)
if left, _ := os.ReadDir(workDir); len(left) != 0 {
t.Errorf("work-dir not shredded after a fenced abort: %v", left)
}
})
}
}
+488
View File
@@ -0,0 +1,488 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
hsqlite "github.com/hanzoai/sqlite"
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/store"
)
// TestMain lets the test binary re-exec itself as a FAKE sqlcipher so the
// exec-orchestration tests run with NO external dependency under CGO_ENABLED=0:
// when MIGRATE_V1_FAKE_SQLCIPHER is set, the process acts as the sqlcipher child
// (recording its argv + stdin, then producing/omitting a plaintext export) and
// exits before any Go test runs.
func TestMain(m *testing.M) {
if os.Getenv("MIGRATE_V1_FAKE_SQLCIPHER") != "" {
fakeSQLCipherMain()
return
}
os.Exit(m.Run())
}
var attachRe = regexp.MustCompile(`ATTACH DATABASE '([^']*)' AS plaintext`)
// fakeSQLCipherMain impersonates the C sqlcipher shell. It records the argv it
// was invoked with (to prove the DEK is NOT there) and the stdin script it
// received (to prove the DEK rides stdin), then behaves per FAKE_MODE:
//
// "" success — write a REAL plaintext SQLite db (golden user) to the
// ATTACH target, exit 0.
// "garbage" write a NON-SQLite file to the ATTACH target, exit 0 — the
// migrator's plaintext validation must reject it.
// "exit1" print to stderr and exit 1 — a non-zero child must fail the run.
func fakeSQLCipherMain() {
if p := os.Getenv("FAKE_ARGV_OUT"); p != "" {
_ = os.WriteFile(p, []byte(strings.Join(os.Args[1:], "\x00")), 0o600)
}
script, _ := io.ReadAll(os.Stdin)
if p := os.Getenv("FAKE_STDIN_OUT"); p != "" {
_ = os.WriteFile(p, script, 0o600)
}
target := ""
if mm := attachRe.FindSubmatch(script); mm != nil {
target = string(mm[1])
}
switch os.Getenv("FAKE_MODE") {
case "exit1":
_, _ = os.Stderr.WriteString("fake sqlcipher: simulated failure\n")
os.Exit(1)
case "garbage":
if target != "" {
_ = os.WriteFile(target, []byte("NOT A SQLITE DATABASE"), 0o600)
}
os.Exit(0)
}
if target == "" {
_, _ = os.Stderr.WriteString("fake sqlcipher: no ATTACH target in script\n")
os.Exit(3)
}
if err := writeFakePlaintext(target); err != nil {
_, _ = os.Stderr.WriteString("fake sqlcipher: " + err.Error() + "\n")
os.Exit(4)
}
os.Exit(0)
}
// writeFakePlaintext writes a real plaintext SQLite db (a single golden-digest
// user) to path via the SAME modernc "sqlite" driver the migrator reads with —
// so the orchestration test exercises the true Migrate → cred.Verify chain
// without any real decryption.
func writeFakePlaintext(path string) error {
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
return err
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, email text)`); err != nil {
return err
}
_, err = db.Exec(`INSERT INTO "user" VALUES(?,?,?,?,?,?,?)`,
"hanzo", "z", "2020-01-02T03:04:05Z", "uuid-fake", goldenDigest, "argon2id", "z@hanzo.ai")
return err
}
// --- helpers shared by the WAL tests ---
func randKey(t *testing.T) []byte {
t.Helper()
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
t.Fatal(err)
}
return k
}
// writeGlobalUserShard writes a pure-Go encrypted GLOBAL shard whose only table
// is a golden-digest user — enough for the exec-orchestration tests, which never
// actually decrypt it (the fake ignores its contents; only its .dek sidecar is
// unwrapped to prove the real key path).
func writeGlobalUserShard(t *testing.T, datadir string, master []byte) string {
t.Helper()
dbPath := filepath.Join(datadir, "iam.db")
writeEncryptedShard(t, dbPath, master, hsqlite.PrincipalGlobal, globalPrincipalID, func(db *sql.DB) {
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, password text, password_type text)`)
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?)`, "hanzo", "z", "2020-01-02T03:04:05Z", goldenDigest, "argon2id")
})
return dbPath
}
// expectedDEKHex recomputes the 64-hex shard DEK the migrator will derive, so a
// test can assert the exact key is present on the child's stdin and absent from
// its argv.
func expectedDEKHex(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string) string {
t.Helper()
kek, err := hsqlite.DeriveKey(master, pt, pid)
if err != nil {
t.Fatal(err)
}
wrapped, err := os.ReadFile(dbPath + ".dek")
if err != nil {
t.Fatal(err)
}
dek, err := hsqlite.UnwrapDEK(kek, wrapped, hsqlite.PrincipalAAD(pt, pid))
if err != nil {
t.Fatal(err)
}
return hex.EncodeToString(dek)
}
// TestWALInclusive_Orchestration_KeyOffArgv is the pure-Go proof of the exec
// orchestration: with a fake sqlcipher (this test binary re-exec'd), it asserts
// the temp copy is made, the DEK reaches the child ONLY on stdin (never argv),
// the golden user flows through Migrate → cred.Verify, and the work-dir is
// shredded clean. No external binary, no real decryption — deterministic under
// CGO_ENABLED=0.
func TestWALInclusive_Orchestration_KeyOffArgv(t *testing.T) {
ctx := context.Background()
master := randKey(t)
datadir := t.TempDir()
dbPath := writeGlobalUserShard(t, datadir, master) // single global-only shard
dekHex := expectedDEKHex(t, dbPath, master, hsqlite.PrincipalGlobal, globalPrincipalID)
argvOut := filepath.Join(t.TempDir(), "argv")
stdinOut := filepath.Join(t.TempDir(), "stdin")
t.Setenv("MIGRATE_V1_FAKE_SQLCIPHER", "1")
t.Setenv("FAKE_ARGV_OUT", argvOut)
t.Setenv("FAKE_STDIN_OUT", stdinOut)
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
workDir := t.TempDir()
dest := t.TempDir()
wal := walMode{enabled: true, bin: os.Args[0]}
if err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, wal); err != nil {
t.Fatalf("runEncrypted --wal-inclusive: %v", err)
}
// The golden user flowed through the fake's plaintext export and verifies.
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
if err != nil {
t.Fatalf("reopen dest: %v", err)
}
defer dst.Close()
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
if err != nil || u == nil {
t.Fatalf("user not migrated through --wal-inclusive path: %v", err)
}
if !cred.Verify(cred.Resolve(u.PasswordType, "argon2id"), goldenPassword, u.PasswordHash) {
t.Fatal("golden verify failed through the --wal-inclusive path")
}
// THE key-safety assertion: the DEK is on stdin, NEVER on argv.
argv, err := os.ReadFile(argvOut)
if err != nil {
t.Fatalf("read recorded argv: %v", err)
}
if strings.Contains(string(argv), dekHex) {
t.Fatal("DEK leaked onto the sqlcipher argv/command line")
}
if !strings.Contains(string(argv), "-bail") {
t.Errorf("expected -bail on argv (fail-loud), got %q", argv)
}
script, err := os.ReadFile(stdinOut)
if err != nil {
t.Fatalf("read recorded stdin: %v", err)
}
if !strings.Contains(string(script), dekHex) {
t.Fatal("DEK not on the child's stdin — the key must ride stdin, not argv")
}
if !strings.Contains(string(script), "sqlcipher_export('plaintext')") ||
!strings.Contains(string(script), "wal_checkpoint(TRUNCATE)") {
t.Fatalf("stdin script missing the checkpoint/export SQL:\n%s", script)
}
// The decrypted-temp dir was shredded: the work-dir is empty.
if left, _ := os.ReadDir(workDir); len(left) != 0 {
t.Errorf("work-dir not shredded after run: %v", left)
}
}
// TestWALInclusive_GarbageExportFailsLoud proves a child that exits 0 but emits a
// non-SQLite export (the signature of a wrong key) is caught by plaintext
// validation: the run errors, nothing is written, and the work-dir is shredded.
func TestWALInclusive_GarbageExportFailsLoud(t *testing.T) {
ctx := context.Background()
master := randKey(t)
datadir := t.TempDir()
writeGlobalUserShard(t, datadir, master)
t.Setenv("MIGRATE_V1_FAKE_SQLCIPHER", "1")
t.Setenv("FAKE_MODE", "garbage")
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
workDir := t.TempDir()
dest := t.TempDir()
wal := walMode{enabled: true, bin: os.Args[0]}
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, wal)
if err == nil {
t.Fatal("a non-SQLite export must fail loudly, got nil error")
}
if !strings.Contains(err.Error(), "not a valid SQLite") {
t.Errorf("error should name the invalid export, got: %v", err)
}
if left, _ := os.ReadDir(workDir); len(left) != 0 {
t.Errorf("work-dir not shredded after a failed export: %v", left)
}
assertDestEmpty(t, ctx, dest)
}
// TestWALInclusive_NonZeroExitFailsLoud proves a non-zero sqlcipher exit aborts
// the run before any write.
func TestWALInclusive_NonZeroExitFailsLoud(t *testing.T) {
ctx := context.Background()
master := randKey(t)
datadir := t.TempDir()
writeGlobalUserShard(t, datadir, master)
t.Setenv("MIGRATE_V1_FAKE_SQLCIPHER", "1")
t.Setenv("FAKE_MODE", "exit1")
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
workDir := t.TempDir()
dest := t.TempDir()
wal := walMode{enabled: true, bin: os.Args[0]}
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil, wal)
if err == nil {
t.Fatal("a non-zero sqlcipher exit must fail loudly, got nil error")
}
if left, _ := os.ReadDir(workDir); len(left) != 0 {
t.Errorf("work-dir not shredded after a failed child: %v", left)
}
assertDestEmpty(t, ctx, dest)
}
// TestWALInclusive_MissingBinaryFailsLoud proves --wal-inclusive with an absent
// sqlcipher binary errors at preflight, before any shard is touched — it never
// silently degrades to the WAL-blind checkpointed path.
func TestWALInclusive_MissingBinaryFailsLoud(t *testing.T) {
ctx := context.Background()
master := randKey(t)
datadir := t.TempDir()
writeGlobalUserShard(t, datadir, master)
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
missing := filepath.Join(t.TempDir(), "no-such-sqlcipher")
wal := walMode{enabled: true, bin: missing}
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", t.TempDir(), t.TempDir(), false, nil, wal)
if err == nil {
t.Fatal("--wal-inclusive with a missing sqlcipher binary must fail loudly")
}
if !strings.Contains(err.Error(), "sqlcipher") {
t.Errorf("error should name the missing sqlcipher binary, got: %v", err)
}
}
// --- genuine uncheckpointed-WAL end-to-end (real C sqlcipher; skipped if absent) ---
func sqlcipherOrSkip(t *testing.T) string {
t.Helper()
bin, err := exec.LookPath("sqlcipher")
if err != nil {
t.Skip("C sqlcipher binary not on PATH; skipping genuine-WAL end-to-end test")
}
return bin
}
func keyHeader(dek []byte) string {
return `PRAGMA key = "x'` + hex.EncodeToString(dek) + `'";` + "\n" + "PRAGMA cipher_page_size = 4096;\n"
}
// buildEncryptedDBViaC creates an encrypted db at dbPath under raw key dek and
// runs seedSQL, checkpointed (rollback journal → all rows land in the main db).
func buildEncryptedDBViaC(t *testing.T, bin, dbPath string, dek []byte, seedSQL string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bin, "-bail", dbPath)
cmd.Stdin = strings.NewReader(keyHeader(dek) + seedSQL)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build encrypted db: %v\n%s", err, out)
}
}
// leaveRowsInWAL inserts insertSQL into an existing encrypted db and leaves the
// frames in an UNCHECKPOINTED -wal — the exact production hazard. It switches the
// db to WAL with autocheckpoint off, commits, then KILLS the writer before its
// clean-close checkpoint can run (a held-open stdin keeps the connection alive so
// no checkpoint fires; a sentinel file flushed right after COMMIT synchronizes
// the kill deterministically).
func leaveRowsInWAL(t *testing.T, bin, dbPath string, dek []byte, insertSQL string) {
t.Helper()
sentinel := dbPath + ".committed"
script := keyHeader(dek) +
"PRAGMA journal_mode=WAL;\n" +
"PRAGMA wal_autocheckpoint=0;\n" +
"PRAGMA synchronous=FULL;\n" +
"BEGIN IMMEDIATE;\n" + insertSQL + "\nCOMMIT;\n" +
".output '" + sentinel + "'\n" +
"SELECT 'committed';\n" +
".output stdout\n" // flushes+closes the sentinel; then blocks on stdin
cmd := exec.Command(bin, dbPath)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(stdin, script); err != nil {
t.Fatal(err)
}
// Do NOT close stdin — the shell blocks reading the next line, keeping the
// connection open so no close-checkpoint runs.
deadline := time.Now().Add(20 * time.Second)
for {
if fi, statErr := os.Stat(sentinel); statErr == nil && fi.Size() > 0 {
break
}
if time.Now().After(deadline) {
_ = cmd.Process.Kill()
t.Fatal("timed out waiting for the WAL commit sentinel")
}
time.Sleep(10 * time.Millisecond)
}
_ = cmd.Process.Kill() // kill before clean close → WAL stays uncheckpointed
_ = stdin.Close()
_ = cmd.Wait()
if fi, err := os.Stat(dbPath + "-wal"); err != nil || fi.Size() == 0 {
t.Fatalf("fixture has no uncheckpointed -wal (err=%v)", err)
}
_ = os.Remove(sentinel)
}
// writeDEKSidecar wraps a raw DEK under the (master, principal) KEK and writes the
// .dek sidecar the migrator unwraps — the inverse of deriveDEK.
func writeDEKSidecar(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string, dek []byte) {
t.Helper()
kek, err := hsqlite.DeriveKey(master, pt, pid)
if err != nil {
t.Fatal(err)
}
wrapped, err := hsqlite.WrapDEK(kek, dek, hsqlite.PrincipalAAD(pt, pid))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dbPath+".dek", wrapped, 0o600); err != nil {
t.Fatal(err)
}
}
// TestWALInclusive_RealSQLCipher_MergesUncheckpointedWAL is the genuine
// end-to-end proof against the real C sqlcipher: an org shard carries user `z` in
// its checkpointed main db and user `late` ONLY in its uncheckpointed -wal. The
// DEFAULT (checkpointed) path migrates `z` but MISSES `late`; the --wal-inclusive
// path recovers BOTH, and `late`'s golden argon2id digest verifies under the
// clean verifier — the row a cutover built on DecryptFile alone would have lost.
func TestWALInclusive_RealSQLCipher_MergesUncheckpointedWAL(t *testing.T) {
bin := sqlcipherOrSkip(t)
ctx := context.Background()
master := randKey(t)
datadir := t.TempDir()
const env = "MIGRATE_V1_TEST_MASTER_KEY"
t.Setenv(env, hex.EncodeToString(master))
// GLOBAL shard (C-written, checkpointed): one org.
globalDB := filepath.Join(datadir, "iam.db")
dekG := randKey(t)
buildEncryptedDBViaC(t, bin, globalDB, dekG,
`CREATE TABLE "organization"(owner text, name text, created_time text, password_type text);`+"\n"+
`INSERT INTO "organization" VALUES('admin','hanzo','2020-01-02T03:04:05Z','argon2id');`+"\n")
writeDEKSidecar(t, globalDB, master, hsqlite.PrincipalGlobal, globalPrincipalID, dekG)
// ORG shard (C-written): base user z (checkpointed) + user late (in -wal only).
orgDB := filepath.Join(datadir, "orgs", "hanzo", "iam.db")
dekO := randKey(t)
buildEncryptedDBViaC(t, bin, orgDB, dekO,
`CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, email text);`+"\n"+
`INSERT INTO "user" VALUES('hanzo','z','2020-01-02T03:04:05Z','uuid-z','`+goldenDigest+`','argon2id','z@hanzo.ai');`+"\n")
leaveRowsInWAL(t, bin, orgDB, dekO,
`INSERT INTO "user" VALUES('hanzo','late','2020-03-03T03:04:05Z','uuid-late','`+goldenDigest+`','argon2id','late@hanzo.ai');`)
writeDEKSidecar(t, orgDB, master, hsqlite.PrincipalOrg, "hanzo", dekO)
// ---- DEFAULT (checkpointed) path now HARD-FAILS on the shard's non-empty -wal
// (HIGH #2): it would silently drop `late`, so with no flag it must refuse. ----
if err := runEncrypted(ctx, datadir, env, t.TempDir(), t.TempDir(), false, nil, walMode{}); err == nil {
t.Fatal("default path must refuse a shard carrying a non-empty uncheckpointed -wal")
} else if !strings.Contains(err.Error(), "--wal-inclusive") || !strings.Contains(err.Error(), "--ignore-wal") {
t.Errorf("hard-fail must name both escape hatches, got: %v", err)
}
// ---- --ignore-wal: proceeds down the checkpointed path — migrates z, MISSES the
// WAL-only late (the documented, opt-in lossy behavior). ----
destCk := t.TempDir()
if err := runEncrypted(ctx, datadir, env, t.TempDir(), destCk, false, nil, walMode{ignoreWAL: true}); err != nil {
t.Fatalf("--ignore-wal checkpointed runEncrypted: %v", err)
}
dck, err := store.Open("sqlite", filepath.Join(destCk, "iam2.db"))
if err != nil {
t.Fatalf("reopen checkpointed dest: %v", err)
}
defer dck.Close()
if u, _ := store.GetUserByName(ctx, dck, "hanzo", "z"); u == nil {
t.Fatal("--ignore-wal checkpointed path lost the base user z")
}
if late, _ := store.GetUserByName(ctx, dck, "hanzo", "late"); late != nil {
t.Fatal("--ignore-wal path unexpectedly saw the WAL-only user — fixture WAL was already checkpointed")
}
// ---- --wal-inclusive path: recovers BOTH, and late verifies golden. ----
destWal := t.TempDir()
workDir := t.TempDir()
if err := runEncrypted(ctx, datadir, env, workDir, destWal, false, nil, walMode{enabled: true, bin: bin}); err != nil {
t.Fatalf("wal-inclusive runEncrypted: %v", err)
}
dw, err := store.Open("sqlite", filepath.Join(destWal, "iam2.db"))
if err != nil {
t.Fatalf("reopen wal-inclusive dest: %v", err)
}
defer dw.Close()
if base, err := store.GetUserByName(ctx, dw, "hanzo", "z"); err != nil || base == nil {
t.Fatalf("wal-inclusive path lost the base user z: %v", err)
}
late, err := store.GetUserByName(ctx, dw, "hanzo", "late")
if err != nil || late == nil {
t.Fatal("--wal-inclusive did NOT recover the uncheckpointed-WAL user — the fix is broken")
}
if late.PasswordHash != goldenDigest {
t.Fatalf("recovered WAL user hash NOT verbatim:\n got %q\nwant %q", late.PasswordHash, goldenDigest)
}
if !cred.Verify(cred.Resolve(late.PasswordType, "argon2id"), goldenPassword, late.PasswordHash) {
t.Fatal("cred.Verify REJECTED the WAL-recovered user's digest — that user could not log in at cutover")
}
if left, _ := os.ReadDir(workDir); len(left) != 0 {
t.Errorf("work-dir not shredded after wal-inclusive run: %v", left)
}
}
// assertDestEmpty fails if a dest store received any user (used after a run that
// must abort before writing).
func assertDestEmpty(t *testing.T, ctx context.Context, dest string) {
t.Helper()
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
if err != nil {
return // no store created at all is the strongest possible "empty"
}
defer dst.Close()
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u != nil {
t.Fatal("a failed --wal-inclusive run still wrote a user — must abort before any write")
}
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package feature is the seam enterprise capabilities plug into. A module
// (hanzoiam/scim, saml, ldap, …) implements Feature and reads/writes the core's
// identity via the injected Store — so it shares ONE identity store with the core
// and never carries a second copy. The core NEVER imports a module; dependency
// flows one way (module → feature).
package feature
import (
"context"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/pkg/model"
)
// Store is the identity surface a feature needs — the union of the calls the
// copied Casdoor code makes (object.* → store.*). The core implements it over its
// orm store (internal/featurestore). A feature ignores methods it doesn't use.
type Store interface {
GetUser(ctx context.Context, owner, name string) (*model.User, error)
GetUserByID(ctx context.Context, id string) (*model.User, error)
GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error)
AddUser(ctx context.Context, u *model.User) (bool, error)
UpdateUser(ctx context.Context, u *model.User) (bool, error)
DeleteUser(ctx context.Context, owner, name string) (bool, error)
GetApplication(ctx context.Context, id string) (*model.Application, error)
GetOrganization(ctx context.Context, name string) (*model.Organization, error)
// GetProvider resolves an identity provider by (owner, name) — the SP-inbound
// SAML/OAuth surface (a user signing in through a corporate IdP where Hanzo is
// the Service Provider). SAML SP-initiated login reads its IdP config from here.
GetProvider(ctx context.Context, owner, name string) (*model.Provider, error)
// GetCert resolves a signing cert by (owner, name) — SAML metadata signing, etc.
GetCert(ctx context.Context, owner, name string) (*model.Cert, error)
// SetPassword sets a user's password: the core hashes the plaintext exactly
// once and stores only the one-way digest (never the clear text). Used by SCIM
// to provision the `password` attribute. An empty plaintext leaves the digest
// untouched. Hashing lives in ONE place (the core) — a module never sees a hash.
SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
// VerifyPassword reports whether plaintext matches the user's stored digest
// (argon2id for migrated v1 rows, bcrypt for v2, per the org's password type).
// Used by LDAP bind — verification stays in the core, never in a module.
VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
}
// Feature is one pluggable enterprise capability. Mount registers its routes on
// the shared app, backed by store. Name is for diagnostics.
type Feature interface {
Name() string
Mount(app *zip.App, store Store) error
}
var registry []Feature
// Register adds a feature to the set MountAll mounts. Called by the composing
// binary (cloud) or a module init — the core decides which enterprise features ship.
func Register(f Feature) { registry = append(registry, f) }
// Registered returns the registered features (diagnostics/tests).
func Registered() []Feature { return append([]Feature(nil), registry...) }
// MountAll mounts every registered feature on app with store, fail-fast: a
// registered-but-broken enterprise module surfaces loudly at boot, never a silent no-op.
func MountAll(app *zip.App, store Store) error {
for _, f := range registry {
if err := f.Mount(app, store); err != nil {
return err
}
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package feature_test
import (
"context"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/pkg/model"
)
// A registered feature is mounted by MountAll and reaches the app + store; a
// module that fails to mount surfaces the error (fail-fast).
type fakeFeature struct {
name string
mounted bool
err error
}
func (f *fakeFeature) Name() string { return f.name }
func (f *fakeFeature) Mount(app *zip.App, store feature.Store) error {
f.mounted = true
return f.err
}
type nopStore struct{}
func (nopStore) GetUser(context.Context, string, string) (*model.User, error) { return nil, nil }
func (nopStore) GetUserByID(context.Context, string) (*model.User, error) { return nil, nil }
func (nopStore) GetGlobalUsers(context.Context, int, int) ([]*model.User, int, error) {
return nil, 0, nil
}
func (nopStore) AddUser(context.Context, *model.User) (bool, error) { return true, nil }
func (nopStore) UpdateUser(context.Context, *model.User) (bool, error) { return true, nil }
func (nopStore) DeleteUser(context.Context, string, string) (bool, error) { return true, nil }
func (nopStore) GetApplication(context.Context, string) (*model.Application, error) { return nil, nil }
func (nopStore) GetOrganization(context.Context, string) (*model.Organization, error) {
return nil, nil
}
func (nopStore) GetCert(context.Context, string, string) (*model.Cert, error) { return nil, nil }
func (nopStore) GetProvider(context.Context, string, string) (*model.Provider, error) {
return nil, nil
}
func (nopStore) SetPassword(context.Context, string, string, string) (bool, error) {
return true, nil
}
func (nopStore) VerifyPassword(context.Context, string, string, string) (bool, error) {
return true, nil
}
func TestMountAll_MountsRegistered(t *testing.T) {
f := &fakeFeature{name: "fake"}
feature.Register(f)
app := zip.New(zip.Config{DisableStartupMessage: true})
if err := feature.MountAll(app, nopStore{}); err != nil {
t.Fatalf("MountAll: %v", err)
}
if !f.mounted {
t.Fatal("registered feature was not mounted")
}
found := false
for _, r := range feature.Registered() {
if r.Name() == "fake" {
found = true
}
}
if !found {
t.Fatal("Registered() did not list the feature")
}
}
+37 -18
View File
@@ -1,4 +1,4 @@
module github.com/hanzoai/iam2
module github.com/hanzoai/iam
go 1.26.4
@@ -8,7 +8,8 @@ go 1.26.4
require (
github.com/hanzoai/orm v0.6.1
github.com/spf13/cobra v1.10.2
github.com/zap-proto/zip v1.6.0
github.com/zap-proto/zip v1.8.3
golang.org/x/crypto v0.53.0
)
// Migration-only: linked solely in `go build -tags migration` so `iam2 compare`
@@ -19,10 +20,24 @@ require (
github.com/jackc/pgx/v5 v5.9.2
)
require (
github.com/alexedwards/argon2id v1.0.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/hanzoai/sqlcipher v0.1.0
github.com/hanzoai/sqlite v0.2.1
github.com/luxfi/crypto v1.20.1
github.com/luxwallet/connect/go v0.1.4
github.com/pquerna/otp v1.5.0
github.com/zap-proto/fiber/v3 v3.2.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
@@ -33,45 +48,49 @@ require (
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/hanzoai/dbx v1.16.0 // indirect
github.com/hanzoai/kv-go/v9 v9.18.0 // indirect
github.com/hanzoai/sqlite v0.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/codec v1.1.4 // indirect
github.com/luxfi/container v0.0.4 // indirect
github.com/luxfi/ids v1.2.10 // indirect
github.com/luxfi/log v1.4.3 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/metric v1.5.7 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/http v0.2.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.1 // indirect
)
// Local checkouts during the migration so iam2 stays in sync with patches
// landing in orm and zip. Switch to pinned vX.Y.Z once the v2 surface
// stabilises (Phase 1).
replace (
github.com/hanzoai/orm => ../orm
github.com/zap-proto/zip => ../../zap-proto/zip
)
+116 -21
View File
@@ -2,18 +2,28 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
@@ -36,14 +46,24 @@ github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/hanzoai/dbx v1.16.0 h1:C8wsb9BIiit4nYnXizpcB4SyzVaepPkQFwq5i9fxAV0=
github.com/hanzoai/dbx v1.16.0/go.mod h1:ynP6HSiDDoFZ8M3DC+XvSglBPFRygfTd/gjTWabh4yA=
github.com/hanzoai/kv-go/v9 v9.18.0 h1:vO2SD8dV0+H9WWCVKV9KHaWZq4yeMsZruohrsZN9448=
github.com/hanzoai/kv-go/v9 v9.18.0/go.mod h1:S+Li20E6Bskpw6r+c8WWhfi4hCr8SVV32qPXO0wdl+E=
github.com/hanzoai/orm v0.6.1 h1:PELYVy+kTVuA7hqn1y3IQqR1Q5cTk008Wh4CLn9Isok=
github.com/hanzoai/orm v0.6.1/go.mod h1:7tXULhLKymkAwlC+jASS66tlLEzU2sdCXX1sRFPoAFs=
github.com/hanzoai/sqlcipher v0.1.0 h1:V9gKG3ZltN2ZCteDrOnXWfOeEe/YDhhUm9AorQEAuBo=
github.com/hanzoai/sqlcipher v0.1.0/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
github.com/hanzoai/sqlite v0.2.1 h1:PqUty8+NhJsfwzT5K/U6vgFSIykM1vM0GMLeoH2KWio=
github.com/hanzoai/sqlite v0.2.1/go.mod h1:SVhzKrbEovivr/sEaL/Wgw81a7Xfy6gSoOMzuRCvt7s=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -58,24 +78,53 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
github.com/luxfi/crypto v1.20.1 h1:d0/jW7vVVQZbeGJNVmtMKkrhjTM6BtqEOWH234iUghM=
github.com/luxfi/crypto v1.20.1/go.mod h1:bLCBuIV/KDjPytld7jSYe1WbfWknPQXcivq88Qo96QU=
github.com/luxfi/ids v1.2.10 h1:f1WILZE199ayMuqnEyB2WP1qfMZkmozOQXSVYtB3e5k=
github.com/luxfi/ids v1.2.10/go.mod h1:QBIwy3OHvrtskbUqKh1+OYRa6PsyR7f7oNX33sOfK7w=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/metric v1.5.7 h1:LoSPEUpak2SLcynF+LT2cXjl9ECp4nY+Lia9zudmDv4=
github.com/luxfi/metric v1.5.7/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
github.com/luxwallet/connect/go v0.1.4 h1:Gmyl+MkrDxGI9jUjSzRt2yL/CL32apcLxVUdvoJdD7A=
github.com/luxwallet/connect/go v0.1.4/go.mod h1:ReVK757g7VqTfcbUNg5SinpjBCzMgilEYm+Gux8tdmo=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 h1:yfQ2sO9WJXUAIUR+g7NUkxJSKCAFJcR5sUDu+ZmjTZI=
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -100,32 +149,78 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/http v0.2.0 h1:WiTqJ7Wh0O2qA3DNhvyi0b9F4j2wX8ctZDlW46WMxWQ=
github.com/zap-proto/http v0.2.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/zip v1.8.3 h1:oSDtwtgOGaQJPwolZ/Ga4YRR/Mips+n6CpowX4V9BW4=
github.com/zap-proto/zip v1.8.3/go.mod h1:TJ8ZwpwLQphqr1pYRr2cjzL8DbMmUljRKmrAPzM9S+4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package applications is the Phase-1 typed CRUD surface for the `applications`
// entity. Every operation is a zip typed handler (decode In -> run -> encode
// Out) over hanzoai/orm and is owner-scoped by the (owner, name) natural key,
// materialized as the orm id "<owner>/<name>". The same In/Out types back both
// the REST route and the MCP tools/call projection zip derives from them, so
// identity arguments travel in the typed request, not in ad-hoc path parsing.
package applications
import (
"context"
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// authorizeOrganization gates the Organization an application will SERVE (the
// tenant a credential minted through it lands in), not just its registry Owner:
// the op-invoke authz hook authorizes the top-level Owner, but Organization is a
// separate field that a tenant admin could otherwise set to the reserved admin
// org (a SuperAdmin-minting app) or to a victim tenant. On a gated HTTP request
// the Guard attached a Principal; a non-super may point an app only at its OWN
// org. A server-internal call (bootstrap/seed) carries no Principal and is
// trusted, so an unauthenticated context is left to the surrounding trust
// boundary rather than blocked here.
func authorizeOrganization(ctx context.Context, in *schema.Application) error {
if in.Organization == "" {
return nil // an org-less app mints no cross-tenant/SuperAdmin identity
}
p, ok := authz.From(ctx)
if !ok {
return nil // server-internal (no principal) — trusted caller
}
if !authz.CanSetOrg(p, in.Organization) {
return zip.ErrForbidden("not authorized to set the application organization to " + in.Organization)
}
return nil
}
// ensureClientIdUnique rejects a create/update whose clientId is already held by a
// DIFFERENT application (any owner). clientId is the GLOBAL key the mint and Basic-auth
// resolvers authenticate against, so it must be unique across every owner, not merely
// within one — otherwise a tenant could register a row whose clientId collides with a
// platform console's and (on a backend whose duplicate-row order is unspecified) shadow
// it. A JSON-document store has no per-field column to carry a DB UNIQUE index, so the
// invariant is enforced here at the write, exactly as the (owner,name) natural key is.
// An empty clientId cannot collide (a public app authenticates no confidential grant);
// the self-row (same owner,name) is skipped so an update that keeps its own clientId is
// never a self-collision.
func ensureClientIdUnique(ctx context.Context, db orm.DB, clientId, owner, name string) error {
if clientId == "" {
return nil
}
existing, err := store.ListApplicationsByClientId(ctx, db, clientId)
if err != nil {
return zip.ErrInternal(err.Error())
}
for _, a := range existing {
if a.Owner != owner || a.Name != name {
return zip.ErrConflict("clientId already in use: " + clientId)
}
}
return nil
}
// appID is the owner-scoped natural key "<owner>/<name>" — the single source
// of an application's orm id. Every handler routes through it so reads and
// writes address the exact same row.
func appID(owner, name string) string { return owner + "/" + name }
// ApplicationRef identifies one application by its owner-scoped natural key.
// It is the input for the get and delete operations.
type ApplicationRef struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
}
// ApplicationQuery filters applications by owner for the list operation.
type ApplicationQuery struct {
Owner string `json:"owner" validate:"required"`
}
// ApplicationListResult wraps the applications owned by one owner, newest
// first.
type ApplicationListResult struct {
Applications []*schema.Application `json:"applications"`
}
// DeleteResult reports the outcome of a delete operation.
type DeleteResult struct {
Deleted bool `json:"deleted"`
}
// Route registers the applications CRUD surface on app, closing over db. Reads
// use GET, create POST, update PUT, delete DELETE — every one a zip typed
// handler.
func Route(app *zip.App, db orm.DB) {
zip.Get(app, "/v1/iam/applications", listApplications(db),
zip.WithSummary("List applications for an owner"), zip.WithTags("applications"))
zip.Get(app, "/v1/iam/application", getApplication(db),
zip.WithSummary("Get one application by owner and name"), zip.WithTags("applications"))
zip.Post(app, "/v1/iam/application", Create(db),
zip.WithSummary("Create an application"), zip.WithTags("applications"))
zip.Put(app, "/v1/iam/application", Update(db),
zip.WithSummary("Update an application"), zip.WithTags("applications"))
zip.Delete(app, "/v1/iam/application", deleteApplication(db),
zip.WithSummary("Delete an application"), zip.WithTags("applications"))
}
// listApplications returns every application owned by in.Owner, ordered by
// creation time descending.
func listApplications(db orm.DB) zip.TypedHandler[ApplicationQuery, ApplicationListResult] {
return func(ctx context.Context, in *ApplicationQuery) (*ApplicationListResult, error) {
if in.Owner == "" {
return nil, zip.ErrBadRequest("owner is required")
}
apps, err := orm.TypedQuery[schema.Application](db).
Filter("Owner=", in.Owner).
Order("-CreatedTime").
GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
for i, app := range apps {
apps[i] = app.Mask() // never emit clientSecret in a list response
}
return &ApplicationListResult{Applications: apps}, nil
}
}
// getApplication returns the application at (in.Owner, in.Name).
func getApplication(db orm.DB) zip.TypedHandler[ApplicationRef, schema.Application] {
return func(ctx context.Context, in *ApplicationRef) (*schema.Application, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
id := appID(in.Owner, in.Name)
app, err := orm.Get[schema.Application](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("application not found: " + id)
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return app.Mask(), nil
}
}
// Create persists a new application under (in.Owner, in.Name), rejecting a
// collision on that owner-scoped key. Exported so the Casdoor add-application
// alias reuses this exact logic (no duplication); the REST route and the alias
// share the one create path.
func Create(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := authorizeOrganization(ctx, in); err != nil {
return nil, err
}
id := appID(in.Owner, in.Name)
// Owner-scoped uniqueness: (owner, name) must be free.
if _, err := orm.Get[schema.Application](db, id); err == nil {
return nil, zip.ErrConflict("application already exists: " + id)
} else if !errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrInternal(err.Error())
}
// Global uniqueness: clientId is the mint/Basic-auth resolution key, so it must
// be free across ALL owners — the invariant the confidential-client gates rely on.
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
return nil, err
}
// Wire the decoded entity to db under its natural key and persist.
in.Init(db)
in.SetId(id)
if err := in.Create(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return in.Mask(), nil
}
}
// Update overwrites the application at (in.Owner, in.Name), preserving its
// immutable creation metadata. The (owner, name) identity is fixed by the record,
// not editable through the body. Exported so the Casdoor update-application alias
// reuses this exact logic (no duplication).
func Update(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if err := authorizeOrganization(ctx, in); err != nil {
return nil, err
}
id := appID(in.Owner, in.Name)
existing, err := orm.Get[schema.Application](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("application not found: " + id)
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
// Global clientId uniqueness (see Create): an update may keep its own clientId
// but must never steal another app's.
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
return nil, err
}
in.Init(db)
in.SetId(id)
in.CreatedTime = existing.CreatedTime
in.CreatedAt = existing.CreatedAt
if err := in.Update(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return in.Mask(), nil
}
}
// deleteApplication removes the application at (in.Owner, in.Name).
// Delete exposes the delete handler so the Casdoor `delete-application` verb alias
// (internal/compat) can reuse it — one delete path, wrapped in the compat envelope.
func Delete(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] { return deleteApplication(db) }
func deleteApplication(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] {
return func(ctx context.Context, in *ApplicationRef) (*DeleteResult, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
id := appID(in.Owner, in.Name)
app, err := orm.Get[schema.Application](db, id)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("application not found: " + id)
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := app.Delete(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteResult{Deleted: true}, nil
}
}
@@ -0,0 +1,90 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package applications
import (
"context"
"path/filepath"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam/internal/schema"
)
func memDB(t *testing.T) orm.DB {
t.Helper()
_ = schema.Kinds()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "apptest.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// The clientId global-uniqueness guard on create: a create may not take a clientId
// already held by a DIFFERENT (owner,name), so a tenant can never register a row that
// collides with a platform console's confidential-client key. This is the store-layer
// enforcement of the invariant the mint/Basic-auth gates rely on (a JSON-document
// store has no column for a DB UNIQUE index). A background ctx carries no principal,
// so authorizeOrganization is the trusted server-internal path and the guard is
// exercised in isolation.
func TestCreate_RejectsDuplicateClientId(t *testing.T) {
db := memDB(t)
ctx := context.Background()
create := Create(db)
// The legit platform console.
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
t.Fatalf("seed console: %v", err)
}
// A tenant tries to register a DIFFERENT (owner,name) with the SAME clientId.
if _, err := create(ctx, &schema.Application{Owner: "evil", Name: "evil-console", ClientId: "hanzo-console"}); err == nil {
t.Fatal("HIGH REOPENED: a colliding clientId was accepted on create")
}
// A distinct clientId under a tenant is fine (no false positive).
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
t.Fatalf("a distinct clientId must be accepted: %v", err)
}
// A public app (no clientId) never collides with another public app.
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-a"}); err != nil {
t.Fatalf("empty clientId #1: %v", err)
}
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-b"}); err != nil {
t.Fatalf("empty clientId #2 must not collide with #1: %v", err)
}
}
// Update may keep its OWN clientId (the self-row is skipped, never a self-collision)
// but must not steal another app's.
func TestUpdate_ClientIdCollision(t *testing.T) {
db := memDB(t)
ctx := context.Background()
create, update := Create(db), Update(db)
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
t.Fatalf("seed console: %v", err)
}
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
t.Fatalf("seed tenant app: %v", err)
}
// hanzo-app keeps its own clientId on update — allowed (self-row skipped).
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app", DisplayName: "renamed"}); err != nil {
t.Fatalf("keeping own clientId on update must be allowed: %v", err)
}
// hanzo-app tries to STEAL the console's clientId — rejected.
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-console"}); err == nil {
t.Fatal("HIGH REOPENED: an update stole another app's clientId")
}
}
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package auditlogs serves the IAM v2 CRUD surface for the `audit_logs` entity:
// an append-only action record owner-scoped by (owner, name). Every operation
// is a typed zip handler over hanzoai/orm; the orm string key is "owner/name".
// Reads scope to one owner (organization); writes address one log by its
// (owner, name) key. Rows are written once at request time — the update path
// exists only for administrative correction, never for normal operation.
package auditlogs
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// Handler binds the audit-log operations to one orm store.
type Handler struct {
db orm.DB
}
// Route registers the audit-log CRUD routes on app against db.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/audit-logs", h.List, zip.WithSummary("List audit logs for an owner"), zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs", h.Create, zip.WithSummary("Create an audit log"), zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs/get", h.Get, zip.WithSummary("Get one audit log"), zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs/update", h.Update, zip.WithSummary("Update an audit log"), zip.WithTags("audit-logs"))
zip.Post(app, "/v1/iam/audit-logs/delete", h.Delete, zip.WithSummary("Delete an audit log"), zip.WithTags("audit-logs"))
}
// Ref addresses one audit log by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// Input is the writable projection of an audit log (the v1 add/update-record
// body). It keeps the wire contract clean of the orm.Model bookkeeping fields
// and of the v1 integer surrogate id, which the orm string key supersedes.
type Input struct {
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
Organization string `json:"organization"`
ClientIp string `json:"clientIp"`
User string `json:"user"`
Method string `json:"method"`
RequestUri string `json:"requestUri"`
Action string `json:"action"`
Language string `json:"language"`
Object string `json:"object"`
Response string `json:"response"`
StatusCode int `json:"statusCode"`
IsTriggered bool `json:"isTriggered"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of audit logs, newest first.
type ListOutput struct {
AuditLogs []*schema.AuditLog `json:"auditLogs"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// key builds the orm string key from the (owner, name) natural key.
func key(owner, name string) string { return owner + "/" + name }
// apply copies the mutable domain fields of an Input onto an audit log. The
// identity fields (owner, name) and the created stamp are set only on Create,
// never overwritten by an update.
func apply(dst *schema.AuditLog, in *Input) {
dst.Organization = in.Organization
dst.ClientIp = in.ClientIp
dst.User = in.User
dst.Method = in.Method
dst.RequestUri = in.RequestUri
dst.Action = in.Action
dst.Language = in.Language
dst.Object = in.Object
dst.Response = in.Response
dst.StatusCode = in.StatusCode
dst.IsTriggered = in.IsTriggered
}
// List returns the audit logs for one owner, newest first. An empty owner lists
// every log (the unscoped admin view).
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
q := orm.TypedQuery[schema.AuditLog](h.db)
if in.Owner != "" {
q = q.Filter("owner", in.Owner)
}
logs, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListOutput{AuditLogs: logs, Total: len(logs)}, nil
}
// Get returns one audit log addressed by (owner, name).
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.AuditLog, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return log, nil
}
// Create persists a new audit log. It rejects a duplicate (owner, name).
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.AuditLog, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("audit log already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
log := orm.New[schema.AuditLog](h.db)
log.Owner = in.Owner
log.Name = in.Name
log.CreatedTime = in.CreatedTime
if log.CreatedTime == "" {
log.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
apply(log, in)
log.SetId(key(in.Owner, in.Name))
if err := log.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return log, nil
}
// Update mutates an existing audit log in place. Identity and created stamp are
// immutable; a missing log is a 404. Audit rows are append-only in normal
// operation — this path is for administrative correction only.
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.AuditLog, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
apply(log, in)
if err := log.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return log, nil
}
// Delete removes one audit log addressed by (owner, name).
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := log.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// mapErr translates an orm lookup error into the matching HTTP status.
func mapErr(err error) error {
if errors.Is(err, orm.ErrNotFound) {
return zip.ErrNotFound("audit log not found")
}
return zip.ErrInternal(err.Error())
}
+506
View File
@@ -0,0 +1,506 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package authz is the IAM v2 authorization seam in front of the Phase-1 entity
// CRUD, which is otherwise unauthenticated — the door an attacker would walk
// through to overwrite an admin-owned signing cert and forge tokens. It is two
// orthogonal decisions, never braided:
//
// - AUTHENTICATION — the Guard middleware, mounted ONCE via app.Use, AFTER the
// public group and BEFORE the authed routes. Public (pre-authentication)
// routes are registered first, so a matched one terminates fiber's middleware
// walk and the Guard never runs on it — public vs gated is structural (which
// group a route is on), not an allow-list. Every request the Guard wraps must
// carry a verified bearer; the resolved Principal is attached to the request
// context for the authorization decision and audit. Fails closed (401).
//
// - AUTHORIZATION — the Authorize hook, installed ONCE via app.Authorize. It
// runs at the framework's op-invoke seam, on the DECODED typed input the
// handler will act on, for REST and MCP alike. The value it authorizes is by
// construction the value the handler binds: there is no second parse of the
// body for it to diverge from. Fails closed (403).
//
// Splitting the two removes the defect a single body-reparsing middleware had:
// authorizing a target extracted from the raw bytes divergently from where the
// handler binds it. A write's target now comes from the one decode the handler
// itself runs on. A read's target rides in the query string (a GET has no body
// for the op seam to decode), so the Guard authorizes reads there; a read invoked
// over MCP DOES decode a target into its input, and the op seam authorizes that.
//
// Three scopes, never conflated (conflation is privilege escalation):
//
// - SuperAdmin — the principal's organization is the reserved "admin" org.
// The ONLY cross-tenant scope. Required for every write to a platform-owned
// (admin/built-in) resource: the signing-cert poisoning gate, admin-scoped
// application/provider registration, every reserved surface.
// - Org admin — IsAdmin, scoped to its OWN organization. Manages every
// resource its org owns; never another org's, never a platform-owned one.
// - Regular user — self-service only: reading its own user record.
//
// One predicate governs SuperAdmin everywhere: the principal's organization is
// "admin". That organization comes from the token SUBJECT — the authenticated
// principal's own owner/name — never from the token's `owner`/`organization`
// claims. Those name the APPLICATION's org and diverge from the user's org for a
// shared app, so trusting them would let a tenant user sign in through a shared
// admin-org app and read as SuperAdmin. Authenticity, expiry, algorithm, and
// signing-key trust are delegated to the same oidc.VerifyToken every protected
// route already uses; the org-admin flag comes from the loaded user record, the
// authoritative source (it is not a token claim).
package authz
import (
"context"
"crypto/subtle"
"errors"
"reflect"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/store"
)
// adminOrg is the reserved organization whose membership IS SuperAdmin — the one
// cross-tenant scope, the one predicate. The broader reserved-owner set
// {admin, built-in} the poisoning gate protects lives in ONE place,
// store.IsSigningCertOwner, shared with the token verifier and the JWKS.
const adminOrg = "admin"
// Principal is the identity a gated request acts as, resolved from a verified
// bearer. Org is the tenant (the authenticated principal's own org, from the
// subject); User is its name within that org (empty for a machine token); Admin
// is the org-admin flag; Super is the SuperAdmin predicate (Org == adminOrg).
type Principal struct {
Org string
User string
// App is the application NAME when the request authenticated as a confidential
// client (client_secret_basic), and "" for every human. An app principal is
// never Admin and never Super — its whole authority is its capability allowlist
// (cap.go), so a leaked client credential can neither read another tenant nor
// touch signing material.
App string
// AppOwner is the OWNING organization of that application row — "admin"/"built-in"
// for a platform app, the tenant's own org for a customer app. It is NOT App's
// served Organization. A capability (cap.go Allowed) is granted ONLY when this is
// a reserved platform signing owner, so a tenant that registers an app whose NAME
// (or clientId) collides with a platform console inherits none of its authority:
// the allowlist keys on the name, and the owner-pin binds that name to the
// platform. Empty for every human.
AppOwner string
Admin bool
Super bool
}
type ctxKey struct{}
// From returns the Principal the Guard attached to ctx for a gated request, and
// whether one is present (public routes carry none).
func From(ctx context.Context) (*Principal, bool) {
p, ok := ctx.Value(ctxKey{}).(*Principal)
return p, ok
}
// Scope resolves the owner a listing is bound to: a SuperAdmin lists the owner
// it asks for (empty = every tenant), anyone else lists only its own org. The
// org comes from the verified bearer, so a request parameter can never widen a
// read beyond the caller's authority — the one value authorized is the one value
// queried. Every owner-scoped lister resolves its owner here.
func Scope(ctx context.Context, owner string) (string, error) {
p, ok := From(ctx)
if !ok {
return "", zip.ErrForbidden("no principal")
}
if p.Super {
return owner, nil
}
return p.Org, nil
}
// Can reports whether the ctx principal may perform `method` on the entity's
// (owner, name) — the SAME policy the op-invoke seam (Authorize) applies, exposed
// for a RAW handler that does not pass through app.Authorize (e.g. SCIM, whose
// writes call the CRUD directly). Owner-pinning via Scope alone is NOT sufficient
// for a write: it enforces tenant isolation but not the admin/self clause, so a
// raw handler MUST call this. Fails closed when no principal is present.
func Can(ctx context.Context, method, entity, owner, name string) bool {
p, ok := From(ctx)
if !ok {
return false
}
return authorize(p, method, entity, owner, name)
}
// IsSuper reports whether the ctx principal is a SuperAdmin — used by a raw
// handler to gate a privileged field (e.g. provision-don't-promote: only a super
// may set isAdmin). Fails closed when no principal is present.
func IsSuper(ctx context.Context) bool {
p, ok := From(ctx)
return ok && p.Super
}
// CanSetOrg reports whether principal p may point a resource at organization
// `org` — the tenant an application SERVES (the org every credential minted
// through that app lands in), authorized EXACTLY as an owner target through the
// one policy: a SuperAdmin may set any org; anyone else only their OWN org, never
// a reserved platform org (admin/built-in — the SuperAdmin/signing vector) nor
// another tenant (cross-tenant mint). It is the gate the application create/update
// path applies to the Organization FIELD — closing the hole where authorizing only
// the top-level Owner let a tenant admin register an app whose Organization named
// the admin org (SuperAdmin) or a victim tenant. Fails closed on a nil principal.
func CanSetOrg(p *Principal, org string) bool {
if p == nil {
return false
}
return authorize(p, "POST", "applications", org, "")
}
// Optional resolves the Principal a PUBLIC route's caller happens to carry, or
// nil when the request is anonymous or its bearer does not verify. The Guard
// admits a public path WITHOUT resolving a principal (a browser must reach the
// pre-auth surface before it holds a token), so From() is empty there — a public
// handler that legitimately honors an authenticated caller resolves it here.
//
// It is the same fail-closed resolution every gated route runs (one verifier,
// one user load, one revocation check); only the outcome differs — a bad bearer
// is nil rather than a 401, because the caller's flow continues anonymously.
// A handler must therefore treat a nil Principal as "anonymous", never as an
// error, and must never widen authority on the strength of this alone: it proves
// only WHO the caller is, not that the caller INTENDED this request (the wallet
// link branch pairs it with a same-site check for exactly that reason).
func Optional(c *zip.Ctx, db orm.DB) *Principal {
p, err := principal(c, db)
if err != nil {
return nil
}
return p
}
// Fail-closed reasons. The Guard collapses all of them to one opaque 401 so a
// prober cannot tell a bad signature from an expired token from a revoked user.
var (
errNoBearer = errors.New("authz: no bearer")
errNoSubject = errors.New("authz: token subject carries no org")
errRevoked = errors.New("authz: principal is forbidden or deleted")
)
// isRead reports whether a method addresses its target through the query string
// rather than a body: a GET (or HEAD) has no body for the op-invoke seam to
// decode, so its target is authorized in the Guard. Every other method carries a
// body decoded once by the op and is authorized at that seam.
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
// ReadTarget extracts the (owner, name) a GET addresses, from the query string.
// A native typed read files them as `?owner=&name=`; the Casdoor compat verbs
// (get-user, get-organization, …) file them as `?id=<owner>/<name>`. Explicit
// owner/name win; the id split is a fallback only when owner is absent, so this
// can only make an id-based read's authorization MORE precise than the empty
// target it resolves to today (which fail-closed denies every non-super). It
// never widens: the tenant rule still pins owner to the principal's org, and the
// handler independently re-scopes the query owner through Scope, so a request
// that spells one owner in `?owner` and another in `?id` cannot read across
// tenants — the authorized owner and the queried owner are both pinned.
//
// It is exported so the compat read aliases resolve their target through the
// SAME function the Guard authorizes with: one extraction, so a handler can
// never address a row the Guard did not authorize.
func ReadTarget(c *zip.Ctx) (owner, name string) {
owner, name = c.Query("owner"), c.Query("name")
if owner == "" {
if o, n, ok := strings.Cut(c.Query("id"), "/"); ok && o != "" {
return o, n
}
}
return owner, name
}
// handlerAuthorizedPrefixes are path subtrees whose target rides in the PATH, not
// the query — the Guard authenticates them (a bearer is still required) but does
// NOT pre-authorize the read; the handler authorizes on the path id via
// authz.Scope. SCIM (RFC 7644, /v1/iam/scim/v2/Users/{id}) is path-targeted, so it
// belongs here. This is the read analogue of a write deferring to the op-invoke
// seam — the target is authorized where it is bound, not guessed from the query.
// get-organization-projects (and its workspace tier, get-organization-workspaces)
// is the Casdoor read verb whose target rides in ?organization= (the
// ScopeSwitcher's project/workspace list), not ?owner=/?id=/the path, so the Guard
// cannot pre-authorize it generically; the handler scopes it through authz.Scope
// instead (the read analogue of SCIM's path-targeted authorization).
var handlerAuthorizedPrefixes = []string{"/v1/iam/scim/", "/v1/iam/get-organization-projects", "/v1/iam/get-organization-workspaces", "/v1/iam/service-accounts", "/v1/iam/memberships"}
// pathAuthorized reports whether path is under a handler-authorized subtree.
func pathAuthorized(path string) bool {
for _, p := range handlerAuthorizedPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
// Guard is the AUTHENTICATION middleware. Mount it via app.Use AFTER the public
// group and BEFORE the authed routes: the public (pre-authentication) routes are
// registered first, so a matched public route terminates fiber's middleware walk
// and the Guard never runs on it — public vs gated is decided structurally, by
// which group a route is registered on, not by an allow-list. Every route the
// Guard does wrap — the typed CRUD handlers and the framework's /mcp and /openapi
// surfaces alike — requires a valid bearer (401 otherwise) whose Principal is
// attached to the request context for the authorization hook downstream. A read's
// authorization target rides in the query string, so reads are authorized here; a
// write's rides in the body, decoded once by the op and authorized at the op-invoke
// seam (Authorize) on that exact decoded value — this middleware never re-parses a
// write body, which is what let the old target extraction diverge from execution.
func Guard(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
p, err := principal(c, db)
if err != nil {
return zip.ErrUnauthorized("authentication required")
}
// A path-targeted resource (SCIM: /Users/{id}) carries its target in the
// PATH, not the query — so, like a write whose target rides in the body, the
// Guard authenticates (bearer required, principal attached) and the handler
// authorizes via authz.Scope on the path id. The Guard never authorizes an
// empty query target for these (which would fail-closed deny every non-super
// before the handler could scope). Every other read is authorized here.
if !pathAuthorized(c.Path()) {
rOwner, rName := ReadTarget(c)
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), rOwner, rName) {
return zip.ErrForbidden("forbidden")
}
}
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
return c.Continue()
}
}
// Authorize is the AUTHORIZATION hook, installed via app.Authorize so the
// framework runs it at every typed op's invoke seam — after the request is
// decoded into its typed In and validated, before the handler runs, for REST and
// MCP alike. It authorizes the DECODED target: the exact (owner, name) the
// handler will bind, read from the same struct the handler runs on, so the value
// authorized cannot diverge from the value written.
//
// A REST read carries its target in the query string, not the body, so its
// decoded In is empty and the Guard already authorized it there — such a call is
// admitted here (owner == ""). Every write, and any read invoked over MCP (whose
// arguments DO decode a target into In), is authorized against authorize().
//
// Every typed op is authed by construction — the public surface is raw handlers
// in the pre-Guard group, none of which is a typed op — so this hook needs no
// public bypass: whenever it runs, the Guard has already run and attached a
// principal (over REST, before the op; over MCP, on the gated /mcp route).
func Authorize(ctx context.Context, op zip.Op, in any) error {
owner, name := decodedTarget(in)
if owner == "" && isRead(op.Method) {
return nil // REST read: target rode in the query, authorized by the Guard
}
p, present := From(ctx)
if !present {
return zip.ErrForbidden("forbidden") // gated op with no principal: fail closed
}
if !authorize(p, op.Method, entityOf(op.Path), owner, name) {
return zip.ErrForbidden("forbidden")
}
return nil
}
// authorize is the pure authorization decision: may p act on a resource owned by
// `owner` (named `name`) on the given entity? The order IS the policy:
//
// 1. SuperAdmin may do anything — the only cross-tenant scope.
// 2. A platform-owned resource — one under a RESERVED system org (store.IsReservedOrg:
// admin/built-in, the signing owners, PLUS "app", the service-principal org) — is
// writable only by a SuperAdmin. This single rule is the signing-cert poisoning
// gate, the admin-scoped app/provider registration gate, the built-in-org gap, AND
// the service-org ("app") consistency the self-service surfaces already enforce, all
// at once: a built-in-org principal is not SuperAdmin (that is admin only), so it
// cannot write a built-in-owned signing cert; and no capability app nor "app"-org
// admin can land a user under owner="app" (a platform identity) — the raw CRUD now
// consults the SAME predicate signup/onboarding do, so the reserved set never
// drifts between surfaces.
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
// empty or foreign owner is refused — the target org is bound to the
// principal, never trusted from the request.
// 4. Inside its own org, an org admin manages everything; a regular user may
// only READ its own user record (self-service). The users entity serves
// reads as GET and writes as POST, so gating the self clause to GET keeps a
// regular user from writing its own record — a raw entity write would
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
// is the Phase-5 provision-don't-promote concern; here it is closed by
// denial.
func authorize(p *Principal, method, entity, owner, name string) bool {
if p.Super {
return true
}
if store.IsReservedOrg(owner) {
// The ONE exception to the reserved-owner gate is the tenant registry: every
// organization row is filed under the admin owner, but an org row is the
// TENANT'S own record, not platform trust material — a tenant reads its own
// org, its admin edits it, and an org-admin-capable confidential client
// manages orgs during onboarding (v1 requireAppCapability(CapOrgAdmin)).
// Certs, applications, providers, and users under a reserved owner
// (admin/built-in/app) stay SuperAdmin-only.
if entity != "organizations" {
return false
}
if p.App != "" {
return Allowed(p, CapOrgAdmin)
}
return name == p.Org && (isRead(method) || p.Admin)
}
// A confidential client's authority is its capability allowlist and nothing
// else — never Super, never Admin; an unmapped entity or unset allowlist denies.
if p.App != "" {
return Allowed(p, capFor(entity))
}
if owner == "" || owner != p.Org {
return false
}
if p.Admin {
return true
}
return method == "GET" && entity == "users" && name != "" && name == p.User
}
// owned is implemented by a typed input whose authorization target is NOT its
// top-level Owner/Name. The user create/update body nests the record under
// `user`, so its owner is in.User.Owner, not a top-level field; its AuthzTarget
// returns exactly what the handler binds — the handler calls the same method — so
// the value authorized is by construction the value written. Any future input
// that nests its owner implements this too: it is the ONE contract for nesting,
// so the seam never guesses which field the handler uses and never mistakes a
// read-only enrichment sub-struct (e.g. an application's resolved certObj, which
// carries its OWN owner) for the target.
type owned interface {
AuthzTarget() (owner, name string)
}
// decodedTarget returns the (owner, name) a decoded request addresses — exactly
// the values the handler will bind, read from the SAME decoded struct the handler
// runs on, so there is no second parse to diverge from. An input that nests its
// owner declares it via owned; every other input files its owner at the top level
// (directly, or promoted from an embedded record), read reflectively so no entity
// needs bespoke wiring and an attacker-supplied nested sub-struct is never a
// target.
func decodedTarget(in any) (owner, name string) {
if o, ok := in.(owned); ok {
return o.AuthzTarget()
}
v := reflect.ValueOf(in)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return "", ""
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return "", ""
}
return stringField(v, "Owner"), stringField(v, "Name")
}
// stringField returns the string value of the named field (traversing embedded
// anonymous fields via FieldByName), or "" when the field is absent or not a
// string. FieldByName does not descend named sub-fields, so it reads the record's
// own owner, never one nested under an unrelated field.
func stringField(v reflect.Value, name string) string {
f := v.FieldByName(name)
if f.IsValid() && f.Kind() == reflect.String {
return f.String()
}
return ""
}
// principal resolves the verified bearer into a Principal, failing closed on a
// missing/malformed/expired/wrong-key token (oidc.VerifyToken enforces the
// algorithm allowlist and trusted signing-cert resolution), a subject with no
// org, a store error, or a forbidden/deleted user. Org, Admin, and Super are
// read from the LOADED user record — authoritative — never from the token
// claims: SuperAdmin is a real, live member of the admin org, not a subject that
// merely names one. A subject with no user row (a client_credentials machine
// token, or a since-deleted user) authenticates but carries no admin or
// SuperAdmin authority and no self-service identity — org-scoped only, which on
// the raw CRUD authorizes to nothing until a later phase grants machine
// identities explicit scope. This closes the phantom-admin subject: a token for
// "admin/<nobody>" resolves to no authority, not SuperAdmin.
func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
if p, ok := app(c, db); ok {
return p, nil
}
bearer := httpx.Bearer(c)
if bearer == "" {
return nil, errNoBearer
}
ctx := c.Context()
claims, err := oidc.VerifyToken(ctx, db, bearer)
if err != nil {
return nil, err
}
// The subject is "<owner>/<name>": the principal's OWN org and name, set
// server-side at mint and signed. Never the `owner` claim (the app's org).
owner, name, _ := strings.Cut(claims.Subject, "/")
if owner == "" {
return nil, errNoSubject
}
u, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return nil, err // fail closed: cannot establish the principal
}
if u != nil {
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: owner}, nil
}
// 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
// IAM_MINT_CLIENT_ID/SECRET and sends exactly this). The application NAME is the
// identity, because the capability allowlists key on the name.
//
// It is deliberately NOT an authority: the returned Principal is never Admin and
// never Super, so the ONLY thing it can do is what its name is allowlisted for
// (authorize → Allowed). This is what keeps the v1 "every confidential client is a
// global admin" hole closed as the transport is re-added.
//
// Fail-closed: an unparseable header, an unknown clientId, an application with no
// registered secret, an empty presented secret (a public client must never
// authenticate as an app), or a mismatch all report false — the caller then finds
// no bearer either and answers 401. The comparison is constant-time.
func app(c *zip.Ctx, db orm.DB) (*Principal, bool) {
id, secret, ok := httpx.Basic(c)
if !ok || id == "" || secret == "" {
return nil, false
}
a, err := store.GetApplicationByClientId(c.Context(), db, id)
if err != nil || a == nil || a.ClientSecret == "" {
return nil, false
}
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, Org: a.Organization}, true
}
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
// "" for anything else (e.g. /mcp). Only the users entity needs distinguishing —
// its regular-user self-service rule — so every other segment is treated
// uniformly by the tenant rule.
func entityOf(path string) string {
const p = "/v1/iam/"
if !strings.HasPrefix(path, p) {
return ""
}
rest := path[len(p):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
return rest[:i]
}
return rest
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz
import "testing"
// The confidential-client authorization policy: an app principal's ENTIRE
// authority is its capability allowlist — never Super, never Admin, never a
// tenant. This is the v1 "every client credential is a global admin" hole, held
// closed. authorize() IS the decision; this table is its truth for app principals.
func TestAuthorizeAppCapabilities(t *testing.T) {
// The allowlists reserve each capability to a named admin-owned app.
t.Setenv("IAM_USER_ADMIN_APPS", "hanzo-console")
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-team")
t.Setenv("IAM_SA_LIST_ALLOWED_APPS", "hanzo-reader")
console := &Principal{App: "hanzo-console", AppOwner: "admin", Org: "admin"} // admin-owned: user+org admin caps
nobody := &Principal{App: "rogue-app", AppOwner: "hanzo", Org: "hanzo"} // in no allowlist
// attacker: a tenant that registered <its-org>/hanzo-console — the SAME allow-listed
// NAME, but owned by a NON-signing org. The owner-pin (cap.go) denies every capability,
// so the public signup→onboard→register-app→Basic-auth escalation is inert.
attacker := &Principal{App: "hanzo-console", AppOwner: "evil", Org: "evil"}
cases := []struct {
name string
p *Principal
method string
entity string
owner string
name2 string
want bool
}{
// A capability-holding app may act on its mapped entity — cross-tenant by
// design (a platform console onboards any customer org).
{"console writes users in any org", console, "POST", "users", "orgb", "x", true},
{"console writes org (reserved-owner exception)", console, "POST", "organizations", "admin", "hanzo", true},
{"console reads org", console, "GET", "organizations", "admin", "hanzo", true},
// An app NEVER reaches signing material or unmapped entities, allowlisted
// or not — capFor has no mapping, so the allowlist is vacuously empty.
{"console -> certs denied", console, "POST", "certs", "admin", "k", false},
{"console -> providers denied", console, "POST", "providers", "hanzo", "p", false},
{"console -> tokens denied", console, "POST", "tokens", "hanzo", "t", false},
// An app in NO allowlist holds nothing — a leaked credential is inert.
{"rogue -> users denied", nobody, "POST", "users", "hanzo", "x", false},
{"rogue -> orgs denied", nobody, "POST", "organizations", "admin", "hanzo", false},
{"rogue -> own-org users denied", nobody, "POST", "users", "hanzo", "x", false},
// A user under a reserved owner is NEVER writable by an app — provision,
// never promote (no capability moves a user into the admin org).
{"console -> admin-org user denied", console, "POST", "users", "admin", "x", false},
{"console -> built-in user denied", console, "POST", "users", "built-in", "x", false},
// [INFO] consistency: even the LEGIT admin-owned console may not land a user in
// the reserved service-principal org "app" — a platform identity, super-only. The
// raw CRUD now consults IsReservedOrg, the SAME predicate signup/onboarding use.
{"console -> app-org user denied", console, "POST", "users", "app", "x", false},
// RED PoC, now DENIED: a tenant-owned app spoofing the console NAME holds NOTHING.
// The owner-pin refuses a non-signing owner BEFORE any allowlist name match, so a
// leaked/forged tenant credential named like the console cannot act on any tenant.
{"attacker spoof -> victim users denied", attacker, "POST", "users", "victim", "x", false},
{"attacker spoof -> org write denied", attacker, "POST", "organizations", "admin", "victim", false},
{"attacker spoof -> org delete denied", attacker, "DELETE", "organizations", "admin", "victim", false},
{"attacker spoof -> org read denied", attacker, "GET", "organizations", "admin", "victim", false},
{"attacker spoof -> own-org users denied", attacker, "POST", "users", "evil", "x", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
t.Fatalf("authorize(App=%q,%s,%s,%s/%s) = %v, want %v",
c.p.App, c.method, c.entity, c.owner, c.name2, got, c.want)
}
})
}
// An app principal is structurally never Super/Admin, so it can never take the
// human privileged paths even if a future bug set the flags.
if console.Super || console.Admin {
t.Fatal("an app principal must never carry Super/Admin")
}
// RED's four assertions, VERBATIM — the exact PoC principal &Principal{App:"hanzo-console"}
// (no owning signing org). Every one fired == true before the owner-pin; every one must
// be false now. A bare app principal is inert regardless of the NAME it presents.
red := &Principal{App: "hanzo-console"}
for _, a := range []struct{ method, entity, owner, name string }{
{"POST", "users", "victim", "x"},
{"POST", "organizations", "admin", "victim"},
{"DELETE", "organizations", "admin", "victim"},
{"GET", "organizations", "admin", "victim"},
} {
if authorize(red, a.method, a.entity, a.owner, a.name) {
t.Fatalf("RED PoC REOPENED: authorize(App=hanzo-console,%s,%s,%s/%s) GRANTED — the owner-pin failed",
a.method, a.entity, a.owner, a.name)
}
}
}
// The capability primitives, fail-secure to the letter.
func TestCapabilityPrimitives(t *testing.T) {
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console, brand-console")
t.Run("Allowed named", func(t *testing.T) {
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapOrgAdmin) {
t.Fatal("a named, admin-owned app must hold its capability")
}
})
t.Run("Allowed unnamed denied", func(t *testing.T) {
if Allowed(&Principal{App: "other", AppOwner: "admin"}, CapOrgAdmin) {
t.Fatal("an unnamed app must hold nothing") // admin-owned, so the NAME check alone denies
}
})
t.Run("Allowed unset env denied", func(t *testing.T) {
if Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapKeyMint) { // IAM_KEY_MINT_ALLOWED_APPS unset here
t.Fatal("an unset allowlist must deny every app")
}
})
t.Run("Allowed owner-pin denies a non-signing owner", func(t *testing.T) {
// hanzo-console IS on IAM_ORG_ADMIN_APPS, but these rows are NOT admin/built-in owned.
if Allowed(&Principal{App: "hanzo-console", AppOwner: "hanzo"}, CapOrgAdmin) {
t.Fatal("a tenant-owned app must hold nothing even with an allow-listed name")
}
if Allowed(&Principal{App: "hanzo-console"}, CapOrgAdmin) { // AppOwner "" — no owning signing org at all
t.Fatal("an app with no owning signing org must hold nothing")
}
// built-in is the other reserved signing owner — a built-in-owned allow-listed app is legit.
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "built-in"}, CapOrgAdmin) {
t.Fatal("a built-in-owned allow-listed app must hold its capability")
}
})
t.Run("Allowed non-app is vacuous", func(t *testing.T) {
if !Allowed(&Principal{Org: "hanzo"}, CapOrgAdmin) {
t.Fatal("a human holds capabilities vacuously; the org policy decides")
}
})
t.Run("BoundToOrg prefix", func(t *testing.T) {
p := &Principal{App: "hanzo-team"}
if !BoundToOrg(p, "hanzo") {
t.Fatal("hanzo-team must be bound to hanzo")
}
if BoundToOrg(p, "lux") {
t.Fatal("hanzo-team must NOT be bound to lux")
}
if BoundToOrg(&Principal{App: "hanzo"}, "hanzo") {
t.Fatal("an exact-name app (no agent segment) is bound to nothing")
}
})
t.Run("capFor mapping", func(t *testing.T) {
if capFor("organizations") != CapOrgAdmin || capFor("users") != CapUserAdmin {
t.Fatal("org/user entities must map to their capability")
}
if capFor("certs") != (Cap{}) || capFor("providers") != (Cap{}) {
t.Fatal("an unmapped entity must map to the empty (deny-all) capability")
}
})
}
+463
View File
@@ -0,0 +1,463 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz_test
import (
"net/http"
"testing"
"time"
)
// The eight required cases, each through the real mounted router. Sub names map
// to seeded principals: admin/root = SuperAdmin, hanzo/boss = org admin,
// hanzo/alice = regular user, orgb/bob = a foreign org's admin.
// 1. An unauthenticated CRUD write is refused before any handler runs.
func TestUnauthenticatedWriteIs401(t *testing.T) {
h := newHarness(t)
cases := []struct {
name, method, path string
body any
}{
{"create user", "POST", "/v1/iam/users", user("hanzo", "x")},
{"write cert", "POST", "/v1/iam/certs", cert("admin", signingKid)},
{"register app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x"}},
{"delete user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
{"update cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
{"create org", "POST", "/v1/iam/organizations", map[string]any{"owner": "admin", "name": "x"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, "", c.body); got != http.StatusUnauthorized {
t.Fatalf("%s %s no bearer = %d, want 401", c.method, c.path, got)
}
})
}
}
// 2. A valid principal in orgB writing an orgA-owned entity is refused (tenant
// isolation): the target org is bound to the principal, never the body.
func TestCrossOrgWriteIs403(t *testing.T) {
h := newHarness(t)
bob := h.token(t, "orgb/bob") // org admin, but of orgb
cases := []struct {
name, method, path string
body any
}{
{"create user in hanzo", "POST", "/v1/iam/users", user("hanzo", "mole")},
{"update user in hanzo", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
{"delete user in hanzo", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
{"create role in hanzo", "POST", "/v1/iam/roles", map[string]any{"owner": "hanzo", "name": "r"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, bob, c.body); got != http.StatusForbidden {
t.Fatalf("orgb principal %s %s = %d, want 403", c.method, c.path, got)
}
})
}
}
// 3. THE poisoning gate. A non-SuperAdmin — org admin OR regular user OR a
// built-in-org member — writing an admin/built-in-owned signing cert is refused.
// Every cert write verb is covered, and the update/delete target the LIVE
// signing cert, so a bypass would truly overwrite the platform key.
func TestSigningCertPoisoningIs403(t *testing.T) {
h := newHarness(t)
principals := map[string]string{
"org admin (hanzo/boss)": h.token(t, "hanzo/boss"),
"regular user (hanzo/alice)": h.token(t, "hanzo/alice"),
"built-in member (built-in/svc)": h.token(t, "built-in/svc"),
}
writes := []struct {
name, path string
body any
}{
{"create admin cert", "/v1/iam/certs", cert("admin", "cert-forge")},
{"overwrite live admin cert", "/v1/iam/certs/update", cert("admin", signingKid)},
{"delete live admin cert", "/v1/iam/certs/delete", map[string]any{"owner": "admin", "name": signingKid}},
{"create built-in cert", "/v1/iam/certs", cert("built-in", "cert-forge")},
{"overwrite built-in cert", "/v1/iam/certs/update", cert("built-in", "anything")},
}
for who, tok := range principals {
for _, w := range writes {
t.Run(who+" "+w.name, func(t *testing.T) {
if got := h.do(t, "POST", w.path, tok, w.body); got != http.StatusForbidden {
t.Fatalf("%s writing %s = %d, want 403 (poisoning gate)", who, w.path, got)
}
})
}
}
}
// 4. A SuperAdmin (org == admin) may write the admin signing cert and act across
// any org. The guard admits it; the handler then succeeds (2xx). The rotation
// case overwrites the LIVE signing cert with a complete body (key preserved) —
// the legitimate operation the poisoning gate exists to reserve to SuperAdmins.
func TestSuperAdminWritesAdminCertAndCrossOrg(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
rotate := map[string]any{
"owner": "admin", "name": signingKid,
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
}
cases := []struct {
name, method, path string
body any
}{
{"create a new admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-fresh")},
{"rotate the live admin signing cert", "POST", "/v1/iam/certs/update", rotate},
{"create a user in any org", "POST", "/v1/iam/users", user("hanzo", "hire-by-root")},
{"create a user in another org", "POST", "/v1/iam/users", user("orgb", "hire-by-root")},
{"register an admin-owned app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "root-app", "clientId": "root-app"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := h.do(t, c.method, c.path, root, c.body)
if got < 200 || got >= 300 {
t.Fatalf("SuperAdmin %s %s = %d, want 2xx", c.method, c.path, got)
}
})
}
}
// 5. An org admin manages its OWN org's users and apps (2xx) but not another
// org's (403). This is the org-admin tier: org-scoped, never cross-tenant.
func TestOrgAdminManagesOwnOrgOnly(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
allow := []struct {
name, method, path string
body any
}{
{"create user in own org", "POST", "/v1/iam/users", user("hanzo", "newhire")},
{"update self org's user", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
{"register app in own org", "POST", "/v1/iam/application", map[string]any{"owner": "hanzo", "name": "hanzo-app", "clientId": "hanzo-app"}},
}
for _, c := range allow {
t.Run("allow/"+c.name, func(t *testing.T) {
got := h.do(t, c.method, c.path, boss, c.body)
if got < 200 || got >= 300 {
t.Fatalf("org admin %s %s (own org) = %d, want 2xx", c.method, c.path, got)
}
})
}
deny := []struct {
name, method, path string
body any
}{
{"create user in another org", "POST", "/v1/iam/users", user("orgb", "mole")},
{"register app in another org", "POST", "/v1/iam/application", map[string]any{"owner": "orgb", "name": "x", "clientId": "x"}},
{"write a platform (admin) app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x", "clientId": "x"}},
}
for _, c := range deny {
t.Run("deny/"+c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, boss, c.body); got != http.StatusForbidden {
t.Fatalf("org admin %s %s (foreign) = %d, want 403", c.method, c.path, got)
}
})
}
}
// 6. A regular user may read its own user record (guard admits it) but not touch
// another's, and may NOT write even its own record — a raw self-write would let
// it carry isAdmin and self-promote, so writes are refused outright.
func TestRegularUserSelfServiceOnly(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
// Reading own record: the guard admits it (not 401/403). The Phase-1 GET
// handler binds no query, so the status is the handler's, never the guard's
// forbid — the point here is that the guard did NOT block self-read.
if got := h.do(t, "GET", "/v1/iam/users/get?owner=hanzo&name=alice", alice, nil); got == http.StatusForbidden || got == http.StatusUnauthorized {
t.Fatalf("regular self-read = %d, want the guard to admit it (not 401/403)", got)
}
// Everything else a regular user might try is refused.
deny := []struct {
name, method, path string
body any
}{
{"read another user", "GET", "/v1/iam/users/get?owner=hanzo&name=boss", nil},
{"list the org's users", "GET", "/v1/iam/users?owner=hanzo", nil},
{"update own record (self-promote)", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
{"create a user", "POST", "/v1/iam/users", user("hanzo", "puppet")},
{"delete another user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "boss"}},
{"read another org", "GET", "/v1/iam/users/get?owner=orgb&name=bob", nil},
}
for _, c := range deny {
t.Run("deny/"+c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, alice, c.body); got != http.StatusForbidden {
t.Fatalf("regular user %s %s = %d, want 403", c.method, c.path, got)
}
})
}
}
// 7. Public routes are reachable with NO bearer — the pre-auth OIDC/OAuth and
// front-door surface a browser must reach before it holds a token. "Reachable"
// means NOT the guard's 401: the endpoint's own handler answers (which may be a
// 400 for a missing param — that is the handler, past the guard).
func TestPublicRoutesNeedNoBearer(t *testing.T) {
h := newHarness(t)
public := []struct{ method, path string }{
{"GET", "/healthz"},
{"GET", "/.well-known/openid-configuration"},
{"GET", "/v1/iam/.well-known/openid-configuration"},
{"GET", "/v1/iam/.well-known/jwks"},
{"GET", "/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (root)
{"GET", "/v1/iam/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (v1)
{"POST", "/v1/iam/login"},
{"GET", "/v1/iam/oauth/authorize"},
{"POST", "/v1/iam/oauth/token"},
{"GET", "/v1/iam/get-app-login"},
{"GET", "/v1/iam/auth/methods"},
{"POST", "/v1/iam/oauth/logout"},
// The front-door session/identity surface — each self-resolves the caller
// (session cookie, else bearer) and answers anonymously (200 {status:error}
// or a handler 400), never the Guard's 401. These are the routes the old
// publicPaths list had to be patched to include; now they are public purely
// because oidc.Route registers them on the pre-Guard group.
{"GET", "/v1/iam/get-account"},
{"POST", "/v1/iam/signin"},
{"GET", "/v1/iam/whoami"},
{"GET", "/v1/iam/linked-accounts"},
{"POST", "/v1/iam/signup"},
{"POST", "/v1/iam/send-verification-code"},
{"POST", "/v1/iam/update-preferences"},
}
for _, c := range public {
t.Run(c.method+" "+c.path, func(t *testing.T) {
if got := h.do(t, c.method, c.path, "", map[string]any{}); got == http.StatusUnauthorized {
t.Fatalf("public %s %s = 401, want the endpoint reachable without a bearer", c.method, c.path)
}
})
}
// userinfo is bearer-gated but self-verifying: no bearer → its OWN 401
// (WWW-Authenticate), which is correct and must not be double-gated away.
if got := h.do(t, "GET", "/v1/iam/oauth/userinfo", "", nil); got != http.StatusUnauthorized {
t.Fatalf("userinfo no bearer = %d, want its own 401", got)
}
}
// 8. Bad bearers are refused with the same opaque 401 (no oracle): expired,
// wrong algorithm (HMAC / none — never in the allowlist), a kid that names no
// trusted cert, and a good-shape token under the wrong key. This reuses the
// Phase-2 verifier defenses verbatim.
func TestBadBearersAre401(t *testing.T) {
h := newHarness(t)
other := genRSA(t)
path, body := "/v1/iam/users", user("hanzo", "x")
bad := map[string]string{
"expired": h.mint(t, "admin/root", time.Now().Add(-time.Hour)),
"forged kid": mintKid(t, h.key, "cert-nonexistent", "admin/root"),
"wrong key": mintKid(t, other, signingKid, "admin/root"),
"hmac alg": signHS256(t, signingKid, "admin/root"),
"alg none": forgeNone(signingKid, "admin/root"),
"garbage": "not.a.jwt",
}
for name, tok := range bad {
t.Run(name, func(t *testing.T) {
if got := h.do(t, "POST", path, tok, body); got != http.StatusUnauthorized {
t.Fatalf("bad bearer %q = %d, want 401", name, got)
}
})
}
// A revoked (forbidden) user's otherwise-valid token is refused too.
t.Run("revoked user", func(t *testing.T) {
if got := h.do(t, "POST", path, h.token(t, "hanzo/ghost"), body); got != http.StatusUnauthorized {
t.Fatalf("revoked user = %d, want 401", got)
}
})
}
// Org-confusion escalation defense: a token minted through a SHARED admin-org
// app carries owner/organization = "admin" while its subject is a tenant user.
// The guard authorizes from the subject (the real user's org), never the owner
// claim, so this token is a hanzo REGULAR user — it cannot write an admin cert
// or reach across orgs, exactly as if the misleading claim were absent.
func TestOwnerClaimCannotEscalate(t *testing.T) {
h := newHarness(t)
// alice is a regular hanzo user; the token lies that owner == admin.
tok := h.sharedAppToken(t, "hanzo/alice", "admin")
cases := []struct {
name, method, path string
body any
}{
{"write admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
{"overwrite live admin cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
{"create a user cross-org", "POST", "/v1/iam/users", user("orgb", "mole")},
{"promote self in own org", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, tok, c.body); got != http.StatusForbidden {
t.Fatalf("owner-claim=admin %s %s = %d, want 403 (claim must not escalate)", c.method, c.path, got)
}
})
}
}
// A verified token whose subject names NO live user — a machine token, a
// since-deleted user, or a forged-looking "admin/<nobody>" — authenticates but
// carries no authority: SuperAdmin requires a real member of the admin org, so
// the phantom-admin subject is refused everywhere.
func TestPhantomSubjectHasNoAuthority(t *testing.T) {
h := newHarness(t)
ghostAdmin := h.token(t, "admin/nobody") // no such user seeded
ghostTenant := h.token(t, "hanzo/nobody")
cases := []struct {
name, tok, method, path string
body any
}{
{"phantom admin -> admin cert", ghostAdmin, "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
{"phantom admin -> user in admin org", ghostAdmin, "POST", "/v1/iam/users", user("admin", "x")},
{"phantom admin -> user in a tenant", ghostAdmin, "POST", "/v1/iam/users", user("hanzo", "x")},
{"phantom tenant -> user in own org", ghostTenant, "POST", "/v1/iam/users", user("hanzo", "x")},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := h.do(t, c.method, c.path, c.tok, c.body); got != http.StatusForbidden {
t.Fatalf("%s = %d, want 403 (phantom subject has no authority)", c.name, got)
}
})
}
}
// The framework's generic side doors (MCP tool-call, OpenAPI doc) are gated by
// the same fail-closed default — proven on a REAL, installed route and a REAL
// tool INVOCATION, not just the envelope path. newHarness calls app.Prepare(), so
// /mcp and /openapi are actually registered (the old test hit a route that was
// never mounted, so the guard's 401 masked the fact the invocation was untested),
// and the tool id is the framework's real one (post_v1_iam_certs), so a
// regression that let a tool arguments-mask through would FAIL here, not pass.
func TestFrameworkSideDoorsAreGated(t *testing.T) {
h := newHarness(t)
forge := cert("admin", "cert-forge") // {owner:"admin", …} — the poisoning target
// No bearer reaches /mcp at all: the guard authenticates the envelope before
// any dispatch, so it is 401 — never an unauthorized invocation, never a 404.
if got := h.do(t, "POST", "/mcp", "", mcpEnvelope("post_v1_iam_certs", forge)); got != http.StatusUnauthorized {
t.Fatalf("POST /mcp no bearer = %d, want 401 (guard fail-closed)", got)
}
// The OpenAPI doc — now a real installed route — is gated too.
if got := h.do(t, "GET", "/.well-known/openapi.json", "", nil); got != http.StatusUnauthorized {
t.Fatalf("GET openapi.json no bearer = %d, want 401", got)
}
// A non-SuperAdmin driving the REAL cert tool is refused at the op-invoke seam
// (isError), and — the assertion that matters — NOTHING is written.
boss := h.token(t, "hanzo/boss")
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
t.Fatalf("MCP post_v1_iam_certs (non-super) = status %d isError %v, want 200/true (refused at op seam)", status, isErr)
}
if h.certExists(t, "admin", "cert-forge") {
t.Fatal("MCP cert-forge PERSISTED an admin-owned cert — the /mcp side door is OPEN")
}
}
// THE critical bug (finding #1), proven closed at the REST seam. The users entity
// is the one input that nests its owner, so an org admin who masks a benign
// top-level owner over a nested admin/isAdmin record must NOT create a platform
// SuperAdmin. The write is refused (403) AND — the assertion the vacuous test
// lacked — the store holds no such row afterward. Query the store, not the status.
func TestUserOwnerMaskIsRefused(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss") // org admin of hanzo — authorized for "hanzo" only
// The PoC verbatim: top-level owner is the attacker's OWN org (which the guard
// would authorize), the nested record targets the reserved admin org with
// isAdmin — a platform SuperAdmin (owner=="admin" IS the predicate) if it landed.
createMask := map[string]any{
"owner": "hanzo",
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
"password": "x",
}
if got := h.do(t, "POST", "/v1/iam/users", boss, createMask); got != http.StatusForbidden {
t.Fatalf("users create owner-mask = %d, want 403", got)
}
if h.userExists(t, "admin", "red-super") {
t.Fatal("owner-mask PERSISTED admin/red-super — total-account-takeover path is OPEN")
}
// The same mask, aimed cross-tenant: inject a user into a foreign org.
crossOrgMask := map[string]any{
"owner": "hanzo",
"user": map[string]any{"owner": "orgb", "name": "mole"},
"password": "x",
}
if got := h.do(t, "POST", "/v1/iam/users", boss, crossOrgMask); got != http.StatusForbidden {
t.Fatalf("users create cross-org mask = %d, want 403", got)
}
if h.userExists(t, "orgb", "mole") {
t.Fatal("owner-mask injected a user into orgb (cross-tenant)")
}
// Hijack an EXISTING admin-org user via /users/update (nested owner=admin):
// refused, and the victim's privilege/credentials are untouched.
hijack := map[string]any{
"user": map[string]any{"owner": "admin", "name": "root", "isAdmin": true},
"password": "attacker-chosen",
}
if got := h.do(t, "POST", "/v1/iam/users/update", boss, hijack); got != http.StatusForbidden {
t.Fatalf("users update hijack of admin/root = %d, want 403", got)
}
if h.userIsAdmin(t, "admin", "root") {
t.Fatal("update hijack flipped admin/root.isAdmin — privilege takeover via /users/update")
}
}
// The MCP arguments-mask (finding #2), proven closed at the SAME op-invoke seam —
// the design claim "the guard gates /mcp" made real, independent of the prod
// MCP.Disabled flag (this harness leaves MCP ENABLED). A non-SuperAdmin driving
// the real tools with admin-targeted arguments is refused and writes nothing; a
// SuperAdmin drives the same tool successfully, so the seam refuses by AUTHORITY,
// not by blanket-denying every MCP call.
func TestMCPArgumentsMaskIsRefused(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
attackerPEM := rsaKeyToPEM(t, genRSA(t))
// a) cert-forge over MCP arguments: an admin signing cert with an attacker key.
forge := map[string]any{
"owner": "admin", "name": "cert-forge",
"cryptoAlgorithm": "RS256", "privateKey": attackerPEM,
}
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
t.Fatalf("MCP cert-forge (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
}
if h.certExists(t, "admin", "cert-forge") {
t.Fatal("MCP cert-forge PERSISTED an admin signing cert with an attacker key")
}
// b) the users owner-mask over MCP arguments: a nested admin SuperAdmin record.
userMask := map[string]any{
"owner": "hanzo",
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
"password": "x",
}
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_users", userMask); status != http.StatusOK || !isErr {
t.Fatalf("MCP users owner-mask (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
}
if h.userExists(t, "admin", "red-super") {
t.Fatal("MCP users owner-mask PERSISTED admin/red-super — total takeover via /mcp")
}
// Control: a SuperAdmin drives the SAME cert tool successfully — the seam
// discriminates by authority; it does not just refuse everything over MCP.
root := h.token(t, "admin/root")
legit := map[string]any{
"owner": "admin", "name": "cert-legit",
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
}
if status, isErr := h.mcpToolCall(t, root, "post_v1_iam_certs", legit); status != http.StatusOK || isErr {
t.Fatalf("MCP cert create by SuperAdmin = status %d isError %v, want 200/false (allowed)", status, isErr)
}
if !h.certExists(t, "admin", "cert-legit") {
t.Fatal("SuperAdmin MCP cert create did not persist — the seam is over-refusing")
}
}
+331
View File
@@ -0,0 +1,331 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz_test
// End-to-end authorization tests driven through the REAL mounted router
// (routes.Mount, which installs authz.Guard after the public group, so gating is
// structural — the public routes registered before it are never reached by it).
// Every case is a wire request
// a client could send: a status code is the whole contract. Tokens are genuine
// RS256 JWTs signed by the seeded admin signing cert, so they pass the exact
// oidc.VerifyToken the guard reuses — nothing here is mocked.
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
const signingKid = "cert-hanzo" // the seeded admin signing cert's name = JWKS kid
// Two RSA keys, generated once for the whole suite: the trust-anchor key the
// signing cert holds, and a distinct "other" key for the wrong-key bearer test.
// Keygen is the slow part and the crypto under test is identical whichever key
// it is, so caching them keeps the suite (and -race) fast.
var (
anchorKeyOnce, otherKeyOnce sync.Once
anchorKey, otherKey *rsa.PrivateKey
)
func trustKey() *rsa.PrivateKey {
anchorKeyOnce.Do(func() { anchorKey = mustRSA() })
return anchorKey
}
func mustRSA() *rsa.PrivateKey {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return k
}
// harness holds the mounted app, the RSA key the signing cert holds (so a test
// can mint a token any principal would carry), and the store (so a test can
// assert that a refused write persisted NOTHING — the real security property, not
// just a status code).
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
// userExists reports whether a user row (owner, name) is persisted — used to
// prove a refused create/update wrote nothing.
func (h *harness) userExists(t *testing.T, owner, name string) bool {
t.Helper()
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
if err != nil {
t.Fatalf("lookup user %s/%s: %v", owner, name, err)
}
return u != nil
}
// certExists reports whether a cert row (owner, name) is persisted.
func (h *harness) certExists(t *testing.T, owner, name string) bool {
t.Helper()
c, err := store.GetCert(context.Background(), h.db, owner, name)
if err != nil {
t.Fatalf("lookup cert %s/%s: %v", owner, name, err)
}
return c != nil
}
// userIsAdmin reports the persisted isAdmin flag of (owner, name) — used to prove
// a refused update did NOT flip a victim's privilege.
func (h *harness) userIsAdmin(t *testing.T, owner, name string) bool {
t.Helper()
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
if err != nil || u == nil {
t.Fatalf("expected user %s/%s to exist: %v", owner, name, err)
}
return u.IsAdmin
}
// newHarness opens a fresh SQLite store, seeds the trust anchor (an admin-owned
// RS256 signing cert) plus a cast of principals across three orgs, and mounts
// the full router — guard and all. MCP is left ENABLED here (unlike prod) so the
// tests prove the guard, not a disabled feature, closes the /mcp side door.
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds() // force kind registration
key := trustKey()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "authz.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Trust anchor: the admin-owned signing cert the verifier and JWKS trust.
// Poisoning tests target THIS row, so a bypassed guard would really overwrite
// the live signing key.
seedCert(t, db, "admin", signingKid, rsaKeyToPEM(t, key))
// Principals: one per scope, plus a revoked user and a cross-tenant org.
seedUser(t, db, "admin", "root", false, false, false) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true, false, false) // org admin of hanzo
seedUser(t, db, "hanzo", "alice", false, false, false) // regular user in hanzo
seedUser(t, db, "orgb", "bob", true, false, false) // org admin of orgb (cross-tenant)
seedUser(t, db, "hanzo", "ghost", true, true, false) // forbidden — revoked
seedUser(t, db, "built-in", "svc", true, false, false) // built-in org, NOT SuperAdmin
app := zip.New(zip.Config{AppName: "authz-test", DisableStartupMessage: true})
routes.Route(app, db)
// Install the deferred framework projections (/mcp, /openapi) for real, so the
// side-door tests drive the ACTUAL routes — the same surface a served app
// exposes — not a route that never got registered. MCP is left ENABLED here
// (unlike prod) so the tests prove the guard, not a disabled feature, closes it.
app.Prepare()
return &harness{app: app, key: key, db: db}
}
// mint signs an RS256 bearer for subject `sub` (an "owner/name") with the given
// expiry, under the trusted kid — the exact shape a real token carries.
func (h *harness) mint(t *testing.T, sub string, exp time.Time) string {
t.Helper()
return signRS256(t, h.key, signingKid, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": exp.Unix(),
})
}
// token is a convenience for a valid, hour-long bearer for sub.
func (h *harness) token(t *testing.T, sub string) string {
return h.mint(t, sub, time.Now().Add(time.Hour))
}
// sharedAppToken mints a valid bearer whose owner/organization claims say
// ownerClaim (as a token minted through a SHARED admin-org app would) while the
// subject names a different, tenant user. The guard must authorize from the
// subject, never these claims — the org-confusion escalation defense.
func (h *harness) sharedAppToken(t *testing.T, sub, ownerClaim string) string {
t.Helper()
return signRS256(t, h.key, signingKid, jwt.MapClaims{
"sub": sub, "owner": ownerClaim, "organization": ownerClaim, "exp": future(),
})
}
// do issues one request through the real router and returns the status code.
func (h *harness) do(t *testing.T, method, path, bearer string, body any) int {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := h.app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return resp.StatusCode
}
// mcpEnvelope builds a JSON-RPC 2.0 tools/call for the framework tool `tool`
// (its real op id, e.g. "post_v1_iam_certs") with `args` as the tool arguments —
// the same body an MCP agent would POST to /mcp.
func mcpEnvelope(tool string, args any) map[string]any {
return map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
}
}
// mcpToolCall fires an MCP tools/call for `tool` with `args` through the REAL
// mounted /mcp route and reports the HTTP status plus whether the op-invoke
// authorizer refused it. A refusal at the op seam surfaces as an isError result
// with HTTP 200 (MCP reports handler errors in-band), never a transport 403, so
// a refused write shows up as isError==true — the status stays 200.
func (h *harness) mcpToolCall(t *testing.T, bearer, tool string, args any) (status int, isError bool) {
t.Helper()
b, _ := json.Marshal(mcpEnvelope(tool, args))
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := h.app.Fiber().Test(req)
if err != nil {
t.Fatalf("mcp tools/call %s: %v", tool, err)
}
defer func() { _ = resp.Body.Close() }()
var out struct {
Result struct {
IsError bool `json:"isError"`
} `json:"result"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out.Result.IsError
}
// ---- seed helpers ----------------------------------------------------------
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.CryptoAlgorithm = "RS256"
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert %s/%s: %v", owner, name, err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin, forbidden, deleted bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin, u.IsForbidden, u.IsDeleted = admin, forbidden, deleted
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user %s/%s: %v", owner, name, err)
}
}
func rsaKeyToPEM(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
func signRS256(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = kid
s, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func future() int64 { return time.Now().Add(time.Hour).Unix() }
// mintKid signs an hour-long RS256 token for sub under an arbitrary key and kid,
// for the forged-kid and wrong-key bearer tests.
func mintKid(t *testing.T, key *rsa.PrivateKey, kid, sub string) string {
return signRS256(t, key, kid, jwt.MapClaims{"sub": sub, "exp": future()})
}
// genRSA returns the suite's cached "other" key — a valid key that is NOT the
// trust anchor, for the wrong-signature bearer test.
func genRSA(t *testing.T) *rsa.PrivateKey {
t.Helper()
otherKeyOnce.Do(func() { otherKey = mustRSA() })
return otherKey
}
// signHS256 forges an HMAC-signed token carrying the trusted kid. The verifier's
// algorithm allowlist has no HMAC family, so it is rejected before any key is
// consulted (the classic alg-confusion downgrade, closed).
func signHS256(t *testing.T, kid, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub, "exp": future()})
tok.Header["kid"] = kid
s, err := tok.SignedString([]byte("attacker-chosen-secret"))
if err != nil {
t.Fatalf("hs256 sign: %v", err)
}
return s
}
// forgeNone hand-builds an alg:none token (header.claims. with an empty
// signature) — the unsigned-token attack. "none" is absent from the allowlist,
// so it never verifies.
func forgeNone(kid, sub string) string {
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
head := enc(map[string]any{"alg": "none", "typ": "JWT", "kid": kid})
body := enc(map[string]any{"sub": sub, "exp": future()})
return head + "." + body + "."
}
// cert is a minimal signing-cert create/update/delete body.
func cert(owner, name string) map[string]any {
return map[string]any{"owner": owner, "name": name, "cryptoAlgorithm": "RS256"}
}
// user wraps a create/update user body ({user:{...}, password}).
func user(owner, name string) map[string]any {
return map[string]any{"user": map[string]any{"owner": owner, "name": name}, "password": "x"}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz
import "testing"
// The pure policy, tested exhaustively and independent of HTTP. authorize IS the
// security decision; this table is its full truth.
func TestAuthorizePolicy(t *testing.T) {
super := &Principal{Org: "admin", User: "root", Super: true}
orgAdmin := &Principal{Org: "hanzo", User: "boss", Admin: true}
regular := &Principal{Org: "hanzo", User: "alice"}
builtin := &Principal{Org: "built-in", User: "svc", Admin: true} // NOT super
cases := []struct {
name string
p *Principal
method string
entity string
owner string
name2 string
want bool
}{
// SuperAdmin: unrestricted, including the reserved owners and cross-org.
{"super writes admin cert", super, "POST", "certs", "admin", "k", true},
{"super writes built-in cert", super, "POST", "certs", "built-in", "k", true},
{"super cross-org user", super, "POST", "users", "orgb", "x", true},
// Poisoning gate: no non-super may write a reserved-owner resource.
{"org admin -> admin cert", orgAdmin, "POST", "certs", "admin", "k", false},
{"org admin -> built-in cert", orgAdmin, "POST", "certs", "built-in", "k", false},
{"regular -> admin cert", regular, "POST", "certs", "admin", "k", false},
{"built-in member -> built-in cert", builtin, "POST", "certs", "built-in", "k", false},
{"built-in member -> admin app", builtin, "POST", "application", "admin", "a", false},
// Tenant isolation: own org only.
{"org admin own org", orgAdmin, "POST", "users", "hanzo", "x", true},
{"org admin foreign org", orgAdmin, "POST", "users", "orgb", "x", false},
{"org admin empty owner", orgAdmin, "POST", "certs", "", "k", false},
// Regular user: read own record only; no writes, no others, no self-promote.
{"regular read own", regular, "GET", "users", "hanzo", "alice", true},
{"regular read other", regular, "GET", "users", "hanzo", "boss", false},
{"regular list org", regular, "GET", "users", "hanzo", "", false},
{"regular write own (self-promote)", regular, "POST", "users", "hanzo", "alice", false},
{"regular read own non-user entity", regular, "GET", "roles", "hanzo", "alice", false},
{"regular read foreign org self-name", regular, "GET", "users", "orgb", "alice", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
t.Fatalf("authorize(%s) = %v, want %v", c.name, got, c.want)
}
})
}
}
// SuperAdmin is exactly org=="admin"; built-in is NOT super — the built-in gap
// the poisoning gate must close depends on this.
func TestSuperIsAdminOrgOnly(t *testing.T) {
if (&Principal{Org: "built-in", Super: false}).Super {
t.Fatal("built-in must not be SuperAdmin")
}
// A built-in-org principal fails the reserved-owner write even for its own org.
if authorize(&Principal{Org: "built-in", Admin: true}, "POST", "certs", "built-in", "k") {
t.Fatal("built-in admin must not write built-in signing certs")
}
}
// Public vs gated is no longer a path allow-list this package owns — it is
// STRUCTURAL, decided by which group a route is registered on in routes.Mount
// (the public group before the Guard, everything else after it). The boundary is
// therefore proven end-to-end over the real mounted router: TestPublicRoutesNeedNoBearer
// (public routes reachable without a bearer), TestUnauthenticatedWriteIs401 /
// TestCrossOrgWriteIs403 (authed routes gated), and TestFrameworkSideDoorsAreGated
// (/mcp + /openapi gated) in authz_cases_test.go.
func TestEntityOf(t *testing.T) {
cases := map[string]string{
"/v1/iam/users": "users",
"/v1/iam/users/get": "users",
"/v1/iam/users/update": "users",
"/v1/iam/certs/delete": "certs",
"/v1/iam/application": "application",
"/v1/iam/audit-logs": "audit-logs",
"/mcp": "",
"/healthz": "",
"/v1/iam/": "",
}
for path, want := range cases {
if got := entityOf(path); got != want {
t.Errorf("entityOf(%q) = %q, want %q", path, got, want)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz_test
// Read-path authorization, driven through the REAL mounted router. A status code
// is not the contract here — the BODY is: a listing that returns 200 while
// carrying the admin signing key is a total compromise. Every case asserts on
// what actually crossed the wire.
import (
"bytes"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
)
// doBody is do() plus the response body — the read surface's real contract.
func (h *harness) doBody(t *testing.T, method, path, bearer string, body any) (int, string) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := h.app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b)
}
// leaks reports whether a response body carries private key material.
func leaks(body string) bool {
return strings.Contains(body, "PRIVATE KEY") || strings.Contains(body, `"privateKey":"-`)
}
// TestCertPrivateKeyNeverLeaks is the PoC that proved a full token-forgery
// compromise: a hanzo org admin listed certs and received the admin trust
// anchor's private key. Two independent defects composed into it — the listing
// ignored its owner (a GET binds no query, so in.Owner was always "", and an
// empty owner listed EVERY tenant), and the response serialized privateKey. Both
// are closed: the owner is resolved from the verified bearer (authz.Scope), and
// a Cert is masked on the way out (schema.Cert.Mask), so the key material that
// signs every token cannot cross the API at all — a relying party reads the
// PUBLIC half from the JWKS (RFC 7517).
func TestCertPrivateKeyNeverLeaks(t *testing.T) {
h := newHarness(t)
anchor := rsaKeyToPEM(t, h.key) // the admin signing cert's real private key
t.Run("the org-admin PoC leaks neither key material nor another tenant's cert", func(t *testing.T) {
status, body := h.doBody(t, "GET", "/v1/iam/certs?owner=hanzo", h.token(t, "hanzo/boss"), nil)
if status != 200 {
t.Fatalf("own-org listing must succeed, got %d: %s", status, body)
}
if strings.Contains(body, anchor) || leaks(body) {
t.Fatal("LEAK: admin signing key material in an org-admin listing")
}
if strings.Contains(body, signingKid) {
t.Fatal("CROSS-TENANT: the admin-owned cert appeared in a hanzo listing")
}
})
t.Run("a query owner cannot widen the listing past the bearer", func(t *testing.T) {
// Ask for the admin org explicitly: the guard denies the cross-tenant
// read, and even if it did not, Scope binds the listing to hanzo.
status, body := h.doBody(t, "GET", "/v1/iam/certs?owner=admin", h.token(t, "hanzo/boss"), nil)
if status == 200 && (strings.Contains(body, signingKid) || leaks(body)) {
t.Fatalf("LEAK: querying owner=admin escaped the bearer's scope: %s", body)
}
})
t.Run("SuperAdmin reads every tenant but never key material", func(t *testing.T) {
status, body := h.doBody(t, "GET", "/v1/iam/certs", h.token(t, "admin/root"), nil)
if status != 200 {
t.Fatalf("SuperAdmin listing must succeed, got %d: %s", status, body)
}
if !strings.Contains(body, signingKid) {
t.Fatalf("SuperAdmin must still SEE the cert (masked, not hidden): %s", body)
}
if strings.Contains(body, anchor) || leaks(body) {
t.Fatal("LEAK: key material served to SuperAdmin — the key never leaves the store")
}
})
t.Run("an unscoped listing by a tenant is refused, never lists-all", func(t *testing.T) {
status, body := h.doBody(t, "GET", "/v1/iam/certs", h.token(t, "hanzo/boss"), nil)
if status == 200 && strings.Contains(body, signingKid) {
t.Fatalf("LEAK: an empty owner listed every tenant: %s", body)
}
})
t.Run("the JWKS still publishes the PUBLIC half at both paths", func(t *testing.T) {
// The keys are masked out of the CRUD surface, not out of the protocol:
// the gateway defaults to the root path, the SDK reads the /v1/iam one.
for _, p := range []string{"/.well-known/jwks", "/v1/iam/.well-known/jwks"} {
status, body := h.doBody(t, "GET", p, "", nil)
if status != 200 {
t.Fatalf("%s must be public and serve keys, got %d", p, status)
}
if !strings.Contains(body, `"kty":"RSA"`) || !strings.Contains(body, signingKid) {
t.Fatalf("%s must publish the signing key: %s", p, body)
}
if leaks(body) || strings.Contains(body, `"d":`) {
t.Fatalf("LEAK: %s served private material: %s", p, body)
}
}
})
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz
import (
"os"
"strings"
"github.com/hanzoai/iam/internal/store"
)
// Confidential-client capabilities — the port of the v1 gate (object/app_authz.go
// + controllers/app_mutation_guard.go requireAppCapability) that revoked the
// "every client credential is a global admin" privilege.
//
// A Cap is a named authority an app principal holds ONLY when its application
// name is listed in the allowlist Env names. It is the ONLY thing an app
// principal's authority is made of: an app is never a SuperAdmin and never an
// org admin (see Principal), so a leaked client credential grants exactly the
// capabilities its NAME was allowlisted for and nothing more.
//
// The key is the application NAME, not its (owner, name) row. That alone would let
// ANY owner's app claim a listed name, so Allowed ALSO pins the app's OWNING org to
// a reserved platform signing owner (store.IsSigningCertOwner): the name is thereby
// reserved to the platform's admin-owned app, and a tenant that registers
// <theirOrg>/hanzo-console — same name, its own owner — inherits none of its grants.
// The pin is what ENFORCES that reservation; the name match alone was the escalation.
// Cap is one capability: a Name for diagnostics and the Env var holding its
// comma-separated allowlist of application names.
type Cap struct {
Name string
Env string
}
// The capability set, matching the live allowlists byte-for-byte
// (universe infra/k8s/operator/crs/iam.yaml). Every one is fail-secure: an unset
// or empty allowlist denies EVERY app.
var (
// CapKeyMint gates minting, rotating, or revoking a credential on another
// principal's behalf — the service-account administration boundary, since a
// minted key is an org-billing credential.
CapKeyMint = Cap{Name: "key-mint", Env: "IAM_KEY_MINT_ALLOWED_APPS"}
// CapUserAdmin gates cross-user account mutation (owner, isAdmin, email,
// type, credentials) — cloud moves an onboarding user into the org it just
// created through this.
CapUserAdmin = Cap{Name: "user", Env: "IAM_USER_ADMIN_APPS"}
// CapOrgAdmin gates organization create/read/update/delete. Unlike the
// signing-material capabilities this one is populated in every environment:
// the brand consoles legitimately create customer orgs during onboarding.
CapOrgAdmin = Cap{Name: "organization", Env: "IAM_ORG_ADMIN_APPS"}
// CapServiceAccountRead gates LISTING an org's service accounts — names and
// metadata only, never secrets. It is the read-only counterpart to
// CapKeyMint (a read cap can never mint, rotate, or delete a credential) and
// is additionally tenant-bound by BoundToOrg.
CapServiceAccountRead = Cap{Name: "service-account-read", Env: "IAM_SA_LIST_ALLOWED_APPS"}
)
// Allowed reports whether p holds c.
//
// A non-app principal holds every capability vacuously: this gate concerns
// confidential clients ONLY, and a human's authority is decided by the org
// policy in authorize(). Conflating the two would either lock every human out or
// hand every app a human's scope.
//
// Fail-secure, exactly as v1: an app whose allowlist is unset, empty, or does
// not name it holds nothing.
func Allowed(p *Principal, c Cap) bool {
if p == nil {
return false
}
if p.App == "" {
return true // not an app; the org policy decides
}
// The owner-pin: an app holds a platform capability ONLY when its OWNING org is a
// reserved platform signing owner (admin/built-in). Every allow-listed console is
// admin-owned, so this never revokes a legitimate grant — but it binds the NAME
// allowlist to the platform: a tenant that registers <theirOrg>/hanzo-console
// (same name, its OWN owner) is not a signing owner, so it inherits nothing. This
// is the single gate that turns the allowlist's NAME key from a spoofable label
// into an authority reserved to the admin-owned app.
if !store.IsSigningCertOwner(p.AppOwner) {
return false
}
if c.Env == "" {
return false
}
for _, item := range strings.Split(os.Getenv(c.Env), ",") {
if strings.TrimSpace(item) == p.App {
return true
}
}
return false
}
// BoundToOrg reports whether an app principal is bound to org by the
// <org>-<app> naming convention — app/hanzo-team may act on organization=hanzo
// and on no other tenant's. The org is derived from the (allowlist-reserved)
// application NAME, so the binding holds regardless of the app row's owner, and
// it is the same prefix rule the service-account names it reads obey.
func BoundToOrg(p *Principal, org string) bool {
if p == nil || org == "" {
return false
}
prefix := org + "-"
return len(p.App) > len(prefix) && strings.HasPrefix(p.App, prefix)
}
// capFor maps an entity to the capability a confidential client needs to act on
// it. An entity with NO mapping grants an app nothing: unmapped denies exactly
// as an unset allowlist does, which IS v1's live behaviour for every capability
// the deployment leaves empty — certs, providers, tokens, syncers, webhooks are
// all deny-all by design, because no client credential should ever reach signing
// material. Only the two entities a live confidential client touches are mapped:
// the brand consoles create customer orgs, and cloud moves the onboarding user
// into the org it just created.
func capFor(entity string) Cap {
switch entity {
case "organizations":
return CapOrgAdmin
case "users":
return CapUserAdmin
}
return Cap{}
}
+274
View File
@@ -0,0 +1,274 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package bootstrap serves the operator-driven service-account provisioning
// endpoints — `POST /v1/iam/admin/{applications,users}/upsert`. The Hanzo K8s
// operator (operator-core) reconciles an IAM CR's spec.applications[]/users[] here,
// wiring the service-account OAuth apps that KMS/signers authenticate with, with NO
// human admin in the loop. It is idempotent (create OR update by the natural key)
// so a ~30s reconcile is a no-op once converged.
//
// Auth is a UNIFIED SERVICE TOKEN presented as `Authorization: Bearer <token>`,
// validated constant-time against the first non-empty of HANZO_API_KEY /
// KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN — the same pipeline the old iam used. The
// token is system-level (bypasses the org-membership gate), so these routes live in
// the PUBLIC group (before the Guard) and self-authenticate here. An unset token
// fails closed: no service token configured → no bootstrap.
package bootstrap
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Route registers the bootstrap upsert endpoints on the PUBLIC group r (they
// self-authenticate via the service token, not a bearer principal).
func Route(r zip.Router, db orm.DB) {
r.Post("/v1/iam/admin/applications/upsert", upsertApplication(db))
r.Post("/v1/iam/admin/users/upsert", upsertUser(db))
}
// serviceToken returns the configured unified service token, or "" (fail closed).
func serviceToken() string {
for _, key := range []string{"HANZO_API_KEY", "KMS_SERVICE_TOKEN", "IAM_SERVICE_TOKEN"} {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
// authService validates the Bearer service token (constant-time). An unset expected
// token, or a mismatch, is unauthorized.
func authService(c *zip.Ctx) bool {
expected := serviceToken()
if expected == "" {
return false
}
const p = "Bearer "
h := c.Header("Authorization")
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
return false
}
got := strings.TrimSpace(h[len(p):])
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}
func unauthorized(c *zip.Ctx) error {
return c.JSON(401, map[string]any{"status": "error", "msg": "a valid service token is required"})
}
// appUpsertReq is the operator's application upsert body (operator-core UpsertRequest).
type appUpsertReq struct {
Organization string `json:"organization"`
Name string `json:"name"`
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
GrantTypes []string `json:"grantTypes"`
RedirectUris []string `json:"redirectUris"`
DisplayName string `json:"displayName"`
Cert string `json:"cert"`
}
// upsertApplication idempotently creates or updates a service-account application,
// keyed by (Owner="admin", Name) — applications are platform-owned. Returns
// {status:"ok", action:"created"|"updated", data:{name, organization, clientId,
// clientSecret}} — the shape operator-core parses. A missing clientSecret preserves
// the existing one (no rotation on a steady-state reconcile) or is generated.
func upsertApplication(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
if !authService(c) {
return unauthorized(c)
}
ctx := c.Context()
var req appUpsertReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body: "+err.Error()))
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return c.JSON(400, errResp("name is required"))
}
existing, err := store.GetApplicationByName(ctx, db, "admin", req.Name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if req.ClientSecret == "" {
if existing != nil && existing.ClientSecret != "" {
req.ClientSecret = existing.ClientSecret
} else {
req.ClientSecret = randomSecret()
}
}
if req.ClientId == "" {
req.ClientId = req.Name // <org>-<app> convention: clientId == name
}
action := "created"
if existing != nil {
action = "updated"
existing.ClientId = req.ClientId
existing.ClientSecret = req.ClientSecret
existing.Organization = pick(req.Organization, existing.Organization)
if req.DisplayName != "" {
existing.DisplayName = req.DisplayName
}
if len(req.GrantTypes) > 0 {
existing.GrantTypes = req.GrantTypes
}
if len(req.RedirectUris) > 0 {
existing.RedirectUris = req.RedirectUris
}
if req.Cert != "" {
existing.Cert = req.Cert
}
existing.EnablePassword = true
if err := existing.UpdateCtx(ctx); err != nil {
return c.JSON(500, errResp("server_error"))
}
} else {
a := orm.New[schema.Application](db)
model := a.Model
a.Owner, a.Name = "admin", req.Name
a.ClientId, a.ClientSecret = req.ClientId, req.ClientSecret
a.Organization, a.DisplayName = req.Organization, pick(req.DisplayName, req.Name)
a.GrantTypes, a.RedirectUris, a.Cert = req.GrantTypes, req.RedirectUris, req.Cert
a.EnablePassword, a.ExpireInHours = true, 1
a.Model = model
a.SetId("admin/" + req.Name)
if err := a.CreateCtx(ctx); err != nil {
return c.JSON(500, errResp("server_error"))
}
}
return c.JSON(200, map[string]any{
"status": "ok", "action": action,
"data": map[string]any{
"name": req.Name, "organization": req.Organization,
"clientId": req.ClientId, "clientSecret": req.ClientSecret,
},
})
}
}
// userUpsertReq is the operator's user upsert body.
type userUpsertReq struct {
Owner string `json:"owner"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
PasswordType string `json:"passwordType"`
IsAdmin bool `json:"isAdmin"`
}
// upsertUser idempotently creates or updates a user keyed by (Owner, Name). The
// password is argon2id-hashed (SOTA; never stored plaintext); an empty password preserves
// the existing credential. Returns {status:"ok", action, data:{owner, name}}.
func upsertUser(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
if !authService(c) {
return unauthorized(c)
}
ctx := c.Context()
var req userUpsertReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body: "+err.Error()))
}
req.Owner, req.Name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
if req.Owner == "" || req.Name == "" {
return c.JSON(400, errResp("owner and name are required"))
}
var hash string
if req.Password != "" {
h, err := cred.Hash(req.Password)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
hash = h
}
existing, err := store.GetUserByName(ctx, db, req.Owner, req.Name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
action := "created"
if existing != nil {
action = "updated"
existing.DisplayName = pick(req.DisplayName, existing.DisplayName)
existing.Email = pick(req.Email, existing.Email)
existing.Phone = pick(req.Phone, existing.Phone)
existing.IsAdmin = req.IsAdmin
if hash != "" {
existing.PasswordHash, existing.PasswordType, existing.PasswordSalt = hash, cred.TypeArgon2id, ""
}
existing.UpdatedTime = now()
if err := existing.UpdateCtx(ctx); err != nil {
return c.JSON(500, errResp("server_error"))
}
} else {
u := orm.New[schema.User](db)
model := u.Model
u.Owner, u.Name = req.Owner, req.Name
u.DisplayName, u.Email, u.Phone, u.IsAdmin = req.DisplayName, req.Email, req.Phone, req.IsAdmin
if hash != "" {
u.PasswordHash, u.PasswordType = hash, cred.TypeArgon2id
}
u.CreatedTime, u.UpdatedTime = now(), now()
u.Model = model
u.SetId(req.Owner + "/" + req.Name)
if err := u.CreateCtx(ctx); err != nil {
return c.JSON(500, errResp("server_error"))
}
}
return c.JSON(200, map[string]any{
"status": "ok", "action": action,
"data": map[string]any{"owner": req.Owner, "name": req.Name},
})
}
}
// ---- helpers ----
// decode reads the raw JSON body (content-type independent) into v.
func decode(c *zip.Ctx, v any) error {
body := c.Body()
if len(body) == 0 {
return errors.New("empty request body")
}
return json.Unmarshal(body, v)
}
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
// pick returns a if non-empty (trimmed), else b.
func pick(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// randomSecret returns a 32-byte URL-safe random client secret.
func randomSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func now() string { return time.Now().UTC().Format(time.RFC3339) }
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package bootstrap_test
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
const svcToken = "svc-token-secret-value"
func boot(t *testing.T) (*zip.App, orm.DB) {
t.Helper()
t.Setenv("IAM_SERVICE_TOKEN", svcToken)
_ = schema.Kinds()
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "boot.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
app := zip.New(zip.Config{AppName: "bootstrap-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
return app, db
}
func post(t *testing.T, app *zip.App, path, token, body string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(b, &m)
return resp.StatusCode, m
}
func TestUpsertApplication_createThenIdempotentUpdate(t *testing.T) {
app, db := boot(t)
body := `{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","grantTypes":["client_credentials"]}`
// Create — a secret is generated, action=created.
st, m := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
if st != 200 || m["status"] != "ok" || m["action"] != "created" {
t.Fatalf("create: status=%d body=%v", st, m)
}
data, _ := m["data"].(map[string]any)
secret, _ := data["clientSecret"].(string)
if secret == "" {
t.Fatalf("no clientSecret generated: %v", data)
}
if a, _ := store.GetApplicationByName(context.Background(), db, "admin", "hanzo-kms"); a == nil {
t.Fatalf("app not persisted")
}
// Re-upsert with NO secret — idempotent: action=updated, the SAME secret is
// preserved (no rotation storm on a steady-state reconcile).
st2, m2 := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
if st2 != 200 || m2["action"] != "updated" {
t.Fatalf("re-upsert: status=%d body=%v", st2, m2)
}
data2, _ := m2["data"].(map[string]any)
if data2["clientSecret"] != secret {
t.Fatalf("clientSecret rotated on idempotent re-upsert: %v → %v", secret, data2["clientSecret"])
}
}
func TestUpsertUser_createHashesPassword(t *testing.T) {
app, db := boot(t)
body := `{"owner":"hanzo","name":"svc-signer","password":"s3cret","isAdmin":false}`
st, m := post(t, app, "/v1/iam/admin/users/upsert", svcToken, body)
if st != 200 || m["action"] != "created" {
t.Fatalf("create user: status=%d body=%v", st, m)
}
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "svc-signer")
if u == nil || u.PasswordHash == "" || u.PasswordHash == "s3cret" {
t.Fatalf("password not hashed: %+v", u)
}
if u.PasswordType != "argon2id" {
t.Fatalf("passwordType = %q, want argon2id", u.PasswordType)
}
}
func TestBootstrap_requiresServiceToken(t *testing.T) {
app, _ := boot(t)
body := `{"name":"x"}`
// No token → 401.
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "", body); st != 401 {
t.Fatalf("no-token status = %d, want 401", st)
}
// Wrong token → 401.
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "wrong-token", body); st != 401 {
t.Fatalf("wrong-token status = %d, want 401", st)
}
}
+177
View File
@@ -0,0 +1,177 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package certs serves the IAM v2 CRUD surface for the `certs` entity: a
// signing / TLS certificate owner-scoped by (owner, name). Every operation is a
// typed zip handler over hanzoai/orm; the orm string key is "owner/name". Reads
// scope to one owner (organization); writes address one cert by its (owner,
// name) key.
package certs
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/schema"
)
// Handler binds the certs operations to one orm store.
type Handler struct {
db orm.DB
}
// Route registers the certs CRUD routes on app against db. Reads are zip.Get,
// writes are zip.Post; the create/update body is the schema.Cert row itself, so
// the wire contract and the stored entity never drift.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/certs", h.List, zip.WithSummary("List certs for an owner"), zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs", h.Create, zip.WithSummary("Create a cert"), zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs/get", h.Get, zip.WithSummary("Get one cert"), zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs/update", h.Update, zip.WithSummary("Update a cert"), zip.WithTags("certs"))
zip.Post(app, "/v1/iam/certs/delete", h.Delete, zip.WithSummary("Delete a cert"), zip.WithTags("certs"))
}
// Ref addresses one cert by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of certs.
type ListOutput struct {
Certs []*schema.Cert `json:"certs"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// key builds the orm string key from the (owner, name) natural key.
func key(owner, name string) string { return owner + "/" + name }
// List returns the certs the caller may read, newest first, secrets masked. The
// owner is resolved by authz.Scope from the authenticated principal — a tenant
// reads only its own org, a SuperAdmin reads the owner it asks for — so a query
// parameter can never widen a listing beyond the bearer's authority.
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
owner, err := authz.Scope(ctx, in.Owner)
if err != nil {
return nil, err
}
q := orm.TypedQuery[schema.Cert](h.db)
if owner != "" {
q = q.Filter("owner", owner)
}
certs, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
out := make([]*schema.Cert, len(certs))
for i, c := range certs {
out[i] = c.Mask()
}
return &ListOutput{Certs: out, Total: len(out)}, nil
}
// Get returns one cert addressed by (owner, name), secrets masked.
func (h *Handler) Get(_ context.Context, in *Ref) (*schema.Cert, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return cert.Mask(), nil
}
// Create persists a new cert. It rejects a duplicate (owner, name) and stamps
// CreatedTime when the caller leaves it blank.
func (h *Handler) Create(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("cert already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
// orm.New wires the store and applies defaults; overlay the decoded row,
// then restore the wired Model so its db handle survives the assignment.
cert := orm.New[schema.Cert](h.db)
model := cert.Model
*cert = *in
cert.Model = model
if cert.CreatedTime == "" {
cert.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
cert.SetId(key(in.Owner, in.Name))
if err := cert.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return cert, nil
}
// Update overwrites a cert's mutable fields. Identity (owner, name) and the
// CreatedTime stamp are immutable; a missing cert is a 404.
func (h *Handler) Update(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
// Keep the loaded Model (id, createdAt, key, snapshot) and the original
// creation stamp; overlay the decoded domain fields onto them.
model := cert.Model
created := cert.CreatedTime
*cert = *in
cert.Model = model
cert.Owner, cert.Name = in.Owner, in.Name
if created != "" {
cert.CreatedTime = created
}
if err := cert.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return cert, nil
}
// Delete removes one cert addressed by (owner, name).
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := cert.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// mapErr translates an orm lookup error into the matching HTTP status.
func mapErr(err error) error {
if errors.Is(err, orm.ErrNotFound) {
return zip.ErrNotFound("cert not found")
}
return zip.ErrInternal(err.Error())
}
+2
View File
@@ -44,6 +44,8 @@ var mapping = []pair{
{"token", "tokens"},
{"record", "audit_logs"},
{"invitation", "invitations"},
{"web3_nonce", "challenges"},
{"wallet_link", "wallets"},
}
// Run writes a tab-aligned per-entity drift report to w. ctx bounds every
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package compat serves the Casdoor VERB surface (get-users, get-organizations,
// …) over iam2's orm store, in the v1 Response envelope. It exists because every
// live consumer — the console admin BFF, the gateway admin-api, the hanzo.id
// portal — hard-codes the Casdoor verb spellings and the `{status,data,data2}`
// envelope, while iam2's native surface is REST (`/v1/iam/users`,
// `/v1/iam/users/get`). Without these aliases a backend swap 404s every console
// IAM page. The aliases are a thin routing + envelope layer over the SAME orm
// store and the SAME schema.Mask redaction the REST handlers use — no CRUD and
// no redaction is reimplemented here.
//
// Authorization is NOT reimplemented either. These paths are not in authz's
// public allowlist, so the Guard (app.Use, mounted first) authenticates every
// request AND authorizes the read against the exact (owner, name) it addresses —
// resolved by the same authz.ReadTarget the handlers use, so a handler can never
// reach a row the Guard did not authorize. Each handler then re-scopes the query
// owner through authz.Scope: a SuperAdmin may list any owner (empty = all
// tenants), everyone else is pinned to their own org, so a request parameter can
// never widen a read past the caller's authority.
package compat
import (
"errors"
"strconv"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
)
// Route registers the Casdoor read-verb aliases. The mask argument is the
// entity's schema.Mask method (the ONE redaction contract) for entities that
// carry secrets, or nil for those that do not — nil means "no field to strip",
// not "skip a needed redaction". Writes ride a companion file.
func Route(app *zip.App, db orm.DB) {
// List reads — `?owner=&p=&pageSize=` (Casdoor shape). Owner-scoped by authz.
app.Get("/v1/iam/get-organizations", listHandler(db, (*schema.Organization).Mask))
app.Get("/v1/iam/get-users", listHandler(db, (*schema.User).Mask))
app.Get("/v1/iam/get-global-users", listHandler(db, (*schema.User).Mask))
app.Get("/v1/iam/get-applications", listHandler(db, (*schema.Application).Mask))
app.Get("/v1/iam/get-providers", listHandler(db, (*schema.Provider).Mask))
app.Get("/v1/iam/get-certs", listHandler(db, (*schema.Cert).Mask))
app.Get("/v1/iam/get-roles", listHandler[schema.Role](db, nil))
app.Get("/v1/iam/get-permissions", listHandler[schema.Permission](db, nil))
app.Get("/v1/iam/get-invitations", listHandler[schema.Invitation](db, nil))
app.Get("/v1/iam/get-records", listHandler[schema.AuditLog](db, nil))
// Single reads — `?id=<owner>/<name>` (or `?owner=&name=`).
app.Get("/v1/iam/get-organization", getHandler(db, (*schema.Organization).Mask))
app.Get("/v1/iam/get-user", getHandler(db, (*schema.User).Mask))
app.Get("/v1/iam/get-application", getHandler(db, (*schema.Application).Mask))
app.Get("/v1/iam/get-provider", getHandler(db, (*schema.Provider).Mask))
app.Get("/v1/iam/get-cert", getHandler(db, (*schema.Cert).Mask))
app.Get("/v1/iam/get-role", getHandler[schema.Role](db, nil))
app.Get("/v1/iam/get-permission", getHandler[schema.Permission](db, nil))
// get-organization-projects — the console ScopeSwitcher's project list, keyed by
// ?organization= (not ?owner=). Its target rides in ?organization, which the Guard
// does not inspect generically, so this path is handler-authorized (authz's
// handlerAuthorizedPrefixes): the Guard authenticates, and this handler scopes the
// requested org through authz.Scope — a non-super is pinned to its own org, so any
// authenticated member lists exactly its own org's projects (the ScopeSwitcher is
// shown to every user, not only admins, so this read is intentionally not
// admin-gated the way the generic listers are).
app.Get("/v1/iam/get-organization-projects", orgProjectsHandler(db))
// get-organization-workspaces — the console ScopeSwitcher's workspace list, the
// tier above projects in the Organization → Workspace → Project hierarchy. Keyed
// by ?organization= exactly like get-organization-projects, so it is
// handler-authorized (authz's handlerAuthorizedPrefixes) the same way: the Guard
// authenticates, and this handler scopes the requested org through authz.Scope so
// a request parameter can never widen the read past the caller's tenant.
app.Get("/v1/iam/get-organization-workspaces", orgWorkspacesHandler(db))
// The Casdoor WRITE verbs (companion file), over the same store + authz seam.
routeWrites(app, db)
}
// orgProjectsHandler serves get-organization-projects: the org's project list for
// the console ScopeSwitcher. The requested org rides in ?organization= (or ?owner=
// as a fallback); authz.Scope pins a non-super to its own org, so a request
// parameter can never widen the read past the caller's tenant. Projects carry no
// secrets, so no Mask is applied.
func orgProjectsHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
requested := c.Query("organization")
if requested == "" {
requested = c.Query("owner")
}
owner, err := authz.Scope(ctx, requested)
if err != nil {
return httpx.Err(c, err.Error())
}
q := orm.TypedQuery[schema.Project](db)
if owner != "" {
q = q.Filter("Owner=", owner)
}
rows, err := q.Order("Name").GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, rows)
}
}
// orgWorkspacesHandler serves get-organization-workspaces: the org's workspace
// list for the console ScopeSwitcher. The requested org rides in ?organization=
// (or ?owner= as a fallback); authz.Scope pins a non-super to its own org, so a
// request parameter can never widen the read past the caller's tenant. Workspaces
// carry no secrets, so no Mask is applied.
func orgWorkspacesHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
requested := c.Query("organization")
if requested == "" {
requested = c.Query("owner")
}
owner, err := authz.Scope(ctx, requested)
if err != nil {
return httpx.Err(c, err.Error())
}
q := orm.TypedQuery[schema.Workspace](db)
if owner != "" {
q = q.Filter("Owner=", owner)
}
rows, err := q.Order("Name").GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, rows)
}
}
// listHandler serves a Casdoor get-<entities> list for one orm kind: it scopes
// the owner through authz, queries the store, redacts each row via the entity's
// Mask, and wraps the result in the v1 envelope. Per the v1 contract a list
// paginates ONLY when BOTH `p` and `pageSize` are present — then the total rides
// in data2; otherwise the full owner-scoped set is returned with no data2.
//
// Scoping note (intentional, fail-closed): iam2's ownership model is mixed —
// users/roles/permissions are owned by their tenant org, while organizations/
// applications/providers/certs are platform-owned (Owner "admin"). A SuperAdmin
// (Scope → the requested owner, empty = all) therefore lists every entity, which
// is the console-admin path. A non-super is pinned by Scope to its own org, so it
// lists its tenant-owned entities correctly and is refused the platform-owned
// lists at the Guard (owner "" or "admin" both deny) — a safe 403, never another
// tenant's rows. Non-super, membership-scoped views of the platform-owned
// entities (e.g. an org console's own app list keyed on Application.Organization)
// are a separate, additive surface, not a silent behavior of this generic lister.
func listHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, err := authz.Scope(ctx, c.Query("owner"))
if err != nil {
return httpx.Err(c, err.Error())
}
base := func() *orm.ModelQuery[T] {
q := orm.TypedQuery[T](db)
if owner != "" {
q = q.Filter("Owner=", owner)
}
return q
}
page, size, paginated := pageParams(c)
if !paginated {
rows, err := base().Order("Name").GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, maskAll(rows, mask))
}
total, err := base().Count(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
rows, err := base().Order("Name").Limit(size).Offset((page - 1) * size).GetAll(ctx)
if err != nil {
return httpx.Err(c, err.Error())
}
return c.JSON(200, httpx.Response{Status: "ok", Data: maskAll(rows, mask), Data2: total})
}
}
// getHandler serves a Casdoor get-<entity> single read. The target is resolved
// by authz.ReadTarget (the same extraction the Guard authorized with), then the
// owner is re-scoped through authz.Scope so a non-super can never read another
// tenant's row even if it spells one in `?id`.
func getHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name := authz.ReadTarget(c)
if name == "" {
return httpx.Err(c, "id (owner/name) or name is required")
}
scoped, err := authz.Scope(ctx, owner)
if err != nil {
return httpx.Err(c, err.Error())
}
row, err := orm.TypedQuery[T](db).Filter("Owner=", scoped).Filter("Name=", name).First()
if errors.Is(err, orm.ErrNotFound) {
return httpx.Err(c, "the entity does not exist")
}
if err != nil {
return httpx.Err(c, err.Error())
}
if mask != nil {
row = mask(row)
}
return httpx.Ok(c, row)
}
}
// maskAll redacts every row through the entity's Mask (a no-op when the entity
// has no secrets, i.e. mask is nil). Mask returns a copy, so the slice is
// rewritten in place with the masked copies.
func maskAll[T any](rows []*T, mask func(*T) *T) []*T {
if mask == nil {
return rows
}
for i, r := range rows {
rows[i] = mask(r)
}
return rows
}
// pageParams returns (page, size, paginated). A list paginates ONLY when BOTH
// `p` and `pageSize` are present and positive; otherwise the caller returns the
// full set (v1 semantics).
func pageParams(c *zip.Ctx) (page, size int, paginated bool) {
pp, ps := c.Query("p"), c.Query("pageSize")
if pp == "" || ps == "" {
return 0, 0, false
}
page, _ = strconv.Atoi(pp)
size, _ = strconv.Atoi(ps)
if page <= 0 || size <= 0 {
return 0, 0, false
}
return page, size, true
}
+370
View File
@@ -0,0 +1,370 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package compat_test
// End-to-end tests for the Casdoor verb aliases, driven through the REAL mounted
// router (routes.Mount installs the authz Guard between the public group and the
// authed routes; compat is registered after it, so gated). Every
// case is a wire request a live console/gateway client sends. The assertions are
// the three contracts a backend swap depends on: the v1 {status,data,data2}
// envelope shape, owner-scoping that no request parameter can widen, and — the
// security one — that NO secret material ever appears in a response body.
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
)
const signingKid = "cert-hanzo"
// Distinctive secret sentinels: if any of these strings appears in ANY response
// body, redaction failed and a real credential leaked.
const (
secretUserHash = "$argon2id$SENTINEL_USER_PW_HASH"
secretOrgMaster = "SENTINEL_ORG_MASTER_PW"
secretAppClient = "SENTINEL_APP_CLIENT_SECRET"
secretProvClient = "SENTINEL_PROVIDER_CLIENT_SECRET"
)
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "compat.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Trust anchor (admin-owned RS256 signing cert = JWKS kid).
seedCert(t, db, "admin", signingKid, pemOf(t, key))
// Principals across two orgs: a SuperAdmin, an org-admin, a regular user.
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
seedUser(t, db, "orgb", "bob", true) // org-admin of a second tenant
// Secret-bearing rows: every one carries a sentinel that must never surface.
// users already seeded carry a password hash sentinel (set in seedUser).
seedOrg(t, db, "hanzo") // Owner="admin", Name="hanzo", MasterPassword sentinel
seedApp(t, db, "hanzo-console") // Owner="admin", ClientSecret sentinel
seedProvider(t, db, "provider-gh") // Owner="admin", ClientSecret sentinel
app := zip.New(zip.Config{AppName: "compat-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
return &harness{app: app, key: key, db: db}
}
func (h *harness) token(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = signingKid
s, err := tok.SignedString(h.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
// get issues a GET through the real router and returns (status, rawBody).
func (h *harness) get(t *testing.T, path, bearer string) (int, string) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
req.Host = "hanzo.id"
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := h.app.Fiber().Test(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b)
}
// envelope is the v1 Response shape the clients parse.
type envelope struct {
Status string `json:"status"`
Msg string `json:"msg"`
Data []json.RawMessage `json:"data"`
Data2 json.RawMessage `json:"data2"`
}
// ---- assertions ------------------------------------------------------------
func TestGetUsers_super_envelopeAndNoSecretLeak(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
assertNoSecretLeak(t, body)
var env envelope
if err := json.Unmarshal([]byte(body), &env); err != nil {
t.Fatalf("body is not the v1 envelope: %v; body=%s", err, body)
}
if env.Status != "ok" {
t.Fatalf("status field = %q, want ok", env.Status)
}
// SuperAdmin, no owner filter → every user across every org (4 seeded).
if len(env.Data) != 4 {
t.Fatalf("super get-users returned %d users, want 4", len(env.Data))
}
}
func TestGetUsers_paged_data2IsTotal(t *testing.T) {
h := newHarness(t)
_, body := h.get(t, "/v1/iam/get-users?p=1&pageSize=2", h.token(t, "admin/root"))
assertNoSecretLeak(t, body)
var env envelope
if err := json.Unmarshal([]byte(body), &env); err != nil {
t.Fatalf("not the v1 envelope: %v", err)
}
if len(env.Data) != 2 {
t.Fatalf("page 1 pageSize 2 returned %d rows, want 2", len(env.Data))
}
// data2 carries the FULL owner-scoped total (4), not the page length.
var total int
if err := json.Unmarshal(env.Data2, &total); err != nil {
t.Fatalf("data2 is not an int total: %v (data2=%s)", err, env.Data2)
}
if total != 4 {
t.Fatalf("data2 total = %d, want 4", total)
}
}
func TestGetUsers_unpaged_hasNoData2(t *testing.T) {
h := newHarness(t)
_, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
// v1 omits data2 entirely when the list is not paginated.
if strings.Contains(body, "\"data2\"") {
t.Fatalf("unpaged list must omit data2; body=%s", body)
}
}
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"))
if status != 200 {
t.Fatalf("status = %d; body=%s", status, body)
}
assertNoSecretLeak(t, body)
// The masked org keeps its "***" sentinel, proving Mask ran (not the raw pw).
if !strings.Contains(body, "***") {
t.Fatalf("expected the masked '***' marker in the org list; body=%s", body)
}
}
func TestGetApplications_super_noClientSecret(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-applications", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
assertNoSecretLeak(t, body)
}
func TestGetProviders_super_noClientSecret(t *testing.T) {
h := newHarness(t)
status, body := h.get(t, "/v1/iam/get-providers", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
assertNoSecretLeak(t, body)
}
func TestGetUser_byId_super(t *testing.T) {
h := newHarness(t)
// The Casdoor `?id=<owner>/<name>` shape — resolved by authz.ReadTarget.
status, body := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
assertNoSecretLeak(t, body)
if !strings.Contains(body, "alice") {
t.Fatalf("get-user?id=hanzo/alice did not return alice; body=%s", body)
}
}
func TestGetUsers_orgAdmin_scopedToOwnOrg(t *testing.T) {
h := newHarness(t)
// An org-admin MUST pass its own owner (the Guard denies an empty owner for a
// non-super); it then sees only its org's users.
status, body := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/boss"))
if status != 200 {
t.Fatalf("status=%d body=%s", status, body)
}
var env envelope
_ = json.Unmarshal([]byte(body), &env)
if len(env.Data) != 2 { // hanzo/boss + hanzo/alice, never orgb/bob
t.Fatalf("org-admin get-users?owner=hanzo returned %d, want 2 (own org only)", len(env.Data))
}
assertNoSecretLeak(t, body)
}
func TestGetUsers_orgAdmin_crossTenantDenied(t *testing.T) {
h := newHarness(t)
// hanzo's admin cannot list orgb's users — the Guard refuses a foreign owner.
status, _ := h.get(t, "/v1/iam/get-users?owner=orgb", h.token(t, "hanzo/boss"))
if status != 403 {
t.Fatalf("cross-tenant get-users status = %d, want 403", status)
}
}
func TestGetUser_byId_crossTenantDenied(t *testing.T) {
h := newHarness(t)
// The `?id=` fallback must not open a cross-tenant hole: hanzo's admin naming
// orgb/bob is refused at the Guard, exactly as the ?owner= form is.
status, _ := h.get(t, "/v1/iam/get-user?id=orgb/bob", h.token(t, "hanzo/boss"))
if status != 403 {
t.Fatalf("cross-tenant get-user?id=orgb/bob status = %d, want 403", status)
}
}
func TestGetUsers_regularUser_cannotList(t *testing.T) {
h := newHarness(t)
// A non-admin user may not enumerate its org's users (the self-service rule is
// a single-record read, never a list).
status, _ := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/alice"))
if status != 403 {
t.Fatalf("regular-user get-users status = %d, want 403", status)
}
}
func TestGetApplications_nonSuper_deniedOnPlatformOwned(t *testing.T) {
h := newHarness(t)
// Applications are platform-owned (Owner "admin"); a non-super gets a safe 403
// at the Guard, never another tenant's app rows.
status, _ := h.get(t, "/v1/iam/get-applications", h.token(t, "hanzo/boss"))
if status != 403 {
t.Fatalf("non-super get-applications status = %d, want 403", status)
}
}
func TestCompatAliases_requireAuth(t *testing.T) {
h := newHarness(t)
// No bearer → the Guard fails closed (compat is registered after the Guard).
if status, _ := h.get(t, "/v1/iam/get-users", ""); status != 401 {
t.Fatalf("unauthenticated get-users status = %d, want 401", status)
}
}
// assertNoSecretLeak fails if any seeded secret sentinel appears in the body —
// the single most important property of the whole layer.
func assertNoSecretLeak(t *testing.T, body string) {
t.Helper()
for _, secret := range []string{secretUserHash, secretOrgMaster, secretAppClient, secretProvClient} {
if strings.Contains(body, secret) {
t.Fatalf("SECRET LEAK: %q appeared in a response body:\n%s", secret, body)
}
}
}
// ---- seed helpers ----------------------------------------------------------
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.CryptoAlgorithm = "RS256"
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin = admin
u.PasswordHash = secretUserHash // the sentinel that must never surface
u.PasswordType = "argon2id"
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func seedOrg(t *testing.T, db orm.DB, name string) {
t.Helper()
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", name // orgs are platform-owned
o.MasterPassword = secretOrgMaster
o.SetId("admin/" + name)
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed org: %v", err)
}
}
func seedApp(t *testing.T, db orm.DB, name string) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name = "admin", name
a.Organization = "hanzo"
a.ClientSecret = secretAppClient
a.SetId("admin/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed app: %v", err)
}
}
func seedProvider(t *testing.T, db orm.DB, name string) {
t.Helper()
p := orm.New[schema.Provider](db)
p.Owner, p.Name = "admin", name
p.ClientSecret = secretProvClient
p.SetId("admin/" + name)
if err := p.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed provider: %v", err)
}
}
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package compat
import (
"context"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/applications"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/organizations"
"github.com/hanzoai/iam/internal/projects"
"github.com/hanzoai/iam/internal/providers"
"github.com/hanzoai/iam/internal/roles"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/internal/workspaces"
)
// The Casdoor WRITE verbs (add-organization, add-user, update-user,
// update-application) the console admin BFF hard-codes, served over the SAME entity
// Create/Update logic as the REST surface — no CRUD is reimplemented here. Each is a
// TYPED zip op (not a raw handler), which is what preserves authorization: the ONE
// authz seam (app.Authorize) runs at every typed op's invoke on the DECODED input, so
// a write alias is authorized against the exact (owner, name) it will bind — a super
// for a platform-owned org/app, an org-admin for its own users — identical to the REST
// twin. The result is wrapped in the casibase {status,msg,data} envelope the clients
// parse; the data is the REDACTED entity (each Create/Update returns Mask()).
//
// Read verbs ride aliases.go; these are the "Writes ride a companion file" half.
// routeWrites registers the Casdoor write-verb aliases on app. Called from Route
// (aliases.go) so reads and writes share the one Guard/Authorize seam.
func routeWrites(app *zip.App, db orm.DB) {
orgs := organizations.NewOrganizationAPI(db)
usersAPI := users.New(db)
appCreate, appUpdate, appDelete := applications.Create(db), applications.Update(db), applications.Delete(db)
rolesH := roles.New(db)
projectsH := projects.New(db)
workspacesH := workspaces.New(db)
provAdd, provUpdate, provDelete := providers.Add(db), providers.Update(db), providers.Delete(db)
zip.Post(app, "/v1/iam/add-organization",
func(ctx context.Context, in *organizations.CreateOrganizationInput) (*httpx.Response, error) {
return envelope(orgs.Create(ctx, in))
},
zip.WithOperationID("addOrganization"), zip.WithSummary("Create an organization (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/add-user",
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
return envelope(usersAPI.Create(ctx, &users.CreateInput{User: in.User, Password: in.Password}))
},
zip.WithOperationID("addUser"), zip.WithSummary("Create a user (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/update-user",
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
return envelope(usersAPI.Update(ctx, &users.UpdateInput{User: in.User, Password: in.Password}))
},
zip.WithOperationID("updateUser"), zip.WithSummary("Update a user (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/update-application",
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
return envelope(appUpdate(ctx, in))
},
zip.WithOperationID("updateApplication"), zip.WithSummary("Update an application (Casdoor verb)"), zip.WithTags("compat"))
// delete-user — the console IamAdminApi + /org/iam admin mutation.
zip.Post(app, "/v1/iam/delete-user",
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
return envelope(usersAPI.Delete(ctx, &users.Ref{Owner: in.Owner, Name: in.Name}))
},
zip.WithOperationID("deleteUser"), zip.WithSummary("Delete a user (Casdoor verb)"), zip.WithTags("compat"))
// Applications: add-/delete- (update-application already above).
zip.Post(app, "/v1/iam/add-application",
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) { return envelope(appCreate(ctx, in)) },
zip.WithOperationID("addApplication"), zip.WithSummary("Create an application (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/delete-application",
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
return envelope(appDelete(ctx, &applications.ApplicationRef{Owner: in.Owner, Name: in.Name}))
},
zip.WithOperationID("deleteApplication"), zip.WithSummary("Delete an application (Casdoor verb)"), zip.WithTags("compat"))
// Providers: add-/update-/delete- (console admin Providers page).
zip.Post(app, "/v1/iam/add-provider",
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provAdd(ctx, in)) },
zip.WithOperationID("addProvider"), zip.WithSummary("Create a provider (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/update-provider",
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provUpdate(ctx, in)) },
zip.WithOperationID("updateProvider"), zip.WithSummary("Update a provider (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/delete-provider",
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provDelete(ctx, in)) },
zip.WithOperationID("deleteProvider"), zip.WithSummary("Delete a provider (Casdoor verb)"), zip.WithTags("compat"))
// Roles: add-/update-/delete- (console admin Roles page).
zip.Post(app, "/v1/iam/add-role",
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) { return envelope(rolesH.Create(ctx, in)) },
zip.WithOperationID("addRole"), zip.WithSummary("Create a role (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/update-role",
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) { return envelope(rolesH.Update(ctx, in)) },
zip.WithOperationID("updateRole"), zip.WithSummary("Update a role (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/delete-role",
func(ctx context.Context, in *roles.Ref) (*httpx.Response, error) { return envelope(rolesH.Delete(ctx, in)) },
zip.WithOperationID("deleteRole"), zip.WithSummary("Delete a role (Casdoor verb)"), zip.WithTags("compat"))
// Projects: add-/delete- (console ScopeSwitcher; the read rides get-organization-projects
// in aliases.go). Owner is the org, so app.Authorize gates a write to an org-admin
// of that org — the same clause as add-role.
zip.Post(app, "/v1/iam/add-project",
func(ctx context.Context, in *projects.Input) (*httpx.Response, error) { return envelope(projectsH.Create(ctx, in)) },
zip.WithOperationID("addProject"), zip.WithSummary("Create a project (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/delete-project",
func(ctx context.Context, in *projects.Ref) (*httpx.Response, error) { return envelope(projectsH.Delete(ctx, in)) },
zip.WithOperationID("deleteProject"), zip.WithSummary("Delete a project (Casdoor verb)"), zip.WithTags("compat"))
// Workspaces: add-/delete- (console ScopeSwitcher; the read rides
// get-organization-workspaces in aliases.go). Owner is the org, so app.Authorize
// gates a write to an org-admin of that org — the same clause as add-project.
zip.Post(app, "/v1/iam/add-workspace",
func(ctx context.Context, in *workspaces.Input) (*httpx.Response, error) { return envelope(workspacesH.Create(ctx, in)) },
zip.WithOperationID("addWorkspace"), zip.WithSummary("Create a workspace (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/delete-workspace",
func(ctx context.Context, in *workspaces.Ref) (*httpx.Response, error) { return envelope(workspacesH.Delete(ctx, in)) },
zip.WithOperationID("deleteWorkspace"), zip.WithSummary("Delete a workspace (Casdoor verb)"), zip.WithTags("compat"))
// Organizations: update-/delete- (add-organization already above).
zip.Post(app, "/v1/iam/update-organization",
func(ctx context.Context, in *organizations.UpdateOrganizationInput) (*httpx.Response, error) {
return envelope(orgs.Update(ctx, in))
},
zip.WithOperationID("updateOrganization"), zip.WithSummary("Update an organization (Casdoor verb)"), zip.WithTags("compat"))
zip.Post(app, "/v1/iam/delete-organization",
func(ctx context.Context, in *organizations.DeleteOrganizationInput) (*httpx.Response, error) {
return envelope(orgs.Delete(ctx, in))
},
zip.WithOperationID("deleteOrganization"), zip.WithSummary("Delete an organization (Casdoor verb)"), zip.WithTags("compat"))
}
// userBody is the bare-user body the Casdoor add-user/update-user verbs post (the
// user's fields at top level, plus an optional plaintext password), distinct from the
// REST twin's {user,password} envelope. It embeds schema.User so the authz op-seam
// reads the target (Owner, Name) straight off it, then the handler hands the parts to
// the ONE users Create/Update path (which bcrypt-hashes the password — never stored
// plaintext — and returns the redacted row).
type userBody struct {
schema.User
Password string `json:"password,omitempty"`
}
// envelope wraps an entity Create/Update result in the casibase Response the Casdoor
// clients parse: {status:"ok", data:<masked entity>} on success, or a 200
// {status:"error", msg} on a handler error (the casibase convention — clients branch
// on status, not the HTTP code), never an HTTP error status. An authorization refusal
// happens earlier, at the op-seam, and surfaces as a 403 the clients already handle.
func envelope[T any](entity *T, err error) (*httpx.Response, error) {
if err != nil {
return &httpx.Response{Status: "error", Msg: err.Error()}, nil
}
return &httpx.Response{Status: "ok", Data: entity}, nil
}
+230
View File
@@ -0,0 +1,230 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package compat_test
// End-to-end tests for the Casdoor WRITE verbs + the structurally-public front
// door, driven through the REAL mounted router (routes.Mount installs the authz
// Guard + Authorize seam; the front door is registered on the pre-Guard public
// group). They assert the three write contracts a backend swap depends on:
// the {status,ok} envelope every client parses, authorization identical to the REST
// twin (super for platform-owned org/app; org-admin for its own users; cross-tenant
// refused), and that no secret ever surfaces. Plus: the front-door session routes are
// reachable WITHOUT a bearer (the portal/admin-guard call them with a cookie).
import (
"bytes"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
)
// post issues a JSON POST through the real router and returns (status, rawBody).
func (h *harness) post(t *testing.T, path, bearer string, body any) (int, string) {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := h.app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b2, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp.StatusCode, string(b2)
}
// okEnvelope decodes a body and asserts status=="ok".
func okEnvelope(t *testing.T, status int, body string) {
t.Helper()
if status != 200 {
t.Fatalf("status = %d, want 200; body=%s", status, body)
}
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("not the v1 envelope: %v; body=%s", err, body)
}
if m["status"] != "ok" {
t.Fatalf("status field = %v, want ok; body=%s", m["status"], body)
}
}
// add-organization is a platform-owned write — only a SuperAdmin may create one,
// through the SAME organizations.Create the REST route uses. The created row is then
// readable via the get-organization read alias.
func TestAddOrganization_super(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/add-organization", h.token(t, "admin/root"),
map[string]any{"owner": "admin", "name": "acme", "displayName": "Acme"})
okEnvelope(t, status, body)
assertNoSecretLeak(t, body)
// It hit the real store — the org is now readable through the get alias.
if s, rb := h.get(t, "/v1/iam/get-organization?id=admin/acme", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "acme") {
t.Fatalf("created org not readable: status=%d body=%s", s, rb)
}
}
// A non-super is refused at the ONE authz seam (platform-owned resource → super-only),
// exactly as the REST twin is.
func TestAddOrganization_nonSuperForbidden(t *testing.T) {
h := newHarness(t)
status, _ := h.post(t, "/v1/iam/add-organization", h.token(t, "hanzo/boss"),
map[string]any{"owner": "admin", "name": "acme"})
if status != 403 {
t.Fatalf("org-admin add-organization status = %d, want 403", status)
}
}
// add-user: an org-admin creates a user in its OWN org through users.Create — the
// password is bcrypt-hashed (never returned/stored plaintext) and the row comes back
// redacted.
func TestAddUser_orgAdmin(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
map[string]any{"owner": "hanzo", "name": "newbie", "password": "S3cret-pw!"})
okEnvelope(t, status, body)
if strings.Contains(body, "S3cret-pw!") {
t.Fatalf("add-user echoed the plaintext password: %s", body)
}
assertNoSecretLeak(t, body)
// Readable through the get alias (same store).
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/newbie", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "newbie") {
t.Fatalf("created user not readable: status=%d body=%s", s, rb)
}
}
// A cross-tenant create is refused: hanzo's admin cannot add a user under orgb.
func TestAddUser_crossTenantForbidden(t *testing.T) {
h := newHarness(t)
status, _ := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
map[string]any{"owner": "orgb", "name": "intruder", "password": "x"})
if status != 403 {
t.Fatalf("cross-tenant add-user status = %d, want 403", status)
}
}
// update-user overwrites from the body (casdoor semantics) through users.Update; the
// change is visible via the get alias and no secret leaks.
func TestUpdateUser_super(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/update-user", h.token(t, "admin/root"),
map[string]any{"owner": "hanzo", "name": "alice", "displayName": "Alice Updated"})
okEnvelope(t, status, body)
assertNoSecretLeak(t, body)
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "Alice Updated") {
t.Fatalf("update-user not applied: status=%d body=%s", s, rb)
}
}
// update-application is platform-owned → super-only, through applications.Update.
func TestUpdateApplication_super(t *testing.T) {
h := newHarness(t)
status, body := h.post(t, "/v1/iam/update-application", h.token(t, "admin/root"),
map[string]any{"owner": "admin", "name": "hanzo-console", "displayName": "Console"})
okEnvelope(t, status, body)
assertNoSecretLeak(t, body)
}
// The write verbs are gated — no bearer fails closed at the Guard (they are
// registered after it).
func TestWriteAliases_requireAuth(t *testing.T) {
h := newHarness(t)
if status, _ := h.post(t, "/v1/iam/add-user", "", map[string]any{"owner": "hanzo", "name": "x"}); status != 401 {
t.Fatalf("unauthenticated add-user status = %d, want 401", status)
}
}
// The FRONT-DOOR session routes are structurally PUBLIC — registered on the
// pre-Guard group, so reachable WITHOUT a bearer (the portal + gateway admin-guard
// call them with a session cookie). An anonymous caller gets the casibase
// {status:"error"} (200), never a 401 and never a leak.
func TestFrontDoorPublic_ReachableWithoutBearer(t *testing.T) {
h := newHarness(t)
for _, tc := range []struct {
method, path string
}{
{"GET", "/v1/iam/get-account"},
{"GET", "/v1/iam/whoami"},
{"GET", "/v1/iam/linked-accounts"},
} {
status, body := h.get(t, tc.path, "")
if status != 200 {
t.Fatalf("%s %s without a bearer status=%d, want 200 (public); body=%s", tc.method, tc.path, status, body)
}
if !strings.Contains(body, "\"error\"") {
t.Fatalf("anonymous %s must be the casibase error envelope; body=%s", tc.path, body)
}
}
// signin (a POST) is public too — anonymous, no code → a 200 error, not a 401.
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 200 || !strings.Contains(body, "\"error\"") {
t.Fatalf("anonymous signin status=%d body=%s, want 200 error (public)", status, body)
}
}
// --- C2 parity write-verb aliases (the console admin mutations) ---
// delete-user: a full lifecycle through the Casdoor verb (add → delete → gone).
func TestDeleteUser_lifecycle(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
if s, b := h.post(t, "/v1/iam/add-user", root, map[string]any{"owner": "hanzo", "name": "tmp", "password": "x"}); s != 200 {
t.Fatalf("add-user status=%d body=%s", s, b)
}
h.postAssertOK(t, "/v1/iam/delete-user", root, map[string]any{"owner": "hanzo", "name": "tmp"})
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/tmp", root); s == 200 && strings.Contains(rb, "\"name\":\"tmp\"") {
t.Fatalf("user still present after delete-user: %s", rb)
}
}
// add-provider is platform-owned — only a SuperAdmin creates one, over the SAME
// providers.Add the REST route uses.
func TestAddProvider_super(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
h.postAssertOK(t, "/v1/iam/add-provider", root,
map[string]any{"owner": "admin", "name": "provider-test", "category": "OAuth", "type": "GitHub"})
if s, rb := h.get(t, "/v1/iam/get-provider?id=admin/provider-test", root); s != 200 || !strings.Contains(rb, "provider-test") {
t.Fatalf("get-provider after add: status=%d body=%s", s, rb)
}
}
// add-provider by a non-super is refused (platform-owned write).
func TestAddProvider_nonSuperForbidden(t *testing.T) {
h := newHarness(t)
s, _ := h.post(t, "/v1/iam/add-provider", h.token(t, "hanzo/boss"),
map[string]any{"owner": "admin", "name": "evil", "category": "OAuth", "type": "GitHub"})
if s != 403 {
t.Fatalf("non-super add-provider status=%d, want 403", s)
}
}
// add-role is tenant-owned — an org-admin creates one in its OWN org.
func TestAddRole_orgAdmin(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
h.postAssertOK(t, "/v1/iam/add-role", boss,
map[string]any{"owner": "hanzo", "name": "editors", "displayName": "Editors"})
if s, rb := h.get(t, "/v1/iam/get-role?id=hanzo/editors", boss); s != 200 || !strings.Contains(rb, "editors") {
t.Fatalf("get-role after add: status=%d body=%s", s, rb)
}
}
// update-organization is platform-owned — SuperAdmin only.
func TestUpdateOrganization_super(t *testing.T) {
h := newHarness(t)
root := h.token(t, "admin/root")
h.postAssertOK(t, "/v1/iam/update-organization", root,
map[string]any{"owner": "admin", "name": "hanzo", "displayName": "Hanzo Updated"})
}
// postAssertOK posts and asserts the {status:ok} envelope.
func (h *harness) postAssertOK(t *testing.T, path, bearer string, body any) {
s, b := h.post(t, path, bearer, body)
okEnvelope(t, s, b)
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package cred verifies a stored password digest against a plaintext, resolving
// the algorithm FROM THE STORED ROW — never from a constant.
//
// Why this exists: v1 stamps `argon2id` on effectively every live row (the org's
// PasswordType is rewritten to argon2id on create/update, and UpdateUserPassword
// stamps it per user). A bcrypt-only verifier handed an argon2id PHC string
// returns ErrHashTooShort, so a bcrypt-only login fails 100% of real users at
// cutover. v1 resolves per row — user.PasswordType, falling back to the
// organization's — and dispatches to the matching manager. iam2 does the same.
//
// Hashing is argon2id ONLY (SOTA). Verify stays scheme-aware so pre-existing
// bcrypt and v1 argon2id rows keep validating, but every NEW or updated digest
// this package mints is argon2id — one way to hash, the strongest one. Re-hashing
// a verified bcrypt row to argon2id (upgrade-on-login) is a separate, deliberate
// decision, not a side effect of a read.
package cred
import (
"crypto/subtle"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
)
// Supported password types. These are the two schemes Hanzo actually stores:
// argon2id (every live v1 row) and bcrypt (what iam2 mints for new users).
// Anything else fails CLOSED — a silent "true" on an unrecognized scheme would
// be an auth bypass, and a silent "false" we can't explain is a support
// nightmare, so Verify reports Unsupported distinctly.
const (
TypeArgon2id = "argon2id"
TypeBcrypt = "bcrypt"
)
// Resolve returns the password type for a row: the user's own, else the
// organization's, else "" (caller decides — never guess a default, since a wrong
// guess is either a failed login or, worse, a bypass).
func Resolve(userType, orgType string) string {
if userType != "" {
return userType
}
return orgType
}
// Supported reports whether Verify can handle this password type.
func Supported(passwordType string) bool {
switch passwordType {
case TypeArgon2id, TypeBcrypt:
return true
}
return false
}
// Verify reports whether plaintext matches the stored digest under passwordType.
// Both supported schemes carry their own parameters in the digest (bcrypt's
// $2a$… and argon2id's $argon2id$v=19$… PHC string), so no external salt is
// needed; salt is accepted for the legacy per-row salt schemes v1 also supports
// and is currently unused.
//
// Fails closed: an unknown/empty type, an empty hash, or a malformed digest
// returns false.
func Verify(passwordType, plaintext, hashed string) bool {
if hashed == "" || !Supported(passwordType) {
return false
}
switch passwordType {
case TypeArgon2id:
// ComparePasswordAndHash is constant-time internally and parses the PHC
// parameters from the digest itself; a malformed digest returns an error,
// which we treat as "no match" (never a panic, never a pass).
match, err := argon2id.ComparePasswordAndHash(plaintext, hashed)
return err == nil && match
case TypeBcrypt:
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plaintext)) == nil
}
return false
}
// hashParams are the argon2id cost parameters for every new digest — OWASP-aligned
// SOTA (64 MiB memory, 2 passes, parallelism 1), tuned so a login stays well under
// ~100ms while resisting GPU/ASIC cracking. The parameters + a per-hash random salt
// ride INSIDE the PHC string, so Verify reads them from the digest itself — changing
// these never invalidates an already-stored hash.
var hashParams = &argon2id.Params{
Memory: 64 * 1024, // 64 MiB
Iterations: 2,
Parallelism: 1,
SaltLength: 16,
KeyLength: 32,
}
// Hash derives a one-way argon2id (PHC) digest from a plaintext password — the
// SOTA scheme every new/updated Hanzo password uses, stamped TypeArgon2id. The
// cost parameters and a per-hash random salt are embedded in the returned string,
// so Verify needs no external salt or config. The plaintext is never logged or
// stored; only this one-way digest is.
func Hash(plaintext string) (string, error) {
return argon2id.CreateHash(plaintext, hashParams)
}
// ConstantTimeEqual is a small helper for comparing non-hash secrets (e.g. a
// verification code) without leaking length/position through timing.
func ConstantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package cred
import (
"testing"
"github.com/alexedwards/argon2id"
"golang.org/x/crypto/bcrypt"
)
// TestVerify_Argon2id_RealV1FormatHash is the regression for the cutover
// blocker: every live v1 row is argon2id, and a bcrypt-only verifier fails all
// of them. This proves iam2 verifies a genuine argon2id PHC digest — the exact
// shape v1's Argon2idCredManager writes (github.com/alexedwards/argon2id,
// DefaultParams).
func TestVerify_Argon2id_RealV1FormatHash(t *testing.T) {
pw := "correct horse battery staple"
hash, err := argon2id.CreateHash(pw, argon2id.DefaultParams)
if err != nil {
t.Fatal(err)
}
// Sanity: it really is the PHC shape a live row carries.
if len(hash) < 20 || hash[:9] != "$argon2id" {
t.Fatalf("not an argon2id PHC digest: %q", hash)
}
if !Verify(TypeArgon2id, pw, hash) {
t.Fatal("argon2id: correct password REJECTED — this is the cutover blocker")
}
if Verify(TypeArgon2id, "wrong password", hash) {
t.Fatal("argon2id: wrong password ACCEPTED")
}
}
// TestVerify_BcryptStillWorks — new iam2-minted users are bcrypt; don't regress.
func TestVerify_Bcrypt(t *testing.T) {
pw := "s3cret-pw"
h, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if !Verify(TypeBcrypt, pw, string(h)) {
t.Fatal("bcrypt: correct password rejected")
}
if Verify(TypeBcrypt, "nope", string(h)) {
t.Fatal("bcrypt: wrong password accepted")
}
}
// TestVerify_CrossSchemeFailsClosed — the actual bug: an argon2id digest handed
// to the bcrypt path (or vice versa) must NOT pass, and must not panic.
func TestVerify_CrossSchemeFailsClosed(t *testing.T) {
pw := "x"
argon, _ := argon2id.CreateHash(pw, argon2id.DefaultParams)
bc, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if Verify(TypeBcrypt, pw, argon) {
t.Fatal("argon2id digest verified under bcrypt — auth bypass")
}
if Verify(TypeArgon2id, pw, string(bc)) {
t.Fatal("bcrypt digest verified under argon2id — auth bypass")
}
}
// TestVerify_FailsClosedOnGarbage — unknown type, empty hash, malformed digest.
func TestVerify_FailsClosedOnGarbage(t *testing.T) {
cases := []struct{ typ, pw, hash string }{
{"", "pw", "$argon2id$v=19$whatever"}, // no type
{"sha256-salt", "pw", "deadbeef"}, // unsupported legacy type
{"plain", "pw", "pw"}, // plaintext scheme: refused
{TypeArgon2id, "pw", ""}, // empty hash
{TypeArgon2id, "pw", "not-a-phc-string"}, // malformed
{TypeBcrypt, "pw", "$2a$garbage"}, // malformed bcrypt
{"ARGON2ID", "pw", "$argon2id$v=19$x"}, // case-sensitive: not supported
}
for _, c := range cases {
if Verify(c.typ, c.pw, c.hash) {
t.Fatalf("verify(%q, hash=%q) returned TRUE — must fail closed", c.typ, c.hash)
}
}
}
// TestResolve_PerRowThenOrgFallback — v1's contract: the user's own type wins;
// an empty user type falls back to the org's; never a hardcoded default.
func TestResolve(t *testing.T) {
if got := Resolve("bcrypt", "argon2id"); got != "bcrypt" {
t.Fatalf("user type must win: got %q", got)
}
if got := Resolve("", "argon2id"); got != "argon2id" {
t.Fatalf("empty user type must fall back to org: got %q", got)
}
if got := Resolve("", ""); got != "" {
t.Fatalf("both empty must stay empty (caller decides), got %q", got)
}
}
func TestSupported(t *testing.T) {
for _, ok := range []string{TypeArgon2id, TypeBcrypt} {
if !Supported(ok) {
t.Fatalf("%s must be supported", ok)
}
}
for _, no := range []string{"", "plain", "salt", "sha512-salt", "md5-salt", "pbkdf2-salt"} {
if Supported(no) {
t.Fatalf("%q must NOT be supported (fail closed)", no)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package cred
import "testing"
// Golden vectors: PHC digests produced by **v1's own Argon2idCredManager**
// (hanzoai/iam `cred.NewArgon2idCredManager().GetHashedPassword`, DefaultParams),
// captured verbatim. This is the parity proof that matters — iam2 must verify the
// exact bytes v1 wrote, not merely a digest iam2 generated itself.
//
// It also pins a REAL cross-version risk: v1 resolves
// `github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387` while iam2
// pins `v1.0.0`. The PHC string is self-describing (m/t/p + salt + key), so a
// digest from either version must verify under the other — this test is what
// proves that, and what fails loudly if a future bump ever breaks it.
//
// These are throwaway TEST passwords. No live user's digest is ever committed —
// a real hash is an offline-attackable secret and does not belong in a repo.
const (
// v1 Argon2idCredManager.GetHashedPassword("golden-test-password-1", "")
goldenV1Password = "golden-test-password-1"
goldenV1Digest = "$argon2id$v=19$m=65536,t=1,p=2$oOen09XtFBqKnv2/K4q5mQ$iZKRwt09CdXDXr4E1CQtRoF/nWzgI810tMFUUiKHugo"
)
// TestGolden_V1Argon2idDigestVerifies is the cutover-parity assertion: a digest
// written by the LIVE v1 code path verifies under iam2's cred.Verify.
func TestGolden_V1Argon2idDigestVerifies(t *testing.T) {
if !Verify(TypeArgon2id, goldenV1Password, goldenV1Digest) {
t.Fatal("iam2 REJECTED a digest produced by v1's Argon2idCredManager — " +
"credential parity is broken; every live login would fail at cutover")
}
if Verify(TypeArgon2id, "not-the-password", goldenV1Digest) {
t.Fatal("wrong password ACCEPTED against the v1 golden digest")
}
}
// TestGolden_V1DigestShape documents the exact PHC shape v1 emits, so a change in
// v1's params (or a lib bump on either side) is caught here rather than in prod.
func TestGolden_V1DigestShape(t *testing.T) {
// $argon2id$v=19$m=65536,t=1,p=2$<salt>$<key>
const wantPrefix = "$argon2id$v=19$m=65536,t=1,p="
if len(goldenV1Digest) < len(wantPrefix) || goldenV1Digest[:len(wantPrefix)] != wantPrefix {
t.Fatalf("v1 digest shape changed: %q", goldenV1Digest)
}
}
// TestGolden_ResolvedThroughRowType proves the full row→algorithm path a real
// login takes: the row says "argon2id" (what every live v1 row says), the org
// fallback is irrelevant, and the v1 digest verifies.
func TestGolden_ResolvedThroughRowType(t *testing.T) {
typ := Resolve("argon2id", "bcrypt") // user's own type must win
if typ != TypeArgon2id {
t.Fatalf("resolve: got %q", typ)
}
if !Verify(typ, goldenV1Password, goldenV1Digest) {
t.Fatal("row-resolved argon2id failed to verify the v1 golden digest")
}
// And the bug that shipped: resolving to bcrypt against this digest must FAIL,
// never pass.
if Verify(TypeBcrypt, goldenV1Password, goldenV1Digest) {
t.Fatal("v1 argon2id digest verified under bcrypt — auth bypass")
}
}
+365
View File
@@ -0,0 +1,365 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package e2e_test drives the WHOLE iam2 surface through the real mounted router
// (routes.Route) as one integrated journey — the behavioral parity proof that the
// old Casdoor IAM's clients work against iam2. Unlike the per-package unit tests,
// this chains the real flows a live client runs in sequence: OIDC discovery →
// PKCE login → code→token → userinfo → introspect → revoke; the admin console's
// get-account → get-organizations → get-users (the Casdoor compat surface); SCIM
// 2.0 provisioning; and RFC 8693 token exchange. Every step asserts the response
// CONTRACT the client depends on.
package e2e_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/oidc"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
)
const (
kid = "cert-hanzo"
redirectURI = "https://console.hanzo.ai/auth/callback"
)
type env struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func boot(t *testing.T) *env {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "e2e.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
seedCert(t, db, key)
// A confidential console app: password login + PKCE, in the hanzo org.
seedApp(t, db)
seedOrg(t, db, "admin")
seedOrg(t, db, "hanzo")
seedUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw", false)
seedUser(t, db, "admin", "root", "root@hanzo.ai", "pw", true) // SuperAdmin
app := zip.New(zip.Config{AppName: "iam2-e2e", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
return &env{app: app, key: key, db: db}
}
// TestJourney_OIDCFlow is the full OAuth2/OIDC round trip a client SDK runs.
func TestJourney_OIDCFlow(t *testing.T) {
e := boot(t)
// 1) Discovery is self-consistent (one issuer, the endpoints a strict client pins).
disc := e.getJSON(t, "/.well-known/openid-configuration", "")
if disc["issuer"] == "" || disc["token_endpoint"] == "" || disc["jwks_uri"] == "" {
t.Fatalf("discovery incomplete: %v", disc)
}
if disc["introspection_endpoint"] == "" || disc["revocation_endpoint"] == "" {
t.Fatalf("discovery missing RFC 7662/7009 endpoints: %v", disc)
}
// RFC 8414 AS metadata served at its own well-known.
if as := e.getJSON(t, "/.well-known/oauth-authorization-server", ""); as["issuer"] == "" {
t.Fatalf("RFC 8414 AS metadata missing")
}
// 2) JWKS publishes a verification key.
jwks := e.getJSON(t, "/v1/iam/.well-known/jwks", "")
if keys, _ := jwks["keys"].([]any); len(keys) == 0 {
t.Fatalf("JWKS has no keys: %v", jwks)
}
// 3) PKCE login → single-use code.
verifier := "e2e-verifier-0000000000000000000000000000000000000"
code := e.login(t, verifier)
// 4) Redeem the code → access token (+ id_token on openid, refresh on offline).
tok := e.token(t, url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
"redirect_uri": {redirectURI}, "code_verifier": {verifier},
})
access, _ := tok["access_token"].(string)
if access == "" {
t.Fatalf("no access_token: %v", tok)
}
// 5) UserInfo carries the identity + the admin-guard contract (owner, isAdmin).
info := e.getJSON(t, "/v1/iam/oauth/userinfo", access)
if info["sub"] != "hanzo/alice" || info["owner"] != "hanzo" {
t.Fatalf("userinfo sub/owner wrong: %v", info)
}
if _, ok := info["isAdmin"]; !ok {
t.Fatalf("userinfo missing the isAdmin claim (admin-guard contract): %v", info)
}
// 6) Introspection (RFC 7662): active, with the standard claims.
ir := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}})
if ir["active"] != true || ir["sub"] != "hanzo/alice" {
t.Fatalf("introspect not active/wrong sub: %v", ir)
}
// 7) Revocation (RFC 7009): the token dies — introspect flips to inactive.
e.form(t, "/v1/iam/oauth/revoke", "hanzo-console", "top-secret", url.Values{"token": {access}})
if after := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}}); after["active"] != false {
t.Fatalf("token still active after revoke: %v", after)
}
}
// TestJourney_PasswordGrant_and_TokenExchange proves the two non-interactive grants
// the console/BFF rely on.
func TestJourney_PasswordGrant_and_TokenExchange(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
e := boot(t)
// Password grant → a first-party session token for alice.
pw := e.token(t, url.Values{
"grant_type": {"password"}, "client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
"username": {"alice@hanzo.ai"}, "password": {"pw"}, "scope": {"openid profile"},
})
subjectToken, _ := pw["access_token"].(string)
if subjectToken == "" {
t.Fatalf("password grant failed: %v", pw)
}
// RFC 8693 token exchange: the BFF exchanges alice's token for one scoped to a
// downstream resource, still bound to alice.
xe := e.token(t, url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
"subject_token": {subjectToken}, "resource": {"hanzo-cloud"},
})
if xe["issued_token_type"] != "urn:ietf:params:oauth:token-type:access_token" || xe["access_token"] == "" {
t.Fatalf("token exchange failed: %v", xe)
}
}
// TestJourney_AdminConsole_CasdoorSurface proves the old admin console's calls work:
// get-account (the security contract), get-organizations (OrgSwitcher), get-users.
func TestJourney_AdminConsole_CasdoorSurface(t *testing.T) {
e := boot(t)
root := e.mint(t, "admin/root") // a SuperAdmin bearer
// get-account — {status:ok, data:<masked user>} with owner + isAdmin.
acct := e.getJSON(t, "/v1/iam/get-account", root)
if acct["status"] != "ok" {
t.Fatalf("get-account status: %v", acct)
}
// get-organizations — the OrgSwitcher workhorse; SuperAdmin sees all.
orgs := e.getJSON(t, "/v1/iam/get-organizations", root)
if orgs["status"] != "ok" {
t.Fatalf("get-organizations status: %v", orgs)
}
if data, _ := orgs["data"].([]any); len(data) < 2 {
t.Fatalf("get-organizations returned %d orgs, want >=2 (admin+hanzo)", len(data))
}
// get-users scoped to an org — no secret leaks.
usersBody := e.getRaw(t, "/v1/iam/get-users?owner=hanzo", root)
if strings.Contains(usersBody, "passwordHash") || strings.Contains(usersBody, "\"password\"") {
t.Fatalf("get-users leaked a secret: %s", usersBody)
}
}
// TestJourney_SCIMProvisioning proves the RFC-standard provisioning path an IdP uses.
func TestJourney_SCIMProvisioning(t *testing.T) {
e := boot(t)
root := e.mint(t, "admin/root")
create := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"newhire",` +
`"active":true,"password":"pw","urn:ietf:params:scim:schemas:extension:hanzo:2.0:User":{"owner":"hanzo"}}`
st, body := e.req(t, "POST", "/v1/iam/scim/v2/Users", root, create, "application/scim+json")
if st != 201 {
t.Fatalf("SCIM create status = %d: %s", st, body)
}
if st, _ := e.req(t, "GET", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 200 {
t.Fatalf("SCIM get status = %d", st)
}
if st, _ := e.req(t, "DELETE", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 204 {
t.Fatalf("SCIM delete status = %d", st)
}
}
// ---- flow helpers ----
func (e *env) login(t *testing.T, verifier string) string {
t.Helper()
body, _ := json.Marshal(map[string]string{
"type": "code", "organization": "hanzo", "username": "alice@hanzo.ai", "password": "pw",
"clientId": "hanzo-console", "redirectUri": redirectURI, "scope": "openid profile email offline_access",
"codeChallenge": oidc.ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
})
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
if st != 200 {
t.Fatalf("login status = %d: %s", st, resp)
}
var m map[string]any
_ = json.Unmarshal([]byte(resp), &m)
code, _ := m["data"].(string)
if code == "" {
t.Fatalf("login returned no code: %s", resp)
}
return code
}
func (e *env) token(t *testing.T, form url.Values) map[string]any {
t.Helper()
st, body := e.req(t, "POST", "/v1/iam/oauth/token", "", form.Encode(), "application/x-www-form-urlencoded")
_ = st
var m map[string]any
_ = json.Unmarshal([]byte(body), &m)
return m
}
func (e *env) form(t *testing.T, path, clientID, secret string, form url.Values) map[string]any {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
resp, err := e.app.Fiber().Test(req)
if err != nil {
t.Fatalf("form %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(b, &m)
return m
}
func (e *env) req(t *testing.T, method, path, bearer, body, contentType string) (int, string) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
req.Host = "hanzo.id"
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := e.app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
func (e *env) getJSON(t *testing.T, path, bearer string) map[string]any {
t.Helper()
_, body := e.req(t, "GET", path, bearer, "", "")
var m map[string]any
_ = json.Unmarshal([]byte(body), &m)
return m
}
func (e *env) getRaw(t *testing.T, path, bearer string) string {
t.Helper()
_, body := e.req(t, "GET", path, bearer, "", "")
return body
}
// mint signs an RS256 bearer for sub under the seeded cert — a valid principal the
// Guard admits (used for the compat/SCIM admin calls, which need a verified bearer
// but not a persisted grant row).
func (e *env) mint(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub, "iat": time.Now().Add(-time.Minute).Unix(), "exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = kid
s, err := tok.SignedString(e.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
// ---- seed helpers ----
func seedCert(t *testing.T, db orm.DB, key *rsa.PrivateKey) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name, c.CryptoAlgorithm = "admin", kid, "RS256"
c.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
c.SetId("admin/" + kid)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedApp(t *testing.T, db orm.DB) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner, a.Name, a.ClientId, a.ClientSecret = "admin", "hanzo-console", "hanzo-console", "top-secret"
a.Organization, a.Cert, a.EnablePassword = "hanzo", kid, true
a.RedirectUris = []string{redirectURI}
a.ExpireInHours = 1
a.SetId("admin/hanzo-console")
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed app: %v", err)
}
}
func seedOrg(t *testing.T, db orm.DB, name string) {
t.Helper()
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", name
o.SetId("admin/" + name)
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed org %s: %v", name, err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name, email, password string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name, u.Email, u.IsAdmin = owner, name, email, admin
hash, herr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if herr != nil {
t.Fatalf("hash: %v", herr)
}
u.PasswordHash, u.PasswordType = string(hash), "bcrypt"
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user %s/%s: %v", owner, name, err)
}
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package featurestore implements feature.Store over the iam2 orm store, so the
// hanzoiam/* enterprise modules read/write the SAME identity data as the core.
// Internal: the core (server.Mount) constructs it and hands the interface to
// feature.MountAll — modules never see this package, only the feature.Store seam.
package featurestore
import (
"context"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/feature"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/internal/users"
"github.com/hanzoai/iam/pkg/model"
)
type ormStore struct {
db orm.DB
u *users.API
}
// New returns a feature.Store backed by db (the core's one identity store).
func New(db orm.DB) feature.Store { return &ormStore{db: db, u: users.New(db)} }
func (s *ormStore) GetUser(ctx context.Context, owner, name string) (*model.User, error) {
return store.GetUserByName(ctx, s.db, owner, name)
}
func (s *ormStore) GetUserByID(_ context.Context, id string) (*model.User, error) {
u, err := orm.Get[schema.User](s.db, id)
if err != nil {
return nil, err
}
return u, nil
}
func (s *ormStore) GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error) {
total, err := orm.TypedQuery[schema.User](s.db).Count(ctx)
if err != nil {
return nil, 0, err
}
q := orm.TypedQuery[schema.User](s.db).Order("Name")
if offset > 0 {
q = q.Offset(offset)
}
if limit > 0 {
q = q.Limit(limit)
}
list, err := q.GetAll(ctx)
return list, total, err
}
func (s *ormStore) AddUser(ctx context.Context, u *model.User) (bool, error) {
if _, err := s.u.Create(ctx, &users.CreateInput{User: *u}); err != nil {
return false, err
}
return true, nil
}
func (s *ormStore) UpdateUser(ctx context.Context, u *model.User) (bool, error) {
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u}); err != nil {
return false, err
}
return true, nil
}
func (s *ormStore) DeleteUser(ctx context.Context, owner, name string) (bool, error) {
out, err := s.u.Delete(ctx, &users.Ref{Owner: owner, Name: name})
if err != nil {
return false, err
}
return out.Deleted, nil
}
func (s *ormStore) GetApplication(ctx context.Context, id string) (*model.Application, error) {
if app, err := store.GetApplicationByName(ctx, s.db, "admin", id); err == nil && app != nil {
return app, nil
}
return store.GetApplicationByClientId(ctx, s.db, id)
}
func (s *ormStore) GetOrganization(ctx context.Context, name string) (*model.Organization, error) {
return store.GetOrganizationByName(ctx, s.db, name)
}
func (s *ormStore) GetProvider(ctx context.Context, owner, name string) (*model.Provider, error) {
return store.GetProvider(ctx, s.db, owner, name)
}
func (s *ormStore) GetCert(ctx context.Context, owner, name string) (*model.Cert, error) {
return store.GetCert(ctx, s.db, owner, name)
}
// SetPassword loads the canonical row and re-saves it with the plaintext, which
// users.Update hashes exactly once (empty leaves the digest untouched). Passing
// the full existing row means no other field is zeroed by the update.
func (s *ormStore) SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
u, err := store.GetUserByName(ctx, s.db, owner, name)
if err != nil {
return false, err
}
if u == nil {
return false, nil
}
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u, Password: plaintext}); err != nil {
return false, err
}
return true, nil
}
// VerifyPassword defers to the core's digest-scheme-aware verifier (argon2id v1 /
// bcrypt v2), keyed by the org's password type — no hash ever leaves the core.
func (s *ormStore) VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
u, err := store.GetUserByName(ctx, s.db, owner, name)
if err != nil {
return false, err
}
if u == nil {
return false, nil
}
pwType := ""
if org, oerr := store.GetOrganizationByName(ctx, s.db, owner); oerr == nil && org != nil {
pwType = org.PasswordType
}
return users.VerifyPassword(u, plaintext, pwType), nil
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package httpx is the shared HTTP layer for the IAM v2 handlers: the
// Casdoor-compatible Response envelope that the @hanzo/iam SDK and the hanzo.id
// portal consume, plus small helpers over zip.Ctx. Every front-door JSON
// endpoint (get-app-login, login, signup) returns this shape; the OIDC
// endpoints (token/authorize/userinfo) use their own RFC 6749 shapes.
package httpx
import (
"encoding/base64"
"strings"
"github.com/zap-proto/zip"
)
// Response is the Casdoor-compatible envelope. status is "ok" or "error"; a
// non-ok status rides on a 200 (every SDK branches on status, not the HTTP
// code — preserving that contract keeps the clients unchanged at cutover).
type Response struct {
Status string `json:"status"`
Msg string `json:"msg"`
Sub string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Data any `json:"data"`
Data2 any `json:"data2,omitempty"`
Data3 any `json:"data3,omitempty"`
}
// Ok writes 200 { status:"ok", data }.
func Ok(c *zip.Ctx, data any, more ...any) error {
r := Response{Status: "ok", Data: data}
if len(more) > 0 {
r.Data2 = more[0]
}
return c.JSON(200, r)
}
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
// not HTTP code).
func Err(c *zip.Ctx, msg string) error {
return c.JSON(200, Response{Status: "error", Msg: msg})
}
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
func Bearer(c *zip.Ctx) string {
const p = "Bearer "
h := c.Header("Authorization")
if len(h) > len(p) && h[:len(p)] == p {
return h[len(p):]
}
return ""
}
// Basic returns the (id, secret) an `Authorization: Basic <base64>` header carries,
// and whether it carried one — RFC 7617: base64 of "<id>:<secret>", split on the
// FIRST colon so a secret may contain one. This is the ONE Basic parser; a caller
// bound by RFC 6749 §2.3.1 (client_secret_basic, whose halves are form-urlencoded
// before the base64) form-decodes the two values afterwards.
func Basic(c *zip.Ctx) (id, secret string, ok bool) {
const p = "Basic "
h := c.Header("Authorization")
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(h[len(p):]))
if err != nil {
return "", "", false
}
id, secret, found := strings.Cut(string(raw), ":")
if !found {
return "", "", false
}
return id, secret, true
}
// The request host is read through the ONE header-immune accessor, zip.Ctx.Host()
// — the same seam the OIDC issuer resolver uses. It ignores X-Forwarded-Host (zip
// has no trusted-proxy knob), so the brand host a client authenticates to cannot
// be spoofed by a request header. There is deliberately no EffectiveHost helper
// here: a second accessor that honored X-Forwarded-Host would reopen that spoof.
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package invitations serves the IAM v2 CRUD surface for the `invitations`
// entity: a pending org-membership invite owner-scoped by (owner, name). Every
// operation is a typed zip handler over hanzoai/orm; the orm string key is
// "owner/name". Reads scope to one owner (organization); writes address one
// invitation by its (owner, name) key.
package invitations
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// Handler binds the invitations operations to one orm store.
type Handler struct {
db orm.DB
}
// Route registers the invitations CRUD routes on app against db.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/invitations", h.List, zip.WithSummary("List invitations for an owner"), zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations", h.Create, zip.WithSummary("Create an invitation"), zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations/get", h.Get, zip.WithSummary("Get one invitation"), zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations/update", h.Update, zip.WithSummary("Update an invitation"), zip.WithTags("invitations"))
zip.Post(app, "/v1/iam/invitations/delete", h.Delete, zip.WithSummary("Delete an invitation"), zip.WithTags("invitations"))
}
// Ref addresses one invitation by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// Input is the writable projection of an invitation (the v1 add/update-invitation
// body). It keeps the wire contract clean of the orm.Model bookkeeping fields.
type Input struct {
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
UpdatedTime string `json:"updatedTime"`
DisplayName string `json:"displayName"`
Code string `json:"code"`
IsRegexp bool `json:"isRegexp"`
Quota int `json:"quota"`
UsedCount int `json:"usedCount"`
Application string `json:"application"`
Username string `json:"username"`
Email string `json:"email"`
Phone string `json:"phone"`
SignupGroup string `json:"signupGroup"`
DefaultCode string `json:"defaultCode"`
State string `json:"state"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of invitations.
type ListOutput struct {
Invitations []*schema.Invitation `json:"invitations"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// key builds the orm string key from the (owner, name) natural key.
func key(owner, name string) string { return owner + "/" + name }
// apply copies the mutable domain fields of an Input onto an invitation. The
// identity fields (owner, name) and the created stamp are set only on Create,
// never overwritten by an update.
func apply(dst *schema.Invitation, in *Input) {
dst.UpdatedTime = in.UpdatedTime
dst.DisplayName = in.DisplayName
dst.Code = in.Code
dst.IsRegexp = in.IsRegexp
dst.Quota = in.Quota
dst.UsedCount = in.UsedCount
dst.Application = in.Application
dst.Username = in.Username
dst.Email = in.Email
dst.Phone = in.Phone
dst.SignupGroup = in.SignupGroup
dst.DefaultCode = in.DefaultCode
dst.State = in.State
}
// List returns the invitations for one owner, newest first. An empty owner
// lists every invitation (the unscoped admin view).
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
q := orm.TypedQuery[schema.Invitation](h.db)
if in.Owner != "" {
q = q.Filter("owner", in.Owner)
}
invitations, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListOutput{Invitations: invitations, Total: len(invitations)}, nil
}
// Get returns one invitation addressed by (owner, name).
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Invitation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return invitation, nil
}
// Create persists a new invitation. It rejects a duplicate (owner, name).
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Invitation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("invitation already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
invitation := orm.New[schema.Invitation](h.db)
invitation.Owner = in.Owner
invitation.Name = in.Name
invitation.CreatedTime = in.CreatedTime
if invitation.CreatedTime == "" {
invitation.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
apply(invitation, in)
invitation.SetId(key(in.Owner, in.Name))
if err := invitation.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return invitation, nil
}
// Update mutates an existing invitation. Identity and created stamp are
// immutable; a missing invitation is a 404.
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Invitation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
apply(invitation, in)
if err := invitation.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return invitation, nil
}
// Delete removes one invitation addressed by (owner, name).
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := invitation.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// mapErr translates an orm lookup error into the matching HTTP status.
func mapErr(err error) error {
if errors.Is(err, orm.ErrNotFound) {
return zip.ErrNotFound("invitation not found")
}
return zip.ErrInternal(err.Error())
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package keys serves the owner-scoped CRUD surface for the `keys` entity
// (v1 Casdoor `key`) as typed zip handlers over hanzoai/orm.
//
// Identity is the (owner, name) pair; it maps onto the orm storage id as
// "owner/name", exactly as the v1 record addressed itself. Reads are
// zip.Get[In,Out], writes are zip.Post[In,Out]; every handler closes over the
// one orm.DB entity store so the typed signatures carry no transport or
// storage plumbing.
package keys
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// Route registers the key CRUD routes on app, binding each handler to db.
// Called from routes.Mount once it is threaded the entity store.
func Route(app *zip.App, db orm.DB) {
zip.Get(app, "/v1/iam/keys", list(db),
zip.WithSummary("List keys in an owner"), zip.WithTags("keys"))
zip.Get(app, "/v1/iam/key", get(db),
zip.WithSummary("Get a key by (owner, name)"), zip.WithTags("keys"))
zip.Post(app, "/v1/iam/key", create(db),
zip.WithSummary("Create a key"), zip.WithTags("keys"))
zip.Post(app, "/v1/iam/key/update", update(db),
zip.WithSummary("Update a key"), zip.WithTags("keys"))
zip.Post(app, "/v1/iam/key/delete", del(db),
zip.WithSummary("Delete a key"), zip.WithTags("keys"))
}
// ListRequest scopes a listing to one owner.
type ListRequest struct {
Owner string `json:"owner"`
}
// ListResponse is the owner-scoped key set, newest first.
type ListResponse struct {
Keys []schema.Key `json:"keys"`
}
// Ref addresses one key by its (owner, name) identity.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// DeleteResponse reports whether the key was removed.
type DeleteResponse struct {
Deleted bool `json:"deleted"`
}
// id joins the owner-scoped natural key into the orm storage id — the same
// "owner/name" identity the v1 record used.
func id(owner, name string) string { return owner + "/" + name }
// list returns every key under in.Owner, newest first.
func list(db orm.DB) zip.TypedHandler[ListRequest, ListResponse] {
return func(ctx context.Context, in *ListRequest) (*ListResponse, error) {
if in.Owner == "" {
return nil, zip.ErrBadRequest("owner is required")
}
items, err := orm.TypedQuery[schema.Key](db).
Filter("Owner=", in.Owner).
Order("-CreatedTime").
GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
out := &ListResponse{Keys: make([]schema.Key, 0, len(items))}
for _, k := range items {
out.Keys = append(out.Keys, *k)
}
return out, nil
}
}
// get resolves one key by (owner, name).
func get(db orm.DB) zip.TypedHandler[Ref, schema.Key] {
return func(_ context.Context, in *Ref) (*schema.Key, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return k, nil
}
}
// create inserts a new key under (owner, name), minting any missing pk-/sk-
// credential halves. It refuses to overwrite an existing key.
func create(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if _, err := orm.Get[schema.Key](db, id(in.Owner, in.Name)); err == nil {
return nil, zip.ErrConflict("key already exists: " + id(in.Owner, in.Name))
} else if !errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrInternal(err.Error())
}
k := orm.New[schema.Key](db)
k.SetId(id(in.Owner, in.Name))
k.Owner, k.Name = in.Owner, in.Name
apply(k, in)
if k.AccessKey == "" {
k.AccessKey = Mint("pk", k.State)
}
if k.AccessSecret == "" {
k.AccessSecret = Mint("sk", k.State)
}
now := time.Now().UTC().Format(time.RFC3339)
k.CreatedTime, k.UpdatedTime = now, now
if err := k.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return k, nil
}
}
// update overwrites the mutable fields of an existing key, keyed by
// (owner, name), and re-stamps UpdatedTime.
func update(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
apply(k, in)
k.UpdatedTime = time.Now().UTC().Format(time.RFC3339)
if err := k.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return k, nil
}
}
// del removes a key by (owner, name).
func del(db orm.DB) zip.TypedHandler[Ref, DeleteResponse] {
return func(ctx context.Context, in *Ref) (*DeleteResponse, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := k.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteResponse{Deleted: true}, nil
}
}
// apply copies the caller-settable fields from src onto dst, leaving the
// (owner, name) identity, storage id, and audit stamps under handler control.
func apply(dst, src *schema.Key) {
dst.DisplayName = src.DisplayName
dst.Type = src.Type
dst.Organization = src.Organization
dst.Application = src.Application
dst.User = src.User
dst.AccessKey = src.AccessKey
dst.AccessSecret = src.AccessSecret
dst.ExpireTime = src.ExpireTime
dst.State = src.State
}
// mint generates a prefixed credential half — "{pk|sk}-{live|test}-{random}"
// — mirroring the v1 key format. State == "test" selects the test env.
func Mint(prefix, state string) string {
env := "live"
if state == "test" {
env = "test"
}
var b [16]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("%s-%s-%s", prefix, env, hex.EncodeToString(b[:]))
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package memberships serves the (User × Org × Role) tenancy relation — which
// orgs an identity may act in, and with what coarse role. It is the set a token
// carries as the `orgs` claim, which is what lets the edge authorize an
// org-switch statelessly (X-Org-Id ∈ orgs).
//
// A user's HOME org (User.Owner) is always an implicit membership — the token
// consumer treats it as one — so an explicit row is only ever needed for a TEAM
// org the identity was invited into. The boot backfill seeds the home row anyway,
// so an org's roster is complete from one query.
//
// This is the transport face. The relation's operations are store's
// (EnsureMembership, MembershipsByUser/ByOrg), because the token mint needs them
// too and it sits below the authorization seam this face sits above.
package memberships
import (
"context"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Path is the verb face: GET lists by ?user= or ?org=, POST ensures one.
const Path = "/v1/iam/memberships"
// unauthorized is v1's refusal message, verbatim.
const unauthorized = "auth:Unauthorized operation"
// Route registers the membership surface on app, backed by db.
func Route(app *zip.App, db orm.DB) {
app.Get(Path, list(db))
app.Post(Path, ensure(db))
}
// request is the ensure body.
type request struct {
User string `json:"user"` // "<homeOrg>/<username>"
Org string `json:"org"`
Role string `json:"role"`
}
// list serves GET /v1/iam/memberships?user=<owner/name> or ?org=<slug> — one
// identity's orgs, or one org's roster.
//
// Both are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or
// about a user whose home org is its own, and nothing else. The bound comes from
// the verified credential via authz.Scope, so a request parameter can never
// widen it — a membership row names who may act and spend in an org, so a
// cross-tenant read is a customer roster leak.
func list(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
user, org := c.Query("user"), c.Query("org")
if (user == "") == (org == "") {
return httpx.Err(c, "exactly one of user or org is required")
}
if org != "" {
if !scoped(ctx, org) {
return httpx.Err(c, unauthorized)
}
rows, err := store.MembershipsByOrg(ctx, db, org)
return listed(c, rows, err)
}
// A user id is "<homeOrg>/<name>": its home org is the tenant bound here.
home, _, found := strings.Cut(user, "/")
if !found || home == "" {
return httpx.Err(c, "user must be <owner>/<name>")
}
if !scoped(ctx, home) {
return httpx.Err(c, unauthorized)
}
rows, err := store.MembershipsByUser(ctx, db, user)
return listed(c, rows, err)
}
}
// ensure serves POST /v1/iam/memberships — grant an identity the right to act in
// an org. Granting membership IS the org's authority to give, so it takes the
// same gate a write to that org's own registry row takes: a SuperAdmin, an admin
// of the org itself, or an org-admin-capable confidential client. One rule, one
// place (internal/authz).
func ensure(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
var in request
if err := c.Bind(&in); err != nil {
return httpx.Err(c, err.Error())
}
if in.User == "" || in.Org == "" {
return httpx.Err(c, "user and org are required")
}
switch in.Role {
case store.RoleOwner, store.RoleAdmin, store.RoleMember:
case "":
in.Role = store.RoleMember
default:
return httpx.Err(c, "role must be owner, admin, or member")
}
if !authz.Can(ctx, "POST", "organizations", store.MembershipOwner, in.Org) {
return httpx.Err(c, unauthorized)
}
added, err := store.EnsureMembership(ctx, db, in.User, in.Org, in.Role)
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, added)
}
}
// scoped reports whether the caller may read the membership rows of org — i.e.
// whether resolving the scope from its own verified credential yields exactly
// the org it asked for. A SuperAdmin gets what it asks for; anyone else gets its
// own org, so any other request fails the equality and is refused.
func scoped(ctx context.Context, org string) bool {
got, err := authz.Scope(ctx, org)
return err == nil && got == org
}
// listed writes a membership listing, or the error envelope on failure.
func listed(c *zip.Ctx, rows []*schema.Membership, err error) error {
if err != nil {
return httpx.Err(c, err.Error())
}
return c.JSON(200, httpx.Response{Status: "ok", Data: rows, Data2: len(rows)})
}
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package factor
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"strings"
"github.com/hanzoai/orm"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Package factor is the pure multi-factor DOMAIN — what a factor IS, whether a
// passcode verifies, which factors a user has, whether the org demands one, and
// how that state is written. It is the ONE implementation both the enrollment
// surface (internal/mfa) and the login-time second-factor gate (internal/oidc)
// call, so the Verify the challenge runs is the one enrollment's setup check uses
// and the Save every MFA write goes through cannot drift apart.
//
// It is a LEAF: it imports only store + schema, never authz or oidc. That is what
// lets the gate (in oidc, which authz imports) use it without an import cycle,
// while the enrollment surface (which does need authz) uses it too — one domain,
// two callers, no duplication. Radius and push are deliberately absent: no v2
// provider transport serves them, and a factor listed as available but unservable
// is an unusable challenge.
// The factor types, verbatim from v1 (object/mfa.go:42-48). "app" is TOTP — the
// name is v1's and it is on the wire, so it does not get "improved".
const (
App = "app"
SMS = "sms"
Email = "email"
)
// Types lists the factors this package can project, in v1's order. It bounds
// AllProps: a factor absent here is never offered on a challenge.
var Types = []string{SMS, Email, App}
// errNoUser is the ONE answer to an unresolvable MFA subject.
var errNoUser = errors.New("user doesn't exist")
// Enroll generates a fresh TOTP secret for userID ("owner/name") and the
// otpauth:// URL that encodes it, using the RFC 6238 defaults every authenticator
// app assumes (the same totp.Generate defaults the enrollment surface uses). It
// persists NOTHING: enrollment is stateless and client-held until enable commits
// it.
func Enroll(userID, issuer string) (secret, url string, err error) {
if issuer == "" {
issuer = "Hanzo"
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: userID})
if err != nil {
return "", "", err
}
return key.Secret(), key.URL(), nil
}
// Verify reports whether passcode is currently valid for secret. It is the ONE
// TOTP verification point — enrollment's setup check and the login challenge call
// this same function, so they cannot drift apart. totp.Validate accepts the
// adjacent windows (skew 1), tolerating clock drift.
func Verify(secret, passcode string) bool {
if secret == "" || passcode == "" {
return false
}
return totp.Validate(passcode, secret)
}
// recoveryBytes is the entropy behind one recovery code: 20 bytes → 32 base32
// characters, the same strength as the TOTP secret it backs up.
const recoveryBytes = 20
// MintRecovery returns one fresh recovery code, in the clear, for the user to write
// down. It asks crypto/rand for a secret directly (not a formatted identifier).
func MintRecovery() (string, error) {
b := make([]byte, recoveryBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
}
// HashRecovery is the digest a recovery code is STORED as. A recovery code is a
// bearer credential verified by equality alone, so — unlike the TOTP secret, which
// the verifier needs back in the clear — it hashes like a password.
func HashRecovery(plain string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
return string(h), err
}
// HashRecoveryCodes digests each plaintext recovery code for storage — enrollment
// hands the user the plaintext (the QR's backup code) exactly once and keeps only
// the digest, so a database dump exposes no usable recovery credential.
func HashRecoveryCodes(plain []string) ([]string, error) {
out := make([]string, 0, len(plain))
for _, p := range plain {
h, err := HashRecovery(p)
if err != nil {
return nil, err
}
out = append(out, h)
}
return out, nil
}
// UseRecovery consumes one of the user's recovery codes, reporting whether code
// matched. A hit is DELETED from u.RecoveryCodes in place — one-time use — and the
// caller persists the row.
//
// Stored codes are bcrypt digests, but every code migrated from v1 is PLAINTEXT
// (object/mfa.go:81 compares in the clear), so a stored value that is not a digest
// is compared literally. The algorithm is a property of the stored value, never a
// constant — the same rule the password path lives by. A legacy hit is spent and
// removed like any other, so the plaintext dies on first use.
func UseRecovery(u *schema.User, code string) bool {
if u == nil || code == "" {
return false
}
for i, stored := range u.RecoveryCodes {
if !recoveryMatches(stored, code) {
continue
}
u.RecoveryCodes = append(u.RecoveryCodes[:i:i], u.RecoveryCodes[i+1:]...)
return true
}
return false
}
// recoveryMatches compares one presented code against one stored value, choosing
// the comparison from what the value IS: a bcrypt digest is verified with bcrypt,
// a v1-era plaintext by equality.
func recoveryMatches(stored, code string) bool {
if isBcrypt(stored) {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(code)) == nil
}
return stored != "" && stored == code
}
// isBcrypt reports whether s is a bcrypt digest by asking the library's own parser
// (bcrypt.Cost), so the answer comes from the format itself rather than a guess.
func isBcrypt(s string) bool {
_, err := bcrypt.Cost([]byte(s))
return err == nil
}
// Enabled reports whether the user has multi-factor sign-in on. The predicate is
// PreferredMfaType != "" and nothing else (v1 object/user.go:1641): the per-factor
// enabled flags say which factors exist, not whether the gate runs.
func Enabled(u *schema.User) bool { return u != nil && u.PreferredMfaType != "" }
// Prompt reports whether the organization REQUIRES a factor the user has not
// enrolled yet — the sign-in must divert to enrollment before it can finish. The
// user's own MfaItems override the org's entirely when present (not merge: v1
// object/organization.go:770-792), so a per-user policy is a replacement.
func Prompt(org *schema.Organization, u *schema.User) bool {
if org == nil || u == nil {
return false
}
items := org.MfaItems
if len(u.MfaItems) > 0 {
items = u.MfaItems
}
for _, item := range items {
if item == nil || item.Rule != "Required" {
continue
}
switch item.Name {
case Email:
if !u.MfaEmailEnabled {
return true
}
case SMS:
if !u.MfaPhoneEnabled {
return true
}
case App:
if u.TotpSecret == "" {
return true
}
}
}
return false
}
// Props projects one factor of the user for a client, ALWAYS masked: Secret and
// RecoveryCodes are never populated (and are json:"-" besides). The login-gate
// verifier reads u.TotpSecret directly, so this projection has no unmasked mode to
// misuse.
func Props(u *schema.User, mfaType string) *schema.MfaProps {
p := &schema.MfaProps{MfaType: mfaType}
if u == nil {
return p
}
switch mfaType {
case SMS:
p.Enabled = u.MfaPhoneEnabled
if p.Enabled {
p.CountryCode = u.CountryCode
}
case Email:
p.Enabled = u.MfaEmailEnabled
case App:
p.Enabled = u.TotpSecret != ""
}
if !p.Enabled {
return &schema.MfaProps{MfaType: mfaType}
}
p.IsPreferred = u.PreferredMfaType == mfaType
return p
}
// AllProps projects every factor this package serves, masked, in v1's order.
func AllProps(u *schema.User) []*schema.MfaProps {
all := make([]*schema.MfaProps, 0, len(Types))
for _, t := range Types {
all = append(all, Props(u, t))
}
return all
}
// Copy overwrites dst's multi-factor state with src's, and nothing else. It is the
// ONE declaration of which columns ARE multi-factor state, so every writer agrees
// on the set by construction: Save overlays a caller's factors onto the STORED row
// through this, which is what makes an MFA write column-scoped — the request's user
// value never reaches the store, so it cannot carry isAdmin along and self-promote.
func Copy(dst, src *schema.User) {
if dst == nil || src == nil {
return
}
dst.PreferredMfaType = src.PreferredMfaType
dst.RecoveryCodes = src.RecoveryCodes
dst.TotpSecret = src.TotpSecret
dst.MfaPhoneEnabled = src.MfaPhoneEnabled
dst.MfaEmailEnabled = src.MfaEmailEnabled
dst.MfaRadiusEnabled = src.MfaRadiusEnabled
dst.MfaRadiusUsername = src.MfaRadiusUsername
dst.MfaRadiusProvider = src.MfaRadiusProvider
dst.MfaPushEnabled = src.MfaPushEnabled
dst.MfaPushReceiver = src.MfaPushReceiver
dst.MfaPushProvider = src.MfaPushProvider
dst.MfaRememberDeadline = src.MfaRememberDeadline
}
// Save writes u's multi-factor state — and ONLY that — onto its stored row. It is
// the single write point for every MFA mutation the login gate makes: spend a
// recovery code, remember a device. The scoping is what makes it safe: the row is
// loaded fresh and Copy overlays exactly the multi-factor columns, so an isAdmin,
// a balance, or a password digest arriving on an MFA request reaches nothing.
func Save(ctx context.Context, db orm.DB, u *schema.User) error {
if u == nil {
return errNoUser
}
stored, err := store.GetUserByName(ctx, db, u.Owner, u.Name)
if err != nil {
return err
}
if stored == nil {
return errNoUser
}
Copy(stored, u)
return stored.UpdateCtx(ctx)
}
+266
View File
@@ -0,0 +1,266 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package mfa serves the TOTP multi-factor enrollment surface — the account
// security page's initiate → verify → enable flow (RFC 6238 TOTP), plus
// delete-mfa and set-preferred-mfa. Enrollment is SELF-SERVICE: every handler
// acts on the AUTHENTICATED caller's own user record (authz.From), so the routes
// mount AFTER the Guard — they need the Principal. Touching a DIFFERENT user's
// MFA requires admin authority over that org, authorized through the SAME seam a
// SCIM write uses (authz.Can); the general user-write policy correctly refuses a
// non-admin writing a user row, so self-enrollment is authorized by
// self-ownership (target == principal), NOT by that policy.
//
// The handshake is STATELESS across the three calls: initiate mints a TOTP
// secret + otpauth URL + recovery code and hands them to the client; the client
// renders the QR, the authenticator app derives a passcode, verify checks it
// against the SAME secret the client echoes back, and enable persists the secret
// + recovery code to the user. No pending secret is parked server-side between
// calls — it is client-held until enable commits it.
package mfa
import (
"crypto/rand"
"encoding/base32"
"encoding/json"
"errors"
"os"
"strings"
"github.com/pquerna/otp/totp"
"github.com/zap-proto/zip"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/authz"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/store"
)
// The TOTP factor type ("app") and the domain helpers are factor.App et al (internal/mfa/factor).
// Route registers the MFA endpoints on app. They are RAW handlers (not typed
// ops), so — like SCIM — each authorizes itself; callers mount app AFTER the
// Guard so a verified Principal rides the request context.
func Route(app *zip.App, db orm.DB) {
app.Post("/v1/iam/mfa/setup/initiate", initiate(db))
app.Post("/v1/iam/mfa/setup/verify", verify(db))
app.Post("/v1/iam/mfa/setup/enable", enable(db))
app.Post("/v1/iam/delete-mfa", disable(db))
app.Post("/v1/iam/set-preferred-mfa", setPreferred(db))
}
// setupReq is the union of fields the enrollment handshake posts. owner/name
// address the target user (default: the caller itself); secret/passcode/
// recoveryCodes carry the client-held enrollment material; mfaType selects the
// preferred factor for set-preferred-mfa.
type setupReq struct {
Owner string `json:"owner"`
Name string `json:"name"`
Secret string `json:"secret"`
Passcode string `json:"passcode"`
RecoveryCodes []string `json:"recoveryCodes"`
MfaType string `json:"mfaType"`
}
// target resolves the (owner, name) an MFA request addresses and authorizes it:
// the caller may always manage its OWN record; touching another user's MFA
// requires admin authority over that org (authz.Can — the seam SCIM writes use).
// An unauthenticated caller fails closed (the Guard already required a bearer, so
// this is defense in depth). Returns a zip error to return verbatim on refusal.
func target(c *zip.Ctx, req *setupReq) (owner, name string, err error) {
p, present := authz.From(c.Context())
if !present {
return "", "", zip.ErrUnauthorized("authentication required")
}
owner, name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
if owner == "" || name == "" {
owner, name = p.Org, p.User // default: the caller itself
}
self := owner == p.Org && name == p.User
if !self && !authz.Can(c.Context(), "PUT", "users", owner, name) {
return "", "", zip.ErrForbidden("forbidden")
}
return owner, name, nil
}
// initiate mints a fresh TOTP secret + otpauth URL + a single recovery code and
// returns them for the client to display (QR + backup code). Nothing is
// persisted — the secret is committed only by enable. Response:
// {status:"ok", data:{secret, url, recoveryCodes:[code]}}.
func initiate(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
_ = decode(c, &req) // body optional: owner/name default to the caller
owner, name, err := target(c, &req)
if err != nil {
return err
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer(owner), AccountName: name})
if err != nil {
return c.JSON(500, errResp("failed to generate secret"))
}
code, err := recoveryCode()
if err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(map[string]any{
"secret": key.Secret(),
"url": key.URL(),
"recoveryCodes": []string{code},
}))
}
}
// verify checks a passcode against the client-echoed secret (RFC 6238, ±1 step).
// A valid code → {status:"ok"}; an invalid one → 200 {status:"error"} (the
// casibase convention: clients branch on status, not the HTTP code).
func verify(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body"))
}
if _, _, err := target(c, &req); err != nil {
return err
}
if req.Secret == "" || req.Passcode == "" {
return c.JSON(200, errResp("secret and passcode are required"))
}
if !totp.Validate(req.Passcode, req.Secret) {
return c.JSON(200, errResp("the code is incorrect"))
}
return c.JSON(200, okData(nil))
}
}
// enable commits the client-held secret + recovery code to the target user and
// marks TOTP the preferred factor. An idempotent overwrite of the MFA fields.
func enable(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body"))
}
owner, name, err := target(c, &req)
if err != nil {
return err
}
if req.Secret == "" {
return c.JSON(200, errResp("secret is required"))
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if u == nil {
return c.JSON(404, errResp("user not found"))
}
u.TotpSecret = req.Secret
hashed, herr := factor.HashRecoveryCodes(req.RecoveryCodes)
if herr != nil {
return c.JSON(500, errResp("server_error"))
}
u.RecoveryCodes = hashed
u.PreferredMfaType = factor.App
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(map[string]any{"preferredMfaType": factor.App}))
}
}
// disable clears every TOTP field on the target user (delete-mfa).
func disable(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
_ = decode(c, &req) // body optional: owner/name default to the caller
owner, name, err := target(c, &req)
if err != nil {
return err
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if u == nil {
return c.JSON(404, errResp("user not found"))
}
u.TotpSecret = ""
u.RecoveryCodes = nil
u.PreferredMfaType = ""
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(nil))
}
}
// setPreferred selects which enrolled factor is preferred (set-preferred-mfa).
func setPreferred(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var req setupReq
if err := decode(c, &req); err != nil {
return c.JSON(400, errResp("invalid body"))
}
owner, name, err := target(c, &req)
if err != nil {
return err
}
if strings.TrimSpace(req.MfaType) == "" {
return c.JSON(200, errResp("mfaType is required"))
}
u, err := store.GetUserByName(c.Context(), db, owner, name)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
if u == nil {
return c.JSON(404, errResp("user not found"))
}
u.PreferredMfaType = req.MfaType
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(nil))
}
}
// ---- helpers ----
func decode(c *zip.Ctx, v any) error {
body := c.Body()
if len(body) == 0 {
return errors.New("empty request body")
}
return json.Unmarshal(body, v)
}
// issuer is the otpauth issuer label the authenticator app shows: an explicit
// IAM_MFA_ISSUER override (white-label brand), else the account's org, else Hanzo.
func issuer(owner string) string {
if v := strings.TrimSpace(os.Getenv("IAM_MFA_ISSUER")); v != "" {
return v
}
if owner != "" {
return owner
}
return "Hanzo"
}
// recoveryCode returns a 160-bit base32 single-use backup code.
func recoveryCode() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
}
func okData(data any) map[string]any {
m := map[string]any{"status": "ok"}
if data != nil {
m["data"] = data
}
return m
}
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package mfa_test
// TOTP MFA tests driven through the REAL mounted router (routes.Route installs
// the Guard, then mfa.Route after it). Every case is a wire request the account
// security page sends. The assertions pin the enrollment contract (initiate mints
// a secret the client can turn into a valid passcode; enable persists it) and the
// security one: enrollment is self-service on your OWN record, and a regular user
// can NEVER touch another user's MFA — that needs admin authority.
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/pquerna/otp/totp"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/routes"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
const signingKid = "cert-hanzo"
type harness struct {
app *zip.App
key *rsa.PrivateKey
db orm.DB
}
func newHarness(t *testing.T) *harness {
t.Helper()
_ = schema.Kinds()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa: %v", err)
}
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "mfa.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
seedCert(t, db, "admin", signingKid, pemOf(t, key))
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
app := zip.New(zip.Config{AppName: "mfa-test", DisableStartupMessage: true})
routes.Route(app, db)
app.Prepare()
return &harness{app: app, key: key, db: db}
}
func (h *harness) token(t *testing.T, sub string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": sub,
"iat": time.Now().Add(-time.Minute).Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = signingKid
s, err := tok.SignedString(h.key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func (h *harness) do(t *testing.T, path, bearer, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest("POST", path, r)
req.Host = "hanzo.id"
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := h.app.Fiber().Test(req)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var m map[string]any
_ = json.Unmarshal(b, &m)
return resp.StatusCode, m
}
// dataString reads m.data.<key> as a string.
func dataString(m map[string]any, key string) string {
d, _ := m["data"].(map[string]any)
s, _ := d[key].(string)
return s
}
// TestMFA_enrollLifecycle: a regular user enrolls TOTP on her own account —
// initiate mints a secret she can turn into a valid passcode, verify accepts it,
// enable persists it, disable clears it.
func TestMFA_enrollLifecycle(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
// initiate — a secret, an otpauth URL, and a recovery code.
st, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
if st != 200 || m["status"] != "ok" {
t.Fatalf("initiate: status=%d body=%v", st, m)
}
secret := dataString(m, "secret")
if secret == "" {
t.Fatalf("initiate returned no secret: %v", m)
}
if url := dataString(m, "url"); !strings.HasPrefix(url, "otpauth://totp/") {
t.Fatalf("initiate url is not an otpauth URI: %q", url)
}
d, _ := m["data"].(map[string]any)
codes, _ := d["recoveryCodes"].([]any)
if len(codes) == 0 || codes[0].(string) == "" {
t.Fatalf("initiate returned no recovery code: %v", d)
}
recovery := codes[0].(string)
// verify — a code derived from the secret is accepted.
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("totp code: %v", err)
}
if st, m := h.do(t, "/v1/iam/mfa/setup/verify", alice,
`{"secret":"`+secret+`","passcode":"`+code+`"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("verify valid code: status=%d body=%v", st, m)
}
// enable — the secret + recovery code land on alice's row; TOTP is preferred.
if st, m := h.do(t, "/v1/iam/mfa/setup/enable", alice,
`{"secret":"`+secret+`","recoveryCodes":["`+recovery+`"]}`); st != 200 || m["status"] != "ok" {
t.Fatalf("enable: status=%d body=%v", st, m)
}
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
if u == nil || u.TotpSecret != secret {
t.Fatalf("enable did not persist TotpSecret: %+v", u)
}
if u.PreferredMfaType != "app" {
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
}
if len(u.RecoveryCodes) == 0 {
t.Fatalf("enable did not persist recovery codes")
}
// disable — every TOTP field is cleared.
if st, m := h.do(t, "/v1/iam/delete-mfa", alice, `{}`); st != 200 || m["status"] != "ok" {
t.Fatalf("disable: status=%d body=%v", st, m)
}
u, _ = store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
if u.TotpSecret != "" || u.PreferredMfaType != "" || len(u.RecoveryCodes) != 0 {
t.Fatalf("disable did not clear MFA fields: %+v", u)
}
}
// TestMFA_verifyRejectsBadCode: an incorrect passcode is refused (status:error at
// 200 — the casibase convention the console branches on).
func TestMFA_verifyRejectsBadCode(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
_, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
secret := dataString(m, "secret")
st, body := h.do(t, "/v1/iam/mfa/setup/verify", alice,
`{"secret":"`+secret+`","passcode":"000000"}`)
if st != 200 || body["status"] != "error" {
t.Fatalf("bad code should be rejected: status=%d body=%v", st, body)
}
}
// TestMFA_crossUserRequiresAdmin: a regular user cannot enroll/disable MFA on
// ANOTHER user — the general user-write policy refuses it (403). An org-admin and
// a super over that user CAN.
func TestMFA_crossUserRequiresAdmin(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice") // regular
boss := h.token(t, "hanzo/boss") // org-admin of hanzo
super := h.token(t, "admin/root") // SuperAdmin
// alice → boss's MFA: forbidden.
body := `{"owner":"hanzo","name":"boss"}`
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", alice, body); st != 403 {
t.Fatalf("regular user initiating another user's MFA: status=%d, want 403", st)
}
if st, _ := h.do(t, "/v1/iam/delete-mfa", alice, body); st != 403 {
t.Fatalf("regular user disabling another user's MFA: status=%d, want 403", st)
}
// org-admin → a user in the SAME org: allowed.
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", boss,
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("org-admin initiating a same-org user's MFA: status=%d body=%v", st, m)
}
// super → anyone: allowed.
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", super,
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("super initiating a user's MFA: status=%d body=%v", st, m)
}
}
// TestMFA_setPreferred: a user selects a preferred factor on her own account.
func TestMFA_setPreferred(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice")
if st, m := h.do(t, "/v1/iam/set-preferred-mfa", alice, `{"mfaType":"app"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("set-preferred-mfa: status=%d body=%v", st, m)
}
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
if u.PreferredMfaType != "app" {
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
}
}
// TestMFA_requiresBearer: no token → the Guard refuses before the handler.
func TestMFA_requiresBearer(t *testing.T) {
h := newHarness(t)
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", "", `{}`); st != 401 {
t.Fatalf("no-bearer initiate: status=%d, want 401", st)
}
}
// ---- seed helpers (mirror the SCIM harness) ----
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner, c.Name = owner, name
c.PrivateKey = privPEM
c.SetId(owner + "/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsAdmin = admin
u.PasswordHash = "$argon2id$SENTINEL"
u.PasswordType = "argon2id"
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
return string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
}))
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/url"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
// of the authorization-code flow. iam2 validates the request BEFORE it trusts
// any redirect: an unknown client_id or an unregistered redirect_uri is answered
// in place and NEVER redirected to (RFC 6749 §4.1.2.1), closing the open-redirect
// and code-injection surface that a bare pass-through would leave open. A
// well-formed request is delegated to the hosted login UI (matching v1), which
// collects credentials and posts to /v1/iam/login; that endpoint mints the
// PKCE-bound code and the browser lands back on the registered redirect_uri.
// hostedLoginPath is the default hosted-login route the authorize endpoint hands
// a validated request to when the application pins no SigninUrl of its own.
const hostedLoginPath = "/login/oauth/authorize"
// authorizeRequest is the parsed authorize query.
type authorizeRequest struct {
responseType string
clientID string
redirectURI string
scope string
state string
nonce string
codeChallenge string
codeChallengeMethod string
resource string
responseMode string
provider string
}
func authorizeHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
q := authorizeParams(c)
// 1. Resolve the client. Without a known client there is no trusted
// redirect target, so the error is shown in place — never redirected.
if q.clientID == "" {
return authorizeUserError(c, "client_id is required")
}
app, err := store.GetApplicationByClientId(ctx, db, q.clientID)
if err != nil {
return authorizeUserError(c, "internal error")
}
if app == nil {
return authorizeUserError(c, "unknown client_id")
}
// 2. redirect_uri must EXACTLY match a registered URI before it can ever
// be used as a redirect target. A mismatch is answered in place.
if q.redirectURI == "" || !app.IsRedirectUriValid(q.redirectURI) {
return authorizeUserError(c, "invalid redirect_uri")
}
// The redirect target is now trusted: protocol errors redirect back to it
// with error+state (RFC 6749 §4.1.2.1).
if q.responseType != "code" {
return authorizeErrorRedirect(c, q, "unsupported_response_type", "only response_type=code is supported")
}
method := normalizeChallengeMethod(q.codeChallenge, q.codeChallengeMethod)
if q.codeChallenge != "" && method != "S256" {
return authorizeErrorRedirect(c, q, "invalid_request", "only S256 PKCE is supported")
}
if app.ClientSecret == "" && q.codeChallenge == "" {
return authorizeErrorRedirect(c, q, "invalid_request", "PKCE is required for public clients")
}
// A request that names a social `provider` is federated to that external
// IdP (Google/GitHub, …) instead of the hosted credential login. The
// client + redirect_uri + PKCE policy above are already enforced, so the
// federation broker starts from a validated request and a trusted target.
if q.provider != "" {
return beginFederation(c, db, app, q, method)
}
// Delegate to the hosted login with a clean, re-encoded request. The login
// page posts credentials to /v1/iam/login, which mints the code.
return c.Redirect(302, hostedLoginTarget(app)+"?"+authorizeForwardQuery(q, method))
}
}
// authorizeParams reads the authorize parameters from the query (GET) or form
// body (POST).
func authorizeParams(c *zip.Ctx) authorizeRequest {
return authorizeRequest{
responseType: param(c, "response_type"),
clientID: param(c, "client_id"),
redirectURI: param(c, "redirect_uri"),
scope: param(c, "scope"),
state: param(c, "state"),
nonce: param(c, "nonce"),
codeChallenge: param(c, "code_challenge"),
codeChallengeMethod: param(c, "code_challenge_method"),
resource: param(c, "resource"),
responseMode: param(c, "response_mode"),
provider: param(c, "provider"),
}
}
// hostedLoginTarget is the login URL a validated request is delegated to — the
// application's own SigninUrl when set, else the default hosted-login route.
func hostedLoginTarget(app *schema.Application) string {
if app.SigninUrl != "" {
return app.SigninUrl
}
return hostedLoginPath
}
// authorizeForwardQuery re-encodes the validated request as a clean query string
// for the hosted login — reconstructed from known parameters so nothing
// unexpected is passed through.
func authorizeForwardQuery(q authorizeRequest, method string) string {
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", q.clientID)
v.Set("redirect_uri", q.redirectURI)
setIfPresent(v, "scope", q.scope)
setIfPresent(v, "state", q.state)
setIfPresent(v, "nonce", q.nonce)
if q.codeChallenge != "" {
v.Set("code_challenge", q.codeChallenge)
v.Set("code_challenge_method", method)
}
setIfPresent(v, "resource", q.resource)
setIfPresent(v, "response_mode", q.responseMode)
return v.Encode()
}
// authorizeErrorRedirect bounces a protocol error back to the (already
// validated) redirect_uri with error+state, in the requested response mode.
func authorizeErrorRedirect(c *zip.Ctx, q authorizeRequest, code, desc string) error {
v := url.Values{}
v.Set("error", code)
setIfPresent(v, "error_description", desc)
setIfPresent(v, "state", q.state)
sep := "?"
switch {
case q.responseMode == "fragment":
sep = "#"
case strings.Contains(q.redirectURI, "?"):
sep = "&"
}
return c.Redirect(302, q.redirectURI+sep+v.Encode())
}
// authorizeUserError answers a request whose client_id/redirect_uri could not be
// validated: the resource owner is informed in place and the request is NOT
// redirected anywhere (RFC 6749 §4.1.2.1). The message is server-controlled.
func authorizeUserError(c *zip.Ctx, msg string) error {
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
return c.String(400, "authorization error: "+msg)
}
// normalizeChallengeMethod maps an omitted PKCE method to S256 when a challenge
// is present (S256 is the only method iam2 supports); an explicit non-S256
// method is returned unchanged so the caller rejects the downgrade.
func normalizeChallengeMethod(challenge, method string) string {
if challenge == "" {
return method
}
if method == "" || strings.EqualFold(method, "null") {
return "S256"
}
return method
}
// setIfPresent sets a query value only when non-empty.
func setIfPresent(v url.Values, key, value string) {
if value != "" {
v.Set(key, value)
}
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/http"
"net/url"
"strings"
"testing"
)
const testRedirect = "https://app.example/callback"
func authorizeURL(q url.Values) string {
return PathAuthorize + "?" + q.Encode()
}
// The authorize endpoint validates the client and redirect_uri BEFORE it will
// redirect anywhere: an unknown client or an unregistered redirect_uri is
// answered in place (never bounced), closing the open-redirect surface.
func TestAuthorize_RefusesToRedirectOnBadClientOrRedirect(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
cases := []struct {
name string
q url.Values
}{
{"missing client_id", url.Values{"response_type": {"code"}, "redirect_uri": {testRedirect}}},
{"unknown client_id", url.Values{"response_type": {"code"}, "client_id": {"ghost"}, "redirect_uri": {testRedirect}}},
{"missing redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}}},
{"unregistered redirect_uri", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {"https://evil.example/steal"}}},
{"redirect near-match", url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect + "/.."}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(tc.q)))
if resp.StatusCode != 400 {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "" {
t.Fatalf("must NOT redirect on bad client/redirect; got Location %q", loc)
}
})
}
}
// Once the client + redirect_uri are validated, a protocol error bounces back to
// the (trusted) redirect_uri with error + state.
func TestAuthorize_ProtocolErrorRedirectsToClient(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
t.Run("unsupported response_type", func(t *testing.T) {
q := url.Values{"response_type": {"token"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"xyz"}, "code_challenge": {"abc"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "error=unsupported_response_type") || !strings.Contains(loc, "state=xyz") {
t.Fatalf("Location = %q", loc)
}
})
t.Run("public client without PKCE", func(t *testing.T) {
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "state": {"s1"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "error=invalid_request") {
t.Fatalf("public client without PKCE should error; Location = %q", loc)
}
})
t.Run("plain PKCE rejected", func(t *testing.T) {
q := url.Values{"response_type": {"code"}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_challenge": {"abc"}, "code_challenge_method": {"plain"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "error=invalid_request") {
t.Fatalf("plain PKCE should be rejected; Location = %q", loc)
}
})
}
// A well-formed request is delegated to the hosted login with the (re-encoded)
// request preserved.
func TestAuthorize_DelegatesValidRequest(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
challenge := ComputeS256Challenge("verifier-abcdefghijklmnopqrstuvwxyz-012345")
q := url.Values{
"response_type": {"code"},
"client_id": {"pub"},
"redirect_uri": {testRedirect},
"scope": {"openid profile"},
"state": {"state-1"},
"nonce": {"nonce-1"},
"code_challenge": {challenge},
}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
if resp.StatusCode != 302 {
t.Fatalf("status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, hostedLoginPath+"?") {
t.Fatalf("Location = %q, want hosted-login delegate", loc)
}
forwarded, err := url.Parse(loc)
if err != nil {
t.Fatal(err)
}
fq := forwarded.Query()
if fq.Get("client_id") != "pub" || fq.Get("redirect_uri") != testRedirect ||
fq.Get("code_challenge") != challenge || fq.Get("code_challenge_method") != "S256" ||
fq.Get("state") != "state-1" || fq.Get("nonce") != "nonce-1" {
t.Fatalf("delegated query missing/incorrect: %v", fq)
}
}
// A confidential client may authorize without PKCE (it authenticates with its
// secret at the token endpoint).
func TestAuthorize_ConfidentialWithoutPKCEDelegates(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
q := url.Values{"response_type": {"code"}, "client_id": {"conf"}, "redirect_uri": {testRedirect}, "scope": {"openid"}}
resp, _ := do(t, app, formReqNoBody("GET", authorizeURL(q)))
if resp.StatusCode != 302 || !strings.HasPrefix(resp.Header.Get("Location"), hostedLoginPath+"?") {
t.Fatalf("confidential authorize: status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
}
}
// requireRedirect asserts a 302 whose Location targets wantPrefix and returns it.
func requireRedirect(t *testing.T, resp *http.Response, wantPrefix string) string {
t.Helper()
if resp.StatusCode != 302 {
t.Fatalf("status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, wantPrefix) {
t.Fatalf("Location = %q, want prefix %q", loc, wantPrefix)
}
return loc
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"strings"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/hanzoai/iam/internal/schema"
)
// certkey resolves the PUBLIC half of a signing Cert and encodes it as a JWK.
// It is the one place cert → public-key happens, shared by the JWKS endpoint
// (which publishes the key so relying parties can verify) and token
// verification (which checks a bearer against it). The public key is read from
// the Cert's published x509 certificate when present, else derived from the key
// pair; private material never crosses this boundary.
// certPublicKey returns a Cert's public key, its JOSE alg, and (for x509 certs)
// the base64 DER chain for the JWK `x5c`. An ML-DSA cert yields a raw ML-DSA
// public key and no chain.
func certPublicKey(cert *schema.Cert) (pub crypto.PublicKey, alg string, x5c []string, err error) {
if cert == nil {
return nil, "", nil, errors.New("jwks: nil cert")
}
if isMLDSACert(cert) {
pk, err := mldsa65PublicFromCert(cert)
if err != nil {
return nil, "", nil, err
}
return pk, algMLDSA65, nil, nil
}
if cert.Certificate != "" {
block, _ := pem.Decode([]byte(cert.Certificate))
if block != nil {
x509Cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, "", nil, err
}
a, err := classicalAlg(x509Cert.PublicKey, cert.CryptoAlgorithm)
if err != nil {
return nil, "", nil, err
}
return x509Cert.PublicKey, a, []string{base64.StdEncoding.EncodeToString(x509Cert.Raw)}, nil
}
}
// Dev/test cert that stores only the private key: derive the public half.
signer, err := parsePrivateKeyPEM(cert.PrivateKey)
if err != nil {
return nil, "", nil, err
}
a, err := classicalAlg(signer.Public(), cert.CryptoAlgorithm)
if err != nil {
return nil, "", nil, err
}
return signer.Public(), a, nil, nil
}
// certToJWK encodes a Cert's public key as a JWK map: {kty, alg, use:"sig", kid,
// key params, x5c?}. kid is the Cert name (what token headers carry), matching
// the live hanzo.id JWKS.
func certToJWK(cert *schema.Cert) (map[string]any, error) {
pub, alg, x5c, err := certPublicKey(cert)
if err != nil {
return nil, err
}
var jwk map[string]any
switch k := pub.(type) {
case *rsa.PublicKey:
jwk = rsaJWK(k)
case *ecdsa.PublicKey:
jwk, err = ecJWK(k)
if err != nil {
return nil, err
}
case *mldsa65.PublicKey:
jwk = map[string]any{"kty": "MLDSA", "x": base64.RawURLEncoding.EncodeToString(k.Bytes())}
default:
return nil, errors.New("jwks: unsupported public key type")
}
jwk["use"] = "sig"
jwk["kid"] = cert.Name
jwk["alg"] = alg
if len(x5c) > 0 {
jwk["x5c"] = x5c
}
return jwk, nil
}
// rsaJWK encodes an RSA public key's modulus and exponent (RFC 7518 §6.3).
func rsaJWK(k *rsa.PublicKey) map[string]any {
return map[string]any{
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
}
}
// ecJWK encodes an EC public key's curve and fixed-width coordinates (RFC 7518
// §6.2) and returns the curve's JOSE alg.
func ecJWK(k *ecdsa.PublicKey) (map[string]any, error) {
var crv string
var size int
switch k.Curve.Params().BitSize {
case 256:
crv, size = "P-256", 32
case 384:
crv, size = "P-384", 48
case 521:
crv, size = "P-521", 66
default:
return nil, errors.New("jwks: unsupported EC curve")
}
return map[string]any{
"kty": "EC",
"crv": crv,
"x": base64.RawURLEncoding.EncodeToString(leftPad(k.X.Bytes(), size)),
"y": base64.RawURLEncoding.EncodeToString(leftPad(k.Y.Bytes(), size)),
}, nil
}
// classicalAlg maps a classical public key (and the Cert's declared algorithm,
// when it agrees with the key family) to a JOSE alg. The key type is
// authoritative; the declared value only refines RSA (RS256 default, RS512 when
// pinned).
func classicalAlg(pub crypto.PublicKey, declared string) (string, error) {
switch k := pub.(type) {
case *rsa.PublicKey:
if strings.EqualFold(declared, "RS512") {
return "RS512", nil
}
return "RS256", nil
case *ecdsa.PublicKey:
switch k.Curve.Params().BitSize {
case 256:
return "ES256", nil
case 384:
return "ES384", nil
case 521:
return "ES512", nil
}
return "", errors.New("jwks: unsupported EC curve")
default:
return "", errors.New("jwks: unsupported public key type")
}
}
// leftPad left-zero-pads b to size bytes (EC coordinates are fixed-width).
func leftPad(b []byte, size int) []byte {
if len(b) >= size {
return b
}
out := make([]byte, size)
copy(out[size-len(b):], b)
return out
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// The login-challenge lifecycle: the ONE primitive for a sign-in that has proven
// one thing and must prove another before a token exists. The MFA gate mints one
// when a password verifies but the second factor is outstanding; the matching
// finish takes it.
//
// v1 keeps this in a beego cookie session; v2 has no key/value session store, so
// the state is a server-side row (schema.LoginChallenge) and the client holds only
// its opaque id. It is a SIBLING of Token, never a Token with borrowed fields:
// /token resolves a grant by Code, so a challenge filed there would sit on the
// redemption path wearing a fictional Application.
// challengeTTL bounds a half-finished ceremony. Five minutes is the authorization
// code's own bound — long enough to read a code off a phone, short enough that an
// abandoned challenge is not a standing key to an account whose password is
// already known.
const challengeTTL = 5 * time.Minute
// The challenge kinds. Each names the proof still outstanding, and a taker demands
// its own kind: a challenge minted for one purpose must never satisfy another.
const (
KindMfa = "mfa"
KindFederation = "federation"
)
// ErrChallenge is the ONE opaque failure for every way a challenge can be refused
// — unknown, expired, spent, or the wrong kind. They collapse to one answer so a
// prober cannot tell a spent challenge from a forged one.
var ErrChallenge = errors.New("the multi-factor session has expired")
// challengeOwner files every challenge under the reserved admin org. A challenge
// is the authorization server's own state, not a tenant record: it is never
// listed, never served by an entity route, and its subject is the only tenancy
// that matters (and rides inside it, verified).
const challengeOwner = "admin"
// MintChallenge persists a fresh challenge for subject ("owner/name") and returns
// its opaque id. payload is the kind's own state — the just-used verification type
// for the MFA gate. now is injected for testability.
func MintChallenge(ctx context.Context, db orm.DB, kind, subject, payload string, now time.Time) (string, error) {
id, err := newOpaqueToken()
if err != nil {
return "", err
}
c := orm.New[schema.LoginChallenge](db)
c.Owner = challengeOwner
c.Name = id
c.CreatedTime = now.UTC().Format(time.RFC3339)
c.Kind = kind
c.Subject = subject
c.Payload = payload
c.ExpireIn = now.Add(challengeTTL).Unix()
c.SetId(challengeOwner + "/" + id)
if err := c.CreateCtx(ctx); err != nil {
return "", err
}
return id, nil
}
// TakeChallenge resolves and SPENDS a challenge of the given kind, returning it.
// Taking is the only read: a challenge that is found is immediately marked used,
// so a replay of the same id loses whether it races or follows. The caller gets
// the subject from the returned row and nowhere else — never from a request
// parameter, so a body naming another user cannot redirect the ceremony.
//
// Every refusal is ErrChallenge.
func TakeChallenge(ctx context.Context, db orm.DB, id, kind string, now time.Time) (*schema.LoginChallenge, error) {
if id == "" {
return nil, ErrChallenge
}
c, err := orm.Get[schema.LoginChallenge](db, challengeOwner+"/"+id)
if err != nil || c == nil {
return nil, ErrChallenge
}
if c.Used || c.Kind != kind || now.Unix() > c.ExpireIn {
return nil, ErrChallenge
}
c.Used = true
if err := c.UpdateCtx(ctx); err != nil {
return nil, ErrChallenge
}
return c, nil
}
// challengeCookie carries the challenge id to the client exactly the way v1 carries
// its beego session: a host-only, HttpOnly cookie the browser returns on the
// finishing request. Script cannot read it; it is bound to the ceremony's own
// short life.
const challengeCookie = "hanzo_challenge"
// SetChallenge writes the challenge id for the finishing request to return.
// HttpOnly keeps script out of it; SameSite=Lax lets the portal's own POST carry
// it while refusing a cross-site one; the MaxAge matches the row's TTL so the
// browser forgets it exactly when the server does.
func SetChallenge(c *zip.Ctx, id string) {
c.Fiber().Cookie(&fiber.Cookie{
Name: challengeCookie,
Value: id,
Path: "/",
MaxAge: int(challengeTTL / time.Second),
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// ClearChallenge expires the cookie once its challenge is spent, so a finished
// ceremony leaves nothing behind to replay.
func ClearChallenge(c *zip.Ctx) {
c.Fiber().Cookie(&fiber.Cookie{
Name: challengeCookie,
Value: "",
Path: "/",
MaxAge: -1,
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// ReadChallenge returns the challenge id a finishing request presents: the body
// field when one is given (an SDK holding no cookie jar), else the cookie the
// browser returned. ONE function, ONE precedence — the id is the bearer of the
// ceremony either way, and the row it names is single-use, short-lived, and
// carries its own subject, so neither source can widen what it proves.
func ReadChallenge(c *zip.Ctx, fromBody string) string {
if fromBody != "" {
return fromBody
}
return c.Fiber().Cookies(challengeCookie)
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"time"
"github.com/hanzoai/iam/internal/schema"
)
// Authorization-code lifecycle over the Token entity. A code is a short-lived,
// single-use bearer of the right to mint tokens for one (app, user); PKCE binds
// it to the client instance that started the flow, and the single-use + expiry
// guards close replay.
// codeTTL bounds how long an authorization code is redeemable (RFC 6749 §4.1.2
// recommends ≤ 10 min; we use 5).
const codeTTL = 5 * time.Minute
var (
// ErrCodeUnknown — no token row carries this code.
ErrCodeUnknown = errors.New("oauth: authorization code not found")
// ErrCodeUsed — the code was already redeemed (replay). Per RFC 6749 §4.1.2
// a reused code SHOULD also revoke previously-issued tokens; the caller does
// that when it detects this error.
ErrCodeUsed = errors.New("oauth: authorization code already used")
// ErrCodeExpired — the code is past its TTL.
ErrCodeExpired = errors.New("oauth: authorization code expired")
// ErrClientMismatch — the redeeming client_id is not the one the code was
// minted for.
ErrClientMismatch = errors.New("oauth: client_id does not match the authorization code")
)
// newOpaqueToken returns a 256-bit URL-safe random token (code / access token).
func newOpaqueToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// MintCode builds (does not persist) a Token row representing a fresh
// authorization code bound to (app, user), the PKCE challenge, scope, and
// resource. The caller persists it via the store. now is injected for
// testability.
func MintCode(app *schema.Application, userID, scope, challenge, method, resource string, now time.Time) (*schema.Token, error) {
code, err := newOpaqueToken()
if err != nil {
return nil, err
}
// If a challenge is present, pin the method to S256 — never store "plain".
if challenge != "" && method != "S256" {
return nil, ErrPKCEPlainRejected
}
// The token row is keyed by the application's OWNER (its registry owner, e.g.
// "admin"), so (Owner, Application) is the application's natural key and the
// token endpoint resolves the app back unambiguously. Organization records the
// tenant the grant belongs to.
return &schema.Token{
Owner: app.Owner,
Organization: app.Organization,
Application: app.Name,
User: userID,
Code: code,
Scope: scope,
TokenType: "Bearer",
CodeChallenge: challenge,
CodeChallengeMethod: method,
CodeIsUsed: false,
CodeExpireIn: now.Add(codeTTL).Unix(),
Resource: resource,
}, nil
}
// RedeemCode validates an authorization_code exchange against the stored token
// row and returns nil iff the code may be used. It is the single guard the
// token endpoint calls; on success the caller MUST immediately mark the row used
// (MarkUsed) inside the same transaction so a concurrent replay loses.
//
// Checks, in order (each fail-closed):
// 1. row exists (caller passes nil → ErrCodeUnknown)
// 2. not already used (replay)
// 3. not expired
// 4. client_id matches (constant-time)
// 5. PKCE: verifier derives the stored challenge (S256; plain refused; a public
// client that stored a challenge must present a verifier)
func RedeemCode(tok *schema.Token, clientAppName, verifier string, now time.Time) error {
if tok == nil {
return ErrCodeUnknown
}
if tok.CodeIsUsed {
return ErrCodeUsed
}
if tok.CodeExpireIn != 0 && now.Unix() > tok.CodeExpireIn {
return ErrCodeExpired
}
if subtle.ConstantTimeCompare([]byte(tok.Application), []byte(clientAppName)) != 1 {
return ErrClientMismatch
}
return VerifyPKCE(verifier, tok.CodeChallenge, tok.CodeChallengeMethod)
}
// IssueAccessToken fills the row with a freshly-minted access token + expiry and
// marks the code used — the atomic success step after RedeemCode. now injected
// for tests. ttlSeconds is the access-token lifetime.
func IssueAccessToken(tok *schema.Token, ttlSeconds int, now time.Time) error {
at, err := newOpaqueToken()
if err != nil {
return err
}
tok.AccessToken = at
tok.ExpiresIn = ttlSeconds
tok.CodeIsUsed = true // one-shot: any subsequent RedeemCode → ErrCodeUsed
return nil
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"errors"
"testing"
"time"
"github.com/hanzoai/iam/internal/schema"
)
func testApp() *schema.Application {
a := &schema.Application{Organization: "hanzo"}
a.Name = "hanzo-console"
a.ClientId = "hanzo-console"
return a
}
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-abc-000000000000000000000000000000000"
ch := ComputeS256Challenge(verifier)
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
if err != nil {
t.Fatal(err)
}
if tok.Code == "" || len(tok.Code) < 40 {
t.Fatalf("code not a 256-bit token: %q", tok.Code)
}
if tok.CodeIsUsed {
t.Fatal("fresh code must not be used")
}
if tok.CodeExpireIn != now.Add(codeTTL).Unix() {
t.Fatalf("expiry = %d, want %d", tok.CodeExpireIn, now.Add(codeTTL).Unix())
}
if tok.Application != "hanzo-console" || tok.User != "hanzo/alice" {
t.Fatalf("binding wrong: app=%q user=%q", tok.Application, tok.User)
}
}
func TestMintCode_RefusesPlain(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
if _, err := MintCode(testApp(), "u", "", "some-challenge", "plain", "", now); !errors.Is(err, ErrPKCEPlainRejected) {
t.Fatalf("mint with plain: got %v, want ErrPKCEPlainRejected", err)
}
}
func TestRedeemCode_HappyPath(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-happy-0000000000000000000000000000000"
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", ComputeS256Challenge(verifier), "S256", "", now)
if err := RedeemCode(tok, "hanzo-console", verifier, now.Add(30*time.Second)); err != nil {
t.Fatalf("valid redemption rejected: %v", err)
}
}
func TestRedeemCode_ReplayRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-replay-000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
// First redemption + issue marks it used.
if err := RedeemCode(tok, "hanzo-console", verifier, now); err != nil {
t.Fatal(err)
}
if err := IssueAccessToken(tok, 3600, now); err != nil {
t.Fatal(err)
}
// Replay must now fail.
if err := RedeemCode(tok, "hanzo-console", verifier, now); !errors.Is(err, ErrCodeUsed) {
t.Fatalf("replay: got %v, want ErrCodeUsed", err)
}
}
func TestRedeemCode_ExpiredRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-exp-00000000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
past := now.Add(codeTTL + time.Second)
if err := RedeemCode(tok, "hanzo-console", verifier, past); !errors.Is(err, ErrCodeExpired) {
t.Fatalf("expired code: got %v, want ErrCodeExpired", err)
}
}
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
verifier := "verifier-cli-00000000000000000000000000000000000"
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
if err := RedeemCode(tok, "some-other-app", verifier, now); !errors.Is(err, ErrClientMismatch) {
t.Fatalf("client mismatch: got %v, want ErrClientMismatch", err)
}
}
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
if err := RedeemCode(tok, "hanzo-console", "the-WRONG-verifier-0000000000000000000000000", now); !errors.Is(err, ErrPKCEMismatch) {
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
}
}
func TestRedeemCode_PublicClientMustPresentVerifier(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
// Code minted WITH a challenge (public client) but token request omits the verifier.
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
if err := RedeemCode(tok, "hanzo-console", "", now); !errors.Is(err, ErrPKCEMissing) {
t.Fatalf("missing verifier: got %v, want ErrPKCEMissing", err)
}
}
func TestRedeemCode_UnknownCode(t *testing.T) {
if err := RedeemCode(nil, "hanzo-console", "v", time.Now()); !errors.Is(err, ErrCodeUnknown) {
t.Fatalf("nil token: got %v, want ErrCodeUnknown", err)
}
}
func TestIssueAccessToken_MintsAndMarksUsed(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
tok, _ := MintCode(testApp(), "u", "openid", "", "", "", now)
if err := IssueAccessToken(tok, 3600, now); err != nil {
t.Fatal(err)
}
if tok.AccessToken == "" || len(tok.AccessToken) < 40 {
t.Fatalf("access token not minted: %q", tok.AccessToken)
}
if !tok.CodeIsUsed {
t.Fatal("code must be marked used after issue")
}
if tok.ExpiresIn != 3600 {
t.Fatalf("expiresIn = %d, want 3600", tok.ExpiresIn)
}
}
+371
View File
@@ -0,0 +1,371 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rand"
"crypto/subtle"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The RFC 8628 device authorization grant: how a machine with no browser and no
// keyboard signs in (`hanzo login` on a GPU box, over ssh, in CI). Three legs,
// each landing on an EXISTING seam rather than a parallel stack:
//
// 1. POST /v1/iam/oauth/device — the device asks for a device_code + a short
// user_code and shows the human a verification URI.
// 2. POST /v1/iam/login {type:"device"} — the human, on any other machine,
// proves who they are and approves the user_code (login.go).
// 3. POST /v1/iam/oauth/token grant_type=…:device_code — the device polls and
// mints through issueTokens, the same path every other grant mints through.
//
// A device authorization IS a pending authorization code, so it is a Token row
// (Code=device_code, UserCode=user_code, User empty until approved) — not a
// process-local map, which would die on restart and never work across replicas.
//
// Client authentication follows RFC 8628 §3.1 (request) and §3.4 (poll), which
// both defer to RFC 6749 §3.2.1: a CONFIDENTIAL client (one with a registered
// secret) authenticates at both legs exactly as it would at the token endpoint;
// a PUBLIC device client (no secret — the usual CLI) is bound by its client_id
// alone. The verification_uri page a human opens is public; the JSON legs here
// are not a browser surface.
// The device grant's vocabulary. deviceCodeTTL and devicePollInterval are each
// read by the device request, the poll, and Discovery, so the lifetime a client
// is told and the lifetime enforced can never drift.
const (
// deviceGrant is the RFC 8628 grant_type identifier.
deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
// deviceCodeTTL bounds a device_code/user_code pair: long enough for a human
// to open the link on a phone, sign in, and approve. It is deliberately NOT
// codeTTL (5 min) — an authorization code is redeemed by software in seconds,
// a device code waits on a person.
deviceCodeTTL = 15 * time.Minute
// devicePollInterval is the minimum seconds between token-endpoint polls
// (RFC 8628 §3.5 `interval`).
devicePollInterval = 5
)
// user_code generation. The alphabet is RFC 8628 §6.1 "unambiguous": no I, L, O,
// 0 or 1, because a human reads this off one screen and types it into another.
// Its 32 symbols make the 5-bit mask below a UNIFORM draw — a modulo over a
// non-power-of-two alphabet would bias the code and cost entropy — so 8
// characters carry a full 40 bits. The live portal normalizes a typed code to
// exactly this alphabet, uppercasing and stripping separators
// (id pkgs/auth/src/client.ts normalizeUserCode), so the minted code is the
// canonical form: uppercase, no dashes.
const (
userCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
userCodeLen = 8
userCodeTries = 5
)
// errUserCodeExhausted — every generated user_code collided with a live one.
// Astronomically unlikely (40 bits against the handful of pending codes); it
// fails closed rather than reusing a code.
var errUserCodeExhausted = errors.New("device: could not generate a free user_code")
// deviceResponse is the RFC 8628 §3.2 device authorization response. The field
// names are load-bearing: both CLIs decode exactly this shape and hard-fail on
// an empty device_code/user_code (cloud/cli/device.go, codex-rs
// login/src/oidc_device_auth.rs).
type deviceResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
VerificationUriComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// routeDevice registers POST /v1/iam/oauth/device on the PUBLIC group r
// (registered before the Guard, exactly like the token endpoint): the endpoint
// authenticates the CLIENT inline — a confidential client by its secret, a
// public device client by its client_id — so it needs no bearer and joins no
// allow-list, membership in this group is what makes it reachable.
func routeDevice(r zip.Router, db orm.DB) {
r.Post(PathDevice, deviceHandler(db))
}
// deviceHandler serves the device authorization request (RFC 8628 §3.1): it
// mints the device_code/user_code pair and tells the device where to send its
// human. A confidential client must authenticate its secret here (§3.1 → RFC
// 6749 §3.2.1); a public device client presents only its client_id. The row it
// creates grants nothing until a human approves it.
func deviceHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
setTokenCacheHeaders(c)
ctx := c.Context()
clientID, clientSecret := clientAuth(c)
app, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if app == nil {
return tokenError(c, 400, "invalid_client", "client_id is invalid")
}
// A confidential client (one with a registered secret) MUST authenticate
// (RFC 8628 §3.1 → RFC 6749 §3.2.1). A public device client has no secret
// and is identified by its client_id alone.
if app.ClientSecret != "" &&
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
if !appGrants(app, deviceGrant) {
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
}
deviceCode, err := newOpaqueToken()
if err != nil {
return tokenError(c, 500, "server_error", "")
}
userCode, err := newUserCode(ctx, db)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
// One row IS the pending authorization: Code is the device_code the
// machine polls with, UserCode the code its human transcribes, and an
// empty User means nobody has approved yet.
row := &schema.Token{
Owner: app.Owner,
Application: app.Name,
Organization: app.Organization,
Code: deviceCode,
UserCode: userCode,
Scope: param(c, "scope"),
TokenType: "Bearer",
CodeExpireIn: nowFunc().Add(deviceCodeTTL).Unix(),
}
row.Name = "dc-" + deviceCode[:24]
if err := store.PersistToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
// Both URIs point at the SPA approval page a human opens, never at this
// JSON API. The complete form is a PATH segment because that is the route
// the page is mounted on (/login/oauth/device/:userCode).
verify := tokenIssuer(c) + PathDeviceVerify
return c.JSON(200, deviceResponse{
DeviceCode: deviceCode,
UserCode: userCode,
VerificationUri: verify,
VerificationUriComplete: verify + "/" + userCode,
ExpiresIn: int(deviceCodeTTL.Seconds()),
Interval: devicePollInterval,
})
}
}
// deviceCodeGrant is the device's poll (RFC 8628 §3.4), dispatched from the one
// token endpoint. It authenticates the client (confidential by secret, public by
// client_id) and answers `authorization_pending` until a human approves, then
// mints exactly once. The human who authenticated and approved at the
// verification URI IS the end-user authentication.
func deviceCodeGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
presented := param(c, "device_code")
if presented == "" {
return tokenError(c, 400, "invalid_request", "device_code is required")
}
row, err := store.GetTokenByCode(ctx, db, presented)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
// Unknown, not a device authorization, or already redeemed. isDevice is what
// stops an authorization code being redeemed HERE, where neither its PKCE
// challenge nor its redirect_uri is verified.
if row == nil || !isDevice(row) || row.CodeIsUsed {
return deviceDead(c)
}
// Expired — reap it on the way past, so a dead authorization does not linger.
if expired(row.CodeExpireIn, now) {
_ = store.DeleteToken(ctx, db, row)
return deviceDead(c)
}
app, err := resolveTokenApp(ctx, db, row)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if app == nil {
return tokenError(c, 400, "invalid_grant", "the device code is invalid")
}
clientID, clientSecret := clientAuth(c)
if deviceClientMismatch(app, clientID) {
return tokenError(c, 400, "invalid_grant", "the device_code was not issued to this client")
}
// A confidential client authenticates on EVERY poll (RFC 8628 §3.4 → RFC 6749
// §3.2.1), checked before the pending/mint split so an unauthenticated
// confidential poll never even learns the grant's approval state. A public
// device client has no secret and is bound by its client_id alone (above).
if app.ClientSecret != "" &&
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
// Re-gated at redemption, not only at the request: an application whose device
// grant was withdrawn between the two must not still mint.
if !appGrants(app, deviceGrant) {
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
}
// Not approved yet: leave the row exactly as it is — the device keeps polling.
if row.User == "" {
return tokenError(c, 400, "authorization_pending", "the device authorization is pending approval")
}
// One-shot: burn the approval BEFORE minting, so any later poll finds the row
// already redeemed rather than minting a second token off one approval. Like
// the authorization-code grant beside it this is a read-modify-write, not a
// compare-and-swap: two polls landing inside the same write window could still
// both mint. They mint the same user, app, scope and refresh family, so the
// duplicate is contained (revoking the family revokes both) — a real CAS is a
// property the Token row would have to carry for every grant, not just this one.
row.CodeIsUsed = true
if err := store.SaveToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
resp, err := issueTokens(ctx, db, c, app, row, newFamilyID(row), now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if err := store.SaveToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
return c.JSON(200, resp)
}
// approveDevice binds an authenticated human's identity onto a pending device
// authorization — the act that lets the device's next poll mint. The row's
// application and scope stay authoritative for that mint: the portal app the
// browser happens to be on is irrelevant to WHAT is being approved, so it is
// never read here. Called from the login handler once the credential check has
// already proven who the approver is.
func approveDevice(c *zip.Ctx, db orm.DB, user *schema.User, userCode string) error {
// ONE opaque refusal for unknown / not-a-device / expired / already-approved /
// already-redeemed. The user_code is only 40 bits — the one secret in this
// flow — so an answer that distinguished those cases would turn this page into
// an oracle for hunting live codes.
const refuse = "the user code is invalid or expired"
ctx := c.Context()
row, err := store.GetTokenByUserCode(ctx, db, userCode)
if err != nil {
return httpx.Err(c, refuse)
}
if row == nil || !isDevice(row) || row.CodeIsUsed || row.User != "" ||
expired(row.CodeExpireIn, nowFunc()) {
return httpx.Err(c, refuse)
}
// Tenant boundary: a user in org A must not approve a device sign-in bound to
// an app in org B (a confused deputy — brands seed same-named superusers). The
// org compared is the DEVICE row's, captured when the code was issued. A
// SuperAdmin — a member of the reserved admin org, the one predicate — crosses
// tenants deliberately: that is the identity an operator signs a CLI into any
// brand's app with. An unresolvable tenant fails closed.
if row.Organization == "" {
return httpx.Err(c, refuse)
}
if !store.IsSuperAdmin(user.Owner) && user.Owner != row.Organization {
return httpx.Err(c, "your organization may not approve this device sign-in")
}
row.User = user.Owner + "/" + user.Name
if err := store.SaveToken(ctx, db, row); err != nil {
return httpx.Err(c, refuse)
}
return httpx.Ok(c, row.User)
}
// deviceDead is the one answer for a device_code that cannot be redeemed —
// unknown, not a device authorization, already redeemed, or expired. To the
// client those are the same fact (this code is dead, start over), so they get
// the same words: sharing one answer makes that structural rather than a
// coincidence of copied strings.
func deviceDead(c *zip.Ctx) error {
return tokenError(c, 400, "expired_token", "the device code is expired or already redeemed")
}
// isDevice reports whether a code row is an RFC 8628 device authorization rather
// than an authorization code. Both kinds live in Token.Code, so every grant
// checks the kind before redeeming: an authorization code must never be redeemed
// at the device grant, which verifies neither PKCE nor redirect_uri, and a
// device code must never be redeemed at the authorization-code grant, which
// would mint on a row no human has approved. The user_code IS the
// discriminator — only a device authorization has one.
func isDevice(tok *schema.Token) bool { return tok != nil && tok.UserCode != "" }
// deviceClientMismatch reports whether clientID is NOT the client the device
// authorization was issued to (RFC 8628 §3.4). Without this an approval for app
// A is redeemable as app B: a confused deputy that hands the caller a token for
// the wrong audience. Pure, so the binding is unit-testable.
func deviceClientMismatch(app *schema.Application, clientID string) bool {
return app == nil ||
subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1
}
// appGrants reports whether app permits grant — the per-application grant gate
// (v1 IsGrantTypeValid, object/token_oauth.go:605). A grant must be DECLARED on
// the application to be usable, so an app that never enabled the device grant can
// never mint a device token. Fail-closed by construction: every live application
// declares its grant set, so an app with none permits none.
func appGrants(app *schema.Application, grant string) bool {
if app == nil {
return false
}
for _, g := range app.GrantTypes {
if g == grant {
return true
}
}
return false
}
// expired reports whether a unix deadline has passed. A zero deadline never
// expires (the v1 convention for "unset").
func expired(deadline int64, now time.Time) bool {
return deadline != 0 && now.Unix() > deadline
}
// newUserCode mints a user_code that no live row already carries. Each attempt
// REGENERATES the candidate — a loop that re-tests one fixed code could never
// clear a collision.
func newUserCode(ctx context.Context, db orm.DB) (string, error) {
for range userCodeTries {
code, err := randomUserCode()
if err != nil {
return "", err
}
row, err := store.GetTokenByUserCode(ctx, db, code)
if err != nil {
return "", err
}
if row == nil {
return code, nil
}
}
return "", errUserCodeExhausted
}
// randomUserCode draws userCodeLen symbols uniformly from userCodeAlphabet.
func randomUserCode() (string, error) {
buf := make([]byte, userCodeLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i := range buf {
buf[i] = userCodeAlphabet[buf[i]&0x1f]
}
return string(buf), nil
}
+584
View File
@@ -0,0 +1,584 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The RFC 8628 device grant, driven through the real router exactly as the two
// live CLIs drive it: client_id/scope as QUERY params on the device request
// (cloud/cli/device.go, codex-rs oidc_device_auth.rs), then a form-encoded poll
// at the one token endpoint.
// deviceGrants is the grant set a device-capable app declares — what hanzo-app
// carries in the live seed.
var deviceGrants = []string{"authorization_code", "refresh_token", deviceGrant}
// seedDeviceApp seeds a public, device-capable app plus a user in its org.
func seedDeviceApp(t *testing.T, db orm.DB, clientID string) {
t.Helper()
seedApp(t, db, appOpts{clientID: clientID, grants: deviceGrants})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
}
// requestDevice drives POST /v1/iam/oauth/device the way a PUBLIC device client
// does: client_id/scope only, no secret.
func requestDevice(t *testing.T, app *zip.App, clientID, scope string) (*http.Response, map[string]any) {
t.Helper()
return requestDeviceSecret(t, app, clientID, "", scope)
}
// requestDeviceSecret drives the device request with an optional client_secret —
// the confidential-client leg (RFC 8628 §3.1). An empty secret is the public
// case.
func requestDeviceSecret(t *testing.T, app *zip.App, clientID, secret, scope string) (*http.Response, map[string]any) {
t.Helper()
q := url.Values{"client_id": {clientID}, "scope": {scope}, "response_type": {"device_code"}}
if secret != "" {
q.Set("client_secret", secret)
}
resp, body := do(t, app, formReqNoBody("POST", PathDevice+"?"+q.Encode()))
return resp, decode(t, body)
}
// pollDevice drives one device poll at the token endpoint (public client).
func pollDevice(t *testing.T, app *zip.App, clientID, deviceCode string) (*http.Response, map[string]any) {
t.Helper()
return pollDeviceSecret(t, app, clientID, "", deviceCode)
}
// pollDeviceSecret drives one device poll with an optional client_secret — the
// confidential-client leg (RFC 8628 §3.4).
func pollDeviceSecret(t *testing.T, app *zip.App, clientID, secret, deviceCode string) (*http.Response, map[string]any) {
t.Helper()
form := url.Values{
"grant_type": {deviceGrant},
"client_id": {clientID},
"device_code": {deviceCode},
}
if secret != "" {
form.Set("client_secret", secret)
}
resp, body := do(t, app, formReq("POST", PathToken, form))
return resp, decode(t, body)
}
// approveAs drives the human approval leg: POST /v1/iam/login {type:"device"}.
func approveAs(t *testing.T, app *zip.App, org, user, userCode string) map[string]any {
t.Helper()
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": org, "username": user, "password": "pw",
"type": "device", "userCode": userCode,
}))
return decode(t, body)
}
// The device response carries exactly the keys both CLIs decode, with the TTL
// and poll interval the server actually enforces. cloud/cli/device.go hard-fails
// on an empty device_code/user_code, so an error envelope here is a dead CLI.
func TestDevice_RequestShape(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
resp, m := requestDevice(t, app, "hanzo-app", "openid profile")
if resp.StatusCode != 200 {
t.Fatalf("status %d: %v", resp.StatusCode, m)
}
deviceCode, _ := m["device_code"].(string)
userCode, _ := m["user_code"].(string)
if deviceCode == "" || userCode == "" {
t.Fatalf("device_code/user_code must be non-empty: %v", m)
}
if m["expires_in"] != float64(900) {
t.Errorf("expires_in = %v, want 900", m["expires_in"])
}
if m["interval"] != float64(5) {
t.Errorf("interval = %v, want 5", m["interval"])
}
// verification_uri_complete must be the PATH form: the SPA route is
// /login/oauth/device/:userCode.
verify, _ := m["verification_uri"].(string)
if verify != "https://hanzo.id"+PathDeviceVerify {
t.Errorf("verification_uri = %q", verify)
}
if got, want := m["verification_uri_complete"], verify+"/"+userCode; got != want {
t.Errorf("verification_uri_complete = %v, want %v", got, want)
}
if resp.Header.Get("Cache-Control") != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", resp.Header.Get("Cache-Control"))
}
// The user_code must be transcribable AND survive the portal's
// normalization (uppercase, separators stripped) unchanged — a code the
// portal rewrites is a code the lookup can never find.
if len(userCode) != userCodeLen {
t.Errorf("user_code %q: length %d, want %d", userCode, len(userCode), userCodeLen)
}
if got := strings.ToUpper(strings.ReplaceAll(userCode, "-", "")); got != userCode {
t.Errorf("user_code %q is not already normalized (portal would send %q)", userCode, got)
}
for _, r := range userCode {
if !strings.ContainsRune(userCodeAlphabet, r) {
t.Errorf("user_code %q contains ambiguous symbol %q", userCode, r)
}
}
// The pending grant is a persisted row, not process-local state.
row, err := store.GetTokenByCode(tctx(), db, deviceCode)
if err != nil || row == nil {
t.Fatalf("device authorization was not persisted: %v", err)
}
if row.User != "" {
t.Errorf("a fresh device authorization must be unapproved, got user %q", row.User)
}
if row.UserCode != userCode {
t.Errorf("row.UserCode = %q, want %q", row.UserCode, userCode)
}
}
// Discovery advertises the device endpoint and grant so a discovery-driven
// client can find them.
func TestDevice_Discovery(t *testing.T) {
app, _ := newServer(t)
_, body := do(t, app, formReqNoBody("GET", PathDiscovery))
d := decode(t, body)
if d["device_authorization_endpoint"] != "https://hanzo.id"+PathDevice {
t.Errorf("device_authorization_endpoint = %v, want %v", d["device_authorization_endpoint"], "https://hanzo.id"+PathDevice)
}
gts, _ := d["grant_types_supported"].([]any)
found := false
for _, g := range gts {
if g == deviceGrant {
found = true
}
}
if !found {
t.Errorf("grant_types_supported missing %q: %v", deviceGrant, gts)
}
}
// Before approval the poll answers authorization_pending and LEAVES the row —
// the CLI polls on this answer, so consuming the row would end the login.
func TestDevice_PollPendingIsRepeatable(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode := da["device_code"].(string)
for i := range 3 {
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 400 {
t.Fatalf("poll %d: status %d, want 400", i, resp.StatusCode)
}
if m["error"] != "authorization_pending" {
t.Fatalf("poll %d: error = %v, want authorization_pending", i, m["error"])
}
// A 401 would send the CLI down its terminal error path.
if resp.Header.Get("WWW-Authenticate") != "" {
t.Fatalf("poll %d: a pending poll must not carry a WWW-Authenticate challenge", i)
}
}
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row == nil {
t.Fatal("a pending poll must not consume the device authorization")
}
}
// The whole point, end to end: approve once, mint once. The SECOND poll of an
// approved code must fail — one approval is one token.
func TestDevice_ApproveThenPollMintsExactlyOnce(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
t.Fatalf("approval failed: %v", m)
}
// The approval binds the approver onto the row — identity comes from there,
// never from the polling device.
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
if row == nil || row.User != "hanzo/alice" {
t.Fatalf("approval must bind the approver onto the row, got %+v", row)
}
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 200 {
t.Fatalf("approved poll: status %d: %v", resp.StatusCode, m)
}
access, _ := m["access_token"].(string)
if access == "" {
t.Fatalf("approved poll must mint an access_token: %v", m)
}
if id, _ := m["id_token"].(string); id == "" {
t.Error("the openid scope must mint an id_token")
}
if rt, _ := m["refresh_token"].(string); rt == "" {
t.Error("the device grant must mint a refresh token")
}
// The minted token describes the approver, and is usable.
claims, err := verifyToken(tctx(), db, access)
if err != nil {
t.Fatalf("minted access token does not verify: %v", err)
}
if claims.Subject != "hanzo/alice" {
t.Errorf("sub = %q, want hanzo/alice", claims.Subject)
}
// One approval, one token: a replayed poll gets nothing.
resp2, m2 := pollDevice(t, app, "hanzo-app", deviceCode)
if resp2.StatusCode != 400 || m2["error"] != "expired_token" {
t.Fatalf("second poll: %d %v, want 400 expired_token", resp2.StatusCode, m2)
}
if _, ok := m2["access_token"]; ok {
t.Fatal("a redeemed device code must never mint twice")
}
}
// A CONFIDENTIAL device client authenticates at BOTH legs (RFC 8628 §3.1 request,
// §3.4 poll). Without its secret the request is refused and the poll — even of an
// approved code — mints nothing; with the secret both succeed.
func TestDevice_ConfidentialClientAuth(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf-cli", secret: "s3cret", grants: deviceGrants})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
// §3.1: a confidential client's device request without its secret is refused.
resp, m := requestDevice(t, app, "conf-cli", "openid")
if resp.StatusCode != 401 || m["error"] != "invalid_client" {
t.Fatalf("unauthenticated device request: %d %v, want 401 invalid_client", resp.StatusCode, m)
}
if m["device_code"] != nil {
t.Fatal("a refused device request must not mint a device_code")
}
// With the secret it succeeds.
resp, m = requestDeviceSecret(t, app, "conf-cli", "s3cret", "openid")
if resp.StatusCode != 200 {
t.Fatalf("authenticated device request: %d %v", resp.StatusCode, m)
}
deviceCode, userCode := m["device_code"].(string), m["user_code"].(string)
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
t.Fatalf("approval failed: %v", am)
}
// §3.4: the poll without the secret is refused — even though the code is
// approved — and mints nothing.
presp, pm := pollDevice(t, app, "conf-cli", deviceCode)
if presp.StatusCode != 401 || pm["error"] != "invalid_client" {
t.Fatalf("unauthenticated poll: %d %v, want 401 invalid_client", presp.StatusCode, pm)
}
if _, ok := pm["access_token"]; ok {
t.Fatal("an unauthenticated confidential poll must never mint")
}
// With the secret the poll mints.
presp, pm = pollDeviceSecret(t, app, "conf-cli", "s3cret", deviceCode)
if presp.StatusCode != 200 || pm["access_token"] == nil {
t.Fatalf("authenticated poll must mint: %d %v", presp.StatusCode, pm)
}
}
// Tenant boundary: a user in org B must not approve a device sign-in bound to an
// app in org A. A SuperAdmin — a member of the reserved admin org — may, because
// that is the identity an operator signs a CLI into any brand with.
func TestDevice_ApprovalTenantBoundary(t *testing.T) {
for _, tc := range []struct {
name string
org string // approver's org; the device app lives in "hanzo"
allow bool
}{
{"same org approves", "hanzo", true},
{"foreign org refused", "lux", false},
{"superadmin crosses tenants", "admin", true},
} {
t.Run(tc.name, func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants}) // org "hanzo"
seedUserInOrg(t, db, tc.org, "eve", "eve@"+tc.org+".example", "pw")
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
m := approveAs(t, app, tc.org, "eve", userCode)
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
if !tc.allow {
if m["status"] != "error" {
t.Fatalf("cross-tenant approval must be refused, got %v", m)
}
// The store is the proof: refused means NOT approved.
if row.User != "" {
t.Fatalf("refused approval must not bind a user, got %q", row.User)
}
// And the device must still not be able to mint.
if _, p := pollDevice(t, app, "hanzo-app", deviceCode); p["error"] != "authorization_pending" {
t.Fatalf("a refused approval must leave the device pending, got %v", p)
}
return
}
if m["status"] != "ok" {
t.Fatalf("approval must succeed, got %v", m)
}
if row.User != tc.org+"/eve" {
t.Fatalf("row.User = %q, want %q", row.User, tc.org+"/eve")
}
})
}
}
// RFC 8628 §3.4: a device_code is redeemable only by the client it was issued
// to. Otherwise an approval for app A is redeemable as app B — a token for the
// wrong audience.
func TestDevice_ClientBinding(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
seedApp(t, db, appOpts{clientID: "other-app", grants: deviceGrants})
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
t.Fatalf("approval failed: %v", m)
}
resp, m := pollDevice(t, app, "other-app", deviceCode)
if resp.StatusCode != 400 || m["error"] != "invalid_grant" {
t.Fatalf("foreign client redemption: %d %v, want 400 invalid_grant", resp.StatusCode, m)
}
if _, ok := m["access_token"]; ok {
t.Fatal("a device_code must never be redeemable by another client")
}
// The rightful client can still redeem — the binding refused, it did not burn.
if _, own := pollDevice(t, app, "hanzo-app", deviceCode); own["access_token"] == nil {
t.Fatalf("the issuing client must still redeem its own code: %v", own)
}
}
// An expired device_code is dead even once approved.
func TestDevice_Expiry(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
start := time.Unix(1_800_000_000, 0)
nowFuncSet(t, start)
_, da := requestDevice(t, app, "hanzo-app", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
t.Fatalf("approval failed: %v", m)
}
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
if resp.StatusCode != 400 || m["error"] != "expired_token" {
t.Fatalf("expired poll: %d %v, want 400 expired_token", resp.StatusCode, m)
}
if _, ok := m["access_token"]; ok {
t.Fatal("an expired device code must never mint")
}
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row != nil {
t.Error("an expired device authorization should be reaped on the poll that finds it")
}
}
// The per-application grant gate: an app that never declared the device grant
// can neither start a device flow nor redeem one. Gated at BOTH ends, so a row
// created while the grant was enabled cannot mint after it is withdrawn.
func TestDevice_GrantGate(t *testing.T) {
app, db := newServer(t)
// A real app, fully functional — it simply never declared the device grant.
seedApp(t, db, appOpts{clientID: "web-only", grants: []string{"authorization_code", "refresh_token"}})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
resp, m := requestDevice(t, app, "web-only", "openid")
if resp.StatusCode != 400 || m["error"] != "unsupported_grant_type" {
t.Fatalf("request: %d %v, want 400 unsupported_grant_type", resp.StatusCode, m)
}
if m["device_code"] != nil {
t.Fatal("a refused device request must not mint a device_code")
}
// And at the poll: forge a device row for the app, as if the grant had been
// enabled and then withdrawn, and prove the redemption is still refused.
seedApp(t, db, appOpts{clientID: "was-enabled", grants: deviceGrants})
_, da := requestDevice(t, app, "was-enabled", "openid")
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
t.Fatalf("approval failed: %v", am)
}
withdrawGrants(t, db, "was-enabled")
resp2, m2 := pollDevice(t, app, "was-enabled", deviceCode)
if resp2.StatusCode != 400 || m2["error"] != "unsupported_grant_type" {
t.Fatalf("poll after withdrawal: %d %v, want 400 unsupported_grant_type", resp2.StatusCode, m2)
}
if _, ok := m2["access_token"]; ok {
t.Fatal("an app without the device grant must never mint a device token")
}
}
// The user_code is the only secret in the approval flow (40 bits), so unknown,
// expired, and already-approved codes must be indistinguishable — otherwise the
// approval page is an oracle for hunting live codes.
func TestDevice_UserCodeRefusalIsNonDifferential(t *testing.T) {
app, db := newServer(t)
seedDeviceApp(t, db, "hanzo-app")
start := time.Unix(1_800_000_000, 0)
nowFuncSet(t, start)
// (a) unknown
unknown := approveAs(t, app, "hanzo", "alice", "ZZZZZZZZ")
// (b) already approved
_, da := requestDevice(t, app, "hanzo-app", "openid")
if m := approveAs(t, app, "hanzo", "alice", da["user_code"].(string)); m["status"] != "ok" {
t.Fatalf("first approval must succeed: %v", m)
}
reapproved := approveAs(t, app, "hanzo", "alice", da["user_code"].(string))
// (c) expired
_, da2 := requestDevice(t, app, "hanzo-app", "openid")
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
expiredCode := approveAs(t, app, "hanzo", "alice", da2["user_code"].(string))
for _, m := range []map[string]any{unknown, reapproved, expiredCode} {
if m["status"] != "error" {
t.Fatalf("must be refused: %v", m)
}
}
if unknown["msg"] != reapproved["msg"] || unknown["msg"] != expiredCode["msg"] {
t.Fatalf("refusals differ — an oracle: unknown=%q reapproved=%q expired=%q",
unknown["msg"], reapproved["msg"], expiredCode["msg"])
}
}
// An AUTHORIZATION code must never be redeemable at the device grant. The device
// grant verifies neither PKCE nor redirect_uri, so accepting one there would
// defeat both for any app that permits the device grant — a stolen code would
// mint tokens with no verifier.
func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants, redirectURIs: []string{testRedirect}})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
verifier := "device-xchg-verifier-00000000000000000000000000000"
code, _, _ := loginForCode(t, app, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
"codeChallenge": ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
})
if code == "" {
t.Fatal("setup: no authorization code minted")
}
resp, m := pollDevice(t, app, "hanzo-app", code)
if _, ok := m["access_token"]; ok {
t.Fatal("PKCE BYPASS: an authorization code was redeemed at the device grant")
}
if resp.StatusCode != 400 || m["error"] != "expired_token" {
t.Fatalf("got %d %v, want 400 expired_token", resp.StatusCode, m)
}
// The real exchange still works — the guard refused, it did not burn the code.
if _, tm := exchangeCode(t, app, url.Values{
"code": {code}, "client_id": {"hanzo-app"},
"code_verifier": {verifier}, "redirect_uri": {testRedirect},
}); tm["access_token"] == nil {
t.Fatalf("the legitimate code exchange must still succeed: %v", tm)
}
}
// The mirror image: a DEVICE code must never be redeemable at the
// authorization-code grant, which would mint on a row no human has approved.
func TestDevice_DeviceCodeIsNotRedeemableAsAuthorizationCode(t *testing.T) {
app, db := newServer(t)
// A CONFIDENTIAL app: its secret would otherwise satisfy the code grant's
// client check, and an unapproved device row carries no PKCE challenge to
// stop it. The device request authenticates that same secret (§3.1).
seedApp(t, db, appOpts{clientID: "conf-app", secret: "s3cret", grants: deviceGrants})
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
_, da := requestDeviceSecret(t, app, "conf-app", "s3cret", "openid")
deviceCode := da["device_code"].(string)
_, m := exchangeCode(t, app, url.Values{
"code": {deviceCode}, "client_id": {"conf-app"}, "client_secret": {"s3cret"},
})
if _, ok := m["access_token"]; ok {
t.Fatal("APPROVAL BYPASS: an unapproved device code minted at the authorization-code grant")
}
if m["error"] != "invalid_grant" {
t.Fatalf("error = %v, want invalid_grant", m["error"])
}
}
// An unknown client_id is refused — and mints nothing.
func TestDevice_UnknownClient(t *testing.T) {
app, _ := newServer(t)
resp, m := requestDevice(t, app, "no-such-client", "openid")
if resp.StatusCode != 400 || m["error"] != "invalid_client" {
t.Fatalf("got %d %v, want 400 invalid_client", resp.StatusCode, m)
}
if m["device_code"] != nil {
t.Fatal("an unknown client must not mint a device_code")
}
}
// appGrants is the pure gate both ends call.
func TestAppGrants(t *testing.T) {
for _, tc := range []struct {
name string
declare []string
want bool
}{
{"declared", deviceGrants, true},
{"not declared", []string{"authorization_code", "refresh_token"}, false},
{"none declared", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := appGrants(&schema.Application{GrantTypes: tc.declare}, deviceGrant); got != tc.want {
t.Fatalf("appGrants(%v) = %v, want %v", tc.declare, got, tc.want)
}
})
}
if appGrants(nil, deviceGrant) {
t.Fatal("a nil application must permit nothing")
}
}
// user_codes are drawn fresh each time — a generator that reuses one value could
// never clear a collision.
func TestRandomUserCode_Distinct(t *testing.T) {
seen := map[string]bool{}
for range 64 {
code, err := randomUserCode()
if err != nil {
t.Fatal(err)
}
if seen[code] {
t.Fatalf("user_code %q repeated — the draw is not random", code)
}
seen[code] = true
}
}
// withdrawGrants strips an application's declared grants in place.
func withdrawGrants(t *testing.T, db orm.DB, name string) {
t.Helper()
a, err := orm.Get[schema.Application](db, "admin/"+name)
if err != nil {
t.Fatalf("load app %s: %v", name, err)
}
a.GrantTypes = nil
if err := a.UpdateCtx(tctx()); err != nil {
t.Fatalf("withdraw grants: %v", err)
}
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"testing"
)
// Discovery is served at both well-known paths, host-relative, advertising only
// what iam2 implements — matching the live hanzo.id surface so a client's
// discovery step is unchanged across the backend swap.
func TestDiscovery_ShapeAtBothPaths(t *testing.T) {
app, _ := newServer(t)
for _, path := range []string{PathDiscovery, PathDiscoveryV1} {
resp, body := do(t, app, formReqNoBody("GET", path))
if resp.StatusCode != 200 {
t.Fatalf("%s: status %d", path, resp.StatusCode)
}
d := decode(t, body)
if d["issuer"] != "https://hanzo.id" {
t.Errorf("%s: issuer = %v, want https://hanzo.id", path, d["issuer"])
}
if d["authorization_endpoint"] != "https://hanzo.id"+PathAuthorize {
t.Errorf("%s: authorization_endpoint = %v", path, d["authorization_endpoint"])
}
if d["token_endpoint"] != "https://hanzo.id"+PathToken {
t.Errorf("%s: token_endpoint = %v", path, d["token_endpoint"])
}
if d["userinfo_endpoint"] != "https://hanzo.id"+PathUserInfo {
t.Errorf("%s: userinfo_endpoint = %v", path, d["userinfo_endpoint"])
}
if d["jwks_uri"] != "https://hanzo.id"+PathJWKS {
t.Errorf("%s: jwks_uri = %v", path, d["jwks_uri"])
}
if !containsStr(d["code_challenge_methods_supported"], "S256") {
t.Errorf("%s: S256 not advertised", path)
}
if containsStr(d["code_challenge_methods_supported"], "plain") {
t.Errorf("%s: plain must never be advertised", path)
}
for _, alg := range []string{"RS256", "ES256", "MLDSA65"} {
if !containsStr(d["id_token_signing_alg_values_supported"], alg) {
t.Errorf("%s: signing alg %s not advertised", path, alg)
}
}
for _, gt := range []string{"authorization_code", "refresh_token", "client_credentials"} {
if !containsStr(d["grant_types_supported"], gt) {
t.Errorf("%s: grant %s not advertised", path, gt)
}
}
}
}
// The issuer NEVER follows X-Forwarded-Host: it is resolved from the TRUSTED
// request host (zip.Ctx.Host(), which ignores X-Forwarded-Host) through the pinned
// issuer resolver, so a client-supplied X-Forwarded-Host cannot steer `iss`. Here
// the trusted host is hanzo.id (formReqNoBody) and no issuer map is configured, so
// the spoofed header is discarded and the issuer stays host-relative to hanzo.id.
func TestDiscovery_IssuerIgnoresForwardedHost(t *testing.T) {
app, _ := newServer(t)
req := formReqNoBody("GET", PathDiscovery)
req.Header.Set("X-Forwarded-Host", "id.example.test")
resp, body := do(t, app, req)
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
if got := decode(t, body)["issuer"]; got != "https://hanzo.id" {
t.Fatalf("issuer = %v, want https://hanzo.id (X-Forwarded-Host must not steer iss)", got)
}
}
func containsStr(v any, want string) bool {
list, ok := v.([]any)
if !ok {
return false
}
for _, s := range list {
if s == want {
return true
}
}
return false
}
+635
View File
@@ -0,0 +1,635 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"net/url"
"strings"
"time"
"github.com/hanzoai/orm"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/internal/users"
)
// Identity federation — iam2 as an OIDC/OAuth2 Relying Party to external IdPs.
//
// A social sign-in is a DETOUR inside the ordinary authorization-code flow. The
// authorize endpoint, having already validated the client and its EXACT
// redirect_uri (so there is a trusted target before anything is trusted), hands
// a request that names a `provider` to beginFederation, which stashes the whole
// app-leg request server-side and sends the browser to the IdP. When the IdP
// returns to the fixed callback, iam2 verifies the response, LINKS or PROVISIONS
// a local user, and mints ITS OWN authorization code — bound to the original
// PKCE challenge, redirect_uri, and nonce — exactly as a password login would.
// The relying party's existing PKCE code→token exchange then completes unchanged.
//
// The whole surface lives on the PUBLIC group (before the Guard): it
// self-authenticates through the single-use, browser-bound, expiring state, not
// a bearer. Every failure is fail-closed; no IdP token or secret is ever logged.
// PathFederationCallback is the fixed IdP return endpoint. One callback for every
// provider — the provider is recovered from the server-side transaction the
// state keys, never from a spoofable URL segment. It is the redirect_uri iam2
// registers with each external IdP.
const PathFederationCallback = "/v1/iam/oauth/callback"
// PathMfaVerify is the hosted 2FA PAGE (a route in the SPA, not an API path) the
// federation callback sends a second-factor-enrolled user's browser to. The page
// collects the factor and POSTs it to PathFederationMfa; the challenge id rides
// the httpOnly cookie the callback set, never a URL segment.
const PathMfaVerify = "/login/mfa"
// fedCookieName is the per-transaction anti-forgery cookie the begin leg sets and
// the callback checks — the browser binding that defeats login-CSRF.
const fedCookieName = "hanzo_fed"
// fedStateTTL bounds how long a federation transaction (and its cookie) is
// redeemable. Short, because it only has to survive one IdP round-trip.
const fedStateTTL = 10 * time.Minute
// routeFederation registers the IdP callback on the PUBLIC group r. GET only: the
// IdP returns via a top-level browser redirect (Google/GitHub), on which the
// SameSite=Lax browser-binding cookie IS sent. A cross-site form_post (POST) would
// NOT carry a Lax cookie, so the bind check would fail closed — rather than ship a
// half-working POST path, form_post support is a deliberate future change (it needs
// SameSite=None + its own CSRF analysis). The callback self-authenticates via the
// single-use state + the browser cookie.
func routeFederation(r zip.Router, db orm.DB) {
r.Get(PathFederationCallback, federationCallbackHandler(db))
}
// beginFederation starts an Authorization-Code federation. It is entered from
// authorizeHandler ONLY after the client_id and exact redirect_uri are validated
// and the response_type/PKCE policy is enforced, so a protocol error may now be
// redirected to the trusted redirect_uri (RFC 6749 §4.1.2.1). It resolves the
// named provider, mints a single-use transaction, sets the browser-binding
// cookie, and sends the browser to the IdP.
func beginFederation(c *zip.Ctx, db orm.DB, app *schema.Application, q authorizeRequest, method string) error {
ctx := c.Context()
// A federated (external) identity may never be minted into a reserved system
// org (the SuperAdmin vector) nor into a tenant an attacker-owned app has no
// right to serve. Refuse BEFORE starting the round-trip (fail fast, no IdP
// traffic) — defense in depth behind the application-write org authorization.
if !federationOrgAllowed(app) {
return authorizeErrorRedirect(c, q, "access_denied", "federation is not permitted for this application")
}
store.EnrichProviders(ctx, db, app)
prov := federationProvider(app, q.provider)
if prov == nil {
return authorizeErrorRedirect(c, q, "invalid_request", "unknown or unavailable provider")
}
if idpKind(prov) == "" {
return authorizeErrorRedirect(c, q, "invalid_request", "provider is not a supported federation type")
}
if _, ok := connectorFor(prov.Type); !ok {
return authorizeErrorRedirect(c, q, "invalid_request", "provider has no local identity binding")
}
state, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
verifier, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
nonce, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
bindSecret, err := newOpaqueToken()
if err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
now := nowFunc()
st := &schema.FederationState{
Owner: providerOwner(prov),
Name: state,
CreatedTime: now.UTC().Format(time.RFC3339),
Provider: prov.Name,
ClientId: q.clientID,
RedirectUri: q.redirectURI,
AppState: q.state,
Scope: q.scope,
AppNonce: q.nonce,
CodeChallenge: q.codeChallenge,
CodeChallengeMethod: method,
Resource: q.resource,
IdpVerifier: verifier,
IdpNonce: nonce,
BindHash: hashToken(bindSecret),
ExpireIn: now.Add(fedStateTTL).Unix(),
}
// Build the IdP authorize URL BEFORE persisting so a discovery/config failure
// never leaves an orphaned transaction row.
idpURL, err := idpAuthorizeURL(ctx, prov, st, federationCallbackURL(c))
if err != nil {
return authorizeErrorRedirect(c, q, "temporarily_unavailable", "the identity provider is unavailable")
}
if err := store.PersistFederationState(ctx, db, st); err != nil {
return authorizeErrorRedirect(c, q, "server_error", "")
}
setBindCookie(c, bindSecret)
return c.Redirect(302, idpURL)
}
// federationCallbackHandler completes the round-trip: it resolves and burns the
// single-use transaction (checking expiry + browser binding), exchanges and
// verifies the IdP response, links or provisions the local user, and mints the
// iam2 authorization code the relying party expects — then redirects to the
// original redirect_uri with code + state.
func federationCallbackHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
now := nowFunc()
state := param(c, "state")
if state == "" {
return authorizeUserError(c, "missing state")
}
st, err := store.GetFederationState(ctx, db, state)
if err != nil {
return authorizeUserError(c, "internal error")
}
// Until the state resolves there is NO trusted redirect target, so an
// invalid/expired/replayed state is answered in place, never redirected.
if st == nil || st.Used || (st.ExpireIn != 0 && now.Unix() > st.ExpireIn) {
return authorizeUserError(c, "the federation session is invalid or expired")
}
// Browser binding: the callback must present the same anti-forgery cookie
// the begin leg set in THIS browser (constant-time) — the login-CSRF /
// session-fixation defense. A stolen or injected state without the cookie
// stops here.
raw := readBindCookie(c)
if raw == "" || subtle.ConstantTimeCompare([]byte(hashToken(raw)), []byte(st.BindHash)) != 1 {
return authorizeUserError(c, "the federation session could not be verified")
}
// Burn the transaction now (single-use). A concurrent replay reads Used and
// loses; a later replay finds nothing.
st.Used = true
if err := store.SaveFederationState(ctx, db, st); err != nil {
return authorizeUserError(c, "internal error")
}
clearBindCookie(c)
// Resolve the relying-party app (the trusted redirect target) and re-check
// its redirect_uri against the live allow-list — never trust the stored
// value blindly (defense in depth against a tampered row).
app, err := store.GetApplicationByClientId(ctx, db, st.ClientId)
if err != nil || app == nil {
return authorizeUserError(c, "the client application is unavailable")
}
if !app.IsRedirectUriValid(st.RedirectUri) {
return authorizeUserError(c, "invalid redirect_uri")
}
// Re-assert the reserved-org / tenant-legitimacy gate at the mint boundary,
// never trusting that the begin leg still holds or that the app row is honest.
if !federationOrgAllowed(app) {
return fedErrorRedirect(c, st, "access_denied", "federation is not permitted for this application")
}
prov, err := store.GetProvider(ctx, db, st.Owner, st.Provider)
if err != nil || prov == nil {
return fedErrorRedirect(c, st, "temporarily_unavailable", "the identity provider is unavailable")
}
// An IdP-reported denial (user declined / error) is surfaced to the RP as
// access_denied, not a server error.
if e := param(c, "error"); e != "" {
return fedErrorRedirect(c, st, "access_denied", "the identity provider denied the request")
}
code := param(c, "code")
if code == "" {
return fedErrorRedirect(c, st, "invalid_request", "the identity provider returned no code")
}
identity, err := idpExchange(ctx, prov, st, code, federationCallbackURL(c), now)
if err != nil || identity.subject == "" {
return fedErrorRedirect(c, st, "access_denied", "the identity provider could not be verified")
}
user, err := linkOrProvision(ctx, db, app, prov, identity)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
if user.IsForbidden || user.IsDeleted {
return fedErrorRedirect(c, st, "access_denied", "the account is not permitted")
}
// The resume parameters — the ORIGINAL authorize request — pinned so the mint
// (now, or after a second factor) uses exactly these and nothing a later
// request could supply.
p := fedResumeParams{
ClientId: st.ClientId,
RedirectUri: st.RedirectUri,
AppState: st.AppState,
Scope: st.Scope,
AppNonce: st.AppNonce,
CodeChallenge: st.CodeChallenge,
CodeChallengeMethod: st.CodeChallengeMethod,
Resource: st.Resource,
}
// Second-factor gate: a federated login must NOT skip the factor a password
// login would demand (the MFA gate, mfa_gate.go). If the resolved user owes a
// factor, mint NOTHING here — park the resume, bound to the user and these
// pinned params, and send the browser to the hosted 2FA page.
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
if factor.Prompt(org, user) {
// The organization requires a factor this federated user has not enrolled;
// a federated login cannot enroll one inline, so it fails closed.
return fedErrorRedirect(c, st, "access_denied", "two-factor authentication must be set up before signing in")
}
if factor.Enabled(user) && !remembered(user, now) {
return federationChallenge(c, db, st, user, p, now)
}
// No second factor owed — complete exactly as before, through the one mint.
loc, err := federationMint(ctx, db, app, user, p, now)
if err != nil {
return fedMintErrorRedirect(c, st, err)
}
return c.Redirect(302, loc)
}
}
// fedResumeParams is the ORIGINAL iam2 authorize request, pinned server-side so
// the code minted after a federated login (immediately, or after a second factor)
// binds to exactly these values — never to anything a later request supplies.
type fedResumeParams struct {
ClientId string `json:"clientId"`
RedirectUri string `json:"redirectUri"`
AppState string `json:"appState"`
Scope string `json:"scope"`
AppNonce string `json:"appNonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
Resource string `json:"resource"`
}
// errPKCERequired is the one distinguished mint error a caller maps to an OAuth
// invalid_request; every other mint failure is an opaque server_error.
var errPKCERequired = errors.New("federation: PKCE is required for public clients")
// federationMint mints iam2's own authorization code — the SAME artifact a
// password login mints — bound to the pinned app-leg PKCE, redirect_uri and nonce,
// and returns the RP redirect (redirect_uri?code&state). It is the ONE mint path
// both the no-factor completion and the post-2FA resume reach, so a federated code
// can never be minted two different ways.
func federationMint(ctx context.Context, db orm.DB, app *schema.Application, user *schema.User, p fedResumeParams, now time.Time) (string, error) {
// A public client must have carried a PKCE challenge, re-asserted at the mint so
// a minted code is never redeemable without proof.
if app.ClientSecret == "" && p.CodeChallenge == "" {
return "", errPKCERequired
}
userID := user.Owner + "/" + user.Name
codeRow, err := MintCode(app, userID, p.Scope, p.CodeChallenge, p.CodeChallengeMethod, p.Resource, now)
if err != nil {
return "", err
}
codeRow.RedirectUri = p.RedirectUri
codeRow.Nonce = p.AppNonce
if err := store.PersistToken(ctx, db, codeRow); err != nil {
return "", err
}
v := url.Values{}
v.Set("code", codeRow.Code)
setIfPresent(v, "state", p.AppState)
return joinQuery(p.RedirectUri, v), nil
}
// federationChallenge parks a resolved-but-not-yet-second-factored federated login.
// The pending state IS a LoginChallenge (KindFederation) — the same single-use,
// expiring, subject-pinned lifecycle the password MFA gate uses, so there is ONE
// challenge concept — carrying the resume params as its payload. The browser is
// sent to the hosted 2FA page; the challenge id rides the httpOnly cookie, never a
// URL segment.
func federationChallenge(c *zip.Ctx, db orm.DB, st *schema.FederationState, user *schema.User, p fedResumeParams, now time.Time) error {
payload, err := json.Marshal(p)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
id, err := MintChallenge(c.Context(), db, KindFederation, user.Owner+"/"+user.Name, string(payload), now)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
SetChallenge(c, id)
return c.Redirect(302, federationBaseURL(c)+PathMfaVerify)
}
// fedMintErrorRedirect maps a federationMint error to the RP redirect_uri.
func fedMintErrorRedirect(c *zip.Ctx, st *schema.FederationState, err error) error {
if err == errPKCERequired {
return fedErrorRedirect(c, st, "invalid_request", "PKCE is required for public clients")
}
return fedErrorRedirect(c, st, "server_error", "")
}
// linkOrProvision resolves the local identity for a verified federated login,
// PROVISION-DON'T-PROMOTE: (1) an account already linked to this provider
// subject, else (2) an existing account matched by a VERIFIED IdP email (linked
// now), else (3) a freshly provisioned account. It NEVER sets isAdmin and never
// grants an existing account anything — federation only authenticates.
func linkOrProvision(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, id federatedIdentity) (*schema.User, error) {
// Innermost guard on the mint itself: never provision/link a federated identity
// into a reserved system org (SuperAdmin) or a tenant this app may not serve.
// This layer assumes the two before it (app-write authorization + the begin/
// callback checks) both failed.
if !federationOrgAllowed(app) {
return nil, errors.New("federation: provisioning into this organization is not permitted")
}
org := app.Organization
binding, ok := connectorFor(prov.Type)
if !ok {
return nil, errors.New("federation: provider has no local identity binding")
}
// 1. Already linked by the provider's stable subject — the authoritative match
// for a returning federated user (immune to email churn/ambiguity).
if u, err := store.GetUserByConnector(ctx, db, org, binding.field, id.subject); err != nil {
return nil, err
} else if u != nil {
return u, nil
}
// 2. Link to an existing account ONLY on a VERIFIED IdP email. An unverified
// email never links (it would let an unproven address take over an account).
if id.emailVerified && id.email != "" {
if u, err := store.GetUserByEmail(ctx, db, org, id.email); err != nil {
return nil, err
} else if u != nil {
*binding.ref(u) = id.subject
u.EmailVerified = true
if err := saveUser(ctx, db, u); err != nil {
return nil, err
}
return u, nil
}
}
// 3. Provision a fresh account. Federated accounts carry NO password (the
// digest stays empty, so password login fails closed) and are never admin.
return provisionFederatedUser(ctx, db, app, prov, binding, id)
}
// provisionFederatedUser creates a new federated account through the ONE
// canonical user-create path (users.Create, no password → no login-able digest),
// stamping the provider subject on its connector column. The username is
// system-generated and collision-checked; the email's verified flag is carried
// straight from the IdP.
func provisionFederatedUser(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, binding connectorBinding, id federatedIdentity) (*schema.User, error) {
org := app.Organization
for attempt := 0; attempt < 4; attempt++ {
name := federatedUsername(id.email, prov.Type)
taken, err := userExists(ctx, db, org, name)
if err != nil {
return nil, err
}
if taken {
continue
}
u := schema.User{
Owner: org,
Name: name,
Type: "normal-user",
DisplayName: firstNonEmpty(id.displayName, name),
Email: id.email,
EmailVerified: id.emailVerified,
Avatar: id.avatar,
SignupApplication: app.Name,
RegisterType: "Federation",
RegisterSource: org + "/" + prov.Name,
}
*binding.ref(&u) = id.subject
return users.New(db).Create(ctx, &users.CreateInput{User: u})
}
return nil, errors.New("federation: could not allocate a unique username")
}
// federationProvider resolves the app's ProviderItem named name to its shared
// Provider record, requiring the link to be sign-in-enabled and configured with
// real credentials — otherwise the request never dead-ends at the IdP.
func federationProvider(app *schema.Application, name string) *schema.Provider {
if name == "" {
return nil
}
for _, it := range app.Providers {
if it == nil || it.Name != name || !it.CanSignIn || it.Provider == nil {
continue
}
if !isConfigured(it.Provider) {
continue
}
return it.Provider
}
return nil
}
// providerOwner is the Provider record's owner, defaulting to the admin org where
// providers are seeded.
func providerOwner(p *schema.Provider) string {
if p.Owner != "" {
return p.Owner
}
return "admin"
}
// federationCallbackURL is the iam2 callback iam2 registers with the IdP and
// re-presents at the token exchange. It is PINNED from config, never steered by a
// request header, so an attacker cannot redirect the IdP leg via X-Forwarded-Host.
func federationCallbackURL(c *zip.Ctx) string {
return federationBaseURL(c) + PathFederationCallback
}
// federationBaseURL is the pinned public origin the IdP callback is registered
// under — the SAME per-brand issuer the tokens carry, resolved through the ONE
// issuer resolver (issuer.go) keyed on the TRUSTED request host (c.Host(), which
// ignores X-Forwarded-Host). So a brand's federation callback is registered at
// that brand's pinned origin, header-immune and never steered to an attacker
// origin. See resolveIssuer for the fail-closed resolution order.
func federationBaseURL(c *zip.Ctx) string {
return resolveIssuer(c.Host())
}
// federationOrgAllowed reports whether a federated (external) identity may be
// provisioned or linked into the application's Organization. Two invariants,
// both fail-closed:
//
// 1. NEVER a reserved system org (admin/built-in/app). A social sign-in that
// landed a user in the admin org would make that user a SuperAdmin — the
// critical escalation. Federation is customer sign-in; system orgs are seeded
// / onboarded / SuperAdmin-managed, never reached by an external login.
// 2. Tenant legitimacy. A platform app (admin/built-in-owned, and thus only
// SuperAdmin-creatable) may serve any non-reserved tenant. A tenant-registered
// app may only land users in the org it legitimately serves — its OWN org, a
// shared app, or one with an explicit org-choice mode — mirroring the
// login/signup tenant gate, so an attacker-owned app cannot mint or link
// identities into a victim tenant.
//
// This is defense in depth behind the application-write authorization (authz
// authorizes the Organization field on create/update); this layer assumes that
// one was bypassed and still refuses the escalation.
func federationOrgAllowed(app *schema.Application) bool {
org := strings.TrimSpace(app.Organization)
if org == "" || store.IsReservedOrg(org) {
return false
}
if store.IsSigningCertOwner(app.Owner) {
return true // platform app — SuperAdmin-configured, may serve any tenant
}
if app.IsShared || app.OrgChoiceMode != "" {
return true
}
return org == app.Owner
}
// fedSuccessRedirect returns the browser to the relying party's redirect_uri with
// the iam2 authorization code and the original app state (RFC 6749 §4.1.2).
func fedSuccessRedirect(c *zip.Ctx, st *schema.FederationState, code string) error {
v := url.Values{}
v.Set("code", code)
setIfPresent(v, "state", st.AppState)
return c.Redirect(302, joinQuery(st.RedirectUri, v))
}
// fedErrorRedirect returns an OAuth error to the relying party's redirect_uri
// (already allow-list-validated) with the original app state.
func fedErrorRedirect(c *zip.Ctx, st *schema.FederationState, code, desc string) error {
v := url.Values{}
v.Set("error", code)
setIfPresent(v, "error_description", desc)
setIfPresent(v, "state", st.AppState)
return c.Redirect(302, joinQuery(st.RedirectUri, v))
}
// setBindCookie writes the per-transaction anti-forgery cookie: HttpOnly + Secure,
// SameSite=Lax (so it IS sent on the IdP's top-level GET back to the callback),
// scoped to the callback path, expiring with the transaction.
func setBindCookie(c *zip.Ctx, value string) {
c.Fiber().Cookie(&fiber.Cookie{
Name: fedCookieName,
Value: value,
Path: PathFederationCallback,
MaxAge: int(fedStateTTL / time.Second),
Secure: true,
HTTPOnly: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// readBindCookie returns the anti-forgery cookie value, or "" when absent.
func readBindCookie(c *zip.Ctx) string { return c.Fiber().Cookies(fedCookieName) }
// clearBindCookie expires the anti-forgery cookie once the transaction is
// consumed, so it can never be replayed.
func clearBindCookie(c *zip.Ctx) {
c.Fiber().Cookie(&fiber.Cookie{
Name: fedCookieName,
Value: "",
Path: PathFederationCallback,
MaxAge: -1,
Secure: true,
HTTPOnly: true,
SameSite: fiber.CookieSameSiteLaxMode,
})
}
// connectorBinding ties a provider type to the User's per-connector identity
// column: the EXACT lowercase orm/json field name to filter on and a pointer
// accessor to read/set the stored subject.
type connectorBinding struct {
field string
ref func(*schema.User) *string
}
// connectorRegistry maps a provider Type to its User connector column. The field
// name is the EXACT json/orm name (already lowercase): orm's filter lowercases
// only the FIRST rune, so a Go field name like "GitHub" would query '$.gitHub'
// (the tag is 'github') — passing the exact json name is the one correct way, and
// this registry is its single source of truth. Only the connectors iam2 can
// federate are listed; anything else fails closed.
var connectorRegistry = map[string]connectorBinding{
"google": {"google", func(u *schema.User) *string { return &u.Google }},
"github": {"github", func(u *schema.User) *string { return &u.GitHub }},
"gitlab": {"gitlab", func(u *schema.User) *string { return &u.Gitlab }},
"gitee": {"gitee", func(u *schema.User) *string { return &u.Gitee }},
"bitbucket": {"bitbucket", func(u *schema.User) *string { return &u.Bitbucket }},
"facebook": {"facebook", func(u *schema.User) *string { return &u.Facebook }},
"apple": {"apple", func(u *schema.User) *string { return &u.Apple }},
"linkedin": {"linkedin", func(u *schema.User) *string { return &u.LinkedIn }},
"discord": {"discord", func(u *schema.User) *string { return &u.Discord }},
"slack": {"slack", func(u *schema.User) *string { return &u.Slack }},
"okta": {"okta", func(u *schema.User) *string { return &u.Okta }},
"azuread": {"azuread", func(u *schema.User) *string { return &u.AzureAD }},
"microsoftonline": {"microsoftonline", func(u *schema.User) *string { return &u.MicrosoftOnline }},
}
// connectorFor resolves the connector binding for a provider type (case-folded),
// or (zero, false) when the type has no local identity column.
func connectorFor(providerType string) (connectorBinding, bool) {
b, ok := connectorRegistry[strings.ToLower(strings.TrimSpace(providerType))]
return b, ok
}
// federatedUsername generates a valid, human-friendly, collision-resistant
// username for a provisioned account: the email local-part (or provider name)
// sanitized to a handle, guaranteed to start with a letter (so it passes the
// username policy), plus a random suffix so concurrent provisions never collide.
func federatedUsername(email, providerType string) string {
base := ""
if at := strings.IndexByte(email, '@'); at > 0 {
base = email[:at]
}
base = sanitizeHandle(base)
if base == "" {
base = sanitizeHandle(providerType)
}
if base == "" {
base = "user"
}
if base[0] < 'a' || base[0] > 'z' {
base = "u" + base
}
return base + "-" + randHex(4)
}
// randHex returns 2n lowercase hex chars of cryptographic randomness.
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// sanitizeHandle reduces s to a lowercase [a-z0-9._-] handle, capped at 24 chars.
func sanitizeHandle(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
ch := s[i]
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '_' || ch == '-' {
out = append(out, ch)
}
}
if len(out) > 24 {
out = out[:24]
}
return string(out)
}
+749
View File
@@ -0,0 +1,749 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/iam/internal/schema"
)
// The Relying-Party side of federation: iam2 as an OIDC/OAuth2 CLIENT of an
// external identity provider. Two dialects, one contract (federatedIdentity):
//
// - OIDC (Google + any provider with an IssuerUrl): OIDC Discovery resolves the
// endpoints and JWKS; the end user is authenticated by the id_token, whose
// SIGNATURE (against the published JWKS), issuer, audience, expiry, and nonce
// are all verified before a single claim is trusted. email_verified is read
// from the signed token.
// - GitHub (OAuth2, no id_token): the code is exchanged for an access token,
// then the user + verified-email endpoints are read. Only a GitHub-verified,
// primary email is treated as verified.
//
// Every outbound call is hardened: a bounded-timeout client that never follows
// redirects (a 3xx on a token/JWKS endpoint is answered as a failure, not
// chased), a response-body size cap, an https-except-loopback URL guard, and
// alg-pinned JWT verification (RS/ES only — never `none`, never an HMAC that a
// public key could be abused as the secret for). No secret or token is logged.
// federatedIdentity is the VERIFIED identity an external IdP asserts about the
// end user — the only thing the broker trusts out of the round-trip. Subject is
// the IdP's stable, opaque user id (the connector-column value); Email is linked
// against a local account ONLY when EmailVerified is true.
type federatedIdentity struct {
subject string
email string
emailVerified bool
displayName string
avatar string
}
// federationHTTPClient is the hardened client every IdP call rides. The timeout
// bounds a slow/hostile IdP; CheckRedirect refuses to chase a redirect (an IdP
// token/userinfo/JWKS endpoint answering 3xx is a fault, not a hop), closing the
// SSRF-via-redirect vector; and the dialer Control refuses to connect to a
// private/loopback/link-local/metadata address AT DIAL TIME — after DNS
// resolution, on the ACTUAL connecting IP — so a hostile IssuerUrl/Custom*Url (or
// a DNS-rebinding hostname) cannot make iam2 reach an internal service or the
// cloud metadata endpoint.
var federationHTTPClient = &http.Client{
Timeout: 12 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
Control: federationDialControl,
}).DialContext,
TLSHandshakeTimeout: 8 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 8,
IdleConnTimeout: 30 * time.Second,
},
}
// federationDialAllowsPrivate relaxes the SSRF dial guard to permit
// private/loopback addresses. It is a TEST SEAM ONLY (the mock IdPs bind to
// 127.0.0.1); production code never sets it, so the guard is always fully armed
// in a real deployment.
var federationDialAllowsPrivate = false
// federationDialControl is the net.Dialer.Control hook: it inspects the resolved
// address every connection actually dials and refuses a private, loopback,
// link-local, ULA, unspecified, multicast, or CGNAT target — the SSRF gate that a
// literal-URL check cannot provide because it sees the post-DNS IP (defeating
// DNS-rebinding). Fails closed on an unparseable address.
func federationDialControl(_, address string, _ syscall.RawConn) error {
if federationDialAllowsPrivate {
return nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return errors.New("federation: refusing an unparseable dial address")
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("federation: dial host did not resolve to an IP")
}
if ipBlockedForFederation(ip) {
return errors.New("federation: refusing to dial a private/loopback/link-local address")
}
return nil
}
// ipBlockedForFederation reports whether an IP is in a range iam2 must never
// fetch from during federation. net.IP.IsPrivate covers RFC1918 and IPv6 ULA
// (fc00::/7); IsLinkLocalUnicast covers 169.254.0.0/16 (incl. the 169.254.169.254
// cloud-metadata address) and fe80::/10.
func ipBlockedForFederation(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || isCGNAT(ip)
}
// isCGNAT reports whether ip is in 100.64.0.0/10 (carrier-grade NAT), a shared
// range net.IP.IsPrivate does not cover.
func isCGNAT(ip net.IP) bool {
v4 := ip.To4()
return v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127
}
// maxIdPBodyBytes caps every IdP response read — a hostile or broken IdP cannot
// exhaust memory (discovery/JWKS/token/userinfo are all a few KB).
const maxIdPBodyBytes = 1 << 20 // 1 MiB
// defaultGoogleIssuer is the OIDC issuer for a Google provider that pins none of
// its own — the value the id_token carries as `iss` and the discovery origin.
const defaultGoogleIssuer = "https://accounts.google.com"
// GitHub's fixed OAuth2 endpoints (overridable per-Provider for GitHub
// Enterprise / tests via Custom{Auth,Token,UserInfo}Url).
const (
githubAuthorizeEndpoint = "https://github.com/login/oauth/authorize"
githubTokenEndpoint = "https://github.com/login/oauth/access_token"
githubUserEndpoint = "https://api.github.com/user"
)
// idpKind classifies a provider into its federation dialect. A provider with an
// explicit OIDC issuer — or Google — is OIDC; GitHub is OAuth2+userinfo.
// Anything else is unsupported and fails closed (""), never guessed.
func idpKind(p *schema.Provider) string {
switch {
case strings.EqualFold(p.Type, "GitHub"):
return "github"
case strings.EqualFold(p.Type, "Google") || strings.TrimSpace(p.IssuerUrl) != "":
return "oidc"
default:
return ""
}
}
// idpAuthorizeURL builds the IdP authorization-endpoint URL the browser is sent
// to at the begin leg — dialect-dispatched, with iam2's callback as the IdP
// redirect_uri, our single-use state, IdP-leg PKCE, and (OIDC) the nonce.
func idpAuthorizeURL(ctx context.Context, p *schema.Provider, st *schema.FederationState, callback string) (string, error) {
switch idpKind(p) {
case "oidc":
cfg, err := oidcResolve(ctx, p)
if err != nil {
return "", err
}
return oidcAuthorizeURL(cfg, p, st, callback), nil
case "github":
return githubAuthorizeURL(p, st, callback), nil
default:
return "", fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
}
}
// idpExchange completes the callback leg: it exchanges the IdP authorization
// code and returns the VERIFIED identity, or an error if any verification fails.
func idpExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
switch idpKind(p) {
case "oidc":
cfg, err := oidcResolve(ctx, p)
if err != nil {
return federatedIdentity{}, err
}
return oidcExchange(ctx, cfg, p, st, code, callback, now)
case "github":
return githubExchange(ctx, p, st, code, callback)
default:
return federatedIdentity{}, fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
}
}
// --- OIDC dialect (Google + any IssuerUrl provider) ---
// oidcConfig is the resolved OIDC endpoint set for a provider.
type oidcConfig struct {
issuer string
authURL string
tokenURL string
jwksURL string
}
// oidcResolve determines the issuer and runs OIDC Discovery to fill the endpoint
// set. A per-Provider Custom{Auth,Token}Url overrides the discovered
// authorize/token endpoint (a provider that publishes discovery but pins a
// vanity endpoint); the JWKS URI always comes from the signed discovery document
// so id_token verification keys are never attacker-chosen.
func oidcResolve(ctx context.Context, p *schema.Provider) (oidcConfig, error) {
issuer := strings.TrimRight(strings.TrimSpace(p.IssuerUrl), "/")
if issuer == "" && strings.EqualFold(p.Type, "Google") {
issuer = defaultGoogleIssuer
}
if issuer == "" {
return oidcConfig{}, errors.New("federation: OIDC provider has no issuerUrl")
}
disco, err := oidcDiscover(ctx, issuer)
if err != nil {
return oidcConfig{}, err
}
cfg := oidcConfig{
issuer: issuer,
authURL: disco.AuthorizationEndpoint,
tokenURL: disco.TokenEndpoint,
jwksURL: disco.JwksURI,
}
if v := strings.TrimSpace(p.CustomAuthUrl); v != "" {
cfg.authURL = v
}
if v := strings.TrimSpace(p.CustomTokenUrl); v != "" {
cfg.tokenURL = v
}
if cfg.authURL == "" || cfg.tokenURL == "" || cfg.jwksURL == "" {
return oidcConfig{}, errors.New("federation: OIDC discovery is missing required endpoints")
}
return cfg, nil
}
// oidcDiscoveryDocument is the subset of the OIDC Discovery document iam2 reads.
type oidcDiscoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
}
// oidcDiscover fetches and validates the issuer's discovery document. The
// document's own `issuer` MUST equal the configured issuer (OIDC Discovery §4.3)
// — a mismatch means the origin is impersonating another issuer, so it fails
// closed.
func oidcDiscover(ctx context.Context, issuer string) (oidcDiscoveryDocument, error) {
var doc oidcDiscoveryDocument
if err := getJSON(ctx, issuer+"/.well-known/openid-configuration", &doc); err != nil {
return doc, err
}
if strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(issuer, "/") {
return oidcDiscoveryDocument{}, fmt.Errorf("federation: discovery issuer mismatch")
}
return doc, nil
}
// oidcAuthorizeURL builds the OIDC authorization request: response_type=code,
// the app-or-default scope, our callback, the single-use state, S256 PKCE, and
// the nonce that the returned id_token must echo.
func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationState, callback string) string {
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", p.ClientId)
v.Set("redirect_uri", callback)
// The OIDC leg MUST request openid, or the IdP returns no id_token and the
// exchange fails closed — force it in even if the provider's configured scopes
// omit it, so a scope misconfiguration can never silently disable verification.
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
v.Set("state", st.Name)
v.Set("nonce", st.IdpNonce)
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
v.Set("code_challenge_method", "S256")
return joinQuery(cfg.authURL, v)
}
// oidcTokenResponse is the token-endpoint response the OIDC exchange reads.
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
}
// oidcExchange redeems the code at the token endpoint (proving the IdP-leg PKCE
// verifier), then VERIFIES the id_token — signature against the discovered JWKS,
// issuer, audience (== our client id), expiry, and nonce — before trusting any
// claim. The identity comes from the signed id_token, never from an unverified
// userinfo body.
func oidcExchange(ctx context.Context, cfg oidcConfig, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", callback)
form.Set("client_id", p.ClientId)
form.Set("client_secret", p.ClientSecret)
form.Set("code_verifier", st.IdpVerifier)
var tr oidcTokenResponse
if err := postFormJSON(ctx, cfg.tokenURL, form, nil, &tr); err != nil {
return federatedIdentity{}, err
}
if tr.IDToken == "" {
return federatedIdentity{}, errors.New("federation: OIDC token response carried no id_token")
}
claims, err := verifyIDToken(ctx, tr.IDToken, cfg.jwksURL, cfg.issuer, p.ClientId, st.IdpNonce, now)
if err != nil {
return federatedIdentity{}, err
}
return federatedIdentity{
subject: claims.Subject,
email: strings.ToLower(strings.TrimSpace(claims.Email)),
emailVerified: truthy(claims.EmailVerified),
displayName: claims.Name,
avatar: claims.Picture,
}, nil
}
// idTokenClaims is the id_token claim set iam2 reads. Nonce is a top-level OIDC
// claim (not a registered JWT claim), verified against the transaction's stored
// nonce. email_verified is `any` because providers send it as a JSON bool or
// (legacy) the string "true".
type idTokenClaims struct {
jwt.RegisteredClaims
Nonce string `json:"nonce"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
// verifyIDToken parses and fully validates an id_token. The signing method is
// PINNED to the asymmetric set (RS/ES) so a `none` token or an HMAC-with-public-
// key confusion attack is rejected outright; the key comes from the issuer's
// JWKS, selected by `kid`; issuer, audience, and expiry are enforced by the
// parser (now is injected for testability); and the nonce is compared in
// constant time. A subject-less token is refused.
func verifyIDToken(ctx context.Context, idToken, jwksURL, issuer, audience, nonce string, now time.Time) (idTokenClaims, error) {
var claims idTokenClaims
tok, err := jwt.ParseWithClaims(idToken, &claims, jwksKeyfunc(ctx, jwksURL),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
jwt.WithExpirationRequired(),
jwt.WithTimeFunc(func() time.Time { return now }),
)
if err != nil || !tok.Valid {
return idTokenClaims{}, fmt.Errorf("federation: id_token verification failed")
}
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
return idTokenClaims{}, errors.New("federation: id_token nonce mismatch")
}
if strings.TrimSpace(claims.Subject) == "" {
return idTokenClaims{}, errors.New("federation: id_token has no subject")
}
return claims, nil
}
// --- GitHub dialect (OAuth2 + userinfo) ---
// githubAuthorizeURL builds GitHub's OAuth2 authorization request. GitHub OAuth
// Apps support neither PKCE nor a nonce, so the single-use, browser-bound state
// carries the CSRF defense; PKCE is added only when the provider opts in
// (EnablePkce) for a compatible deployment.
func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback string) string {
v := url.Values{}
v.Set("client_id", p.ClientId)
v.Set("redirect_uri", callback)
v.Set("scope", providerScopes(p, "read:user user:email"))
v.Set("state", st.Name)
v.Set("allow_signup", "true")
if p.EnablePkce {
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
v.Set("code_challenge_method", "S256")
}
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
}
// githubTokenResponse is GitHub's (JSON, via Accept) token response.
type githubTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
}
// githubUser / githubEmail are the userinfo shapes iam2 reads.
type githubUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
// githubExchange redeems the code for an access token, then reads the user and
// the verified-email list. The subject is GitHub's immutable numeric id; an
// email is treated as verified ONLY when GitHub reports it verified (primary
// preferred), so a local account is never linked to an unproven address.
func githubExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string) (federatedIdentity, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", callback)
form.Set("client_id", p.ClientId)
form.Set("client_secret", p.ClientSecret)
if p.EnablePkce {
form.Set("code_verifier", st.IdpVerifier)
}
var tr githubTokenResponse
if err := postFormJSON(ctx, firstNonEmpty(p.CustomTokenUrl, githubTokenEndpoint), form, http.Header{"Accept": {"application/json"}}, &tr); err != nil {
return federatedIdentity{}, err
}
if tr.Error != "" || tr.AccessToken == "" {
return federatedIdentity{}, errors.New("federation: GitHub token exchange failed")
}
userURL := firstNonEmpty(p.CustomUserInfoUrl, githubUserEndpoint)
var gu githubUser
if err := getJSONBearer(ctx, userURL, tr.AccessToken, &gu); err != nil {
return federatedIdentity{}, err
}
if gu.ID == 0 {
return federatedIdentity{}, errors.New("federation: GitHub user has no id")
}
email, verified := githubPrimaryEmail(ctx, userURL, tr.AccessToken, gu.Email)
name := gu.Name
if name == "" {
name = gu.Login
}
return federatedIdentity{
subject: strconv.FormatInt(gu.ID, 10),
email: strings.ToLower(strings.TrimSpace(email)),
emailVerified: verified,
displayName: name,
avatar: gu.AvatarURL,
}, nil
}
// githubPrimaryEmail resolves the address to link on: the GitHub /user/emails
// list's primary-and-verified entry (then any verified entry). It returns
// (email, verified); when nothing is verified it returns verified=false so the
// broker provisions a fresh account rather than link by an unproven email. A
// failure to read the list is not fatal — it degrades to unverified.
func githubPrimaryEmail(ctx context.Context, userURL, token, fallback string) (string, bool) {
var emails []githubEmail
if err := getJSONBearer(ctx, strings.TrimRight(userURL, "/")+"/emails", token, &emails); err == nil {
var anyVerified string
for _, e := range emails {
if !e.Verified {
continue
}
if e.Primary {
return e.Email, true
}
if anyVerified == "" {
anyVerified = e.Email
}
}
if anyVerified != "" {
return anyVerified, true
}
}
// No verified address available — the profile email is unproven.
return fallback, false
}
// --- hardened HTTP + JWKS ---
// getJSON GETs a URL and decodes a JSON body, with the safety guard, a 200-only
// contract, and a body-size cap.
func getJSON(ctx context.Context, rawURL string, out any) error {
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, nil)
if err != nil {
return err
}
return doJSON(req, out)
}
// getJSONBearer GETs a bearer-authenticated JSON endpoint (GitHub userinfo).
func getJSONBearer(ctx context.Context, rawURL, token string, out any) error {
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, http.Header{
"Authorization": {"Bearer " + token},
"Accept": {"application/vnd.github+json"},
})
if err != nil {
return err
}
return doJSON(req, out)
}
// postFormJSON POSTs a urlencoded form and decodes a JSON body.
func postFormJSON(ctx context.Context, rawURL string, form url.Values, header http.Header, out any) error {
req, err := newIdPRequest(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()), header)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if req.Header.Get("Accept") == "" {
req.Header.Set("Accept", "application/json")
}
return doJSON(req, out)
}
// newIdPRequest builds a context-bound request to a guard-checked URL with a
// stable User-Agent and the caller's headers.
func newIdPRequest(ctx context.Context, method, rawURL string, body io.Reader, header http.Header) (*http.Request, error) {
safe, err := requireSafeURL(rawURL)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, safe, body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "hanzo-iam2-federation")
for k, vs := range header {
for _, v := range vs {
req.Header.Add(k, v)
}
}
return req, nil
}
// doJSON executes a request and decodes a 200 JSON body under the size cap. A
// non-200 status is a hard failure — no partial trust in an error body.
func doJSON(req *http.Request, out any) error {
resp, err := federationHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("federation: idp request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxIdPBodyBytes))
if err != nil {
return fmt.Errorf("federation: reading idp response failed")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("federation: idp returned status %d", resp.StatusCode)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("federation: decoding idp response failed")
}
return nil
}
// requireSafeURL parses rawURL and enforces the transport guard: http(s) only,
// a non-empty host, and https EXCEPT for loopback (so the production path is
// always TLS while tests may target 127.0.0.1). This also rejects file://, and
// any non-web scheme — an SSRF/exfiltration hygiene gate on the (admin-supplied)
// endpoint configuration.
func requireSafeURL(rawURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("federation: invalid idp url")
}
if u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("federation: idp url must be http(s) with a host")
}
if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
return "", fmt.Errorf("federation: idp url must use https")
}
return u.String(), nil
}
// isLoopbackHost reports whether host is a loopback name/address.
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
// jwkSet / jwk are the JSON Web Key Set shapes iam2 verifies id_tokens against.
type jwkSet struct {
Keys []jwk `json:"keys"`
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
// jwksKeyfunc returns a jwt.Keyfunc that fetches the issuer's JWKS and selects
// the verification key by the token's `kid`. When the token carries a kid, an
// exact match is required; a kid-less token is accepted only against a
// single-key set. The fetch happens inside the closure so it is bounded by the
// same hardened client and request context.
func jwksKeyfunc(ctx context.Context, jwksURL string) jwt.Keyfunc {
return func(t *jwt.Token) (any, error) {
var set jwkSet
if err := getJSON(ctx, jwksURL, &set); err != nil {
return nil, err
}
kid, _ := t.Header["kid"].(string)
if kid == "" {
if len(set.Keys) != 1 {
return nil, errors.New("federation: id_token has no kid and JWKS is not single-key")
}
return set.Keys[0].publicKey()
}
for _, k := range set.Keys {
if k.Kid == kid {
return k.publicKey()
}
}
return nil, errors.New("federation: no JWKS key matches the id_token kid")
}
}
// publicKey materializes a JWK into a crypto public key (RSA or EC). Only the
// two families iam2 signs with are supported; any other key type is refused.
func (k jwk) publicKey() (any, error) {
switch k.Kty {
case "RSA":
n, err := b64uBigInt(k.N)
if err != nil {
return nil, err
}
eb, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.E, "="))
if err != nil {
return nil, errors.New("federation: bad JWKS RSA exponent")
}
e := 0
for _, b := range eb {
e = e<<8 | int(b)
}
if e == 0 {
return nil, errors.New("federation: zero JWKS RSA exponent")
}
return &rsa.PublicKey{N: n, E: e}, nil
case "EC":
curve, err := ecCurve(k.Crv)
if err != nil {
return nil, err
}
x, err := b64uBigInt(k.X)
if err != nil {
return nil, err
}
y, err := b64uBigInt(k.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
default:
return nil, fmt.Errorf("federation: unsupported JWKS key type %q", k.Kty)
}
}
// ecCurve maps a JWK curve name to its elliptic.Curve.
func ecCurve(crv string) (elliptic.Curve, error) {
switch crv {
case "P-256":
return elliptic.P256(), nil
case "P-384":
return elliptic.P384(), nil
case "P-521":
return elliptic.P521(), nil
default:
return nil, fmt.Errorf("federation: unsupported JWKS curve %q", crv)
}
}
// b64uBigInt decodes a base64url (unpadded) big-endian integer — the JWK
// encoding for RSA modulus/exponent and EC coordinates.
func b64uBigInt(s string) (*big.Int, error) {
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "="))
if err != nil {
return nil, errors.New("federation: bad JWKS integer encoding")
}
return new(big.Int).SetBytes(b), nil
}
// --- small shared helpers ---
// providerScopes returns the provider's configured scopes, or a dialect default
// when it configures none.
func providerScopes(p *schema.Provider, fallback string) string {
if s := strings.TrimSpace(p.Scopes); s != "" {
return s
}
return fallback
}
// ensureOpenID guarantees the space-delimited scope contains "openid" (the OIDC
// requirement for an id_token), prepending it when absent.
func ensureOpenID(scope string) string {
for _, s := range strings.Fields(scope) {
if s == "openid" {
return scope
}
}
return strings.TrimSpace("openid " + scope)
}
// joinQuery appends encoded query values to a base URL, honoring an existing
// query string.
func joinQuery(base string, v url.Values) string {
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
return base + sep + v.Encode()
}
// firstNonEmpty returns the first non-blank string.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// truthy interprets an id_token email_verified value (bool or string form).
func truthy(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(t, "true")
default:
return false
}
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"encoding/json"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/store"
)
// The second-factor RESUME for a federated login. When the federation callback
// resolves a user who owes a factor, it mints nothing — it parks the resume in a
// single-use, expiring, subject-pinned LoginChallenge (KindFederation) and sends
// the browser to the hosted 2FA page. This endpoint is where that page posts the
// factor: it verifies it through the SAME factor seam the password MFA gate uses
// and, only then, mints the code for the PINNED authorize request.
//
// It closes the hole a live MFA gate would otherwise leave open: without it, a
// 2FA-enrolled user signing in through Google/GitHub would skip the second factor
// a password login demands.
// PathFederationMfa is the resume endpoint. Public (before the Guard): it
// self-authenticates through the challenge the callback set — a single-use,
// expiring, subject-pinned token — exactly as the callback self-authenticates via
// its state. The challenge id rides the httpOnly, SameSite=Lax cookie, so a
// cross-site POST carries no challenge and fails closed.
const PathFederationMfa = "/v1/iam/oauth/federation/mfa"
// routeFederationMfa registers the resume endpoint on the PUBLIC group r.
func routeFederationMfa(r zip.Router, db orm.DB) {
r.Post(PathFederationMfa, federationMfaHandler(db))
}
// fedMfaForm is the resume body. It carries the FACTOR and nothing else — no user
// id and no redirect_uri, BY DESIGN: the target user and the whole authorize
// request are pinned in the challenge (Subject + Payload), so there is
// structurally no request field that could swap them mid-flow.
type fedMfaForm struct {
Challenge string `json:"challenge"`
MfaType string `json:"mfaType"`
Passcode string `json:"passcode"`
RecoveryCode string `json:"recoveryCode"`
}
// federationMfaHandler finishes a parked federated login: it spends the challenge,
// loads the PINNED user from its subject (never the request), verifies the second
// factor through the shared factor seam, and only then mints the code for the
// pinned authorize request. Fail-closed at every step; taking the challenge spends
// it, so a wrong factor burns it and a fresh federation is required to retry —
// exactly as the password gate behaves.
func federationMfaHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var f fedMfaForm
if err := c.Bind(&f); err != nil {
return httpx.Err(c, "invalid request body")
}
ctx := c.Context()
ch, err := TakeChallenge(ctx, db, ReadChallenge(c, f.Challenge), KindFederation, nowFunc())
if err != nil {
return httpx.Err(c, err.Error())
}
ClearChallenge(c)
owner, name, _ := strings.Cut(ch.Subject, "/")
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return httpx.Err(c, err.Error())
}
if user == nil || user.IsForbidden || user.IsDeleted {
return httpx.Err(c, ErrChallenge.Error())
}
// Verify the factor — the ONE factor seam, shared with the password gate.
switch {
case f.Passcode != "":
if f.MfaType != factor.App {
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 != "":
if !factor.UseRecovery(user, f.RecoveryCode) {
return httpx.Err(c, "the recovery code is incorrect")
}
if err := factor.Save(ctx, db, user); err != nil {
return httpx.Err(c, err.Error())
}
default:
return httpx.Err(c, "missing passcode or recovery code")
}
// Resume the ORIGINAL authorize request from the PINNED payload — never from
// the request. Re-resolve the app and re-validate its redirect_uri against the
// live allow-list (defense in depth against a tampered row).
var p fedResumeParams
if err := json.Unmarshal([]byte(ch.Payload), &p); err != nil {
return httpx.Err(c, "internal error")
}
app, err := store.GetApplicationByClientId(ctx, db, p.ClientId)
if err != nil || app == nil {
return httpx.Err(c, "the client application is unavailable")
}
if !app.IsRedirectUriValid(p.RedirectUri) {
return httpx.Err(c, "invalid redirect_uri")
}
loc, err := federationMint(ctx, db, app, user, p, nowFunc())
if err != nil {
if err == errPKCERequired {
return httpx.Err(c, "PKCE is required for public clients")
}
return httpx.Err(c, "internal error")
}
// The SPA navigates the browser to this RP redirect (redirect_uri?code&state).
return httpx.Ok(c, loc)
}
}
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"encoding/json"
"net/url"
"strings"
"testing"
"time"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/mfa/factor"
)
// The federated-login second-factor gate, driven through the REAL mounted routes
// and the same httptest mock IdP the federation suite uses. The contract that
// matters is a store fact: an MFA-enrolled user who signs in through an external
// IdP gets NO authorization code until the factor lands — the hole a live MFA gate
// would otherwise leave open.
// fedEnroll seeds a user (in org hanzo) with the given email AND a live TOTP
// factor, returning the secret. The email must match the mock IdP so the callback
// links to this account by verified email.
func fedEnroll(t *testing.T, db orm.DB, name, email string) string {
t.Helper()
seedUser(t, db, name, email, "pw")
secret, _, err := factor.Enroll("hanzo/"+name, "Hanzo")
if err != nil {
t.Fatal(err)
}
u := userRow(t, db, name)
u.TotpSecret = secret
u.PreferredMfaType = factor.App
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
return secret
}
// (a) An MFA-enrolled user signing in through federation is CHALLENGED, not minted:
// the callback sends the browser to the 2FA page, sets the challenge cookie, and
// persists no token. Presenting the factor then mints the code for that user.
func TestFederationMfa_EnrolledUserChallengedThenResumes(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
secret := fedEnroll(t, db, "alice", "alice@example.com")
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := resp.Header.Get("Location")
if resp.StatusCode != 302 {
t.Fatalf("callback status = %d, want 302", resp.StatusCode)
}
// The load-bearing negative: the callback must NOT have minted a code to the RP.
if strings.HasPrefix(loc, testRedirect) {
t.Fatalf("PASSWORD-FREE MFA BYPASS: federation minted a code for a 2FA user without the factor: %q", loc)
}
if !strings.HasSuffix(loc, PathMfaVerify) {
t.Fatalf("a 2FA-enrolled federated login must go to the 2FA page, got %q", loc)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted before the second factor", n)
}
id := challengeOf(t, resp)
// Present the factor → the code is minted and the RP redirect returned.
req := jsonReq("POST", PathFederationMfa, map[string]string{"mfaType": factor.App, "passcode": passcode(t, secret)})
req.Header.Set("Cookie", challengeCookie+"="+id)
_, body := do(t, app, req)
mm := decode(t, body)
if mm["status"] != "ok" {
t.Fatalf("resume with a valid factor failed: %v", mm["msg"])
}
rurl, _ := mm["data"].(string)
if !strings.HasPrefix(rurl, testRedirect) {
t.Fatalf("resume must return the RP redirect, got %q", rurl)
}
cb, _ := url.Parse(rurl)
code := cb.Query().Get("code")
if code == "" {
t.Fatal("resume returned no authorization code")
}
if cb.Query().Get("state") != fedAppState {
t.Errorf("app state not echoed on resume: %q", cb.Query().Get("state"))
}
tok, err := store2GetTokenByCode(db, code)
if err != nil || tok == nil {
t.Fatalf("minted code resolves to no token: %v", err)
}
if tok.User != "hanzo/alice" {
t.Fatalf("code bound to %q, want hanzo/alice", tok.User)
}
}
// A recovery code answers the federated challenge too, and is consumed once.
func TestFederationMfa_RecoveryCodeResumes(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", secret: "s3cret", redirectURIs: []string{testRedirect}})
fedEnroll(t, db, "alice", "alice@example.com")
plain, err := factor.MintRecovery()
if err != nil {
t.Fatal(err)
}
hash, err := factor.HashRecovery(plain)
if err != nil {
t.Fatal(err)
}
u := userRow(t, db, "alice")
u.RecoveryCodes = []string{hash}
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
p := fedResumeParams{ClientId: "webapp", RedirectUri: testRedirect, AppState: fedAppState, Scope: "openid"}
payload, _ := json.Marshal(p)
id, err := MintChallenge(context.Background(), db, KindFederation, "hanzo/alice", string(payload), time.Now())
if err != nil {
t.Fatal(err)
}
req := jsonReq("POST", PathFederationMfa, map[string]string{"recoveryCode": plain})
req.Header.Set("Cookie", challengeCookie+"="+id)
_, body := do(t, app, req)
if m := decode(t, body); m["status"] != "ok" {
t.Fatalf("recovery-code resume failed: %v", m["msg"])
}
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
t.Fatalf("recovery code not consumed: %v", got)
}
}
// (b) A user with NO factor flows straight through federation exactly as before —
// the gate is invisible to everyone else.
func TestFederationMfa_UnenrolledUserFlowsThrough(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
seedUser(t, db, "bob", "alice@example.com", "pw") // matches the mock email, NO factor
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
cb, _ := url.Parse(loc)
if cb.Query().Get("code") == "" {
t.Fatalf("an unenrolled federated login must mint a code directly, got %q", loc)
}
}
// (c) The federation challenge is single-use and expiring.
func TestFederationMfa_ChallengeSingleUseAndExpiring(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", secret: "s3cret", redirectURIs: []string{testRedirect}})
secret := fedEnroll(t, db, "alice", "alice@example.com")
mk := func(now time.Time) string {
p := fedResumeParams{ClientId: "webapp", RedirectUri: testRedirect, AppState: fedAppState, Scope: "openid"}
payload, _ := json.Marshal(p)
id, err := MintChallenge(context.Background(), db, KindFederation, "hanzo/alice", string(payload), now)
if err != nil {
t.Fatal(err)
}
return id
}
resume := func(id string) map[string]any {
req := jsonReq("POST", PathFederationMfa, map[string]string{"mfaType": factor.App, "passcode": passcode(t, secret)})
req.Header.Set("Cookie", challengeCookie+"="+id)
_, body := do(t, app, req)
return decode(t, body)
}
// single-use: first spends, second is refused.
id := mk(time.Now())
if m := resume(id); m["status"] != "ok" {
t.Fatalf("first resume failed: %v", m["msg"])
}
if m := resume(id); m["status"] != "error" {
t.Fatalf("a spent federation challenge was accepted again: %v", m)
}
// expiring: a challenge past its TTL is refused before any factor is checked.
start := time.Unix(1_800_000_000, 0)
nowFuncSet(t, start)
id2 := mk(start)
nowFuncSet(t, start.Add(challengeTTL+time.Second))
if m := resume(id2); m["status"] != "error" {
t.Fatalf("an expired federation challenge was accepted: %v", m)
}
}
// (d) The target user and redirect_uri are PINNED in the challenge: the resume
// body has no field that can swap them, and extra request fields are ignored.
func TestFederationMfa_UserAndRedirectPinned(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedApp(t, db, appOpts{clientID: "evil", secret: "x", redirectURIs: []string{"https://evil.example/cb"}})
secret := fedEnroll(t, db, "alice", "alice@example.com")
seedUser(t, db, "mallory", "mallory@example.com", "pw")
p := fedResumeParams{ClientId: "webapp", RedirectUri: testRedirect, AppState: fedAppState, Scope: "openid"}
payload, _ := json.Marshal(p)
id, err := MintChallenge(context.Background(), db, KindFederation, "hanzo/alice", string(payload), time.Now())
if err != nil {
t.Fatal(err)
}
// The body tries to steer the ceremony at another user, app, and redirect.
req := jsonReq("POST", PathFederationMfa, map[string]string{
"mfaType": factor.App, "passcode": passcode(t, secret),
"username": "mallory", "name": "mallory", "clientId": "evil", "redirectUri": "https://evil.example/cb",
})
req.Header.Set("Cookie", challengeCookie+"="+id)
_, body := do(t, app, req)
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("resume failed: %v", m["msg"])
}
rurl, _ := m["data"].(string)
if !strings.HasPrefix(rurl, testRedirect) {
t.Fatalf("redirect_uri not pinned — the body steered it to %q", rurl)
}
cb, _ := url.Parse(rurl)
tok, err := store2GetTokenByCode(db, cb.Query().Get("code"))
if err != nil || tok == nil {
t.Fatalf("no token for the minted code: %v", err)
}
if tok.User != "hanzo/alice" {
t.Fatalf("target user not pinned — code bound to %q", tok.User)
}
if tok.RedirectUri != testRedirect {
t.Fatalf("redirect_uri not pinned on the code: %q", tok.RedirectUri)
}
}
// A missing/forged challenge fails closed — no user, no code.
func TestFederationMfa_NoChallengeFailsClosed(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
req := jsonReq("POST", PathFederationMfa, map[string]string{"mfaType": factor.App, "passcode": "000000"})
_, body := do(t, app, req) // no cookie, no body challenge
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("a resume with no challenge must fail closed, got %v", m)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token(s) minted with no challenge", n)
}
}
+925
View File
@@ -0,0 +1,925 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Federation is driven through the REAL mounted routes (authorize → IdP → callback
// → code → token). The external IdP is an httptest server — a real HTTP RP round
// trip with a real OIDC discovery document, a real JWKS, and a real RS256-signed
// id_token whose signature/issuer/audience/nonce iam2 actually verifies (Google
// dialect), plus a real GitHub userinfo + verified-email exchange. No live
// Google/GitHub is contacted.
const (
fedAppState = "app-state-xyz"
fedVerifier = "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
fedGoogleCID = "google-oauth-client"
fedGitHubCID = "github-oauth-client"
fedProvGoogle = "provider-google"
fedProvGitHub = "provider-github"
)
// ---------------------------------------------------------------------------
// Mock OIDC IdP (Google-shaped): discovery + JWKS + RS256 id_token.
// ---------------------------------------------------------------------------
type mockOIDC struct {
*httptest.Server
key *rsa.PrivateKey
wrongKey *rsa.PrivateKey
kid string
clientID string
mu sync.Mutex
sub string
email string
name string
emailVerified bool
nonce string // baked into the id_token; wired from the authorize redirect
signWrong bool // sign with wrongKey → signature must fail
noneAlg bool // emit an alg=none (unsigned) id_token → must be rejected
issuerOverride string // override id_token iss (issuer-confusion test)
audOverride string // override id_token aud (audience test)
tokenForm url.Values
}
// allowPrivateFederationDial relaxes the SSRF dial guard for the test's duration
// so the httptest mock IdPs (bound to 127.0.0.1) are reachable — the same
// package-var test-injection pattern as nowFuncSet. Production never flips it.
func allowPrivateFederationDial(t *testing.T) {
t.Helper()
prev := federationDialAllowsPrivate
federationDialAllowsPrivate = true
t.Cleanup(func() { federationDialAllowsPrivate = prev })
}
func newMockOIDC(t *testing.T, clientID string) *mockOIDC {
t.Helper()
allowPrivateFederationDial(t)
m := &mockOIDC{
key: mustGenRSA(t),
wrongKey: mustGenRSA(t),
kid: "mock-oidc-kid",
clientID: clientID,
sub: "google-sub-1001",
email: "alice@example.com",
name: "Alice Example",
emailVerified: true,
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{
"issuer": m.URL,
"authorization_endpoint": m.URL + "/authorize",
"token_endpoint": m.URL + "/token",
"userinfo_endpoint": m.URL + "/userinfo",
"jwks_uri": m.URL + "/jwks",
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
pub := m.key.PublicKey
writeJSON(w, map[string]any{"keys": []map[string]any{{
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": m.kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
m.mu.Lock()
m.tokenForm = r.Form
iss := firstNonEmpty(m.issuerOverride, m.URL)
aud := firstNonEmpty(m.audOverride, m.clientID)
claims := jwt.MapClaims{
"iss": iss, "sub": m.sub, "aud": aud,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Add(-time.Minute).Unix(),
"nonce": m.nonce,
"email": m.email,
"email_verified": m.emailVerified,
"name": m.name,
}
signKey := m.key
if m.signWrong {
signKey = m.wrongKey
}
noneAlg := m.noneAlg
m.mu.Unlock()
// The alg=none forgery: an unsigned token whose header claims no signature.
if noneAlg {
tok := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
idt, _ := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
writeJSON(w, map[string]any{"access_token": "x", "id_token": idt, "token_type": "Bearer"})
return
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = m.kid
idt, err := tok.SignedString(signKey)
if err != nil {
http.Error(w, "sign", 500)
return
}
writeJSON(w, map[string]any{"access_token": "idp-at-" + randHex(6), "id_token": idt, "token_type": "Bearer"})
})
m.Server = httptest.NewServer(mux)
t.Cleanup(m.Close)
return m
}
// ---------------------------------------------------------------------------
// Mock GitHub IdP (OAuth2 + userinfo + verified emails).
// ---------------------------------------------------------------------------
type mockGitHub struct {
*httptest.Server
mu sync.Mutex
id int64
login string
name string
profileEmail string
emails []map[string]any // {email, primary, verified}
tokenForm url.Values
}
func newMockGitHub(t *testing.T) *mockGitHub {
t.Helper()
allowPrivateFederationDial(t)
m := &mockGitHub{
id: 424242,
login: "octocat",
name: "The Octocat",
emails: []map[string]any{
{"email": "octo-unverified@example.com", "primary": false, "verified": false},
{"email": "octo@example.com", "primary": true, "verified": true},
},
}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
m.mu.Lock()
m.tokenForm = r.Form
m.mu.Unlock()
writeJSON(w, map[string]any{"access_token": "gho-" + randHex(6), "token_type": "bearer", "scope": "read:user,user:email"})
})
mux.HandleFunc("/user/emails", func(w http.ResponseWriter, _ *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
writeJSON(w, m.emails)
})
mux.HandleFunc("/user", func(w http.ResponseWriter, _ *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
writeJSON(w, map[string]any{"id": m.id, "login": m.login, "name": m.name, "email": m.profileEmail, "avatar_url": "https://avatars/x"})
})
m.Server = httptest.NewServer(mux)
t.Cleanup(m.Close)
return m
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// ---------------------------------------------------------------------------
// Seeds + drivers.
// ---------------------------------------------------------------------------
// seedOIDCProvider seeds a Google-dialect Provider row whose OIDC issuer points
// at the mock, and links it (sign-in enabled) onto the app.
func seedOIDCProvider(t *testing.T, db orm.DB, appClientID string, m *mockOIDC) {
t.Helper()
p := orm.New[schema.Provider](db)
p.Owner, p.Name = "admin", fedProvGoogle
p.Category, p.Type = "OAuth", "Google"
p.ClientId, p.ClientSecret = m.clientID, "google-secret-do-not-log"
p.IssuerUrl = m.URL
p.SetId("admin/" + fedProvGoogle)
if err := p.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed google provider: %v", err)
}
linkProvider(t, db, appClientID, fedProvGoogle)
}
// seedGitHubProvider seeds a GitHub-dialect Provider row whose endpoints point at
// the mock, and links it onto the app.
func seedGitHubProvider(t *testing.T, db orm.DB, appClientID string, m *mockGitHub) {
t.Helper()
p := orm.New[schema.Provider](db)
p.Owner, p.Name = "admin", fedProvGitHub
p.Category, p.Type = "OAuth", "GitHub"
p.ClientId, p.ClientSecret = fedGitHubCID, "github-secret-do-not-log"
p.CustomAuthUrl = m.URL + "/authorize"
p.CustomTokenUrl = m.URL + "/token"
p.CustomUserInfoUrl = m.URL + "/user"
p.SetId("admin/" + fedProvGitHub)
if err := p.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed github provider: %v", err)
}
linkProvider(t, db, appClientID, fedProvGitHub)
}
// linkProvider appends a sign-in-enabled ProviderItem to an app and persists it.
func linkProvider(t *testing.T, db orm.DB, appClientID, providerName string) {
t.Helper()
a, err := orm.Get[schema.Application](db, "admin/"+appClientID)
if err != nil {
t.Fatalf("load app: %v", err)
}
a.Providers = append(a.Providers, &schema.ProviderItem{Owner: "admin", Name: providerName, CanSignIn: true, CanSignUp: true})
if err := a.UpdateCtx(context.Background()); err != nil {
t.Fatalf("link provider: %v", err)
}
}
// beginAuthorize drives GET /v1/iam/oauth/authorize with a provider hint and
// returns the IdP-authorize query (from the 302 Location) and the anti-forgery
// cookie the response set. It asserts the request is a 302 to an IdP.
func beginAuthorize(t *testing.T, app *zip.App, clientID, provider string) (url.Values, string) {
t.Helper()
q := url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {testRedirect},
"scope": {"openid email profile"},
"state": {fedAppState},
"code_challenge": {ComputeS256Challenge(fedVerifier)},
"code_challenge_method": {"S256"},
"provider": {provider},
}
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
if resp.StatusCode != 302 {
t.Fatalf("authorize(provider) status = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("parse IdP authorize location: %v", err)
}
// A federation kickoff redirects to an ABSOLUTE external IdP URL, never to the
// relative hosted-login path a credential flow uses.
if !loc.IsAbs() || loc.Host == "" {
t.Fatalf("authorize(provider) must redirect to an external IdP; got %q", loc.String())
}
return loc.Query(), cookieKV(resp.Header.Get("Set-Cookie"))
}
// callback drives GET /v1/iam/oauth/callback with the given state/code and the
// anti-forgery cookie.
func callback(t *testing.T, app *zip.App, state, code, cookie string) *http.Response {
t.Helper()
q := url.Values{"state": {state}, "code": {code}}
req := formReqNoBody("GET", PathFederationCallback+"?"+q.Encode())
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, _ := do(t, app, req)
return resp
}
// countUsers returns the number of users in the hanzo org.
func countUsers(t *testing.T, db orm.DB) int {
t.Helper()
n, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Count(context.Background())
if err != nil {
t.Fatalf("count users: %v", err)
}
return n
}
// ---------------------------------------------------------------------------
// Tests.
// ---------------------------------------------------------------------------
// The authorize endpoint, given a provider hint, redirects the browser to the
// external IdP with response_type=code, our callback, a single-use state, S256
// PKCE, and (OIDC) a nonce — and sets the HttpOnly browser-binding cookie. The
// client_secret is NEVER on this browser-facing redirect.
func TestFederation_AuthorizeRedirectsToOIDCWithStatePKCENonce(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
if q.Get("response_type") != "code" {
t.Errorf("response_type = %q", q.Get("response_type"))
}
if q.Get("client_id") != fedGoogleCID {
t.Errorf("client_id = %q, want the provider's IdP client id", q.Get("client_id"))
}
if !strings.HasSuffix(q.Get("redirect_uri"), PathFederationCallback) {
t.Errorf("redirect_uri = %q, want our callback", q.Get("redirect_uri"))
}
if q.Get("state") == "" {
t.Error("state must be present (single-use CSRF token)")
}
if q.Get("nonce") == "" {
t.Error("OIDC nonce must be present")
}
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("IdP-leg PKCE missing: challenge=%q method=%q", q.Get("code_challenge"), q.Get("code_challenge_method"))
}
if cookie == "" || !strings.HasPrefix(cookie, fedCookieName+"=") {
t.Errorf("anti-forgery cookie missing: %q", cookie)
}
// The provider secret must never cross to the browser.
if strings.Contains(q.Encode(), "google-secret-do-not-log") {
t.Fatal("client_secret leaked into the browser-facing IdP redirect")
}
// State is server-side single-use.
if st, _ := store.GetFederationState(context.Background(), db, q.Get("state")); st == nil {
t.Fatal("federation state row was not persisted")
}
}
// GitHub authorize carries state (its CSRF defense) but no nonce (OAuth2, no
// id_token) and no PKCE by default.
func TestFederation_AuthorizeRedirectsToGitHub(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockGitHub(t)
seedGitHubProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGitHub)
if q.Get("state") == "" {
t.Error("GitHub authorize must carry state")
}
if q.Get("nonce") != "" {
t.Error("GitHub (OAuth2) must not carry an OIDC nonce")
}
if cookie == "" {
t.Error("anti-forgery cookie must be set")
}
}
// Full OIDC round-trip: a first-time login PROVISIONS a user (no password, not
// admin) and mints an iam2 authorization code; the relying party's existing PKCE
// code→token exchange then completes unchanged and carries the new user's sub.
func TestFederation_OIDCCallbackProvisionsUserAndIssuesCode(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
before := countUsers(t, db)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce") // wire the transaction's real IdP nonce into the id_token
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
cb, _ := url.Parse(loc)
code := cb.Query().Get("code")
if code == "" {
t.Fatalf("callback must redirect with an iam2 code; got %q", loc)
}
if cb.Query().Get("state") != fedAppState {
t.Errorf("app state not echoed: %q", cb.Query().Get("state"))
}
// A user was provisioned, linked by the Google subject, no password, no admin.
u, err := store.GetUserByConnector(context.Background(), db, "hanzo", "google", m.sub)
if err != nil || u == nil {
t.Fatalf("provisioned user not found by connector subject: %v", err)
}
if u.PasswordHash != "" {
t.Error("federated user must have NO password hash")
}
if u.IsAdmin {
t.Fatal("federation must NEVER set isAdmin")
}
if !u.EmailVerified || u.Email != m.email {
t.Errorf("verified email not carried: verified=%v email=%q", u.EmailVerified, u.Email)
}
if countUsers(t, db) != before+1 {
t.Fatalf("expected exactly one new user")
}
// The iam2 code redeems through the ordinary PKCE token exchange, unchanged.
tokResp, tok := exchangeCode(t, app, url.Values{
"code": {code}, "client_id": {"webapp"}, "redirect_uri": {testRedirect}, "code_verifier": {fedVerifier},
})
if tokResp.StatusCode != 200 {
t.Fatalf("iam2 code exchange failed: %d %v", tokResp.StatusCode, tok)
}
if tok["access_token"] == nil {
t.Fatal("no access_token from the iam2 code exchange")
}
// The token subject is the provisioned user; no IdP token leaks into it.
if body := tokenBody(tok); strings.Contains(body, "idp-at-") || strings.Contains(body, "google-secret-do-not-log") {
t.Fatal("IdP access token / client secret leaked into the iam2 token response")
}
}
// The GitHub dialect: token exchange + /user + /user/emails, provisioning by the
// primary VERIFIED email.
func TestFederation_GitHubCallbackProvisionsViaVerifiedEmail(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockGitHub(t)
seedGitHubProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGitHub)
resp := callback(t, app, q.Get("state"), "gh-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
if code := mustQuery(t, loc).Get("code"); code == "" {
t.Fatalf("GitHub federation did not mint an iam2 code: %q", loc)
}
u, err := store.GetUserByConnector(context.Background(), db, "hanzo", "github", "424242")
if err != nil || u == nil {
t.Fatalf("GitHub user not provisioned by subject: %v", err)
}
if u.Email != "octo@example.com" || !u.EmailVerified {
t.Errorf("expected the primary verified email; got %q verified=%v", u.Email, u.EmailVerified)
}
// The GitHub client secret only ever went to the token endpoint (server-side).
m.mu.Lock()
defer m.mu.Unlock()
if m.tokenForm.Get("client_secret") != "github-secret-do-not-log" {
t.Errorf("expected the secret at the token endpoint, form=%v", m.tokenForm)
}
}
// A returning federated user (same subject) is matched by subject — no duplicate
// account is created on the second login.
func TestFederation_ReloginBySubjectIsStable(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
runOIDCLogin(t, app, db, m, "webapp", nil)
after1 := countUsers(t, db)
runOIDCLogin(t, app, db, m, "webapp", nil)
if countUsers(t, db) != after1 {
t.Fatalf("second login by the same subject must not create a new user")
}
}
// A verified IdP email that matches an EXISTING local account LINKS to it (sets
// the connector column) instead of creating a duplicate.
func TestFederation_LinksExistingAccountByVerifiedEmail(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@example.com", "pw") // pre-existing password account, same email
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
before := countUsers(t, db)
runOIDCLogin(t, app, db, m, "webapp", nil)
if countUsers(t, db) != before {
t.Fatalf("verified-email login must link, not create a duplicate")
}
// The Google subject is now linked onto the pre-existing account.
linked, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
if linked == nil || linked.Google != m.sub {
t.Fatalf("connector subject not linked onto the existing account: %+v", linked)
}
}
// email_verified:false must NOT auto-link by email — it provisions a fresh
// account, so an unproven address can never take over an existing one.
func TestFederation_UnverifiedEmailDoesNotAutoLink(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
seedUser(t, db, "victim", "victim@example.com", "pw")
m := newMockOIDC(t, fedGoogleCID)
m.email = "victim@example.com"
m.emailVerified = false
m.sub = "attacker-sub-9"
seedOIDCProvider(t, db, "webapp", m)
before := countUsers(t, db)
runOIDCLogin(t, app, db, m, "webapp", nil)
// The victim account was NOT linked.
victim, _ := store.GetUserByName(context.Background(), db, "hanzo", "victim")
if victim == nil || victim.Google != "" {
t.Fatalf("unverified email must not link onto the victim account: %+v", victim)
}
// A fresh account was provisioned instead.
if countUsers(t, db) != before+1 {
t.Fatalf("expected a freshly provisioned account, not a takeover")
}
if u, _ := store.GetUserByConnector(context.Background(), db, "hanzo", "google", "attacker-sub-9"); u == nil {
t.Fatal("federated identity should have been provisioned onto its own account")
}
}
// An unknown/forged state is answered in place (no trusted redirect target) and
// never completes.
func TestFederation_UnknownStateRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
// A cookie alone cannot substitute for a real server-side state row.
resp := callback(t, app, "totally-made-up-state", "idp-code", fedCookieName+"=whatever")
if resp.StatusCode != 400 {
t.Fatalf("unknown state status = %d, want 400", resp.StatusCode)
}
if resp.Header.Get("Location") != "" {
t.Fatalf("unknown state must NOT redirect anywhere; got %q", resp.Header.Get("Location"))
}
}
// A consumed state cannot be replayed — the second callback mints nothing.
func TestFederation_ReplayedStateRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
first := callback(t, app, q.Get("state"), "idp-code-1", cookie)
requireRedirect(t, first, testRedirect) // success
replay := callback(t, app, q.Get("state"), "idp-code-1", cookie)
if replay.StatusCode != 400 || replay.Header.Get("Location") != "" {
t.Fatalf("replayed state must be refused in place; status=%d loc=%q", replay.StatusCode, replay.Header.Get("Location"))
}
}
// Without the browser-binding cookie the callback is refused (login-CSRF /
// session-fixation defense): a state injected into another browser cannot land.
func TestFederation_MissingOrWrongBindCookieRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
// No cookie.
noCookie := callback(t, app, q.Get("state"), "idp-code-1", "")
if noCookie.StatusCode != 400 || noCookie.Header.Get("Location") != "" {
t.Fatalf("callback without the bind cookie must be refused; status=%d", noCookie.StatusCode)
}
// Wrong cookie value.
wrong := callback(t, app, q.Get("state"), "idp-code-1", fedCookieName+"=not-the-secret")
if wrong.StatusCode != 400 || wrong.Header.Get("Location") != "" {
t.Fatalf("callback with a wrong bind cookie must be refused; status=%d", wrong.StatusCode)
}
// The state was not consumed by the failed attempts — the legit browser still works.
_ = cookie
}
// An id_token whose nonce does not match the transaction's nonce is rejected —
// the login does not complete and no account is created/linked.
func TestFederation_OIDCNonceMismatchRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
before := countUsers(t, db)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = "a-different-nonce-than-issued" // tamper: id_token nonce != state.IdpNonce
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
t.Fatalf("nonce mismatch must fail closed (error, no code); got %q", loc)
}
if countUsers(t, db) != before {
t.Fatal("a nonce-mismatched login must not provision an account")
}
}
// An id_token whose signature does not verify against the published JWKS is
// rejected — proving the signature check is real, not stubbed.
func TestFederation_OIDCBadSignatureRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
m.signWrong = true // sign with a key NOT in the JWKS
seedOIDCProvider(t, db, "webapp", m)
before := countUsers(t, db)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
t.Fatalf("bad signature must fail closed; got %q", loc)
}
if countUsers(t, db) != before {
t.Fatal("a signature-invalid login must not provision an account")
}
}
// An alg=none (unsigned) id_token is rejected — the signing method is pinned to
// the asymmetric set, so the classic JWT downgrade never authenticates anyone.
func TestFederation_OIDCAlgNoneRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
m.noneAlg = true
seedOIDCProvider(t, db, "webapp", m)
before := countUsers(t, db)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
t.Fatalf("alg=none must fail closed; got %q", loc)
}
if countUsers(t, db) != before {
t.Fatal("an unsigned id_token must not provision an account")
}
}
// An id_token minted for a different audience (not our client id) is rejected.
func TestFederation_OIDCWrongAudienceRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
m.audOverride = "some-other-client"
seedOIDCProvider(t, db, "webapp", m)
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
loc := requireRedirect(t, resp, testRedirect)
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
t.Fatalf("wrong audience must fail closed; got %q", loc)
}
}
// A non-allow-listed redirect_uri is refused at the authorize leg IN PLACE (never
// redirected), and no federation transaction is created for it.
func TestFederation_NonAllowlistedRedirectUriRefused(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
q := url.Values{
"response_type": {"code"}, "client_id": {"webapp"},
"redirect_uri": {"https://evil.example/steal"},
"code_challenge": {ComputeS256Challenge(fedVerifier)},
"provider": {fedProvGoogle},
}
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
if resp.StatusCode != 400 || resp.Header.Get("Location") != "" {
t.Fatalf("bad redirect_uri must be answered in place; status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
}
}
// runOIDCLogin drives a full successful OIDC federation login (authorize →
// callback) and asserts it lands an iam2 code. mutate may tweak the mock after
// the nonce is wired.
func runOIDCLogin(t *testing.T, app *zip.App, db orm.DB, m *mockOIDC, clientID string, mutate func()) {
t.Helper()
q, cookie := beginAuthorize(t, app, clientID, fedProvGoogle)
m.mu.Lock()
m.nonce = q.Get("nonce")
m.mu.Unlock()
if mutate != nil {
mutate()
}
resp := callback(t, app, q.Get("state"), "idp-code-"+randHex(3), cookie)
loc := requireRedirect(t, resp, testRedirect)
if mustQuery(t, loc).Get("code") == "" {
t.Fatalf("federation login did not mint an iam2 code: %q", loc)
}
}
func mustQuery(t *testing.T, loc string) url.Values {
t.Helper()
u, err := url.Parse(loc)
if err != nil {
t.Fatalf("parse location %q: %v", loc, err)
}
return u.Query()
}
// ---------------------------------------------------------------------------
// RED-TEAM PoCs — F1: federation must never mint a SuperAdmin or cross-tenant
// identity. These reproduce the reported exploits and assert they are REFUSED.
// ---------------------------------------------------------------------------
// seedFederationTarget seeds a (possibly malicious) app — appOwner/appClientID
// pointing its Organization at `serves` — linked to a Google-dialect provider
// (owned by provOwner) whose OIDC issuer is the mock IdP.
func seedFederationTarget(t *testing.T, db orm.DB, appClientID, appOwner, serves, provOwner string, m *mockOIDC) {
t.Helper()
ctx := context.Background()
p := orm.New[schema.Provider](db)
p.Owner, p.Name = provOwner, fedProvGoogle
p.Category, p.Type = "OAuth", "Google"
p.ClientId, p.ClientSecret = m.clientID, "secret-do-not-log"
p.IssuerUrl = m.URL
p.SetId(provOwner + "/" + fedProvGoogle)
if err := p.CreateCtx(ctx); err != nil {
t.Fatalf("seed provider: %v", err)
}
a := orm.New[schema.Application](db)
a.Owner, a.Name, a.ClientId = appOwner, appClientID, appClientID
a.Organization = serves
a.EnablePassword = true
a.ExpireInHours = 1
a.RedirectUris = []string{testRedirect}
a.Providers = []*schema.ProviderItem{{Owner: provOwner, Name: fedProvGoogle, CanSignIn: true}}
a.SetId(appOwner + "/" + appClientID)
if err := a.CreateCtx(ctx); err != nil {
t.Fatalf("seed app: %v", err)
}
}
func federationAuthorizeQuery(clientID string) url.Values {
return url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {testRedirect},
"code_challenge": {ComputeS256Challenge(fedVerifier)},
"code_challenge_method": {"S256"},
"state": {fedAppState},
"provider": {fedProvGoogle},
}
}
// assertFederationRefused asserts a federation kickoff was refused: bounced back
// to the relying party with an OAuth error, NEVER redirected to the IdP, NEVER a
// code.
func assertFederationRefused(t *testing.T, resp *http.Response, m *mockOIDC) {
t.Helper()
if resp.StatusCode != 302 {
t.Fatalf("want a 302 refusal redirect, got %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, testRedirect) {
t.Fatalf("refusal must redirect to the relying party, not the IdP: %q", loc)
}
if m != nil && strings.HasPrefix(loc, m.URL) {
t.Fatal("a refused federation must never reach the IdP")
}
q := mustQuery(t, loc)
if q.Get("error") == "" {
t.Fatalf("refusal must carry an OAuth error: %q", loc)
}
if q.Get("code") != "" {
t.Fatal("a refused federation must not mint a code")
}
}
func countUsersIn(t *testing.T, db orm.DB, org string) int {
t.Helper()
n, err := orm.TypedQuery[schema.User](db).Filter("Owner=", org).Count(context.Background())
if err != nil {
t.Fatalf("count users in %q: %v", org, err)
}
return n
}
// PoC 1 — an attacker-owned app whose Organization names the reserved admin org
// would provision User{Owner:"admin"} = SuperAdmin. Federation must refuse it.
func TestRedTeam_FederationMintsSuperAdmin(t *testing.T) {
app, db := newServer(t)
m := newMockOIDC(t, fedGoogleCID) // the attacker's OWN Google account
seedFederationTarget(t, db, "evil-app", "attackerorg", "admin", "admin", m)
beforeAdmin := countUsersIn(t, db, "admin")
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("evil-app").Encode()))
assertFederationRefused(t, resp, m)
if countUsersIn(t, db, "admin") != beforeAdmin {
t.Fatal("PoC: federation provisioned a user into the admin org (SuperAdmin mint)")
}
// Defense in depth: the innermost mint refuses this app directly too.
evil, _ := store.GetApplicationByClientId(tctx(), db, "evil-app")
prov, _ := store.GetProvider(tctx(), db, "admin", fedProvGoogle)
if _, err := linkOrProvision(tctx(), db, evil, prov, federatedIdentity{subject: "s1", email: "a@b.com", emailVerified: true}); err == nil {
t.Fatal("PoC: linkOrProvision minted an identity into the admin org")
}
}
// PoC 2 — an attacker-owned app whose Organization names a VICTIM tenant, driven
// by a tenant-owned IdP that asserts the victim's verified email, would link the
// attacker's identity onto the victim's account. Federation must refuse it.
func TestRedTeam_FederationCrossTenantTakeover(t *testing.T) {
app, db := newServer(t)
seedUserInOrg(t, db, "victimorg", "ceo", "ceo@victim.com", "pw")
m := newMockOIDC(t, fedGoogleCID) // attacker's tenant-owned IdP...
m.email = "ceo@victim.com" // ...asserting the victim's email, "verified"
m.emailVerified = true
m.sub = "attacker-controlled-sub"
seedFederationTarget(t, db, "evil-app", "attackerorg", "victimorg", "attackerorg", m)
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("evil-app").Encode()))
assertFederationRefused(t, resp, m)
victim, _ := store.GetUserByName(tctx(), db, "victimorg", "ceo")
if victim == nil || victim.Google != "" {
t.Fatalf("PoC: cross-tenant identity linked onto the victim account: %+v", victim)
}
}
// PoC 3 — the fully tenant-owned variant: the attacker's OWN app AND OWN provider
// (no platform resource referenced) still cannot point Organization at admin.
func TestRedTeam_FederationMintsSuperAdmin_TenantOwnedApp(t *testing.T) {
app, db := newServer(t)
m := newMockOIDC(t, fedGoogleCID)
seedFederationTarget(t, db, "evil-app", "attackerorg", "admin", "attackerorg", m)
beforeAdmin := countUsersIn(t, db, "admin")
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("evil-app").Encode()))
assertFederationRefused(t, resp, m)
if countUsersIn(t, db, "admin") != beforeAdmin {
t.Fatal("PoC: a tenant-owned app federated a user into the admin org")
}
}
// The legitimate case still works: a platform app (admin-owned) serving a real
// tenant federates fine — proving the guard refuses only the escalation, not the
// happy path.
func TestFederation_PlatformAppLegitimateOrgAllowed(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}}) // Owner=admin, Org=hanzo
m := newMockOIDC(t, fedGoogleCID)
seedOIDCProvider(t, db, "webapp", m)
runOIDCLogin(t, app, db, m, "webapp", nil)
if u, _ := store.GetUserByConnector(tctx(), db, "hanzo", "google", m.sub); u == nil {
t.Fatal("a legitimate platform-app federation must still provision a user")
}
}
// F2 — SSRF: an org-admin-writable IssuerUrl pointing at the cloud-metadata
// endpoint must be refused at DIAL time (the guard is armed; no private-dial seam
// here), so federation fails closed to the relying party and never fetches it.
func TestFederation_SSRFPrivateIssuerRefused(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
p := orm.New[schema.Provider](db)
p.Owner, p.Name = "admin", fedProvGoogle
p.Category, p.Type = "OAuth", "Google"
p.ClientId, p.ClientSecret = "cid", "secret"
p.IssuerUrl = "https://169.254.169.254" // link-local cloud metadata over TLS
p.SetId("admin/" + fedProvGoogle)
if err := p.CreateCtx(tctx()); err != nil {
t.Fatalf("seed provider: %v", err)
}
linkProvider(t, db, "webapp", fedProvGoogle)
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("webapp").Encode()))
if resp.StatusCode != 302 {
t.Fatalf("want 302, got %d", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, testRedirect) || mustQuery(t, loc).Get("error") == "" {
t.Fatalf("SSRF to metadata must fail closed to the RP with an error: %q", loc)
}
if strings.Contains(loc, "169.254") {
t.Fatal("must not redirect the browser to the metadata endpoint")
}
}
func tokenBody(tok map[string]any) string {
b, _ := json.Marshal(tok)
return string(b)
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// PathUnlink removes a federated link from an account: POST /v1/iam/unlink. It is
// the inverse of the linkOrProvision law (federation.go) — and the account is only
// ever LEFT unlinked, never re-linked here, so re-linking still runs the full
// verified-subject / verified-email law.
const PathUnlink = "/v1/iam/unlink"
// routeUnlink registers POST /v1/iam/unlink on the PUBLIC group. It is not
// anonymous — it SELF-AUTHENTICATES through callerOf (session cookie, else a
// verified bearer), exactly as get-account and userinfo do, because an oidc
// handler cannot import authz (authz imports oidc). A caller callerOf cannot
// resolve is refused.
func routeUnlink(r zip.Router, db orm.DB) {
r.Post(PathUnlink, unlink(db))
}
// unlinkForm is the request body, matching v1's shape.
type unlinkForm struct {
ProviderType string `json:"providerType"`
User struct {
Owner string `json:"owner"`
Name string `json:"name"`
} `json:"user"`
}
// unlink clears one provider link from one account. Two principals may do it, and
// only two: the account holder itself, and a SuperAdmin (a member of the reserved
// admin org, the one predicate). An ORG ADMIN deliberately may NOT — unlinking is
// not tenant administration, it is unpicking someone's own sign-in method, so the
// generic org-admin rule is the wrong answer here.
//
// A holder unlinking itself must also be permitted by the application — the
// provider link's CanUnlink flag — so an organization that mandates federated
// sign-in cannot have its users strand themselves. A SuperAdmin is not bound by
// that flag; it is the platform's own recovery path. Fail-closed throughout.
func unlink(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var f unlinkForm
if err := c.Bind(&f); err != nil {
return httpx.Err(c, "invalid request body")
}
ctx := c.Context()
caller, name, ok := callerOf(ctx, c, db)
if !ok {
return httpx.Err(c, "Please login first")
}
self := caller == f.User.Owner && name == f.User.Name
super := store.IsSuperAdmin(caller)
if !self && !super {
return httpx.Err(c, "you are not permitted to unlink another user's account")
}
u, err := store.GetUserByName(ctx, db, f.User.Owner, f.User.Name)
if err != nil {
return httpx.Err(c, err.Error())
}
if u == nil {
return httpx.Err(c, "the user does not exist")
}
// Read/write the link through the ONE connector registry (federation.go),
// never by reflecting the provider type onto a Go field name — the exact
// class of bug that made v1's GitLab unlink silently no-op (the type
// "GitLab" vs the column `Gitlab`).
b, known := connectorFor(f.ProviderType)
if !known {
return httpx.Err(c, "the provider type "+f.ProviderType+" can't be unlinked")
}
if *b.ref(u) == "" {
return httpx.Err(c, "please link first")
}
if self && !super && !canUnlink(ctx, db, u, f.ProviderType) {
return httpx.Err(c, "this provider can't be unlinked")
}
*b.ref(u) = ""
if err := saveUser(ctx, db, u); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, nil)
}
}
// canUnlink reports whether the account's own sign-up application permits
// unlinking this provider. An account whose application is gone, or which has no
// link of that type declared, cannot self-unlink — the same fail-closed answer v1
// gives.
func canUnlink(ctx context.Context, db orm.DB, u *schema.User, providerType string) bool {
app, err := store.GetApplicationByName(ctx, db, "admin", u.SignupApplication)
if err != nil || app == nil {
return false
}
store.EnrichProviders(ctx, db, app)
for _, it := range app.Providers {
if it != nil && it.Provider != nil && it.Provider.Type == providerType {
return it.CanUnlink
}
}
return false
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"testing"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// Unlink self-authenticates (session cookie / bearer) on the public OIDC surface,
// so the harness only needs the one mounted group — the same one the confidential
// flow mints the bearer from.
func newUnlinkServer(t *testing.T) (*zip.App, orm.DB) {
t.Helper()
db := openTestDB(t)
app := zip.New(zip.Config{AppName: "iam2-unlink-test", DisableStartupMessage: true})
Route(app.Group(""), db) // public: authorize/login/token AND the self-authenticating unlink
return app, db
}
// linkGitHub declares a GitHub provider on the "conf" app (CanUnlink toggled) and
// stamps a GitHub subject onto the user, whose SignupApplication is that app.
func linkGitHub(t *testing.T, db orm.DB, user, subject string, canUnlink bool) {
t.Helper()
pv := orm.New[schema.Provider](db)
pv.Owner, pv.Name, pv.Category, pv.Type = "admin", "prov-github-unlink", "OAuth", "GitHub"
pv.SetId("admin/prov-github-unlink")
// Idempotent across sub-tests sharing a db is not needed (each test opens its own).
if err := pv.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed provider: %v", err)
}
a, err := orm.Get[schema.Application](db, "admin/conf")
if err != nil {
t.Fatalf("load conf app: %v", err)
}
a.Providers = append(a.Providers, &schema.ProviderItem{Owner: "admin", Name: "prov-github-unlink", CanSignIn: true, CanUnlink: canUnlink})
if err := a.UpdateCtx(context.Background()); err != nil {
t.Fatalf("link provider: %v", err)
}
u := userRow(t, db, user)
u.GitHub = subject
u.SignupApplication = "conf"
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatalf("stamp connector: %v", err)
}
}
func doUnlink(t *testing.T, app *zip.App, bearer, providerType, owner, name string) (int, map[string]any) {
t.Helper()
req := jsonReq("POST", PathUnlink, map[string]any{
"providerType": providerType,
"user": map[string]string{"owner": owner, "name": name},
})
req.Header.Set("Authorization", "Bearer "+bearer)
resp, body := do(t, app, req)
return resp.StatusCode, decode(t, body)
}
// The account holder unlinks its own GitHub link when the app permits it; the
// connector column is cleared.
func TestUnlink_SelfClearsLinkWhenPermitted(t *testing.T) {
app, db := newUnlinkServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
linkGitHub(t, db, "alice", "gh-alice", true)
access := accessTokenFor(t, app, "openid") // bearer for hanzo/alice
if _, m := doUnlink(t, app, access, "GitHub", "hanzo", "alice"); m["status"] != "ok" {
t.Fatalf("self-unlink failed: %v", m["msg"])
}
if got := userRow(t, db, "alice").GitHub; got != "" {
t.Fatalf("self-unlink did not clear the connector, got %q", got)
}
}
// A holder cannot unlink ANOTHER account (not self, not super), and the target's
// link survives.
func TestUnlink_CrossUserRefused(t *testing.T) {
app, db := newUnlinkServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
linkGitHub(t, db, "bob", "gh-bob", true)
access := accessTokenFor(t, app, "openid") // bearer for hanzo/alice
status, m := doUnlink(t, app, access, "GitHub", "hanzo", "bob")
if m["status"] != "error" {
t.Fatalf("cross-user unlink must be refused, got %v (status %d)", m, status)
}
if got := userRow(t, db, "bob").GitHub; got != "gh-bob" {
t.Fatalf("a non-owner removed bob's link: %q", got)
}
}
// When the application forbids unlinking (CanUnlink=false), a self-unlink is
// refused — an org that mandates federated sign-in keeps its users linked.
func TestUnlink_SelfRefusedWhenAppForbids(t *testing.T) {
app, db := newUnlinkServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
linkGitHub(t, db, "alice", "gh-alice", false)
access := accessTokenFor(t, app, "openid")
if _, m := doUnlink(t, app, access, "GitHub", "hanzo", "alice"); m["status"] != "error" {
t.Fatalf("self-unlink must be refused when the app forbids it, got %v", m)
}
if got := userRow(t, db, "alice").GitHub; got != "gh-alice" {
t.Fatalf("a forbidden unlink still cleared the connector: %q", got)
}
}
// An unauthenticated request is refused (the SDK envelope carries status:error on
// a 200, the casibase contract) and clears nothing.
func TestUnlink_RequiresAuthentication(t *testing.T) {
app, db := newUnlinkServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
linkGitHub(t, db, "alice", "gh-alice", true)
req := jsonReq("POST", PathUnlink, map[string]any{"providerType": "GitHub", "user": map[string]string{"owner": "hanzo", "name": "alice"}})
_, body := do(t, app, req) // no bearer, no session cookie
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("unlink without authentication must be refused, got %v", m)
}
if got := userRow(t, db, "alice").GitHub; got != "gh-alice" {
t.Fatal("an unauthenticated request cleared the connector")
}
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Front-door JSON endpoints the @hanzo/iam SDK + hanzo.id portal call: the login
// UI descriptors (get-app-login, auth/methods), the account read (get-account),
// account creation (signup), and OTP send (send-verification-code). Login itself
// is routeLogin; the OIDC/OAuth surface is Route.
const (
PathGetAppLogin = "/v1/iam/get-app-login"
PathAuthMethods = "/v1/iam/auth/methods"
)
// routeFrontDoor registers the front-door endpoints the hosted hanzo.id portal
// and the @hanzo/iam SDK call, on the PUBLIC group r. Each handler RESOLVES the
// caller itself (callerOf: session cookie first, then bearer) and SELF-SCOPES to
// that caller, so — like the rest of this group — they are reachable without a
// Guard-verified bearer yet never act on anyone but the resolved caller.
func routeFrontDoor(r zip.Router, db orm.DB) {
r.Get(PathGetAppLogin, getAppLogin(db))
r.Get(PathAuthMethods, authMethods(db))
// get-account is anonymous-safe (returns {status:"error"} unauthenticated)
// and a security contract — the gateway admin-guard reads its `owner`.
r.Get(PathGetAccount, getAccount(db))
// Account creation + email/phone OTP send. signup is JSON; send-verification-code
// is multipart/form-data (HIP-0111 §4 invariant), read via fiber's FormValue.
r.Post(PathSignup, signupHandler(db))
r.Post(PathSendVerificationCode, sendVerificationCode(db))
// The session/identity front door the console drives once a user is signed in:
// signin (the code→session exchange), whoami (lightweight identity), onboard
// (first-run org creation + move), update-preferences (self, shallow-merge), and
// linked-accounts (the caller's linked identities).
r.Post(PathSignin, signinHandler(db))
r.Get(PathWhoami, whoamiHandler(db))
r.Post(PathOnboard, onboardHandler(db))
r.Post(PathUpdatePreferences, updatePreferencesHandler(db))
r.Get(PathLinkedAccounts, linkedAccountsHandler(db))
}
// getAppLogin resolves an application by clientId and returns it with the
// ClientSecret masked and each provider link enriched with its shared provider
// record — the canonical source of truth the login UI reads to decide which
// sign-in methods to render. Mirrors the v1 Casdoor get-app-login contract
// (Response envelope, data = the masked application).
func getAppLogin(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
if rt := c.Query("responseType"); rt != "" && rt != "code" {
return httpx.Err(c, "response_type is required (must be code)")
}
clientId := c.Query("clientId")
if clientId == "" {
return httpx.Err(c, "clientId is required")
}
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application does not exist")
}
store.EnrichProviders(c.Context(), db, app)
return httpx.Ok(c, maskApp(app))
}
}
// authMethods reports the enabled sign-in methods for an application so the SDK
// <Login> self-configures instead of hard-coding a provider list. This endpoint
// does NOT exist in v1 — it is the clean seam that lets one <Login> render the
// right buttons for any app. Pure read over the resolved application.
func authMethods(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
clientId := c.Query("clientId")
if clientId == "" {
return httpx.Err(c, "clientId is required")
}
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application does not exist")
}
store.EnrichProviders(c.Context(), db, app)
oauth := []map[string]string{}
web3 := false
for _, it := range app.Providers {
if it == nil || it.Provider == nil || !it.CanSignIn {
continue
}
if !isConfigured(it.Provider) {
continue // hidden until real creds land — never a dead-end button
}
switch strings.ToLower(it.Provider.Category) {
case "web3":
web3 = true
case "oauth":
oauth = append(oauth, map[string]string{
"name": it.Name,
"type": it.Provider.Type,
"logo": it.Provider.CustomLogo,
})
}
}
return httpx.Ok(c, map[string]any{
"password": app.EnablePassword,
"code": app.EnableCodeSignin,
"webauthn": app.EnableWebAuthn,
"web3": web3,
"oauth": oauth,
"signup": app.EnableSignUp,
})
}
}
// isConfigured reports whether a provider holds a real (non-placeholder)
// credential — the guard that keeps an unconfigured provider's button hidden so
// it never dead-ends the OAuth redirect.
func isConfigured(p *schema.Provider) bool {
if p == nil {
return false
}
// Web3 is native challenge/response — no OAuth client to configure.
if strings.EqualFold(p.Category, "Web3") {
return true
}
id := strings.ToLower(strings.TrimSpace(p.ClientId))
if id == "" {
return false
}
return !strings.Contains(id, "placeholder") &&
!strings.HasPrefix(id, "your-") &&
!strings.HasPrefix(id, "xxx") &&
!strings.Contains(id, "change")
}
// maskApp returns a copy-safe view of the application with the client secret and
// every provider's secret removed — get-app-login is called by the browser, so
// no secret may cross it.
func maskApp(app *schema.Application) *schema.Application {
if app == nil {
return nil
}
masked := *app
masked.ClientSecret = ""
for _, it := range masked.Providers {
if it != nil && it.Provider != nil {
p := *it.Provider
p.ClientSecret = ""
p.ClientSecret2 = ""
it.Provider = &p
}
}
return &masked
}
+261
View File
@@ -0,0 +1,261 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/url"
"strings"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// sessionCookieFor drives a bare (type=login) portal sign-in for hanzo/alice and
// returns the "name=value" of the session cookie it set — the credential the
// front-door session routes resolve the caller from.
func sessionCookieFor(t *testing.T, app *zip.App) string {
t.Helper()
form := url.Values{
"organization": {"hanzo"}, "application": {"conf"},
"username": {"alice"}, "password": {"pw"}, "type": {"login"},
}
resp, body := do(t, app, formReq("POST", PathLogin, form))
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
t.Fatalf("login failed: %s", body)
}
return cookieKV(resp.Header.Get("Set-Cookie"))
}
// signin exchanges an authorization code for a session and returns the caller's
// redacted account — the same envelope get-account returns, so the console's
// post<Account>('iam/signin') resolves the signed-in user in one call.
func TestSignin_CodeExchangeSetsSessionAndReturnsAccount(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
code, _, _ := loginForCode(t, app, map[string]string{
"organization": "hanzo", "application": "conf", "clientId": "conf",
"username": "alice", "password": "pw",
})
if code == "" {
t.Fatal("login (type=code) minted no code")
}
resp, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code))
env := decode(t, body)
if resp.StatusCode != 200 || env["status"] != "ok" {
t.Fatalf("signin status=%d body=%s", resp.StatusCode, body)
}
if env["sub"] != "hanzo/alice" || env["name"] != "alice" {
t.Errorf("signin sub/name = %v/%v, want hanzo/alice / alice", env["sub"], env["name"])
}
data, _ := env["data"].(map[string]any)
if data["owner"] != "hanzo" {
t.Errorf("signin data.owner = %v, want hanzo", data["owner"])
}
if v, ok := data["passwordHash"]; ok && v != "" {
t.Errorf("signin leaked passwordHash")
}
// It establishes the durable session get-account resolves from.
cookie := resp.Header.Get("Set-Cookie")
if !strings.HasPrefix(cookie, "hanzo_session=") {
t.Fatalf("signin did not set the session cookie: %q", cookie)
}
req := formReqNoBody("GET", PathGetAccount)
req.Header.Set("Cookie", cookieKV(cookie))
resp2, body2 := do(t, app, req)
if resp2.StatusCode != 200 || decode(t, body2)["status"] != "ok" {
t.Fatalf("get-account via the signin cookie failed: %s", body2)
}
}
// The code is single-use: a replay after redemption is refused (no second session).
func TestSignin_ReplayedCodeRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
code, _, _ := loginForCode(t, app, map[string]string{
"organization": "hanzo", "application": "conf", "clientId": "conf",
"username": "alice", "password": "pw",
})
if _, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code)); decode(t, body)["status"] != "ok" {
t.Fatalf("first signin should succeed: %s", body)
}
_, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code))
if decode(t, body)["status"] != "error" {
t.Fatalf("replayed code must be refused, got: %s", body)
}
}
// whoami resolves the caller from the session cookie and returns the lightweight
// identity; an anonymous caller gets {status:"error"}, never a leak.
func TestWhoami_CookieResolvesIdentity(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
req := formReqNoBody("GET", PathWhoami)
req.Header.Set("Cookie", cookie)
resp, body := do(t, app, req)
env := decode(t, body)
if resp.StatusCode != 200 || env["status"] != "ok" || env["sub"] != "hanzo/alice" {
t.Fatalf("whoami via cookie: status=%d body=%s", resp.StatusCode, body)
}
data, _ := env["data"].(map[string]any)
if data["owner"] != "hanzo" || data["name"] != "alice" || data["id"] != "hanzo/alice" {
t.Errorf("whoami identity = %v, want owner/name/id = hanzo/alice/hanzo/alice", data)
}
// Anonymous → error, no data.
_, anon := do(t, app, formReqNoBody("GET", PathWhoami))
if e := decode(t, anon); e["status"] != "error" || e["data"] != nil {
t.Fatalf("anonymous whoami must be error with no data: %s", anon)
}
}
// update-preferences shallow-merges: a second patch adds a key without clobbering
// the first, and the merged object is returned + persisted on the caller's row.
func TestUpdatePreferences_ShallowMergeRoundTrip(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
post := func(patch any) map[string]any {
req := jsonReq("POST", PathUpdatePreferences, patch)
req.Header.Set("Cookie", cookie)
resp, body := do(t, app, req)
env := decode(t, body)
if resp.StatusCode != 200 || env["status"] != "ok" {
t.Fatalf("update-preferences: status=%d body=%s", resp.StatusCode, body)
}
data, _ := env["data"].(map[string]any)
return data
}
if got := post(map[string]any{"onboarding_completed": true}); got["onboarding_completed"] != true {
t.Fatalf("first patch not reflected: %v", got)
}
got := post(map[string]any{"theme": "dark"})
if got["onboarding_completed"] != true || got["theme"] != "dark" {
t.Fatalf("second patch clobbered the first (want both keys): %v", got)
}
// Persisted on the row — a fresh read shows the merged blob under hanzo.preferences.
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
if u == nil || !strings.Contains(u.Properties[preferencesKey], "onboarding_completed") ||
!strings.Contains(u.Properties[preferencesKey], "theme") {
t.Fatalf("preferences not persisted: %+v", u.Properties)
}
}
// onboard creates the named org and MOVES the caller into it as admin (their owner
// becomes the new slug); the console reads {org:<slug>} and re-authenticates.
func TestOnboard_CreatesOrgAndMovesCaller(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
req := jsonReq("POST", PathOnboard, map[string]any{"name": "Acme Inc"})
req.Header.Set("Cookie", cookie)
resp, body := do(t, app, req)
env := decode(t, body)
if resp.StatusCode != 200 || env["org"] != "acme-inc" {
t.Fatalf("onboard status=%d body=%s (want {org:acme-inc})", resp.StatusCode, body)
}
ctx := context.Background()
if org, _ := store.GetOrganizationByName(ctx, db, "acme-inc"); org == nil || org.Owner != "admin" {
t.Fatalf("onboard did not create the acme-inc org: %+v", org)
}
// alice moved out of hanzo and into acme-inc as admin.
if old, _ := store.GetUserByName(ctx, db, "hanzo", "alice"); old != nil {
t.Errorf("alice was not moved out of hanzo")
}
moved, _ := store.GetUserByName(ctx, db, "acme-inc", "alice")
if moved == nil || !moved.IsAdmin {
t.Fatalf("alice not moved into acme-inc as admin: %+v", moved)
}
}
// The one-click personal path creates a `<username>` org.
func TestOnboard_PersonalOrg(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
req := jsonReq("POST", PathOnboard, map[string]any{"personal": true})
req.Header.Set("Cookie", cookie)
resp, body := do(t, app, req)
if env := decode(t, body); resp.StatusCode != 200 || env["org"] != "alice" {
t.Fatalf("personal onboard status=%d body=%s (want {org:alice})", resp.StatusCode, body)
}
if org, _ := store.GetOrganizationByName(context.Background(), db, "alice"); org == nil || !org.IsPersonal {
t.Fatalf("personal org not created/flagged: %+v", org)
}
}
// A reserved slug (an IAM system owner) is refused — a customer can never become admin.
func TestOnboard_ReservedRefused(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
cookie := sessionCookieFor(t, app)
req := jsonReq("POST", PathOnboard, map[string]any{"name": "admin"})
req.Header.Set("Cookie", cookie)
resp, body := do(t, app, req)
if resp.StatusCode == 200 || !strings.Contains(string(body), "reserved") {
t.Fatalf("reserved org must be refused: status=%d body=%s", resp.StatusCode, body)
}
}
// linked-accounts returns the caller's non-empty connector columns and nothing else.
func TestLinkedAccounts_ListsConnectorColumns(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
// Link a GitHub identity to alice.
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
u.GitHub = "octocat"
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatalf("link github: %v", err)
}
cookie := sessionCookieFor(t, app)
req := formReqNoBody("GET", PathLinkedAccounts)
req.Header.Set("Cookie", cookie)
resp, body := do(t, app, req)
if resp.StatusCode != 200 || !strings.Contains(string(body), "github") || !strings.Contains(string(body), "octocat") {
t.Fatalf("linked-accounts: status=%d body=%s", resp.StatusCode, body)
}
}
// linkedAccountsOf reflects only the connector columns — never Owner/Name/Email.
func TestLinkedAccountsOf_OnlyConnectors(t *testing.T) {
u := &schema.User{Owner: "hanzo", Name: "alice", Email: "a@x.io", GitHub: "octocat", Google: "g-1"}
got := linkedAccountsOf(u)
seen := map[string]string{}
for _, la := range got {
seen[la.Provider] = la.Subject
}
if seen["github"] != "octocat" || seen["google"] != "g-1" {
t.Fatalf("missing a linked connector: %v", got)
}
for _, forbidden := range []string{"owner", "name", "email"} {
if _, bad := seen[forbidden]; bad {
t.Errorf("linkedAccountsOf leaked a non-connector field %q", forbidden)
}
}
if len(got) != 2 {
t.Errorf("want exactly 2 linked accounts, got %d: %v", len(got), got)
}
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/store"
)
// PathGetAccount is the native front-door account endpoint — what the hanzo.id
// portal's account page and the gateway admin-guard call.
//
// SECURITY CONTRACT. The gateway admin-guard derives the global-admin
// (SuperAdmin) predicate from the `owner` this returns — a caller is a global
// admin iff `data.owner == AdminOrg` (gateway/cmd/admin-guard). So the response
// shape MUST match v1 exactly — {status, sub, name, data:<user>, data2:<org>} —
// and every secret (password hash, access secret, TOTP, recovery codes) MUST be
// redacted. Anonymous callers get {status:"error"} (200, casibase convention),
// never a leak: the admin-guard reads status=="error" → not-admin, fail-closed.
const PathGetAccount = "/v1/iam/get-account"
// accountResponse mirrors v1's Response for get-account (the casibase envelope).
type accountResponse struct {
Status string `json:"status"`
Msg string `json:"msg,omitempty"`
Sub string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Data any `json:"data,omitempty"`
Data2 any `json:"data2,omitempty"`
}
// getAccount resolves the signed-in caller and returns their REDACTED account +
// organization. Resolution (callerOf) is by session cookie first — the portal +
// gateway-admin-guard path — then bearer access token — the API path.
func getAccount(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name, ok := callerOf(ctx, c, db)
if !ok {
return c.JSON(200, accountResponse{Status: "error", Msg: "please sign in first"})
}
env, status := accountEnvelopeFor(ctx, db, owner, name)
return c.JSON(status, env)
}
}
// accountEnvelopeFor builds the get-account envelope for an already-resolved
// caller: the REDACTED user + organization in the casibase shape, or an error
// envelope when the user or org lookup fails. It is the ONE place the account
// envelope is assembled, shared by get-account and signin (the code→session
// exchange), so the two can never drift. status is the HTTP code to return with
// it (200 for both ok and the casibase "error" convention, 500 on a store fault).
func accountEnvelopeFor(ctx context.Context, db orm.DB, owner, name string) (accountResponse, int) {
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return accountResponse{Status: "error", Msg: "server_error"}, 500
}
if user == nil {
return accountResponse{Status: "error", Msg: "the user does not exist"}, 200
}
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return accountResponse{Status: "error", Msg: "server_error"}, 500
}
return accountResponse{
Status: "ok",
Sub: owner + "/" + name,
Name: user.Name,
Data: user.Mask(), // owner + isAdmin survive; every secret stripped
Data2: org.Mask(), // org master/default passwords masked
}, 200
}
// callerOf resolves the signed-in principal by SESSION COOKIE first (the portal
// and gateway-admin-guard path) then bearer access token (the API path) — two
// credentials, one identity. ok=false means no valid session or token.
func callerOf(ctx context.Context, c *zip.Ctx, db orm.DB) (owner, name string, ok bool) {
if o, n, ok := sessions.Resolve(ctx, c.Fiber(), db); ok {
return o, n, true
}
bearer := httpx.Bearer(c)
if bearer == "" {
return "", "", false
}
claims, err := verifyToken(ctx, db, bearer)
if err != nil {
return "", "", false
}
o, n := splitSub(claims.Subject)
return o, n, true
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// getAccountReq drives GET /v1/iam/get-account with an optional bearer,
// returning the status code and decoded envelope.
func getAccountReq(t *testing.T, app *zip.App, bearer string) (int, map[string]any) {
t.Helper()
req := formReqNoBody("GET", PathGetAccount)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, body := do(t, app, req)
return resp.StatusCode, decode(t, body)
}
// The bearer path resolves the caller and returns a REDACTED account whose
// `owner` (the admin-guard's SuperAdmin input) is correct and whose secrets are
// stripped — the security contract, end to end through the real router.
func TestGetAccount_BearerReturnsRedactedAccount(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
access := accessTokenFor(t, app, "openid profile email")
status, env := getAccountReq(t, app, access)
if status != 200 || env["status"] != "ok" {
t.Fatalf("status=%d env=%v, want 200 ok", status, env)
}
if env["sub"] != "hanzo/alice" || env["name"] != "alice" {
t.Errorf("sub/name = %v/%v, want hanzo/alice / alice", env["sub"], env["name"])
}
data, ok := env["data"].(map[string]any)
if !ok {
t.Fatalf("data is not an object: %v", env["data"])
}
// The admin-guard reads data.owner — it MUST be present and correct.
if data["owner"] != "hanzo" {
t.Errorf("data.owner = %v, want hanzo (the admin-guard SuperAdmin input)", data["owner"])
}
// Every secret MUST be stripped — a leak here hands out password hashes.
for _, secret := range []string{"passwordHash", "passwordSalt", "accessSecret", "accessSecretHash", "totpSecret", "accessToken"} {
if v, present := data[secret]; present && v != "" {
t.Errorf("get-account leaked %q = %v", secret, v)
}
}
}
// Anonymous callers get {status:"error"} (200, casibase convention) — never a
// leak and never a 5xx. The admin-guard reads status=="error" → not-admin,
// fail-closed. Same for an invalid bearer.
func TestGetAccount_AnonymousIsErrorNotLeak(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
for name, bearer := range map[string]string{"no bearer": "", "garbage bearer": "not-a-real-token"} {
t.Run(name, func(t *testing.T) {
status, env := getAccountReq(t, app, bearer)
if status != 200 || env["status"] != "error" {
t.Fatalf("status=%d env=%v, want 200 error", status, env)
}
if _, leaked := env["data"]; leaked {
t.Errorf("anonymous get-account must carry no data, got %v", env["data"])
}
})
}
}
// Redact keeps the admin-guard fields (owner, isAdmin) while stripping every
// secret — the invariant get-account relies on. Unit-level, no db/login flow.
func TestUserMask_KeepsAdminFieldsStripsSecrets(t *testing.T) {
u := &schema.User{
Owner: "admin",
Name: "root",
IsAdmin: true,
PasswordHash: "$argon2id$v=19$…",
PasswordSalt: "salt",
AccessSecret: "sk_live_abc",
TotpSecret: "JBSWY3DPEHPK3PXP",
}
got := u.Mask()
if got.Owner != "admin" || !got.IsAdmin {
t.Errorf("Mask dropped an admin-guard field: owner=%q isAdmin=%v", got.Owner, got.IsAdmin)
}
if got.PasswordHash != "" || got.PasswordSalt != "" || got.AccessSecret != "" || got.TotpSecret != "" {
t.Errorf("Mask left a secret: %+v", got)
}
// Mask returns a COPY — the login-verify path must still see the live hash on
// the original row, so masking a response can never blank it.
if u.PasswordHash == "" {
t.Errorf("Mask must NOT mutate the receiver, but the original's PasswordHash was cleared")
}
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// HTTP-level test harness: mount the whole OIDC surface on a fresh store and
// drive it through the real router (app.Fiber().Test), so every test exercises
// the wire contract a client sees — status codes, headers, redirects, bodies.
// sharedKey is one RSA key reused across tests (keygen is the slow part; the
// crypto under test is identical regardless of which key it is).
var (
sharedKeyOnce sync.Once
sharedKeyVal *rsa.PrivateKey
)
func sharedKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
sharedKeyOnce.Do(func() {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
sharedKeyVal = k
})
return sharedKeyVal
}
// appOpts configures a seeded OAuth application.
type appOpts struct {
clientID string
secret string // "" → public (PKCE) client
redirectURIs []string
refreshHours float64
shared bool // IsShared → accepts users from any org
signup bool // EnableSignUp → the app allows new-account creation
grants []string // declared OAuth grants; a grant absent here is refused
}
// tctx is the background context used by the test seed helpers.
func tctx() context.Context { return context.Background() }
// newServer mounts the full OIDC surface on a fresh SQLite store.
func newServer(t *testing.T) (*zip.App, orm.DB) {
t.Helper()
db := openTestDB(t)
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
// The whole OIDC surface is the pre-authentication PUBLIC group; a root
// (empty-prefix) router registers it at its absolute paths, no Guard.
Route(app.Group(""), db)
return app, db
}
// seedRSACert creates a named RS256 signing cert holding the shared key.
func seedRSACert(t *testing.T, db orm.DB, name string) {
t.Helper()
c := orm.New[schema.Cert](db)
c.Owner = "admin"
c.Name = name
c.CryptoAlgorithm = "RS256"
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
c.SetId("admin/" + name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed cert: %v", err)
}
}
// seedApp creates an application (org "hanzo") with the given options and a
// shared RS256 cert.
func seedApp(t *testing.T, db orm.DB, o appOpts) *schema.Application {
t.Helper()
seedRSACert(t, db, "cert-"+o.clientID)
a := orm.New[schema.Application](db)
a.Owner = "admin"
a.Name = o.clientID
a.ClientId = o.clientID
a.ClientSecret = o.secret
a.Organization = "hanzo"
a.Cert = "cert-" + o.clientID
a.EnablePassword = true
a.EnableSignUp = o.signup
a.ExpireInHours = 1
a.RefreshExpireInHours = o.refreshHours
a.RedirectUris = o.redirectURIs
a.IsShared = o.shared
a.GrantTypes = o.grants
a.SetId("admin/" + o.clientID)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed app: %v", err)
}
return a
}
// --- HTTP helpers ---
func formReq(method, path string, form url.Values) *http.Request {
req := httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Host = "hanzo.id"
return req
}
func formReqNoBody(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
req.Host = "hanzo.id"
return req
}
func jsonReq(method, path string, body any) *http.Request {
b, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Host = "hanzo.id"
return req
}
func do(t *testing.T, app *zip.App, req *http.Request) (*http.Response, []byte) {
t.Helper()
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("test request %s %s: %v", req.Method, req.URL.Path, err)
}
body, _ := io.ReadAll(resp.Body)
return resp, body
}
func decode(t *testing.T, body []byte) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("decode json %q: %v", string(body), err)
}
return m
}
// loginForCode drives POST /v1/iam/login (type=code) and returns the minted
// authorization code from the Response envelope.
func loginForCode(t *testing.T, app *zip.App, f map[string]string) (string, *http.Response, []byte) {
t.Helper()
f["type"] = "code"
resp, body := do(t, app, jsonReq("POST", PathLogin, f))
m := decode(t, body)
code, _ := m["data"].(string)
return code, resp, body
}
// exchangeCode drives POST /v1/iam/oauth/token for the authorization_code grant.
func exchangeCode(t *testing.T, app *zip.App, form url.Values) (*http.Response, map[string]any) {
t.Helper()
form.Set("grant_type", "authorization_code")
resp, body := do(t, app, formReq("POST", PathToken, form))
return resp, decode(t, body)
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"testing"
)
// rsaGenTest generates a 2048-bit RSA key (JWKS minimum) for tests.
func rsaGenTest() (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, 2048)
}
// rsaKeyToPEM encodes an RSA private key as PKCS#1 PEM (what a Cert row holds).
func rsaKeyToPEM(t *testing.T, k *rsa.PrivateKey) string {
t.Helper()
der := x509.MarshalPKCS1PrivateKey(k)
return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}))
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/subtle"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/store"
)
// RFC 7662 Token Introspection + RFC 7009 Token Revocation — the two standard
// token-management endpoints a resource server / confidential client uses. Both
// are POST, client-authenticated (client_secret_basic or _post, constant-time),
// on the OAuth token surface. Introspection reports whether a token is currently
// active — JWT-valid AND its grant row still exists, so a REVOKED token reads
// inactive — and returns its claims when active. Revocation deletes the grant
// row (the whole refresh-rotation family for a refresh token) and always answers
// 200 (RFC 7009 §2.2: an invalid/unknown token is not an error, so the endpoint
// is no token-existence oracle).
const (
PathIntrospect = "/v1/iam/oauth/introspect"
PathRevoke = "/v1/iam/oauth/revoke"
)
// routeIntrospectRevoke registers the introspection + revocation endpoints on the
// PUBLIC group r (client-authenticated, not Bearer-gated).
func routeIntrospectRevoke(r zip.Router, db orm.DB) {
r.Post(PathIntrospect, introspectHandler(db))
r.Post(PathRevoke, revokeHandler(db))
}
// authConfidentialClient authenticates the calling client and requires it to be
// CONFIDENTIAL (holds a verified secret). Introspection and revocation are
// privileged token-management operations; a public client may not call them.
// Constant-time secret compare; a nil app or empty stored secret fails closed.
func authConfidentialClient(ctx context.Context, db orm.DB, c *zip.Ctx) (name string, ok bool) {
clientID, clientSecret := clientAuth(c)
if clientID == "" {
return "", false
}
app, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil || app == nil || app.ClientSecret == "" {
return "", false
}
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return "", false
}
return app.Name, true
}
// introspectHandler implements RFC 7662. Active iff the grant row still exists
// (revocation-aware, the same liveness check userinfo makes) AND the JWT verifies
// under the trusted keys. The response carries the standard introspection claims;
// an inactive/absent/unauthenticated-target token returns only `{active:false}`.
func introspectHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
setTokenCacheHeaders(c)
ctx := c.Context()
if _, ok := authConfidentialClient(ctx, db, c); !ok {
return tokenErrorClient(c, "client authentication failed")
}
tokenStr := param(c, "token")
if tokenStr == "" {
return c.JSON(200, inactiveToken())
}
h := hashToken(tokenStr)
// Liveness: the grant row must still exist (a revoked/rotated token has none).
row, _ := store.GetTokenByAccessTokenHash(ctx, db, h)
if row == nil {
row, _ = store.GetTokenByRefreshHash(ctx, db, h)
}
if row == nil {
return c.JSON(200, inactiveToken())
}
claims, err := verifyToken(ctx, db, tokenStr)
if err != nil {
return c.JSON(200, inactiveToken())
}
resp := map[string]any{
"active": true,
"token_type": "Bearer",
"scope": claims.Scope,
"client_id": claims.Azp,
"sub": claims.Subject,
"iss": claims.Issuer,
"owner": claims.Owner,
}
if claims.Organization != "" {
resp["organization"] = claims.Organization
}
if claims.Email != "" {
resp["username"] = claims.Email
}
if len(claims.Audience) > 0 {
resp["aud"] = claims.Audience
}
if claims.ExpiresAt != nil {
resp["exp"] = claims.ExpiresAt.Unix()
}
if claims.IssuedAt != nil {
resp["iat"] = claims.IssuedAt.Unix()
}
if claims.NotBefore != nil {
resp["nbf"] = claims.NotBefore.Unix()
}
if claims.ID != "" {
resp["jti"] = claims.ID
}
return c.JSON(200, resp)
}
}
// revokeHandler implements RFC 7009. A confidential client revokes a token that
// was issued to IT (§2.1) — an access token deletes that grant row; a refresh
// token revokes the whole rotation family so no further access tokens can be
// minted and every sibling dies. A token belonging to another client, or an
// unknown token, is a silent 200 (no revocation, no oracle — §2.2).
func revokeHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
setTokenCacheHeaders(c)
ctx := c.Context()
clientName, ok := authConfidentialClient(ctx, db, c)
if !ok {
return tokenErrorClient(c, "client authentication failed")
}
tokenStr := param(c, "token")
if tokenStr == "" {
return revoked(c)
}
h := hashToken(tokenStr)
if row, _ := store.GetTokenByAccessTokenHash(ctx, db, h); row != nil {
if row.Application == clientName {
_ = store.DeleteToken(ctx, db, row)
}
return revoked(c)
}
if row, _ := store.GetTokenByRefreshHash(ctx, db, h); row != nil {
if row.Application == clientName {
family, _ := store.ListTokensByRefreshFamily(ctx, db, row.RefreshFamily)
for _, t := range family {
_ = store.DeleteToken(ctx, db, t)
}
}
return revoked(c)
}
return revoked(c)
}
}
// inactiveToken is the RFC 7662 response for a token that is not active.
func inactiveToken() map[string]any { return map[string]any{"active": false} }
// revoked is the RFC 7009 §2.2 success response: HTTP 200, empty body.
func revoked(c *zip.Ctx) error { return c.Status(200).JSON(200, struct{}{}) }
+139
View File
@@ -0,0 +1,139 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"encoding/base64"
"net/http"
"net/url"
"testing"
"github.com/zap-proto/zip"
)
// RFC 7662 introspection + RFC 7009 revocation, end to end: mint a real token via
// the password grant, introspect it (active + claims), revoke it, and confirm it
// then reads inactive AND its bearer no longer resolves at userinfo.
// postForm posts a form to path as the confidential client (client_secret_basic).
func postForm(t *testing.T, app *zip.App, path, clientID, secret string, form url.Values) (*http.Response, map[string]any) {
t.Helper()
req := formReq("POST", path, form)
if clientID != "" {
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
}
resp, body := do(t, app, req)
return resp, decode(t, body)
}
// mintPasswordToken issues an access+refresh token for the seeded user.
func mintPasswordToken(t *testing.T, app *zip.App) (access, refresh string) {
t.Helper()
_, tok := postToken(t, app, url.Values{
"grant_type": {"password"},
"client_id": {"hanzo-console"},
"client_secret": {"top-secret"},
"username": {"alice@hanzo.ai"},
"password": {"correct horse"},
"scope": {"openid profile email offline_access"},
})
access, _ = tok["access_token"].(string)
refresh, _ = tok["refresh_token"].(string)
if access == "" {
t.Fatalf("no access_token minted; body=%v", tok)
}
return access, refresh
}
func TestIntrospect_activeToken_returnsClaims(t *testing.T) {
app, db := newServer(t)
_ = db
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
access, _ := mintPasswordToken(t, app)
_, ir := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {access}})
if ir["active"] != true {
t.Fatalf("active = %v, want true; body=%v", ir["active"], ir)
}
if ir["sub"] != "hanzo/alice" {
t.Errorf("sub = %v, want hanzo/alice", ir["sub"])
}
if ir["owner"] != "hanzo" {
t.Errorf("owner = %v, want hanzo", ir["owner"])
}
if ir["token_type"] != "Bearer" {
t.Errorf("token_type = %v, want Bearer", ir["token_type"])
}
}
func TestIntrospect_requiresConfidentialClientAuth(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
access, _ := mintPasswordToken(t, app)
// No client auth → 401 (introspection is privileged).
resp, _ := postForm(t, app, PathIntrospect, "", "", url.Values{"token": {access}})
if resp.StatusCode != 401 {
t.Fatalf("unauthenticated introspect status = %d, want 401", resp.StatusCode)
}
// Wrong secret → 401.
resp2, _ := postForm(t, app, PathIntrospect, "hanzo-console", "WRONG", url.Values{"token": {access}})
if resp2.StatusCode != 401 {
t.Fatalf("bad-secret introspect status = %d, want 401", resp2.StatusCode)
}
}
func TestIntrospect_garbageToken_inactive(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
_, ir := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {"not-a-real-token"}})
if ir["active"] != false {
t.Fatalf("garbage token active = %v, want false", ir["active"])
}
}
func TestRevoke_accessToken_thenInactive(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
access, _ := mintPasswordToken(t, app)
// Active before revoke.
_, before := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {access}})
if before["active"] != true {
t.Fatalf("token not active before revoke; body=%v", before)
}
// Revoke → 200.
resp, _ := postForm(t, app, PathRevoke, "hanzo-console", "top-secret", url.Values{"token": {access}})
if resp.StatusCode != 200 {
t.Fatalf("revoke status = %d, want 200", resp.StatusCode)
}
// Inactive after revoke — introspection reflects the deleted grant row.
_, after := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {access}})
if after["active"] != false {
t.Fatalf("token still active after revoke; body=%v", after)
}
// And the bearer no longer resolves at userinfo (revocation is real).
req := formReqNoBody("GET", PathUserInfo)
req.Header.Set("Authorization", "Bearer "+access)
if resp, _ := do(t, app, req); resp.StatusCode == 200 {
t.Fatalf("userinfo still 200 for a revoked bearer")
}
}
func TestRevoke_unknownToken_is200_noOracle(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
// RFC 7009 §2.2: an unknown token is a silent 200, not an error.
resp, _ := postForm(t, app, PathRevoke, "hanzo-console", "top-secret", url.Values{"token": {"unknown"}})
if resp.StatusCode != 200 {
t.Fatalf("unknown-token revoke status = %d, want 200", resp.StatusCode)
}
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
)
// devFallbackIssuer is the ONE fixed, non-host-derived issuer this package ever
// falls back to. It is a constant, never interpolated from the request, so a
// fail-closed landing can never become a host echo.
const devFallbackIssuer = "https://hanzo.id"
// errNoIssuer is the fail-LOUD boot error when nothing pins the issuer — no
// IAM_ISSUER, no IAM_ISSUER_MAP — and the operator has NOT explicitly opted into
// host-relative dev issuers (IAM_DEV_HOST_RELATIVE=1). Booting here would leave
// the request host as the only value `iss` could take, a silent fail-open host
// echo; we refuse to boot instead.
var errNoIssuer = errors.New(
"no OIDC issuer configured: set IAM_ISSUER to an absolute https URL " +
"(optionally with IAM_ISSUER_MAP for per-brand pinning); " +
"set IAM_DEV_HOST_RELATIVE=1 only on a local dev box that intends host-relative issuers")
// The per-host OIDC issuer resolver.
//
// iam2 runs as ONE multi-tenant instance behind the ingress for every brand host
// — hanzo.id, lux.id, id.zoo.network, pars.id, and their iam.* aliases. Each
// brand must emit its OWN issuer so a relying party that discovered via lux.id
// validates lux.id-issued tokens (`iss` is the boundary an RP pins). A single
// pinned IAM_ISSUER cannot serve that: every non-matching brand's tokens would
// carry the wrong `iss` and fail RP validation.
//
// This resolver is the ONE seam every issuer read routes through — the token
// `iss` claim, the discovery document `issuer` (and the `jwks_uri` derived from
// it), and the federation callback origin. The issuer it returns is ALWAYS a
// trusted CONFIG value (a map entry or the default), NEVER a string interpolated
// from the request, so a client-supplied Host/X-Forwarded-Host can at most SELECT
// an already-configured brand's issuer — never inject an arbitrary or foreign one.
// That is the "header-immune issuer" property, preserved and generalized to N
// brands.
// issuerResolver maps a brand host to the OIDC issuer iam2 emits for it. It is
// built ONCE from config (IAM_ISSUER + IAM_ISSUER_MAP) and is immutable
// thereafter — a value, not a place, so every request goroutine reads it without
// a lock and no request can mutate it.
type issuerResolver struct {
def string // default issuer (IAM_ISSUER), normalized; "" ONLY under the dev opt-in
byHost map[string]string // normalized brand host -> normalized pinned issuer
// devHostRelative is the EXPLICIT opt-in (IAM_DEV_HOST_RELATIVE=1) that permits
// a host-relative issuer when nothing is pinned. It is the ONLY thing that lets
// issuerFor derive `iss` from the request host; absent it, a no-issuer config is
// a hard boot error, so the request host is NEVER echoed as `iss` in any mode.
devHostRelative bool
}
// newIssuerResolver builds a resolver from the default issuer and the JSON
// host→issuer map. Both inputs are trusted CONFIG (env / flags), never request
// data.
//
// Fail-closed by construction:
//
// - A non-empty default (IAM_ISSUER) MUST be an absolute https URL — the SAME
// bar every map entry clears, applied to the default too, so no mode admits a
// non-https or scheme-less issuer (checked FIRST, before the map).
// - An empty mapJSON yields a resolver that returns def for every host —
// EXACTLY the single-issuer behavior that predates the map (backward
// compatible; zero behavior change when IAM_ISSUER_MAP is unset).
// - No default AND no map is a HARD error UNLESS devHostRelative is set. Without
// that opt-in the only issuer left to emit would be the request host echoed
// back — a silent fail-open — so we refuse to boot. With the opt-in
// (IAM_DEV_HOST_RELATIVE=1) a dev box serves a host-relative issuer instead.
// - A non-empty map REQUIRES a non-empty default — even with the dev opt-in. A
// map without a default would let an unknown host fall through (fail-open); we
// refuse that configuration, so an unknown/spoofed Host in map mode ALWAYS
// lands on the pinned default, never an echoed host.
// - A malformed map, or an entry whose host or issuer is empty or whose issuer
// is not an absolute https URL, is a hard error. A misconfigured issuer map
// must fail the boot LOUD, never silently mint tokens under the wrong `iss`.
func newIssuerResolver(defaultIssuer, mapJSON string, devHostRelative bool) (*issuerResolver, error) {
def := normalizeIssuer(defaultIssuer)
if def != "" && !strings.HasPrefix(def, "https://") {
return nil, fmt.Errorf("IAM_ISSUER must be an absolute https URL, got %q", def)
}
r := &issuerResolver{def: def, devHostRelative: devHostRelative}
mapJSON = strings.TrimSpace(mapJSON)
if mapJSON == "" {
if def == "" && !devHostRelative {
return nil, errNoIssuer
}
return r, nil
}
var raw map[string]string
if err := json.Unmarshal([]byte(mapJSON), &raw); err != nil {
return nil, fmt.Errorf("IAM_ISSUER_MAP: invalid JSON: %w", err)
}
if len(raw) > 0 && def == "" {
return nil, fmt.Errorf("IAM_ISSUER_MAP is set but IAM_ISSUER (the fail-closed default) is empty: " +
"an unknown host would have no pinned issuer to fall back to")
}
r.byHost = make(map[string]string, len(raw))
for host, iss := range raw {
h := normalizeHost(host)
v := normalizeIssuer(iss)
if h == "" || v == "" {
return nil, fmt.Errorf("IAM_ISSUER_MAP: empty host or issuer in entry %q:%q", host, iss)
}
if !strings.HasPrefix(v, "https://") {
return nil, fmt.Errorf("IAM_ISSUER_MAP: issuer for host %q must be an absolute https URL, got %q", h, v)
}
r.byHost[h] = v
}
return r, nil
}
// issuerFor returns the pinned issuer for host. The host is echoed into `iss`
// ONLY inside the explicit dev opt-in branch (4); every other branch yields a
// trusted config value or the fixed fallback, so "never echo the request host"
// is structural here, not merely an emergent property of construction:
//
// 1. host is a configured brand → that brand's PINNED issuer (trusted config).
// 2. otherwise the default issuer (IAM_ISSUER) when one is pinned — the
// fail-closed landing spot for an unknown/spoofed Host. The attacker-supplied
// Host is NEVER echoed as the issuer.
// 3. no default and NOT the dev opt-in → the fixed fallback. This is unreachable
// given newIssuerResolver's gate (a def-less, non-dev resolver never builds),
// but issuerFor still refuses to echo if one is ever hand-constructed.
// 4. dev opt-in (IAM_DEV_HOST_RELATIVE=1) with no pin → host-relative from the
// TRUSTED host, so a dev box still serves a coherent discovery / JWKS / iss
// triple. host here is zip.Ctx.Host(), which ignores X-Forwarded-Host, so even
// this dev branch cannot be steered by a client header.
func (r *issuerResolver) issuerFor(host string) string {
if r == nil { // defensive: an unbuilt resolver still fails closed, never echoes
return devFallbackIssuer
}
if iss, ok := r.byHost[normalizeHost(host)]; ok {
return iss
}
if r.def != "" {
return r.def
}
if r.devHostRelative {
return devIssuer(host)
}
return devFallbackIssuer
}
// devIssuer is the host-relative dev issuer: "https://<trusted host>", or the
// fixed fallback when even the host is absent. Reached ONLY under the explicit
// IAM_DEV_HOST_RELATIVE=1 opt-in (issuerFor branch 4); any real deployment pins at
// least IAM_ISSUER and never reaches it.
func devIssuer(host string) string {
if h := normalizeHost(host); h != "" {
return "https://" + h
}
return devFallbackIssuer
}
// normalizeHost lowercases, trims whitespace, strips a :port, and strips a single
// trailing FQDN dot so "LUX.ID", " lux.id ", "lux.id:443" and "lux.id." all resolve
// to the one map key "lux.id". Applied to BOTH the config keys and the request
// host, so the two are compared apples to apples and neither a port nor a trailing
// dot on the request can dodge a configured brand. Any host that still fails to
// match a key fails CLOSED to the default — so an imperfect normalization can only
// ever cost availability (a brand landing on the default issuer), never safety (an
// arbitrary host is never echoed as the issuer).
func normalizeHost(host string) string {
h := strings.ToLower(strings.TrimSpace(host))
if i := strings.IndexByte(h, ':'); i >= 0 {
h = h[:i]
}
return strings.TrimSuffix(h, ".")
}
// normalizeIssuer trims whitespace and a trailing slash so the issuer is the
// canonical no-trailing-slash origin RFC 8414 clients expect — matching the
// pre-existing strings.TrimRight(iss, "/") behavior exactly.
func normalizeIssuer(iss string) string {
return strings.TrimRight(strings.TrimSpace(iss), "/")
}
// activeResolver is the process issuer resolver, installed once at startup by
// InitIssuerResolver (and swapped by tests). Atomic so the install is visible to
// every request goroutine with no lock on the hot path.
var activeResolver atomic.Pointer[issuerResolver]
// devHostRelativeEnabled reports whether the operator has EXPLICITLY opted into
// host-relative dev issuers (IAM_DEV_HOST_RELATIVE=1). It gates the one branch
// that derives `iss` from the request host; absent the opt-in, a no-issuer boot is
// a hard error (errNoIssuer) rather than a silent host echo.
func devHostRelativeEnabled() bool { return os.Getenv("IAM_DEV_HOST_RELATIVE") == "1" }
// envIssuerResolver builds the resolver from IAM_ISSUER + IAM_ISSUER_MAP exactly
// once, lazily. It is the fallback for a request that reaches a handler before
// InitIssuerResolver has installed one (only tests that skip the startup path).
// A malformed map degrades fail-closed to the default issuer — never a boot an
// attacker can steer — while InitIssuerResolver remains the eager, hard-error
// path a real deploy hits first, so this degrade branch is a last-resort net. If
// even the default is absent AND the dev opt-in is unset, the fallback build also
// errors and yields nil, which issuerFor handles by failing closed (never echo).
var envIssuerResolver = sync.OnceValue(func() *issuerResolver {
dev := devHostRelativeEnabled()
r, err := newIssuerResolver(os.Getenv("IAM_ISSUER"), os.Getenv("IAM_ISSUER_MAP"), dev)
if err != nil {
r, _ = newIssuerResolver(os.Getenv("IAM_ISSUER"), "", dev)
}
return r
})
// InitIssuerResolver parses IAM_ISSUER + IAM_ISSUER_MAP once at startup and
// installs the process resolver. A malformed / fail-open / non-https config — or
// nothing pinned at all without the IAM_DEV_HOST_RELATIVE opt-in — is a HARD error
// so a misconfigured deploy fails to boot rather than silently minting tokens
// under the wrong (or host-echoed) `iss`. Called from serve() before the listener
// opens.
func InitIssuerResolver() error {
r, err := newIssuerResolver(os.Getenv("IAM_ISSUER"), os.Getenv("IAM_ISSUER_MAP"), devHostRelativeEnabled())
if err != nil {
return err
}
activeResolver.Store(r)
return nil
}
// resolveIssuer is THE seam every issuer read routes through. host is the TRUSTED
// request host (zip.Ctx.Host(), which ignores X-Forwarded-Host), so a
// client-supplied header can only ever SELECT an already-configured brand's
// issuer, never inject an arbitrary one.
func resolveIssuer(host string) string {
if r := activeResolver.Load(); r != nil {
return r.issuerFor(host)
}
return envIssuerResolver().issuerFor(host)
}
+351
View File
@@ -0,0 +1,351 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/url"
"testing"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
)
// testIssuerMap is the canonical multi-brand map the cutover deploy configures as
// IAM_ISSUER_MAP: several ingress hosts (including iam.* aliases) collapse to ONE
// pinned issuer per brand, all served by the single iam2 instance.
const testIssuerMap = `{
"hanzo.id": "https://hanzo.id",
"iam.hanzo.ai": "https://hanzo.id",
"lux.id": "https://lux.id",
"iam.lux.network": "https://lux.id",
"id.zoo.network": "https://id.zoo.network",
"pars.id": "https://pars.id"
}`
// installIssuerResolver swaps a resolver built from (def, mapJSON) into the
// process for the duration of a test, restoring the prior resolver on cleanup —
// the SAME activeResolver seam InitIssuerResolver drives at startup, so an e2e
// request routes through exactly the production path.
// installIssuerResolver builds a PROD-like resolver (no dev host-relative opt-in);
// every caller configures a real issuer, so the dev branch is irrelevant here.
func installIssuerResolver(t *testing.T, def, mapJSON string) {
t.Helper()
r, err := newIssuerResolver(def, mapJSON, false)
if err != nil {
t.Fatalf("newIssuerResolver(%q, %q): %v", def, mapJSON, err)
}
prev := activeResolver.Swap(r)
t.Cleanup(func() { activeResolver.Store(prev) })
}
// The resolver maps each configured brand host (and alias) to its pinned issuer,
// normalizes case / whitespace / port, and — critically — FAILS CLOSED to the
// default for anything not configured, never echoing the input host.
func TestIssuerResolver_Resolve(t *testing.T) {
r, err := newIssuerResolver("https://hanzo.id", testIssuerMap, false)
if err != nil {
t.Fatalf("build resolver: %v", err)
}
for _, tc := range []struct{ name, host, want string }{
{"brand lux", "lux.id", "https://lux.id"},
{"brand hanzo", "hanzo.id", "https://hanzo.id"},
{"brand zoo", "id.zoo.network", "https://id.zoo.network"},
{"brand pars", "pars.id", "https://pars.id"},
{"alias to hanzo", "iam.hanzo.ai", "https://hanzo.id"},
{"alias to lux", "iam.lux.network", "https://lux.id"},
{"uppercase host", "LUX.ID", "https://lux.id"},
{"whitespace host", " lux.id ", "https://lux.id"},
{"host with port", "lux.id:443", "https://lux.id"},
{"trailing fqdn dot", "lux.id.", "https://lux.id"},
{"trailing dot with port", "lux.id.:443", "https://lux.id"},
{"unknown host → default", "evil.example", "https://hanzo.id"},
{"empty host → default", "", "https://hanzo.id"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := r.issuerFor(tc.host); got != tc.want {
t.Errorf("issuerFor(%q) = %q, want %q", tc.host, got, tc.want)
}
})
}
}
// The core security property: an unknown / attacker-chosen Host can NEVER produce
// an issuer derived from that Host. It always fails closed to the pinned default —
// including suffix-confusion hosts that merely CONTAIN a real brand.
func TestIssuerResolver_UnknownHostNeverEchoed(t *testing.T) {
r, err := newIssuerResolver("https://hanzo.id", testIssuerMap, false)
if err != nil {
t.Fatalf("build resolver: %v", err)
}
for _, evil := range []string{
"evil.example",
"attacker.test",
"lux.id.evil.example", // suffix of a brand, but not the brand
"hanzo.id.attacker", // prefix of a brand, but not the brand
"xn--80ak6aa92e.com", // punycode lookalike
} {
got := r.issuerFor(evil)
if got != "https://hanzo.id" {
t.Errorf("issuerFor(%q) = %q, want fail-closed default https://hanzo.id", evil, got)
}
if got == "https://"+evil {
t.Errorf("SECURITY: issuerFor(%q) echoed the attacker host into the issuer", evil)
}
}
}
// Backward compatibility: with no IAM_ISSUER_MAP the resolver is the pre-existing
// single-issuer — every host gets IAM_ISSUER — and with no config at all it is the
// pre-existing dev host-relative fallback (now from the trusted host).
func TestIssuerResolver_BackwardCompat(t *testing.T) {
t.Run("empty map, default set → single issuer for every host", func(t *testing.T) {
r, err := newIssuerResolver("https://hanzo.id", "", false)
if err != nil {
t.Fatal(err)
}
for _, h := range []string{"hanzo.id", "lux.id", "anything.example", "", "LUX.ID:8443"} {
if got := r.issuerFor(h); got != "https://hanzo.id" {
t.Errorf("issuerFor(%q) = %q, want https://hanzo.id (single-issuer)", h, got)
}
}
})
t.Run("empty map, trailing slash trimmed", func(t *testing.T) {
r, err := newIssuerResolver("https://hanzo.id/", "", false)
if err != nil {
t.Fatal(err)
}
if got := r.issuerFor("lux.id"); got != "https://hanzo.id" {
t.Errorf("issuerFor = %q, want https://hanzo.id (trailing slash trimmed)", got)
}
})
t.Run("whitespace-only map is treated as unset", func(t *testing.T) {
r, err := newIssuerResolver("https://hanzo.id", " \n\t ", false)
if err != nil {
t.Fatal(err)
}
if got := r.issuerFor("lux.id"); got != "https://hanzo.id" {
t.Errorf("issuerFor = %q, want https://hanzo.id", got)
}
})
t.Run("no config + dev opt-in → dev host-relative from trusted host", func(t *testing.T) {
r, err := newIssuerResolver("", "", true) // IAM_DEV_HOST_RELATIVE=1
if err != nil {
t.Fatal(err)
}
if got := r.issuerFor("dev.local"); got != "https://dev.local" {
t.Errorf("issuerFor(dev.local) = %q, want https://dev.local (dev host-relative)", got)
}
if got := r.issuerFor(""); got != "https://hanzo.id" {
t.Errorf("issuerFor(\"\") = %q, want https://hanzo.id (dev last-resort)", got)
}
})
}
// A malformed, fail-open, or non-https issuer config — or nothing pinned at all
// without the dev opt-in — is a hard error, so a misconfigured deploy fails LOUD
// at startup (InitIssuerResolver) rather than silently minting under the wrong /
// an attacker-influenced / a host-echoed `iss`.
func TestNewIssuerResolver_Errors(t *testing.T) {
for _, tc := range []struct {
name, def, mapJSON string
dev bool
}{
{name: "malformed json", def: "https://hanzo.id", mapJSON: `{not json}`},
{name: "map without default is fail-open, refused", def: "", mapJSON: `{"lux.id":"https://lux.id"}`},
{name: "map without default refused even WITH dev opt-in", def: "", mapJSON: `{"lux.id":"https://lux.id"}`, dev: true},
{name: "non-https map issuer", def: "https://hanzo.id", mapJSON: `{"lux.id":"http://lux.id"}`},
{name: "scheme-less map issuer", def: "https://hanzo.id", mapJSON: `{"lux.id":"lux.id"}`},
{name: "empty map issuer value", def: "https://hanzo.id", mapJSON: `{"lux.id":""}`},
{name: "empty host key", def: "https://hanzo.id", mapJSON: `{"":"https://lux.id"}`},
// Fix #3: a non-https / scheme-less DEFAULT is refused, the same bar map
// entries already clear — checked even when a valid map is present.
{name: "non-https default", def: "http://hanzo.id", mapJSON: ""},
{name: "scheme-less default", def: "hanzo.id", mapJSON: ""},
{name: "non-https default with a valid map", def: "http://hanzo.id", mapJSON: testIssuerMap},
// Fix #1: nothing pinned AND no dev opt-in ⇒ hard boot error, never a
// resolver that could echo the request host.
{name: "no issuer at all, no dev opt-in", def: "", mapJSON: "", dev: false},
} {
t.Run(tc.name, func(t *testing.T) {
if r, err := newIssuerResolver(tc.def, tc.mapJSON, tc.dev); err == nil {
t.Fatalf("newIssuerResolver(%q, %q, %v) = %+v, nil error; want error", tc.def, tc.mapJSON, tc.dev, r)
}
})
}
}
// Fix #1: with NOTHING pinned — no IAM_ISSUER, no IAM_ISSUER_MAP — construction is
// a hard boot error UNLESS the operator explicitly opts into host-relative dev
// issuers. "Never echo the request host into `iss`" is enforced in BOTH the
// constructor (fail-loud) and issuerFor (fail-closed), so no path can echo.
func TestIssuerResolver_FailLoudNoIssuer(t *testing.T) {
t.Run("no config, no opt-in → hard boot error", func(t *testing.T) {
if r, err := newIssuerResolver("", "", false); err == nil {
t.Fatalf(`newIssuerResolver("","",false) = %+v, nil; want a hard error`, r)
}
})
t.Run("no config, dev opt-in → host-relative issuer, no error", func(t *testing.T) {
r, err := newIssuerResolver("", "", true)
if err != nil {
t.Fatalf("dev opt-in should construct: %v", err)
}
if got := r.issuerFor("dev.local"); got != "https://dev.local" {
t.Errorf("issuerFor(dev.local) = %q, want https://dev.local", got)
}
})
t.Run("a hand-built def-less non-dev resolver still refuses to echo", func(t *testing.T) {
// Belt and suspenders: even bypassing the constructor, issuerFor never
// derives `iss` from the request host without the dev opt-in — it fails
// closed to the fixed default, never https://<host>.
r := &issuerResolver{} // def=="", devHostRelative==false
for _, evil := range []string{"evil.example", "lux.id.attacker", "hanzo.id.evil"} {
got := r.issuerFor(evil)
if got == "https://"+evil {
t.Fatalf("SECURITY: issuerFor(%q) echoed the request host", evil)
}
if got != devFallbackIssuer {
t.Errorf("issuerFor(%q) = %q, want fail-closed %q", evil, got, devFallbackIssuer)
}
}
})
}
// The boot function itself (what serve() calls) fails LOUD on a no-issuer /
// non-https config and boots only under a valid pin or the explicit dev opt-in.
// Drives the real env → InitIssuerResolver path, preserving the process resolver.
func TestInitIssuerResolver_FailLoud(t *testing.T) {
prev := activeResolver.Load()
t.Cleanup(func() { activeResolver.Store(prev) }) // no env-driven leak into the package
t.Run("no issuer + no opt-in → boot fails loud", func(t *testing.T) {
t.Setenv("IAM_ISSUER", "")
t.Setenv("IAM_ISSUER_MAP", "")
t.Setenv("IAM_DEV_HOST_RELATIVE", "")
if err := InitIssuerResolver(); err == nil {
t.Fatal("want a hard boot error when nothing pins the issuer")
}
})
t.Run("non-https IAM_ISSUER → boot fails loud", func(t *testing.T) {
t.Setenv("IAM_ISSUER", "http://hanzo.id")
t.Setenv("IAM_ISSUER_MAP", "")
t.Setenv("IAM_DEV_HOST_RELATIVE", "")
if err := InitIssuerResolver(); err == nil {
t.Fatal("want a hard boot error when IAM_ISSUER is not https")
}
})
t.Run("dev opt-in boots host-relative", func(t *testing.T) {
t.Setenv("IAM_ISSUER", "")
t.Setenv("IAM_ISSUER_MAP", "")
t.Setenv("IAM_DEV_HOST_RELATIVE", "1")
if err := InitIssuerResolver(); err != nil {
t.Fatalf("dev opt-in should boot: %v", err)
}
})
}
// End-to-end over the real HTTP surface: for each brand host, the `iss` a minted
// token carries EQUALS the discovery document's `issuer`, which EQUALS the base of
// its `jwks_uri` — the mutual consistency an RP that discovered via one brand host
// relies on. The single instance emits a DIFFERENT correct issuer per brand.
func TestIssuerResolver_E2E_PerBrandConsistency(t *testing.T) {
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
for _, tc := range []struct{ host, want string }{
{"lux.id", "https://lux.id"},
{"hanzo.id", "https://hanzo.id"},
{"iam.hanzo.ai", "https://hanzo.id"},
{"id.zoo.network", "https://id.zoo.network"},
{"pars.id", "https://pars.id"},
} {
tokenIss := mintClientCredsIssuer(t, app, db, tc.host)
discIss, jwksURI := discoveryIssuer(t, app, tc.host)
if tokenIss != tc.want {
t.Errorf("host %q: token iss = %q, want %q", tc.host, tokenIss, tc.want)
}
if discIss != tc.want {
t.Errorf("host %q: discovery issuer = %q, want %q", tc.host, discIss, tc.want)
}
if tokenIss != discIss {
t.Errorf("host %q: token iss %q != discovery issuer %q (split origin)", tc.host, tokenIss, discIss)
}
if jwksURI != tc.want+PathJWKS {
t.Errorf("host %q: jwks_uri = %q, want %q (JWKS split origin)", tc.host, jwksURI, tc.want+PathJWKS)
}
}
}
// End-to-end over HTTP: an unknown / spoofed Host fails closed to the default
// issuer and is never echoed, and a client-supplied X-Forwarded-Host cannot steer
// `iss` toward another configured brand — the trusted routed host wins.
func TestIssuerResolver_E2E_SpoofFailsClosed(t *testing.T) {
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
t.Run("unknown host → default, never echoed", func(t *testing.T) {
if iss := mintClientCredsIssuer(t, app, db, "evil.example"); iss != "https://hanzo.id" {
t.Fatalf("unknown host: iss = %q, want default https://hanzo.id", iss)
}
})
t.Run("X-Forwarded-Host cannot override the trusted host", func(t *testing.T) {
req := formReq("POST", PathToken, url.Values{
"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"},
})
req.Host = "hanzo.id" // the brand the ingress routed to
req.Header.Set("X-Forwarded-Host", "lux.id") // attacker-supplied
resp, body := do(t, app, req)
if resp.StatusCode != 200 {
t.Fatalf("mint status = %d, body = %s", resp.StatusCode, body)
}
access, _ := decode(t, body)["access_token"].(string)
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Issuer != "https://hanzo.id" {
t.Errorf("X-Forwarded-Host steered iss to %q, want https://hanzo.id", claims.Issuer)
}
})
}
// --- e2e helpers ---
// mintClientCredsIssuer mints a client_credentials token for app "svc" under the
// given request host and returns the verified `iss` claim it carries.
func mintClientCredsIssuer(t *testing.T, app *zip.App, db orm.DB, host string) string {
t.Helper()
req := formReq("POST", PathToken, url.Values{
"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"},
})
req.Host = host
resp, body := do(t, app, req)
access, _ := decode(t, body)["access_token"].(string)
if resp.StatusCode != 200 || access == "" {
t.Fatalf("mint under host %q: status=%d body=%s", host, resp.StatusCode, body)
}
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("verify token under host %q: %v", host, err)
}
return claims.Issuer
}
// discoveryIssuer fetches the discovery document under the given host and returns
// its `issuer` and `jwks_uri`.
func discoveryIssuer(t *testing.T, app *zip.App, host string) (issuer, jwksURI string) {
t.Helper()
req := formReqNoBody("GET", PathDiscovery)
req.Host = host
resp, body := do(t, app, req)
if resp.StatusCode != 200 {
t.Fatalf("discovery under host %q: status %d", host, resp.StatusCode)
}
d := decode(t, body)
issuer, _ = d["issuer"].(string)
jwksURI, _ = d["jwks_uri"].(string)
return issuer, jwksURI
}
+334
View File
@@ -0,0 +1,334 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/subtle"
"os"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The confidential-client on-behalf-of primitives. A trusted, allow-listed backend
// (the console BFF as `hanzo-console`) authenticates as the confidential CLIENT —
// not an end-user bearer — and acts on a `?id=<owner>/<name>` target user: mint a
// short-lived user-bound access token (`issue-user-token`), or (re)generate/revoke
// the user's durable `hk-` Cloud API key.
//
// `issue-user-token` is the CANONICAL FORWARD path's transitional twin: the RFC
// 8693 Token Exchange grant on /oauth/token is the standard (HIP-0111), and this
// verb is the COMPAT SHIM the console still calls (identity.ts `issueUserToken` →
// `adminBearer` backs EVERY /v1/* BFF proxy call). It mints over the exact same
// authorizeMinter allow-list + reserved-org gate + SignUserToken as token exchange
// — same authority, same audit — so the console works unchanged during the cutover,
// then migrates to grant_type=token-exchange and this shim is retired. API keys are
// a PRODUCT credential (no IETF standard), a first-party primitive.
//
// They are NOT Bearer-gated (they live in the PUBLIC group, before the Guard); each
// does its own tighter authentication through the ONE authorizeMinter seam.
const (
PathIssueUserToken = "/v1/iam/issue-user-token"
PathMintUserKeys = "/v1/iam/mint-user-keys"
PathRevokeUserKeys = "/v1/iam/revoke-user-keys"
)
// routeIssueToken registers the confidential-client primitives on the PUBLIC group
// r. POST-only: they mint/rotate a credential — never over a cacheable GET (a
// client_secret in a query string would reach logs/proxies).
func routeIssueToken(r zip.Router, db orm.DB) {
r.Post(PathIssueUserToken, issueUserTokenHandler(db))
r.Post(PathMintUserKeys, mintUserKeysHandler(db))
r.Post(PathRevokeUserKeys, revokeUserKeysHandler(db))
}
// issueUserTokenHandler mints an access token for the `?id=<owner>/<name>` target
// user (optional `?aud=` resource, RFC 8707), issued by the authenticated +
// allow-listed confidential client. The token's subject + owner are the TARGET
// USER's, so a resource server scopes on the validated owner claim to the user's
// tenant — indistinguishable from a token the user obtained directly. Response is
// the camelCase `{accessToken, expiresIn}` body identity.ts consumes. Equivalent to
// the RFC 8693 token-exchange grant, minus the subject_token proof (the console has
// the user's id, not a token) — the reason this compat shim exists.
func issueUserTokenHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
now := nowFunc()
clientApp, status, msg := authorizeMinter(ctx, db, c)
if status != 0 {
return mintErr(c, status, msg)
}
user, status, msg := mintTarget(ctx, db, c, clientApp)
if status != 0 {
return mintErr(c, status, msg)
}
aud := strings.TrimSpace(c.Query("aud"))
if aud == "" {
aud = defaultUserAudience(ctx, db, user, clientApp)
}
signer, err := signerFor(ctx, db, clientApp, tokenIssuer(c))
if err != nil {
return mintErr(c, 500, "server_error")
}
ttl := appTTL(clientApp)
subject := user.Owner + "/" + user.Name
display := user.DisplayName
if display == "" {
display = user.Name
}
access, err := signer.SignUserToken(subject, user.Owner, aud, clientApp.ClientId, user.Email, display, "", ttl, now)
if err != nil {
return mintErr(c, 500, "server_error")
}
row := &schema.Token{
Owner: user.Owner,
Application: clientApp.Name,
Organization: user.Owner,
User: subject,
TokenType: "Bearer",
ExpiresIn: int(ttl.Seconds()),
AccessTokenHash: hashToken(access),
}
row.Name = "iut-" + hashToken(access)[:32]
if err := store.PersistToken(ctx, db, row); err != nil {
return mintErr(c, 500, "server_error")
}
auditMint(ctx, db, c, "issue-user-token", clientApp.ClientId, subject)
return httpx.Ok(c, map[string]any{
"accessToken": access,
"expiresIn": int(ttl.Seconds()),
})
}
}
// mintUserKeysHandler (re)generates the target user's durable `hk-` Cloud API key
// (schema.User.AccessKey) and returns it once, over the shared authorizeMinter +
// mintTarget seam.
func mintUserKeysHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
clientApp, status, msg := authorizeMinter(ctx, db, c)
if status != 0 {
return mintErr(c, status, msg)
}
user, status, msg := mintTarget(ctx, db, c, clientApp)
if status != 0 {
return mintErr(c, status, msg)
}
key, err := newAccessKey()
if err != nil {
return mintErr(c, 500, "server_error")
}
user.AccessKey = key
user.UpdatedTime = nowFunc().UTC().Format(time.RFC3339)
if err := saveUser(ctx, db, user); err != nil {
return mintErr(c, 500, "server_error")
}
auditMint(ctx, db, c, "mint-user-keys", clientApp.ClientId, user.Owner+"/"+user.Name)
return httpx.Ok(c, map[string]any{"accessKey": key})
}
}
// revokeUserKeysHandler clears the target user's `hk-` key (immediate revoke).
func revokeUserKeysHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
clientApp, status, msg := authorizeMinter(ctx, db, c)
if status != 0 {
return mintErr(c, status, msg)
}
user, status, msg := mintTarget(ctx, db, c, clientApp)
if status != 0 {
return mintErr(c, status, msg)
}
user.AccessKey = ""
user.AccessSecret = ""
user.AccessSecretHash = ""
user.UpdatedTime = nowFunc().UTC().Format(time.RFC3339)
if err := saveUser(ctx, db, user); err != nil {
return mintErr(c, 500, "server_error")
}
auditMint(ctx, db, c, "revoke-user-keys", clientApp.ClientId, user.Owner+"/"+user.Name)
return httpx.Ok(c, map[string]any{"affected": true})
}
}
// authorizeMinter is the ONE authentication seam for the confidential-client
// primitives: it authenticates the client (client_secret_basic or _post,
// constant-time) and enforces the mint allow-list. status==0 means authorized and
// returns the client app; otherwise (status, msg) is the response to render. It
// never reveals WHICH check failed beyond auth-vs-permission (401 vs 403).
func authorizeMinter(ctx context.Context, db orm.DB, c *zip.Ctx) (*schema.Application, int, string) {
clientID, clientSecret := clientAuth(c)
if clientID == "" {
return nil, 401, "client authentication required"
}
app, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return nil, 500, "server_error"
}
if app == nil || app.ClientSecret == "" ||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return nil, 401, "client authentication failed"
}
// The capability gate: only an ALLOW-LISTED, admin-owned app may act on a user's
// behalf. Fail closed — an unset allow-list permits NOTHING (these hand out /
// rotate a user's credential; a missing config must never mean "anyone"). Keyed on
// the resolved app's globally-unique clientId AND pinned to its signing owner (see
// mintAllowed), so a colliding-clientId tenant app is refused here.
if !mintAllowed(app) {
return nil, 403, "client is not on the user-key mint allow-list"
}
return app, 0, ""
}
// mintTarget resolves and validates the `?id=<owner>/<name>` target user for the
// authenticated clientApp. A missing id or absent user is a v1 business error
// (200 + status:error); a revoked (forbidden/deleted) user is a 403 — no
// credential is ever minted for it. A RESERVED-org (admin/built-in) target — a
// cross-tenant / SuperAdmin identity — additionally requires the separate
// admin-mint capability, so even a valid general minter cannot reach an admin-org
// user unless explicitly granted (defense-in-depth behind the mint allow-list).
func mintTarget(ctx context.Context, db orm.DB, c *zip.Ctx, clientApp *schema.Application) (*schema.User, int, string) {
owner, name := splitSub(c.Query("id"))
if owner == "" || name == "" {
return nil, 200, "id (owner/name) is required"
}
if store.IsSigningCertOwner(owner) && !adminMintAllowed(clientApp) {
return nil, 403, "client is not permitted to act for a reserved-org user"
}
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return nil, 500, "server_error"
}
if user == nil {
return nil, 200, "the user does not exist"
}
if user.IsForbidden || user.IsDeleted {
return nil, 403, "the user is forbidden"
}
return user, 0, ""
}
// auditMint best-effort records a confidential-primitive event — the
// accountability trail for WHO (minter clientId) issued/rotated a credential for
// WHOM (target subject). Emitted only on success. A failed audit write never
// fails the operation (the credential was already issued); it is a record, not a
// gate.
func auditMint(ctx context.Context, db orm.DB, c *zip.Ctx, action, minterClientID, targetSub string) {
name, err := newOpaqueToken()
if err != nil {
return
}
owner, _ := splitSub(targetSub)
log := orm.New[schema.AuditLog](db)
log.Owner = owner
log.Name = name
log.CreatedTime = nowFunc().UTC().Format(time.RFC3339)
log.Organization = owner
log.User = targetSub
log.Action = action
log.Object = minterClientID
log.Method = "POST"
log.RequestUri = c.Path()
log.StatusCode = 200
log.IsTriggered = true
log.SetId(owner + "/" + name)
_ = log.CreateCtx(ctx)
}
// mintErr renders the v1 error envelope with a correct HTTP status (the SDK
// branches on status; a business error rides a 200, an auth/permission failure
// its real 401/403).
func mintErr(c *zip.Ctx, status int, msg string) error {
return c.JSON(status, httpx.Response{Status: "error", Msg: msg})
}
// mintAllowed reports whether app may act on a user's behalf. TWO conditions, both
// required: its OWNING org must be a reserved platform signing owner (admin/built-in),
// AND its clientId must be on IAM_KEY_MINT_ALLOWED_APPS. The owner-pin is the decisive
// gate — clientId and secret are body-supplied at registration, so a tenant could
// register an app whose clientId collides with a mint-listed one and, on a backend
// whose duplicate-row order is unspecified, have its row resolve and its known secret
// authenticate; but its owner is its OWN tenant, never a signing owner, so it mints
// nothing. (Resolution is additionally admin-preferring and clientId is unique on
// create, so the collision cannot arise nor win — this is the third, innermost gate.)
// Every legit minter is admin-owned, so no legitimate grant regresses. Empty/unset
// list allows nothing — fail closed.
func mintAllowed(app *schema.Application) bool {
return store.IsSigningCertOwner(app.Owner) && appInList("IAM_KEY_MINT_ALLOWED_APPS", app.ClientId)
}
// adminMintAllowed reports whether app may act on behalf of a RESERVED-org
// (admin/built-in) user — a strictly narrower, separately-granted capability than the
// general mint list, so a leaked general-minter secret can never reach a SuperAdmin
// identity. Same owner-pin as mintAllowed: the app must be admin/built-in owned. The
// console, which legitimately drives admin.hanzo.ai, is on both lists. Fail closed.
func adminMintAllowed(app *schema.Application) bool {
return store.IsSigningCertOwner(app.Owner) && appInList("IAM_ADMIN_MINT_ALLOWED_APPS", app.ClientId)
}
// appInList matches clientID against a comma/space-separated env allow-list, by
// exact clientId. Empty/unset → false (fail closed).
func appInList(env, clientID string) bool {
if clientID == "" {
return false
}
raw := os.Getenv(env)
if strings.TrimSpace(raw) == "" {
return false
}
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' }) {
if item == clientID {
return true
}
}
return false
}
// defaultUserAudience is the audience a user token carries when the caller names
// no explicit resource: the target user's own application's clientId (a same-app
// consumer accepts it), falling back to the minting client when the user's app
// can't be resolved — the caller then pins `?aud=` for a cross-app resource.
func defaultUserAudience(ctx context.Context, db orm.DB, user *schema.User, clientApp *schema.Application) string {
if user.SignupApplication != "" {
// Applications are platform-owned (owner "admin").
if ua, err := store.GetApplicationByName(ctx, db, "admin", user.SignupApplication); err == nil && ua != nil {
return ua.ClientId
}
}
return clientApp.ClientId
}
// newAccessKey mints an `hk-`-prefixed Cloud API key (the durable credential the
// gateway recognizes), a cryptographically-random opaque token behind the prefix.
func newAccessKey() (string, error) {
tok, err := newOpaqueToken()
if err != nil {
return "", err
}
return "hk-" + tok, nil
}
// saveUser read-modify-writes the mutated user row by its (owner, name) key,
// preserving every other field (orm persists the whole record).
func saveUser(ctx context.Context, db orm.DB, user *schema.User) error {
existing, err := orm.Get[schema.User](db, user.Owner+"/"+user.Name)
if err != nil {
return err
}
model := existing.Model
*existing = *user
existing.Model = model
return existing.UpdateCtx(ctx)
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
)
// Red-team helpers + the field-preservation guard for the on-behalf-of primitives.
// The name-collision priv-esc PoC that motivated the clientId-only allow-list is
// now the regression guard TestTokenExchange_nameCollisionAttacker_403 (the
// issue-user-token verb it originally attacked is retired in favor of RFC 8693
// Token Exchange). seedAttackerApp stays here — it models exactly what a tenant
// org-admin can create through POST /v1/iam/application (every field bound from
// the body), the precondition that test relies on.
// seedAttackerApp creates a tenant-owned application the attacker fully controls
// (owner=evil, chosen name/clientId/secret) whose Cert points at an EXISTING
// trusted platform cert by name — exactly what internal/applications.create binds
// from the request body (Owner/Name/ClientId/ClientSecret/Cert all verbatim).
func seedAttackerApp(t *testing.T, db orm.DB, owner, name, clientID, secret, platformCert string) {
t.Helper()
a := orm.New[schema.Application](db)
a.Owner = owner // a NON-reserved tenant org the attacker org-admins
a.Name = name
a.ClientId = clientID
a.ClientSecret = secret
a.Organization = owner
a.Cert = platformCert // resolved among admin/built-in by GetSigningCert
a.ExpireInHours = 1
a.SetId(owner + "/" + name)
if err := a.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed attacker app: %v", err)
}
}
// TestRedTeam_mintKeys_preservesPasswordHashAndIsAdmin proves the mint/revoke
// read-modify-write (saveUser) does not blank PasswordHash nor flip privilege
// bits. GetUserByName returns the FULL row (no mask), so *existing = *user
// preserves every field the handler didn't touch.
func TestRedTeam_mintKeys_preservesPasswordHashAndIsAdmin(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
u := orm.New[schema.User](db)
u.Owner, u.Name, u.Email = "hanzo", "carol", "carol@hanzo.ai"
u.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=4$SALTSALT$HASHHASHHASH"
u.PasswordType = "argon2id"
u.IsAdmin = true
u.SetId("hanzo/carol")
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed: %v", err)
}
if resp, body := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/carol")); resp.StatusCode != 200 {
t.Fatalf("mint status=%d body=%s", resp.StatusCode, body)
}
got, err := orm.Get[schema.User](db, "hanzo/carol")
if err != nil {
t.Fatalf("reload: %v", err)
}
if got.PasswordHash != u.PasswordHash {
t.Errorf("PasswordHash mutated by mint: %q", got.PasswordHash)
}
if got.PasswordType != "argon2id" {
t.Errorf("PasswordType mutated by mint: %q", got.PasswordType)
}
if !got.IsAdmin {
t.Errorf("IsAdmin flipped false by mint")
}
if got.AccessKey == "" {
t.Errorf("mint did not set AccessKey")
}
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The `hk-` Cloud API-key primitives (mint/revoke). A confidential, allow-listed
// client (client_secret_basic) acts on a ?id=<owner>/<name> target user. These are
// a product credential, not an RFC token flow — the on-behalf-of TOKEN minting is
// RFC 8693 Token Exchange (token_exchange_test.go).
// seedForbiddenUser seeds a revoked (forbidden) user — no credential may be minted
// or rotated for it.
func seedForbiddenUser(t *testing.T, db orm.DB, owner, name string) {
t.Helper()
u := orm.New[schema.User](db)
u.Owner, u.Name = owner, name
u.IsForbidden = true
u.SetId(owner + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed forbidden user %s/%s: %v", owner, name, err)
}
}
// keyReq builds a POST to a key primitive authenticating clientID/secret via Basic.
func keyReq(path, clientID, secret, query string) *http.Request {
req := httptest.NewRequest("POST", path+query, nil)
req.Host = "hanzo.id"
if clientID != "" {
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
}
return req
}
// dataMap pulls the `data` object out of the v1 envelope.
func dataMap(t *testing.T, body []byte) map[string]any {
t.Helper()
m := decode(t, body)
d, _ := m["data"].(map[string]any)
return d
}
// issue-user-token is the compat shim the console's adminBearer depends on: an
// allow-listed confidential client mints a token bound to the ?id= target user.
func TestIssueUserToken_mintsTargetUserToken(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
resp, body := do(t, app, keyReq(PathIssueUserToken, "hanzo-console", "top-secret", "?id=hanzo/alice&aud=hanzo-cloud"))
if resp.StatusCode != 200 {
t.Fatalf("status = %d; body=%s", resp.StatusCode, body)
}
access, _ := dataMap(t, body)["accessToken"].(string)
if access == "" {
t.Fatalf("no accessToken; body=%s", body)
}
// The minted token verifies under the JWKS and carries the TARGET user's identity.
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("minted token does not verify: %v", err)
}
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
t.Fatalf("subject/owner = %q/%q, want hanzo/alice / hanzo", claims.Subject, claims.Owner)
}
if exp, _ := dataMap(t, body)["expiresIn"].(float64); exp <= 0 {
t.Fatalf("expiresIn = %v, want > 0", dataMap(t, body)["expiresIn"])
}
}
func TestIssueUserToken_notAllowlisted_403(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "other-app")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
resp, _ := do(t, app, keyReq(PathIssueUserToken, "hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 403 {
t.Fatalf("off-allow-list issue-user-token = %d, want 403", resp.StatusCode)
}
}
func TestMintUserKeys_generatesReadableHkKey(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
resp, body := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 200 {
t.Fatalf("status = %d; body=%s", resp.StatusCode, body)
}
key, _ := dataMap(t, body)["accessKey"].(string)
if !strings.HasPrefix(key, "hk-") || len(key) < 8 {
t.Fatalf("accessKey = %q, want an hk- key", key)
}
// The minted key is persisted on the user row (get-user / getUserKey read it).
u, err := store.GetUserByName(context.Background(), db, "hanzo", "alice")
if err != nil || u == nil {
t.Fatalf("reload user: %v", err)
}
if u.AccessKey != key {
t.Fatalf("persisted AccessKey = %q, want the minted %q", u.AccessKey, key)
}
}
func TestRevokeUserKeys_clearsTheKey(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
resp, body := do(t, app, keyReq(PathRevokeUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
t.Fatalf("revoke status = %d; body=%s", resp.StatusCode, body)
}
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
if u.AccessKey != "" {
t.Fatalf("AccessKey after revoke = %q, want empty", u.AccessKey)
}
}
func TestMintUserKeys_notAllowlisted_403(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "other")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
resp, _ := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 403 {
t.Fatalf("off-allow-list mint status = %d, want 403", resp.StatusCode)
}
}
func TestMintUserKeys_forbiddenUser_403(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedForbiddenUser(t, db, "hanzo", "banned")
resp, _ := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/banned"))
if resp.StatusCode != 403 {
t.Fatalf("forbidden-user mint status = %d, want 403", resp.StatusCode)
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The JSON Web Key Set: the public half of every active signing Cert, so relying
// parties verify the tokens iam2 issues. This is the load-bearing interop
// surface — the live hanzo.id JWKS publishes one RSA (RS256) key per Cert, keyed
// by `kid` = the Cert name, and every existing verifier reads it. Keys are
// deduplicated by kid and ordered stably; the response carries a strong ETag and
// a 60s cache, matching live.
// signingAlgs is the set of JOSE algorithms iam2 publishes signing keys for.
// A Cert whose CryptoAlgorithm is outside this set (e.g. an ACME/SSL TLS cert)
// is not a token-signing key and is excluded from the JWKS.
var signingAlgs = map[string]bool{
"RS256": true, "RS512": true,
"ES256": true, "ES384": true, "ES512": true,
"MLDSA65": true,
}
// jwksHandler serves GET /v1/iam/.well-known/jwks.
func jwksHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
certs, err := store.ListCerts(c.Context(), db)
if err != nil {
return c.JSON(500, map[string]string{"error": "server_error"})
}
keys := make([]any, 0, len(certs))
seen := make(map[string]bool, len(certs))
for _, cert := range certs {
if !isSigningCert(cert) || seen[cert.Name] {
continue
}
jwk, err := certToJWK(cert)
if err != nil {
continue // a cert we cannot encode never fails the whole set
}
seen[cert.Name] = true
keys = append(keys, jwk)
}
body, err := json.Marshal(map[string]any{"keys": keys})
if err != nil {
return c.JSON(500, map[string]string{"error": "server_error"})
}
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
c.SetHeader("Cache-Control", "public, max-age=60")
c.SetHeader("ETag", etag)
if c.Header("If-None-Match") == etag {
return c.NoContent(304)
}
c.SetHeader("Content-Type", "application/json")
return c.Bytes(200, body)
}
}
// isSigningCert reports whether a Cert is a token-signing key that belongs in the
// JWKS: it must be owned by a reserved platform org (so a tenant cannot publish a
// key under a colliding kid), carry key material and a recognized signing
// algorithm, and not be a TLS/SSL certificate.
func isSigningCert(cert *schema.Cert) bool {
if cert == nil || cert.Name == "" {
return false
}
if !store.IsSigningCertOwner(cert.Owner) {
return false
}
if cert.PrivateKey == "" && cert.Certificate == "" {
return false
}
if strings.EqualFold(cert.Type, "SSL") {
return false
}
return signingAlgs[strings.ToUpper(strings.ReplaceAll(cert.CryptoAlgorithm, "-", ""))]
}
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/rand"
"encoding/base64"
"math/big"
"testing"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
)
// seedMLDSACert creates an ML-DSA-65 signing cert (raw base64 private key).
func seedMLDSACert(t *testing.T, db orm.DB, name string) {
t.Helper()
_, sk, err := mldsa65.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("mldsa keygen: %v", err)
}
c := orm.New[schema.Cert](db)
c.Owner = "admin"
c.Name = name
c.CryptoAlgorithm = "MLDSA65"
c.PrivateKey = base64.StdEncoding.EncodeToString(sk.Bytes())
c.SetId("admin/" + name)
if err := c.CreateCtx(tctx()); err != nil {
t.Fatalf("seed mldsa cert: %v", err)
}
}
// A fresh server with no signing certs still serves a well-formed, empty key set
// — the guard against the earlier bug where JWKS was empty yet discovery
// advertised signing algorithms, so verifiers could never resolve a key.
func TestJWKS_EmptyButWellFormed(t *testing.T) {
app, _ := newServer(t)
resp, body := do(t, app, formReqNoBody("GET", PathJWKS))
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
set := decode(t, body)
if keys, ok := set["keys"].([]any); !ok || len(keys) != 0 {
t.Fatalf("empty JWKS = %v, want an empty keys array", set["keys"])
}
}
// The RSA signing key is published with the exact shape RS256 verifiers read —
// kty/alg/use/kid/n/e — and never any private material.
func TestJWKS_PublishesRSAPublicKey(t *testing.T) {
app, db := newServer(t)
seedRSACert(t, db, "cert-hanzo")
resp, body := do(t, app, formReqNoBody("GET", PathJWKS))
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=60" {
t.Errorf("Cache-Control = %q", cc)
}
if resp.Header.Get("ETag") == "" {
t.Error("JWKS must carry a strong ETag")
}
k := jwkByKid(t, body, "cert-hanzo")
if k["kty"] != "RSA" || k["alg"] != "RS256" || k["use"] != "sig" {
t.Errorf("jwk header wrong: %v", k)
}
// n encodes the real modulus.
nb, err := base64.RawURLEncoding.DecodeString(k["n"].(string))
if err != nil {
t.Fatalf("decode n: %v", err)
}
if new(big.Int).SetBytes(nb).Cmp(sharedKey(t).N) != 0 {
t.Error("jwk modulus does not match the signing key")
}
// Private material must never appear.
for _, secret := range []string{"d", "p", "q", "dp", "dq", "qi"} {
if _, bad := k[secret]; bad {
t.Fatalf("JWKS leaked private RSA parameter %q", secret)
}
}
}
// A conditional GET with the current ETag is answered 304 (parity with live).
func TestJWKS_ETag304(t *testing.T) {
app, db := newServer(t)
seedRSACert(t, db, "cert-hanzo")
resp, _ := do(t, app, formReqNoBody("GET", PathJWKS))
etag := resp.Header.Get("ETag")
req := formReqNoBody("GET", PathJWKS)
req.Header.Set("If-None-Match", etag)
resp2, _ := do(t, app, req)
if resp2.StatusCode != 304 {
t.Fatalf("conditional GET status = %d, want 304", resp2.StatusCode)
}
}
// A post-quantum ML-DSA-65 cert is published as {kty:MLDSA, alg:MLDSA65, x}.
func TestJWKS_PublishesMLDSAKey(t *testing.T) {
app, db := newServer(t)
seedMLDSACert(t, db, "cert-pq")
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
k := jwkByKid(t, body, "cert-pq")
if k["kty"] != "MLDSA" || k["alg"] != "MLDSA65" || k["use"] != "sig" {
t.Errorf("mldsa jwk header wrong: %v", k)
}
if x, _ := k["x"].(string); x == "" {
t.Error("mldsa jwk missing raw public key x")
}
}
// A TLS/SSL certificate is not a token-signing key and is excluded.
func TestJWKS_ExcludesTLSCert(t *testing.T) {
app, db := newServer(t)
seedRSACert(t, db, "cert-hanzo")
c := orm.New[schema.Cert](db)
c.Owner = "admin"
c.Name = "cert-tls"
c.Type = "SSL"
c.CryptoAlgorithm = "RS256"
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
c.SetId("admin/cert-tls")
if err := c.CreateCtx(tctx()); err != nil {
t.Fatal(err)
}
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
if hasKid(t, body, "cert-tls") {
t.Fatal("TLS cert must not appear in the JWKS")
}
if !hasKid(t, body, "cert-hanzo") {
t.Fatal("signing cert missing from JWKS")
}
}
// A cert owned by a non-platform org is never published, so a tenant cannot
// inject a signing key under a chosen kid.
func TestJWKS_ExcludesNonPlatformCert(t *testing.T) {
app, db := newServer(t)
seedRSACert(t, db, "cert-hanzo") // admin-owned, trusted
c := orm.New[schema.Cert](db)
c.Owner = "attacker-org"
c.Name = "cert-evil"
c.CryptoAlgorithm = "RS256"
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
c.SetId("attacker-org/cert-evil")
if err := c.CreateCtx(tctx()); err != nil {
t.Fatal(err)
}
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
if hasKid(t, body, "cert-evil") {
t.Fatal("a non-platform cert must not appear in the JWKS")
}
if !hasKid(t, body, "cert-hanzo") {
t.Fatal("platform signing cert missing from JWKS")
}
}
// Keys are deduplicated by kid so a name reused across owners publishes once.
func TestJWKS_DedupesByKid(t *testing.T) {
app, db := newServer(t)
// Two TRUSTED platform owners hold a cert of the same name; the JWKS must
// publish that kid exactly once.
for _, owner := range []string{"admin", "built-in"} {
c := orm.New[schema.Cert](db)
c.Owner = owner
c.Name = "cert-shared"
c.CryptoAlgorithm = "RS256"
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
c.SetId(owner + "/cert-shared")
if err := c.CreateCtx(tctx()); err != nil {
t.Fatal(err)
}
}
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
set := decode(t, body)
keys, _ := set["keys"].([]any)
count := 0
for _, k := range keys {
if k.(map[string]any)["kid"] == "cert-shared" {
count++
}
}
if count != 1 {
t.Fatalf("kid cert-shared published %d times, want 1", count)
}
}
// --- helpers ---
func jwkByKid(t *testing.T, body []byte, kid string) map[string]any {
t.Helper()
set := decode(t, body)
keys, _ := set["keys"].([]any)
for _, k := range keys {
m := k.(map[string]any)
if m["kid"] == kid {
return m
}
}
t.Fatalf("kid %q not found in JWKS %s", kid, string(body))
return nil
}
func hasKid(t *testing.T, body []byte, kid string) bool {
t.Helper()
set := decode(t, body)
keys, _ := set["keys"].([]any)
for _, k := range keys {
if k.(map[string]any)["kid"] == kid {
return true
}
}
return false
}
+328
View File
@@ -0,0 +1,328 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/iam/internal/schema"
)
// JWT token signing. The signing algorithm is a property of the signing Cert's
// key, not a global: an RSA cert signs RS256 (the interoperable default that the
// live hanzo.id JWKS serves), an EC cert signs ES256/384/512, and a post-quantum
// ML-DSA-65 cert signs MLDSA65 (mldsa.go, behind the same jwt.SigningMethod
// seam). The classical path is the load-bearing interop path — every existing
// verifier reads the RS256 keys published in the JWKS; ML-DSA is additive and
// inert until an ML-DSA Cert is configured. Keys come from the Cert entity
// (KMS-backed); tests inject an ephemeral in-memory key through the same path.
// Claims is the iam2 token claim set: the standard registered claims plus the
// Hanzo first-class claims the SDK and downstream validators read. owner and
// organization are the tenant (both the org slug); scope carries the granted
// scopes; nonce is echoed into the id_token; tokenType distinguishes an
// access-token from an id-token. A field is emitted only when populated, so one
// struct serves both token shapes without leaking empty claims.
type Claims struct {
jwt.RegisteredClaims
Scope string `json:"scope,omitempty"`
Owner string `json:"owner,omitempty"`
Organization string `json:"organization,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Nonce string `json:"nonce,omitempty"`
Azp string `json:"azp,omitempty"`
TokenType string `json:"tokenType,omitempty"`
}
// Signer signs tokens with one key under one algorithm. Immutable after
// construction; the (method, key, kid, alg) tuple is fixed to the Cert it was
// built from so a token can never be signed under a key/alg mismatch.
type Signer struct {
method jwt.SigningMethod
key any // *rsa.PrivateKey | *ecdsa.PrivateKey | *mldsa65.PrivateKey
kid string // JWKS key id — the Cert name
alg string // JOSE alg — "RS256" | "ES256" | … | "MLDSA65"
issuer string
}
// NewSignerFromCert builds a Signer from a Cert, selecting the algorithm from
// the cert's key type: RSA → RS256 (or RS512 when the app pins it), EC → ES256/
// ES384/ES512 by curve, ML-DSA → MLDSA65. issuer is the canonical OIDC issuer
// (https://<host>) that discovery advertises; it is pinned into every token so
// id_token `iss` matches the discovery document. app may be nil (the method is
// then chosen purely from the key type).
func NewSignerFromCert(cert *schema.Cert, app *schema.Application, issuer string) (*Signer, error) {
if cert == nil {
return nil, errors.New("jwt: nil cert")
}
// Post-quantum ML-DSA-65 cert: raw key material, own signing method.
if isMLDSACert(cert) {
key, err := parseMLDSA65PrivateKey(cert.PrivateKey)
if err != nil {
return nil, err
}
return &Signer{method: SigningMethodMLDSA65, key: key, kid: cert.Name, alg: algMLDSA65, issuer: issuer}, nil
}
if cert.PrivateKey == "" {
return nil, errors.New("jwt: cert has no private key")
}
key, err := parsePrivateKeyPEM(cert.PrivateKey)
if err != nil {
return nil, err
}
method, alg, err := methodForKey(key, pinnedMethod(app))
if err != nil {
return nil, err
}
return &Signer{method: method, key: key, kid: cert.Name, alg: alg, issuer: issuer}, nil
}
// NewRSASignerFromCert builds an RS256 Signer from a Cert whose PrivateKey is a
// PEM RSA key. Retained as the explicit RSA constructor; NewSignerFromCert is
// the general dispatch used by the token endpoint.
func NewRSASignerFromCert(cert *schema.Cert, issuer string) (*Signer, error) {
if cert == nil || cert.PrivateKey == "" {
return nil, errors.New("jwt: cert has no private key")
}
key, err := parseRSAPrivateKeyPEM(cert.PrivateKey)
if err != nil {
return nil, err
}
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: cert.Name, alg: "RS256", issuer: issuer}, nil
}
// NewRSASigner builds an RS256 Signer directly from an RSA key (tests and, in
// dev, an ephemeral key when no Cert is configured).
func NewRSASigner(key *rsa.PrivateKey, kid, issuer string) *Signer {
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: kid, alg: "RS256", issuer: issuer}
}
// Sign issues a signed access token for (app, user) with the given scope. now is
// injected for testability; ttl is the token lifetime. The audience is the app's
// clientId (validators fail closed when aud != clientId).
func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
jti, err := newOpaqueToken()
if err != nil {
return "", err
}
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.issuer,
Subject: userID,
Audience: audienceFor(app, ""),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
Azp: app.ClientId,
TokenType: "access-token",
}
return s.signClaims(claims)
}
// SignUserToken mints an access token a confidential client issues ON BEHALF OF a
// target user — the RFC 8693 Token Exchange grant. Unlike Sign (which stamps the
// APP's org as the owner claim), every authority claim here is the TARGET USER's:
// the subject and owner are the user's, so a resource server that scopes on the
// validated `owner` claim (cloud's SanitizeIdentity) scopes to the USER's tenant,
// never the minting client's. `aud` is the caller-resolved audience (an explicit
// RFC 8707 resource, else the user's own app) and `azp` records the minting
// client. Signed under this signer's trusted cert + canonical issuer, so the same
// JWKS verifies it — the token is indistinguishable from one the user obtained
// directly, which is the point. The Signer stays decoupled from schema.User: the
// handler resolves and passes the values it authorized.
func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
jti, err := newOpaqueToken()
if err != nil {
return "", err
}
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.issuer,
Subject: subject,
Audience: jwt.ClaimStrings{aud},
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
NotBefore: jwt.NewNumericDate(now),
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Scope: scope,
Owner: owner,
Organization: owner,
Email: email,
Name: name,
Azp: azp,
TokenType: "access-token",
}
return s.signClaims(claims)
}
// SignID issues an OIDC id_token for (app, user). It differs from the access
// token by carrying the echoed nonce and by declaring tokenType "id-token"; the
// audience is the client the token was minted for (the RP), and iss matches the
// discovery issuer so a standard OIDC client validates it.
func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, nonce string, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
jti, err := newOpaqueToken()
if err != nil {
return "", err
}
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.issuer,
Subject: userID,
Audience: jwt.ClaimStrings{app.ClientId},
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ID: jti,
},
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
Nonce: nonce,
Azp: app.ClientId,
TokenType: "id-token",
}
return s.signClaims(claims)
}
// signClaims is the single choke point that turns a claim set into a signed
// compact JWS under this signer's fixed (method, key, kid).
func (s *Signer) signClaims(claims Claims) (string, error) {
tok := jwt.NewWithClaims(s.method, claims)
if s.kid != "" {
tok.Header["kid"] = s.kid
}
return tok.SignedString(s.key)
}
// PublicKey returns the signer's RSA public key, or nil for a non-RSA signer
// (JWKS + verification read the public key from the Cert, not the Signer).
func (s *Signer) PublicKey() *rsa.PublicKey {
if k, ok := s.key.(*rsa.PrivateKey); ok {
return &k.PublicKey
}
return nil
}
// Kid returns the key id (the Cert name).
func (s *Signer) Kid() string { return s.kid }
// Alg returns the JOSE algorithm this signer uses (matches the JWKS `alg`).
func (s *Signer) Alg() string { return s.alg }
// audienceFor computes the token audience per RFC 8707: an explicit resource
// indicator wins; a shared application scopes the audience to the org; otherwise
// the audience is the client id (the value validators check).
func audienceFor(app *schema.Application, resource string) jwt.ClaimStrings {
if resource != "" {
return jwt.ClaimStrings{resource}
}
if app.IsShared && app.Organization != "" {
return jwt.ClaimStrings{app.ClientId + "-org-" + app.Organization}
}
return jwt.ClaimStrings{app.ClientId}
}
// pinnedMethod is the app's requested signing method (TokenSigningMethod), or ""
// to let the key type decide.
func pinnedMethod(app *schema.Application) string {
if app == nil {
return ""
}
return app.TokenSigningMethod
}
// methodForKey maps a parsed private key (and an optional app-pinned method
// within the same family) to a jwt.SigningMethod and its JOSE alg name.
func methodForKey(key any, pinned string) (jwt.SigningMethod, string, error) {
switch k := key.(type) {
case *rsa.PrivateKey:
if pinned == "RS512" {
return jwt.SigningMethodRS512, "RS512", nil
}
return jwt.SigningMethodRS256, "RS256", nil
case *ecdsa.PrivateKey:
switch k.Curve.Params().BitSize {
case 256:
return jwt.SigningMethodES256, "ES256", nil
case 384:
return jwt.SigningMethodES384, "ES384", nil
case 521:
return jwt.SigningMethodES512, "ES512", nil
}
return nil, "", fmt.Errorf("jwt: unsupported EC curve bit size %d", k.Curve.Params().BitSize)
default:
return nil, "", errors.New("jwt: unsupported private key type")
}
}
// parsePrivateKeyPEM decodes a classical (RSA or EC) PEM private key.
func parsePrivateKeyPEM(pemText string) (crypto.Signer, error) {
block, _ := pem.Decode([]byte(pemText))
if block == nil {
return nil, errors.New("jwt: private key is not valid PEM")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
if k, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return k, nil
}
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("jwt: parse private key: %w", err)
}
signer, ok := k8.(crypto.Signer)
if !ok {
return nil, errors.New("jwt: PKCS#8 key is not a signing key")
}
return signer, nil
}
// parseRSAPrivateKeyPEM decodes a PEM RSA private key (PKCS#1 or PKCS#8).
func parseRSAPrivateKeyPEM(pemText string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemText))
if block == nil {
return nil, errors.New("jwt: private key is not valid PEM")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("jwt: parse private key: %w", err)
}
rk, ok := k8.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("jwt: private key is not RSA")
}
return rk, nil
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/rsa"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/iam/internal/schema"
)
// testKey is a small (fast) RSA key — fine for tests; production uses the Cert.
func testKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
// A fixed 2048-bit key generated once would be faster, but generating keeps
// the test self-contained. 2048 is the JWKS minimum.
k, err := rsaGenTest()
if err != nil {
t.Fatal(err)
}
return k
}
func TestSign_RoundTripAndClaims(t *testing.T) {
key := testKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://iam.hanzo.ai")
now := time.Unix(1_800_000_000, 0)
app := testApp()
tokenStr, err := s.Sign(app, "hanzo/alice", "alice@hanzo.ai", "Alice", "openid profile", time.Hour, now)
if err != nil {
t.Fatal(err)
}
// Verify with the public key + assert every claim.
var claims Claims
parsed, err := jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) {
return &key.PublicKey, nil
}, jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) }))
if err != nil {
t.Fatalf("verify: %v", err)
}
if !parsed.Valid {
t.Fatal("token not valid")
}
if kid, _ := parsed.Header["kid"].(string); kid != "cert-hanzo" {
t.Fatalf("kid = %q, want cert-hanzo", kid)
}
if claims.Issuer != "https://iam.hanzo.ai" {
t.Fatalf("iss = %q", claims.Issuer)
}
if claims.Subject != "hanzo/alice" {
t.Fatalf("sub = %q", claims.Subject)
}
if len(claims.Audience) != 1 || claims.Audience[0] != "hanzo-console" {
t.Fatalf("aud = %v, want [hanzo-console]", claims.Audience)
}
if claims.Owner != "hanzo" {
t.Fatalf("owner = %q, want hanzo", claims.Owner)
}
if claims.Scope != "openid profile" || claims.Email != "alice@hanzo.ai" {
t.Fatalf("scope/email wrong: %q / %q", claims.Scope, claims.Email)
}
if claims.ID == "" {
t.Fatal("jti empty — every token must be uniquely identifiable")
}
}
func TestSign_ExpiredTokenRejected(t *testing.T) {
key := testKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://iam.hanzo.ai")
now := time.Unix(1_800_000_000, 0)
tokenStr, err := s.Sign(testApp(), "u", "", "", "openid", time.Minute, now)
if err != nil {
t.Fatal(err)
}
// Validate well after expiry.
var claims Claims
_, err = jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return &key.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(2 * time.Minute) }))
if err == nil {
t.Fatal("expired token accepted")
}
}
func TestSign_WrongKeyRejected(t *testing.T) {
s := NewRSASigner(testKey(t), "cert-hanzo", "https://iam.hanzo.ai")
other := testKey(t)
now := time.Unix(1_800_000_000, 0)
tokenStr, _ := s.Sign(testApp(), "u", "", "", "openid", time.Hour, now)
var claims Claims
_, err := jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return &other.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}))
if err == nil {
t.Fatal("token verified under the wrong key")
}
}
func TestParseRSAPrivateKeyPEM_RejectsGarbage(t *testing.T) {
if _, err := parseRSAPrivateKeyPEM("not a pem"); err == nil {
t.Fatal("garbage PEM accepted")
}
}
func TestNewRSASignerFromCert_PEMRoundTrip(t *testing.T) {
key := testKey(t)
pemText := rsaKeyToPEM(t, key)
cert := &schema.Cert{PrivateKey: pemText}
cert.Name = "cert-hanzo"
s, err := NewRSASignerFromCert(cert, "https://iam.hanzo.ai")
if err != nil {
t.Fatalf("load from cert PEM: %v", err)
}
if s.Kid() != "cert-hanzo" || s.PublicKey() == nil {
t.Fatal("signer from cert missing kid/public key")
}
// Sign+verify to prove the parsed key works.
now := time.Unix(1_800_000_000, 0)
str, err := s.Sign(testApp(), "u", "", "", "openid", time.Hour, now)
if err != nil {
t.Fatal(err)
}
var claims Claims
if _, err := jwt.ParseWithClaims(str, &claims, func(*jwt.Token) (any, error) { return s.PublicKey(), nil },
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })); err != nil {
t.Fatalf("verify with cert-loaded key: %v", err)
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"reflect"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// GET /v1/iam/linked-accounts — the caller's linked social/OAuth identities.
//
// schema.User has NO single "linkedIdentities" array; a linked identity is stored as
// the connector's own column (User.GitHub, User.Google, …) holding the federated
// subject — the casdoor data model. So the linked accounts ARE those per-connector
// columns that are set; this returns [{provider, subject}] for each non-empty one.
// Each item carries only the subject string (the schema stores no per-link display
// name / avatar / linkedAt), so a richer per-link shape is not available from iam2.
// Self-scoped: resolved from the caller (callerOf), never the request.
// PathLinkedAccounts is the canonical linked-identities endpoint.
const PathLinkedAccounts = "/v1/iam/linked-accounts"
// connectorTags is the set of User json tags that hold a linked federated-identity
// subject — the casdoor per-connector columns (schema/user.go "Linked
// federated-identity subjects"). ONE list; linked-accounts reflects the non-empty
// ones out, so a new connector column is picked up by adding its tag here only.
var connectorTags = fields(
"github google qq wechat facebook dingtalk weibo gitee linkedin wecom lark gitlab " +
"adfs baidu alipay iam infoflow apple azuread azureadb2c slack steam bilibili okta " +
"douyin kwai line amazon auth0 battlenet bitbucket box cloudfoundry dailymotion deezer " +
"digitalocean discord dropbox eveonline fitbit gitea heroku influxcloud instagram " +
"intercom kakao lastfm mailru meetup microsoftonline naver nextcloud onedrive oura " +
"patreon paypal salesforce shopify soundcloud spotify strava stripe telegram tiktok " +
"tumblr twitch twitter typetalk uber vk wepay xero yahoo yammer yandex zoom " +
"custom custom2 custom3 custom4 custom5 custom6 custom7 custom8 custom9 custom10")
// linkedAccount is one linked social/OAuth identity.
type linkedAccount struct {
Provider string `json:"provider"`
Subject string `json:"subject"`
}
// linkedAccountsHandler returns the caller's linked identities.
func linkedAccountsHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name, ok := callerOf(ctx, c, db)
if !ok {
return httpx.Err(c, "please sign in first")
}
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return httpx.Err(c, "server_error")
}
if user == nil {
return httpx.Err(c, "the user does not exist")
}
return httpx.Ok(c, linkedAccountsOf(user))
}
}
// linkedAccountsOf reflects a user's non-empty connector columns into the linked list.
// Reflection over the ONE connectorTags set avoids a per-field cascade and stays
// faithful to whichever columns hold a subject.
func linkedAccountsOf(u *schema.User) []linkedAccount {
out := []linkedAccount{}
v := reflect.ValueOf(*u)
t := v.Type()
for i := 0; i < t.NumField(); i++ {
tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
if !connectorTags[tag] {
continue
}
if s := v.Field(i).String(); s != "" {
out = append(out, linkedAccount{Provider: tag, Subject: s})
}
}
return out
}
// fields turns a space-separated tag list into a set.
func fields(list string) map[string]bool {
m := map[string]bool{}
for _, f := range strings.Fields(list) {
m[f] = true
}
return m
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/sessions"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/internal/users"
)
// The credential login front door: POST /v1/iam/login. The @hanzo/iam SDK +
// hanzo.id portal post here with the app/org + username/password (+ the PKCE
// authorize params when type=code). On success with type=code we mint a
// PKCE-bound authorization code and return it in the Response envelope; the SDK
// then exchanges it at /v1/iam/oauth/token. Login by EMAIL or USERNAME.
//
// This is the interactive-flow counterpart to the token endpoint: login mints
// the code, /token redeems it. Password verification is bcrypt (constant-time),
// never plaintext, and the hash never crosses a response.
// PathLogin is the canonical credential-login endpoint.
const PathLogin = "/v1/iam/login"
// loginForm is the request body the SDK/portal posts.
type loginForm struct {
Application string `json:"application"`
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)
// UserCode is the RFC 8628 code the device displays, transcribed by the human
// approving it (type=device).
UserCode string `json:"userCode"`
// PKCE authorize passthrough (present when type=code).
ClientId string `json:"clientId"`
RedirectUri string `json:"redirectUri"`
State string `json:"state"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
Resource string `json:"resource"`
// The second factor (present on the finishing request). Challenge names the
// outstanding ceremony; a browser returns it in the cookie the gate set and
// leaves this empty.
MfaType string `json:"mfaType"`
Passcode string `json:"passcode"`
RecoveryCode string `json:"recoveryCode"`
EnableMfaRemember bool `json:"enableMfaRemember"`
Challenge string `json:"challenge"`
}
// routeLogin registers POST /v1/iam/login.
func routeLogin(r zip.Router, db orm.DB) {
r.Post(PathLogin, loginHandler(db))
}
func loginHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var f loginForm
if err := c.Bind(&f); err != nil {
return httpx.Err(c, "invalid request body")
}
ctx := c.Context()
// A post carrying no fresh credential but naming an outstanding challenge is
// the SECOND half of a sign-in this endpoint already gated: the second-factor
// answer. The principal comes from the challenge, never from the body.
if f.Username == "" && f.Password == "" {
if id := ReadChallenge(c, f.Challenge); id != "" {
return finishMfa(c, db, id, f)
}
}
if f.Organization == "" || f.Username == "" || f.Password == "" {
return httpx.Err(c, "organization, username and password are required")
}
user, err := resolveLoginUser(ctx, db, f.Organization, f.Username)
if err != nil {
return httpx.Err(c, err.Error())
}
// 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
// verify would fail every real login at cutover.
orgPasswordType := loginOrgPasswordType(ctx, db, f.Organization)
// One opaque failure for "no such user" and "wrong password" — no oracle
// that reveals whether the account exists.
if user == nil || !users.VerifyPassword(user, f.Password, orgPasswordType) {
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)
}
}
// 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
// finish alike — so the checks between "this is the user" and "here is the grant"
// are stated once and cannot be true of one path and false of another.
func loginGrant(c *zip.Ctx, db orm.DB, user *schema.User, f loginForm) error {
ctx := c.Context()
// type=device: approve a pending RFC 8628 device authorization against the
// identity now fully proven (device.go).
if f.Type == "device" {
return approveDevice(c, db, user, f.UserCode)
}
userID := user.Owner + "/" + user.Name
// type=login: a bare portal sign-in. Establish the durable session the portal +
// the gateway admin-guard read via get-account, then report the user id. The
// cookie is best-effort — a session failure never blocks a valid login.
if f.Type != "code" {
_ = sessions.Set(ctx, c.Fiber(), db, user.Owner, user.Name, f.Application)
return httpx.Ok(c, userID)
}
// type=code: mint a PKCE-bound authorization code for the OAuth flow. The org is
// the USER's own, from the loaded row, so a second-factor post (which carries no
// organization field) is checked exactly like the first.
app, err := resolveLoginApp(ctx, db, f)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application does not exist")
}
if user.Owner != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return httpx.Err(c, "the user is not permitted to sign in to this application")
}
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3); the
// token endpoint re-checks it. A supplied-but-unregistered URI is refused.
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
return httpx.Err(c, "invalid redirect_uri")
}
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
if f.CodeChallenge != "" && method != "S256" {
return httpx.Err(c, "only S256 PKCE is supported")
}
// A public client (no secret) must use PKCE — no downgrade.
if app.ClientSecret == "" && f.CodeChallenge == "" {
return httpx.Err(c, "PKCE is required for public clients")
}
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
if err != nil {
return httpx.Err(c, err.Error())
}
// Bind the redirect_uri and nonce onto the code so the token exchange can
// re-verify the redirect and echo the nonce into the id_token.
code.RedirectUri = f.RedirectUri
code.Nonce = f.Nonce
if err := store.PersistToken(ctx, db, code); err != nil {
return httpx.Err(c, err.Error())
}
// The SDK reads data as the authorization code to exchange at /token.
return httpx.Ok(c, code.Code)
}
// resolveLoginUser looks a user up by email (contains "@") or username, scoped
// to the org.
func resolveLoginUser(ctx context.Context, db orm.DB, org, identifier string) (*schema.User, error) {
if strings.Contains(identifier, "@") {
u, err := store.GetUserByEmail(ctx, db, org, identifier)
if err != nil || u != nil {
return u, err
}
// Fall through: some accounts set name = email (email is not indexed as
// a separate login) — try name too.
}
return store.GetUserByName(ctx, db, org, identifier)
}
// resolveLoginApp resolves the OAuth app for a type=code login: by clientId when
// present, else by (org, application name).
func resolveLoginApp(ctx context.Context, db orm.DB, f loginForm) (*schema.Application, error) {
if f.ClientId != "" {
return store.GetApplicationByClientId(ctx, db, f.ClientId)
}
if f.Application != "" {
return store.GetApplicationByName(ctx, db, "admin", f.Application)
}
return nil, nil
}
// 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
// an algorithm).
func loginOrgPasswordType(ctx context.Context, db orm.DB, org string) string {
o, err := store.GetOrganizationByName(ctx, db, org)
if err != nil || o == nil {
return ""
}
return o.PasswordType
}
+527
View File
@@ -0,0 +1,527 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/hanzoai/orm"
"github.com/pquerna/otp/totp"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/schema"
)
// The MFA gate at login, driven through the REAL mounted router. The contract is
// not a status code: every one of these answers is a 200, because the envelope
// carries the outcome. What matters is WHICH answer, and — the point of the whole
// gate — whether a token row exists afterwards. A test that checked only the
// status would pass while every 2FA user signed in with a password alone.
// newApp mounts the OIDC surface on an EXISTING store, so a test can seed the
// same db the router serves (newServer opens its own).
func newApp(t *testing.T, db orm.DB) *zip.App {
t.Helper()
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
// The OIDC surface is the pre-auth PUBLIC group; login + the challenge finish
// both live here, so a root (empty-prefix) router mounts them at their absolute
// paths (main renamed Mount→Route on the zip-group model).
Route(app.Group(""), db)
return app
}
// enrolled seeds a user with a password AND a live TOTP factor, returning the
// TOTP secret.
func enrolled(t *testing.T, db orm.DB, name, password string) string {
t.Helper()
seedUser(t, db, name, name+"@hanzo.ai", password)
secret, _, err := factor.Enroll("hanzo/"+name, "Hanzo")
if err != nil {
t.Fatal(err)
}
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
if err != nil {
t.Fatal(err)
}
u.TotpSecret = secret
u.PreferredMfaType = factor.App
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
return secret
}
// tokens counts persisted token rows — the store-side proof that no credential
// was minted. The gate's whole job is that this stays zero until the second
// factor lands.
func tokens(t *testing.T, db orm.DB) int {
t.Helper()
n, err := orm.TypedQuery[schema.Token](db).Count(context.Background())
if err != nil {
t.Fatal(err)
}
return n
}
// passcode computes the code an authenticator would show right now.
func passcode(t *testing.T, secret string) string {
t.Helper()
code, err := totp.GenerateCode(secret, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
return code
}
// challengeOf extracts the challenge id the gate set as a cookie.
func challengeOf(t *testing.T, resp *http.Response) string {
t.Helper()
for _, ck := range resp.Cookies() {
if ck.Name == challengeCookie && ck.Value != "" {
return ck.Value
}
}
t.Fatal("the gate set no challenge cookie")
return ""
}
// TestEnrolledUserIsChallengedAndGetsNoToken is THE regression. Before the gate,
// login verified the password and minted a code directly: an enrolled user signed
// in with one factor and the second was never asked for. Not a missing feature —
// a silent downgrade of every 2FA account.
func TestEnrolledUserIsChallengedAndGetsNoToken(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "correct horse battery staple")
resp, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "correct horse battery staple",
"type": "code", "clientId": "hanzo-app",
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("gate answered an error: %v", m["msg"])
}
// `data` is the literal string the portal compares against. Any other shape
// and the client reads it as an authorization code.
if m["data"] != NextMfa {
t.Fatalf("data = %q, want %q — the client treats anything else as a code, so MFA is bypassed", m["data"], NextMfa)
}
// data2 carries the factors to choose from.
list, ok := m["data2"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("data2 = %#v, want exactly the one enrolled factor", m["data2"])
}
got := list[0].(map[string]any)
if got["mfaType"] != factor.App || got["enabled"] != true {
t.Fatalf("offered factor = %#v, want the enabled app factor", got)
}
// The masked projection must not carry the shared secret out.
if s := string(body); strings.Contains(s, "secret") || strings.Contains(s, "recoveryCodes") {
t.Fatalf("the challenge leaked secret material: %s", s)
}
// THE assertion: nothing was minted.
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted at the challenge — the password alone bought a credential", n)
}
challengeOf(t, resp)
}
// TestChallengeAnsweredWithPasscodeMintsCode — the happy path: the second factor
// lands and the code appears.
func TestChallengeAnsweredWithPasscodeMintsCode(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret),
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("the correct passcode was refused: %v", m["msg"])
}
code, _ := m["data"].(string)
if code == "" || code == NextMfa || code == RequiredMfa {
t.Fatalf("data = %q, want an authorization code", m["data"])
}
tok, err := store2GetTokenByCode(db, code)
if err != nil || tok == nil {
t.Fatalf("the minted code resolves to no token row: %v", err)
}
if tok.User != "hanzo/alice" {
t.Fatalf("code bound to %q, want hanzo/alice", tok.User)
}
}
// TestWrongPasscodeMintsNothing — a failed second factor must leave the sign-in
// exactly where it was: nowhere.
func TestWrongPasscodeMintsNothing(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": factor.App, "passcode": "000000",
}))
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("a wrong passcode was accepted: %#v", m)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted for a wrong passcode", n)
}
}
// TestChallengeIsSingleUse — a challenge is spent by the attempt that takes it,
// so a captured id cannot be replayed. The wrong passcode below spends it; the
// RIGHT passcode afterwards must still fail, on the challenge and not the code.
func TestChallengeIsSingleUse(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
first := map[string]any{"type": "code", "clientId": "hanzo-app", "challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret)}
if m := decode(t, mustBody(t, app, first)); m["status"] != "ok" {
t.Fatalf("first use failed: %v", m["msg"])
}
// Same id, same valid passcode, second time.
m := decode(t, mustBody(t, app, first))
if m["status"] != "error" {
t.Fatalf("a spent challenge was accepted again: %#v", m)
}
if m["msg"] != ErrChallenge.Error() {
t.Fatalf("msg = %q, want the challenge refusal %q", m["msg"], ErrChallenge.Error())
}
}
// TestChallengeBindsItsOwnSubject — invariant 3. A challenge minted for alice
// must resolve alice even when the body names mallory. The user comes from the
// verified server-side record, never from a request parameter.
func TestChallengeBindsItsOwnSubject(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
seedUser(t, db, "mallory", "mallory@hanzo.ai", "pw")
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw",
"type": "code", "clientId": "hanzo-app",
}))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret),
// The body tries to redirect the ceremony at another account.
"username": "", "organization": "hanzo", "name": "mallory",
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("the ceremony failed: %v", m["msg"])
}
tok, err := store2GetTokenByCode(db, m["data"].(string))
if err != nil || tok == nil {
t.Fatal("no token row for the minted code")
}
if tok.User != "hanzo/alice" {
t.Fatalf("code bound to %q — the body redirected the challenge's subject", tok.User)
}
}
// TestRecoveryCodeIsAcceptedOnceAndStoredHashed proves three things at once: a
// recovery code answers the challenge, it is CONSUMED (a second use fails), and
// what sits in the row is a bcrypt digest — never the code itself.
func TestRecoveryCodeIsAcceptedOnceAndStoredHashed(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "pw")
plain, err := factor.MintRecovery()
if err != nil {
t.Fatal(err)
}
hash, err := factor.HashRecovery(plain)
if err != nil {
t.Fatal(err)
}
u := userRow(t, db, "alice")
u.RecoveryCodes = []string{hash}
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
if strings.Contains(hash, plain) {
t.Fatal("the stored value contains the plaintext recovery code")
}
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
id := challengeOf(t, resp)
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": id, "recoveryCode": plain,
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("the recovery code was refused: %v", m["msg"])
}
if code, _ := m["data"].(string); code == "" || code == NextMfa {
t.Fatalf("data = %q, want an authorization code", m["data"])
}
// Spent: the row no longer carries it.
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
t.Fatalf("recovery codes after use = %v, want none — a one-time code survived", got)
}
// And a second sign-in cannot reuse it.
resp2, _ := do(t, app, jsonReq("POST", PathLogin, login))
_, body2 := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp2), "recoveryCode": plain,
}))
if m2 := decode(t, body2); m2["status"] != "error" {
t.Fatalf("a spent recovery code signed in a second time: %#v", m2)
}
}
// TestLegacyPlaintextRecoveryCodeStillVerifies — every recovery code migrated
// from v1 is PLAINTEXT (object/factor.go:81 compares in the clear). The algorithm is
// a property of the stored value, so a legacy row must still verify, and the
// plaintext must die on first use.
func TestLegacyPlaintextRecoveryCodeStillVerifies(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
enrolled(t, db, "alice", "pw")
const legacy = "0d5a7f0e-3a1e-4a1a-9f6c-2b1d3e4f5a6b" // a v1 uuid.NewString() code
u := userRow(t, db, "alice")
u.RecoveryCodes = []string{legacy}
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
}))
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp), "recoveryCode": legacy,
}))
if m := decode(t, body); m["status"] != "ok" {
t.Fatalf("a migrated v1 plaintext recovery code was refused: %v — every live 2FA user's way back is gone", m["msg"])
}
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
t.Fatalf("the legacy plaintext survived its use: %v", got)
}
}
// TestPasscodeRefusedWhenItRepeatsTheUsedFactor — v1 controllers/auth.go:1325.
// The factor already used to get here cannot answer for the one still owed.
func TestPasscodeRefusedWhenItRepeatsTheUsedFactor(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
u := userRow(t, db, "alice")
// A challenge whose payload says "the app factor was already used".
id, err := MintChallenge(context.Background(), db, KindMfa, "hanzo/"+u.Name, factor.App, time.Now())
if err != nil {
t.Fatal(err)
}
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app",
"challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret),
}))
if m := decode(t, body); m["status"] != "error" {
t.Fatalf("the just-used factor answered its own challenge: %#v", m)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted", n)
}
}
// TestRememberDeadlineRoundTrips — the "don't ask again" window short-circuits
// the whole gate, so the value the writer writes must be the value the reader
// reads. A format mismatch is silent: a permanent skip, or a permanent challenge.
func TestRememberDeadlineRoundTrips(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
// An org with a real remember window (live orgs leave it at zero).
o := orm.New[schema.Organization](db)
o.Owner, o.Name, o.MfaRememberInHours = "admin", "hanzo", 24
o.SetId("admin/hanzo")
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatal(err)
}
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
"mfaType": factor.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
}))
if m := decode(t, body); m["status"] != "ok" {
t.Fatalf("the passcode was refused: %v", m["msg"])
}
// The exact stored string must parse for the exact reader the gate uses.
stored := userRow(t, db, "alice").MfaRememberDeadline
if stored == "" {
t.Fatal("enableMfaRemember wrote no deadline")
}
if !remembered(userRow(t, db, "alice"), time.Now()) {
t.Fatalf("the gate cannot read back the deadline it wrote (%q) — the window is silently dead", stored)
}
// A future deadline SKIPS the challenge: the next password login mints.
_, body2 := do(t, app, jsonReq("POST", PathLogin, login))
m2 := decode(t, body2)
if m2["data"] == NextMfa {
t.Fatal("a live remember window still challenged")
}
if code, _ := m2["data"].(string); code == "" {
t.Fatalf("remembered login did not mint: %#v", m2)
}
// A PAST deadline challenges again.
u := userRow(t, db, "alice")
u.MfaRememberDeadline = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatal(err)
}
_, body3 := do(t, app, jsonReq("POST", PathLogin, login))
if m3 := decode(t, body3); m3["data"] != NextMfa {
t.Fatalf("an expired remember window skipped the gate: %#v", m3)
}
}
// TestZeroRememberWindowStillChallenges pins the LIVE configuration: every
// organization today leaves MfaRememberInHours at zero, which puts the deadline
// in the past the instant it is written. "Fixing" a zero into an always-on skip
// would turn 2FA off for every tenant at once.
func TestZeroRememberWindowStillChallenges(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
secret := enrolled(t, db, "alice", "pw")
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
do(t, app, jsonReq("POST", PathLogin, map[string]any{
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
"mfaType": factor.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
}))
_, body := do(t, app, jsonReq("POST", PathLogin, login))
if m := decode(t, body); m["data"] != NextMfa {
t.Fatalf("a zero remember window skipped the gate: %#v — 2FA is off for every live org", m)
}
}
// TestOrgRequiredFactorPromptsEnrollment — v1 object/organization.go:770. The org
// demands a factor the user has not enrolled, so the answer is "go enroll", not a
// challenge it could never answer.
func TestOrgRequiredFactorPromptsEnrollment(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw") // no factor
o := orm.New[schema.Organization](db)
o.Owner, o.Name = "admin", "hanzo"
o.MfaItems = []*schema.MfaItem{{Name: factor.App, Rule: "Required"}}
o.SetId("admin/hanzo")
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatal(err)
}
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
}))
m := decode(t, body)
if m["data"] != RequiredMfa {
t.Fatalf("data = %q, want %q", m["data"], RequiredMfa)
}
if n := tokens(t, db); n != 0 {
t.Fatalf("%d token row(s) persisted while a required factor was missing", n)
}
}
// TestUnenrolledUserSignsInUnchanged — the gate must be invisible to everyone
// else. A user with no factor still logs in with a password, exactly as before.
func TestUnenrolledUserSignsInUnchanged(t *testing.T) {
db := openTestDB(t)
app := newApp(t, db)
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": "hanzo", "username": "bob", "password": "pw", "type": "code", "clientId": "hanzo-app",
}))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("an unenrolled user was refused: %v", m["msg"])
}
if code, _ := m["data"].(string); code == "" || code == NextMfa || code == RequiredMfa {
t.Fatalf("data = %q, want an authorization code", m["data"])
}
}
// --- helpers ---
func mustBody(t *testing.T, app *zip.App, body any) []byte {
t.Helper()
_, b := do(t, app, jsonReq("POST", PathLogin, body))
return b
}
func userRow(t *testing.T, db orm.DB, name string) *schema.User {
t.Helper()
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
if err != nil {
t.Fatal(err)
}
return u
}
func store2GetTokenByCode(db orm.DB, code string) (*schema.Token, error) {
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
)
// seedUserInOrg creates a bcrypt-credentialed user in an arbitrary org.
func seedUserInOrg(t *testing.T, db orm.DB, org, name, email, password string) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
u := orm.New[schema.User](db)
u.Owner = org
u.Name = name
u.Email = email
u.PasswordHash = string(hash)
u.PasswordType = "bcrypt"
u.SetId(org + "/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user %s/%s: %v", org, name, err)
}
}
// A user authenticated in one tenant cannot obtain an authorization code for a
// single-tenant application belonging to a different org — even with fully valid
// credentials in their own org.
func TestLogin_CrossOrgSignInRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}}) // org "hanzo"
seedUserInOrg(t, db, "lux", "eve", "eve@lux.example", "pw") // valid user in org "lux"
f := map[string]string{
"organization": "lux", "username": "eve", "password": "pw",
"clientId": "conf", "redirectUri": testRedirect, "scope": "openid", "type": "code",
}
_, body := do(t, app, jsonReq("POST", PathLogin, f))
m := decode(t, body)
if m["status"] != "error" {
t.Fatalf("cross-org sign-in must be refused; got %v", m)
}
if code, _ := m["data"].(string); code != "" {
t.Fatalf("no code may be minted for a cross-org sign-in; got %q", code)
}
}
// A shared application legitimately accepts users from any org.
func TestLogin_SharedAppAllowsCrossOrg(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "shared", secret: "s3cret", redirectURIs: []string{testRedirect}, shared: true})
seedUserInOrg(t, db, "lux", "eve", "eve@lux.example", "pw")
f := map[string]string{
"organization": "lux", "username": "eve", "password": "pw",
"clientId": "shared", "redirectUri": testRedirect, "scope": "openid", "type": "code",
}
_, body := do(t, app, jsonReq("POST", PathLogin, f))
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("shared app must accept a cross-org user; got %v", m)
}
if code, _ := m["data"].(string); code == "" {
t.Fatal("shared app cross-org sign-in should mint a code")
}
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// seedUser creates a user with a bcrypt password in org "hanzo".
func seedUser(t *testing.T, db orm.DB, name, email, password string) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) // MinCost = fast tests
if err != nil {
t.Fatal(err)
}
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = name
u.Email = email
u.PasswordHash = string(hash)
u.PasswordType = "bcrypt"
u.SetId("hanzo/" + name)
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed user: %v", err)
}
}
// TestLoginToTokenFlow is the full interactive round-trip: a password login
// (verified with bcrypt) mints a PKCE-bound code, which the token endpoint
// redeems into a signed JWT. Proves login→code→token end to end.
func TestLoginToTokenFlow(t *testing.T) {
db := openTestDB(t)
key := mustGenRSA(t)
app := seedAppWithCert(t, db, key)
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse battery staple")
ctx := context.Background()
now := time.Unix(1_800_000_000, 0)
verifier := "login-verifier-000000000000000000000000000000000"
challenge := ComputeS256Challenge(verifier)
// --- login side: resolve app+user, verify password, mint the code ---
user, err := resolveLoginUser(ctx, db, "hanzo", "alice@hanzo.ai") // login by EMAIL
if err != nil || user == nil {
t.Fatalf("resolve user by email: %v (nil=%v)", err, user == nil)
}
code, err := MintCode(app, user.Owner+"/"+user.Name, "openid profile", challenge, "S256", "", now)
if err != nil {
t.Fatal(err)
}
if err := store.PersistToken(ctx, db, code); err != nil {
t.Fatal(err)
}
// --- token side: redeem the code with the verifier ---
tok, _ := store.GetTokenByCode(ctx, db, code.Code)
if err := RedeemCode(tok, app.Name, verifier, now.Add(time.Second)); err != nil {
t.Fatalf("redeem: %v", err)
}
if tok.User != "hanzo/alice" {
t.Fatalf("code bound to wrong user: %q", tok.User)
}
}
func TestResolveLoginUser_ByUsernameAndEmail(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
byName, _ := resolveLoginUser(ctx, db, "hanzo", "bob")
if byName == nil || byName.Name != "bob" {
t.Fatal("login by username failed")
}
byEmail, _ := resolveLoginUser(ctx, db, "hanzo", "bob@hanzo.ai")
if byEmail == nil || byEmail.Name != "bob" {
t.Fatal("login by email failed")
}
// Wrong org → not found (tenant isolation).
other, _ := resolveLoginUser(ctx, db, "lux", "bob")
if other != nil {
t.Fatal("user resolved in the wrong org — tenant isolation broken")
}
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/url"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The end-session endpoint: GET/POST /v1/iam/oauth/logout. iam2 holds no
// server-side browser session to destroy here, so logout's security-relevant
// job is the redirect: it bounces to post_logout_redirect_uri ONLY when that URI
// is registered by the client named in a signature-verified id_token_hint —
// never to an unvalidated absolute URL (open-redirect defense).
func logoutHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
redirect := param(c, "post_logout_redirect_uri")
if redirect == "" {
return c.JSON(200, map[string]string{"status": "ok"})
}
app := appFromIDTokenHint(c.Context(), db, param(c, "id_token_hint"))
if app == nil || !app.IsRedirectUriValid(redirect) {
// No proof the caller owns the target — refuse to redirect.
return c.JSON(200, map[string]string{"status": "ok"})
}
if state := param(c, "state"); state != "" {
sep := "?"
if strings.Contains(redirect, "?") {
sep = "&"
}
redirect += sep + "state=" + url.QueryEscape(state)
}
return c.Redirect(302, redirect)
}
}
// appFromIDTokenHint resolves the application an id_token_hint was issued to, but
// only when the hint's signature verifies. A forged or unsigned hint yields nil,
// so it can never authorize a redirect.
func appFromIDTokenHint(ctx context.Context, db orm.DB, hint string) *schema.Application {
if hint == "" {
return nil
}
claims, err := verifyToken(ctx, db, hint)
if err != nil || len(claims.Audience) == 0 {
return nil
}
app, err := store.GetApplicationByClientId(ctx, db, claims.Audience[0])
if err != nil {
return nil
}
return app
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/url"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// idTokenHint runs the confidential flow and returns a verifiable id_token.
func idTokenHint(t *testing.T, app *zip.App) string {
t.Helper()
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
_, tok := exchangeCode(t, app, url.Values{
"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect},
})
idt, _ := tok["id_token"].(string)
if idt == "" {
t.Fatal("no id_token issued")
}
return idt
}
// Logout only redirects to a post_logout_redirect_uri that is registered by the
// client named in a signature-verified id_token_hint — never an open redirect.
func TestLogout_RedirectSafety(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
t.Run("no redirect param → 200", func(t *testing.T) {
resp, _ := do(t, app, formReqNoBody("GET", PathLogout))
if resp.StatusCode != 200 || resp.Header.Get("Location") != "" {
t.Fatalf("status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
}
})
t.Run("redirect without hint is refused (no open redirect)", func(t *testing.T) {
q := url.Values{"post_logout_redirect_uri": {"https://evil.example/x"}}
resp, _ := do(t, app, formReqNoBody("GET", PathLogout+"?"+q.Encode()))
if resp.StatusCode != 200 || resp.Header.Get("Location") != "" {
t.Fatalf("must not redirect without a verified hint: status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
}
})
t.Run("verified hint but unregistered redirect is refused", func(t *testing.T) {
q := url.Values{"post_logout_redirect_uri": {"https://evil.example/x"}, "id_token_hint": {idTokenHint(t, app)}}
resp, _ := do(t, app, formReqNoBody("GET", PathLogout+"?"+q.Encode()))
if resp.StatusCode != 200 || resp.Header.Get("Location") != "" {
t.Fatalf("unregistered redirect must be refused: status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
}
})
t.Run("verified hint + registered redirect is honored", func(t *testing.T) {
q := url.Values{"post_logout_redirect_uri": {testRedirect}, "id_token_hint": {idTokenHint(t, app)}, "state": {"s-9"}}
resp, _ := do(t, app, formReqNoBody("GET", PathLogout+"?"+q.Encode()))
loc := requireRedirect(t, resp, testRedirect)
if !strings.Contains(loc, "state=s-9") {
t.Fatalf("state not echoed: %q", loc)
}
})
}
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/mfa/factor"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The login-time second-factor gate. A verified password proves ONE factor;
// everything here decides whether a SECOND is owed before any token or device
// approval is minted. It is the counterpart to the enrollment surface in
// internal/mfa — enrollment decides what factors a user HAS, this decides when the
// sign-in must present one.
//
// The two answers are v1's wire STRINGS (object/factor.go:50-54): the client
// string-compares `data` against them, so they are wire format, not internal
// names. Any other shape and the client reads the answer as an authorization code
// and the factor is skipped.
const (
// RequiredMfa — the organization requires a factor this user has not enrolled;
// the client must divert to enrollment.
RequiredMfa = "RequiredMfa"
// NextMfa — the user has factors; data2 carries the allowed ones and the client
// must post one back. NO code is minted with this answer.
NextMfa = "NextMfa"
)
// gate is the second-factor decision — the ONE place a sign-in is held. It answers
// the request itself and reports true when it did; a false means this principal has
// proven everything it owes and the caller may mint.
//
// Every path that signs a user in calls this BEFORE minting a token or approving a
// device — one function, every call site, because a gate that exists in one branch
// is not a gate.
//
// verificationType names the factor the caller already proved, so the challenge
// never offers it back. "" excludes nothing (a password proves none of the
// offerable factors).
func gate(c *zip.Ctx, db orm.DB, user *schema.User, org *schema.Organization, verificationType string) (bool, error) {
ctx := c.Context()
// The organization REQUIRES a factor this user has not enrolled: the answer is
// enrollment, not a challenge it could never answer.
if factor.Prompt(org, user) {
return true, httpx.Ok(c, RequiredMfa)
}
if !factor.Enabled(user) {
return false, nil
}
// "Remember this device" — a deadline in the FUTURE skips the factor. Written by
// remember() with the same RFC3339 the parse below expects; a value the parser
// cannot read is treated as no deadline, so a bad value re-challenges rather than
// silently granting a permanent skip.
if remembered(user, nowFunc()) {
return false, nil
}
allow := allowList(user, org, verificationType)
if len(allow) == 0 {
// Every factor is either the one just used or not actually enrolled: there is
// nothing left to ask for.
return false, nil
}
id, err := MintChallenge(ctx, db, KindMfa, user.Owner+"/"+user.Name, verificationType, nowFunc())
if err != nil {
return true, err
}
SetChallenge(c, id)
// data is the STRING "NextMfa"; data2 carries the factors. No code is minted
// here — that is the whole point of the gate.
return true, httpx.Ok(c, NextMfa, allow)
}
// allowList is the factors a challenge may be answered with: enrolled, and not the
// one the caller just used. Each carries the org's remember window so the client
// can offer "don't ask again".
func allowList(user *schema.User, org *schema.Organization, verificationType string) []*schema.MfaProps {
hours := 0
if org != nil {
hours = org.MfaRememberInHours
}
allow := []*schema.MfaProps{}
for _, p := range factor.AllProps(user) {
if !p.Enabled || p.MfaType == verificationType {
continue
}
p.MfaRememberInHours = hours
allow = append(allow, p)
}
return allow
}
// remembered reports whether the user's "don't ask again" window is still open. An
// unparsable or empty deadline is not a skip: this fails CLOSED, to the challenge.
func remembered(user *schema.User, now time.Time) bool {
if user.MfaRememberDeadline == "" {
return false
}
deadline, err := time.Parse(time.RFC3339, user.MfaRememberDeadline)
return err == nil && deadline.After(now)
}
// finishMfa answers an outstanding challenge. The user is loaded from the
// CHALLENGE's subject — never from the request — so a body naming another account
// cannot redirect the ceremony. Taking the challenge spends it, so a passcode
// replayed against the same id loses. On success it completes the ORIGINAL sign-in
// through the same loginGrant every other path uses, so a second factor over a
// device approval reaches approveDevice, not a token.
func finishMfa(c *zip.Ctx, db orm.DB, id string, f loginForm) error {
ctx := c.Context()
ch, err := TakeChallenge(ctx, db, id, KindMfa, nowFunc())
if err != nil {
return httpx.Err(c, err.Error())
}
ClearChallenge(c)
owner, name, _ := strings.Cut(ch.Subject, "/")
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return httpx.Err(c, err.Error())
}
if user == nil {
return httpx.Err(c, ErrChallenge.Error())
}
switch {
case f.Passcode != "":
// The challenge's payload is the factor already used to get here. Answering
// with that same factor proves nothing new.
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.
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.
if !factor.UseRecovery(user, f.RecoveryCode) {
return httpx.Err(c, "the recovery code is incorrect")
}
if err := factor.Save(ctx, db, user); err != nil {
return httpx.Err(c, err.Error())
}
default:
return httpx.Err(c, "missing passcode or recovery code")
}
if f.EnableMfaRemember {
if err := remember(ctx, db, user); err != nil {
return httpx.Err(c, err.Error())
}
}
return loginGrant(c, db, user, f)
}
// remember opens the "don't ask again" window: now + the ORG's MfaRememberInHours.
// A zero window — every live organization today — yields a deadline already in the
// past, so the gate keeps challenging. That is the shipped behavior and it is
// preserved: turning a zero into "forever" would silently disable the factor for
// every tenant.
func remember(ctx context.Context, db orm.DB, user *schema.User) error {
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return err
}
hours := 0
if org != nil {
hours = org.MfaRememberInHours
}
// Written with the SAME format `remembered` parses — a mismatch here is a
// permanent skip or a permanent challenge, silently.
user.MfaRememberDeadline = nowFunc().UTC().Add(time.Duration(hours) * time.Hour).Format(time.RFC3339)
return factor.Save(ctx, db, user)
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"errors"
"strings"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The shared tail of every interactive authentication: given a user who has
// ALREADY proven who they are — by password, by wallet signature, by any future
// factor — decide what the SDK reads back. One place, so a new front door
// inherits the redirect/PKCE/tenant rules instead of restating them.
// Mint is the authorize passthrough an interactive login carries. Type selects
// the shape: "code" mints a PKCE-bound authorization code for the OAuth flow,
// anything else is a bare portal sign-in.
type Mint struct {
Type string
RedirectUri string
State string
Scope string
Nonce string
CodeChallenge string
CodeChallengeMethod string
Resource string
}
// MintFor resolves what a successful authentication returns to the SDK: the
// user id for a bare portal sign-in, or a fresh PKCE-bound authorization code
// (persisted) for the OAuth flow. userID is "<org>/<name>" — the caller's own
// verified identity, never a client-supplied value.
//
// This is the ONE mint path. Every rule below is a security invariant, so it
// lives here rather than in each front door:
// - Tenant isolation: the user's org must be permitted for this application —
// its own org, a shared app, or an app that lets users choose their org.
// Without it a user in one tenant could obtain a token naming another.
// - Redirect binding: an exactly-registered redirect_uri (RFC 6749 §3.1.2.3);
// the token endpoint re-checks it. A supplied-but-unregistered URI is never
// minted against.
// - PKCE: S256 only (never "plain"), and a public client must present a
// challenge — no downgrade.
func MintFor(ctx context.Context, db orm.DB, app *schema.Application, userID string, p Mint) (string, error) {
// A bare sign-in needs no application: report the identity and stop.
if p.Type != "code" {
return userID, nil
}
if app == nil {
return "", errors.New("the application does not exist")
}
// The user's org is the owner half of its own id, set server-side at
// authentication — never read from the request.
org, _, _ := strings.Cut(userID, "/")
if org != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return "", errors.New("the user is not permitted to sign in to this application")
}
if p.RedirectUri != "" && !app.IsRedirectUriValid(p.RedirectUri) {
return "", errors.New("invalid redirect_uri")
}
method := normalizeChallengeMethod(p.CodeChallenge, p.CodeChallengeMethod)
if p.CodeChallenge != "" && method != "S256" {
return "", errors.New("only S256 PKCE is supported")
}
if app.ClientSecret == "" && p.CodeChallenge == "" {
return "", errors.New("PKCE is required for public clients")
}
code, err := MintCode(app, userID, p.Scope, p.CodeChallenge, method, p.Resource, nowFunc())
if err != nil {
return "", err
}
// Bind the redirect_uri and nonce onto the code so the token exchange can
// re-verify the redirect and echo the nonce into the id_token.
code.RedirectUri = p.RedirectUri
code.Nonce = p.Nonce
if err := store.PersistToken(ctx, db, code); err != nil {
return "", err
}
return code.Code, nil
}
// ResolveApp resolves the OAuth application a front door names: by clientId when
// present, else by name under the "admin" registry owner. Returns (nil, nil)
// when the request names no application. Shared by every interactive login so
// they all resolve the same app from the same fields.
func ResolveApp(ctx context.Context, db orm.DB, clientId, name string) (*schema.Application, error) {
if clientId != "" {
return store.GetApplicationByClientId(ctx, db, clientId)
}
if name != "" {
return store.GetApplicationByName(ctx, db, "admin", name)
}
return nil, nil
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/url"
"testing"
"github.com/hanzoai/iam/internal/schema"
)
// The mint owner-pin, unit-level: mintAllowed / adminMintAllowed require the resolved
// minter app's OWNING org to be a reserved signing owner (admin/built-in) AND its
// clientId to be allow-listed. A tenant-owned app whose clientId collides with an
// allow-listed one is refused on the owner, so the clientId allow-list can never be
// satisfied by a body-supplied collision.
func TestMintAllowed_OwnerPinned(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
t.Setenv("IAM_ADMIN_MINT_ALLOWED_APPS", "hanzo-console")
admin := &schema.Application{Owner: "admin", ClientId: "hanzo-console"}
builtin := &schema.Application{Owner: "built-in", ClientId: "hanzo-console"}
tenant := &schema.Application{Owner: "evil", ClientId: "hanzo-console"} // SAME allow-listed clientId
if !mintAllowed(admin) {
t.Fatal("an admin-owned allow-listed app must be permitted to mint")
}
if !mintAllowed(builtin) {
t.Fatal("a built-in-owned allow-listed app must be permitted to mint")
}
if mintAllowed(tenant) {
t.Fatal("HIGH REOPENED: a tenant-owned app with a colliding clientId was permitted to mint")
}
if adminMintAllowed(tenant) {
t.Fatal("HIGH REOPENED: a tenant-owned app reached the admin-mint capability")
}
if !adminMintAllowed(admin) {
t.Fatal("an admin-owned app on the admin list must hold the admin-mint capability")
}
// Off the list entirely is denied even when admin-owned (the clientId gate still applies).
if mintAllowed(&schema.Application{Owner: "admin", ClientId: "not-listed"}) {
t.Fatal("an admin-owned app NOT on the allow-list must not mint")
}
}
// TestTokenExchange_clientIdCollisionAttacker_denied is the end-to-end regression
// guard for the HIGH: an attacker registers an app with the SAME clientId as the
// allow-listed console (not merely the same NAME) and its OWN known secret, then
// tries a token exchange. Two independent controls deny it: admin-preferring
// resolution returns the REAL console (so the attacker's secret mismatches → 401),
// and the owner-pin (mintAllowed) refuses a non-signing owner even if a backend
// returned the attacker row. Either way, NO token is minted.
func TestTokenExchange_clientIdCollisionAttacker_denied(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"}) // admin-owned console
// Attacker: SAME clientId as the console, attacker's OWN secret, tenant-owned.
seedAttackerApp(t, db, "evil", "evil-console", "hanzo-console", "attacker-knows-this", "cert-hanzo-console")
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
status, body := exchange(t, app, "hanzo-console", "attacker-knows-this", url.Values{
"subject_token": {subject},
"resource": {"hanzo-cloud"},
})
if status == 200 {
t.Fatalf("HIGH REOPENED: clientId-collision attacker minted a token (status=200); body=%v", body)
}
if _, ok := body["access_token"]; ok {
t.Fatalf("HIGH REOPENED: a token was minted for a colliding-clientId attacker; body=%v", body)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"encoding/base64"
"encoding/pem"
"errors"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/hanzoai/iam/internal/schema"
)
// ML-DSA-65 (FIPS 204, NIST security level 3) as a first-class JWT signing
// method. This is the post-quantum half of the hybrid signing story: RS256
// (jwt.go) is the classical interop path every existing verifier already reads
// from the JWKS, and MLDSA65 is the forward path, active only for a Cert whose
// CryptoAlgorithm is ML-DSA. The two share the same Signer / JWKS seam, so a
// deployment migrates one Cert at a time without touching the token core.
//
// The signature scheme is pure ML-DSA-65 over the JWS signing input (no context,
// deterministic), which is exactly what the ML-DSA-65 Verify checks — so a token
// this method signs round-trips through the same package's verify path, and a
// PQ-aware relying party reads the raw public key published in the JWKS.
// algMLDSA65 is the JOSE `alg` value for ML-DSA-65 — the identifier carried in
// the JWT header and advertised in discovery + JWKS.
const algMLDSA65 = "MLDSA65"
// signingMethodMLDSA65 implements jwt.SigningMethod for ML-DSA-65.
type signingMethodMLDSA65 struct{}
// SigningMethodMLDSA65 is the shared, stateless ML-DSA-65 signing method.
var SigningMethodMLDSA65 jwt.SigningMethod = signingMethodMLDSA65{}
func init() {
jwt.RegisterSigningMethod(algMLDSA65, func() jwt.SigningMethod { return SigningMethodMLDSA65 })
}
// Alg returns the JOSE algorithm identifier.
func (signingMethodMLDSA65) Alg() string { return algMLDSA65 }
// Sign produces a deterministic ML-DSA-65 signature over the JWS signing input.
func (signingMethodMLDSA65) Sign(signingString string, key any) ([]byte, error) {
sk, ok := key.(*mldsa65.PrivateKey)
if !ok {
return nil, jwt.ErrInvalidKeyType
}
sig, err := mldsa65.Sign(sk, []byte(signingString), nil, false)
if err != nil {
return nil, err
}
return sig, nil
}
// Verify checks an ML-DSA-65 signature; a mismatch is a signature error, never a
// key/type panic.
func (signingMethodMLDSA65) Verify(signingString string, sig []byte, key any) error {
pk, ok := key.(*mldsa65.PublicKey)
if !ok {
return jwt.ErrInvalidKeyType
}
if len(sig) != mldsa65.SignatureSize {
return jwt.ErrSignatureInvalid
}
if !mldsa65.Verify(pk, []byte(signingString), nil, sig) {
return jwt.ErrSignatureInvalid
}
return nil
}
// isMLDSACert reports whether a Cert is an ML-DSA-65 signing cert.
func isMLDSACert(cert *schema.Cert) bool {
if cert == nil {
return false
}
a := strings.ToUpper(strings.ReplaceAll(cert.CryptoAlgorithm, "-", ""))
return a == "MLDSA65"
}
// parseMLDSA65PrivateKey decodes an ML-DSA-65 private key from a Cert's stored
// material: a PEM envelope ("MLDSA65 PRIVATE KEY") or bare base64 of the packed
// key bytes.
func parseMLDSA65PrivateKey(material string) (*mldsa65.PrivateKey, error) {
raw, err := decodeKeyMaterial(material)
if err != nil {
return nil, err
}
sk := new(mldsa65.PrivateKey)
if err := sk.UnmarshalBinary(raw); err != nil {
return nil, err
}
return sk, nil
}
// parseMLDSA65PublicKey decodes an ML-DSA-65 public key from stored material.
func parseMLDSA65PublicKey(material string) (*mldsa65.PublicKey, error) {
raw, err := decodeKeyMaterial(material)
if err != nil {
return nil, err
}
pk := new(mldsa65.PublicKey)
if err := pk.UnmarshalBinary(raw); err != nil {
return nil, err
}
return pk, nil
}
// mldsa65PublicFromCert returns the ML-DSA-65 public key for a cert, from its
// published Certificate material when present, else derived from the private key
// (dev certs that store only the key). It never returns private material.
func mldsa65PublicFromCert(cert *schema.Cert) (*mldsa65.PublicKey, error) {
if cert.Certificate != "" {
if pk, err := parseMLDSA65PublicKey(cert.Certificate); err == nil {
return pk, nil
}
}
sk, err := parseMLDSA65PrivateKey(cert.PrivateKey)
if err != nil {
return nil, err
}
pub, ok := sk.Public().(*mldsa65.PublicKey)
if !ok {
return nil, errors.New("mldsa: derived public key has the wrong type")
}
return pub, nil
}
// decodeKeyMaterial extracts raw key bytes from a PEM envelope or bare base64
// (standard or url encoding), the two shapes a Cert row stores raw keys in.
func decodeKeyMaterial(material string) ([]byte, error) {
material = strings.TrimSpace(material)
if material == "" {
return nil, errors.New("mldsa: empty key material")
}
if block, _ := pem.Decode([]byte(material)); block != nil {
return block.Bytes, nil
}
if raw, err := base64.StdEncoding.DecodeString(material); err == nil {
return raw, nil
}
raw, err := base64.RawURLEncoding.DecodeString(material)
if err != nil {
return nil, errors.New("mldsa: key material is neither PEM nor base64")
}
return raw, nil
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package oidc serves the IAM v2 OpenID Connect / OAuth2 surface on zip. The
// handlers are RAW zip handlers (func(c *zip.Ctx) error), not typed generics,
// because the auth surface needs query params, form bodies, redirects, and
// headers a JSON-in/JSON-out handler can't reach.
//
// The surface is the canonical hanzo.id contract, unchanged across the v1→v2
// backend swap: discovery + JWKS under .well-known, the oauth/{authorize,token,
// userinfo,logout} endpoints, and the front-door {get-app-login, auth/methods,
// login} the hosted UI calls. Tokens are signed JWTs (RS256 interop, ES/ML-DSA
// behind the same JWKS); every value is verified, never trusted.
package oidc
import (
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
)
// Canonical OIDC paths — the single source of truth the @hanzo/iam SDK and every
// existing relying party hard-code. iam2 serves them directly; the transition
// off v1 is a backend swap behind the same paths, never a parallel version.
const (
PathAuthorize = "/v1/iam/oauth/authorize"
PathToken = "/v1/iam/oauth/token"
PathUserInfo = "/v1/iam/oauth/userinfo"
PathLogout = "/v1/iam/oauth/logout"
PathJWKS = "/v1/iam/.well-known/jwks"
PathJWKSRoot = "/.well-known/jwks"
PathDiscovery = "/.well-known/openid-configuration"
PathDiscoveryV1 = "/v1/iam/.well-known/openid-configuration"
PathASMetadata = "/.well-known/oauth-authorization-server" // RFC 8414 (root)
PathASMetadataV1 = "/v1/iam/.well-known/oauth-authorization-server" // RFC 8414 (v1)
PathDevice = "/v1/iam/oauth/device" // RFC 8628 device authorization
// PathDeviceVerify is the user-facing device-approval PAGE (a route in the
// hosted SPA), not an API path: RFC 8628's verification_uri is somewhere a
// human opens and signs in, which the JSON token API can never be.
PathDeviceVerify = "/login/oauth/device"
)
// Route registers the entire OIDC/OAuth2 surface on r, backed by db. This is the
// one entry point the route table calls — discovery, JWKS, the protocol
// endpoints, and the front door are all wired here so the surface lives in one
// place. r is the PUBLIC group (registered before the router's authentication
// Guard): the whole OIDC/OAuth + front-door surface is pre-authentication by
// construction, so membership in this group IS what makes it reachable without a
// bearer — there is no separate allow-list to keep in sync.
func Route(r zip.Router, db orm.DB) {
// Discovery and the JWKS are each served at BOTH the root well-known path
// (RFC 8414 §3, where a bare-origin client and the gateway's default look)
// and the /v1/iam-prefixed path, matching the live hanzo.id surface. Both
// paths are the same handler over the same keys — one key set, two spellings
// of where to find it.
jwks := jwksHandler(db)
r.Get(PathDiscovery, Discovery)
r.Get(PathDiscoveryV1, Discovery)
// RFC 8414 OAuth Authorization Server Metadata — the same self-consistent
// document at the OAuth well-known path (a superset serves it), so an OAuth-only
// client that looks for `oauth-authorization-server` finds the AS too.
r.Get(PathASMetadata, Discovery)
r.Get(PathASMetadataV1, Discovery)
r.Get(PathJWKS, jwks)
r.Get(PathJWKSRoot, jwks)
// OAuth2 / OIDC protocol endpoints.
r.Get(PathAuthorize, authorizeHandler(db))
r.Post(PathAuthorize, authorizeHandler(db))
r.Get(PathUserInfo, userinfoHandler(db))
r.Post(PathUserInfo, userinfoHandler(db))
r.Get(PathLogout, logoutHandler(db))
r.Post(PathLogout, logoutHandler(db))
// The token endpoint, the credential login that mints codes, and the
// read-only front door the hosted <Login> self-configures from.
routeToken(r, db)
routeLogin(r, db)
routeFrontDoor(r, db)
// Identity federation: the external-IdP callback (Google/GitHub, …). The
// authorize endpoint kicks a federation off when the request names a
// `provider`; this registers the fixed return endpoint the IdP redirects to.
routeFederation(r, db)
routeFederationMfa(r, db)
routeUnlink(r, db)
// RFC 7662 introspection + RFC 7009 revocation — the standard token-management
// endpoints a resource server / confidential client uses (client-authenticated).
routeIntrospectRevoke(r, db)
// RFC 8628 device authorization grant — the browserless CLI sign-in. The
// request endpoint is registered here; the poll rides the token endpoint and
// the approval rides the login endpoint, both already public above.
routeDevice(r, db)
// The confidential-client "act on behalf of a user" primitive (the console +
// keyless-AI proxies mint their forwarded bearer here). Authenticates the
// client itself, so it is not Bearer-gated.
routeIssueToken(r, db)
}
// Discovery serves the OIDC discovery document, host-relative (issuer derived
// from the request host, the same value the tokens carry as `iss`) so a strict
// client never splits origin. It advertises only what iam2 implements: the
// authorization-code flow, S256 PKCE, the three supported grants, and the
// signing algorithms whose public keys the JWKS actually publishes.
func Discovery(c *zip.Ctx) error {
iss := tokenIssuer(c)
return c.JSON(200, map[string]any{
"issuer": iss,
"authorization_endpoint": iss + PathAuthorize,
"token_endpoint": iss + PathToken,
"userinfo_endpoint": iss + PathUserInfo,
"introspection_endpoint": iss + PathIntrospect,
"revocation_endpoint": iss + PathRevoke,
"end_session_endpoint": iss + PathLogout,
"device_authorization_endpoint": iss + PathDevice,
"jwks_uri": iss + PathJWKS,
"response_types_supported": []string{"code"},
"response_modes_supported": []string{"query", "fragment", "form_post"},
"grant_types_supported": []string{"authorization_code", "refresh_token", "client_credentials", "password", grantTypeTokenExchange, deviceGrant},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256", "RS512", "ES256", "ES384", "ES512", "MLDSA65"},
"scopes_supported": []string{"openid", "email", "profile", "address", "phone", "offline_access"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"code_challenge_methods_supported": []string{"S256"},
"claims_supported": []string{
"iss", "sub", "aud", "iat", "exp", "nbf", "jti", "nonce", "azp",
"owner", "organization", "scope", "tokenType",
"name", "preferred_username", "email", "email_verified",
"picture", "address", "phone", "groups", "is_verified",
},
})
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/organizations"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// POST /v1/iam/onboard — first-run org onboarding. A signed-in user with no org of
// their own creates one (named, or a one-click `<username>` personal org) and is
// MOVED into it as its admin, so everyone always has an org. Because an IAM user's
// org IS their identity, the move re-keys the caller (their subject becomes
// slug/name), so the console re-authenticates after a success.
//
// The response is the console's own {org}/{error} contract (NOT the casibase
// envelope): {"org":"<slug>"} on success, an {"error":"..."} with a 4xx/5xx status
// on failure — the OrgOnboarding client reads json.org / json.error directly.
//
// Self-scoped: the caller is resolved from its session/bearer (callerOf), never from
// the body, so onboarding only ever moves the caller — its own identity, its own org.
// PathOnboard is the canonical first-run onboarding endpoint.
const PathOnboard = "/v1/iam/onboard"
// Org slug bounds mirror the console's onboarding policy (src/lib/server/onboarding.ts):
// an IAM org name is varchar(100); keep the slug short + readable.
const (
minOrgSlug = 2
maxOrgSlug = 60
)
// The IAM SYSTEM owners a customer org may never become — creating one would collide
// with a signing-cert owner (admin/built-in) or a system principal (app) — are the
// ONE store.IsReservedOrg set, shared with signup and federated provisioning so the
// reserved set never drifts between surfaces. Brand/staff orgs (hanzo/lux/zoo/pars in
// the console list) are NOT reserved here (iam2 is white-label): an existing one is
// refused by the create-conflict check.
// onboardForm is the request body: a name to create, or personal=true for the
// one-click `<username>` org.
type onboardForm struct {
Name string `json:"name"`
Personal bool `json:"personal"`
}
// onboardHandler creates the caller's org and moves them into it as admin.
func onboardHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
owner, name, ok := callerOf(ctx, c, db)
if !ok {
return onboardErr(c, 401, "please sign in first")
}
var f onboardForm
_ = c.Bind(&f)
slug, display, personal := f.Name, strings.TrimSpace(f.Name), false
if f.Personal {
slug, display, personal = personalOrgSlug(name), name, true
} else {
slug = slugifyOrg(f.Name)
}
if len(slug) < minOrgSlug {
return onboardErr(c, 400, "use at least 2 letters or numbers")
}
if store.IsReservedOrg(slug) {
return onboardErr(c, 400, "\""+slug+"\" is reserved. choose a different name")
}
// Load the caller BEFORE creating the org, so a missing user is a clean 4xx
// with no orphaned org.
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return onboardErr(c, 500, "server_error")
}
if user == nil {
return onboardErr(c, 400, "the user does not exist")
}
// The slug must be free (globally: org names are unique). This is also the
// guard that refuses an existing brand/staff org by name.
existing, err := store.GetOrganizationByName(ctx, db, slug)
if err != nil {
return onboardErr(c, 500, "server_error")
}
if existing != nil {
return onboardErr(c, 409, "the organization \""+slug+"\" already exists")
}
// Create the tenant org (platform-owned, Owner "admin") through the ONE org
// create path.
if display == "" {
display = slug
}
if _, err := organizations.NewOrganizationAPI(db).Create(ctx, &organizations.CreateOrganizationInput{
Organization: schema.Organization{
Owner: "admin",
Name: slug,
DisplayName: display,
IsPersonal: personal,
CreatedTime: onboardNow(),
},
}); err != nil {
return onboardErr(c, 400, err.Error())
}
// Move the caller in as admin. Changing Owner re-keys the identity (the row's
// surrogate id is stable; user lookups are by (owner, name)), so the loaded
// row updates in place and the caller thereafter resolves under the new org.
user.Owner = slug
user.IsAdmin = true
user.UpdatedTime = onboardNow()
if err := user.UpdateCtx(ctx); err != nil {
return onboardErr(c, 500, err.Error())
}
return c.JSON(200, map[string]string{"org": slug})
}
}
// onboardErr writes the console's {"error":...} shape with an HTTP status the
// client treats as failure (res.ok=false), never the casibase 200 envelope.
func onboardErr(c *zip.Ctx, status int, msg string) error {
return c.JSON(status, map[string]string{"error": msg})
}
// onboardNow is the v1-compatible string timestamp for a freshly created/updated row.
func onboardNow() string { return time.Now().UTC().Format(time.RFC3339) }
// slugifyOrg normalizes a human org name into an IAM slug — lowercase ASCII
// alphanumerics, every other run collapsed to a single '-', trimmed, capped at
// maxOrgSlug. Mirrors the console's slugifyOrg for ASCII (the common case); a
// non-ASCII rune becomes '-' rather than its NFKD base letter (no stdlib NFKD), so
// an accented name yields a valid but possibly different slug than the client preview
// — the server slug is authoritative and get-account reports the real org.
func slugifyOrg(input string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(input) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if !dash {
b.WriteByte('-')
dash = true
}
}
s := strings.Trim(b.String(), "-")
if len(s) > maxOrgSlug {
s = s[:maxOrgSlug]
}
return strings.TrimRight(s, "-")
}
// personalOrgSlug is the default one-click org slug for a user: the local part of an
// email-like username (dave@x.com → dave), slugified — so a personal org reads as the
// person, not their address. Mirrors the console's personalOrgSlug.
func personalOrgSlug(username string) string {
base := username
if i := strings.IndexByte(username, '@'); i > 0 {
base = username[:i]
}
return slugifyOrg(base)
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/url"
"testing"
)
// The Resource Owner Password Credentials grant (RFC 6749 §4.3) — the durable
// first-party console session. Verifies the happy path mints a real, verifiable
// token carrying the user's identity, and that every rejection (bad password,
// public client, password-disabled app) fails closed. The password is checked
// through the SAME algorithm-aware path the login form uses.
func TestPasswordGrant_mintsTokenForValidCredentials(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
resp, tok := postToken(t, app, url.Values{
"grant_type": {"password"},
"client_id": {"hanzo-console"},
"client_secret": {"top-secret"},
"username": {"alice@hanzo.ai"},
"password": {"correct horse"},
"scope": {"openid profile email offline_access"},
})
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200; body=%v", resp.StatusCode, tok)
}
access, _ := tok["access_token"].(string)
if access == "" {
t.Fatalf("no access_token; body=%v", tok)
}
// offline_access → a refresh token; openid → an id_token.
if tok["refresh_token"] == nil || tok["refresh_token"] == "" {
t.Errorf("offline_access requested but no refresh_token minted")
}
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("minted token does not verify: %v", err)
}
if claims.Subject != "hanzo/alice" {
t.Errorf("subject = %q, want hanzo/alice", claims.Subject)
}
if claims.Owner != "hanzo" {
t.Errorf("owner = %q, want hanzo", claims.Owner)
}
}
func TestPasswordGrant_wrongPassword_invalidGrant(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
resp, tok := postToken(t, app, url.Values{
"grant_type": {"password"},
"client_id": {"hanzo-console"},
"client_secret": {"top-secret"},
"username": {"alice@hanzo.ai"},
"password": {"WRONG"},
})
requireError(t, resp, tok, 400, "invalid_grant")
}
func TestPasswordGrant_unknownUser_invalidGrant_sameAsBadPassword(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
// No user seeded: an unknown user must return the SAME opaque invalid_grant as
// a wrong password — no user-enumeration oracle.
resp, tok := postToken(t, app, url.Values{
"grant_type": {"password"},
"client_id": {"hanzo-console"},
"client_secret": {"top-secret"},
"username": {"ghost@hanzo.ai"},
"password": {"anything"},
})
requireError(t, resp, tok, 400, "invalid_grant")
}
func TestPasswordGrant_publicClient_rejected(t *testing.T) {
app, db := newServer(t)
// A public (no-secret) client can never use the password grant.
seedApp(t, db, appOpts{clientID: "pub"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
resp, tok := postToken(t, app, url.Values{
"grant_type": {"password"},
"client_id": {"pub"},
"username": {"alice@hanzo.ai"},
"password": {"correct horse"},
})
if resp.StatusCode != 401 {
t.Fatalf("public-client password grant status = %d, want 401; body=%v", resp.StatusCode, tok)
}
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/internal/users"
)
// TestPasswordHashPersists is the regression for the json:"-"-drops-from-storage
// bug: orm serializes an entity to its JSON data column, so a credential field
// tagged json:"-" was never stored → every retrieved user had an empty hash →
// login could never succeed. This proves the hash survives a store round-trip
// and verifies, and that the same holds for AccessSecretHash.
func TestPasswordHashPersists(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
hash, _ := bcrypt.GenerateFromPassword([]byte("s3cret-pw"), bcrypt.MinCost)
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = "persisttest"
u.Email = "persist@hanzo.ai"
u.PasswordHash = string(hash)
u.PasswordType = "bcrypt"
u.AccessSecretHash = "access-hash-value"
if err := u.Create(); err != nil {
t.Fatal(err)
}
got, err := store.GetUserByEmail(ctx, db, "hanzo", "persist@hanzo.ai")
if err != nil || got == nil {
t.Fatalf("lookup: %v", err)
}
if got.PasswordHash == "" {
t.Fatal("PasswordHash did not persist — the json:\"-\" storage bug is back")
}
if got.AccessSecretHash == "" {
t.Fatal("AccessSecretHash did not persist")
}
// The retrieved hash actually verifies the password.
if !users.VerifyPassword(got, "s3cret-pw", "") {
t.Fatal("persisted hash does not verify the password")
}
if users.VerifyPassword(got, "wrong-pw", "") {
t.Fatal("wrong password verified — bcrypt broken")
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
)
// PKCE (RFC 7636) — S256 only. iam2 permanently rejects the "plain" method:
// a downgrade to plain defeats the point of PKCE (the verifier travels in the
// clear), so an authorize request that stored a plain challenge, or a token
// request that presents one, is refused.
var (
// ErrPKCEPlainRejected is returned when a challenge method other than S256
// is presented. Never accept "plain".
ErrPKCEPlainRejected = errors.New("pkce: only S256 is supported (plain is rejected)")
// ErrPKCEMismatch is returned when the verifier does not derive the stored
// challenge. Constant-time — the error is identical regardless of where the
// bytes diverge.
ErrPKCEMismatch = errors.New("pkce: code_verifier does not match code_challenge")
// ErrPKCEMissing is returned when a challenge was stored but no verifier was
// presented (or vice-versa).
ErrPKCEMissing = errors.New("pkce: code_verifier required")
)
// ComputeS256Challenge derives the RFC 7636 S256 challenge from a verifier:
// BASE64URL-ENCODE(SHA256(ASCII(verifier))), no padding.
func ComputeS256Challenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// VerifyPKCE checks a code_verifier against a stored (challenge, method).
//
// - A stored challenge with method != "S256" is refused (ErrPKCEPlainRejected)
// — including an empty method, which some clients send for plain.
// - An empty stored challenge means the authorization code was minted WITHOUT
// PKCE; the caller decides whether that path is allowed (public clients must
// require it). This function returns nil for (empty, empty) so a caller can
// treat "no PKCE on either side" as not-an-error and enforce its own policy.
// - A stored challenge with an empty verifier is ErrPKCEMissing.
// - Otherwise the verifier is hashed and compared to the challenge in constant
// time (subtle.ConstantTimeCompare), so a mismatch leaks no position.
func VerifyPKCE(verifier, challenge, method string) error {
if challenge == "" {
if verifier != "" {
// A verifier with no stored challenge is a protocol error, but it is
// not a match either — treat as missing so the caller fails closed.
return ErrPKCEMissing
}
return nil // no PKCE on either side; caller enforces public-client policy
}
if method != "S256" {
return ErrPKCEPlainRejected
}
if verifier == "" {
return ErrPKCEMissing
}
want := ComputeS256Challenge(verifier)
if subtle.ConstantTimeCompare([]byte(want), []byte(challenge)) != 1 {
return ErrPKCEMismatch
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More