Compare commits

...
52 Commits
Author SHA1 Message Date
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
122 changed files with 17384 additions and 218 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"
+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"
+39
View File
@@ -0,0 +1,39 @@
# 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.
COPY go.mod go.sum ./
RUN 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"]
+126 -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,136 @@ 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 |
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.
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")
}
}
+13 -14
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,17 @@ 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
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.1 // 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 +48,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
@@ -56,22 +64,13 @@ require (
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
)
+70 -19
View File
@@ -2,6 +2,8 @@ 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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -10,10 +12,12 @@ 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 +40,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 +50,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 +66,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 +84,9 @@ 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/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 +111,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=
+182
View File
@@ -0,0 +1,182 @@
// 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"`
}
// Mount 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 Mount(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).
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
}
// Mount registers the audit-log CRUD routes on app against db.
func Mount(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())
}
+440
View File
@@ -0,0 +1,440 @@
// 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 and FIRST via app.Use.
// Every non-public request must carry a verified bearer; the resolved
// Principal is attached to the request context for the authorization decision
// and audit. Public routes pass straight through. 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")
)
// publicPaths is the CLOSED set of routes reachable without a bearer — the
// pre-authentication OIDC/OAuth2 and front-door surface a browser must reach
// before it can hold a token. Everything not listed here is gated: the default
// is fail-closed, so a newly mounted route (including the framework's own /mcp
// and /openapi projections of the typed handlers) is protected until it is
// deliberately published here. userinfo and logout are listed because they
// verify their own bearer (userinfo) or must clear a session without a live one
// (logout); gating them again would break their own OIDC contract.
var publicPaths = map[string]bool{
"/healthz": true, // liveness, unversioned
"/.well-known/openid-configuration": true, // OIDC discovery (root)
"/v1/iam/.well-known/openid-configuration": true, // OIDC discovery (v1)
"/.well-known/jwks": true, // JWKS public keys (root)
"/v1/iam/.well-known/jwks": true, // JWKS public keys (v1)
"/.well-known/oauth-authorization-server": true, // RFC 8414 AS metadata (root)
"/v1/iam/.well-known/oauth-authorization-server": true, // RFC 8414 AS metadata (v1)
"/v1/iam/oauth/introspect": true, // RFC 7662 (client-authenticated)
"/v1/iam/oauth/revoke": true, // RFC 7009 (client-authenticated)
"/v1/iam/login": true, // credential login, mints the code
"/v1/iam/oauth/authorize": true, // OAuth2 authorize
"/v1/iam/oauth/token": true, // OAuth2 token
"/v1/iam/oauth/userinfo": true, // self-verifying bearer read
"/v1/iam/oauth/logout": true, // end session
"/v1/iam/get-app-login": true, // pre-login app config (secrets masked)
"/v1/iam/auth/methods": true, // pre-login method list
"/v1/iam/mint-user-keys": true, // confidential-client auth (Basic + allow-list), not a bearer
"/v1/iam/revoke-user-keys": true, // confidential-client auth (same authorizeMinter seam)
// The front-door session/identity surface (oidc.MountFrontDoor). Each handler
// RESOLVES the caller itself (callerOf: session cookie first, then bearer) and
// SELF-SCOPES to that caller — so, like get-account, they are reachable without a
// Guard-verified bearer (the portal + gateway admin-guard call them with a session
// cookie) yet never act on anyone but the resolved caller. signup and
// send-verification-code are pre-authentication by nature (no token exists yet).
"/v1/iam/get-account": true, // anonymous-safe account read (admin-guard contract)
"/v1/iam/signup": true, // pre-auth account creation (own policy checks)
"/v1/iam/send-verification-code": true, // pre-auth OTP send
"/v1/iam/signin": true, // code→session exchange (the code is the credential)
"/v1/iam/whoami": true, // lightweight caller identity (self-resolving)
"/v1/iam/onboard": true, // first-run org onboarding (self-move only)
"/v1/iam/update-preferences": true, // self preferences (writes only the caller's row)
"/v1/iam/linked-accounts": true, // the caller's own linked identities
}
// isPublic reports whether path is in the public allowlist. A trailing slash is
// trimmed first so /v1/iam/login/ resolves like /v1/iam/login — the same route
// fiber serves. It can only ever widen matches to the fixed public set, never
// turn a gated path into a public one (no gated path equals a public path plus a
// slash), so the fail-closed default holds.
func isPublic(path string) bool {
if len(path) > 1 {
path = strings.TrimRight(path, "/")
}
return publicPaths[path]
}
// 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.
var handlerAuthorizedPrefixes = []string{"/v1/iam/scim/"}
// 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 ONCE and FIRST, via app.Use,
// so it wraps every route — the typed CRUD handlers and the framework's /mcp and
// /openapi surfaces alike. Public routes pass straight through; every other route
// 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 {
if isPublic(c.Path()) {
return c.Continue()
}
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().
func Authorize(ctx context.Context, op zip.Op, in any) error {
if isPublic(op.Path) {
return nil // pre-auth surface; the Guard admitted it without a principal
}
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
}
+449
View File
@@ -0,0 +1,449 @@
// 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"},
{"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"},
}
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")
}
}
+329
View File
@@ -0,0 +1,329 @@
// 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 first). 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.Mount(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"}
}
+119
View File
@@ -0,0 +1,119 @@
// 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")
}
}
func TestIsPublicIsAClosedAllowlist(t *testing.T) {
for _, p := range []string{
"/healthz",
"/.well-known/openid-configuration",
"/v1/iam/.well-known/jwks",
"/v1/iam/login",
"/v1/iam/login/", // trailing slash normalizes to the same public route
"/v1/iam/oauth/token",
"/v1/iam/oauth/userinfo",
} {
if !isPublic(p) {
t.Errorf("%q should be public", p)
}
}
// Everything else is gated by default — including the CRUD, the framework
// side doors, and near-misses on the public paths.
for _, p := range []string{
"/v1/iam/users",
"/v1/iam/certs",
"/v1/iam/certs/update",
"/mcp",
"/.well-known/openapi.json",
"/v1/iam/oauth/tokens", // near-miss, not the token endpoint
"/v1/iam/login/../certs", // pre-normalization junk is never public
"",
} {
if isPublic(p) {
t.Errorf("%q must NOT be public (fail-closed default)", p)
}
}
}
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)
}
}
})
}
+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
}
// Mount 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 Mount(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())
}
+174
View File
@@ -0,0 +1,174 @@
// 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"
)
// Mount 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 Mount(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))
// The Casdoor WRITE verbs (companion file), over the same store + authz seam.
mountWrites(app, db)
}
// 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
}
+369
View File
@@ -0,0 +1,369 @@
// 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 first, then compat.Mount). 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.Mount(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 (not in the public allowlist).
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),
}))
}
+83
View File
@@ -0,0 +1,83 @@
// 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/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.
// mountWrites registers the Casdoor write-verb aliases on app. Called from Mount
// (aliases.go) so reads and writes share the one Guard/Authorize seam.
func mountWrites(app *zip.App, db orm.DB) {
orgs := organizations.NewOrganizationAPI(db)
usersAPI := users.New(db)
appUpdate := applications.Update(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"))
}
// 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
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package compat_test
// End-to-end tests for the Casdoor WRITE verbs + the front-door publicPaths fix,
// driven through the REAL mounted router (routes.Mount installs the authz Guard +
// Authorize seam). 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 not in the
// public allowlist).
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 PUBLIC in the Guard: reachable WITHOUT a bearer
// (the portal + gateway admin-guard call them with a session cookie). Before this
// change they 401'd at the Guard through routes.Mount; now 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)
}
}
+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")
}
}
+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
}
// Mount registers the invitations CRUD routes on app against db.
func Mount(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"
)
// Mount registers the key CRUD routes on app, binding each handler to db.
// Called from routes.Mount once it is threaded the entity store.
func Mount(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[:]))
}
+175
View File
@@ -0,0 +1,175 @@
// 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
}
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")
}
// 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"),
}
}
// 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
}
+166
View File
@@ -0,0 +1,166 @@
// 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 MountLogin; the OIDC/OAuth surface is Mount.
const (
PathGetAppLogin = "/v1/iam/get-app-login"
PathAuthMethods = "/v1/iam/auth/methods"
)
// MountFrontDoor registers the front-door endpoints the hosted hanzo.id portal
// and the @hanzo/iam SDK call. Separate from Mount because these need the entity
// store; the OIDC discovery/JWKS surface does not.
func MountFrontDoor(app *zip.App, db orm.DB) {
app.Get(PathGetAppLogin, getAppLogin(db))
app.Get(PathAuthMethods, authMethods(db))
// get-account is anonymous-safe (returns {status:"error"} unauthenticated)
// and a security contract — the gateway admin-guard reads its `owner`.
app.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.
app.Post(PathSignup, signupHandler(db))
app.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). Each resolves + self-scopes
// the caller (callerOf), so all are public in the Guard like get-account.
app.Post(PathSignin, signinHandler(db))
app.Get(PathWhoami, whoamiHandler(db))
app.Post(PathOnboard, onboardHandler(db))
app.Post(PathUpdatePreferences, updatePreferencesHandler(db))
app.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")
}
}
+168
View File
@@ -0,0 +1,168 @@
// 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})
Mount(app, 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}))
}
+161
View File
@@ -0,0 +1,161 @@
// 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"
)
// MountIntrospectRevoke registers the introspection + revocation endpoints.
func MountIntrospectRevoke(app *zip.App, db orm.DB) {
app.Post(PathIntrospect, introspectHandler(db))
app.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)
}
}
+259
View File
@@ -0,0 +1,259 @@
// 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 `hk-` Cloud API-key primitives. A trusted, allow-listed
// backend (the console BFF as `hanzo-console`) authenticates as the confidential
// CLIENT — not an end-user bearer — and (re)generates or revokes a `?id=<owner>/
// <name>` target user's durable `hk-` key. (The on-behalf-of TOKEN minting that
// used to live here — `issue-user-token` — is retired in favor of the standard
// RFC 8693 Token Exchange grant on /oauth/token, per HIP-0111; it reuses the same
// authorizeMinter allow-list + reserved-org gate + SignUserToken defined here.)
//
// API keys are a PRODUCT credential with no IETF standard, so they stay a first-
// party primitive (flagged for a product decision on whether they become long-
// lived tokens). They are NOT Bearer-gated (authz.Guard lists them public); each
// does its own tighter authentication through the ONE authorizeMinter seam.
const (
PathMintUserKeys = "/v1/iam/mint-user-keys"
PathRevokeUserKeys = "/v1/iam/revoke-user-keys"
)
// MountIssueToken registers the confidential-client API-key primitives. POST-only:
// they rotate a credential — never over a cacheable GET (a client_secret in a
// query string would reach logs/proxies).
func MountIssueToken(app *zip.App, db orm.DB) {
app.Post(PathMintUserKeys, mintUserKeysHandler(db))
app.Post(PathRevokeUserKeys, revokeUserKeysHandler(db))
}
// 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")
}
}
+120
View File
@@ -0,0 +1,120 @@
// 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
}
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"`
}
// MountLogin registers POST /v1/iam/login.
func MountLogin(app *zip.App, db orm.DB) {
app.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
}
+112
View File
@@ -0,0 +1,112 @@
// 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)
)
// Mount registers the entire OIDC/OAuth2 surface on app, 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.
func Mount(app *zip.App, 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)
app.Get(PathDiscovery, Discovery)
app.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.
app.Get(PathASMetadata, Discovery)
app.Get(PathASMetadataV1, Discovery)
app.Get(PathJWKS, jwks)
app.Get(PathJWKSRoot, jwks)
// OAuth2 / OIDC protocol endpoints.
app.Get(PathAuthorize, authorizeHandler(db))
app.Post(PathAuthorize, authorizeHandler(db))
app.Get(PathUserInfo, userinfoHandler(db))
app.Post(PathUserInfo, userinfoHandler(db))
app.Get(PathLogout, logoutHandler(db))
app.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.
MountToken(app, db)
MountLogin(app, db)
MountFrontDoor(app, db)
// RFC 7662 introspection + RFC 7009 revocation — the standard token-management
// endpoints a resource server / confidential client uses (client-authenticated).
MountIntrospectRevoke(app, 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.
MountIssueToken(app, 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"`
}
// MountToken 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 MountToken(app *zip.App, db orm.DB) {
app.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"
// Mount registers the organization CRUD surface on app, backed by db.
func Mount(app *zip.App, db orm.DB) {
NewOrganizationAPI(db).mount(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) mount(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
}
// Mount registers the permission routes on app, backed by db. It is called
// from routes.Mount once the store is open.
func Mount(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
}
+178
View File
@@ -0,0 +1,178 @@
// 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"`
}
// Mount registers the provider surface on app, closing over the entity store.
func Mount(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"))
}
// 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
}
}
+180
View File
@@ -0,0 +1,180 @@
// 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
}
// Mount registers the roles CRUD routes on app against db.
func Mount(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.
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())
}
+82 -12
View File
@@ -2,27 +2,97 @@
// 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.
// Phase 1 serves GET /healthz plus the typed CRUD surface for all
// thirteen identity entities (users, organizations, applications, providers,
// roles, permissions, certs, keys, webauthn credentials, sessions, tokens,
// audit logs, invitations). Each entity owns its own package under internal/;
// every package exposes one uniform entry point — Mount(app, db) — so this
// file is the single place the whole resource surface is wired.
//
// 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 OIDC/OAuth2 surface (/v1/iam/oauth/*, /v1/iam/.well-known/*) lands in
// Phase 2. The /v1/iam prefix keeps these routes orthogonal to the live v1
// mount at /v1/iam/* during the transition; it collapses at Phase 5 cutover.
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/certs"
"github.com/hanzoai/iam2/internal/compat"
"github.com/hanzoai/iam2/internal/invitations"
"github.com/hanzoai/iam2/internal/keys"
"github.com/hanzoai/iam2/internal/oidc"
"github.com/hanzoai/iam2/internal/organizations"
"github.com/hanzoai/iam2/internal/permission"
"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"
)
// Mount registers every Phase-1 route on app, threading the entity store db
// into each entity's typed CRUD handlers.
func Mount(app *zip.App, db orm.DB) {
// Phase 3 — the authorization seam, in two orthogonal halves (see internal/authz):
// - Guard (app.Use) AUTHENTICATES every request first: public OIDC/front-door
// routes pass; every other route needs a verified bearer, and the resolved
// Principal is attached to the context. It also authorizes reads, whose
// target rides in the query string.
// - Authorize (app.Authorize) AUTHORIZES writes at the framework's op-invoke
// seam, 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.
app.Use(authz.Guard(db))
app.Authorize(authz.Authorize)
app.Get("/healthz", health)
// Phase 2 — the full OIDC/OAuth2 surface at the canonical /v1/iam/* paths
// (discovery, JWKS, authorize, token, userinfo, logout) plus the front door
// (get-app-login, auth/methods, login) the @hanzo/iam <Login> self-configures
// from. One entry point wires the whole identity core.
oidc.Mount(app, db)
users.Mount(app, db)
organizations.Mount(app, db)
applications.Mount(app, db)
providers.Mount(app, db)
roles.Mount(app, db)
permission.Mount(app, db)
certs.Mount(app, db)
keys.Mount(app, db)
webauthn.Mount(app, db)
sessions.Mount(app, db)
tokens.Mount(app, db)
auditlogs.Mount(app, db)
invitations.Mount(app, db)
// Casdoor verb-alias layer: the get-users / get-organizations / … 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. This is what makes the backend swap transparent — no
// client changes at cutover. Mounted after the entity CRUD so both share the
// one Guard/Authorize seam wired at the top.
compat.Mount(app, db)
// SCIM 2.0 (RFC 7644/7643) — the STANDARD identity-provisioning surface that
// replaces the Casdoor entity verbs (HIP-0111). Authenticated by the Guard;
// each handler owner-scopes via authz.Scope on the path target.
scim.Mount(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
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Invitation is a pending organization-membership invite (v1 Casdoor
// `invitation`, v2 kind "invitations"). One row grants a bounded number of
// signups against a shared or per-recipient Code: the code is a literal, or a
// pattern when IsRegexp is set, and each successful signup increments UsedCount
// up to Quota. The optional Application, Username, Email, and Phone pins
// constrain who may redeem it; SignupGroup places a redeemer into a group on
// join. State ("Active" vs. suspended) gates redemption and DefaultCode is the
// fallback code surfaced in the signup link. Field-complete against the v1 row
// so no code, quota, or recipient pin is lost on migration. Identity is the
// (Owner, Name) pair; the orm string key is "owner/name".
type Invitation struct {
orm.Model[Invitation]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime" orm:"index"`
UpdatedTime string `json:"updatedTime"`
DisplayName string `json:"displayName"`
Code string `json:"code" orm:"index"`
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"`
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Key is an API access credential (v1 Casdoor `key`, v2 kind "keys").
//
// A Key is owner-scoped: Owner names the tenant it belongs to and Name is
// unique within that Owner, so the (Owner, Name) pair is its natural key — the
// same identity the v1 record addressed as "owner/name".
//
// The credential itself is two independent halves. AccessKey (pk-*) is the
// publishable half — frontend-safe, read-only — and is the hot lookup index.
// AccessSecret (sk-*) is the confidential half — backend-only, full access.
// Neither half is derivable from the other.
type Key struct {
orm.Model[Key]
// Owner is the tenant that holds the key; Name is unique within Owner.
Owner string `json:"owner"`
Name string `json:"name"`
// CreatedTime and UpdatedTime are RFC3339 audit stamps carried as strings
// for byte-parity with the v1 row (orm.Model separately tracks CreatedAt /
// UpdatedAt as time.Time for the store's own lifecycle).
CreatedTime string `json:"createdTime"`
UpdatedTime string `json:"updatedTime"`
// DisplayName is the human-facing label.
DisplayName string `json:"displayName"`
// Type is the scope the key is bound to — "Organization", "Application",
// "User", or "General" — and Organization / Application / User name the
// concrete principal for whichever scope Type selects.
Type string `json:"type"`
Organization string `json:"organization"`
Application string `json:"application"`
User string `json:"user"`
// AccessKey (pk-*) is the publishable identifier and lookup index;
// AccessSecret (sk-*) is the confidential secret.
AccessKey string `json:"accessKey" orm:"index"`
AccessSecret string `json:"accessSecret"`
// ExpireTime is when the key stops being honored (empty = never). State is
// the lifecycle flag ("Active", "test", …); "test" mints test-env
// credentials instead of live ones.
ExpireTime string `json:"expireTime"`
State string `json:"state"`
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
// Redaction is a property of the value, not of any handler: an entity knows its
// own secrets. Every read path — the entity CRUD, the Casdoor compat aliases,
// get-account — returns `x.Mask()`, never `x`, so no digest, client secret, or
// bearer material ever leaves the service. Masking is the ONE way secrets are
// stripped; there is no second copy of this logic in any handler package.
//
// Mask returns a masked COPY and never mutates the receiver. orm reads unmarshal
// into fresh instances (orm ModelQuery.First: "a new instance"), so the receiver
// is already private to the caller — but returning a copy keeps Mask a pure
// function of the value, safe to call on any entity a caller intends to keep
// using. Only fields with a real json tag can reach a response; nested secret
// fields tagged `json:"-"` are never serialized (and, since orm persists via
// json.Marshal, never even stored), so a shallow copy with the top-level secrets
// blanked is sufficient and leak-free.
//
// Cert.Mask lives beside the Cert type in cert.go (the original of this pattern);
// the other four entities that carry secrets are gathered here.
// Mask returns a copy of u with every response-serialized credential and bearer
// field blanked. Mirrors v1's read-path redaction (object/user.go), fail-safe.
func (u *User) Mask() *User {
if u == nil {
return nil
}
m := *u
m.PasswordHash, m.PasswordSalt = "", ""
m.AccessSecret, m.AccessSecretHash, m.AccessToken = "", "", ""
m.OriginalToken, m.OriginalRefreshToken = "", ""
m.TotpSecret, m.RecoveryCodes = "", nil
m.VerificationCode = "" // a live one-time code — as secret as the TOTP seed
return &m
}
// Mask returns a copy of o with every secret blanked to the "***" sentinel v1
// uses (object/organization.go GetMaskedOrganization) — "***" signals "a value
// is set but hidden", distinct from "" ("no value"), which some UIs rely on.
func (o *Organization) Mask() *Organization {
if o == nil {
return nil
}
m := *o
for _, secret := range []*string{
&m.MasterPassword,
&m.DefaultPassword,
&m.MasterVerificationCode,
&m.PasswordSalt,
&m.PasswordObfuscatorKey,
&m.KerberosKeytab,
} {
if *secret != "" {
*secret = "***"
}
}
return &m
}
// Mask returns a copy of a with the OAuth client secret blanked and every
// in-memory join (orm:"-", but carrying real json tags) masked THROUGH — an
// enriched read (get-app-login populates Providers[].Provider via
// store.EnrichProviders; other paths attach CertObj/OrganizationObj) otherwise
// carries a linked entity's own secret (a provider's clientSecret, a cert's
// private key, an org's master password) straight past the top-level mask. Each
// join is copied before it is masked so the receiver's shared slice/pointer is
// never mutated.
func (a *Application) Mask() *Application {
if a == nil {
return nil
}
m := *a
m.ClientSecret = ""
if m.CertObj != nil {
m.CertObj = m.CertObj.Mask()
}
if m.OrganizationObj != nil {
m.OrganizationObj = m.OrganizationObj.Mask()
}
if len(m.Providers) > 0 {
// The shallow copy shares the []*ProviderItem backing array with the
// receiver; rebuild it with masked copies so blanking the nested provider
// secret cannot reach back into the original row.
items := make([]*ProviderItem, len(m.Providers))
for i, it := range m.Providers {
if it == nil {
continue
}
clone := *it
clone.Provider = it.Provider.Mask() // nil-safe; blanks clientSecret(2)
items[i] = &clone
}
m.Providers = items
}
return &m
}
// Mask returns a copy of p with both OAuth client secrets blanked — the fields
// v1 masks (object/provider.go GetMaskedProvider). Content is left intact to
// match v1 (it holds public config/metadata for the provider types that use it).
func (p *Provider) Mask() *Provider {
if p == nil {
return nil
}
m := *p
m.ClientSecret, m.ClientSecret2 = "", ""
return &m
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "testing"
// The Mask methods are the ONE redaction contract for every read path (entity
// CRUD, compat aliases, get-account). These tests are the security assertion:
// (1) every secret field is stripped from the returned value, and (2) the
// RECEIVER is never mutated — Mask returns a copy, so masking a row for a
// response can never blank the secret in a row another caller (the login verify
// path) still holds.
func TestUserMask_stripsEverySecret_andCopiesReceiver(t *testing.T) {
u := &User{
PasswordHash: "$argon2id$v=19$m=65536,t=1,p=2$abc$def",
PasswordSalt: "salt",
PasswordType: "argon2id",
AccessSecret: "acc-secret",
AccessSecretHash: "acc-secret-hash",
AccessToken: "acc-token",
OriginalToken: "orig-token",
OriginalRefreshToken: "orig-refresh",
TotpSecret: "totp",
RecoveryCodes: []string{"r1", "r2"},
VerificationCode: "123456",
}
u.Owner, u.Name = "acme", "bob"
u.Email = "bob@acme.test"
m := u.Mask()
// (1) no secret survives on the masked copy.
for label, got := range map[string]string{
"PasswordHash": m.PasswordHash,
"PasswordSalt": m.PasswordSalt,
"AccessSecret": m.AccessSecret,
"AccessSecretHash": m.AccessSecretHash,
"AccessToken": m.AccessToken,
"OriginalToken": m.OriginalToken,
"OriginalRefreshToken": m.OriginalRefreshToken,
"TotpSecret": m.TotpSecret,
"VerificationCode": m.VerificationCode,
} {
if got != "" {
t.Errorf("User.Mask left %s = %q, want empty", label, got)
}
}
if m.RecoveryCodes != nil {
t.Errorf("User.Mask left RecoveryCodes = %v, want nil", m.RecoveryCodes)
}
// non-secret identity is preserved for the UI.
if m.Owner != "acme" || m.Name != "bob" || m.Email != "bob@acme.test" {
t.Errorf("User.Mask dropped identity: owner=%q name=%q email=%q", m.Owner, m.Name, m.Email)
}
// (2) the receiver still carries its secret — Mask copied, never mutated.
if u.PasswordHash == "" || u.AccessToken == "" || u.RecoveryCodes == nil {
t.Fatal("User.Mask MUTATED the receiver — a response mask would blank the live login row")
}
}
func TestOrganizationMask_sentinelsSecrets_andCopiesReceiver(t *testing.T) {
o := &Organization{
PasswordSalt: "salt",
PasswordObfuscatorKey: "obf-key",
MasterPassword: "master-pw",
DefaultPassword: "default-pw",
MasterVerificationCode: "mvc",
KerberosKeytab: "keytab",
}
o.Owner, o.Name = "admin", "acme"
m := o.Mask()
// v1 uses the "***" sentinel (a set-but-hidden marker), not "".
for label, got := range map[string]string{
"PasswordSalt": m.PasswordSalt,
"PasswordObfuscatorKey": m.PasswordObfuscatorKey,
"MasterPassword": m.MasterPassword,
"DefaultPassword": m.DefaultPassword,
"MasterVerificationCode": m.MasterVerificationCode,
"KerberosKeytab": m.KerberosKeytab,
} {
if got != "***" {
t.Errorf("Organization.Mask left %s = %q, want \"***\"", label, got)
}
}
if m.Name != "acme" {
t.Errorf("Organization.Mask dropped name: %q", m.Name)
}
if o.MasterPassword != "master-pw" {
t.Fatal("Organization.Mask MUTATED the receiver")
}
}
func TestApplicationMask_stripsClientSecret_andEveryEnrichedJoin(t *testing.T) {
a := &Application{
ClientSecret: "app-client-secret",
ClientId: "acme-console",
CertObj: &Cert{PrivateKey: "-----BEGIN PRIVATE KEY-----", AccessSecret: "acme-dns"},
OrganizationObj: &Organization{MasterPassword: "org-master-pw"},
Providers: []*ProviderItem{{
Name: "provider-github",
Provider: &Provider{ClientSecret: "prov-cs", ClientSecret2: "prov-cs2"},
}},
}
a.Owner, a.Name = "acme", "console"
m := a.Mask()
if m.ClientSecret != "" {
t.Errorf("Application.Mask left ClientSecret = %q", m.ClientSecret)
}
if m.ClientId != "acme-console" {
t.Errorf("Application.Mask dropped ClientId: %q", m.ClientId)
}
// Every in-memory join carries its own secret; all must be masked through.
if m.CertObj == nil || m.CertObj.PrivateKey != "" || m.CertObj.AccessSecret != "" {
t.Errorf("Application.Mask left a secret in the nested CertObj: %+v", m.CertObj)
}
if m.OrganizationObj == nil || m.OrganizationObj.MasterPassword != "***" {
t.Errorf("Application.Mask left a secret in OrganizationObj: %+v", m.OrganizationObj)
}
if m.Providers[0].Provider == nil ||
m.Providers[0].Provider.ClientSecret != "" || m.Providers[0].Provider.ClientSecret2 != "" {
t.Errorf("Application.Mask left a secret in Providers[].Provider: %+v", m.Providers[0].Provider)
}
// The receiver — and its SHARED ProviderItem/join backing — must be untouched.
if a.ClientSecret != "app-client-secret" || a.CertObj.PrivateKey == "" {
t.Fatal("Application.Mask MUTATED the receiver (or its shared CertObj)")
}
if a.OrganizationObj.MasterPassword != "org-master-pw" {
t.Fatal("Application.Mask MUTATED the receiver's OrganizationObj")
}
if a.Providers[0].Provider.ClientSecret != "prov-cs" {
t.Fatal("Application.Mask MUTATED the receiver's shared Providers[].Provider")
}
}
func TestProviderMask_stripsBothClientSecrets(t *testing.T) {
p := &Provider{ClientSecret: "cs1", ClientSecret2: "cs2", Type: "GitHub"}
p.Owner, p.Name = "admin", "provider-github"
m := p.Mask()
if m.ClientSecret != "" || m.ClientSecret2 != "" {
t.Errorf("Provider.Mask left a secret: cs=%q cs2=%q", m.ClientSecret, m.ClientSecret2)
}
if m.Type != "GitHub" {
t.Errorf("Provider.Mask dropped Type: %q", m.Type)
}
if p.ClientSecret != "cs1" {
t.Fatal("Provider.Mask MUTATED the receiver")
}
}
func TestMask_nilReceiverIsNil(t *testing.T) {
var u *User
var o *Organization
var a *Application
var p *Provider
if u.Mask() != nil || o.Mask() != nil || a.Mask() != nil || p.Mask() != nil {
t.Fatal("Mask on a nil receiver must return nil")
}
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// AccountItem is one self-service profile field an organization exposes to its
// members, together with the rules that govern who may view or change it.
type AccountItem struct {
Name string `json:"name" orm:"varchar(255)"`
Visible bool `json:"visible" orm:"bool"`
ViewRule string `json:"viewRule" orm:"varchar(255)"`
ModifyRule string `json:"modifyRule" orm:"varchar(255)"`
Regex string `json:"regex" orm:"varchar(255)"`
Tab string `json:"tab" orm:"varchar(255)"`
}
// ThemeData is an organization's default UI theme, inherited by its
// applications unless they override it. It is shared with Application, which
// carries the same shape as its per-app theme override.
type ThemeData struct {
ThemeType string `json:"themeType" orm:"varchar(30)"`
ColorPrimary string `json:"colorPrimary" orm:"varchar(10)"`
BorderRadius int `json:"borderRadius" orm:"int"`
IsCompact bool `json:"isCompact" orm:"bool"`
IsEnabled bool `json:"isEnabled" orm:"bool"`
}
// Organization is a tenant boundary: the top-level owner scope every other IAM
// entity is filed under. Its natural key is the (Owner, Name) pair; orm carries
// the surrogate id, audit timestamps, and soft-delete flag on the embedded
// Model, while CreatedTime preserves the v1 display timestamp verbatim.
//
// Every field below is carried over from the v1 record so no authentication or
// tenant-policy data is lost in the migration.
type Organization struct {
orm.Model[Organization]
Owner string `json:"owner" orm:"varchar(100) notnull pk"`
Name string `json:"name" orm:"varchar(100) notnull pk"`
CreatedTime string `json:"createdTime" orm:"varchar(100)"`
DisplayName string `json:"displayName" orm:"varchar(100)"`
WebsiteUrl string `json:"websiteUrl" orm:"varchar(100)"`
Logo string `json:"logo" orm:"varchar(200)"`
LogoDark string `json:"logoDark" orm:"varchar(200)"`
Favicon string `json:"favicon" orm:"varchar(200)"`
HasPrivilegeConsent bool `json:"hasPrivilegeConsent" orm:"bool"`
PasswordType string `json:"passwordType" orm:"varchar(100)"`
PasswordSalt string `json:"passwordSalt" orm:"varchar(100)"`
PasswordOptions []string `json:"passwordOptions" orm:"mediumtext"`
PasswordObfuscatorType string `json:"passwordObfuscatorType" orm:"varchar(100)"`
PasswordObfuscatorKey string `json:"passwordObfuscatorKey" orm:"varchar(100)"`
PasswordExpireDays int `json:"passwordExpireDays" orm:"int"`
CountryCodes []string `json:"countryCodes" orm:"mediumtext"`
DefaultAvatar string `json:"defaultAvatar" orm:"varchar(200)"`
UsePermanentAvatar bool `json:"usePermanentAvatar" orm:"bool"`
DefaultApplication string `json:"defaultApplication" orm:"varchar(100)"`
UserTypes []string `json:"userTypes" orm:"mediumtext"`
Tags []string `json:"tags" orm:"mediumtext"`
Languages []string `json:"languages" orm:"mediumtext"`
ThemeData *ThemeData `json:"themeData" orm:"json"`
MasterPassword string `json:"masterPassword" orm:"varchar(200)"`
DefaultPassword string `json:"defaultPassword" orm:"varchar(200)"`
MasterVerificationCode string `json:"masterVerificationCode" orm:"varchar(100)"`
IpWhitelist string `json:"ipWhitelist" orm:"varchar(200)"`
InitScore int `json:"initScore" orm:"int"`
EnableSoftDeletion bool `json:"enableSoftDeletion" orm:"bool"`
IsProfilePublic bool `json:"isProfilePublic" orm:"bool"`
UseEmailAsUsername bool `json:"useEmailAsUsername" orm:"bool"`
EnableTour bool `json:"enableTour" orm:"bool"`
DisableSignin bool `json:"disableSignin" orm:"bool"`
IpRestriction string `json:"ipRestriction" orm:"varchar(255)"`
NavItems []string `json:"navItems" orm:"mediumtext"`
UserNavItems []string `json:"userNavItems" orm:"mediumtext"`
WidgetItems []string `json:"widgetItems" orm:"mediumtext"`
MfaItems []*MfaItem `json:"mfaItems" orm:"mediumtext"`
MfaRememberInHours int `json:"mfaRememberInHours" orm:"int"`
AccountMenu string `json:"accountMenu" orm:"varchar(20)"`
AccountItems []*AccountItem `json:"accountItems" orm:"mediumtext"`
// Per-organization signin throttle. Zero means "inherit the application
// default"; a non-zero value overrides it. Safe bounds are clamped by the
// resource service before persistence.
FailedSigninLimit int `json:"failedSigninLimit" orm:"int"`
FailedSigninFrozenTime int `json:"failedSigninFrozenTime" orm:"int"`
DcrPolicy string `json:"dcrPolicy" orm:"varchar(100)"`
LdapAttributes []string `json:"ldapAttributes" orm:"mediumtext"`
KerberosRealm string `json:"kerberosRealm" orm:"varchar(200)"`
KerberosKdcHost string `json:"kerberosKdcHost" orm:"varchar(200)"`
KerberosKeytab string `json:"kerberosKeytab" orm:"mediumtext"`
KerberosServiceName string `json:"kerberosServiceName" orm:"varchar(100)"`
// Balance fields are read-only mirrors; authoritative balances live in
// Commerce (billing.hanzo.ai). Carried for field-complete v1 parity.
OrgBalance float64 `json:"orgBalance" orm:"double"`
UserBalance float64 `json:"userBalance" orm:"double"`
BalanceCredit float64 `json:"balanceCredit" orm:"double"`
BalanceCurrency string `json:"balanceCurrency" orm:"varchar(100)"`
IsPersonal bool `json:"isPersonal" orm:"bool"`
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Permission is a policy grant: it binds a set of subjects (users, groups,
// roles, domains) to a set of actions over a set of resources with an
// allow/deny effect, evaluated against a named authz model and adapter. It is
// the v2 form of the v1 `permission` table (kind "permissions").
//
// Identity is the (Owner, Name) pair: Owner is the organization that holds the
// grant, Name is unique within that owner. Every other field is authorization
// state, so the port is deliberately field-complete against v1 — a dropped
// column silently widens or narrows access.
//
// The orm tag on each field preserves the v1 column spec for storage parity;
// slices persist natively as JSON arrays in the orm entity document.
type Permission struct {
orm.Model[Permission]
// Identity — the (owner, name) natural key.
Owner string `json:"owner" orm:"varchar(100) notnull pk"`
Name string `json:"name" orm:"varchar(100) notnull pk"`
// Descriptive metadata.
CreatedTime string `json:"createdTime" orm:"varchar(100)"`
DisplayName string `json:"displayName" orm:"varchar(100)"`
Description string `json:"description" orm:"varchar(100)"`
// Subjects the grant is evaluated for.
Users []string `json:"users" orm:"mediumtext"`
Groups []string `json:"groups" orm:"mediumtext"`
Roles []string `json:"roles" orm:"mediumtext"`
Domains []string `json:"domains" orm:"mediumtext"`
// Authorization model, targets, and decision. AuthzModel carries the v1
// `model` column (the named authz model); it is not the Go identifier
// `Model` because that name is taken by the embedded orm.Model[Permission]
// mixin. The wire contract is unchanged — json:"model".
AuthzModel string `json:"model" orm:"varchar(100)"`
Adapter string `json:"adapter" orm:"varchar(100)"`
ResourceType string `json:"resourceType" orm:"varchar(100)"`
Resources []string `json:"resources" orm:"mediumtext"`
Actions []string `json:"actions" orm:"mediumtext"`
Effect string `json:"effect" orm:"varchar(100)"`
IsEnabled bool `json:"isEnabled" orm:"default:false"`
// Submission / approval workflow.
Submitter string `json:"submitter" orm:"varchar(100)"`
Approver string `json:"approver" orm:"varchar(100)"`
ApproveTime string `json:"approveTime" orm:"varchar(100)"`
State string `json:"state" orm:"varchar(100)"`
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Provider is a federated identity / connector configuration (v1 Casdoor
// `provider`, v2 kind "providers"). One row configures a third-party endpoint
// an application binds to — OAuth/OIDC and SAML identity providers, captcha,
// SMS and email senders, object storage, payment gateways, and ID-verification
// services — carrying its credentials, endpoints, and dialect flags. Field
// complete against the v1 row so no secret, endpoint, or toggle is lost on
// migration. Identity is the (Owner, Name) pair; the orm string key is
// "owner/name".
//
// UserMapping and HttpHeaders carry orm:"serialize" so the column backends
// (hanzoai/sql, hanzoai/datastore) persist them through their string siblings;
// the default SQLite store round-trips the maps inside the entity JSON blob and
// leaves the siblings empty. DisableSsl is a v1 legacy dual-use flag (for a
// WeChat provider it toggles the QR-code path, for Google it toggles phone
// number sync) superseded by SslMode ("" / "Auto", "Enable", "Disable"); it is
// preserved for exact parity.
type Provider struct {
orm.Model[Provider]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime" orm:"index"`
DisplayName string `json:"displayName"`
Category string `json:"category"`
Type string `json:"type"`
SubType string `json:"subType"`
Method string `json:"method"`
ClientId string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
ClientId2 string `json:"clientId2"`
ClientSecret2 string `json:"clientSecret2"`
Cert string `json:"cert"`
CustomAuthUrl string `json:"customAuthUrl"`
CustomTokenUrl string `json:"customTokenUrl"`
CustomUserInfoUrl string `json:"customUserInfoUrl"`
CustomLogo string `json:"customLogo"`
Scopes string `json:"scopes"`
UserMapping map[string]string `json:"userMapping" orm:"serialize" datastore:"-"`
UserMapping_ string `json:"-"`
HttpHeaders map[string]string `json:"httpHeaders" orm:"serialize" datastore:"-"`
HttpHeaders_ string `json:"-"`
Host string `json:"host"`
Port int `json:"port"`
DisableSsl bool `json:"disableSsl"`
SslMode string `json:"sslMode"`
Title string `json:"title"`
Content string `json:"content"`
Receiver string `json:"receiver"`
RegionId string `json:"regionId"`
SignName string `json:"signName"`
TemplateCode string `json:"templateCode"`
AppId string `json:"appId"`
Endpoint string `json:"endpoint"`
IntranetEndpoint string `json:"intranetEndpoint"`
Domain string `json:"domain"`
Bucket string `json:"bucket"`
PathPrefix string `json:"pathPrefix"`
Metadata string `json:"metadata"`
IdP string `json:"idP"`
IssuerUrl string `json:"issuerUrl"`
EnableSignAuthnRequest bool `json:"enableSignAuthnRequest"`
EmailRegex string `json:"emailRegex"`
ProviderUrl string `json:"providerUrl"`
EnableProxy bool `json:"enableProxy"`
EnablePkce bool `json:"enablePkce"`
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Role is a named grant bundle (v1 Casdoor `role`, v2 kind "roles"). It gathers
// principals — direct users, member groups, and nested sub-roles — under an
// owner-scoped name, optionally partitioned by domain, and is dereferenced by
// permissions to resolve a principal's effective grants. Identity is the
// (Owner, Name) pair; the orm string key is "owner/name".
//
// The membership lists carry orm:"serialize" so the column backends
// (hanzoai/sql, hanzoai/datastore) persist them through their string siblings;
// the default SQLite store round-trips the arrays inside the entity JSON blob
// and leaves the siblings empty.
type Role struct {
orm.Model[Role]
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Users []string `json:"users" orm:"serialize" datastore:"-"`
Users_ string `json:"-"`
Groups []string `json:"groups" orm:"serialize" datastore:"-"`
Groups_ string `json:"-"`
Roles []string `json:"roles" orm:"serialize" datastore:"-"`
Roles_ string `json:"-"`
Domains []string `json:"domains" orm:"serialize" datastore:"-"`
Domains_ string `json:"-"`
IsEnabled bool `json:"isEnabled"`
}
+14 -105
View File
@@ -1,14 +1,19 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package schema declares the thirteen IAM v2 identity entities on
// Package schema declares the fourteen IAM v2 identity entities on
// hanzoai/orm.
//
// Each entity embeds orm.Model[T] and registers its kind in init(). orm
// stores every entity as one row in a single _entities table keyed by kind —
// there is no per-entity DDL, so "schema" here is the domain model, not a
// migration. Phase 0 carries only the owner/name identity fields; the full
// field set per entity lands in Phase 1 beside the handlers that own it
// (MIGRATION.md §4).
// Each entity embeds orm.Model[T]; every kind is registered exactly once in
// this file's init(). orm stores every entity as one row in a single
// _entities table keyed by kind — there is no per-entity DDL, so "schema"
// here is the domain model, not a migration. Phase 1 carries the full field
// set per entity, each in its own file beside the handlers that own it
// (MIGRATION.md §4); the (owner, name) pair is the natural key across the
// whole model.
//
// Registration is centralized here — one place, one way. The per-entity
// files declare only the struct (and its nested value types); they add no
// second orm.Register call, which would panic on a duplicate kind.
//
// Scope is deliberate: the v1 Casdoor object package has ~32 tables, but the
// Casbin artifacts (adapter, enforcer, model) are replaced by hanzoai/authz
@@ -18,103 +23,6 @@ package schema
import "github.com/hanzoai/orm"
// Every IAM entity is owner-scoped (owner = organization) and named uniquely
// within its owner — the (owner, name) pair is the natural key across the
// whole model. Phase 1 adds the per-entity fields on top of these two.
// User is an identity principal (v1 Casdoor `user`). v2 handles password
// hashing and token keys explicitly in Phase 1, not through a framework's
// auth-collection machinery.
type User struct {
orm.Model[User]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Organization is a tenant boundary (v1 `organization`).
type Organization struct {
orm.Model[Organization]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Application is an OAuth2/OIDC client (v1 `application`).
type Application struct {
orm.Model[Application]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Provider is a federated identity / connector config (v1 `provider`).
type Provider struct {
orm.Model[Provider]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Role is a named grant bundle (v1 `role`).
type Role struct {
orm.Model[Role]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Permission is a policy grant (v1 `permission`).
type Permission struct {
orm.Model[Permission]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Cert is a signing/verification certificate (v1 `cert`).
type Cert struct {
orm.Model[Cert]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Key is an API/access key (v1 `key`).
type Key struct {
orm.Model[Key]
Owner string `json:"owner"`
Name string `json:"name"`
}
// WebauthnCredential is a registered passkey (v1 `webauthn_credential`).
type WebauthnCredential struct {
orm.Model[WebauthnCredential]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Session is an authenticated session (v1 `session`).
type Session struct {
orm.Model[Session]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Token is an issued OAuth2 token record (v1 `token`).
type Token struct {
orm.Model[Token]
Owner string `json:"owner"`
Name string `json:"name"`
}
// AuditLog is an append-only action record (v1 `record`).
type AuditLog struct {
orm.Model[AuditLog]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Invitation is a pending org membership invite (v1 `invitation`).
type Invitation struct {
orm.Model[Invitation]
Owner string `json:"owner"`
Name string `json:"name"`
}
// Kinds lists the registered v2 entity kinds in canonical (MIGRATION.md §4)
// order. The drift-compare tool and diagnostics iterate this.
func Kinds() []string {
@@ -122,7 +30,7 @@ func Kinds() []string {
"users", "organizations", "applications", "providers",
"roles", "permissions", "certs", "keys",
"webauthn_credentials", "sessions", "tokens", "audit_logs",
"invitations",
"invitations", "verifications",
}
}
@@ -140,4 +48,5 @@ func init() {
orm.Register[Token]("tokens")
orm.Register[AuditLog]("audit_logs")
orm.Register[Invitation]("invitations")
orm.Register[VerificationRecord]("verifications")
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Session is an authenticated login session (v1 Casdoor `session`, v2 kind
// "sessions"). One row records every live browser-session cookie a single
// principal holds against one application, so a targeted sign-out, an
// exclusive sign-in, or a duplicate-login check can enumerate and destroy
// them. Identity is the (Owner, Name, Application) triple — v1 joins it into
// "owner/name/application" and the orm string id is composed the same way, so
// concurrent sessions for one user across different applications never
// collide. Field-complete against the v1 row: no cookie list or key part is
// dropped, or live sessions would be orphaned on cutover.
//
// SessionId is the append-only list of active cookie ids. It carries
// orm:"serialize" so the column backends (hanzoai/sql, hanzoai/datastore)
// persist it through the SessionId_ string sibling; the default SQLite store
// round-trips the slice inside the entity JSON blob and leaves the sibling
// empty. orm.Model supplies id/createdAt/updatedAt/deleted; CreatedTime below
// is the v1 string timestamp, kept distinct from orm's typed CreatedAt.
type Session struct {
orm.Model[Session]
Owner string `json:"owner" orm:"varchar(100) notnull pk"`
Name string `json:"name" orm:"varchar(100) notnull pk"`
Application string `json:"application" orm:"varchar(100) notnull pk"`
CreatedTime string `json:"createdTime" orm:"varchar(100)"`
SessionId []string `json:"sessionId" orm:"serialize" datastore:"-"`
SessionId_ string `json:"-" orm:"mediumtext"`
// ExclusiveSignin is a transient control flag (v1 xorm:"-"): a caller sets
// it on a create to collapse SessionId down to the single incoming cookie
// instead of appending. It is never stored — a persisted session always
// carries it false, so orm:"-" keeps it off the column backends and
// omitempty keeps it out of the SQLite JSON blob.
ExclusiveSignin bool `json:"exclusiveSignin,omitempty" orm:"-"`
}

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