Compare commits

...
81 Commits
Author SHA1 Message Date
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
163 changed files with 28190 additions and 226 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/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 }}
# 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"
+48
View File
@@ -0,0 +1,48 @@
# 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 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
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/iam2 .
FROM alpine:latest AS STANDARD
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam2"
LABEL org.opencontainers.image.title="Hanzo IAM v2"
RUN apk add --no-cache ca-certificates && 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
# 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
```
+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")
}
}
+35 -17
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,22 @@ 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/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,6 +46,7 @@ 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
@@ -40,38 +54,42 @@ require (
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
)
+114 -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,22 @@ 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/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 +76,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 +147,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=
+216
View File
@@ -0,0 +1,216 @@
// 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"
)
// 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
}
// 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())
}
// 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())
}
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
}
}
+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())
}
+489
View File
@@ -0,0 +1,489 @@
// 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
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 is the one Casdoor read verb whose target rides in
// ?organization= (the ScopeSwitcher's project 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/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 (admin/built-in — the reserved owners the token
// verifier trusts to sign) is writable only by a SuperAdmin. This single
// rule is the signing-cert poisoning gate, the admin-scoped app/provider
// registration gate, AND the built-in-org gap, all at once: a built-in-org
// principal is not SuperAdmin (that is admin only), so it cannot write a
// built-in-owned signing cert either.
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
// empty or foreign owner is refused — the target org is bound to the
// principal, never trusted from the request.
// 4. Inside its own org, an org admin manages everything; a regular user may
// only READ its own user record (self-service). The users entity serves
// reads as GET and writes as POST, so gating the self clause to GET keeps a
// regular user from writing its own record — a raw entity write would
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
// is the Phase-5 provision-don't-promote concern; here it is closed by
// denial.
func authorize(p *Principal, method, entity, owner, name string) bool {
if p.Super {
return true
}
if store.IsSigningCertOwner(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 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
}
return &Principal{App: a.Name, 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
}
+112
View File
@@ -0,0 +1,112 @@
// 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", Org: "admin"} // user+org admin caps
nobody := &Principal{App: "rogue-app", Org: "hanzo"} // in no allowlist
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},
}
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")
}
}
// 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"}, CapOrgAdmin) {
t.Fatal("a named app must hold its capability")
}
})
t.Run("Allowed unnamed denied", func(t *testing.T) {
if Allowed(&Principal{App: "other"}, CapOrgAdmin) {
t.Fatal("an unnamed app must hold nothing")
}
})
t.Run("Allowed unset env denied", func(t *testing.T) {
if Allowed(&Principal{App: "hanzo-console"}, CapKeyMint) { // IAM_KEY_MINT_ALLOWED_APPS unset here
t.Fatal("an unset allowlist must deny every app")
}
})
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)
}
}
})
}
+113
View File
@@ -0,0 +1,113 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package authz
import (
"os"
"strings"
)
// 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 — a name in an
// allowlist is thereby reserved to the platform's admin-owned app, so a tenant
// cannot register <theirOrg>/hanzo-console and inherit its grants.
// 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
}
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
+212
View File
@@ -0,0 +1,212 @@
// 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))
// 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)
}
}
// 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),
}))
}
+150
View File
@@ -0,0 +1,150 @@
// 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"
)
// 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)
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"))
// 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
}
+85
View File
@@ -0,0 +1,85 @@
// 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
}
// EffectiveHost is the request host used to build a host-relative issuer, so
// discovery/JWKS never split-origin (HIP-0111). Honors X-Forwarded-Host when
// the request came through the ingress/gateway.
func EffectiveHost(c *zip.Ctx) string {
if h := c.Header("X-Forwarded-Host"); h != "" {
return h
}
return c.Header("Host")
}
+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)
}
}
+81
View File
@@ -0,0 +1,81 @@
// 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 follows the request host (X-Forwarded-Host at the edge), so
// discovery and the tokens it describes never split origin.
func TestDiscovery_IssuerFollowsForwardedHost(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://id.example.test" {
t.Fatalf("issuer = %v, want https://id.example.test", 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
}
+642
View File
@@ -0,0 +1,642 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"net/url"
"os"
"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. In production it is the IAM_ISSUER pin (required — HIP-0112) so the
// value is fixed and header-immune. Where IAM_ISSUER is unset (dev), it falls
// back to the DIRECT Host header — NEVER httpx.EffectiveHost, which honors the
// attacker-suppliable X-Forwarded-Host — so even the dev path cannot be steered
// to an attacker origin.
func federationBaseURL(c *zip.Ctx) string {
if iss := strings.TrimSpace(os.Getenv("IAM_ISSUER")); iss != "" {
return strings.TrimRight(iss, "/")
}
if h := strings.TrimSpace(c.Header("Host")); h != "" {
return "https://" + h
}
return "https://hanzo.id"
}
// 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 == "" || reservedOrgs[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)
}
}
+328
View File
@@ -0,0 +1,328 @@
// 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 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"). Matched by the
// globally-unique clientId only (see mintAllowed).
if !mintAllowed(clientID) {
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.ClientId) {
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 a client is on the IAM_KEY_MINT_ALLOWED_APPS
// allow-list. It matches the client's GLOBALLY-unique clientId ONLY — never the
// per-owner-unique app Name: a Name match let a tenant org-admin register an app
// named like the console in their OWN org and pass the gate, minting an admin-org
// (SuperAdmin) token (red-team finding, closed here). An empty/unset list allows
// nothing — fail closed.
func mintAllowed(clientID string) bool {
return appInList("IAM_KEY_MINT_ALLOWED_APPS", clientID)
}
// adminMintAllowed reports whether a client 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. The console, which legitimately drives admin.hanzo.ai, is
// on both lists. Fail closed.
func adminMintAllowed(clientID string) bool {
return appInList("IAM_ADMIN_MINT_ALLOWED_APPS", 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
}
+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",
},
})
}
+175
View File
@@ -0,0 +1,175 @@
// 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
)
// reservedOrgs are 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). Brand/staff orgs (hanzo/lux/zoo/pars in the console list) are NOT hard-coded
// here (iam2 is white-label): an existing one is refused by the create-conflict check.
var reservedOrgs = map[string]bool{"admin": true, "built-in": true, "app": true}
// 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 reservedOrgs[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
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"errors"
"testing"
)
func TestComputeS256Challenge_RFC7636Vector(t *testing.T) {
// The canonical RFC 7636 Appendix B test vector.
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
want := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
if got := ComputeS256Challenge(verifier); got != want {
t.Fatalf("S256 challenge = %q, want %q (RFC 7636 vector)", got, want)
}
}
func TestVerifyPKCE_HappyPath(t *testing.T) {
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := ComputeS256Challenge(verifier)
if err := VerifyPKCE(verifier, challenge, "S256"); err != nil {
t.Fatalf("valid verifier rejected: %v", err)
}
}
func TestVerifyPKCE_WrongVerifierRejected(t *testing.T) {
challenge := ComputeS256Challenge("the-real-verifier-value-0000000000000000000")
err := VerifyPKCE("a-different-verifier-value-000000000000000000", challenge, "S256")
if !errors.Is(err, ErrPKCEMismatch) {
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
}
}
func TestVerifyPKCE_PlainRejected(t *testing.T) {
// Even if the "plain" value would match, the method must be refused.
v := "plain-verifier-equals-challenge-under-plain-000"
for _, method := range []string{"plain", "PLAIN", "", "s256", "S384"} {
if err := VerifyPKCE(v, v, method); !errors.Is(err, ErrPKCEPlainRejected) {
t.Fatalf("method %q: got %v, want ErrPKCEPlainRejected", method, err)
}
}
}
func TestVerifyPKCE_MissingVerifier(t *testing.T) {
challenge := ComputeS256Challenge("some-verifier-0000000000000000000000000000000")
if err := VerifyPKCE("", challenge, "S256"); !errors.Is(err, ErrPKCEMissing) {
t.Fatalf("empty verifier with a stored challenge: got %v, want ErrPKCEMissing", err)
}
}
func TestVerifyPKCE_VerifierWithNoChallengeFailsClosed(t *testing.T) {
// A verifier presented when the code was minted with no challenge is a
// protocol error and must NOT be treated as a match.
if err := VerifyPKCE("unexpected-verifier", "", "S256"); !errors.Is(err, ErrPKCEMissing) {
t.Fatalf("verifier with empty challenge: got %v, want ErrPKCEMissing", err)
}
}
func TestVerifyPKCE_NoPKCEEitherSide(t *testing.T) {
// No challenge and no verifier: not an error here — the caller enforces
// whether a public client is allowed to skip PKCE.
if err := VerifyPKCE("", "", ""); err != nil {
t.Fatalf("no PKCE on either side should be nil, got %v", err)
}
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"encoding/json"
"fmt"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/store"
)
// POST /v1/iam/update-preferences — the ONE account-backed store for cross-product,
// cross-device user customizations (onboarding-completed flag, theme, pinned
// favorites, …). The console's PreferencesProvider reads these from the account's
// Properties["hanzo.preferences"] JSON blob and write-through-persists partial updates
// here. Ported from the v1 me_preferences.go contract.
//
// SELF-SCOPED (never the body): the target user is ALWAYS the caller resolved from its
// session/bearer (callerOf) — never a name/org in the request — so a caller can only
// ever write its OWN preferences. MERGE: top-level keys are shallow-merged onto the
// stored object, so concurrent products/devices setting DIFFERENT keys don't clobber
// each other; the merged object is returned so the caller keeps every other key.
// PathUpdatePreferences is the canonical self-preferences endpoint.
const PathUpdatePreferences = "/v1/iam/update-preferences"
// preferencesKey is the User.Properties entry holding the cross-product preferences
// JSON blob — the backend half of the console contract (PREFS_PROPERTY); keep in
// lockstep.
const preferencesKey = "hanzo.preferences"
// preferencesMaxBytes caps the serialized merged blob so a runaway client can't grow
// the properties column without bound.
const preferencesMaxBytes = 64 * 1024
// updatePreferencesHandler shallow-merges the posted partial onto the caller's stored
// preferences and returns the merged object.
func updatePreferencesHandler(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")
}
mergedJSON, merged, err := mergePreferences(user.Properties[preferencesKey], c.Fiber().Body())
if err != nil {
return httpx.Err(c, err.Error())
}
if user.Properties == nil {
user.Properties = map[string]string{}
}
user.Properties[preferencesKey] = mergedJSON
user.UpdatedTime = onboardNow()
if err := user.UpdateCtx(ctx); err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, merged)
}
}
// mergePreferences shallow-merges a JSON `patch` object onto the `existing`
// preferences JSON string, returning the merged JSON plus the merged map. Pure (no
// session, no DB): a patch key overwrites ONLY that top-level key (every other stored
// key survives); an absent/blank/corrupt `existing` is an empty object (a first write
// still lands and self-heals); a non-object patch is rejected; the blob is size-capped.
func mergePreferences(existing string, patch []byte) (string, map[string]json.RawMessage, error) {
patchMap := map[string]json.RawMessage{}
if err := json.Unmarshal(patch, &patchMap); err != nil {
return "", nil, fmt.Errorf("preferences must be a JSON object: %w", err)
}
merged := map[string]json.RawMessage{}
if existing != "" {
_ = json.Unmarshal([]byte(existing), &merged) // corrupt stored blob → treated as empty
}
for k, v := range patchMap {
merged[k] = v
}
out, err := json.Marshal(merged)
if err != nil {
return "", nil, err
}
if len(out) > preferencesMaxBytes {
return "", nil, fmt.Errorf("preferences exceed maximum size of %d bytes", preferencesMaxBytes)
}
return string(out), merged, nil
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/subtle"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// Refresh-token rotation with reuse detection. A refresh token is an opaque,
// single-use bearer (stored only as a SHA-256 hash): every exchange consumes the
// presented token and mints a successor in the same rotation family. Presenting
// an already-consumed refresh is a replay — the whole family is revoked so a
// stolen token cannot outlive its legitimate successor (RFC 9700 §4.14). This is
// the load-bearing hardening over v1, whose refresh path is rotate-and-delete
// with no family cascade.
// refreshTokenGrant handles grant_type=refresh_token.
func refreshTokenGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
presented := param(c, "refresh_token")
if presented == "" {
return tokenError(c, 400, "invalid_request", "refresh_token is required")
}
clientID, clientSecret := clientAuth(c)
tok, err := store.GetTokenByRefreshHash(ctx, db, hashToken(presented))
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if tok == nil {
return tokenError(c, 400, "invalid_grant", "refresh token is invalid or revoked")
}
app, err := resolveTokenApp(ctx, db, tok)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if app == nil {
return tokenError(c, 400, "invalid_grant", "refresh token is invalid or revoked")
}
// Client authentication: the presented client must be the grant's client, and
// a confidential client must present its secret.
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
return tokenError(c, 400, "invalid_grant", "client mismatch")
}
if app.ClientSecret != "" {
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
}
// Reuse detection: a consumed token was already rotated. Revoke the whole
// family and refuse — a replay means the token leaked.
if tok.RefreshConsumed {
revokeRefreshFamily(ctx, db, tok.RefreshFamily)
return tokenError(c, 400, "invalid_grant", "refresh token replay detected")
}
if tok.RefreshExpireIn != 0 && now.Unix() > tok.RefreshExpireIn {
return tokenError(c, 400, "invalid_grant", "refresh token expired")
}
// Optional scope narrowing — never widening (RFC 6749 §6).
scope := tok.Scope
if req := param(c, "scope"); req != "" {
if !scopeSubset(req, tok.Scope) {
return tokenError(c, 400, "invalid_scope", "requested scope exceeds the grant")
}
scope = req
}
// Rotate: consume the presented token, then mint a successor in the same
// family. The successor is a new row so the consumed one remains as a
// tripwire for replay until the family is revoked or expires.
tok.RefreshConsumed = true
if err := store.SaveToken(ctx, db, tok); err != nil {
return tokenError(c, 500, "server_error", "")
}
nameSeed, err := newOpaqueToken()
if err != nil {
return tokenError(c, 500, "server_error", "")
}
nu := &schema.Token{
Owner: tok.Owner,
Application: tok.Application,
Organization: tok.Organization,
User: tok.User,
Scope: scope,
Nonce: tok.Nonce,
Resource: tok.Resource,
RedirectUri: tok.RedirectUri,
}
nu.Name = "rt-" + nameSeed[:24]
resp, err := issueTokens(ctx, db, c, app, nu, tok.RefreshFamily, now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if err := store.PersistToken(ctx, db, nu); err != nil {
return tokenError(c, 500, "server_error", "")
}
return c.JSON(200, resp)
}
// revokeRefreshFamily deletes every token row in a rotation family — the
// containment response when a rotated refresh token is replayed.
func revokeRefreshFamily(ctx context.Context, db orm.DB, family string) {
rows, err := store.ListTokensByRefreshFamily(ctx, db, family)
if err != nil {
return
}
for _, r := range rows {
_ = store.DeleteToken(ctx, db, r)
}
}
// scopeSubset reports whether every scope in sub is present in super.
func scopeSubset(sub, super string) bool {
for _, s := range strings.Fields(sub) {
if !hasScope(super, s) {
return false
}
}
return true
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/url"
"testing"
"github.com/zap-proto/zip"
)
// grantViaPKCE runs the public authorization-code+PKCE flow and returns the
// issued token set.
func grantViaPKCE(t *testing.T, app *zip.App, clientID, scope string) map[string]any {
t.Helper()
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
params := loginParams(clientID, scope)
params["codeChallenge"] = ComputeS256Challenge(verifier)
params["codeChallengeMethod"] = "S256"
code, _, _ := loginForCode(t, app, params)
resp, tok := exchangeCode(t, app, url.Values{
"code": {code}, "client_id": {clientID}, "redirect_uri": {testRedirect}, "code_verifier": {verifier},
})
if resp.StatusCode != 200 {
t.Fatalf("grant failed: %d %v", resp.StatusCode, tok)
}
return tok
}
func refresh(t *testing.T, app *zip.App, clientID, refreshToken string, extra url.Values) (int, map[string]any) {
t.Helper()
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refreshToken}, "client_id": {clientID}}
for k, vs := range extra {
form[k] = vs
}
resp, tok := postToken(t, app, form)
return resp.StatusCode, tok
}
// A refresh rotates: it returns a new access token AND a new refresh token,
// distinct from the one presented.
func TestRefresh_Rotates(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
tok := grantViaPKCE(t, app, "pub", "openid offline_access")
refresh1 := tok["refresh_token"].(string)
status, out := refresh(t, app, "pub", refresh1, nil)
if status != 200 {
t.Fatalf("refresh status = %d, body %v", status, out)
}
refresh2, _ := out["refresh_token"].(string)
if refresh2 == "" || refresh2 == refresh1 {
t.Fatalf("refresh must rotate the token: got %q (old %q)", refresh2, refresh1)
}
if out["access_token"] == nil {
t.Fatal("refresh must issue a new access token")
}
}
// Replaying a rotated (consumed) refresh token is detected and revokes the whole
// family — the legitimate successor dies with it.
func TestRefresh_ReuseDetectionRevokesFamily(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
tok := grantViaPKCE(t, app, "pub", "openid offline_access")
refresh1 := tok["refresh_token"].(string)
// Legitimate rotation → refresh2.
_, out := refresh(t, app, "pub", refresh1, nil)
refresh2 := out["refresh_token"].(string)
// Replay the consumed refresh1 → reuse detected.
status, replay := refresh(t, app, "pub", refresh1, nil)
if status != 400 || replay["error"] != "invalid_grant" {
t.Fatalf("replay of rotated token: status=%d err=%v, want 400 invalid_grant", status, replay["error"])
}
// The family is revoked: the legitimate successor refresh2 no longer works.
status, after := refresh(t, app, "pub", refresh2, nil)
if status != 400 || after["error"] != "invalid_grant" {
t.Fatalf("successor after reuse: status=%d err=%v, want 400 invalid_grant (family revoked)", status, after["error"])
}
}
// Refresh may narrow scope but never widen it.
func TestRefresh_ScopeNarrowingOnly(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
tok := grantViaPKCE(t, app, "pub", "openid profile email")
rt := tok["refresh_token"].(string)
t.Run("narrow ok", func(t *testing.T) {
status, out := refresh(t, app, "pub", rt, url.Values{"scope": {"openid"}})
if status != 200 || out["scope"] != "openid" {
t.Fatalf("narrowing failed: status=%d scope=%v", status, out["scope"])
}
})
t.Run("widen rejected", func(t *testing.T) {
tok2 := grantViaPKCE(t, app, "pub", "openid")
status, out := refresh(t, app, "pub", tok2["refresh_token"].(string), url.Values{"scope": {"openid profile admin"}})
if status != 400 || out["error"] != "invalid_scope" {
t.Fatalf("widening should be invalid_scope: status=%d err=%v", status, out["error"])
}
})
}
// An unknown refresh token is refused without leaking whether it ever existed.
func TestRefresh_UnknownToken(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
status, out := refresh(t, app, "pub", "not-a-real-refresh-token", nil)
if status != 400 || out["error"] != "invalid_grant" {
t.Fatalf("unknown refresh: status=%d err=%v", status, out["error"])
}
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/cred"
"github.com/hanzoai/iam/internal/httpx"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// The native front-door OTP send: POST /v1/iam/send-verification-code. It mirrors
// the v1 Casdoor SendVerificationCode contract (controllers/verification.go): the
// request is multipart/form-data (NOT JSON — a HIP-0111 §4 invariant), and the
// response is the casibase {status,msg,data} envelope with an empty data on
// success.
//
// This endpoint owns the code-generation + persistence + validation surface. The
// actual email/SMS DELIVERY is a separate concern owned by hanzoai/notify (v1
// calls object.SendVerificationCodeToEmail/…Phone, which forwards to notify over
// ZAP). notify is not wired into iam2 yet, so this endpoint persists a verifiable
// code and returns {status:"ok"} honestly — it does NOT fabricate a "sent" claim.
// Delivery plugs in at the marked seam below with no shape change.
// PathSendVerificationCode is the canonical front-door OTP-send endpoint.
const PathSendVerificationCode = "/v1/iam/send-verification-code"
// verificationCodeLength is the OTP digit count (v1 getRandomCode(6)).
const verificationCodeLength = 6
// verificationCodeTTL bounds how long a sent code stays redeemable (v1's
// verificationCodeTimeout default, 10 minutes).
const verificationCodeTTL = 10 * time.Minute
// sendVerificationCode validates the request, mints + persists an OTP, and
// reports success. The request fields are read via fiber's FormValue — the
// escape hatch zip exposes for form bodies (multipart or urlencoded) — since the
// typed JSON Bind does not apply here. v1 also accepts countryCode/method/
// checkUser/captchaType; iam2 ignores them (the captcha/forget/MFA flows those
// drive are not ported), and CAPTCHA verification is likewise not enforced —
// iam2 models no captcha provider — so the code is issued once the destination
// and application validate.
func sendVerificationCode(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
fc := c.Fiber()
dest := fc.FormValue("dest")
typ := fc.FormValue("type")
applicationId := fc.FormValue("applicationId")
// v1 form.VerificationForm.CheckParameter(SendVerifyCode): type + dest
// required, applicationId must be an owner/name id.
if typ == "" {
return httpx.Err(c, "missing parameter: type")
}
if dest == "" {
return httpx.Err(c, "missing parameter: dest")
}
if !strings.Contains(applicationId, "/") {
return httpx.Err(c, "wrong parameter: applicationId")
}
owner, name := splitSub(applicationId)
app, err := store.GetApplicationByName(ctx, db, owner, name)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application: "+applicationId+" does not exist")
}
org, err := store.GetOrganizationByName(ctx, db, app.Organization)
if err != nil {
return httpx.Err(c, err.Error())
}
if org == nil {
return httpx.Err(c, "the organization does not exist")
}
// Validate the destination by type and, for email, resolve the target user
// (metadata on the record). Phone user-resolution + E.164 normalization need
// a phone library iam2 does not carry yet — the record still persists.
var user *schema.User
switch typ {
case "email":
if !isEmailValid(dest) {
return httpx.Err(c, "email is invalid")
}
if user, err = store.GetUserByEmail(ctx, db, org.Name, dest); err != nil {
return httpx.Err(c, err.Error())
}
case "phone":
// dest is required (checked above); accepted as-is.
default:
return httpx.Err(c, "unsupported verification type: "+typ)
}
code, err := generateCode(verificationCodeLength)
if err != nil {
return httpx.Err(c, "failed to generate verification code")
}
id, err := newOpaqueToken()
if err != nil {
return httpx.Err(c, "failed to generate verification record id")
}
rec := &schema.VerificationRecord{
Owner: org.Name,
Name: id,
CreatedTime: nowFunc().UTC().Format(time.RFC3339),
RemoteAddr: fc.IP(),
Type: typ,
Receiver: dest,
Code: code,
Provider: "demo",
Time: nowFunc().Unix(),
IsUsed: false,
}
if user != nil {
rec.User = user.Owner + "/" + user.Name
}
if err := store.AddVerificationRecord(ctx, db, rec); err != nil {
return httpx.Err(c, err.Error())
}
// --- DELIVERY SEAM ---------------------------------------------------
// v1 hands (org, user, dest, code) to hanzoai/notify here
// (object.SendVerificationCodeToEmail / …ToPhone). notify owns the
// per-tenant SendGrid/SMTP/Resend/Twilio provider + template. It is not
// wired into iam2 yet; when it is, the send call slots in exactly here and
// the persisted record above stays the source of truth for verification.
// ---------------------------------------------------------------------
return httpx.Ok(c, nil)
}
}
// generateCode returns an n-digit numeric OTP drawn from crypto/rand, uniformly
// (no modulo bias) and zero-padded to a fixed width.
func generateCode(n int) (string, error) {
max := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil)
k, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
return fmt.Sprintf("%0*d", n, k), nil
}
// CheckVerificationCode reports whether code matches the latest unused,
// unexpired verification record sent to receiver — the check side of the OTP
// surface, which the signup email/phone gate calls ahead of account creation at
// cutover. The compare is constant-time; an expired or absent record fails
// closed. It does NOT consume the record (the caller marks it used on the flow
// it gates).
func CheckVerificationCode(ctx context.Context, db orm.DB, receiver, code string) (bool, error) {
if receiver == "" || code == "" {
return false, nil
}
rec, err := store.GetLatestVerificationRecord(ctx, db, receiver)
if err != nil {
return false, err
}
if rec == nil {
return false, nil
}
if nowFunc().Unix()-rec.Time > int64(verificationCodeTTL/time.Second) {
return false, nil
}
return cred.ConstantTimeEqual(rec.Code, code), nil
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/store"
)
// multipartReq builds a real multipart/form-data POST — the wire format v1's
// SendVerificationCode requires (NOT JSON), so the test exercises the multipart
// parse path, not a urlencoded shortcut.
func multipartReq(path string, fields map[string]string) *http.Request {
var body bytes.Buffer
w := multipart.NewWriter(&body)
for k, v := range fields {
_ = w.WriteField(k, v)
}
_ = w.Close()
req := httptest.NewRequest("POST", path, &body)
req.Header.Set("Content-Type", w.FormDataContentType()) // multipart/form-data; boundary=…
req.Host = "hanzo.id"
return req
}
func sendCode(t *testing.T, app *zip.App, fields map[string]string) (int, map[string]any) {
t.Helper()
resp, raw := do(t, app, multipartReq(PathSendVerificationCode, fields))
return resp.StatusCode, decode(t, raw)
}
// The happy path parses the multipart form, persists a 6-digit unused code
// bound to the receiver, and reports ok — and that code then verifies through
// CheckVerificationCode while a wrong one fails closed.
func TestSendVerificationCode_PersistsAndVerifies(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret"})
seedOrg(t, db, "hanzo")
seedRichUser(t, db) // alice@hanzo.ai — exercises the user-resolution branch
status, env := sendCode(t, app, map[string]string{
"dest": "alice@hanzo.ai",
"type": "email",
"applicationId": "admin/conf",
"captchaType": "none",
})
if status != 200 || env["status"] != "ok" {
t.Fatalf("status=%d env=%v, want 200 ok", status, env)
}
ctx := context.Background()
rec, err := store.GetLatestVerificationRecord(ctx, db, "alice@hanzo.ai")
if err != nil || rec == nil {
t.Fatalf("verification record not persisted: %v (nil=%v)", err, rec == nil)
}
if rec.Type != "email" || rec.IsUsed {
t.Errorf("record type/used = %q/%v, want email/false", rec.Type, rec.IsUsed)
}
if len(rec.Code) != verificationCodeLength {
t.Errorf("code = %q, want %d digits", rec.Code, verificationCodeLength)
}
if rec.User != "hanzo/alice" {
t.Errorf("record.User = %q, want hanzo/alice (resolved from the dest)", rec.User)
}
// The validation surface: the persisted code verifies, a wrong one does not.
if ok, err := CheckVerificationCode(ctx, db, "alice@hanzo.ai", rec.Code); err != nil || !ok {
t.Fatalf("correct code must verify: ok=%v err=%v", ok, err)
}
if ok, _ := CheckVerificationCode(ctx, db, "alice@hanzo.ai", "000000"); ok {
t.Error("a wrong code must not verify")
}
if ok, _ := CheckVerificationCode(ctx, db, "nobody@hanzo.ai", rec.Code); ok {
t.Error("a code must not verify for a different receiver")
}
}
// A urlencoded body reaches the same handler (fiber's FormValue reads both) —
// the code path is not multipart-only.
func TestSendVerificationCode_UrlencodedAlsoWorks(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret"})
seedOrg(t, db, "hanzo")
resp, raw := do(t, app, formReq("POST", PathSendVerificationCode, url.Values{
"dest": {"someone@hanzo.ai"},
"type": {"email"},
"applicationId": {"admin/conf"},
}))
if env := decode(t, raw); resp.StatusCode != 200 || env["status"] != "ok" {
t.Fatalf("status=%d env=%v, want 200 ok", resp.StatusCode, env)
}
if rec, _ := store.GetLatestVerificationRecord(context.Background(), db, "someone@hanzo.ai"); rec == nil {
t.Error("urlencoded send did not persist a record")
}
}
// Every malformed request returns {status:"error"} on a 200 and persists nothing.
func TestSendVerificationCode_Errors(t *testing.T) {
base := func() map[string]string {
return map[string]string{"dest": "x@hanzo.ai", "type": "email", "applicationId": "admin/conf"}
}
cases := map[string]func(m map[string]string){
"missing type": func(m map[string]string) { delete(m, "type") },
"missing dest": func(m map[string]string) { delete(m, "dest") },
"applicationId without '/'": func(m map[string]string) { m["applicationId"] = "conf" },
"application not found": func(m map[string]string) { m["applicationId"] = "admin/ghost" },
"invalid email": func(m map[string]string) { m["dest"] = "not-an-email" },
"unsupported type": func(m map[string]string) { m["type"] = "carrier-pigeon" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret"})
seedOrg(t, db, "hanzo")
m := base()
mutate(m)
status, env := sendCode(t, app, m)
if status != 200 || env["status"] != "error" {
t.Fatalf("status=%d env=%v, want 200 error", status, env)
}
})
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"net/http"
"net/url"
"strings"
"testing"
)
// The full portal session path: a bare (type=login) sign-in sets the session
// cookie, and get-account resolves the caller FROM that cookie (no bearer). This
// is the path the hanzo.id portal + the gateway admin-guard use.
func TestSession_LoginCookieResolvesGetAccount(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db) // hanzo/alice, password "pw"
// 1) Bare portal login → sets the hanzo_session cookie.
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 status=%d body=%s", resp.StatusCode, body)
}
cookie := resp.Header.Get("Set-Cookie")
if !strings.HasPrefix(cookie, "hanzo_session=") {
t.Fatalf("login did not set the session cookie: %q", cookie)
}
for _, want := range []string{"HttpOnly", "secure", "SameSite=Lax", "path=/"} {
if !strings.Contains(cookie, want) {
t.Errorf("session cookie missing %s: %q", want, cookie)
}
}
// 2) get-account WITH the cookie (no bearer) → resolves alice, redacted.
req := formReqNoBody("GET", PathGetAccount)
req.Header.Set("Cookie", cookieKV(cookie))
resp2, body2 := do(t, app, req)
env := decode(t, body2)
if resp2.StatusCode != 200 || env["status"] != "ok" {
t.Fatalf("get-account via cookie status=%d body=%s", resp2.StatusCode, body2)
}
data, _ := env["data"].(map[string]any)
if data["owner"] != "hanzo" || env["name"] != "alice" {
t.Fatalf("cookie session resolved wrong principal: owner=%v name=%v", data["owner"], env["name"])
}
if v, ok := data["passwordHash"]; ok && v != "" {
t.Errorf("cookie get-account leaked passwordHash")
}
}
// A FORGED session cookie (attacker flips owner→admin) does not authenticate:
// the signature fails, get-account stays anonymous. The whole security point.
func TestSession_ForgedCookieRejected(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
// A hand-built cookie with a bogus payload + mac — no valid signature exists
// without the platform cert key.
req := formReqNoBody("GET", PathGetAccount)
req.Header.Set("Cookie", "hanzo_session=eyJvIjoiYWRtaW4ifQ.deadbeef")
resp, body := do(t, app, req)
if resp.StatusCode != 200 || decode(t, body)["status"] != "error" {
t.Fatalf("forged cookie must not authenticate: status=%d body=%s", resp.StatusCode, body)
}
}
// cookieKV extracts "name=value" from a Set-Cookie header for the request echo.
func cookieKV(setCookie string) string {
if i := strings.Index(setCookie, ";"); i >= 0 {
return setCookie[:i]
}
return setCookie
}
var _ = http.MethodGet
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"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"
)
// The code→session exchange: POST /v1/iam/signin. After the authorize/login flow
// redirects back with `?code&state`, the console posts them here (code+state ride
// the query — what the @hanzo/iam client sends — or a JSON body) and iam2 redeems
// the code for a durable SESSION, returning the account envelope like get-account.
//
// This is the session-establishment counterpart to the OAuth token endpoint: the
// token endpoint trades a code (with client auth + PKCE) for an access token; signin
// trades it for the portal's signed hanzo_session cookie. Possession of the 256-bit
// single-use code is the proof — no client secret crosses this call — so the code is
// BURNED on use: a replay establishes no second session.
// PathSignin is the canonical code→session endpoint.
const PathSignin = "/v1/iam/signin"
// signinForm is the JSON body a non-query caller may post; the canonical @hanzo/iam
// client sends code+state on the query string.
type signinForm struct {
Code string `json:"code"`
State string `json:"state"`
}
// signinHandler redeems an authorization code for a session and returns the account.
func signinHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
// Query first (the redirect-flow client posts code+state as query params),
// then a JSON body for programmatic callers.
code := c.Query("code")
if code == "" {
var f signinForm
_ = c.Bind(&f)
code = f.Code
}
if code == "" {
return httpx.Err(c, "code is required")
}
tok, err := store.GetTokenByCode(ctx, db, code)
if err != nil {
return httpx.Err(c, "server_error")
}
now := nowFunc()
// One opaque answer for unknown / already-redeemed / expired — no oracle, and
// the same replay+expiry guards RedeemCode enforces (minus client/PKCE, which
// this session exchange does not present).
if tok == nil || tok.CodeIsUsed || (tok.CodeExpireIn != 0 && now.Unix() > tok.CodeExpireIn) {
return httpx.Err(c, "the authorization code is invalid or expired")
}
owner, name := splitSub(tok.User)
if owner == "" || name == "" {
return httpx.Err(c, "the authorization code has no subject")
}
// Burn the code (single-use) BEFORE establishing the session, so a replay
// loses the race and mints nothing.
tok.CodeIsUsed = true
if err := store.SaveToken(ctx, db, tok); err != nil {
return httpx.Err(c, "server_error")
}
// Establish the durable session the portal + gateway admin-guard read via
// get-account. Its sid is registered for revocation (sessions.Set).
if err := sessions.Set(ctx, c.Fiber(), db, owner, name, tok.Application); err != nil {
return httpx.Err(c, err.Error())
}
env, status := accountEnvelopeFor(ctx, db, owner, name)
return c.JSON(status, env)
}
}
+272
View File
@@ -0,0 +1,272 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam/internal/schema"
)
// NewSignerFromCert picks the algorithm from the key type — RSA→RS256,
// EC-P256→ES256, ML-DSA→MLDSA65 — so a token can never be signed under a
// mismatched alg.
func TestNewSignerFromCert_DispatchesByKeyType(t *testing.T) {
cases := []struct {
name string
cert *schema.Cert
want string
}{
{"rsa", rsaCert(t, "cert-rsa"), "RS256"},
{"ec", ecCert(t, "cert-ec"), "ES256"},
{"mldsa", mldsaCert(t, "cert-pq"), "MLDSA65"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, err := NewSignerFromCert(tc.cert, testApp(), "https://hanzo.id")
if err != nil {
t.Fatalf("build signer: %v", err)
}
if s.Alg() != tc.want {
t.Fatalf("alg = %q, want %q", s.Alg(), tc.want)
}
})
}
}
// An ES256 token round-trips: signed under the EC key, verified under its public
// half, with the expected claims.
func TestSigner_ES256RoundTrip(t *testing.T) {
cert := ecCert(t, "cert-ec")
s, err := NewSignerFromCert(cert, testApp(), "https://hanzo.id")
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0)
tok, err := s.Sign(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", time.Hour, now)
if err != nil {
t.Fatal(err)
}
pub, _, _, err := certPublicKey(cert)
if err != nil {
t.Fatal(err)
}
var claims Claims
parsed, err := jwt.ParseWithClaims(tok, &claims, func(*jwt.Token) (any, error) { return pub, nil },
jwt.WithValidMethods([]string{"ES256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) }))
if err != nil || !parsed.Valid {
t.Fatalf("verify ES256: %v", err)
}
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
t.Fatalf("claims wrong: %+v", claims)
}
}
// The post-quantum path is real: an ML-DSA-65 token signed by the Signer
// verifies through the full package verify path (resolve kid → cert → public
// key → circl Verify).
func TestSigner_MLDSA65RoundTripThroughVerify(t *testing.T) {
db := openTestDB(t)
cert := mldsaCert(t, "cert-pq")
persistCert(t, db, cert)
s, err := NewSignerFromCert(cert, testApp(), "https://hanzo.id")
if err != nil {
t.Fatal(err)
}
if s.Alg() != algMLDSA65 {
t.Fatalf("alg = %q, want MLDSA65", s.Alg())
}
now := time.Unix(1_800_000_000, 0)
nowFuncSet(t, now.Add(time.Minute))
tok, err := s.SignID(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", "nonce-xyz", time.Hour, now)
if err != nil {
t.Fatal(err)
}
claims, err := verifyToken(context.Background(), db, tok)
if err != nil {
t.Fatalf("verify MLDSA65 token: %v", err)
}
if claims.Subject != "hanzo/alice" || claims.Nonce != "nonce-xyz" || claims.TokenType != "id-token" {
t.Fatalf("claims wrong: %+v", claims)
}
}
// SignID echoes the nonce and marks the token as an id-token (OIDC Core).
func TestSignID_EchoesNonce(t *testing.T) {
key := sharedKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://hanzo.id")
now := time.Unix(1_800_000_000, 0)
tok, err := s.SignID(testApp(), "hanzo/alice", "a@h.ai", "Alice", "openid", "n-123", time.Hour, now)
if err != nil {
t.Fatal(err)
}
var claims Claims
if _, err := jwt.ParseWithClaims(tok, &claims, func(*jwt.Token) (any, error) { return &key.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })); err != nil {
t.Fatal(err)
}
if claims.Nonce != "n-123" {
t.Fatalf("nonce = %q, want n-123", claims.Nonce)
}
if claims.TokenType != "id-token" {
t.Fatalf("tokenType = %q, want id-token", claims.TokenType)
}
}
// verifyToken refuses alg:none — a forged unsigned token can never select a
// trusting verification path.
func TestVerifyToken_RejectsAlgNone(t *testing.T) {
db := openTestDB(t)
persistCert(t, db, rsaCert(t, "cert-hanzo"))
header := b64url(t, `{"alg":"none","typ":"JWT","kid":"cert-hanzo"}`)
payload := b64url(t, `{"sub":"hanzo/attacker","iss":"https://hanzo.id"}`)
forged := header + "." + payload + "."
if _, err := verifyToken(context.Background(), db, forged); err == nil {
t.Fatal("alg:none token accepted")
}
}
// verifyToken fails closed on a kid that resolves to no signing cert.
func TestVerifyToken_RejectsUnknownKid(t *testing.T) {
db := openTestDB(t)
persistCert(t, db, rsaCert(t, "cert-hanzo"))
other := rsaCert(t, "cert-ghost") // never persisted
s, _ := NewSignerFromCert(other, testApp(), "https://hanzo.id")
now := time.Unix(1_800_000_000, 0)
nowFuncSet(t, now.Add(time.Minute))
tok, _ := s.Sign(testApp(), "hanzo/alice", "", "", "openid", time.Hour, now)
if _, err := verifyToken(context.Background(), db, tok); err == nil {
t.Fatal("token with an unknown kid was accepted")
}
}
// A tenant cannot shadow a platform signing key: a cert created under a
// non-platform owner with a colliding name (kid) never verifies a forged token,
// even when a real platform cert of the same name also exists.
func TestVerify_TenantCannotShadowSigningKey(t *testing.T) {
db := openTestDB(t)
base := time.Unix(1_800_000_000, 0)
nowFuncSet(t, base.Add(time.Minute))
// Legit platform signing cert (admin owner, shared key), kid = cert-hanzo.
persistCert(t, db, rsaCert(t, "cert-hanzo"))
// Attacker creates a cert with the SAME name under their own org + their key.
attackerKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
ac := &schema.Cert{CryptoAlgorithm: "RS256", PrivateKey: rsaKeyToPEM(t, attackerKey)}
ac.Owner, ac.Name = "attacker-org", "cert-hanzo"
persistCert(t, db, ac)
// Attacker forges a token signed with THEIR key, kid=cert-hanzo, claiming admin.
forger := NewRSASigner(attackerKey, "cert-hanzo", "https://hanzo.id")
forged, err := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "openid", time.Hour, base)
if err != nil {
t.Fatal(err)
}
if _, err := verifyToken(context.Background(), db, forged); err == nil {
t.Fatal("FORGERY ACCEPTED: a tenant cert shadowed a platform signing key")
}
}
// A cert under a non-platform owner is never a trusted signing key, even when it
// is the only cert with that name.
func TestVerify_NonPlatformCertNeverTrusted(t *testing.T) {
db := openTestDB(t)
base := time.Unix(1_800_000_000, 0)
nowFuncSet(t, base.Add(time.Minute))
attackerKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
ac := &schema.Cert{CryptoAlgorithm: "RS256", PrivateKey: rsaKeyToPEM(t, attackerKey)}
ac.Owner, ac.Name = "attacker-org", "cert-evil"
persistCert(t, db, ac)
forger := NewRSASigner(attackerKey, "cert-evil", "https://hanzo.id")
forged, _ := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "openid", time.Hour, base)
if _, err := verifyToken(context.Background(), db, forged); err == nil {
t.Fatal("a non-platform cert must never verify a token")
}
}
// --- cert builders + helpers ---
func rsaCert(t *testing.T, name string) *schema.Cert {
t.Helper()
c := &schema.Cert{CryptoAlgorithm: "RS256", PrivateKey: rsaKeyToPEM(t, sharedKey(t))}
c.Owner, c.Name = "admin", name
return c
}
func ecCert(t *testing.T, name string) *schema.Cert {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
pemText := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}))
c := &schema.Cert{CryptoAlgorithm: "ES256", PrivateKey: pemText}
c.Owner, c.Name = "admin", name
return c
}
func mldsaCert(t *testing.T, name string) *schema.Cert {
t.Helper()
_, sk, err := mldsa65.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
c := &schema.Cert{CryptoAlgorithm: "MLDSA65", PrivateKey: base64.StdEncoding.EncodeToString(sk.Bytes())}
c.Owner, c.Name = "admin", name
return c
}
func persistCert(t *testing.T, db orm.DB, cert *schema.Cert) {
t.Helper()
c := orm.New[schema.Cert](db)
model := c.Model
*c = *cert
c.Model = model
c.SetId(cert.Owner + "/" + cert.Name)
if err := c.CreateCtx(context.Background()); err != nil {
t.Fatalf("persist cert: %v", err)
}
}
func b64url(t *testing.T, s string) string {
t.Helper()
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
// nowFuncSet pins the package clock for the duration of a test.
func nowFuncSet(t *testing.T, at time.Time) {
t.Helper()
prev := nowFunc
nowFunc = func() time.Time { return at }
t.Cleanup(func() { nowFunc = prev })
}
+260
View File
@@ -0,0 +1,260 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/mail"
"regexp"
"strings"
"unicode"
"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"
"github.com/hanzoai/iam/internal/users"
)
// The native front-door signup: POST /v1/iam/signup. The @hanzo/iam SDK + the
// hanzo.id portal post the sign-up form here to create a new account. It mirrors
// the v1 Casdoor Signup contract (controllers/account.go): the casibase
// {status,msg,data} envelope, resolve-app → enforce-policy → create-user, with
// the password hashed (never stored plaintext) and the created row returned
// REDACTED.
//
// Password sign-up only in this increment (the enabled-method the portal drives);
// the email/phone-OTP-gated sign-up variant plugs its verification check
// (CheckVerificationCode) in ahead of the create at cutover.
// PathSignup is the canonical front-door signup endpoint.
const PathSignup = "/v1/iam/signup"
// signupForm is the sign-up request the SDK/portal posts — the signup-relevant
// subset of v1's form.AuthForm.
type signupForm struct {
Application string `json:"application"`
ClientId string `json:"clientId"`
Organization string `json:"organization"`
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"` // display name
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
CountryCode string `json:"countryCode"`
Affiliation string `json:"affiliation"`
}
// signupHandler validates the front-door signup policy and creates the account.
func signupHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
var f signupForm
if err := c.Bind(&f); err != nil {
return httpx.Err(c, "invalid request body")
}
ctx := c.Context()
f.Organization = strings.TrimSpace(f.Organization)
f.Username = strings.TrimSpace(f.Username)
if f.Organization == "" || f.Username == "" || f.Password == "" {
return httpx.Err(c, "organization, username and password are required")
}
// Resolve the application (by clientId when present, else by name under the
// admin owner — the iam2 storage convention), then enforce its policy.
app, err := resolveSignupApp(ctx, db, f)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application: "+f.Application+" does not exist")
}
if !app.EnableSignUp {
return httpx.Err(c, "the application does not allow to sign up new account")
}
if !app.EnablePassword {
return httpx.Err(c, "the application does not allow password sign-up")
}
// Tenant isolation: the requested org must be the app's own org, a shared
// app, or an app that lets users choose their org — the same gate login
// enforces, so a signup cannot land a user in an arbitrary tenant.
if f.Organization != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return httpx.Err(c, "the user is not permitted to sign up to this application")
}
org, err := store.GetOrganizationByName(ctx, db, f.Organization)
if err != nil {
return httpx.Err(c, err.Error())
}
if org == nil {
// v1 auto-mints the founder's own org (TenantOrgForSignup /
// CreatePersonalOrganization) only for a platform tenant org; that path
// needs an org-create helper + the Org.Parent tenant-parent model, neither
// of which iam2 has yet, so signup requires the org to exist. See report.
return httpx.Err(c, "the organization: "+f.Organization+" does not exist")
}
// Username policy (v1 object/check.go CheckUserSignup).
if msg := usernamePolicyError(f.Username); msg != "" {
return httpx.Err(c, msg)
}
// Uniqueness within the org — one opaque check per identifier.
if taken, err := userExists(ctx, db, f.Organization, f.Username); err != nil {
return httpx.Err(c, err.Error())
} else if taken {
return httpx.Err(c, "username already exists")
}
email := strings.ToLower(strings.TrimSpace(f.Email))
if email != "" {
if !isEmailValid(email) {
return httpx.Err(c, "email is invalid")
}
if existing, err := store.GetUserByEmail(ctx, db, f.Organization, email); err != nil {
return httpx.Err(c, err.Error())
} else if existing != nil {
return httpx.Err(c, "email already exists")
}
}
// Password policy (v1 org.PasswordOptions complexity).
if msg := passwordPolicyError(org.PasswordOptions, f.Password); msg != "" {
return httpx.Err(c, msg)
}
// Create through the ONE canonical user path: bcrypt-hash the password once,
// persist, return the REDACTED row (no plaintext, no digest ever stored or
// returned). PasswordType is stamped "bcrypt" — exactly what internal/cred
// verifies for a new iam2 row.
created, err := users.New(db).Create(ctx, &users.CreateInput{
User: schema.User{
Owner: f.Organization,
Name: f.Username,
Type: "normal-user",
DisplayName: displayName(f),
FirstName: f.FirstName,
LastName: f.LastName,
Email: email,
EmailVerified: false,
Phone: f.Phone,
CountryCode: f.CountryCode,
Affiliation: f.Affiliation,
Avatar: org.DefaultAvatar,
SignupApplication: app.Name,
RegisterType: "Application Signup",
RegisterSource: f.Organization + "/" + app.Name,
},
Password: f.Password,
})
if err != nil {
return httpx.Err(c, err.Error())
}
return httpx.Ok(c, created)
}
}
// resolveSignupApp resolves the signup's OAuth app: by clientId when present,
// else by (admin, application name) — mirroring resolveLoginApp.
func resolveSignupApp(ctx context.Context, db orm.DB, f signupForm) (*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
}
// userExists reports whether a user (org, name) already exists.
func userExists(ctx context.Context, db orm.DB, org, name string) (bool, error) {
u, err := store.GetUserByName(ctx, db, org, name)
return u != nil, err
}
// displayName is the user's display name: the supplied name, a "First Last"
// composite, or the username as a last resort — v1's precedence.
func displayName(f signupForm) string {
if f.FirstName != "" || f.LastName != "" {
if n := strings.TrimSpace(f.FirstName + " " + f.LastName); n != "" {
return n
}
}
if f.Name != "" {
return f.Name
}
return f.Username
}
// usernamePolicyError returns the first username rule a candidate violates, or
// "" when it passes — the v1 CheckUserSignup rules for the Username item.
func usernamePolicyError(username string) string {
if len(username) <= 1 {
return "username must have at least 2 characters"
}
if unicode.IsDigit(rune(username[0])) {
return "username cannot start with a digit"
}
if isEmailValid(username) {
return "username cannot be an email address"
}
if strings.IndexFunc(username, unicode.IsSpace) >= 0 {
return "username cannot contain white spaces"
}
return ""
}
// Password-complexity option matchers — the v1 object/check_password_complexity.go
// option set, driven by the organization's PasswordOptions.
var (
pwReLower = regexp.MustCompile(`[a-z]`)
pwReUpper = regexp.MustCompile(`[A-Z]`)
pwReDigit = regexp.MustCompile(`\d`)
pwReSpecial = regexp.MustCompile("[!-/:-@[-`{-~]")
)
// passwordPolicyError returns the first complexity rule the password violates
// under the organization's options, or "" when it passes. With no options set,
// only the non-empty check applies (v1 parity).
func passwordPolicyError(options []string, password string) string {
if password == "" {
return "password cannot be empty"
}
for _, opt := range options {
switch opt {
case "AtLeast6":
if len(password) < 6 {
return "the password must have at least 6 characters"
}
case "AtLeast8":
if len(password) < 8 {
return "the password must have at least 8 characters"
}
case "Aa123":
if !pwReLower.MatchString(password) || !pwReUpper.MatchString(password) || !pwReDigit.MatchString(password) {
return "the password must contain at least one uppercase letter, one lowercase letter and one digit"
}
case "SpecialChar":
if !pwReSpecial.MatchString(password) {
return "the password must contain at least one special character"
}
case "NoRepeat":
for i := 0; i+1 < len(password); i++ {
if password[i] == password[i+1] {
return "the password must not contain any repeated characters"
}
}
}
}
return ""
}
// isEmailValid reports whether s parses as an email address — v1's
// util.IsEmailValid (net/mail.ParseAddress), the single email check shared by
// signup and send-verification-code.
func isEmailValid(s string) bool {
_, err := mail.ParseAddress(s)
return err == nil
}
+181
View File
@@ -0,0 +1,181 @@
// 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"
"github.com/hanzoai/iam/internal/store"
"github.com/hanzoai/iam/internal/users"
)
// seedOrg creates an organization row (owner "admin", v1 convention) with the
// given name and optional password-complexity options.
func seedOrg(t *testing.T, db orm.DB, name string, passwordOptions ...string) {
t.Helper()
o := orm.New[schema.Organization](db)
o.Owner = "admin"
o.Name = name
o.PasswordOptions = passwordOptions
o.SetId("admin/" + name)
if err := o.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed org: %v", err)
}
}
// signupReq drives POST /v1/iam/signup and returns the status + decoded envelope.
func signupReq(t *testing.T, app *zip.App, body map[string]string) (int, map[string]any) {
t.Helper()
resp, raw := do(t, app, jsonReq("POST", PathSignup, body))
return resp.StatusCode, decode(t, raw)
}
// The happy path creates the account, returns it REDACTED (owner/name present,
// no secret), and stores the password as an argon2id hash — never plaintext.
func TestSignup_HappyPathCreatesRedactedUser(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}, signup: true})
seedOrg(t, db, "hanzo")
const pw = "correct horse battery staple"
status, env := signupReq(t, app, map[string]string{
"application": "conf",
"organization": "hanzo",
"username": "newbie",
"password": pw,
"name": "New Bie",
"email": "newbie@hanzo.ai",
})
if status != 200 || env["status"] != "ok" {
t.Fatalf("status=%d env=%v, want 200 ok", status, env)
}
data, ok := env["data"].(map[string]any)
if !ok {
t.Fatalf("data is not an object: %v", env["data"])
}
if data["owner"] != "hanzo" || data["name"] != "newbie" {
t.Errorf("data owner/name = %v/%v, want hanzo/newbie", data["owner"], data["name"])
}
// The response must never carry the digest or any secret.
for _, secret := range []string{"passwordHash", "passwordSalt", "accessSecret", "accessSecretHash"} {
if v, present := data[secret]; present && v != "" {
t.Errorf("signup response leaked %q = %v", secret, v)
}
}
// The STORED row holds an argon2id hash (PasswordType=argon2id) that verifies the
// password — and is NOT the plaintext. This is the no-plaintext contract.
stored, err := store.GetUserByName(context.Background(), db, "hanzo", "newbie")
if err != nil || stored == nil {
t.Fatalf("stored user lookup: %v (nil=%v)", err, stored == nil)
}
if stored.PasswordHash == "" || stored.PasswordHash == pw {
t.Fatalf("password stored as plaintext or empty: %q", stored.PasswordHash)
}
if stored.PasswordType != "argon2id" {
t.Errorf("PasswordType = %q, want argon2id", stored.PasswordType)
}
if !users.VerifyPassword(stored, pw, "") {
t.Error("stored hash does not verify the signup password")
}
if users.VerifyPassword(stored, "wrong", "") {
t.Error("a wrong password verified — hashing is broken")
}
}
// Every failure mode returns {status:"error"} on a 200 (the casibase envelope)
// and creates no user.
func TestSignup_Errors(t *testing.T) {
newbieBody := func() map[string]string {
return map[string]string{
"application": "conf", "organization": "hanzo",
"username": "newbie", "password": "correct horse battery staple",
}
}
t.Run("missing required fields", func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", signup: true})
seedOrg(t, db, "hanzo")
status, env := signupReq(t, app, map[string]string{"application": "conf", "organization": "hanzo"})
if status != 200 || env["status"] != "error" {
t.Fatalf("status=%d env=%v, want 200 error", status, env)
}
})
t.Run("application does not exist", func(t *testing.T) {
app, db := newServer(t)
seedOrg(t, db, "hanzo")
body := newbieBody()
body["application"] = "ghost"
_, env := signupReq(t, app, body)
if env["status"] != "error" {
t.Fatalf("want error, got %v", env)
}
})
t.Run("signup disabled", func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", signup: false}) // EnableSignUp=false
seedOrg(t, db, "hanzo")
_, env := signupReq(t, app, newbieBody())
if env["status"] != "error" {
t.Fatalf("signup must be refused when disabled, got %v", env)
}
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u != nil {
t.Error("a user was created despite signup being disabled")
}
})
t.Run("organization does not exist", func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", signup: true}) // no org seeded
_, env := signupReq(t, app, newbieBody())
if env["status"] != "error" {
t.Fatalf("want error for missing org, got %v", env)
}
})
t.Run("username already taken", func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", signup: true})
seedOrg(t, db, "hanzo")
seedUser(t, db, "newbie", "newbie@hanzo.ai", "pw") // already exists
_, env := signupReq(t, app, newbieBody())
if env["status"] != "error" || env["msg"] != "username already exists" {
t.Fatalf("want 'username already exists', got %v", env)
}
})
t.Run("username policy violated", func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", signup: true})
seedOrg(t, db, "hanzo")
body := newbieBody()
body["username"] = "1bad" // starts with a digit
_, env := signupReq(t, app, body)
if env["status"] != "error" {
t.Fatalf("digit-leading username must be refused, got %v", env)
}
})
t.Run("password fails org complexity", func(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", signup: true})
seedOrg(t, db, "hanzo", "AtLeast8") // require 8+ chars
body := newbieBody()
body["password"] = "short"
_, env := signupReq(t, app, body)
if env["status"] != "error" {
t.Fatalf("short password must be refused under AtLeast8, got %v", env)
}
if u, _ := store.GetUserByName(context.Background(), db, "hanzo", "newbie"); u != nil {
t.Error("a user was created despite the password failing policy")
}
})
}
+544
View File
@@ -0,0 +1,544 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"net/url"
"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"
"github.com/hanzoai/iam/internal/users"
)
// The token endpoint: POST /v1/iam/oauth/token. It dispatches the three grant
// types iam2 issues — authorization_code (PKCE-verified, single-use code →
// access JWT + id_token when openid + rotating refresh), refresh_token
// (rotation with reuse detection, refresh.go), and client_credentials
// (machine-to-machine, no user, no refresh). Every response carries no-store
// caching; every error follows the RFC 6749 §5.2 taxonomy (invalid_client → 401
// with WWW-Authenticate, everything else → 400). Implicit is permanently absent.
// nowFunc is indirected so tests can pin time. Production uses time.Now.
var nowFunc = time.Now
// tokenResponse is the RFC 6749 §5.1 / OIDC success body.
type tokenResponse struct {
AccessToken string `json:"access_token"`
IdToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope,omitempty"`
}
// routeToken registers the ONE token endpoint: POST /v1/iam/oauth/token (the
// RFC 6749 / discovery `token_endpoint`). No legacy `access_token` alias — every
// client posts to the standard path; the stack is fixed to it, not shimmed.
func routeToken(r zip.Router, db orm.DB) {
r.Post(PathToken, tokenHandler(db))
}
// param reads an OAuth parameter from the query first, then the form body
// (application/x-www-form-urlencoded — what NextAuth and most clients send).
func param(c *zip.Ctx, key string) string {
if v := c.Query(key); v != "" {
return v
}
return c.Fiber().FormValue(key)
}
// tokenError writes the RFC 6749 §5.2 error body with the right status.
func tokenError(c *zip.Ctx, status int, code, desc string) error {
body := map[string]string{"error": code}
if desc != "" {
body["error_description"] = desc
}
return c.JSON(status, body)
}
// tokenErrorClient answers a client-authentication failure: 401 + the
// WWW-Authenticate challenge, per RFC 6749 §5.2.
func tokenErrorClient(c *zip.Ctx, desc string) error {
c.SetHeader("WWW-Authenticate", `Basic realm="OAuth2"`)
return tokenError(c, 401, "invalid_client", desc)
}
// setTokenCacheHeaders forbids caching of any token response (RFC 6749 §5.1).
func setTokenCacheHeaders(c *zip.Ctx) {
c.SetHeader("Cache-Control", "no-store")
c.SetHeader("Pragma", "no-cache")
}
func tokenHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
setTokenCacheHeaders(c)
switch param(c, "grant_type") {
case "authorization_code":
return authorizationCodeGrant(c, db)
case "refresh_token":
return refreshTokenGrant(c, db)
case "client_credentials":
return clientCredentialsGrant(c, db)
case "password":
return passwordGrant(c, db)
case grantTypeTokenExchange:
return tokenExchangeGrant(c, db)
case deviceGrant:
return deviceCodeGrant(c, db)
case "":
return tokenError(c, 400, "invalid_request", "grant_type is required")
default:
return tokenError(c, 400, "unsupported_grant_type", "unsupported grant_type")
}
}
}
// authorizationCodeGrant redeems a single-use, PKCE-bound authorization code for
// an access token (+ id_token when openid + rotating refresh).
func authorizationCodeGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
code := param(c, "code")
if code == "" {
return tokenError(c, 400, "invalid_request", "code is required")
}
clientID, clientSecret := clientAuth(c)
verifier := param(c, "code_verifier")
redirectURI := param(c, "redirect_uri")
tok, err := store.GetTokenByCode(ctx, db, code)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
app, err := resolveTokenApp(ctx, db, tok)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
// Unknown code, an app that vanished, or a row that is a DEVICE authorization
// rather than an authorization code — one opaque answer, no oracle. The kind
// check is load-bearing: a device row carries no PKCE challenge and no
// redirect_uri, so redeeming one here would mint on a grant no human approved.
if app == nil || isDevice(tok) {
return tokenError(c, 400, "invalid_grant", "invalid authorization code")
}
// The presented client must be the code's client.
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
return tokenError(c, 400, "invalid_grant", "client mismatch")
}
// Confidential client: verify the secret (constant-time). A public client
// (PKCE, no stored secret) may present none.
if app.ClientSecret != "" {
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
}
// redirect_uri binding (RFC 6749 §4.1.3): if the code carries one, the token
// request must present the same one.
if tok.RedirectUri != "" {
if redirectURI == "" || subtle.ConstantTimeCompare([]byte(redirectURI), []byte(tok.RedirectUri)) != 1 {
return tokenError(c, 400, "invalid_grant", "redirect_uri mismatch")
}
}
// Core guard: replay / expiry / client / PKCE.
if err := RedeemCode(tok, app.Name, verifier, now); err != nil {
return redeemErrToResponse(c, err)
}
// A public client MUST have used PKCE — never let a no-secret grant through
// without a challenge (downgrade / code injection defense).
if app.ClientSecret == "" && tok.CodeChallenge == "" {
return tokenError(c, 400, "invalid_grant", "PKCE is required for public clients")
}
// One-shot: burn the code, then mint the grant's tokens onto the same row.
tok.CodeIsUsed = true
resp, err := issueTokens(ctx, db, c, app, tok, newFamilyID(tok), now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if err := store.SaveToken(ctx, db, tok); err != nil {
return tokenError(c, 500, "server_error", "")
}
return c.JSON(200, resp)
}
// clientCredentialsGrant issues a machine-to-machine access token. The subject
// is the application itself; there is no end user, no id_token, and no refresh
// token (RFC 6749 §4.4 + OIDC — an id_token requires an authenticated user).
func clientCredentialsGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
clientID, clientSecret := clientAuth(c)
if clientID == "" {
return tokenErrorClient(c, "client authentication required")
}
app, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
// A public client (no secret) can never use client_credentials.
if app == nil || app.ClientSecret == "" ||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
if isInternalApp(app) {
return tokenErrorClient(c, "client is not permitted on this endpoint")
}
scope := param(c, "scope")
ttl := appTTL(app)
signer, err := signerFor(ctx, db, app, tokenIssuer(c))
if err != nil {
return tokenError(c, 500, "server_error", "")
}
sub := app.GetId() // <appOwner>/<appName>, per v1
access, err := signer.Sign(app, sub, "", app.Name, scope, ttl, now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
row := &schema.Token{
Owner: app.Owner,
Application: app.Name,
Organization: app.Organization,
User: sub,
Scope: scope,
TokenType: "Bearer",
ExpiresIn: int(ttl.Seconds()),
AccessTokenHash: hashToken(access),
}
row.Name = "cc-" + hashToken(access)[:32]
if err := store.PersistToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
return c.JSON(200, tokenResponse{
AccessToken: access,
TokenType: "Bearer",
ExpiresIn: int(ttl.Seconds()),
Scope: scope,
})
}
// passwordGrant issues tokens for a Resource Owner Password Credentials request
// (RFC 6749 §4.3) — the durable first-party console session (session.ts posts
// grant_type=password with the confidential client + username/password). It is a
// TRUSTED flow: confidential clients only (a public client + password grant is a
// phishing footgun), the app must have password login enabled, and the password
// is verified through the SAME algorithm-aware, per-row path the login form uses
// (argon2id for every live v1 row, bcrypt for new rows). One generic failure for
// unknown-user and bad-password — no user-enumeration oracle.
func passwordGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
clientID, clientSecret := clientAuth(c)
if clientID == "" {
return tokenErrorClient(c, "client authentication required")
}
app, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if app == nil || app.ClientSecret == "" ||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
if isInternalApp(app) {
return tokenErrorClient(c, "client is not permitted on this endpoint")
}
if !app.IsPasswordEnabled() {
return tokenError(c, 400, "unauthorized_client", "password grant is not enabled for this client")
}
username, password := param(c, "username"), param(c, "password")
if username == "" || password == "" {
return tokenError(c, 400, "invalid_request", "username and password are required")
}
// Org scope: an explicit `organization` wins; otherwise the client's own org
// (a same-org first-party login — the console's default).
org := param(c, "organization")
if org == "" {
org = app.Organization
}
user, err := resolveLoginUser(ctx, db, org, username)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
orgPasswordType := loginOrgPasswordType(ctx, db, org)
if user == nil || !users.VerifyPassword(user, password, orgPasswordType) {
return tokenError(c, 400, "invalid_grant", "the username or password is incorrect")
}
if user.IsForbidden || user.IsDeleted {
return tokenError(c, 400, "invalid_grant", "the user is forbidden")
}
// Build a fresh grant row (owner/name upfront so newFamilyID has a stable id),
// then mint through the ONE shared token path (access + id_token on openid +
// rotating refresh) so the password grant's token shape never drifts.
name, err := newOpaqueToken()
if err != nil {
return tokenError(c, 500, "server_error", "")
}
row := &schema.Token{
Owner: user.Owner,
Name: "pwd-" + name[:32],
Application: app.Name,
Organization: user.Owner,
User: user.Owner + "/" + user.Name,
Scope: param(c, "scope"),
}
resp, err := issueTokens(ctx, db, c, app, row, newFamilyID(row), now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if err := store.PersistToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
return c.JSON(200, resp)
}
// issueTokens mints the grant's tokens onto row: a signed access JWT, an
// id_token when the openid scope is present (echoing the stored nonce), and a
// rotating opaque refresh token joined to `family`. row already carries the
// grant identity (Owner/Application/User/Scope/Nonce). It is the single path
// both the code grant and refresh rotation mint through, so the token shape can
// never drift between them.
func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Application, row *schema.Token, family string, now time.Time) (tokenResponse, error) {
ttl := appTTL(app)
signer, err := signerFor(ctx, db, app, tokenIssuer(c))
if err != nil {
return tokenResponse{}, err
}
email, name := userProfile(ctx, db, row.User)
access, err := signer.Sign(app, row.User, email, name, row.Scope, ttl, now)
if err != nil {
return tokenResponse{}, err
}
refresh, err := newOpaqueToken()
if err != nil {
return tokenResponse{}, err
}
// Persist only the SHA-256 hashes, never the reusable plaintext tokens: a
// database dump then exposes no usable bearer or refresh credential. Lookups
// (userinfo, refresh) go through the hash siblings.
row.AccessToken = ""
row.AccessTokenHash = hashToken(access)
row.RefreshToken = ""
row.RefreshTokenHash = hashToken(refresh)
row.RefreshFamily = family
row.RefreshConsumed = false
row.RefreshExpireIn = now.Add(refreshTTL(app)).Unix()
row.ExpiresIn = int(ttl.Seconds())
row.TokenType = "Bearer"
resp := tokenResponse{
AccessToken: access,
RefreshToken: refresh,
TokenType: "Bearer",
ExpiresIn: int(ttl.Seconds()),
Scope: row.Scope,
}
if hasScope(row.Scope, "openid") {
idt, err := signer.SignID(app, row.User, email, name, row.Scope, row.Nonce, ttl, now)
if err != nil {
return tokenResponse{}, err
}
resp.IdToken = idt
}
return resp, nil
}
// clientAuth extracts client credentials, preferring client_secret_post (body /
// query) and falling back to client_secret_basic (Authorization: Basic).
func clientAuth(c *zip.Ctx) (id, secret string) {
id, secret = param(c, "client_id"), param(c, "client_secret")
if id != "" {
return id, secret
}
if bid, bsecret, ok := parseBasicAuth(c.Header("Authorization")); ok {
return bid, bsecret
}
return id, secret
}
// parseBasicAuth decodes an HTTP Basic client-authentication header. Per RFC
// 6749 §2.3.1 the id and secret are form-urlencoded before base64.
func parseBasicAuth(header string) (id, secret string, ok bool) {
const p = "Basic "
if len(header) <= len(p) || !strings.EqualFold(header[:len(p)], p) {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(p):]))
if err != nil {
return "", "", false
}
idPart, secretPart, found := strings.Cut(string(raw), ":")
if !found {
return "", "", false
}
if u, err := url.QueryUnescape(idPart); err == nil {
idPart = u
}
if u, err := url.QueryUnescape(secretPart); err == nil {
secretPart = u
}
return idPart, secretPart, true
}
// resolveTokenApp loads the application a token row belongs to. Returns (nil,nil)
// for an unknown code so the handler answers invalid_grant without leaking which
// of code/app was missing.
func resolveTokenApp(ctx context.Context, db orm.DB, tok *schema.Token) (*schema.Application, error) {
if tok == nil {
return nil, nil
}
return store.GetApplicationByName(ctx, db, tok.Owner, tok.Application)
}
// appTTL is the access-token lifetime for an app (ExpireInHours, default 1h).
func appTTL(app *schema.Application) time.Duration {
if app.ExpireInHours > 0 {
return time.Duration(app.ExpireInHours * float64(time.Hour))
}
return time.Hour
}
// refreshTTL is the refresh-token lifetime (RefreshExpireInHours); when unset it
// clamps to the access-token lifetime, matching v1.
func refreshTTL(app *schema.Application) time.Duration {
if app.RefreshExpireInHours > 0 {
return time.Duration(app.RefreshExpireInHours * float64(time.Hour))
}
return appTTL(app)
}
// signerFor loads the application's signing cert from the trusted platform
// signing-cert owners and builds a Signer with the given canonical issuer. Using
// the same trusted resolution as the JWKS and verification keeps the three
// consistent: a token is signed by a key iam2 will also publish and verify.
func signerFor(ctx context.Context, db orm.DB, app *schema.Application, issuer string) (*Signer, error) {
cert, err := store.GetSigningCert(ctx, db, app.Cert)
if err != nil {
return nil, err
}
if cert == nil {
return nil, errors.New("token: application has no trusted signing cert")
}
return NewSignerFromCert(cert, app, issuer)
}
// signAccessToken signs a bare access token for a token row under the given
// issuer — the direct sign path the end-to-end test drives.
func signAccessToken(ctx context.Context, db orm.DB, app *schema.Application, tok *schema.Token, issuer string, ttl time.Duration, now time.Time) (string, error) {
signer, err := signerFor(ctx, db, app, issuer)
if err != nil {
return "", err
}
return signer.Sign(app, tok.User, "", "", tok.Scope, ttl, now)
}
// tokenIssuer is the canonical OIDC issuer for this request — the value discovery
// advertises and every token carries as `iss`. A deployment PINS it with the
// IAM_ISSUER env (e.g. `https://hanzo.id`): the embedded KMS + every resource
// server validate `iss` against a fixed expected value, so a hanzo deployment
// serving both `hanzo.id` and `iam.hanzo.ai` must emit ONE stable issuer, not a
// host-derived one. Pinning also closes the header-influenced-iss vector (a
// request's X-Forwarded-Host can no longer steer `iss`). Unset → host-relative
// (dev / multi-tenant), so discovery stays split-origin-safe when no pin applies.
func tokenIssuer(c *zip.Ctx) string {
if iss := strings.TrimSpace(os.Getenv("IAM_ISSUER")); iss != "" {
return strings.TrimRight(iss, "/")
}
if h := httpx.EffectiveHost(c); h != "" {
return "https://" + h
}
return "https://hanzo.id"
}
// userProfile loads a user's email and display name for the token claims.
func userProfile(ctx context.Context, db orm.DB, userID string) (email, name string) {
owner, uname := splitSub(userID)
if owner == "" || uname == "" {
return "", ""
}
u, err := store.GetUserByName(ctx, db, owner, uname)
if err != nil || u == nil {
return "", ""
}
name = u.DisplayName
if name == "" {
name = u.Name
}
return u.Email, name
}
// splitSub splits a subject "owner/name" into its two parts.
func splitSub(sub string) (owner, name string) {
owner, name, _ = strings.Cut(sub, "/")
return owner, name
}
// hashToken is the SHA-256 hex digest used to index a stored token by a
// presented bearer/refresh string without keeping the plaintext on the lookup
// path.
func hashToken(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// hasScope reports whether a space-delimited scope string contains want.
func hasScope(scope, want string) bool {
for _, s := range strings.Fields(scope) {
if s == want {
return true
}
}
return false
}
// newFamilyID derives a stable refresh-family id for a fresh grant from the
// code row's identity — the anchor every rotation of this grant shares.
func newFamilyID(tok *schema.Token) string {
return tok.Owner + "/" + tok.Name
}
// isInternalApp reports whether an application is an internal service identity
// (<org>-iam), which may never obtain a token on the public token endpoint.
func isInternalApp(app *schema.Application) bool {
return strings.HasSuffix(app.Name, "-iam")
}
// redeemErrToResponse maps a RedeemCode error to the RFC 6749 error body.
func redeemErrToResponse(c *zip.Ctx, err error) error {
switch err {
case ErrCodeUsed:
return tokenError(c, 400, "invalid_grant", "authorization code already used")
case ErrCodeExpired:
return tokenError(c, 400, "invalid_grant", "authorization code expired")
case ErrClientMismatch:
return tokenError(c, 400, "invalid_grant", "client mismatch")
case ErrPKCEMismatch, ErrPKCEMissing, ErrPKCEPlainRejected:
return tokenError(c, 400, "invalid_grant", "PKCE verification failed")
case ErrCodeUnknown:
return tokenError(c, 400, "invalid_grant", "invalid authorization code")
default:
return tokenError(c, 400, "invalid_grant", "authorization code rejected")
}
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rsa"
"os"
"path/filepath"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// openTestDB opens a fresh SQLite store; the schema init registers the kinds.
func openTestDB(t *testing.T) orm.DB {
t.Helper()
_ = schema.Kinds() // force the schema package init() (kind registration)
dir := t.TempDir()
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(dir, "iam2test.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// seedAppWithCert creates a confidential app (hanzo-console) + its RSA cert.
func seedAppWithCert(t *testing.T, db orm.DB, key *rsa.PrivateKey) *schema.Application {
t.Helper()
ctx := context.Background()
// Cert row with a PEM RSA private key.
c := orm.New[schema.Cert](db)
c.Owner = "admin"
c.Name = "cert-hanzo"
c.CryptoAlgorithm = "RS256"
c.PrivateKey = rsaKeyToPEM(t, key)
c.SetId("admin/cert-hanzo")
if err := c.CreateCtx(ctx); err != nil {
t.Fatalf("seed cert: %v", err)
}
a := orm.New[schema.Application](db)
a.Owner = "admin"
a.Name = "hanzo-console"
a.ClientId = "hanzo-console"
a.ClientSecret = "" // public client (PKCE) for this test
a.Organization = "hanzo"
a.Cert = "cert-hanzo"
a.EnablePassword = true
a.ExpireInHours = 1
a.SetId("admin/hanzo-console")
if err := a.CreateCtx(ctx); err != nil {
t.Fatalf("seed app: %v", err)
}
return a
}
// TestTokenExchange_EndToEnd mints an authorization code (authorize side),
// persists it, then redeems it through the exchange path and verifies the signed
// JWT. Proves the full code→token flow over a real store, and that replay fails.
func TestTokenExchange_EndToEnd(t *testing.T) {
db := openTestDB(t)
key := mustGenRSA(t)
app := seedAppWithCert(t, db, key)
ctx := context.Background()
now := time.Unix(1_800_000_000, 0)
// --- authorize side: mint a PKCE-bound code and persist it ---
verifier := "e2e-verifier-000000000000000000000000000000000000"
code, err := MintCode(app, "hanzo/alice", "openid profile", ComputeS256Challenge(verifier), "S256", "", now)
if err != nil {
t.Fatal(err)
}
if err := store.PersistToken(ctx, db, code); err != nil {
t.Fatalf("persist code: %v", err)
}
// --- token side: redeem via the same guards the handler uses ---
got, err := store.GetTokenByCode(ctx, db, code.Code)
if err != nil || got == nil {
t.Fatalf("get by code: %v (nil=%v)", err, got == nil)
}
if err := RedeemCode(got, app.Name, verifier, now.Add(time.Second)); err != nil {
t.Fatalf("redeem: %v", err)
}
ttl := appTTL(app)
if err := IssueAccessToken(got, int(ttl.Seconds()), now); err != nil {
t.Fatal(err)
}
signed, err := signAccessToken(ctx, db, app, got, "https://iam.hanzo.ai", ttl, now)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := store.SaveToken(ctx, db, got); err != nil {
t.Fatalf("save: %v", err)
}
// The signed JWT verifies under the cert key with the right claims.
var claims Claims
pub := &key.PublicKey
parsed, err := jwt.ParseWithClaims(signed, &claims, func(*jwt.Token) (any, error) { return pub, nil },
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) }))
if err != nil || !parsed.Valid {
t.Fatalf("verify signed token: %v", err)
}
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" ||
len(claims.Audience) != 1 || claims.Audience[0] != "hanzo-console" {
t.Fatalf("claims wrong: sub=%q owner=%q aud=%v", claims.Subject, claims.Owner, claims.Audience)
}
// --- replay: the persisted code is now used; a second redeem fails ---
again, _ := store.GetTokenByCode(ctx, db, code.Code)
if err := RedeemCode(again, app.Name, verifier, now.Add(2*time.Second)); err != ErrCodeUsed {
t.Fatalf("replay after persist: got %v, want ErrCodeUsed", err)
}
}
// --- helpers ---
func mustGenRSA(t *testing.T) *rsa.PrivateKey {
t.Helper()
k, err := rsaGenTest()
if err != nil {
t.Fatal(err)
}
return k
}
func TestMain(m *testing.M) { os.Exit(m.Run()) }
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"crypto/subtle"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
"github.com/hanzoai/iam/internal/store"
)
// RFC 8693 OAuth 2.0 Token Exchange — the standard delegation / act-on-behalf-of
// flow, and the replacement for the Casdoor `issue-user-token` verb (HIP-0111).
// A trusted, allow-listed confidential client presents a `subject_token`
// identifying the end user it acts for and receives a fresh access token bound to
// that user and re-scoped (RFC 8707 `resource`/`audience`) to a downstream
// resource server — indistinguishable from a token the user obtained directly, so
// a BFF can forward it and the resource server scopes on the validated `owner`.
//
// It reuses the same red-hardened controls as the other on-behalf-of primitives:
// the mint allow-list is matched by the globally-unique clientId ONLY, acting for
// a reserved-org (`admin`/`built-in`) subject needs the separate admin capability,
// and every exchange is audit-logged.
const (
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token"
subjectTokenTypeAccess = "urn:ietf:params:oauth:token-type:access_token"
subjectTokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
subjectTokenTypeJWTToken = "urn:ietf:params:oauth:token-type:jwt"
)
// tokenExchangeGrant handles grant_type=urn:ietf:params:oauth:grant-type:token-exchange.
func tokenExchangeGrant(c *zip.Ctx, db orm.DB) error {
ctx := c.Context()
now := nowFunc()
// 1) Authenticate the acting client and enforce the exchange capability. A
// public client can never exchange; the allow-list is keyed on clientId only.
clientID, clientSecret := clientAuth(c)
if clientID == "" {
return tokenErrorClient(c, "client authentication required")
}
clientApp, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if clientApp == nil || clientApp.ClientSecret == "" ||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(clientApp.ClientSecret)) != 1 {
return tokenErrorClient(c, "client authentication failed")
}
if !mintAllowed(clientApp.ClientId) {
return tokenError(c, 403, "unauthorized_client", "client is not permitted for token exchange")
}
// 2) The subject_token identifies the user to act for (RFC 8693 §2.1, required).
// Its type, if given, must be a token type we verify.
subjectToken := param(c, "subject_token")
if subjectToken == "" {
return tokenError(c, 400, "invalid_request", "subject_token is required")
}
if st := param(c, "subject_token_type"); st != "" &&
st != subjectTokenTypeAccess && st != subjectTokenTypeIDToken && st != subjectTokenTypeJWTToken {
return tokenError(c, 400, "invalid_request", "unsupported subject_token_type")
}
claims, err := verifyToken(ctx, db, subjectToken)
if err != nil {
return tokenError(c, 400, "invalid_grant", "subject_token is invalid or expired")
}
owner, name := splitSub(claims.Subject)
if owner == "" || name == "" {
return tokenError(c, 400, "invalid_grant", "subject_token carries no subject")
}
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
if user == nil || user.IsForbidden || user.IsDeleted {
return tokenError(c, 400, "invalid_grant", "the subject is unknown or forbidden")
}
// 3) A reserved-org (admin/built-in) subject is a cross-tenant / SuperAdmin
// identity — gate it behind the separate admin-exchange capability.
if store.IsSigningCertOwner(owner) && !adminMintAllowed(clientApp.ClientId) {
return tokenError(c, 403, "access_denied", "not permitted to act for a reserved-org subject")
}
// 4) The requested audience (RFC 8707) — resource wins, then audience, else the
// subject's own app. requested_token_type, if given, must be an access token.
if rt := param(c, "requested_token_type"); rt != "" && rt != tokenTypeAccessToken {
return tokenError(c, 400, "invalid_request", "only an access_token may be requested")
}
aud := param(c, "resource")
if aud == "" {
aud = param(c, "audience")
}
if aud == "" {
aud = defaultUserAudience(ctx, db, user, clientApp)
}
// 5) Mint for the subject, scoped to the requested audience, azp = the acting
// client (records who exchanged). Signed under the trusted cert + issuer.
signer, err := signerFor(ctx, db, clientApp, tokenIssuer(c))
if err != nil {
return tokenError(c, 500, "server_error", "")
}
ttl := appTTL(clientApp)
scope := param(c, "scope")
subject := owner + "/" + name
display := user.DisplayName
if display == "" {
display = user.Name
}
access, err := signer.SignUserToken(subject, owner, aud, clientApp.ClientId, user.Email, display, scope, ttl, now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
row := &schema.Token{
Owner: owner,
Application: clientApp.Name,
Organization: owner,
User: subject,
Scope: scope,
TokenType: "Bearer",
ExpiresIn: int(ttl.Seconds()),
AccessTokenHash: hashToken(access),
}
row.Name = "tx-" + hashToken(access)[:32]
if err := store.PersistToken(ctx, db, row); err != nil {
return tokenError(c, 500, "server_error", "")
}
auditMint(ctx, db, c, "token-exchange", clientApp.ClientId, subject)
// 6) RFC 8693 §2.2 response.
return c.JSON(200, map[string]any{
"access_token": access,
"issued_token_type": tokenTypeAccessToken,
"token_type": "Bearer",
"expires_in": int(ttl.Seconds()),
"scope": scope,
})
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/url"
"testing"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam/internal/schema"
)
// RFC 8693 Token Exchange — the standard on-behalf-of flow (replaces the retired
// issue-user-token verb). An allow-listed confidential client presents a valid
// subject_token and receives a token bound to that subject, re-scoped to a
// downstream resource. These tests pin the happy path AND every red-hardened
// control: the allow-list is clientId-only (the closed CRITICAL), a reserved-org
// subject needs the separate admin capability, public clients are refused, and an
// invalid subject_token yields invalid_grant.
// exchange posts a token-exchange grant as the confidential client (basic auth).
func exchange(t *testing.T, app *zip.App, clientID, secret string, extra url.Values) (int, map[string]any) {
t.Helper()
form := url.Values{
"grant_type": {grantTypeTokenExchange},
"client_id": {clientID},
"client_secret": {secret},
}
for k, v := range extra {
form[k] = v
}
resp, body := do(t, app, formReq("POST", PathToken, form))
return resp.StatusCode, decode(t, body)
}
// subjectTokenFor mints a real access token for (org, name) via the password
// grant through `viaClient` — a valid subject_token an exchange can present.
func subjectTokenFor(t *testing.T, app *zip.App, viaClient, secret, org, username, password string) string {
t.Helper()
_, tok := postToken(t, app, url.Values{
"grant_type": {"password"},
"client_id": {viaClient},
"client_secret": {secret},
"organization": {org},
"username": {username},
"password": {password},
"scope": {"openid profile"},
})
st, _ := tok["access_token"].(string)
if st == "" {
t.Fatalf("could not mint a subject_token; body=%v", tok)
}
return st
}
func TestTokenExchange_mintsForSubject(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", "correct horse")
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
status, tok := exchange(t, app, "hanzo-console", "top-secret", url.Values{
"subject_token": {subject},
"subject_token_type": {subjectTokenTypeAccess},
"resource": {"hanzo-cloud"},
})
if status != 200 {
t.Fatalf("status = %d; body=%v", status, tok)
}
if tok["issued_token_type"] != tokenTypeAccessToken {
t.Errorf("issued_token_type = %v, want %s", tok["issued_token_type"], tokenTypeAccessToken)
}
access, _ := tok["access_token"].(string)
if access == "" {
t.Fatalf("no access_token; body=%v", tok)
}
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("exchanged token does not verify: %v", err)
}
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
t.Errorf("subject/owner = %q/%q, want hanzo/alice / hanzo", claims.Subject, claims.Owner)
}
if claims.Azp != "hanzo-console" {
t.Errorf("azp = %q, want hanzo-console (the acting client)", claims.Azp)
}
// The RFC 8707 resource became the aud.
audOK := false
for _, a := range claims.Audience {
if a == "hanzo-cloud" {
audOK = true
}
}
if !audOK {
t.Errorf("aud = %v, want it to contain the requested resource hanzo-cloud", claims.Audience)
}
}
func TestTokenExchange_notAllowlisted_403(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "some-other-app")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
// hanzo-console holds a valid subject_token but is off the exchange allow-list.
status, _ := exchange(t, app, "hanzo-console", "top-secret", url.Values{"subject_token": {subject}})
if status != 403 {
t.Fatalf("off-allow-list exchange status = %d, want 403", status)
}
}
// TestTokenExchange_nameCollisionAttacker_403 is the regression guard for the
// closed CRITICAL: an attacker app named like the console but with the attacker's
// OWN clientId is refused at the allow-list (matched by clientId only), even though
// it holds a valid subject_token for its own tenant user.
func TestTokenExchange_nameCollisionAttacker_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"})
// Attacker: a DIFFERENT clientId, but NAME collides with the allow-listed one.
seedAttackerApp(t, db, "evil", "hanzo-console", "evil-pwn", "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, "evil-pwn", "attacker-knows-this", url.Values{
"subject_token": {subject},
"resource": {"hanzo-cloud"},
})
if status != 403 {
t.Fatalf("PRIV-ESC REOPENED: name-collision client admitted (status=%d) — allow-list must key on clientId only; body=%v", status, body)
}
}
func TestTokenExchange_reservedOrgSubject_requiresAdminCapability(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console") // general only
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
// An admin-org user with a password, so we can mint their subject_token.
seedUserInOrg(t, db, "admin", "root", "root@hanzo.ai", "admin pw")
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "admin", "root@hanzo.ai", "admin pw")
status, _ := exchange(t, app, "hanzo-console", "top-secret", url.Values{
"subject_token": {subject},
"resource": {"hanzo-cloud"},
})
if status != 403 {
t.Fatalf("reserved-org exchange without admin capability status = %d, want 403", status)
}
}
func TestTokenExchange_reservedOrgSubject_admitsWithAdminCapability(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
t.Setenv("IAM_ADMIN_MINT_ALLOWED_APPS", "hanzo-console")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
seedUserInOrg(t, db, "admin", "root", "root@hanzo.ai", "admin pw")
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "admin", "root@hanzo.ai", "admin pw")
status, tok := exchange(t, app, "hanzo-console", "top-secret", url.Values{
"subject_token": {subject},
"resource": {"hanzo-cloud"},
})
if status != 200 {
t.Fatalf("legit admin exchange status = %d; body=%v", status, tok)
}
if access, _ := tok["access_token"].(string); access == "" {
t.Fatalf("no admin token minted; body=%v", tok)
}
}
func TestTokenExchange_invalidSubjectToken_invalidGrant(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"})
status, tok := exchange(t, app, "hanzo-console", "top-secret", url.Values{"subject_token": {"garbage.not.a.jwt"}})
if status != 400 || tok["error"] != "invalid_grant" {
t.Fatalf("invalid subject_token → status=%d error=%v, want 400 invalid_grant", status, tok["error"])
}
}
func TestTokenExchange_publicClient_rejected(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "pub")
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub"}) // no secret → public
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
status, _ := exchange(t, app, "pub", "", url.Values{"subject_token": {"x"}})
if status != 401 {
t.Fatalf("public-client exchange status = %d, want 401", status)
}
}
func TestTokenExchange_emitsAudit(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", "correct horse")
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
exchange(t, app, "hanzo-console", "top-secret", url.Values{"subject_token": {subject}, "resource": {"hanzo-cloud"}})
logs, err := orm.TypedQuery[schema.AuditLog](db).Filter("Action=", "token-exchange").GetAll(context.Background())
if err != nil {
t.Fatalf("query audit: %v", err)
}
if len(logs) != 1 || logs[0].User != "hanzo/alice" || logs[0].Object != "hanzo-console" {
t.Fatalf("token-exchange audit = %+v, want one row {user:hanzo/alice, minter:hanzo-console}", logs)
}
}

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