Compare commits

...
65 Commits
Author SHA1 Message Date
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
135 changed files with 21684 additions and 219 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"
+68
View File
@@ -0,0 +1,68 @@
# 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:
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 }}
# 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: ${{ secrets.REGISTRY_USER || github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GH_PAT || 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 }}
# iam2's private modules (hanzoai/orm, hanzoai/sqlite) are fetched inside
# the build via this token — the Dockerfile mounts it as GIT_AUTH_TOKEN.
secrets: |
GIT_AUTH_TOKEN=${{ secrets.REGISTRY_TOKEN || secrets.GH_PAT || secrets.GITHUB_TOKEN }}
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/iam2/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/iam2/feature"
"github.com/hanzoai/iam2/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")
}
}
+16 -15
View File
@@ -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.52.0
)
// Migration-only: linked solely in `go build -tags migration` so `iam2 compare`
@@ -19,10 +20,20 @@ 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/pquerna/otp v1.5.0
github.com/zap-proto/fiber/v3 v3.2.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
@@ -40,7 +51,7 @@ 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/log v1.4.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
@@ -52,26 +63,16 @@ require (
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/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/net v0.54.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
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // 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
)
+74 -19
View File
@@ -2,18 +2,24 @@ 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/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,6 +42,8 @@ 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/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=
@@ -44,6 +52,8 @@ 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,10 +68,12 @@ 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/crypto v1.20.1 h1:d0/jW7vVVQZbeGJNVmtMKkrhjTM6BtqEOWH234iUghM=
github.com/luxfi/crypto v1.20.1/go.mod h1:bLCBuIV/KDjPytld7jSYe1WbfWknPQXcivq88Qo96QU=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -74,8 +86,11 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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 +115,72 @@ 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.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/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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
+186
View File
@@ -0,0 +1,186 @@
// 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/iam2/internal/schema"
)
// 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")
}
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")
}
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/iam2/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())
}
+394
View File
@@ -0,0 +1,394 @@
// 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"
"errors"
"reflect"
"strings"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/oidc"
"github.com/hanzoai/iam2/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
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
}
// 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"}
// 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) {
return false
}
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) {
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
}
// 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
}
+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/iam2/internal/routes"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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)
}
}
})
}
+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"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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 bcrypt-hashed (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 := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return c.JSON(500, errResp("server_error"))
}
hash = string(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, "bcrypt", ""
}
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, "bcrypt"
}
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/iam2/internal/routes"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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 != "bcrypt" {
t.Fatalf("passwordType = %q, want bcrypt", 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/iam2/internal/authz"
"github.com/hanzoai/iam2/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())
}
+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/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/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/iam2/internal/routes"
"github.com/hanzoai/iam2/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/iam2/internal/applications"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/organizations"
"github.com/hanzoai/iam2/internal/projects"
"github.com/hanzoai/iam2/internal/providers"
"github.com/hanzoai/iam2/internal/roles"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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)
}
+83
View File
@@ -0,0 +1,83 @@
// 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.
//
// Verify-only by design: this package never hashes. Re-hashing a verified
// password to a newer scheme (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
}
// 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/iam2/internal/oidc"
"github.com/hanzoai/iam2/internal/routes"
"github.com/hanzoai/iam2/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/iam2/feature"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/internal/users"
"github.com/hanzoai/iam2/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
}
+54
View File
@@ -0,0 +1,54 @@
// 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 "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) error {
return c.JSON(200, Response{Status: "ok", Data: data})
}
// 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 ""
}
// 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/iam2/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/iam2/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[:]))
}
+263
View File
@@ -0,0 +1,263 @@
// 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/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/store"
)
// mfaTypeTOTP is the casibase MFA type for an authenticator app (RFC 6238 TOTP) —
// the value PreferredMfaType carries once TOTP is enrolled.
const mfaTypeTOTP = "app"
// 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
u.RecoveryCodes = req.RecoveryCodes
u.PreferredMfaType = mfaTypeTOTP
if err := u.UpdateCtx(c.Context()); err != nil {
return c.JSON(500, errResp("server_error"))
}
return c.JSON(200, okData(map[string]any{"preferredMfaType": mfaTypeTOTP}))
}
}
// 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/iam2/internal/routes"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/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
}
+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/iam2/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/iam2/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)
}
}
+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
}
+471
View File
@@ -0,0 +1,471 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"net/url"
"strings"
"time"
"github.com/hanzoai/orm"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/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"
// 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 is the
// browser-redirect return; POST covers an IdP that uses form_post. Each
// self-authenticates via the state + browser cookie.
func routeFederation(r zip.Router, db orm.DB) {
r.Get(PathFederationCallback, federationCallbackHandler(db))
r.Post(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()
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")
}
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")
}
// A public client must have carried a PKCE challenge (enforced at the begin
// leg; re-asserted here so a minted code is never redeemable without proof).
if app.ClientSecret == "" && st.CodeChallenge == "" {
return fedErrorRedirect(c, st, "invalid_request", "PKCE is required for public clients")
}
// Mint iam2's own authorization code — the SAME artifact a password login
// mints — bound to the app-leg PKCE, redirect_uri, and nonce.
userID := user.Owner + "/" + user.Name
codeRow, err := MintCode(app, userID, st.Scope, st.CodeChallenge, st.CodeChallengeMethod, st.Resource, now)
if err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
codeRow.RedirectUri = st.RedirectUri
codeRow.Nonce = st.AppNonce
if err := store.PersistToken(ctx, db, codeRow); err != nil {
return fedErrorRedirect(c, st, "server_error", "")
}
return fedSuccessRedirect(c, st, codeRow.Code)
}
}
// 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) {
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 derived from the pinned issuer (or the
// effective host), so it is stable and never steered by a request header.
func federationCallbackURL(c *zip.Ctx) string {
return tokenIssuer(c) + PathFederationCallback
}
// 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)
}
+687
View File
@@ -0,0 +1,687 @@
// 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"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/iam2/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 an
// SSRF-via-redirect vector.
var federationHTTPClient = &http.Client{
Timeout: 12 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
// 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
}
}
+731
View File
@@ -0,0 +1,731 @@
// 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/iam2/internal/schema"
"github.com/hanzoai/iam2/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
}
func newMockOIDC(t *testing.T, clientID string) *mockOIDC {
t.Helper()
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()
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()
}
func tokenBody(tok map[string]any) string {
b, _ := json.Marshal(tok)
return string(b)
}
+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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/sessions"
"github.com/hanzoai/iam2/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/iam2/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")
}
}
+170
View File
@@ -0,0 +1,170 @@
// 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/iam2/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
}
// 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.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/iam2/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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/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/iam2/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/iam2/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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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
}
+174
View File
@@ -0,0 +1,174 @@
// 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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/sessions"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/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) | "login" (bare session)
// 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"`
}
// 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")
}
if f.Organization == "" || f.Username == "" || f.Password == "" {
return httpx.Err(c, "organization, username and password are required")
}
ctx := c.Context()
user, err := resolveLoginUser(ctx, db, f.Organization, f.Username)
if err != nil {
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")
}
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 shape the portal expects for a non-OAuth sign-in). 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.
app, err := resolveLoginApp(ctx, db, f)
if err != nil {
return httpx.Err(c, err.Error())
}
if app == nil {
return httpx.Err(c, "the application does not exist")
}
// Tenant isolation: the authenticated user's organization must be
// permitted for this application — its own org, a shared app, or an app
// that lets users choose their org. Without this a user in one tenant
// could obtain a token whose `organization` claim names another tenant.
if f.Organization != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return httpx.Err(c, "the user is not permitted to sign in to this application")
}
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
// the token endpoint re-checks it. A supplied-but-unregistered URI is
// refused — never minted against.
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
return httpx.Err(c, "invalid redirect_uri")
}
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
if f.CodeChallenge != "" && method != "S256" {
return httpx.Err(c, "only S256 PKCE is supported")
}
// A public client (no secret) must use PKCE — no downgrade.
if app.ClientSecret == "" && f.CodeChallenge == "" {
return httpx.Err(c, "PKCE is required for public clients")
}
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
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
}
+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/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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)
}
})
}
+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/iam2/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
}
+120
View File
@@ -0,0 +1,120 @@
// 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)
)
// 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)
// RFC 7662 introspection + RFC 7009 revocation — the standard token-management
// endpoints a resource server / confidential client uses (client-authenticated).
routeIntrospectRevoke(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,
"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},
"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/iam2/internal/organizations"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/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/iam2/internal/httpx"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/cred"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/sessions"
"github.com/hanzoai/iam2/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/iam2/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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/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 a bcrypt 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 a bcrypt hash (PasswordType=bcrypt) 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 != "bcrypt" {
t.Errorf("PasswordType = %q, want bcrypt", 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")
}
})
}
+539
View File
@@ -0,0 +1,539 @@
// 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/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/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 "":
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", "")
}
if app == nil {
// Unknown code OR its app vanished — one opaque answer, no oracle.
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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/internal/schema"
"github.com/hanzoai/iam2/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/iam2/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)
}
}
+252
View File
@@ -0,0 +1,252 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/http"
"net/url"
"testing"
"time"
"github.com/zap-proto/zip"
)
// loginParams builds a type=code login body for org "hanzo" / user alice.
func loginParams(clientID, scope string) map[string]string {
return map[string]string{
"organization": "hanzo",
"username": "alice",
"password": "pw",
"clientId": clientID,
"redirectUri": testRedirect,
"scope": scope,
"nonce": "nonce-1",
}
}
// The confidential authorization-code flow, end to end over HTTP: login mints a
// code, the token endpoint exchanges it for a verifiable access token, an
// id_token that echoes the nonce, and a refresh token — with no-store caching.
func TestAuthCodeFlow_ConfidentialHappyPath(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")
code, resp, body := loginForCode(t, app, loginParams("conf", "openid profile email"))
if code == "" {
t.Fatalf("login did not mint a code: status=%d body=%s", resp.StatusCode, body)
}
form := url.Values{
"code": {code},
"client_id": {"conf"},
"client_secret": {"s3cret"},
"redirect_uri": {testRedirect},
}
tokResp, tok := exchangeCode(t, app, form)
if tokResp.StatusCode != 200 {
t.Fatalf("token status = %d, body = %v", tokResp.StatusCode, tok)
}
if cc := tokResp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
if tok["token_type"] != "Bearer" || tok["access_token"] == nil ||
tok["id_token"] == nil || tok["refresh_token"] == nil {
t.Fatalf("token response missing fields: %v", tok)
}
// The access token verifies through iam2's own verify path with the right
// issuer, audience, subject, and tenant.
access := tok["access_token"].(string)
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("verify access token: %v", err)
}
if claims.Issuer != "https://hanzo.id" {
t.Errorf("iss = %q, want https://hanzo.id", claims.Issuer)
}
if len(claims.Audience) != 1 || claims.Audience[0] != "conf" {
t.Errorf("aud = %v, want [conf]", claims.Audience)
}
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
t.Errorf("sub/owner = %q/%q, want hanzo/alice + hanzo", claims.Subject, claims.Owner)
}
// The id_token echoes the request nonce.
idClaims, err := verifyToken(context.Background(), db, tok["id_token"].(string))
if err != nil {
t.Fatalf("verify id_token: %v", err)
}
if idClaims.Nonce != "nonce-1" {
t.Errorf("id_token nonce = %q, want nonce-1", idClaims.Nonce)
}
}
// The public client flow requires and verifies PKCE; a tampered verifier fails.
func TestAuthCodeFlow_PublicPKCE(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
params := loginParams("pub", "openid")
params["codeChallenge"] = ComputeS256Challenge(verifier)
params["codeChallengeMethod"] = "S256"
t.Run("valid verifier", func(t *testing.T) {
code, _, _ := loginForCode(t, app, params)
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_verifier": {verifier}})
if resp.StatusCode != 200 || tok["access_token"] == nil {
t.Fatalf("valid PKCE exchange failed: %d %v", resp.StatusCode, tok)
}
})
t.Run("tampered verifier", func(t *testing.T) {
code, _, _ := loginForCode(t, app, params)
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_verifier": {"the-WRONG-verifier-000000000000000000000000"}})
if resp.StatusCode != 400 || tok["error"] != "invalid_grant" {
t.Fatalf("tampered PKCE: status=%d err=%v, want 400 invalid_grant", resp.StatusCode, tok["error"])
}
})
t.Run("missing verifier", func(t *testing.T) {
code, _, _ := loginForCode(t, app, params)
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}})
if resp.StatusCode != 400 || tok["error"] != "invalid_grant" {
t.Fatalf("missing verifier: status=%d err=%v", resp.StatusCode, tok["error"])
}
})
}
// The RFC 6749 §5.2 error taxonomy: invalid_client → 401 + WWW-Authenticate,
// every other error → 400, each with the right code.
func TestToken_ErrorTaxonomy(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("missing grant_type", func(t *testing.T) {
resp, tok := postToken(t, app, url.Values{})
requireError(t, resp, tok, 400, "invalid_request")
})
t.Run("unsupported grant_type", func(t *testing.T) {
// A grant iam2 does not implement (device_code) — password IS supported now.
resp, tok := postToken(t, app, url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}})
requireError(t, resp, tok, 400, "unsupported_grant_type")
})
t.Run("unknown code", func(t *testing.T) {
resp, tok := exchangeCode(t, app, url.Values{"code": {"nope"}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}})
requireError(t, resp, tok, 400, "invalid_grant")
})
t.Run("wrong client secret is invalid_client 401", func(t *testing.T) {
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"WRONG"}, "redirect_uri": {testRedirect}})
requireError(t, resp, tok, 401, "invalid_client")
if resp.Header.Get("WWW-Authenticate") == "" {
t.Error("401 invalid_client must carry WWW-Authenticate")
}
})
t.Run("redirect_uri mismatch", func(t *testing.T) {
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {"https://app.example/other"}})
requireError(t, resp, tok, 400, "invalid_grant")
})
t.Run("code is single-use", func(t *testing.T) {
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
form := url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}}
if resp, _ := exchangeCode(t, app, cloneValues(form)); resp.StatusCode != 200 {
t.Fatalf("first exchange failed: %d", resp.StatusCode)
}
resp, tok := exchangeCode(t, app, cloneValues(form))
requireError(t, resp, tok, 400, "invalid_grant")
})
}
// A code past its TTL is refused.
func TestToken_ExpiredCode(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")
base := time.Unix(1_800_000_000, 0)
nowFuncSet(t, base)
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
// Advance past the 5-minute code TTL.
nowFuncSet(t, base.Add(codeTTL+time.Minute))
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}})
requireError(t, resp, tok, 400, "invalid_grant")
}
// client_credentials issues a machine token (no user, no id_token, no refresh);
// a public client or a bad secret is refused 401.
func TestClientCredentials(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
t.Run("post credentials", func(t *testing.T) {
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"}, "scope": {"read"}})
if resp.StatusCode != 200 {
t.Fatalf("status = %d, body = %v", resp.StatusCode, tok)
}
if tok["refresh_token"] != nil || tok["id_token"] != nil {
t.Errorf("client_credentials must not issue refresh/id_token: %v", tok)
}
claims, err := verifyToken(context.Background(), db, tok["access_token"].(string))
if err != nil {
t.Fatal(err)
}
if claims.Subject != "admin/svc" || claims.Owner != "hanzo" {
t.Errorf("sub/owner = %q/%q, want admin/svc + hanzo", claims.Subject, claims.Owner)
}
})
t.Run("basic auth", func(t *testing.T) {
req := formReq("POST", PathToken, url.Values{"grant_type": {"client_credentials"}})
req.SetBasicAuth("svc", "svc-secret")
resp, body := do(t, app, req)
if resp.StatusCode != 200 {
t.Fatalf("basic-auth client_credentials: status %d, body %s", resp.StatusCode, body)
}
})
t.Run("wrong secret", func(t *testing.T) {
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"nope"}})
requireError(t, resp, tok, 401, "invalid_client")
})
t.Run("public client refused", func(t *testing.T) {
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"pub"}})
requireError(t, resp, tok, 401, "invalid_client")
})
}
// --- helpers ---
func postToken(t *testing.T, app *zip.App, form url.Values) (*http.Response, map[string]any) {
t.Helper()
resp, body := do(t, app, formReq("POST", PathToken, form))
return resp, decode(t, body)
}
func requireError(t *testing.T, resp *http.Response, tok map[string]any, status int, code string) {
t.Helper()
if resp.StatusCode != status {
t.Fatalf("status = %d, want %d (body %v)", resp.StatusCode, status, tok)
}
if tok["error"] != code {
t.Fatalf("error = %v, want %q", tok["error"], code)
}
}
func cloneValues(v url.Values) url.Values {
out := url.Values{}
for k, vs := range v {
out[k] = append([]string(nil), vs...)
}
// exchangeCode re-sets grant_type; drop it so the clone re-adds cleanly.
out.Del("grant_type")
return out
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/httpx"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// The userinfo endpoint: GET/POST /v1/iam/oauth/userinfo. A bearer must satisfy
// two independent checks — the grant still exists (the token row is looked up by
// the SHA-256 hash of the presented token, so a revoked or rotated grant is
// already dead) AND the JWT signature verifies under the issuing cert. It then
// returns exactly the OIDC claims the token's granted scopes authorize; the
// subject is taken from the signed `sub`, so the response can only ever describe
// the token's own principal (no cross-tenant read).
// userinfoHandler serves the userinfo endpoint.
func userinfoHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
bearer := httpx.Bearer(c)
if bearer == "" {
return userinfoUnauthorized(c, "a bearer access token is required")
}
ctx := c.Context()
row, err := store.GetTokenByAccessTokenHash(ctx, db, hashToken(bearer))
if err != nil {
return c.JSON(500, map[string]string{"error": "server_error"})
}
if row == nil {
return userinfoUnauthorized(c, "the access token is invalid or revoked")
}
claims, err := verifyToken(ctx, db, bearer)
if err != nil {
return userinfoUnauthorized(c, "the access token is invalid")
}
owner, name := splitSub(claims.Subject)
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return c.JSON(500, map[string]string{"error": "server_error"})
}
return c.JSON(200, buildUserinfo(user, claims, row, tokenIssuer(c)))
}
}
// buildUserinfo assembles the scope-gated claim set. The identifiers (sub, iss,
// aud, owner, organization) are always present; every profile/email/address/
// phone claim appears only when its scope was granted and the field is set.
func buildUserinfo(u *schema.User, claims *Claims, row *schema.Token, iss string) map[string]any {
aud := ""
if len(claims.Audience) > 0 {
aud = claims.Audience[0]
}
info := map[string]any{
"sub": claims.Subject,
"iss": iss,
"aud": aud,
"owner": claims.Owner,
}
if claims.Organization != "" {
info["organization"] = claims.Organization
}
// A client_credentials token (or a since-deleted user) has no profile.
if u == nil {
return info
}
// isAdmin is the SuperAdmin-predicate input the gateway admin-guard derives its
// decision from (with owner==adminOrg). It describes the token's OWN principal —
// read from the loaded user record (authoritative, never a token claim, matching
// the authz Principal) — so UserInfo is a drop-in for the retired get-account
// security contract. Emitted regardless of scope (it is identity, not profile),
// as `isAdmin` — the exact key the admin-guard + @hanzo/iam SDK read.
info["isAdmin"] = u.IsAdmin
// type distinguishes a real account from an auto-created anonymous session (the
// console's `type === "anonymous-user"` check); always present.
if u.Type != "" {
info["type"] = u.Type
}
scope := row.Scope
if hasScope(scope, "profile") {
putIf(info, "preferred_username", u.Name)
putIf(info, "name", u.DisplayName)
putIf(info, "picture", u.Avatar)
putIf(info, "real_name", u.RealName)
if len(u.Groups) > 0 {
info["groups"] = u.Groups
}
if u.IsVerified {
info["is_verified"] = true
}
}
if hasScope(scope, "email") && u.Email != "" {
info["email"] = u.Email
info["email_verified"] = u.EmailVerified
}
if hasScope(scope, "address") && u.Location != "" {
info["address"] = u.Location
}
if hasScope(scope, "phone") && u.Phone != "" {
info["phone"] = u.Phone
}
return info
}
// putIf sets key only when v is non-empty (omitempty for a map).
func putIf(m map[string]any, key, v string) {
if v != "" {
m[key] = v
}
}
// userinfoUnauthorized answers an invalid/absent bearer with the OIDC 401 shape
// and the Bearer challenge, leaking nothing about why beyond the token being
// unusable.
func userinfoUnauthorized(c *zip.Ctx, desc string) error {
c.SetHeader("WWW-Authenticate", `Bearer error="invalid_token", error_description="`+desc+`"`)
return c.JSON(401, map[string]string{"error": "invalid_token", "error_description": desc})
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"net/url"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
)
// seedRichUser creates alice with the profile fields userinfo projects.
func seedRichUser(t *testing.T, db orm.DB) {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = "alice"
u.Email = "alice@hanzo.ai"
u.EmailVerified = true
u.DisplayName = "Alice Example"
u.Phone = "+15551234567"
u.Location = "San Francisco"
u.PasswordHash = string(hash)
u.PasswordType = "bcrypt"
u.SetId("hanzo/alice")
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed rich user: %v", err)
}
}
// accessTokenFor runs the confidential flow and returns the access token.
func accessTokenFor(t *testing.T, app *zip.App, scope string) string {
t.Helper()
code, _, _ := loginForCode(t, app, loginParams("conf", scope))
_, tok := exchangeCode(t, app, url.Values{
"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect},
})
access, _ := tok["access_token"].(string)
if access == "" {
t.Fatal("no access token issued")
}
return access
}
func userinfo(t *testing.T, app *zip.App, bearer string) (int, map[string]any) {
t.Helper()
req := formReqNoBody("GET", PathUserInfo)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, body := do(t, app, req)
return resp.StatusCode, decode(t, body)
}
// userinfo returns exactly the claims the granted scopes authorize.
func TestUserinfo_ClaimsByScope(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 phone address")
status, info := userinfo(t, app, access)
if status != 200 {
t.Fatalf("userinfo status = %d, body %v", status, info)
}
want := map[string]any{
"sub": "hanzo/alice",
"iss": "https://hanzo.id",
"aud": "conf",
"owner": "hanzo",
"organization": "hanzo",
"preferred_username": "alice",
"name": "Alice Example",
"email": "alice@hanzo.ai",
"email_verified": true,
"phone": "+15551234567",
"address": "San Francisco",
}
for k, v := range want {
if info[k] != v {
t.Errorf("userinfo[%q] = %v, want %v", k, info[k], v)
}
}
}
// UserInfo carries the get-account security contract: isAdmin (the gateway
// admin-guard's SuperAdmin-predicate input, with owner==adminOrg) + type, from the
// loaded user record, so UserInfo is a drop-in for the retired get-account. isAdmin
// is present regardless of scope (identity, not profile).
func TestUserinfo_AdminGuardContract(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
// Promote the seeded user to an admin with a concrete type.
u, err := orm.Get[schema.User](db, "hanzo/alice")
if err != nil {
t.Fatalf("load: %v", err)
}
u.IsAdmin = true
u.Type = "normal-user"
if err := u.UpdateCtx(context.Background()); err != nil {
t.Fatalf("promote: %v", err)
}
// Even a minimal (openid-only) scope carries the identity claims.
access := accessTokenFor(t, app, "openid")
status, info := userinfo(t, app, access)
if status != 200 {
t.Fatalf("status %d: %v", status, info)
}
if info["isAdmin"] != true {
t.Errorf("userinfo[isAdmin] = %v, want true (the admin-guard SuperAdmin input)", info["isAdmin"])
}
if info["type"] != "normal-user" {
t.Errorf("userinfo[type] = %v, want normal-user", info["type"])
}
if info["owner"] != "hanzo" {
t.Errorf("userinfo[owner] = %v, want hanzo", info["owner"])
}
}
// A non-admin's userinfo carries isAdmin:false (never absent — the admin-guard
// must be able to read a definite false, not infer it from a missing key).
func TestUserinfo_NonAdminIsExplicitFalse(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db) // alice, IsAdmin=false
_, info := userinfo(t, app, accessTokenFor(t, app, "openid"))
if v, ok := info["isAdmin"]; !ok || v != false {
t.Errorf("userinfo[isAdmin] = %v (present=%v), want an explicit false", v, ok)
}
}
// A narrow scope yields only the identifiers — no profile/email leakage.
func TestUserinfo_ScopeGating(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")
status, info := userinfo(t, app, access)
if status != 200 {
t.Fatalf("status %d", status)
}
for _, leaked := range []string{"email", "preferred_username", "name", "phone", "address"} {
if _, ok := info[leaked]; ok {
t.Errorf("scope=openid must not expose %q (got %v)", leaked, info[leaked])
}
}
if info["sub"] != "hanzo/alice" {
t.Errorf("sub missing: %v", info["sub"])
}
}
// No/invalid bearer → 401 invalid_token with the Bearer challenge.
func TestUserinfo_Unauthorized(t *testing.T) {
app, db := newServer(t)
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
seedRichUser(t, db)
t.Run("no bearer", func(t *testing.T) {
req := formReqNoBody("GET", PathUserInfo)
resp, body := do(t, app, req)
if resp.StatusCode != 401 || decode(t, body)["error"] != "invalid_token" {
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
}
if resp.Header.Get("WWW-Authenticate") == "" {
t.Error("401 must carry WWW-Authenticate")
}
})
t.Run("garbage bearer", func(t *testing.T) {
status, info := userinfo(t, app, "not.a.jwt")
if status != 401 || info["error"] != "invalid_token" {
t.Fatalf("status=%d err=%v", status, info["error"])
}
})
}
// The store keeps only token hashes — never the reusable plaintext bearer or
// refresh token — so a database dump exposes no usable credential.
func TestTokens_StoredAsHashesOnly(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")
access := tok["access_token"].(string)
refresh := tok["refresh_token"].(string)
row, err := store.GetTokenByAccessTokenHash(context.Background(), db, hashToken(access))
if err != nil || row == nil {
t.Fatalf("locate token row: %v (nil=%v)", err, row == nil)
}
if row.AccessToken != "" || row.RefreshToken != "" {
t.Fatalf("plaintext tokens must not be persisted: access=%q refresh=%q", row.AccessToken, row.RefreshToken)
}
if row.AccessTokenHash != hashToken(access) || row.RefreshTokenHash != hashToken(refresh) {
t.Fatal("token hashes must be persisted for lookup")
}
}
// Deleting the token row revokes the bearer even though the JWT itself is still
// within its lifetime — userinfo looks the grant up by hash first.
func TestUserinfo_RevokedTokenRejected(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")
if status, _ := userinfo(t, app, access); status != 200 {
t.Fatalf("token should work before revocation: %d", status)
}
// Revoke: delete the stored grant.
row, err := store.GetTokenByAccessTokenHash(context.Background(), db, hashToken(access))
if err != nil || row == nil {
t.Fatalf("locate token row: %v (nil=%v)", err, row == nil)
}
if err := store.DeleteToken(context.Background(), db, row); err != nil {
t.Fatal(err)
}
if status, info := userinfo(t, app, access); status != 401 || info["error"] != "invalid_token" {
t.Fatalf("revoked token: status=%d err=%v, want 401 invalid_token", status, info["error"])
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/store"
)
// Token verification is a pure reduction of a signed value: read the `kid`,
// resolve the matching signing Cert, check the signature under an explicit
// algorithm allowlist (never alg:none, never an unexpected method), and validate
// the standard time claims. Every protected route reduces a bearer the same way,
// so a token is trusted for exactly what it cryptographically is — no more.
// acceptedAlgs is the closed set of signing algorithms a bearer may carry. It
// mirrors the JWKS: the classical interop algorithms plus post-quantum ML-DSA.
// alg:none and any HMAC family are absent, so a forged header cannot select a
// verification path that trusts attacker-controlled material.
var acceptedAlgs = []string{"RS256", "RS512", "ES256", "ES384", "ES512", algMLDSA65}
// VerifyToken is the exported bearer-verification primitive the authz layer
// reuses to gate the CRUD surface: it is verifyToken, so a bearer presented to a
// protected route is trusted under the exact same closed algorithm allowlist,
// trusted signing-cert kid resolution, and time validation as every OIDC route.
// One verification path, one trust model — no second, weaker check.
func VerifyToken(ctx context.Context, db orm.DB, tokenStr string) (*Claims, error) {
return verifyToken(ctx, db, tokenStr)
}
// verifyToken parses tokenStr, verifies its signature against the Cert named by
// the token's kid, and returns the validated claims. It fails closed on an
// unknown kid, a disallowed algorithm, a bad signature, or an expired token.
func verifyToken(ctx context.Context, db orm.DB, tokenStr string) (*Claims, error) {
claims := &Claims{}
keyFunc := func(t *jwt.Token) (any, error) {
kid, _ := t.Header["kid"].(string)
if kid == "" {
return nil, errors.New("verify: token has no kid")
}
// Resolve the kid ONLY among trusted platform signing certs, so a token
// signed by a tenant-created cert with a colliding name never verifies.
cert, err := store.GetSigningCert(ctx, db, kid)
if err != nil {
return nil, err
}
if cert == nil {
return nil, errors.New("verify: unknown signing key")
}
pub, _, _, err := certPublicKey(cert)
if err != nil {
return nil, err
}
return pub, nil
}
if _, err := jwt.ParseWithClaims(tokenStr, claims, keyFunc,
jwt.WithValidMethods(acceptedAlgs),
jwt.WithTimeFunc(nowFunc),
); err != nil {
return nil, err
}
return claims, nil
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/store"
)
// GET /v1/iam/whoami — the current caller's identity, lighter than get-account:
// just who you are (owner, name, id, isAdmin), not the full masked profile + org.
// Resolution is the same callerOf as get-account (session cookie first, then bearer
// access token), so one identity is reported whichever credential carried it. An
// anonymous caller gets the casibase {status:"error"} (200), never a leak.
// PathWhoami is the canonical lightweight-identity endpoint.
const PathWhoami = "/v1/iam/whoami"
// whoamiHandler reports the resolved caller's identity in the casibase envelope.
func whoamiHandler(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"})
}
// The identity essentials. isAdmin/displayName enrich best-effort from the
// user row — absent (a machine token, or a since-deleted user), the subject
// alone is still a truthful identity.
data := map[string]any{"owner": owner, "name": name, "id": owner + "/" + name}
if u, err := store.GetUserByName(ctx, db, owner, name); err == nil && u != nil {
data["id"] = u.Owner + "/" + u.Name
data["isAdmin"] = u.IsAdmin
data["displayName"] = u.DisplayName
}
return c.JSON(200, accountResponse{
Status: "ok",
Sub: owner + "/" + name,
Name: name,
Data: data,
})
}
}
+219
View File
@@ -0,0 +1,219 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package organizations implements the IAM v2 organization resource as typed
// zip handlers over hanzoai/orm. The entity is owner-scoped: the (owner, name)
// pair is the natural key, so reads, updates, and deletes resolve a row by that
// pair rather than by the orm surrogate id.
package organizations
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
const orgBase = "/v1/iam/organizations"
// Route registers the organization CRUD surface on app, backed by db.
func Route(app *zip.App, db orm.DB) {
NewOrganizationAPI(db).route(app)
}
// OrganizationAPI serves CRUD for the organization entity over a single
// orm.DB. It is transport-only: credential hashing, password-type
// sanitisation, and signin-throttle clamping are policy concerns applied by the
// caller before Create/Update — never braided into persistence here.
type OrganizationAPI struct {
DB orm.DB
}
// NewOrganizationAPI binds the handlers to a store.
func NewOrganizationAPI(db orm.DB) *OrganizationAPI {
return &OrganizationAPI{DB: db}
}
// mount registers the five organization routes on app. Writes are POST with a
// JSON body; reads are GET whose (owner, name, paging) selector binds from the
// request. Every handler validates its key and fails 400 if it is absent, so a
// missing selector is loud, never a silent full-table action.
func (h *OrganizationAPI) route(app *zip.App) {
zip.Post[CreateOrganizationInput, schema.Organization](app, orgBase, h.Create,
zip.WithOperationID("createOrganization"), zip.WithSummary("Create an organization"), zip.WithTags("organizations"))
zip.Get[ListOrganizationsInput, ListOrganizationsOutput](app, orgBase, h.List,
zip.WithOperationID("listOrganizations"), zip.WithSummary("List organizations"), zip.WithTags("organizations"))
zip.Get[GetOrganizationInput, schema.Organization](app, orgBase+"/get", h.Get,
zip.WithOperationID("getOrganization"), zip.WithSummary("Get one organization by owner and name"), zip.WithTags("organizations"))
zip.Post[UpdateOrganizationInput, schema.Organization](app, orgBase+"/update", h.Update,
zip.WithOperationID("updateOrganization"), zip.WithSummary("Update an organization"), zip.WithTags("organizations"))
zip.Post[DeleteOrganizationInput, DeleteOrganizationOutput](app, orgBase+"/delete", h.Delete,
zip.WithOperationID("deleteOrganization"), zip.WithSummary("Delete an organization"), zip.WithTags("organizations"))
}
// CreateOrganizationInput carries the full organization as the request body.
type CreateOrganizationInput struct {
schema.Organization
}
// UpdateOrganizationInput carries the desired organization state; its Owner and
// Name select the row to overwrite.
type UpdateOrganizationInput struct {
schema.Organization
}
// GetOrganizationInput selects a single organization by natural key.
type GetOrganizationInput struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// DeleteOrganizationInput selects the organization to remove by natural key.
type DeleteOrganizationInput struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// ListOrganizationsInput scopes and pages a listing. All fields are optional.
type ListOrganizationsInput struct {
Owner string `json:"owner"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// ListOrganizationsOutput is one page of organizations.
type ListOrganizationsOutput struct {
Organizations []*schema.Organization `json:"organizations"`
Count int `json:"count"`
}
// DeleteOrganizationOutput reports whether a row was removed.
type DeleteOrganizationOutput struct {
Affected bool `json:"affected"`
}
// Create inserts a new organization, refusing a duplicate (owner, name).
func (h *OrganizationAPI) Create(ctx context.Context, in *CreateOrganizationInput) (*schema.Organization, error) {
org := in.Organization
if org.Owner == "" || org.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := h.find(org.Owner, org.Name); {
case err == nil:
return nil, zip.ErrConflict("organization already exists")
case errors.Is(err, orm.ErrNotFound):
// free to create
default:
return nil, zip.ErrInternal(err.Error())
}
entity := orm.New[schema.Organization](h.DB)
model := entity.Model // keep orm wiring (db handle, key) across the overlay
*entity = org
entity.Model = model
if entity.CreatedTime == "" {
entity.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
if err := entity.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return entity.Mask(), nil
}
// Get resolves one organization by (owner, name).
func (h *OrganizationAPI) Get(ctx context.Context, in *GetOrganizationInput) (*schema.Organization, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
org, err := h.find(in.Owner, in.Name)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("organization not found")
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return org.Mask(), nil
}
// List returns organizations newest-first, optionally scoped by owner and paged.
func (h *OrganizationAPI) List(ctx context.Context, in *ListOrganizationsInput) (*ListOrganizationsOutput, error) {
q := orm.TypedQuery[schema.Organization](h.DB)
if in.Owner != "" {
q = q.Filter("Owner=", in.Owner)
}
if in.Limit > 0 {
q = q.Limit(in.Limit)
}
if in.Offset > 0 {
q = q.Offset(in.Offset)
}
orgs, err := q.Order("-CreatedTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
for i, o := range orgs {
orgs[i] = o.Mask()
}
return &ListOrganizationsOutput{Organizations: orgs, Count: len(orgs)}, nil
}
// Update overwrites an existing organization, preserving its storage identity
// (orm key, id, audit timestamps) and its original creation time.
func (h *OrganizationAPI) Update(ctx context.Context, in *UpdateOrganizationInput) (*schema.Organization, error) {
desired := in.Organization
if desired.Owner == "" || desired.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
existing, err := h.find(desired.Owner, desired.Name)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("organization not found")
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
model := existing.Model // orm key + pre-update snapshot for the diff hooks
created := existing.CreatedTime
*existing = desired
existing.Model = model
if existing.CreatedTime == "" {
existing.CreatedTime = created
}
if err := existing.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return existing.Mask(), nil
}
// Delete removes an organization. The built-in admin organization is protected.
func (h *OrganizationAPI) Delete(ctx context.Context, in *DeleteOrganizationInput) (*DeleteOrganizationOutput, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
if in.Name == "admin" {
return nil, zip.ErrForbidden("the built-in admin organization cannot be deleted")
}
existing, err := h.find(in.Owner, in.Name)
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("organization not found")
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := existing.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOrganizationOutput{Affected: true}, nil
}
// find resolves an organization by its (owner, name) natural key. The error is
// orm.ErrNotFound when no row matches.
func (h *OrganizationAPI) find(owner, name string) (*schema.Organization, error) {
return orm.TypedQuery[schema.Organization](h.DB).
Filter("Owner=", owner).
Filter("Name=", name).
First()
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package permission serves the IAM v2 permission CRUD surface as typed zip
// handlers over hanzoai/orm. Every permission is owner-scoped: its identity is
// the (owner, name) pair, stored under the orm key "owner/name". Reads are
// GET, writes are POST; the DB is captured on the handler receiver so each
// handler keeps the plain TypedHandler shape func(ctx, *In) (*Out, error).
package permission
import (
"context"
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// Handlers holds the storage handle shared by every permission handler.
type Handlers struct {
db orm.DB
}
// Route registers the permission routes on app, backed by db. It is called
// from routes.Mount once the store is open.
func Route(app *zip.App, db orm.DB) {
h := &Handlers{db: db}
zip.Get(app, "/v1/iam/permissions", h.List,
zip.WithSummary("List permissions for an owner"), zip.WithTags("permissions"))
zip.Post(app, "/v1/iam/permissions", h.Add,
zip.WithSummary("Create a permission"), zip.WithTags("permissions"))
zip.Get(app, "/v1/iam/permissions/get", h.Get,
zip.WithSummary("Get one permission by owner and name"), zip.WithTags("permissions"))
zip.Post(app, "/v1/iam/permissions/update", h.Update,
zip.WithSummary("Update a permission"), zip.WithTags("permissions"))
zip.Post(app, "/v1/iam/permissions/delete", h.Delete,
zip.WithSummary("Delete a permission"), zip.WithTags("permissions"))
}
// permissionID is the owner-scoped orm key: "owner/name".
func permissionID(owner, name string) string { return owner + "/" + name }
// ListRequest scopes a list to one owner (organization).
type ListRequest struct {
Owner string `json:"owner"`
}
// ListResponse is the owner's permissions, newest first.
type ListResponse struct {
Permissions []*schema.Permission `json:"permissions"`
}
// Ref identifies a single permission by its (owner, name) key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// DeleteResponse reports the outcome of a delete.
type DeleteResponse struct {
Deleted bool `json:"deleted"`
}
// List returns every permission owned by in.Owner, ordered newest first,
// mirroring v1 GetPermissions (Desc created_time).
func (h *Handlers) List(ctx context.Context, in *ListRequest) (*ListResponse, error) {
if in.Owner == "" {
return nil, zip.ErrBadRequest("owner is required")
}
items, err := orm.TypedQuery[schema.Permission](h.db).
Filter("Owner=", in.Owner).
Order("-CreatedTime").
GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListResponse{Permissions: items}, nil
}
// Get returns one permission by its (owner, name) key.
func (h *Handlers) Get(ctx context.Context, in *Ref) (*schema.Permission, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
p, err := orm.Get[schema.Permission](h.db, permissionID(in.Owner, in.Name))
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("permission not found")
}
return nil, zip.ErrInternal(err.Error())
}
return p, nil
}
// Add creates a permission under the (owner, name) key. It refuses to
// overwrite an existing grant — updates go through Update.
func (h *Handlers) Add(ctx context.Context, in *schema.Permission) (*schema.Permission, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
id := permissionID(in.Owner, in.Name)
if _, err := orm.Get[schema.Permission](h.db, id); err == nil {
return nil, zip.ErrConflict("permission already exists")
} else if !errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrInternal(err.Error())
}
in.Init(h.db)
in.SetId(id)
if err := in.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return in, nil
}
// Update replaces the mutable state of an existing permission, preserving its
// key and creation time (v1 AllCols update semantics).
func (h *Handlers) Update(ctx context.Context, in *schema.Permission) (*schema.Permission, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
existing, err := orm.Get[schema.Permission](h.db, permissionID(in.Owner, in.Name))
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("permission not found")
}
return nil, zip.ErrInternal(err.Error())
}
in.Init(h.db)
in.SetKey(existing.Key())
in.CreatedAt = existing.CreatedAt
if err := in.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return in, nil
}
// Delete removes a permission by its (owner, name) key.
func (h *Handlers) Delete(ctx context.Context, in *Ref) (*DeleteResponse, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
existing, err := orm.Get[schema.Permission](h.db, permissionID(in.Owner, in.Name))
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("permission not found")
}
return nil, zip.ErrInternal(err.Error())
}
if err := existing.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteResponse{Deleted: true}, nil
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package projects serves the IAM v2 CRUD surface for the `projects` entity: an
// organization-scoped work container owner-scoped by (owner, name), where Owner
// is the owning organization. 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 project by its (owner, name) key. This is
// the ONE project CRUD path — the Casdoor get-organization-projects / add-project
// / delete-project verb aliases (internal/compat) reuse it via New.
package projects
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// Handler binds the projects operations to one orm store.
type Handler struct {
db orm.DB
}
// Route registers the projects CRUD routes on app against db.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/projects", h.List, zip.WithSummary("List projects for an owner"), zip.WithTags("projects"))
zip.Post(app, "/v1/iam/projects", h.Create, zip.WithSummary("Create a project"), zip.WithTags("projects"))
zip.Post(app, "/v1/iam/projects/get", h.Get, zip.WithSummary("Get one project"), zip.WithTags("projects"))
zip.Post(app, "/v1/iam/projects/update", h.Update, zip.WithSummary("Update a project"), zip.WithTags("projects"))
zip.Post(app, "/v1/iam/projects/delete", h.Delete, zip.WithSummary("Delete a project"), zip.WithTags("projects"))
}
// New exposes a project Handler so the Casdoor add-/delete-project verb aliases
// (internal/compat) reuse the ONE project CRUD path, wrapped in the compat
// envelope.
func New(db orm.DB) *Handler { return &Handler{db: db} }
// Ref addresses one project by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// Input is the writable projection of a project (the v1 add/update-project 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"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Organization string `json:"organization"`
Tags []string `json:"tags"`
Metadata string `json:"metadata"`
IsDefault bool `json:"isDefault"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of projects.
type ListOutput struct {
Projects []*schema.Project `json:"projects"`
Total int `json:"total"`
}
// DeleteOutput reports the delete result.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
func key(owner, name string) string { return owner + "/" + name }
// apply copies the mutable domain fields of an Input onto a project. The identity
// fields (owner, name) and the created stamp are set only on Create, never
// overwritten by an update.
func apply(dst *schema.Project, in *Input) {
dst.DisplayName = in.DisplayName
dst.Description = in.Description
dst.Organization = pick(in.Organization, in.Owner)
dst.Tags = in.Tags
dst.Metadata = in.Metadata
dst.IsDefault = in.IsDefault
}
// List returns the projects for one owner, newest first. An empty owner lists
// every project (the unscoped admin view).
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
q := orm.TypedQuery[schema.Project](h.db)
if in.Owner != "" {
q = q.Filter("owner", in.Owner)
}
rows, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListOutput{Projects: rows, Total: len(rows)}, nil
}
// Get returns one project addressed by (owner, name).
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Project, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
p, err := orm.Get[schema.Project](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return p, nil
}
// Create persists a new project. It rejects a duplicate (owner, name).
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Project, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.Project](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("project already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
p := orm.New[schema.Project](h.db)
p.Owner = in.Owner
p.Name = in.Name
p.CreatedTime = in.CreatedTime
if p.CreatedTime == "" {
p.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
apply(p, in)
p.SetId(key(in.Owner, in.Name))
if err := p.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return p, nil
}
// Update mutates an existing project. Identity and created stamp are immutable; a
// missing project is a 404.
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Project, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
p, err := orm.Get[schema.Project](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
apply(p, in)
if err := p.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return p, nil
}
// Delete removes one project 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")
}
p, err := orm.Get[schema.Project](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := p.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("project not found")
}
return zip.ErrInternal(err.Error())
}
// pick returns a if non-empty, else b.
func pick(a, b string) string {
if a != "" {
return a
}
return b
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package projects_test
// Project tests driven through the REAL mounted router (routes.Route installs the
// Guard, the typed CRUD, and the Casdoor verb aliases). Every case is a wire
// request the console ScopeSwitcher / Projects page sends via the /org/iam proxy.
// The assertions pin the parity contract (add-project → get-organization-projects
// → delete-project) and the security ones: any org member may LIST its org's
// projects (the switcher is shown to everyone), a WRITE needs org-admin authority,
// and no request parameter can list another tenant's projects.
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/iam2/internal/routes"
"github.com/hanzoai/iam2/internal/schema"
)
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)
}
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(t.TempDir(), "projects.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
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: "projects-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, method, path, bearer, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, 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("%s %s: %v", method, path, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var m map[string]any
_ = json.Unmarshal(b, &m)
return resp.StatusCode, m
}
// names reads the project names out of a get-organization-projects envelope's data.
func names(m map[string]any) []string {
rows, _ := m["data"].([]any)
out := make([]string, 0, len(rows))
for _, r := range rows {
if o, ok := r.(map[string]any); ok {
if n, _ := o["name"].(string); n != "" {
out = append(out, n)
}
}
}
return out
}
func has(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
// TestProjects_lifecycle: an org-admin creates a project, any member lists it, the
// admin deletes it.
func TestProjects_lifecycle(t *testing.T) {
h := newHarness(t)
boss := h.token(t, "hanzo/boss")
alice := h.token(t, "hanzo/alice")
// add-project (org-admin).
body := `{"owner":"hanzo","name":"alpha","displayName":"Alpha","organization":"hanzo","description":"first"}`
if st, m := h.do(t, "POST", "/v1/iam/add-project", boss, body); st != 200 || m["status"] != "ok" {
t.Fatalf("add-project: status=%d body=%v", st, m)
}
// get-organization-projects — a REGULAR member sees it (the switcher is for all).
st, m := h.do(t, "GET", "/v1/iam/get-organization-projects?organization=hanzo", alice, "")
if st != 200 || m["status"] != "ok" {
t.Fatalf("list: status=%d body=%v", st, m)
}
if !has(names(m), "alpha") {
t.Fatalf("list missing 'alpha': %v", names(m))
}
// delete-project (org-admin), keyed by owner/name.
if st, m := h.do(t, "POST", "/v1/iam/delete-project", boss,
`{"owner":"hanzo","name":"alpha","organization":"hanzo"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("delete-project: status=%d body=%v", st, m)
}
_, m = h.do(t, "GET", "/v1/iam/get-organization-projects?organization=hanzo", alice, "")
if has(names(m), "alpha") {
t.Fatalf("'alpha' still listed after delete: %v", names(m))
}
}
// TestProjects_writeNeedsAdmin: a regular member can LIST but not create/delete.
func TestProjects_writeNeedsAdmin(t *testing.T) {
h := newHarness(t)
alice := h.token(t, "hanzo/alice") // regular
body := `{"owner":"hanzo","name":"beta","organization":"hanzo"}`
if st, _ := h.do(t, "POST", "/v1/iam/add-project", alice, body); st != 403 {
t.Fatalf("regular user add-project: status=%d, want 403", st)
}
if st, _ := h.do(t, "POST", "/v1/iam/delete-project", alice, body); st != 403 {
t.Fatalf("regular user delete-project: status=%d, want 403", st)
}
// But listing is allowed (200) — the switcher is shown to every user.
if st, m := h.do(t, "GET", "/v1/iam/get-organization-projects?organization=hanzo", alice, ""); st != 200 || m["status"] != "ok" {
t.Fatalf("regular user list: status=%d body=%v", st, m)
}
}
// TestProjects_crossTenantScoping: a non-super cannot list another tenant's
// projects — authz.Scope pins the read to the caller's own org regardless of the
// ?organization= it asks for.
func TestProjects_crossTenantScoping(t *testing.T) {
h := newHarness(t)
super := h.token(t, "admin/root")
alice := h.token(t, "hanzo/alice")
// super seeds a project under a DIFFERENT tenant, orgb.
if st, m := h.do(t, "POST", "/v1/iam/add-project", super,
`{"owner":"orgb","name":"secret","organization":"orgb"}`); st != 200 || m["status"] != "ok" {
t.Fatalf("super add orgb project: status=%d body=%v", st, m)
}
// alice (hanzo) asks for orgb's projects → gets HER org's scope, never orgb's.
_, m := h.do(t, "GET", "/v1/iam/get-organization-projects?organization=orgb", alice, "")
if has(names(m), "secret") {
t.Fatalf("VULN: hanzo user listed orgb's project 'secret': %v", names(m))
}
}
// ---- 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.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),
}))
}
+191
View File
@@ -0,0 +1,191 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package providers is the Phase-1 typed CRUD surface for the `providers`
// entity, owner-scoped by the (owner, name) natural key.
//
// The five operations are typed zip handlers over orm: reads are zip.Get,
// writes are zip.Post. zip decodes the request body into the In struct for
// every non-GET method (and, over the MCP projection, for GET too); the REST
// GET projection carries no body, so any op that needs the (owner, name) key
// from the caller is a POST. Each op is also an MCP tool and an OpenAPI 3.1
// operation from this one registration.
package providers
import (
"context"
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// providerId renders the (owner, name) pair as the orm row id so Get, Update,
// and Delete resolve by natural key without a secondary lookup.
func providerId(owner, name string) string { return owner + "/" + name }
// providerKey is the (owner, name) selector for get and delete.
type providerKey struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
}
// listProvidersIn scopes a list to one owner. An empty owner lists every
// provider (superuser view); a set owner filters to that tenant.
type listProvidersIn struct {
Owner string `json:"owner"`
}
type listProvidersOut struct {
Providers []*schema.Provider `json:"providers"`
}
type providerResult struct {
Provider *schema.Provider `json:"provider"`
}
// mutationResult mirrors v1's Affected/Unaffected action response and carries
// the resulting row on a successful write.
type mutationResult struct {
Affected bool `json:"affected"`
Provider *schema.Provider `json:"provider,omitempty"`
}
// Route registers the provider surface on app, closing over the entity store.
func Route(app *zip.App, db orm.DB) {
zip.Get[listProvidersIn, listProvidersOut](app, "/v1/iam/providers", listProviders(db),
zip.WithOperationID("listProviders"),
zip.WithSummary("List providers in an owner scope"),
zip.WithTags("providers"))
zip.Post[providerKey, providerResult](app, "/v1/iam/providers/get", getProvider(db),
zip.WithOperationID("getProvider"),
zip.WithSummary("Get one provider by (owner, name)"),
zip.WithTags("providers"))
zip.Post[schema.Provider, providerResult](app, "/v1/iam/providers", addProvider(db),
zip.WithOperationID("addProvider"),
zip.WithSummary("Create a provider"),
zip.WithTags("providers"))
zip.Post[schema.Provider, mutationResult](app, "/v1/iam/providers/update", updateProvider(db),
zip.WithOperationID("updateProvider"),
zip.WithSummary("Update an existing provider"),
zip.WithTags("providers"))
zip.Post[providerKey, mutationResult](app, "/v1/iam/providers/delete", deleteProvider(db),
zip.WithOperationID("deleteProvider"),
zip.WithSummary("Delete a provider by (owner, name)"),
zip.WithTags("providers"))
}
// Add / Update / Delete expose the write handlers so the Casdoor add-/update-/
// delete-provider verb aliases (internal/compat) reuse the ONE provider CRUD path,
// wrapped in the compat envelope. Delete decodes the posted provider body and keys
// the delete by its (owner, name) — the Casdoor verb posts the object, not a ?id=.
func Add(db orm.DB) zip.TypedHandler[schema.Provider, providerResult] { return addProvider(db) }
func Update(db orm.DB) zip.TypedHandler[schema.Provider, mutationResult] { return updateProvider(db) }
func Delete(db orm.DB) zip.TypedHandler[schema.Provider, mutationResult] {
del := deleteProvider(db)
return func(ctx context.Context, in *schema.Provider) (*mutationResult, error) {
return del(ctx, &providerKey{Owner: in.Owner, Name: in.Name})
}
}
// listProviders returns every provider in the owner scope, newest first.
func listProviders(db orm.DB) zip.TypedHandler[listProvidersIn, listProvidersOut] {
return func(ctx context.Context, in *listProvidersIn) (*listProvidersOut, error) {
q := orm.TypedQuery[schema.Provider](db)
if in.Owner != "" {
q = q.Filter("Owner=", in.Owner)
}
rows, err := q.Order("-CreatedTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
for i, p := range rows {
rows[i] = p.Mask() // never emit clientSecret in a list response
}
return &listProvidersOut{Providers: rows}, nil
}
}
// getProvider resolves one provider by its (owner, name) key.
func getProvider(db orm.DB) zip.TypedHandler[providerKey, providerResult] {
return func(_ context.Context, in *providerKey) (*providerResult, error) {
p, err := orm.Get[schema.Provider](db, providerId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("provider not found: " + providerId(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &providerResult{Provider: p.Mask()}, nil
}
}
// addProvider creates a provider from the request body, keyed by (owner, name).
func addProvider(db orm.DB) zip.TypedHandler[schema.Provider, providerResult] {
return func(ctx context.Context, in *schema.Provider) (*providerResult, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
// orm.New wires the store and applies defaults; copy the decoded domain
// fields over it, then restore the wired Model so its db handle and key
// survive the assignment.
p := orm.New[schema.Provider](db)
model := p.Model
*p = *in
p.Model = model
p.SetId(providerId(in.Owner, in.Name))
if err := p.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &providerResult{Provider: p.Mask()}, nil
}
}
// updateProvider read-modify-writes a provider in place. A missing row is
// reported as Unaffected (v1 UpdateProvider returns false), not an error.
func updateProvider(db orm.DB) zip.TypedHandler[schema.Provider, mutationResult] {
return func(ctx context.Context, in *schema.Provider) (*mutationResult, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
p, err := orm.Get[schema.Provider](db, providerId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return &mutationResult{Affected: false}, nil
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
// Overlay the decoded domain fields onto the loaded row, keeping the
// loaded Model (id, createdAt, key, snapshot) so the write targets the
// existing key and preserves creation metadata.
model := p.Model
*p = *in
p.Model = model
if err := p.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &mutationResult{Affected: true, Provider: p.Mask()}, nil
}
}
// deleteProvider removes a provider by key. A missing row is Unaffected.
func deleteProvider(db orm.DB) zip.TypedHandler[providerKey, mutationResult] {
return func(ctx context.Context, in *providerKey) (*mutationResult, error) {
p, err := orm.Get[schema.Provider](db, providerId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return &mutationResult{Affected: false}, nil
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := p.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &mutationResult{Affected: true}, nil
}
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package roles serves the IAM v2 CRUD surface for the `roles` entity: a named
// grant bundle 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 role by its (owner, name) key.
package roles
import (
"context"
"errors"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// Handler binds the roles operations to one orm store.
type Handler struct {
db orm.DB
}
// Route registers the roles CRUD routes on app against db.
func Route(app *zip.App, db orm.DB) {
h := &Handler{db: db}
zip.Get(app, "/v1/iam/roles", h.List, zip.WithSummary("List roles for an owner"), zip.WithTags("roles"))
zip.Post(app, "/v1/iam/roles", h.Create, zip.WithSummary("Create a role"), zip.WithTags("roles"))
zip.Post(app, "/v1/iam/roles/get", h.Get, zip.WithSummary("Get one role"), zip.WithTags("roles"))
zip.Post(app, "/v1/iam/roles/update", h.Update, zip.WithSummary("Update a role"), zip.WithTags("roles"))
zip.Post(app, "/v1/iam/roles/delete", h.Delete, zip.WithSummary("Delete a role"), zip.WithTags("roles"))
}
// Ref addresses one role by its owner-scoped natural key.
type Ref struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
// Input is the writable projection of a role (the v1 add/update-role 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"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Users []string `json:"users"`
Groups []string `json:"groups"`
Roles []string `json:"roles"`
Domains []string `json:"domains"`
IsEnabled bool `json:"isEnabled"`
}
// ListInput scopes a listing to one owner (organization).
type ListInput struct {
Owner string `json:"owner"`
}
// ListOutput is the owner-scoped page of roles.
type ListOutput struct {
Roles []*schema.Role `json:"roles"`
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.
// New exposes a role Handler so the Casdoor add-/update-/delete-role verb aliases
// (internal/compat) reuse the ONE role CRUD path, wrapped in the compat envelope.
func New(db orm.DB) *Handler { return &Handler{db: db} }
func key(owner, name string) string { return owner + "/" + name }
// apply copies the mutable domain fields of an Input onto a role. The identity
// fields (owner, name) and the created stamp are set only on Create, never
// overwritten by an update.
func apply(dst *schema.Role, in *Input) {
dst.DisplayName = in.DisplayName
dst.Description = in.Description
dst.Users = in.Users
dst.Groups = in.Groups
dst.Roles = in.Roles
dst.Domains = in.Domains
dst.IsEnabled = in.IsEnabled
}
// List returns the roles for one owner, newest first. An empty owner lists
// every role (the unscoped admin view).
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
q := orm.TypedQuery[schema.Role](h.db)
if in.Owner != "" {
q = q.Filter("owner", in.Owner)
}
roles, err := q.Order("-createdTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &ListOutput{Roles: roles, Total: len(roles)}, nil
}
// Get returns one role addressed by (owner, name).
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Role, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
role, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
return role, nil
}
// Create persists a new role. It rejects a duplicate (owner, name).
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Role, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
switch _, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name)); {
case err == nil:
return nil, zip.ErrConflict("role already exists")
case !errors.Is(err, orm.ErrNotFound):
return nil, zip.ErrInternal(err.Error())
}
role := orm.New[schema.Role](h.db)
role.Owner = in.Owner
role.Name = in.Name
role.CreatedTime = in.CreatedTime
if role.CreatedTime == "" {
role.CreatedTime = time.Now().UTC().Format(time.RFC3339)
}
apply(role, in)
role.SetId(key(in.Owner, in.Name))
if err := role.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return role, nil
}
// Update mutates an existing role's grant set. Identity and created stamp are
// immutable; a missing role is a 404.
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Role, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
role, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
apply(role, in)
if err := role.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return role, nil
}
// Delete removes one role 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")
}
role, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name))
if err != nil {
return nil, mapErr(err)
}
if err := role.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("role not found")
}
return zip.ErrInternal(err.Error())
}
+112 -12
View File
@@ -2,27 +2,127 @@
// Package routes mounts the IAM v2 HTTP surface on a zip App.
//
// Phase 0 serves only GET /v1/iam/v2/health. Resource handlers for users,
// organizations, applications, roles, permissions, and keys land in Phase 1
// as typed zip handlers (zip.Get[In,Out]); the OIDC/OAuth2 surface
// (/v1/iam/oauth/*, /v1/iam/.well-known/*) lands in Phase 2.
// Authentication is STRUCTURAL, decided by which group a route is registered on,
// never by a hand-maintained path list. Mount wires the surface in two phases
// around one authentication seam:
//
// The /v1/iam/v2 prefix keeps these routes orthogonal to the live v1 mount at
// /v1/iam/* during the transition; it collapses at Phase 5 cutover.
// - the PUBLIC group (oidc.Route + /healthz) is registered FIRST, before the
// Guard. A matched public route terminates fiber's middleware walk, so the
// Guard never runs on it — membership in this group IS "public".
// - app.Use(authz.Guard) is the ONE authentication seam. Every route registered
// AFTER it — the typed entity CRUD, the Casdoor verb aliases, the SCIM
// surface, and the framework's own /mcp + /openapi projections — requires a
// verified bearer.
//
// A public route therefore can never be accidentally gated, and an authed route
// can never be accidentally public: the decision is where you register, not an
// allow-list that can drift out of sync with the routes.
package routes
import "github.com/zap-proto/zip"
import (
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
// Mount registers every Phase-0 route on app.
func Mount(app *zip.App) {
app.Get("/v1/iam/v2/health", health)
"github.com/hanzoai/iam2/internal/applications"
"github.com/hanzoai/iam2/internal/auditlogs"
"github.com/hanzoai/iam2/internal/authz"
"github.com/hanzoai/iam2/internal/bootstrap"
"github.com/hanzoai/iam2/internal/certs"
"github.com/hanzoai/iam2/internal/compat"
"github.com/hanzoai/iam2/internal/invitations"
"github.com/hanzoai/iam2/internal/keys"
"github.com/hanzoai/iam2/internal/mfa"
"github.com/hanzoai/iam2/internal/oidc"
"github.com/hanzoai/iam2/internal/organizations"
"github.com/hanzoai/iam2/internal/permission"
"github.com/hanzoai/iam2/internal/projects"
"github.com/hanzoai/iam2/internal/providers"
"github.com/hanzoai/iam2/internal/roles"
"github.com/hanzoai/iam2/internal/scim"
"github.com/hanzoai/iam2/internal/sessions"
"github.com/hanzoai/iam2/internal/tokens"
"github.com/hanzoai/iam2/internal/users"
"github.com/hanzoai/iam2/internal/webauthn"
)
// Route registers the whole IAM v2 route surface on app, threading the entity
// store db into every handler. This is the route table server.Mount embeds — the
// one Mount(app, db) is the public entry; everything below is Route.
func Route(app *zip.App, db orm.DB) {
// AUTHORIZATION of writes: the op-invoke hook authorizes every typed op on the
// DECODED input the handler binds — for REST and MCP alike, so the value
// authorized is the value written. Writes to the reserved admin/built-in owners
// (the signing-cert poisoning gate) stay SuperAdmin-only. Installed once; it is
// order-independent (it fires inside each typed op, not as middleware).
app.Authorize(authz.Authorize)
// ─────────────────────────── PUBLIC ───────────────────────────
// The pre-authentication surface: OIDC discovery/JWKS + RFC 8414 AS metadata,
// the oauth/* protocol endpoints (authorize, token, userinfo, logout,
// introspect, revoke), credential login, the confidential-client key minters,
// and the front door (get-app-login, auth/methods, get-account, signup, signin,
// whoami, onboard, …). Registered on a root (empty-prefix) group BEFORE the
// Guard, at their absolute paths, so a matched public route terminates the
// middleware walk and the Guard never runs on it. There is no allow-list to
// keep in sync: a route is public because it is registered here.
public := app.Group("")
public.Get("/healthz", health)
oidc.Route(public, db)
// Operator bootstrap upsert (admin/{applications,users}/upsert) — self-authenticated
// by the unified service token (Bearer), not a user principal, so it is PUBLIC.
bootstrap.Route(public, db)
// ─────────────────────────── GUARD ────────────────────────────
// The ONE authentication seam. Every route registered AFTER it requires a
// verified bearer — the typed entity CRUD below, the Casdoor verb aliases, the
// SCIM surface, and the framework's own /mcp + /openapi projections (added at
// Prepare). The resolved Principal rides the request context for the write-authz
// hook above; reads are authorized here (their target rides the query string, or
// the handler scopes a path target itself). Fails closed (401).
app.Use(authz.Guard(db))
// ─────────────────────────── AUTHED ───────────────────────────
// Typed entity CRUD. Each registers its typed ops on app (the projection into
// REST + OpenAPI + MCP needs *App); registered after the Guard, so all are
// gated.
users.Route(app, db)
organizations.Route(app, db)
applications.Route(app, db)
providers.Route(app, db)
roles.Route(app, db)
projects.Route(app, db)
permission.Route(app, db)
certs.Route(app, db)
keys.Route(app, db)
webauthn.Route(app, db)
sessions.Route(app, db)
tokens.Route(app, db)
auditlogs.Route(app, db)
invitations.Route(app, db)
// Casdoor verb-alias layer: the get-users / get-organizations / add-organization
// / … spellings (in the v1 {status,data,data2} envelope) every live console/
// gateway/portal client hard-codes, served over the SAME store, redaction, and
// authz as the REST surface above — the transparent backend swap. After the
// Guard, so it shares the one Guard/Authorize seam.
compat.Route(app, db)
// SCIM 2.0 (RFC 7644/7643) — the STANDARD identity-provisioning surface that
// replaces the Casdoor entity verbs (HIP-0111). After the Guard, so it is
// authenticated; each handler owner-scopes via authz.Scope on the path target.
scim.Route(app, db)
// TOTP multi-factor enrollment (RFC 6238) — the account security page's
// initiate/verify/enable/disable flow. After the Guard: self-service on the
// caller's own user (authz.From); a cross-user reset needs admin (authz.Can).
mfa.Route(app, db)
}
// health is the Phase-0 liveness probe.
// health is the Phase-1 liveness probe.
func health(c *zip.Ctx) error {
return c.JSON(200, map[string]string{
"status": "ok",
"phase": "0",
"phase": "1",
"binary": "iam2",
})
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// This file carries the full Phase-1 field set for the `applications` entity
// (v1 Casdoor `application`). The kind is registered once, centrally, in
// schema.go's init(); nothing is registered here.
//
// Storage model: hanzoai/orm persists each Application as one JSON document in
// the shared _entities table (kind = "applications"), so v1 xorm column types
// (varchar/mediumtext/text/bool/int) carry no meaning and are dropped. Nested
// slices and structs live inline in that document — no serialize sibling is
// needed. The three v1 xorm:"-" members (OrganizationObj, CertPublicKey,
// CertObj) are read-time joins, never persisted; they are marked orm:"-" and
// omitempty so a write round-trips them as absent.
package schema
import "github.com/hanzoai/orm"
// SigninMethod is one enabled authentication method on an application
// (e.g. Password, Verification code, WebAuthn, Face ID) with its display
// label and applicability rule.
type SigninMethod struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Rule string `json:"rule"`
}
// SignupItem is one field rendered on the application's sign-up form, with
// its visibility, requirement, and validation rule.
type SignupItem struct {
Name string `json:"name"`
Visible bool `json:"visible"`
Required bool `json:"required"`
Prompted bool `json:"prompted"`
Type string `json:"type"`
CustomCss string `json:"customCss"`
Label string `json:"label"`
Placeholder string `json:"placeholder"`
Options []string `json:"options"`
Regex string `json:"regex"`
Rule string `json:"rule"`
}
// SigninItem is one element of the application's customizable sign-in page
// layout, carrying its per-element CSS and rule.
type SigninItem struct {
Name string `json:"name"`
Visible bool `json:"visible"`
Label string `json:"label"`
CustomCss string `json:"customCss"`
Placeholder string `json:"placeholder"`
Rule string `json:"rule"`
IsCustom bool `json:"isCustom"`
}
// SamlItem is one SAML assertion attribute mapping emitted for this
// application.
type SamlItem struct {
Name string `json:"name"`
NameFormat string `json:"nameFormat"`
Value string `json:"value"`
}
// JwtItem is one extra claim projected into issued access/ID tokens.
type JwtItem struct {
Name string `json:"name"`
Category string `json:"category"`
Value string `json:"value"`
Type string `json:"type"`
}
// ScopeItem is one OAuth2/OIDC scope the application may request, plus the
// MCP tool names that scope authorizes.
type ScopeItem struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Tools []string `json:"tools"`
}
// ProviderItem binds a federated identity Provider into an application and
// records how it may be used (sign-up/sign-in/unlink), its binding rule, and
// the resolved Provider on read.
type ProviderItem struct {
Owner string `json:"owner"`
Name string `json:"name"`
CanSignUp bool `json:"canSignUp"`
CanSignIn bool `json:"canSignIn"`
CanUnlink bool `json:"canUnlink"`
BindingRule *[]string `json:"bindingRule"`
CountryCodes []string `json:"countryCodes"`
Prompted bool `json:"prompted"`
SignupGroup string `json:"signupGroup"`
Rule string `json:"rule"`
Provider *Provider `json:"provider" orm:"-"`
}
// ScopeDescription documents one custom scope surfaced on the consent screen.
type ScopeDescription struct {
Scope string `json:"scope"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
// Application is an OAuth2/OIDC client and its hosted-login configuration
// (v1 Casdoor `application`). It is owner-scoped and uniquely named within its
// owner; the (Owner, Name) pair is the natural key, materialized as the orm id
// "<owner>/<name>". Every field below is field-complete with v1 so no auth
// configuration is lost across the cutover.
type Application struct {
orm.Model[Application]
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
DisplayName string `json:"displayName"`
Category string `json:"category"`
Type string `json:"type"`
Scopes []*ScopeItem `json:"scopes"`
Logo string `json:"logo"`
Title string `json:"title"`
Favicon string `json:"favicon"`
Order int `json:"order"`
HomepageUrl string `json:"homepageUrl"`
Description string `json:"description"`
Organization string `json:"organization"`
Cert string `json:"cert"`
DefaultGroup string `json:"defaultGroup"`
HeaderHtml string `json:"headerHtml"`
EnablePassword bool `json:"enablePassword"`
EnableSignUp bool `json:"enableSignUp"`
DisableSignin bool `json:"disableSignin"`
EnableSigninSession bool `json:"enableSigninSession"`
EnableAutoSignin bool `json:"enableAutoSignin"`
EnableCodeSignin bool `json:"enableCodeSignin"`
EnableExclusiveSignin bool `json:"enableExclusiveSignin"`
EnableSamlCompress bool `json:"enableSamlCompress"`
EnableSamlC14n10 bool `json:"enableSamlC14n10"`
EnableSamlPostBinding bool `json:"enableSamlPostBinding"`
DisableSamlAttributes bool `json:"disableSamlAttributes"`
EnableSamlAssertionSignature bool `json:"enableSamlAssertionSignature"`
UseEmailAsSamlNameId bool `json:"useEmailAsSamlNameId"`
EnableWebAuthn bool `json:"enableWebAuthn"`
EnableLinkWithEmail bool `json:"enableLinkWithEmail"`
OrgChoiceMode string `json:"orgChoiceMode"`
SamlReplyUrl string `json:"samlReplyUrl"`
Providers []*ProviderItem `json:"providers"`
SigninMethods []*SigninMethod `json:"signinMethods"`
SignupItems []*SignupItem `json:"signupItems"`
SigninItems []*SigninItem `json:"signinItems"`
GrantTypes []string `json:"grantTypes"`
OrganizationObj *Organization `json:"organizationObj,omitempty" orm:"-"`
CertPublicKey string `json:"certPublicKey,omitempty" orm:"-"`
Tags []string `json:"tags"`
SamlAttributes []*SamlItem `json:"samlAttributes"`
SamlHashAlgorithm string `json:"samlHashAlgorithm"`
IsShared bool `json:"isShared"`
IpRestriction string `json:"ipRestriction"`
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
ClientCert string `json:"clientCert"`
RedirectUris []string `json:"redirectUris"`
ForcedRedirectOrigin string `json:"forcedRedirectOrigin"`
TokenFormat string `json:"tokenFormat"`
TokenSigningMethod string `json:"tokenSigningMethod"`
TokenFields []string `json:"tokenFields"`
TokenAttributes []*JwtItem `json:"tokenAttributes"`
ExpireInHours float64 `json:"expireInHours"`
RefreshExpireInHours float64 `json:"refreshExpireInHours"`
CookieExpireInHours int64 `json:"cookieExpireInHours"`
SignupUrl string `json:"signupUrl"`
SigninUrl string `json:"signinUrl"`
ForgetUrl string `json:"forgetUrl"`
AffiliationUrl string `json:"affiliationUrl"`
IpWhitelist string `json:"ipWhitelist"`
TermsOfUse string `json:"termsOfUse"`
SignupHtml string `json:"signupHtml"`
SigninHtml string `json:"signinHtml"`
ThemeData *ThemeData `json:"themeData"`
FooterHtml string `json:"footerHtml"`
FormCss string `json:"formCss"`
FormCssMobile string `json:"formCssMobile"`
FormOffset int `json:"formOffset"`
FormSideHtml string `json:"formSideHtml"`
FormBackgroundUrl string `json:"formBackgroundUrl"`
FormBackgroundUrlMobile string `json:"formBackgroundUrlMobile"`
FailedSigninLimit int `json:"failedSigninLimit"`
FailedSigninFrozenTime int `json:"failedSigninFrozenTime"`
CodeResendTimeout int `json:"codeResendTimeout"`
CustomScopes []*ScopeDescription `json:"customScopes"`
Environment string `json:"environment"`
Project string `json:"project"`
Domain string `json:"domain"`
OtherDomains []string `json:"otherDomains"`
UpstreamHost string `json:"upstreamHost"`
SslMode string `json:"sslMode"`
SslCert string `json:"sslCert"`
CertObj *Cert `json:"certObj,omitempty" orm:"-"`
}
// GetId returns the owner-scoped natural key "<owner>/<name>", the value used
// as this entity's orm id.
func (a *Application) GetId() string {
return a.Owner + "/" + a.Name
}
// IsRedirectUriValid reports whether redirectUri is EXACTLY one of the
// application's registered redirect URIs (RFC 6749 3.1.2.3). Match is exact
// string equality only — never a host-suffix, substring, or regex match — so a
// trusted origin can never be leveraged to redeem another app's authorization
// code. New callbacks are added by registering the exact URI, nothing else.
func (a *Application) IsRedirectUriValid(redirectUri string) bool {
if redirectUri == "" {
return false
}
for _, registered := range a.RedirectUris {
if registered != "" && registered == redirectUri {
return true
}
}
return false
}
// IsPasswordEnabled reports whether password sign-in is available: the explicit
// EnablePassword flag when no per-method list is configured, otherwise the
// presence of a "Password" method in SigninMethods.
func (a *Application) IsPasswordEnabled() bool {
if len(a.SigninMethods) == 0 {
return a.EnablePassword
}
for _, m := range a.SigninMethods {
if m.Name == "Password" {
return true
}
}
return false
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// AuditLog is an append-only action record (v1 Casdoor `record`, v2 kind
// "audit_logs"). One row captures a single request against the IAM surface:
// who acted (Organization, User, ClientIp), what they invoked (Method,
// RequestUri, Action), the request payload and the server's answer (Object,
// Response, StatusCode), and whether the row fired its registered webhooks
// (IsTriggered). It is written once at request time and is not mutated in
// normal operation; the CRUD update path exists only for administrative
// correction.
//
// Identity is the (Owner, Name) pair — Name is a generated unique id and Owner
// is the acting organization — so the orm string key is "owner/name". v1's
// integer autoincrement primary key (`id`) is a per-store surrogate with no
// cross-store meaning; it is superseded by the orm string key rather than
// carried as a colliding `id` field, since orm.Model already persists its own
// `id`. Every semantically meaningful v1 column is carried so no actor,
// endpoint, payload, or status is lost on migration.
//
// Object and Response are unbounded text in v1 (mediumtext): Object holds the
// password-masked request body and Response a compact status/message envelope.
// Both carry no orm index. The audit query dimensions — Organization, User, and
// Action — are indexed alongside the (Owner, Name) key and the CreatedTime sort
// column.
type AuditLog struct {
orm.Model[AuditLog]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime" orm:"index"`
Organization string `json:"organization" orm:"index"`
ClientIp string `json:"clientIp"`
User string `json:"user" orm:"index"`
Method string `json:"method"`
RequestUri string `json:"requestUri"`
Action string `json:"action" orm:"index"`
Language string `json:"language"`
Object string `json:"object"`
Response string `json:"response"`
StatusCode int `json:"statusCode"`
IsTriggered bool `json:"isTriggered"`
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Cert is a signing / TLS certificate together with its key material (v1
// Casdoor `cert`, v2 kind "certs"). IAM signs the OIDC tokens it issues with a
// Cert's private key and publishes the certificate so relying parties can
// verify them; an SSL-type Cert instead fronts an ACME-issued domain
// certificate and tracks its renewal. CryptoAlgorithm, BitSize, and
// ExpireInYears drive key generation (RSA / ECDSA / RSA-PSS, and the
// post-quantum ML-DSA raw-key path); Provider, Account, AccessKey, and
// AccessSecret hold the ACME provider credentials used to obtain and renew SSL
// material. Field complete against the v1 row so no key, credential, or expiry
// stamp is lost on migration. Identity is the (Owner, Name) pair; the orm
// string key is "owner/name".
//
// CreatedTime is the RFC3339 creation stamp carried verbatim from v1, distinct
// from the orm-managed CreatedAt / UpdatedAt on the embedded Model. Certificate
// and PrivateKey hold PEM text for x509 certs and raw base64 key material for
// ML-DSA certs.
type Cert struct {
orm.Model[Cert]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime" orm:"index"`
DisplayName string `json:"displayName"`
Scope string `json:"scope"`
Type string `json:"type"`
CryptoAlgorithm string `json:"cryptoAlgorithm"`
BitSize int `json:"bitSize"`
ExpireInYears int `json:"expireInYears"`
ExpireTime string `json:"expireTime"`
DomainExpireTime string `json:"domainExpireTime"`
Provider string `json:"provider"`
Account string `json:"account"`
AccessKey string `json:"accessKey"`
AccessSecret string `json:"accessSecret"`
Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"`
}
// Mask returns a copy of the cert with its secret material removed — the one
// place a Cert is prepared to cross the API. The private key signs every token
// this IAM issues: it lives in the store, signs in process, and is never served.
// Relying parties read the PUBLIC half from the JWKS (RFC 7517), which is
// derived from Certificate. AccessSecret is the ACME/DNS provider credential and
// is secret for the same reason. Returns nil for a nil cert.
func (c *Cert) Mask() *Cert {
if c == nil {
return nil
}
m := *c
m.PrivateKey, m.AccessSecret = "", ""
return &m
}

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