Compare commits

...
35 Commits
Author SHA1 Message Date
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
97 changed files with 13390 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"]
+92 -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,102 @@ 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).
## §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 `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.
| 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 |
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.
**Deliberately not modeled by iam2** (they belong to commerce/other services,
not identity): `payment`, `plan`, `product`, `subscription`, `pricing`,
`model`, `adapter`, `enforcer`, `syncer_*`.
## §5 Credential parity (the cutover landmine, RESOLVED)
## §5 Drift gate
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.
`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.
## §6 Domain model (v1 xorm table → v2 orm kind)
## §6 Cutover
Fourteen identity entities. Field-completeness is mandatory — a dropped column is
lost auth data.
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.
| 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
```
+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=
+179
View File
@@ -0,0 +1,179 @@
// 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", createApplication(db),
zip.WithSummary("Create an application"), zip.WithTags("applications"))
zip.Put(app, "/v1/iam/application", updateApplication(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
}
}
// createApplication persists a new application under (in.Owner, in.Name),
// rejecting a collision on that owner-scoped key.
func createApplication(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
}
}
// updateApplication overwrites the application at (in.Owner, in.Name),
// preserving its immutable creation metadata. The (owner, name) identity is
// fixed by the URL of the record, not editable through the body.
func updateApplication(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())
}
+373
View File
@@ -0,0 +1,373 @@
// 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
}
// 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)
"/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/issue-user-token": true, // confidential-client auth (Basic + allow-list), not a bearer
}
// 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
}
// 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")
}
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())
}
+171
View File
@@ -0,0 +1,171 @@
// 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))
}
// 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 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")
}
}
+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
}
+154
View File
@@ -0,0 +1,154 @@
// 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 to render
// the login UI. Read-only in this increment (get-app-login + auth/methods); the
// credential path (login/signup) lands with the session + token increments.
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))
}
// 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
}
+73
View File
@@ -0,0 +1,73 @@
// 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/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 is by bearer access token (the API path, shared with
// userinfo/verifyToken); the portal session-cookie path lands with the session
// layer (§4 front-door residual) and plugs in here with no shape change.
func getAccount(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
bearer := httpx.Bearer(c)
if bearer == "" {
return c.JSON(200, accountResponse{Status: "error", Msg: "please sign in first"})
}
claims, err := verifyToken(ctx, db, bearer)
if err != nil {
return c.JSON(200, accountResponse{Status: "error", Msg: "the access token is invalid or expired"})
}
owner, name := splitSub(claims.Subject)
user, err := store.GetUserByName(ctx, db, owner, name)
if err != nil {
return c.JSON(500, accountResponse{Status: "error", Msg: "server_error"})
}
if user == nil {
return c.JSON(200, accountResponse{Status: "error", Msg: "the user does not exist"})
}
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
if err != nil {
return c.JSON(500, accountResponse{Status: "error", Msg: "server_error"})
}
return c.JSON(200, accountResponse{
Status: "ok",
Sub: claims.Subject,
Name: user.Name,
Data: user.Mask(), // owner + isAdmin survive; every secret stripped
Data2: org.Mask(), // org master/default passwords masked
})
}
}
+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}))
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/subtle"
"os"
"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"
)
// PathIssueUserToken is the confidential-client "act on behalf of a user"
// primitive. A trusted, allow-listed backend (the console BFF as `hanzo-console`)
// authenticates as the confidential client and mints a short-lived access token
// bound to a TARGET user — the credential a proxy forwards so no long-lived key
// ever reaches a browser. It is THE gate the console admin + keyless-AI proxies
// depend on: absent, every /admin/* and /ai call 502s before any verb.
const PathIssueUserToken = "/v1/iam/issue-user-token"
// MountIssueToken registers the issue-user-token primitive. It is NOT Bearer-gated
// (it authenticates the confidential CLIENT via Basic/POST creds + a capability
// allow-list, not an end-user bearer), so authz.Guard lists it public and this
// handler does its own, tighter authentication.
func MountIssueToken(app *zip.App, db orm.DB) {
app.Post(PathIssueUserToken, issueUserTokenHandler(db))
app.Get(PathIssueUserToken, issueUserTokenHandler(db))
}
// issueUserTokenHandler mints an access token for the `?id=<owner>/<name>` target
// user, issued by the authenticated + allow-listed confidential client. Response
// is the v1 envelope `{status:"ok", data:{accessToken, expiresIn}}` (camelCase,
// the exact shape the console's identity.ts consumes).
func issueUserTokenHandler(db orm.DB) zip.Handler {
return func(c *zip.Ctx) error {
ctx := c.Context()
now := nowFunc()
// 1) Authenticate the confidential CLIENT (client_secret_post or Basic).
clientID, clientSecret := clientAuth(c)
if clientID == "" {
return unauthorizedEnvelope(c, "client authentication required")
}
clientApp, err := store.GetApplicationByClientId(ctx, db, clientID)
if err != nil {
return httpx.Err(c, "server_error")
}
if clientApp == nil || clientApp.ClientSecret == "" ||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(clientApp.ClientSecret)) != 1 {
return unauthorizedEnvelope(c, "client authentication failed")
}
// 2) The capability gate: only an ALLOW-LISTED app may mint a user token.
// Fail closed — an unset allow-list permits NOTHING (this endpoint hands out
// a user's full authority; a missing config must never mean "anyone").
if !mintAllowed(clientID, clientApp.Name) {
return forbiddenEnvelope(c, "client is not permitted to issue user tokens")
}
// 3) Resolve the TARGET user from `?id=<owner>/<name>`.
owner, name := splitSub(c.Query("id"))
if owner == "" || name == "" {
return httpx.Err(c, "id (owner/name) is required")
}
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")
}
if user.IsForbidden || user.IsDeleted {
return forbiddenEnvelope(c, "the user is forbidden")
}
// 4) Audience (RFC 8707): an explicit `?aud=` resource wins (the admin path
// pins the cloud audience so a reserved-admin operator's token is accepted);
// otherwise default to the target user's OWN app — a same-app consumer.
aud := strings.TrimSpace(c.Query("aud"))
if aud == "" {
aud = defaultUserAudience(ctx, db, user, clientApp)
}
// 5) Mint under the confidential client's TRUSTED signing cert. The token's
// subject + owner are the TARGET USER's, so it is indistinguishable from one
// the user obtained directly and a resource server scopes to the user's org.
signer, err := signerFor(ctx, db, clientApp, tokenIssuer(c))
if err != nil {
return httpx.Err(c, "server_error")
}
ttl := appTTL(clientApp)
subject := owner + "/" + name
display := user.DisplayName
if display == "" {
display = user.Name
}
access, err := signer.SignUserToken(subject, owner, aud, clientApp.ClientId, user.Email, display, "", ttl, now)
if err != nil {
return httpx.Err(c, "server_error")
}
// 6) Persist the token (by hash) so it is revocable and resolvable by
// userinfo — the same durability the other grants have.
row := &schema.Token{
Owner: owner,
Application: clientApp.Name,
Organization: owner,
User: subject,
TokenType: "Bearer",
ExpiresIn: int(ttl.Seconds()),
AccessTokenHash: hashToken(access),
}
row.Name = "iut-" + hashToken(access)[:32]
if err := store.PersistToken(ctx, db, row); err != nil {
return httpx.Err(c, "server_error")
}
return httpx.Ok(c, map[string]any{
"accessToken": access,
"expiresIn": int(ttl.Seconds()),
})
}
}
// mintAllowed reports whether an app (by clientId OR name) is on the
// IAM_KEY_MINT_ALLOWED_APPS allow-list. Comma/space separated; matches either
// identifier so the operator can list whichever is convenient. An empty/unset
// list allows nothing — fail closed.
func mintAllowed(clientID, appName string) bool {
raw := os.Getenv("IAM_KEY_MINT_ALLOWED_APPS")
if strings.TrimSpace(raw) == "" {
return false
}
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' }) {
if item == clientID || item == appName {
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
}
// unauthorizedEnvelope / forbiddenEnvelope return the v1 error envelope with a
// correct HTTP status (the SDK branches on status; a prober still gets 401/403).
func unauthorizedEnvelope(c *zip.Ctx, msg string) error {
return c.JSON(401, httpx.Response{Status: "error", Msg: msg})
}
func forbiddenEnvelope(c *zip.Ctx, msg string) error {
return c.JSON(403, httpx.Response{Status: "error", Msg: msg})
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/schema"
)
// issue-user-token is a security primitive: an allow-listed confidential client
// mints a bearer carrying a TARGET user's full authority. These tests pin both
// halves — that a legitimate call mints a token whose claims ARE the target
// user's (so a resource server scopes to the user's tenant, and the token
// verifies under the same JWKS), and that EVERY rejection path (bad secret,
// off-allow-list, no auth, unknown/forbidden user) fails closed.
// issueReq builds a POST issue-user-token request authenticating `clientID`/
// `secret` via HTTP Basic — the confidential-client credential the console sends.
func issueReq(clientID, secret, query string) *http.Request {
req := httptest.NewRequest("POST", PathIssueUserToken+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 TestIssueUserToken_mintsTargetUserAuthority(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, issueReq("hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, body)
}
m := decode(t, body)
if m["status"] != "ok" {
t.Fatalf("status field = %v; body=%s", m["status"], body)
}
data := dataMap(t, body)
access, _ := data["accessToken"].(string)
if access == "" {
t.Fatalf("no accessToken in data; body=%s", body)
}
if exp, _ := data["expiresIn"].(float64); exp <= 0 {
t.Fatalf("expiresIn = %v, want > 0", data["expiresIn"])
}
// The minted token must verify under the SAME JWKS and carry the TARGET user's
// identity — subject = hanzo/alice, owner claim = the user's org (so a resource
// server that scopes on `owner` scopes to alice's tenant, not the client's).
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 claim = %q, want hanzo (the target user's tenant)", claims.Owner)
}
if claims.Azp != "hanzo-console" {
t.Errorf("azp = %q, want hanzo-console (the minting client)", claims.Azp)
}
}
func TestIssueUserToken_audienceOverride(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")
// The admin path pins ?aud=<brand>-cloud so cloud's audience allow-list accepts
// a reserved-admin operator's token.
_, body := do(t, app, issueReq("hanzo-console", "top-secret", "?id=hanzo/alice&aud=hanzo-cloud"))
access, _ := dataMap(t, body)["accessToken"].(string)
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("verify: %v", err)
}
found := false
for _, a := range claims.Audience {
if a == "hanzo-cloud" {
found = true
}
}
if !found {
t.Fatalf("aud = %v, want it to contain the ?aud= override hanzo-cloud", claims.Audience)
}
}
func TestIssueUserToken_defaultAudienceIsClientWhenUserHasNoApp(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") // no SignupApplication
_, body := do(t, app, issueReq("hanzo-console", "top-secret", "?id=hanzo/alice"))
access, _ := dataMap(t, body)["accessToken"].(string)
claims, err := verifyToken(context.Background(), db, access)
if err != nil {
t.Fatalf("verify: %v", err)
}
if len(claims.Audience) != 1 || claims.Audience[0] != "hanzo-console" {
t.Fatalf("default aud = %v, want [hanzo-console] (the minting client fallback)", claims.Audience)
}
}
func TestIssueUserToken_wrongSecret_401(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, issueReq("hanzo-console", "WRONG", "?id=hanzo/alice"))
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401; body=%s", resp.StatusCode, body)
}
if decode(t, body)["status"] != "error" {
t.Fatalf("want status:error; body=%s", body)
}
}
func TestIssueUserToken_notAllowlisted_403(t *testing.T) {
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "some-other-app") // hanzo-console NOT listed
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, issueReq("hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 403 {
t.Fatalf("status = %d, want 403 (client authenticated but not allow-listed); body=%s", resp.StatusCode, body)
}
}
func TestIssueUserToken_emptyAllowlist_failsClosed(t *testing.T) {
// No IAM_KEY_MINT_ALLOWED_APPS set → NOBODY may mint (a missing config must
// never mean "anyone can hand out a user's authority").
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "")
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, issueReq("hanzo-console", "top-secret", "?id=hanzo/alice"))
if resp.StatusCode != 403 {
t.Fatalf("empty allow-list status = %d, want 403 (fail closed)", resp.StatusCode)
}
}
func TestIssueUserToken_noClientAuth_401(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"})
resp, _ := do(t, app, issueReq("", "", "?id=hanzo/alice"))
if resp.StatusCode != 401 {
t.Fatalf("no-auth status = %d, want 401", resp.StatusCode)
}
}
func TestIssueUserToken_unknownUser_error(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"})
_, body := do(t, app, issueReq("hanzo-console", "top-secret", "?id=hanzo/ghost"))
if decode(t, body)["status"] != "error" {
t.Fatalf("unknown user must be status:error; body=%s", body)
}
}
func TestIssueUserToken_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"})
// A forbidden/deleted user is a revoked principal — no token may be minted for it.
u := orm.New[schema.User](db)
u.Owner, u.Name = "hanzo", "banned"
u.IsForbidden = true
u.SetId("hanzo/banned")
if err := u.CreateCtx(context.Background()); err != nil {
t.Fatalf("seed forbidden user: %v", err)
}
resp, _ := do(t, app, issueReq("hanzo-console", "top-secret", "?id=hanzo/banned"))
if resp.StatusCode != 403 {
t.Fatalf("forbidden-user 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 issue-user-token primitive. 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)
}
}
+171
View File
@@ -0,0 +1,171 @@
// 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/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. Session issuance lands with the
// session layer; for now report success + the user id (the shape the
// portal expects for a non-OAuth sign-in).
if f.Type != "code" {
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
}
+99
View File
@@ -0,0 +1,99 @@
// 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"
)
// 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)
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)
// 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,
"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"},
"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",
},
})
}
+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)
}
}
+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)
}
})
}
}
+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")
}
})
}
+443
View File
@@ -0,0 +1,443 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"net/url"
"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 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 POST /v1/iam/oauth/token.
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 "":
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,
})
}
// 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 (https://<host>),
// the value discovery advertises and every token carries as `iss`.
func tokenIssuer(c *zip.Ctx) string {
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()) }
+251
View File
@@ -0,0 +1,251 @@
// 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) {
resp, tok := postToken(t, app, url.Values{"grant_type": {"password"}})
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
}
+112
View File
@@ -0,0 +1,112 @@
// 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
}
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})
}
+189
View File
@@ -0,0 +1,189 @@
// 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)
}
}
}
// 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
}
+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())
}
+76 -12
View File
@@ -2,27 +2,91 @@
// 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/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)
}
// 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:"-"`
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// Token is an issued OAuth2/OIDC token record (v1 Casdoor `token`, v2 kind
// "tokens"). One row is the authorization-server's persistent memory of a
// single grant: the short-lived authorization code and its PKCE challenge, the
// minted access and refresh tokens (stored verbatim for reissue plus as salted
// hashes for constant-shape lookup), the scope, token type, lifetimes, and the
// RFC 8707 resource indicator that binds the grant to an audience. It ties an
// application, its organization, and the authenticated user together for the
// life of the session. Field complete against the v1 row so no credential,
// challenge, or lifetime is lost on migration — a dropped field here is lost
// auth state. Identity is the (Owner, Name) pair; the orm string key is
// "owner/name".
//
// Code, AccessTokenHash, and RefreshTokenHash carry orm:"index": v1 resolves a
// live grant by presented code or by the hash of a bearer/refresh token, so
// those columns are the hot lookup paths and stay indexed. Owner, Name, and
// CreatedTime are indexed for owner-scoped listing in newest-first order.
// AccessToken and RefreshToken are the full secret material (v1 mediumtext) and
// are left unindexed — lookups go through the hash siblings, never the plaintext.
type Token struct {
orm.Model[Token]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime" orm:"index"`
Application string `json:"application"`
Organization string `json:"organization"`
User string `json:"user"`
Code string `json:"code" orm:"index"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
AccessTokenHash string `json:"accessTokenHash" orm:"index"`
RefreshTokenHash string `json:"refreshTokenHash" orm:"index"`
ExpiresIn int `json:"expiresIn"`
Scope string `json:"scope"`
TokenType string `json:"tokenType"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
CodeIsUsed bool `json:"codeIsUsed"`
CodeExpireIn int64 `json:"codeExpireIn"`
Resource string `json:"resource"` // RFC 8707 resource indicator
// RedirectUri binds the authorization code to the exact redirect URI of the
// authorize request (RFC 6749 §4.1.3): the token endpoint refuses a code
// redeemed with a different redirect_uri, closing code-injection across a
// client's registered URIs.
RedirectUri string `json:"redirectUri,omitempty"`
// Nonce is the OIDC authorize nonce, stored on the code and echoed into the
// id_token minted at the exchange (OIDC Core §3.1.3.6) so a relying party
// binds the id_token to its own request and detects replay.
Nonce string `json:"nonce,omitempty"`
// Refresh-token rotation state (v2). Each refresh belongs to a family (the
// grant); rotation mints a new row in the same family and marks the prior
// one consumed. Presenting a consumed refresh is reuse — the whole family is
// revoked (RFC 9700 §4.14.2). RefreshExpireIn is the refresh token's own
// absolute expiry (unix), independent of the access token's shorter life.
RefreshFamily string `json:"refreshFamily,omitempty" orm:"index"`
RefreshConsumed bool `json:"refreshConsumed,omitempty"`
RefreshExpireIn int64 `json:"refreshExpireIn,omitempty"`
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import (
"encoding/json"
"github.com/hanzoai/orm"
)
// User is an identity principal — the v2 form of the v1 Casdoor `user` row,
// re-expressed on hanzoai/orm. It is the authentication entity: the only
// credential material it persists is PasswordHash (a one-way bcrypt digest,
// json:"-" so it never leaves the process) and the legacy hash metadata used
// to migrate rows minted before the bcrypt cutover. The plaintext password is
// never a field on this struct — it arrives on the create/update request,
// is hashed immediately, and is discarded.
//
// The natural key is (Owner, Name): Owner is the tenant/organization slug,
// Name is unique within it. The embedded orm.Model[User] supplies the storage
// id (the OIDC `sub`), the created/updated timestamps, and the CRUD lifecycle.
type User struct {
orm.Model[User]
// Identity / tenancy. (Owner, Name) is the natural key; the OIDC `sub`
// is the embedded orm.Model id.
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime"`
UpdatedTime string `json:"updatedTime"`
DeletedTime string `json:"deletedTime,omitempty"`
ExternalId string `json:"externalId,omitempty" orm:"index"`
Type string `json:"type,omitempty"`
// Credential material. PasswordHash is a one-way bcrypt digest and is
// verify-only. It MUST be persisted (orm serializes the entity to its JSON
// data column, so a json:"-" field would never be stored — that silently
// broke login), so it carries a real json tag; the users API redact() strips
// it (and every other secret) from every response. PasswordType and
// PasswordSalt describe the digest scheme so rows hashed under the legacy
// argon2id scheme can still be verified and lazily re-hashed to bcrypt.
PasswordHash string `json:"passwordHash,omitempty"`
PasswordType string `json:"passwordType,omitempty"`
PasswordSalt string `json:"passwordSalt,omitempty"`
// Profile.
DisplayName string `json:"displayName,omitempty"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Avatar string `json:"avatar,omitempty"`
AvatarType string `json:"avatarType,omitempty"`
PermanentAvatar string `json:"permanentAvatar,omitempty"`
Email string `json:"email,omitempty" orm:"index"`
EmailVerified bool `json:"emailVerified,omitempty"`
Phone string `json:"phone,omitempty" orm:"index"`
CountryCode string `json:"countryCode,omitempty"`
Region string `json:"region,omitempty"`
Location string `json:"location,omitempty"`
Address []string `json:"address,omitempty"`
Addresses []*Address `json:"addresses,omitempty"`
Affiliation string `json:"affiliation,omitempty"`
Title string `json:"title,omitempty"`
IdCardType string `json:"idCardType,omitempty"`
IdCard string `json:"idCard,omitempty" orm:"index"`
RealName string `json:"realName,omitempty"`
IsVerified bool `json:"isVerified,omitempty"`
Homepage string `json:"homepage,omitempty"`
Bio string `json:"bio,omitempty"`
Tag string `json:"tag,omitempty"`
Language string `json:"language,omitempty"`
Gender string `json:"gender,omitempty"`
Birthday string `json:"birthday,omitempty"`
Education string `json:"education,omitempty"`
Score int `json:"score,omitempty"`
Karma int `json:"karma,omitempty"`
Ranking int `json:"ranking,omitempty"`
// Balance mirrors v1 for lossless migration but is authoritative in
// Commerce (billing.hanzo.ai), not here — do not write it from IAM.
Balance float64 `json:"balance,omitempty"`
BalanceCredit float64 `json:"balanceCredit,omitempty"`
Currency string `json:"currency,omitempty"`
BalanceCurrency string `json:"balanceCurrency,omitempty"`
// State flags.
IsDefaultAvatar bool `json:"isDefaultAvatar,omitempty" orm:"default:false"`
IsOnline bool `json:"isOnline,omitempty" orm:"default:false"`
IsAdmin bool `json:"isAdmin,omitempty" orm:"default:false"`
IsForbidden bool `json:"isForbidden,omitempty" orm:"default:false"`
IsDeleted bool `json:"isDeleted,omitempty" orm:"default:false"`
SignupApplication string `json:"signupApplication,omitempty"`
Hash string `json:"hash,omitempty"`
PreHash string `json:"preHash,omitempty"`
RegisterType string `json:"registerType,omitempty"`
RegisterSource string `json:"registerSource,omitempty"`
// API credentials. AccessSecret / AccessSecretHash / the OAuth tokens are
// bearer material. AccessSecretHash MUST persist (orm stores via JSON; a
// json:"-" field is never saved), so it carries a real json tag and the
// handler's redact() strips it (and AccessSecret + the token fields) before
// responding.
AccessKey string `json:"accessKey,omitempty"`
AccessSecret string `json:"accessSecret,omitempty"`
AccessSecretHash string `json:"accessSecretHash,omitempty"`
AccessToken string `json:"accessToken,omitempty"`
OriginalToken string `json:"originalToken,omitempty"`
OriginalRefreshToken string `json:"originalRefreshToken,omitempty"`
// Sign-in provenance.
CreatedIp string `json:"createdIp,omitempty"`
LastSigninTime string `json:"lastSigninTime,omitempty"`
LastSigninIp string `json:"lastSigninIp,omitempty"`
// Linked federated-identity subjects, one column per connector (v1 parity).
GitHub string `json:"github,omitempty"`
Google string `json:"google,omitempty"`
QQ string `json:"qq,omitempty"`
WeChat string `json:"wechat,omitempty"`
Facebook string `json:"facebook,omitempty"`
DingTalk string `json:"dingtalk,omitempty"`
Weibo string `json:"weibo,omitempty"`
Gitee string `json:"gitee,omitempty"`
LinkedIn string `json:"linkedin,omitempty"`
Wecom string `json:"wecom,omitempty"`
Lark string `json:"lark,omitempty"`
Gitlab string `json:"gitlab,omitempty"`
Adfs string `json:"adfs,omitempty"`
Baidu string `json:"baidu,omitempty"`
Alipay string `json:"alipay,omitempty"`
Iam string `json:"iam,omitempty"`
Infoflow string `json:"infoflow,omitempty"`
Apple string `json:"apple,omitempty"`
AzureAD string `json:"azuread,omitempty"`
AzureADB2c string `json:"azureadb2c,omitempty"`
Slack string `json:"slack,omitempty"`
Steam string `json:"steam,omitempty"`
Bilibili string `json:"bilibili,omitempty"`
Okta string `json:"okta,omitempty"`
Douyin string `json:"douyin,omitempty"`
Kwai string `json:"kwai,omitempty"`
Line string `json:"line,omitempty"`
Amazon string `json:"amazon,omitempty"`
Auth0 string `json:"auth0,omitempty"`
BattleNet string `json:"battlenet,omitempty"`
Bitbucket string `json:"bitbucket,omitempty"`
Box string `json:"box,omitempty"`
CloudFoundry string `json:"cloudfoundry,omitempty"`
Dailymotion string `json:"dailymotion,omitempty"`
Deezer string `json:"deezer,omitempty"`
DigitalOcean string `json:"digitalocean,omitempty"`
Discord string `json:"discord,omitempty"`
Dropbox string `json:"dropbox,omitempty"`
EveOnline string `json:"eveonline,omitempty"`
Fitbit string `json:"fitbit,omitempty"`
Gitea string `json:"gitea,omitempty"`
Heroku string `json:"heroku,omitempty"`
InfluxCloud string `json:"influxcloud,omitempty"`
Instagram string `json:"instagram,omitempty"`
Intercom string `json:"intercom,omitempty"`
Kakao string `json:"kakao,omitempty"`
Lastfm string `json:"lastfm,omitempty"`
Mailru string `json:"mailru,omitempty"`
Meetup string `json:"meetup,omitempty"`
MicrosoftOnline string `json:"microsoftonline,omitempty"`
Naver string `json:"naver,omitempty"`
Nextcloud string `json:"nextcloud,omitempty"`
OneDrive string `json:"onedrive,omitempty"`
Oura string `json:"oura,omitempty"`
Patreon string `json:"patreon,omitempty"`
Paypal string `json:"paypal,omitempty"`
SalesForce string `json:"salesforce,omitempty"`
Shopify string `json:"shopify,omitempty"`
Soundcloud string `json:"soundcloud,omitempty"`
Spotify string `json:"spotify,omitempty"`
Strava string `json:"strava,omitempty"`
Stripe string `json:"stripe,omitempty"`
Telegram string `json:"telegram,omitempty"`
TikTok string `json:"tiktok,omitempty"`
Tumblr string `json:"tumblr,omitempty"`
Twitch string `json:"twitch,omitempty"`
Twitter string `json:"twitter,omitempty"`
Typetalk string `json:"typetalk,omitempty"`
Uber string `json:"uber,omitempty"`
VK string `json:"vk,omitempty"`
Wepay string `json:"wepay,omitempty"`
Xero string `json:"xero,omitempty"`
Yahoo string `json:"yahoo,omitempty"`
Yammer string `json:"yammer,omitempty"`
Yandex string `json:"yandex,omitempty"`
Zoom string `json:"zoom,omitempty"`
Custom string `json:"custom,omitempty"`
Custom2 string `json:"custom2,omitempty"`
Custom3 string `json:"custom3,omitempty"`
Custom4 string `json:"custom4,omitempty"`
Custom5 string `json:"custom5,omitempty"`
Custom6 string `json:"custom6,omitempty"`
Custom7 string `json:"custom7,omitempty"`
Custom8 string `json:"custom8,omitempty"`
Custom9 string `json:"custom9,omitempty"`
Custom10 string `json:"custom10,omitempty"`
// Multi-factor authentication. TotpSecret and RecoveryCodes are secret
// verify-only material — the handler strips them from every response.
// WebauthnCredentials is carried as raw JSON here for lossless migration;
// the typed passkey model is the sibling WebauthnCredential entity.
WebauthnCredentials []json.RawMessage `json:"webauthnCredentials,omitempty"`
PreferredMfaType string `json:"preferredMfaType,omitempty"`
RecoveryCodes []string `json:"recoveryCodes,omitempty"`
TotpSecret string `json:"totpSecret,omitempty"`
VerificationCode string `json:"verificationCode,omitempty"`
MfaPhoneEnabled bool `json:"mfaPhoneEnabled,omitempty"`
MfaEmailEnabled bool `json:"mfaEmailEnabled,omitempty"`
MfaRadiusEnabled bool `json:"mfaRadiusEnabled,omitempty"`
MfaRadiusUsername string `json:"mfaRadiusUsername,omitempty"`
MfaRadiusProvider string `json:"mfaRadiusProvider,omitempty"`
MfaPushEnabled bool `json:"mfaPushEnabled,omitempty"`
MfaPushReceiver string `json:"mfaPushReceiver,omitempty"`
MfaPushProvider string `json:"mfaPushProvider,omitempty"`
MultiFactorAuths []*MfaProps `json:"multiFactorAuths,omitempty"`
Invitation string `json:"invitation,omitempty" orm:"index"`
InvitationCode string `json:"invitationCode,omitempty" orm:"index"`
FaceIds []*FaceId `json:"faceIds,omitempty"`
Cart []CartItem `json:"cart,omitempty"`
Ldap string `json:"ldap,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
// Authorization attachments. Roles and Permissions are computed on read
// from the authz store and carried here for API parity with v1.
Roles []*Role `json:"roles,omitempty"`
Permissions []*Permission `json:"permissions,omitempty"`
Groups []string `json:"groups,omitempty"`
LastChangePasswordTime string `json:"lastChangePasswordTime,omitempty"`
LastSigninWrongTime string `json:"lastSigninWrongTime,omitempty"`
SigninWrongTimes int `json:"signinWrongTimes,omitempty"`
ManagedAccounts []ManagedAccount `json:"managedAccounts,omitempty"`
MfaAccounts []MfaAccount `json:"mfaAccounts,omitempty"`
MfaItems []*MfaItem `json:"mfaItems,omitempty"`
MfaRememberDeadline string `json:"mfaRememberDeadline,omitempty"`
NeedUpdatePassword bool `json:"needUpdatePassword,omitempty"`
IpWhitelist string `json:"ipWhitelist,omitempty"`
ApplicationScopes []ConsentRecord `json:"applicationScopes,omitempty"`
}
// Address is a structured postal address held on a user.
type Address struct {
Tag string `json:"tag,omitempty"`
Line1 string `json:"line1,omitempty"`
Line2 string `json:"line2,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
ZipCode string `json:"zipCode,omitempty"`
Region string `json:"region,omitempty"`
}
// ManagedAccount is a credential a user delegates for a downstream application.
// Password is secret verify-only material; the handler strips it on read.
type ManagedAccount struct {
Application string `json:"application,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"-"`
SigninUrl string `json:"signinUrl,omitempty"`
}
// MfaAccount is a stored TOTP authenticator entry. SecretKey never serializes.
type MfaAccount struct {
AccountName string `json:"accountName,omitempty"`
Issuer string `json:"issuer,omitempty"`
SecretKey string `json:"-"`
Origin string `json:"origin,omitempty"`
}
// MfaProps is one configured MFA method surfaced to clients. Secret and
// RecoveryCodes are verify-only and stripped from responses.
type MfaProps struct {
Enabled bool `json:"enabled,omitempty"`
IsPreferred bool `json:"isPreferred,omitempty"`
MfaType string `json:"mfaType,omitempty"`
Secret string `json:"-"`
CountryCode string `json:"countryCode,omitempty"`
URL string `json:"url,omitempty"`
RecoveryCodes []string `json:"-"`
MfaRememberInHours int `json:"mfaRememberInHours,omitempty"`
}
// MfaItem is an organization-defined MFA requirement pinned to the user. It is
// shared with Organization (which declares the org-level requirement set).
type MfaItem struct {
Name string `json:"name,omitempty"`
Rule string `json:"rule,omitempty"`
}
// FaceId is a stored face-recognition template for passwordless sign-in.
type FaceId struct {
Name string `json:"name,omitempty"`
FaceIdData []float64 `json:"faceIdData,omitempty"`
ImageUrl string `json:"imageUrl,omitempty"`
}
// CartItem is a line item retained from v1 (billing lives in Commerce).
type CartItem struct {
Owner string `json:"owner,omitempty"`
Name string `json:"name,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Price float64 `json:"price,omitempty"`
Quantity int `json:"quantity,omitempty"`
}
// ConsentRecord records the scopes a user granted a given application.
type ConsentRecord struct {
Application string `json:"application,omitempty"`
GrantedScopes []string `json:"grantedScopes,omitempty"`
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// VerificationRecord is a one-time verification code (email/SMS OTP) issued for
// signup, sign-in, password reset, or MFA — the v2 form of the v1 Casdoor
// `verification` row. It is verify-only credential material: Code is the secret
// the caller must echo back, so the send endpoint returns only a status and
// never the record.
//
// The natural key is (Owner, Name): Owner is the organization, Name a generated
// unique id. Receiver — the email/phone the code was sent to — is the indexed
// lookup key the check path resolves the latest unused, unexpired record by.
// Every field carries a real json tag: orm persists the entity as one JSON
// document, so a json:"-" field would never be stored (the same trap that once
// silently dropped User.PasswordHash).
type VerificationRecord struct {
orm.Model[VerificationRecord]
Owner string `json:"owner" orm:"index"`
Name string `json:"name" orm:"index"`
CreatedTime string `json:"createdTime"`
RemoteAddr string `json:"remoteAddr,omitempty"`
User string `json:"user,omitempty"` // owner/name of the resolved user, when known
Provider string `json:"provider,omitempty"` // delivery provider name; "demo" when none
Type string `json:"type"` // "email" | "phone"
Receiver string `json:"receiver" orm:"index"`
Code string `json:"code"`
Time int64 `json:"time"`
IsUsed bool `json:"isUsed"`
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package schema
import "github.com/hanzoai/orm"
// WebauthnCredential is a registered WebAuthn/FIDO2 passkey (v1 Casdoor kind
// "webauthn_credential", v2 kind "webauthn_credentials"). In v1 there is no
// standalone table: the credentials live inline on the user row as the
// `webauthnCredentials` blob column — a JSON array of go-webauthn Credential
// values. v2 promotes each element to its own owner-scoped row so a passkey is
// an addressable, revocable entity. Field complete against the v1 credential so
// no key material, transport hint, or clone-detection counter is lost on
// migration.
//
// Identity is the (Owner, Name) pair and the orm string key is "owner/name".
// Name is the standard-base64 encoding of the raw credential id — the same
// value v1 used to locate a credential for deletion — which is unique within
// the owning user. User is the "owner/name" id of the principal this passkey
// authenticates: v2 linkage that replaces v1's inline containment on the user
// row. CreatedTime is stamped at registration for the newest-first list order.
//
// Transport carries orm:"serialize" so the column backends (hanzoai/sql,
// hanzoai/datastore) persist it through its string sibling; the default SQLite
// store round-trips the slice inside the entity JSON blob and leaves the
// sibling empty. CredentialId, PublicKey, and Aaguid stay []byte so they
// marshal to the exact base64 JSON form v1 wrote inside the blob. The
// go-webauthn Flags and Authenticator sub-structs are flattened into scalar
// columns here — one value per column, no nested blob.
type WebauthnCredential struct {
orm.Model[WebauthnCredential]
Owner string `json:"owner"`
Name string `json:"name"`
CreatedTime string `json:"createdTime"`
User string `json:"user"`
CredentialId []byte `json:"credentialId"`
PublicKey []byte `json:"publicKey"`
AttestationType string `json:"attestationType"`
Transport []string `json:"transport" orm:"serialize" datastore:"-"`
Transport_ string `json:"-"`
UserPresent bool `json:"userPresent"`
UserVerified bool `json:"userVerified"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
Aaguid []byte `json:"aaguid"`
SignCount uint32 `json:"signCount"`
CloneWarning bool `json:"cloneWarning"`
Attachment string `json:"attachment"`
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package seed bootstraps the iam2 store from an init_data.json file — the same
// file the Casdoor iam uses. This is the ported InitFromFile behavior: on boot,
// upsert organizations, applications, providers, and certs so a fresh iam2
// (embedded in cloud or standalone) comes up with the real app/provider/cert
// config instead of an empty store.
//
// New-only by default (like Casdoor's initDataNewOnly): an entity that already
// exists is left untouched; only missing ones are created. ${VAR} references in
// the JSON (client ids/secrets, cert keys) are substituted from the environment
// before parsing — the same mechanism that injects KMS-synced secrets.
package seed
import (
"context"
"encoding/json"
"fmt"
"os"
"regexp"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/schema"
)
// initData is the subset of the init_data.json shape iam2 seeds. Users and the
// Casbin/LDAP/syncer artifacts are deliberately excluded — identity config only.
type initData struct {
Organizations []*schema.Organization `json:"organizations"`
Applications []*schema.Application `json:"applications"`
Providers []*schema.Provider `json:"providers"`
Certs []*schema.Cert `json:"certs"`
}
// Summary reports what a seed run created vs skipped.
type Summary struct {
Created map[string]int // kind -> created count
Skipped map[string]int // kind -> already-existed count
}
var envRef = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`)
// substituteEnv replaces ${VAR} with os.Getenv(VAR). An unset var becomes empty
// (Casdoor-compatible) — a provider/cert with an empty credential simply reads
// as unconfigured downstream, never a dead-end.
func substituteEnv(b []byte) []byte {
return envRef.ReplaceAllFunc(b, func(m []byte) []byte {
name := envRef.FindSubmatch(m)[1]
return []byte(os.Getenv(string(name)))
})
}
// FromInitData reads path, substitutes ${VAR}, and upserts the identity config
// into db. new-only: existing rows (by owner/name) are skipped.
func FromInitData(ctx context.Context, db orm.DB, path string) (*Summary, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("seed: read %s: %w", path, err)
}
var data initData
if err := json.Unmarshal(substituteEnv(raw), &data); err != nil {
return nil, fmt.Errorf("seed: parse %s: %w", path, err)
}
return Apply(ctx, db, &data)
}
// Apply upserts an already-parsed initData. Split out so tests can seed from a
// literal without a file.
func Apply(ctx context.Context, db orm.DB, data *initData) (*Summary, error) {
s := &Summary{Created: map[string]int{}, Skipped: map[string]int{}}
for _, o := range data.Organizations {
if err := upsert[schema.Organization](ctx, db, o.Owner, o.Name, o, s, "organizations"); err != nil {
return s, err
}
}
for _, p := range data.Providers {
if err := upsert[schema.Provider](ctx, db, p.Owner, p.Name, p, s, "providers"); err != nil {
return s, err
}
}
for _, c := range data.Certs {
if err := upsert[schema.Cert](ctx, db, c.Owner, c.Name, c, s, "certs"); err != nil {
return s, err
}
}
for _, a := range data.Applications {
if err := upsert[schema.Application](ctx, db, a.Owner, a.Name, a, s, "applications"); err != nil {
return s, err
}
}
return s, nil
}
// upsert creates entity if (owner,name) is absent; otherwise counts it skipped
// (new-only). GetOrCreate wires a fresh Model + sets the id; the defaults func
// copies the entity's data fields via a JSON round-trip, which sets only the
// json-tagged fields and leaves the wired Model's internals (db handle, key)
// intact — the generic-safe way to persist a fully-formed struct.
func upsert[T any](_ context.Context, db orm.DB, owner, name string, entity *T, s *Summary, kind string) error {
if owner == "" {
owner = "admin"
}
id := owner + "/" + name
blob, err := json.Marshal(entity)
if err != nil {
return fmt.Errorf("seed: marshal %s %s: %w", kind, id, err)
}
_, created, err := orm.GetOrCreate[T](db, id, func(dst *T) {
_ = json.Unmarshal(blob, dst)
})
if err != nil {
return fmt.Errorf("seed: create %s %s: %w", kind, id, err)
}
if created {
s.Created[kind]++
} else {
s.Skipped[kind]++
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package seed
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
"github.com/hanzoai/iam2/internal/schema"
)
func openDB(t *testing.T) orm.DB {
t.Helper()
_ = schema.Kinds() // force schema init() (kind registration)
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: filepath.Join(t.TempDir(), "seed.db"),
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
const fixture = `{
"organizations": [{"owner":"admin","name":"hanzo"}],
"certs": [{"owner":"admin","name":"cert-hanzo","cryptoAlgorithm":"RS256","privateKey":"${TEST_CERT_KEY}"}],
"providers": [{"owner":"admin","name":"provider-github","category":"OAuth","type":"GitHub","clientId":"${TEST_GH_ID}"}],
"applications": [{"owner":"admin","name":"hanzo-console","clientId":"hanzo-console","organization":"hanzo","enablePassword":true}]
}`
func TestFromInitData_SeedsAndSubstitutesEnv(t *testing.T) {
db := openDB(t)
ctx := context.Background()
t.Setenv("TEST_CERT_KEY", "PEMDATA")
t.Setenv("TEST_GH_ID", "Iv23-real-github-id")
path := filepath.Join(t.TempDir(), "init_data.json")
if err := os.WriteFile(path, []byte(fixture), 0o600); err != nil {
t.Fatal(err)
}
sum, err := FromInitData(ctx, db, path)
if err != nil {
t.Fatalf("seed: %v", err)
}
for _, kind := range []string{"organizations", "certs", "providers", "applications"} {
if sum.Created[kind] != 1 {
t.Fatalf("%s created = %d, want 1", kind, sum.Created[kind])
}
}
// The application is resolvable and carries its fields.
app, err := orm.Get[schema.Application](db, "admin/hanzo-console")
if err != nil || app == nil {
t.Fatalf("app not seeded: %v", err)
}
if app.ClientId != "hanzo-console" || app.Organization != "hanzo" || !app.EnablePassword {
t.Fatalf("app fields wrong: clientId=%q org=%q pw=%v", app.ClientId, app.Organization, app.EnablePassword)
}
// ${VAR} was substituted from the environment (KMS-style injection).
prov, err := orm.Get[schema.Provider](db, "admin/provider-github")
if err != nil || prov == nil {
t.Fatalf("provider not seeded: %v", err)
}
if prov.ClientId != "Iv23-real-github-id" {
t.Fatalf("env not substituted: clientId=%q", prov.ClientId)
}
cert, _ := orm.Get[schema.Cert](db, "admin/cert-hanzo")
if cert == nil || cert.PrivateKey != "PEMDATA" {
t.Fatalf("cert key not substituted: %+v", cert)
}
}
func TestFromInitData_NewOnlyIdempotent(t *testing.T) {
db := openDB(t)
ctx := context.Background()
path := filepath.Join(t.TempDir(), "init_data.json")
_ = os.WriteFile(path, []byte(fixture), 0o600)
if _, err := FromInitData(ctx, db, path); err != nil {
t.Fatal(err)
}
// Second run: everything already exists → all skipped, nothing created.
sum, err := FromInitData(ctx, db, path)
if err != nil {
t.Fatal(err)
}
for _, kind := range []string{"organizations", "certs", "providers", "applications"} {
if sum.Created[kind] != 0 || sum.Skipped[kind] != 1 {
t.Fatalf("%s: created=%d skipped=%d, want 0/1 (new-only)", kind, sum.Created[kind], sum.Skipped[kind])
}
}
}
func TestSubstituteEnv_UnsetBecomesEmpty(t *testing.T) {
os.Unsetenv("DEFINITELY_UNSET_VAR_XYZ")
got := substituteEnv([]byte(`x=${DEFINITELY_UNSET_VAR_XYZ}y`))
if string(got) != "x=y" {
t.Fatalf("unset var: got %q, want %q", got, "x=y")
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package sessions
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
// CookieName is the portal session cookie the native front-door sets on a bare
// (type=login) sign-in and that get-account resolves the caller from. One name,
// platform-wide.
const CookieName = "hanzo_session"
// Cookie is the tamper-evident session a signed cookie carries. It keys the
// Session row directly — (Owner, Name, Application) — so resolution needs no
// scan, and SID is the per-cookie id checked against that row's SessionId list
// for revocation. Owner is the field the gateway admin-guard reads to derive the
// global-admin predicate, so the signature is what makes it unforgeable.
type Cookie struct {
Owner string `json:"o"`
Name string `json:"n"`
Application string `json:"a"`
SID string `json:"s"`
Expiry int64 `json:"e"` // unix seconds
}
var (
// ErrCookieMalformed — not a "<payload>.<mac>" pair or not decodable.
ErrCookieMalformed = errors.New("session cookie is malformed")
// ErrCookieSignature — the HMAC does not verify (forged or wrong key).
ErrCookieSignature = errors.New("session cookie signature is invalid")
// ErrCookieExpired — past its expiry.
ErrCookieExpired = errors.New("session cookie is expired")
)
// SessionKey derives the cookie HMAC key from the platform signing cert's private
// key material — a stable, secret, per-deployment value, so there is NO new
// secret to provision and cookies survive restarts. Domain-separated so the key
// can never collide with any other use of the cert.
func SessionKey(certPrivateKeyPEM string) []byte {
sum := sha256.Sum256([]byte("iam2.session.cookie.v1\x00" + certPrivateKeyPEM))
return sum[:]
}
// NewSID mints a 256-bit random session id (URL-safe, no padding).
func NewSID() string {
var b [32]byte
_, _ = rand.Read(b[:])
return base64.RawURLEncoding.EncodeToString(b[:])
}
// Issue serializes and signs a session cookie: base64url(payload).base64url(mac).
// A caller that leaves SID empty gets a fresh one; Expiry ≤ 0 defaults to ttl
// from now.
func Issue(c Cookie, key []byte, ttl time.Duration) string {
if c.SID == "" {
c.SID = NewSID()
}
if c.Expiry <= 0 {
c.Expiry = time.Now().Add(ttl).Unix()
}
payload, _ := json.Marshal(c)
return b64(payload) + "." + b64(mac(payload, key))
}
// Verify checks the signature (constant-time) then the expiry, returning the
// carried claims. It NEVER trusts the payload before the MAC verifies — an
// attacker who flips `o` to the admin org fails the signature check.
func Verify(value string, key []byte) (*Cookie, error) {
rawPayload, rawMac, ok := strings.Cut(value, ".")
if !ok {
return nil, ErrCookieMalformed
}
payload, err := unb64(rawPayload)
if err != nil {
return nil, ErrCookieMalformed
}
gotMac, err := unb64(rawMac)
if err != nil {
return nil, ErrCookieMalformed
}
if subtle.ConstantTimeCompare(gotMac, mac(payload, key)) != 1 {
return nil, ErrCookieSignature
}
var c Cookie
if err := json.Unmarshal(payload, &c); err != nil {
return nil, ErrCookieMalformed
}
if c.Expiry > 0 && time.Now().Unix() >= c.Expiry {
return nil, ErrCookieExpired
}
return &c, nil
}
func mac(payload, key []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(payload)
return h.Sum(nil)
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package sessions
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
)
func TestCookie_RoundTrip(t *testing.T) {
key := SessionKey("-----BEGIN KEY-----\nabc\n-----END KEY-----")
in := Cookie{Owner: "hanzo", Name: "alice", Application: "hanzo-cloud"}
got, err := Verify(Issue(in, key, time.Hour), key)
if err != nil {
t.Fatal(err)
}
if got.Owner != "hanzo" || got.Name != "alice" || got.Application != "hanzo-cloud" {
t.Fatalf("round-trip mismatch: %+v", got)
}
if got.SID == "" || got.Expiry <= time.Now().Unix() {
t.Fatalf("Issue must mint a SID and a future expiry: %+v", got)
}
}
// THE security property: an attacker cannot flip `owner` to the admin org. Any
// tamper with the payload invalidates the signature.
func TestCookie_ForgedOwnerRejected(t *testing.T) {
key := SessionKey("platform-cert-pem")
value := Issue(Cookie{Owner: "maxpower", Name: "dave", Application: "hanzo-cloud"}, key, time.Hour)
// Re-encode the payload with owner="admin", keep the original MAC.
payloadB64, macB64, _ := strings.Cut(value, ".")
payload, _ := base64.RawURLEncoding.DecodeString(payloadB64)
var c Cookie
_ = json.Unmarshal(payload, &c)
c.Owner = "admin" // the privilege-escalation attempt
forged, _ := json.Marshal(c)
tampered := base64.RawURLEncoding.EncodeToString(forged) + "." + macB64
if _, err := Verify(tampered, key); err != ErrCookieSignature {
t.Fatalf("forged owner=admin must fail signature, got err=%v", err)
}
}
func TestCookie_WrongKeyRejected(t *testing.T) {
value := Issue(Cookie{Owner: "hanzo", Name: "alice"}, SessionKey("cert-A"), time.Hour)
if _, err := Verify(value, SessionKey("cert-B")); err != ErrCookieSignature {
t.Fatalf("a cookie signed with cert-A must not verify under cert-B, got %v", err)
}
}
func TestCookie_Expired(t *testing.T) {
key := SessionKey("k")
value := Issue(Cookie{Owner: "hanzo", Name: "alice", Expiry: time.Now().Add(-time.Second).Unix()}, key, time.Hour)
if _, err := Verify(value, key); err != ErrCookieExpired {
t.Fatalf("expired cookie must be rejected, got %v", err)
}
}
func TestCookie_Malformed(t *testing.T) {
key := SessionKey("k")
for _, v := range []string{"", "no-dot", "not-base64.$$$", "onlyone."} {
if _, err := Verify(v, key); err == nil {
t.Errorf("malformed %q must error", v)
}
}
}
func TestNewSID_UniqueAndSized(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
s := NewSID()
if seen[s] {
t.Fatal("NewSID collision")
}
seen[s] = true
if b, _ := base64.RawURLEncoding.DecodeString(s); len(b) != 32 {
t.Fatalf("SID = %d bytes, want 32", len(b))
}
}
}
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package sessions serves the IAM v2 session resource as typed zip operations
// over hanzoai/orm. Every operation is a zip.Post[In, Out] typed handler, so a
// single registration projects three ways at once — a REST route, an OpenAPI
// 3.1 operation, and an MCP tool.
//
// zip's typed handlers source their input from the request body (REST) or the
// tool-call arguments (MCP); the GET projection carries no body, so every
// operation that needs the (owner, name, application) key travels as a POST
// with that key on the typed In. Owner-scoping is therefore a property of the
// payload, never of a path parameter.
package sessions
import (
"context"
"errors"
"fmt"
"time"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// maxSessionIds caps the retained cookie list per session, matching the v1
// bound so a long-lived principal can't grow the row without limit.
const maxSessionIds = 100
// Sessions binds the session CRUD operations to an orm store.
type Sessions struct{ db orm.DB }
// Mount registers the session operations on app against db.
func Mount(app *zip.App, db orm.DB) {
h := &Sessions{db: db}
zip.Post(app, "/v1/iam/sessions/list", h.List,
zip.WithSummary("List an owner's sessions, newest first"),
zip.WithTags("sessions"), zip.WithOperationID("listSessions"))
zip.Post(app, "/v1/iam/sessions/get", h.Get,
zip.WithSummary("Get one session by owner/name/application"),
zip.WithTags("sessions"), zip.WithOperationID("getSession"))
zip.Post(app, "/v1/iam/sessions/create", h.Create,
zip.WithSummary("Create or merge a session (upsert)"),
zip.WithTags("sessions"), zip.WithOperationID("createSession"))
zip.Post(app, "/v1/iam/sessions/update", h.Update,
zip.WithSummary("Replace a session's cookie list"),
zip.WithTags("sessions"), zip.WithOperationID("updateSession"))
zip.Post(app, "/v1/iam/sessions/delete", h.Delete,
zip.WithSummary("Delete a session"),
zip.WithTags("sessions"), zip.WithOperationID("deleteSession"))
}
// SessionRef identifies one session by its (owner, name, application) key —
// the same triple v1 joins into "owner/name/application".
type SessionRef struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
Application string `json:"application" validate:"required"`
}
// ListSessionsIn scopes a list to one owner, optionally narrowed to a single
// principal (Name) and/or Application. Only Owner is required.
type ListSessionsIn struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name"`
Application string `json:"application"`
}
// ListSessionsOut is the owner-scoped result, newest first.
type ListSessionsOut struct {
Sessions []*schema.Session `json:"sessions"`
}
// CreateSessionIn is the create/merge payload: the key plus the initial cookie
// list. When ExclusiveSignin is set, an existing session's cookie list is
// collapsed to the single incoming cookie rather than appended (v1 AddSession).
type CreateSessionIn struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
Application string `json:"application" validate:"required"`
SessionId []string `json:"sessionId"`
ExclusiveSignin bool `json:"exclusiveSignin"`
}
// UpdateSessionIn replaces the cookie list of an existing session addressed by
// its key.
type UpdateSessionIn struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
Application string `json:"application" validate:"required"`
SessionId []string `json:"sessionId"`
}
// DeleteSessionOut reports whether the addressed session existed and was
// removed.
type DeleteSessionOut struct {
Deleted bool `json:"deleted"`
}
// List returns every session for an owner, optionally filtered by principal
// and/or application, ordered newest-first on CreatedTime.
func (h *Sessions) List(ctx context.Context, in *ListSessionsIn) (*ListSessionsOut, error) {
q := orm.TypedQuery[schema.Session](h.db).Filter("Owner=", in.Owner)
if in.Name != "" {
q = q.Filter("Name=", in.Name)
}
if in.Application != "" {
q = q.Filter("Application=", in.Application)
}
sessions, err := q.Order("-CreatedTime").GetAll(ctx)
if err != nil {
return nil, err
}
return &ListSessionsOut{Sessions: sessions}, nil
}
// Get resolves one session by its full (owner, name, application) key.
func (h *Sessions) Get(_ context.Context, in *SessionRef) (*schema.Session, error) {
s, err := orm.Get[schema.Session](h.db, sessionID(in.Owner, in.Name, in.Application))
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("session not found")
}
return nil, err
}
return s, nil
}
// Create upserts a session: a new key is inserted, an existing one has its
// incoming cookie ids merged in (deduped, capped, or — under ExclusiveSignin —
// collapsed to the single incoming cookie), mirroring v1 AddSession.
func (h *Sessions) Create(_ context.Context, in *CreateSessionIn) (*schema.Session, error) {
id := sessionID(in.Owner, in.Name, in.Application)
existing, err := orm.Get[schema.Session](h.db, id)
if err != nil && !errors.Is(err, orm.ErrNotFound) {
return nil, err
}
if existing != nil {
existing.SessionId = mergeSessionIds(existing.SessionId, in.SessionId, in.ExclusiveSignin)
existing.CreatedTime = now()
if err := existing.Update(); err != nil {
return nil, err
}
return existing, nil
}
s := orm.New[schema.Session](h.db)
s.SetId(id)
s.Owner = in.Owner
s.Name = in.Name
s.Application = in.Application
s.SessionId = mergeSessionIds(nil, in.SessionId, in.ExclusiveSignin)
s.CreatedTime = now()
if err := s.Create(); err != nil {
return nil, err
}
return s, nil
}
// Update replaces the cookie list of an existing session. The key is
// immutable, so a missing row is a 404 rather than an implicit insert.
func (h *Sessions) Update(_ context.Context, in *UpdateSessionIn) (*schema.Session, error) {
s, err := orm.Get[schema.Session](h.db, sessionID(in.Owner, in.Name, in.Application))
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("session not found")
}
return nil, err
}
s.SessionId = capSessionIds(in.SessionId)
if err := s.Update(); err != nil {
return nil, err
}
return s, nil
}
// Delete removes a session by key. A missing row reports Deleted=false rather
// than an error, so the call is idempotent.
func (h *Sessions) Delete(_ context.Context, in *SessionRef) (*DeleteSessionOut, error) {
s, err := orm.Get[schema.Session](h.db, sessionID(in.Owner, in.Name, in.Application))
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return &DeleteSessionOut{Deleted: false}, nil
}
return nil, err
}
if err := s.Delete(); err != nil {
return nil, err
}
return &DeleteSessionOut{Deleted: true}, nil
}
// sessionID composes the orm string id from the three key parts, byte-identical
// to v1 Session.GetId().
func sessionID(owner, name, application string) string {
return fmt.Sprintf("%s/%s/%s", owner, name, application)
}
// now returns the current UTC timestamp in the v1 string format.
func now() string { return time.Now().UTC().Format(time.RFC3339) }
// mergeSessionIds folds incoming cookie ids into existing ones. ExclusiveSignin
// collapses the result to the single incoming cookie; otherwise the union is
// preserved in order, deduped, and capped to the newest maxSessionIds.
func mergeSessionIds(existing, incoming []string, exclusive bool) []string {
if exclusive {
if len(incoming) > 0 {
return []string{incoming[0]}
}
return nil
}
seen := make(map[string]struct{}, len(existing)+len(incoming))
out := make([]string, 0, len(existing)+len(incoming))
for _, id := range append(append([]string{}, existing...), incoming...) {
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return capSessionIds(out)
}
// capSessionIds keeps only the newest maxSessionIds cookie ids.
func capSessionIds(ids []string) []string {
if len(ids) > maxSessionIds {
return ids[len(ids)-maxSessionIds:]
}
return ids
}
+283
View File
@@ -0,0 +1,283 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package store is the IAM v2 object layer: thin, typed reads over hanzoai/orm
// against the Phase-1 entities. It replaces the v1 xorm ormer.Engine fluent
// calls with orm.TypedQuery, so handlers depend on named operations
// (GetApplicationByClientId, GetProvider, …) rather than a query builder.
//
// Every function takes a context and an orm.DB — one storage abstraction,
// backend-agnostic (sqlite / hanzoai/sql / hanzoai/datastore).
package store
import (
"context"
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/schema"
)
// GetApplicationByClientId resolves an OAuth2/OIDC client by its clientId.
// Returns (nil, nil) when no application matches (a not-found is not an error
// at this layer — the handler decides the response).
func GetApplicationByClientId(_ context.Context, db orm.DB, clientId string) (*schema.Application, error) {
if clientId == "" {
return nil, nil
}
app, err := orm.TypedQuery[schema.Application](db).Filter("ClientId=", clientId).First()
if err == orm.ErrNotFound {
return nil, nil
}
return app, err
}
// GetApplicationByName resolves an application by (owner, name).
func GetApplicationByName(_ context.Context, db orm.DB, owner, name string) (*schema.Application, error) {
app, err := orm.TypedQuery[schema.Application](db).
Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return app, err
}
// GetUserByName resolves a user by (owner, name) — owner is the organization.
// Returns (nil, nil) when absent.
func GetUserByName(_ context.Context, db orm.DB, owner, name string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return u, err
}
// GetUserByEmail resolves a user by (owner, email) — the email-login identifier.
func GetUserByEmail(_ context.Context, db orm.DB, owner, email string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", owner).Filter("Email=", email).First()
if err == orm.ErrNotFound {
return nil, nil
}
return u, err
}
// GetTokenByCode resolves a token row by its authorization code. Returns
// (nil, nil) when no row carries the code.
func GetTokenByCode(_ context.Context, db orm.DB, code string) (*schema.Token, error) {
if code == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// GetCert resolves a signing certificate by (owner, name).
func GetCert(_ context.Context, db orm.DB, owner, name string) (*schema.Cert, error) {
c, err := orm.TypedQuery[schema.Cert](db).Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return c, err
}
// signingCertOwners are the reserved platform organizations that own
// token-signing certificates. A signing cert is trusted ONLY under these
// owners, so a tenant can never shadow a platform signing key by creating a cert
// with the same name (the JWKS `kid`) under its own org and forging tokens.
var signingCertOwners = []string{"admin", "built-in"}
// IsSigningCertOwner reports whether owner is a reserved platform signing-cert
// owner — the trust boundary the JWKS and token verification enforce.
func IsSigningCertOwner(owner string) bool {
for _, o := range signingCertOwners {
if o == owner {
return true
}
}
return false
}
// GetSigningCert resolves a TRUSTED signing certificate by name (the JWKS
// `kid`), searching only the reserved platform owners in order. A cert owned by
// any other org is never returned, so an attacker-created cert with a colliding
// name can neither sign a token iam2 will verify nor be published in the JWKS.
// Returns (nil, nil) when no trusted cert carries the name.
func GetSigningCert(ctx context.Context, db orm.DB, name string) (*schema.Cert, error) {
if name == "" {
return nil, nil
}
for _, owner := range signingCertOwners {
c, err := GetCert(ctx, db, owner, name)
if err != nil {
return nil, err
}
if c != nil {
return c, nil
}
}
return nil, nil
}
// PersistToken wires a domain Token onto the store and creates it. Used to
// persist an authorization code minted by oidc.MintCode. The id is (owner, name);
// callers set Name to a unique value (e.g. the code) before persisting.
func PersistToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
t := orm.New[schema.Token](db)
model := t.Model
*t = *tok
t.Model = model
name := tok.Name
if name == "" {
name = tok.Code // codes are unique; use as the row name when none given
t.Name = name
}
t.SetId(tok.Owner + "/" + name)
return t.CreateCtx(ctx)
}
// SaveToken read-modify-writes an existing token row (e.g. after redemption:
// CodeIsUsed=true + AccessToken set). It looks the row up by (owner, name),
// copies the mutated fields, and updates in place.
func SaveToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
if err != nil {
return err
}
model := existing.Model
*existing = *tok
existing.Model = model
return existing.UpdateCtx(ctx)
}
// ListCerts returns every certificate ordered by name. The JWKS endpoint calls
// this and filters to the token-signing certs it publishes.
func ListCerts(ctx context.Context, db orm.DB) ([]*schema.Cert, error) {
return orm.TypedQuery[schema.Cert](db).Order("Name").GetAll(ctx)
}
// GetTokenByAccessTokenHash resolves a live token row by the SHA-256 hash of a
// presented access token — the userinfo bearer lookup. Because the row is the
// authorization server's memory of the grant, a deleted/rotated row means the
// bearer is revoked, independent of the JWT's own expiry.
func GetTokenByAccessTokenHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
if hash == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("AccessTokenHash=", hash).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// GetTokenByRefreshHash resolves a token row by the SHA-256 hash of a presented
// refresh token — the refresh-grant lookup.
func GetTokenByRefreshHash(_ context.Context, db orm.DB, hash string) (*schema.Token, error) {
if hash == "" {
return nil, nil
}
t, err := orm.TypedQuery[schema.Token](db).Filter("RefreshTokenHash=", hash).First()
if err == orm.ErrNotFound {
return nil, nil
}
return t, err
}
// ListTokensByRefreshFamily returns every row sharing a refresh-token family —
// the rotation chain a reuse-detection event revokes as a unit.
func ListTokensByRefreshFamily(ctx context.Context, db orm.DB, family string) ([]*schema.Token, error) {
if family == "" {
return nil, nil
}
return orm.TypedQuery[schema.Token](db).Filter("RefreshFamily=", family).GetAll(ctx)
}
// DeleteToken removes a token row by (owner, name). A missing row is not an
// error — revocation is idempotent.
func DeleteToken(ctx context.Context, db orm.DB, tok *schema.Token) error {
existing, err := orm.Get[schema.Token](db, tok.Owner+"/"+tok.Name)
if err != nil {
if err == orm.ErrNotFound {
return nil
}
return err
}
return existing.DeleteCtx(ctx)
}
// GetProvider resolves a provider record by (owner, name) — e.g.
// ("admin", "provider-github"). Providers are shared org-level records the
// application's ProviderItem links to by name.
func GetProvider(_ context.Context, db orm.DB, owner, name string) (*schema.Provider, error) {
p, err := orm.TypedQuery[schema.Provider](db).
Filter("Owner=", owner).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return p, err
}
// EnrichProviders resolves each of the application's ProviderItem links to its
// shared Provider record (Category/Type/ClientId), attaching it to
// item.Provider. A link whose provider record is missing is left with a nil
// Provider (the caller treats that as unconfigured — never a dead-end button).
func EnrichProviders(ctx context.Context, db orm.DB, app *schema.Application) {
if app == nil {
return
}
for _, item := range app.Providers {
if item == nil || item.Name == "" {
continue
}
owner := item.Owner
if owner == "" {
owner = "admin" // providers are seeded under the admin org
}
if p, err := GetProvider(ctx, db, owner, item.Name); err == nil && p != nil {
item.Provider = p
}
}
}
// GetOrganizationByName resolves an organization by its name. Orgs are stored
// under the "admin" owner (v1 convention). Returns (nil, nil) when absent.
func GetOrganizationByName(_ context.Context, db orm.DB, name string) (*schema.Organization, error) {
if name == "" {
return nil, nil
}
o, err := orm.TypedQuery[schema.Organization](db).Filter("Name=", name).First()
if err == orm.ErrNotFound {
return nil, nil
}
return o, err
}
// AddVerificationRecord persists a freshly minted verification code. The id is
// (owner, name); the caller sets Name to a unique value before persisting.
// Mirrors PersistToken: the orm.Model is preserved while the caller's fields are
// copied onto the fresh, db-wired entity.
func AddVerificationRecord(ctx context.Context, db orm.DB, rec *schema.VerificationRecord) error {
r := orm.New[schema.VerificationRecord](db)
model := r.Model
*r = *rec
r.Model = model
r.SetId(rec.Owner + "/" + rec.Name)
return r.CreateCtx(ctx)
}
// GetLatestVerificationRecord resolves the most recent UNUSED verification
// record sent to receiver — the row the check path validates a submitted code
// against. Returns (nil, nil) when none exists.
func GetLatestVerificationRecord(_ context.Context, db orm.DB, receiver string) (*schema.VerificationRecord, error) {
if receiver == "" {
return nil, nil
}
rec, err := orm.TypedQuery[schema.VerificationRecord](db).
Filter("Receiver=", receiver).Filter("IsUsed=", false).Order("-Time").First()
if err == orm.ErrNotFound {
return nil, nil
}
return rec, err
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package tokens is the Phase-1 typed CRUD surface for the `tokens` entity
// (an issued OAuth2/OIDC token record), 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 tokens
import (
"context"
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// tokenId renders the (owner, name) pair as the orm row id so Get, Update, and
// Delete resolve by natural key without a secondary lookup.
func tokenId(owner, name string) string { return owner + "/" + name }
// tokenKey is the (owner, name) selector for get and delete.
type tokenKey struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
}
// listTokensIn scopes a list to one owner and, optionally, one organization —
// mirroring v1 GetTokens(owner, organization). An empty owner lists every token
// (superuser view); an empty organization does not filter on organization.
type listTokensIn struct {
Owner string `json:"owner"`
Organization string `json:"organization"`
}
type listTokensOut struct {
Tokens []*schema.Token `json:"tokens"`
}
type tokenResult struct {
Token *schema.Token `json:"token"`
}
// tokenMutation mirrors v1's Affected/Unaffected action response and carries
// the resulting row on a successful write.
type tokenMutation struct {
Affected bool `json:"affected"`
Token *schema.Token `json:"token,omitempty"`
}
// Mount registers the token surface on app, closing over the entity store.
func Mount(app *zip.App, db orm.DB) {
zip.Get[listTokensIn, listTokensOut](app, "/v1/iam/tokens", listTokens(db),
zip.WithOperationID("listTokens"),
zip.WithSummary("List tokens in an owner scope"),
zip.WithTags("tokens"))
zip.Post[tokenKey, tokenResult](app, "/v1/iam/tokens/get", getToken(db),
zip.WithOperationID("getToken"),
zip.WithSummary("Get one token by (owner, name)"),
zip.WithTags("tokens"))
zip.Post[schema.Token, tokenResult](app, "/v1/iam/tokens", addToken(db),
zip.WithOperationID("addToken"),
zip.WithSummary("Create a token"),
zip.WithTags("tokens"))
zip.Post[schema.Token, tokenMutation](app, "/v1/iam/tokens/update", updateToken(db),
zip.WithOperationID("updateToken"),
zip.WithSummary("Update an existing token"),
zip.WithTags("tokens"))
zip.Post[tokenKey, tokenMutation](app, "/v1/iam/tokens/delete", deleteToken(db),
zip.WithOperationID("deleteToken"),
zip.WithSummary("Delete a token by (owner, name)"),
zip.WithTags("tokens"))
}
// listTokens returns every token in the owner scope, newest first, optionally
// narrowed to one organization.
func listTokens(db orm.DB) zip.TypedHandler[listTokensIn, listTokensOut] {
return func(ctx context.Context, in *listTokensIn) (*listTokensOut, error) {
q := orm.TypedQuery[schema.Token](db)
if in.Owner != "" {
q = q.Filter("Owner=", in.Owner)
}
if in.Organization != "" {
q = q.Filter("Organization=", in.Organization)
}
rows, err := q.Order("-CreatedTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &listTokensOut{Tokens: rows}, nil
}
}
// getToken resolves one token by its (owner, name) key.
func getToken(db orm.DB) zip.TypedHandler[tokenKey, tokenResult] {
return func(_ context.Context, in *tokenKey) (*tokenResult, error) {
t, err := orm.Get[schema.Token](db, tokenId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("token not found: " + tokenId(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &tokenResult{Token: t}, nil
}
}
// addToken creates a token from the request body, keyed by (owner, name).
func addToken(db orm.DB) zip.TypedHandler[schema.Token, tokenResult] {
return func(ctx context.Context, in *schema.Token) (*tokenResult, 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.
t := orm.New[schema.Token](db)
model := t.Model
*t = *in
t.Model = model
t.SetId(tokenId(in.Owner, in.Name))
if err := t.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &tokenResult{Token: t}, nil
}
}
// updateToken read-modify-writes a token in place. A missing row is reported as
// Unaffected (v1 UpdateToken returns false), not an error.
func updateToken(db orm.DB) zip.TypedHandler[schema.Token, tokenMutation] {
return func(ctx context.Context, in *schema.Token) (*tokenMutation, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
t, err := orm.Get[schema.Token](db, tokenId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return &tokenMutation{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 := t.Model
*t = *in
t.Model = model
if err := t.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &tokenMutation{Affected: true, Token: t}, nil
}
}
// deleteToken removes a token by key. A missing row is Unaffected.
func deleteToken(db orm.DB) zip.TypedHandler[tokenKey, tokenMutation] {
return func(ctx context.Context, in *tokenKey) (*tokenMutation, error) {
t, err := orm.Get[schema.Token](db, tokenId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return &tokenMutation{Affected: false}, nil
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := t.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &tokenMutation{Affected: true}, nil
}
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package users mounts the Phase-1 typed CRUD surface for the IAM v2 user
// entity on a zip App, backed by hanzoai/orm. Every operation is owner-scoped
// by the (owner, name) natural key.
//
// This is the authentication entity, so the credential invariant is absolute:
// the plaintext password rides in on the create/update request, is hashed with
// bcrypt exactly once, and is discarded. Only the one-way digest reaches the
// store, and no response ever carries the digest or any other secret material —
// every user returned here passes through schema.User.Mask() (internal/schema/
// mask.go), the single redaction contract shared with the compat aliases.
package users
import (
"context"
"errors"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/cred"
"github.com/hanzoai/iam2/internal/schema"
)
// API binds the user handlers to an orm store. Construct once at boot and mount.
type API struct{ db orm.DB }
// New returns a user API over db — the constructor front-door handlers (e.g. the
// signup endpoint) use to reach the ONE canonical create path (Create hashes the
// password with bcrypt exactly once and returns the redacted row), so a user
// minted at signup is byte-identical to one minted through the CRUD surface.
func New(db orm.DB) *API { return &API{db: db} }
// Mount registers the typed user CRUD handlers on app. Reads use zip.Get and
// writes use zip.Post; both project the same transport-agnostic handler to REST
// and MCP, so the (owner, name) identity in each typed request is honored on
// every transport.
func Mount(app *zip.App, db orm.DB) {
a := &API{db: db}
zip.Post(app, "/v1/iam/users", a.Create, zip.WithTags("users"), zip.WithSummary("Create a user"))
zip.Get(app, "/v1/iam/users", a.List, zip.WithTags("users"), zip.WithSummary("List users in an org"))
zip.Get(app, "/v1/iam/users/get", a.Get, zip.WithTags("users"), zip.WithSummary("Get a user by (owner, name)"))
zip.Post(app, "/v1/iam/users/update", a.Update, zip.WithTags("users"), zip.WithSummary("Update a user"))
zip.Post(app, "/v1/iam/users/delete", a.Delete, zip.WithTags("users"), zip.WithSummary("Delete a user"))
}
// Ref identifies one user by its natural key.
type Ref struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
}
// CreateInput carries a full user profile plus a write-only plaintext password.
// Password is never persisted — it is hashed into schema.User.PasswordHash.
type CreateInput struct {
User schema.User `json:"user"`
Password string `json:"password,omitempty"`
}
// UpdateInput carries the desired user state plus an optional new plaintext
// password. An empty Password leaves the stored digest untouched.
type UpdateInput struct {
User schema.User `json:"user"`
Password string `json:"password,omitempty"`
}
// AuthzTarget reports the (owner, name) this create binds — the user entity is
// the ONE input that nests its record under `user`, so its authorization target
// is in.User.Owner, not a top-level field. Create binds the same values via this
// method, so the value the authorization seam authorizes is exactly the value
// written (internal/authz reads the same method through its owned interface).
func (in *CreateInput) AuthzTarget() (owner, name string) {
return strings.TrimSpace(in.User.Owner), strings.TrimSpace(in.User.Name)
}
// AuthzTarget reports the (owner, name) this update binds, from the nested record
// — the same values Update writes, so authorization and execution never diverge.
func (in *UpdateInput) AuthzTarget() (owner, name string) {
return strings.TrimSpace(in.User.Owner), strings.TrimSpace(in.User.Name)
}
// ListInput is an owner-scoped, paged listing request.
type ListInput struct {
Owner string `json:"owner" validate:"required"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// ListOutput is a page of redacted users plus the owner-scoped total.
type ListOutput struct {
Users []*schema.User `json:"users"`
Total int `json:"total"`
}
// DeleteOutput reports the outcome of a delete.
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
// Create inserts a new owner-scoped user, hashing the plaintext password. The
// (owner, name) it binds is in.AuthzTarget() — the exact pair the authorization
// seam authorized, so execution cannot address a different owner than was checked.
func (a *API) Create(ctx context.Context, in *CreateInput) (*schema.User, error) {
owner, name := in.AuthzTarget()
if owner == "" || name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
existing, err := a.lookup(ctx, owner, name)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if existing != nil {
return nil, zip.ErrConflict("user " + owner + "/" + name + " already exists")
}
u := &in.User
u.Owner, u.Name = owner, name
// Never trust a client-supplied digest; the hash is derived here or nowhere.
u.PasswordHash, u.PasswordSalt = "", ""
u.PasswordType = ""
if in.Password != "" {
hash, err := hashPassword(in.Password)
if err != nil {
return nil, zip.ErrInternal("hash password: " + err.Error())
}
u.PasswordHash = hash
u.PasswordType = "bcrypt"
}
now := nowRFC3339()
u.CreatedTime, u.UpdatedTime = now, now
u.Init(a.db)
if err := u.Create(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return u.Mask(), nil
}
// Get returns one user by (owner, name), redacted.
func (a *API) Get(ctx context.Context, in *Ref) (*schema.User, error) {
u, err := a.lookup(ctx, in.Owner, in.Name)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if u == nil {
return nil, zip.ErrNotFound("user " + in.Owner + "/" + in.Name + " not found")
}
return u.Mask(), nil
}
// List returns a redacted page of users within one owner.
func (a *API) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
if strings.TrimSpace(in.Owner) == "" {
return nil, zip.ErrBadRequest("owner is required")
}
total, err := orm.TypedQuery[schema.User](a.db).Filter("Owner=", in.Owner).Count(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
q := orm.TypedQuery[schema.User](a.db).Filter("Owner=", in.Owner).Order("Name")
if in.Limit > 0 {
q = q.Limit(in.Limit)
}
if in.Offset > 0 {
q = q.Offset(in.Offset)
}
list, err := q.GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
for i, u := range list {
list[i] = u.Mask()
}
return &ListOutput{Users: list, Total: total}, nil
}
// Update replaces the mutable fields of an existing user. Immutable identity
// (orm id, creation time) and the stored digest are preserved unless a new
// plaintext password is supplied.
func (a *API) Update(ctx context.Context, in *UpdateInput) (*schema.User, error) {
owner, name := in.AuthzTarget()
if owner == "" || name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
existing, err := a.lookup(ctx, owner, name)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if existing == nil {
return nil, zip.ErrNotFound("user " + owner + "/" + name + " not found")
}
u := &in.User
u.Owner, u.Name = owner, name
// Preserve immutable identity and creation provenance.
u.CreatedTime = existing.CreatedTime
u.UpdatedTime = nowRFC3339()
// Preserve the existing digest unless a new plaintext password is supplied.
u.PasswordHash = existing.PasswordHash
u.PasswordType = existing.PasswordType
u.PasswordSalt = existing.PasswordSalt
if in.Password != "" {
hash, err := hashPassword(in.Password)
if err != nil {
return nil, zip.ErrInternal("hash password: " + err.Error())
}
u.PasswordHash = hash
u.PasswordType = "bcrypt"
u.PasswordSalt = ""
}
// Retarget the decoded value at the stored row (same orm key), then update.
u.Init(a.db)
u.SetKey(existing.Key())
if err := u.Update(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return u.Mask(), nil
}
// Delete removes a user by (owner, name).
func (a *API) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
existing, err := a.lookup(ctx, in.Owner, in.Name)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if existing == nil {
return nil, zip.ErrNotFound("user " + in.Owner + "/" + in.Name + " not found")
}
if err := existing.Delete(); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &DeleteOutput{Deleted: true}, nil
}
// lookup resolves a single user by its (owner, name) natural key. It returns
// (nil, nil) when no row matches — a not-found is not an error here.
func (a *API) lookup(ctx context.Context, owner, name string) (*schema.User, error) {
u, err := orm.TypedQuery[schema.User](a.db).
Filter("Owner=", owner).
Filter("Name=", name).
First()
if err != nil {
if errors.Is(err, orm.ErrNotFound) {
return nil, nil
}
return nil, err
}
return u, nil
}
// hashPassword derives a one-way bcrypt digest from a plaintext password.
func hashPassword(plaintext string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// VerifyPassword reports whether plaintext matches the user's stored digest,
// resolving the hash algorithm FROM THE ROW (never a constant).
//
// orgPasswordType is the owning organization's PasswordType, used as the
// fallback when the user row carries none — the same resolution v1 does
// (object/check.go: user.PasswordType → organization.PasswordType → dispatch).
// This matters at cutover: every live v1 row is argon2id, and a bcrypt-only
// verifier handed an argon2id PHC digest fails EVERY real login. Fails closed on
// an unknown/unsupported scheme (see internal/cred).
//
// It is the single verify choke point for the login path — the digest itself
// never leaves the store, so verification happens here, against the row.
func VerifyPassword(u *schema.User, plaintext, orgPasswordType string) bool {
if u == nil || u.PasswordHash == "" {
return false
}
return cred.Verify(cred.Resolve(u.PasswordType, orgPasswordType), plaintext, u.PasswordHash)
}
// nowRFC3339 is the single timestamp format for v1-compatible string times.
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339) }
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package webauthn is the Phase-1 typed CRUD surface for the
// `webauthn_credentials` entity (a registered passkey), 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 webauthn
import (
"context"
"errors"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/schema"
)
// webauthnCredentialId renders the (owner, name) pair as the orm row id so Get,
// Update, and Delete resolve by natural key without a secondary lookup.
func webauthnCredentialId(owner, name string) string { return owner + "/" + name }
// webauthnCredentialKey is the (owner, name) selector for get and delete.
type webauthnCredentialKey struct {
Owner string `json:"owner" validate:"required"`
Name string `json:"name" validate:"required"`
}
// listWebauthnCredentialsIn scopes a list to one owner. An empty owner lists
// every credential (superuser view); a set owner filters to that tenant.
type listWebauthnCredentialsIn struct {
Owner string `json:"owner"`
}
type listWebauthnCredentialsOut struct {
WebauthnCredentials []*schema.WebauthnCredential `json:"webauthnCredentials"`
}
type webauthnCredentialResult struct {
WebauthnCredential *schema.WebauthnCredential `json:"webauthnCredential"`
}
// webauthnCredentialMutationResult mirrors v1's Affected/Unaffected action
// response and carries the resulting row on a successful write.
type webauthnCredentialMutationResult struct {
Affected bool `json:"affected"`
WebauthnCredential *schema.WebauthnCredential `json:"webauthnCredential,omitempty"`
}
// Mount registers the passkey surface on app, closing over the entity store.
func Mount(app *zip.App, db orm.DB) {
zip.Get[listWebauthnCredentialsIn, listWebauthnCredentialsOut](app, "/v1/iam/webauthn-credentials", listWebauthnCredentials(db),
zip.WithOperationID("listWebauthnCredentials"),
zip.WithSummary("List webauthn credentials in an owner scope"),
zip.WithTags("webauthn_credentials"))
zip.Post[webauthnCredentialKey, webauthnCredentialResult](app, "/v1/iam/webauthn-credentials/get", getWebauthnCredential(db),
zip.WithOperationID("getWebauthnCredential"),
zip.WithSummary("Get one webauthn credential by (owner, name)"),
zip.WithTags("webauthn_credentials"))
zip.Post[schema.WebauthnCredential, webauthnCredentialResult](app, "/v1/iam/webauthn-credentials", addWebauthnCredential(db),
zip.WithOperationID("addWebauthnCredential"),
zip.WithSummary("Create a webauthn credential"),
zip.WithTags("webauthn_credentials"))
zip.Post[schema.WebauthnCredential, webauthnCredentialMutationResult](app, "/v1/iam/webauthn-credentials/update", updateWebauthnCredential(db),
zip.WithOperationID("updateWebauthnCredential"),
zip.WithSummary("Update an existing webauthn credential"),
zip.WithTags("webauthn_credentials"))
zip.Post[webauthnCredentialKey, webauthnCredentialMutationResult](app, "/v1/iam/webauthn-credentials/delete", deleteWebauthnCredential(db),
zip.WithOperationID("deleteWebauthnCredential"),
zip.WithSummary("Delete a webauthn credential by (owner, name)"),
zip.WithTags("webauthn_credentials"))
}
// listWebauthnCredentials returns every credential in the owner scope, newest
// first.
func listWebauthnCredentials(db orm.DB) zip.TypedHandler[listWebauthnCredentialsIn, listWebauthnCredentialsOut] {
return func(ctx context.Context, in *listWebauthnCredentialsIn) (*listWebauthnCredentialsOut, error) {
q := orm.TypedQuery[schema.WebauthnCredential](db)
if in.Owner != "" {
q = q.Filter("Owner=", in.Owner)
}
rows, err := q.Order("-CreatedTime").GetAll(ctx)
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &listWebauthnCredentialsOut{WebauthnCredentials: rows}, nil
}
}
// getWebauthnCredential resolves one credential by its (owner, name) key.
func getWebauthnCredential(db orm.DB) zip.TypedHandler[webauthnCredentialKey, webauthnCredentialResult] {
return func(_ context.Context, in *webauthnCredentialKey) (*webauthnCredentialResult, error) {
c, err := orm.Get[schema.WebauthnCredential](db, webauthnCredentialId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return nil, zip.ErrNotFound("webauthn credential not found: " + webauthnCredentialId(in.Owner, in.Name))
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &webauthnCredentialResult{WebauthnCredential: c}, nil
}
}
// addWebauthnCredential creates a credential from the request body, keyed by
// (owner, name).
func addWebauthnCredential(db orm.DB) zip.TypedHandler[schema.WebauthnCredential, webauthnCredentialResult] {
return func(ctx context.Context, in *schema.WebauthnCredential) (*webauthnCredentialResult, 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.
c := orm.New[schema.WebauthnCredential](db)
model := c.Model
*c = *in
c.Model = model
c.SetId(webauthnCredentialId(in.Owner, in.Name))
if err := c.CreateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &webauthnCredentialResult{WebauthnCredential: c}, nil
}
}
// updateWebauthnCredential read-modify-writes a credential in place. A missing
// row is reported as Unaffected (v1 returns false), not an error.
func updateWebauthnCredential(db orm.DB) zip.TypedHandler[schema.WebauthnCredential, webauthnCredentialMutationResult] {
return func(ctx context.Context, in *schema.WebauthnCredential) (*webauthnCredentialMutationResult, error) {
if in.Owner == "" || in.Name == "" {
return nil, zip.ErrBadRequest("owner and name are required")
}
c, err := orm.Get[schema.WebauthnCredential](db, webauthnCredentialId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return &webauthnCredentialMutationResult{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 := c.Model
*c = *in
c.Model = model
if err := c.UpdateCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &webauthnCredentialMutationResult{Affected: true, WebauthnCredential: c}, nil
}
}
// deleteWebauthnCredential removes a credential by key. A missing row is
// Unaffected.
func deleteWebauthnCredential(db orm.DB) zip.TypedHandler[webauthnCredentialKey, webauthnCredentialMutationResult] {
return func(ctx context.Context, in *webauthnCredentialKey) (*webauthnCredentialMutationResult, error) {
c, err := orm.Get[schema.WebauthnCredential](db, webauthnCredentialId(in.Owner, in.Name))
if errors.Is(err, orm.ErrNotFound) {
return &webauthnCredentialMutationResult{Affected: false}, nil
}
if err != nil {
return nil, zip.ErrInternal(err.Error())
}
if err := c.DeleteCtx(ctx); err != nil {
return nil, zip.ErrInternal(err.Error())
}
return &webauthnCredentialMutationResult{Affected: true}, nil
}
}
+27 -8
View File
@@ -16,7 +16,7 @@
// (hanzoai/datastore over ZAP). Every handler and the drift tool are written
// once against orm.DB, never against a driver.
//
// Phase 0 serves /v1/iam/v2/health and claims the entity namespace; the
// Phase 0 serves /healthz and claims the entity namespace; the
// resource, OIDC, and authz surfaces land in Phases 1-3. See MIGRATION.md.
package main
@@ -37,6 +37,7 @@ import (
"github.com/hanzoai/iam2/internal/compare"
"github.com/hanzoai/iam2/internal/routes"
_ "github.com/hanzoai/iam2/internal/schema" // registers the v2 entity kinds
"github.com/hanzoai/iam2/internal/seed"
)
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
@@ -63,31 +64,49 @@ func main() {
}
func serveCmd() *cobra.Command {
var store, dbPath, zapAddr, httpAddr string
var storeBackend, dbPath, zapAddr, httpAddr, initData string
cmd := &cobra.Command{
Use: "serve",
Short: "Open the entity store and serve the IAM v2 API",
RunE: func(cmd *cobra.Command, _ []string) error {
return serve(cmd.Context(), store, dbPath, zapAddr, httpAddr)
return serve(cmd.Context(), storeBackend, dbPath, zapAddr, httpAddr, initData)
},
}
f := cmd.Flags()
f.StringVar(&store, "store", "sqlite", "storage backend: sqlite | sql | datastore")
f.StringVar(&storeBackend, "store", "sqlite", "storage backend: sqlite | sql | datastore")
f.StringVar(&dbPath, "db", "data/iam2.db", "SQLite database path (store=sqlite)")
f.StringVar(&zapAddr, "zap", ":9653", "ZAP primary listen address")
f.StringVar(&httpAddr, "http", "http://:8080", "HTTP edge listen address")
f.StringVar(&initData, "init-data", "", "path to init_data.json to seed on boot (new-only; ${VAR} from env)")
return cmd
}
func serve(ctx context.Context, store, dbPath, zapAddr, httpAddr string) error {
db, err := openStore(store, dbPath)
func serve(ctx context.Context, storeBackend, dbPath, zapAddr, httpAddr, initData string) error {
db, err := openStore(storeBackend, dbPath)
if err != nil {
return err
}
defer db.Close()
app := zip.New(zip.Config{AppName: "iam2"})
routes.Mount(app)
// Bootstrap the config (orgs/apps/providers/certs) from init_data.json — the
// same file the Casdoor iam uses — so a fresh store comes up with the real
// application/provider/cert set instead of empty. New-only + idempotent.
if initData != "" {
sum, err := seed.FromInitData(ctx, db, initData)
if err != nil {
return fmt.Errorf("serve: seed: %w", err)
}
fmt.Fprintf(os.Stderr, "iam2: seeded from %s — created orgs=%d apps=%d providers=%d certs=%d\n",
initData, sum.Created["organizations"], sum.Created["applications"],
sum.Created["providers"], sum.Created["certs"])
}
// MCP projects every typed CRUD handler onto one generic /mcp tool-call
// endpoint. The authz Guard gates it like any other route (fail-closed), but
// an identity service has no need to expose its admin CRUD as an agent tool
// surface, so it is disabled outright — one fewer surface to defend.
app := zip.New(zip.Config{AppName: "iam2", MCP: zip.MCPConfig{Disabled: true}})
routes.Mount(app, db)
app.OnShutdown(func(context.Context) error { return db.Close() })
// Translate ctx cancellation (SIGINT/SIGTERM) into a graceful shutdown.
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package server is the PUBLIC embedding surface of iam2: a host binary (cloud)
// imports this and mounts the full IAM v2 HTTP surface onto its own zip app,
// over its own orm.DB. This is how iam2 goes live embedded in hanzoai/cloud
// without a separate pod — the same multi-mode pattern cloud already uses for
// the Casdoor iamserver, but zip-native and lean.
//
// SHADOW-FIRST: the caller decides the mount prefix. Mounted under a shadow
// prefix (e.g. /v2-iam) iam2 runs ALONGSIDE the live Casdoor /v1/iam/* with zero
// impact; only once verified against real traffic does the host flip iam2 onto
// the canonical /v1/iam/* paths. Never blind-replace live auth.
package server
import (
"context"
"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" // registers the entity kinds
"github.com/hanzoai/iam2/internal/seed"
)
// Mount registers the entire IAM v2 surface (OIDC discovery/JWKS, get-app-login,
// auth/methods, token, login, and the v2 entity CRUD) onto app, backed by db.
// This is the one call a host binary makes to embed iam2.
func Mount(app *zip.App, db orm.DB) {
routes.Mount(app, db)
}
// OpenSQLite opens an embedded SQLite store for iam2 at path (WAL). The host may
// instead pass its own orm.DB (e.g. hanzoai/sql over ZAP) to Mount.
func OpenSQLite(path string) (orm.DB, error) {
return orm.OpenSQLite(&ormdb.SQLiteDBConfig{
Path: path,
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
})
}
// Seed bootstraps the config (orgs/apps/providers/certs) from an init_data.json
// path — the same file Casdoor uses. New-only + idempotent; ${VAR} from env.
// Returns the created/skipped counts. Call once at host startup after opening db.
func Seed(ctx context.Context, db orm.DB, initDataPath string) (*seed.Summary, error) {
return seed.FromInitData(ctx, db, initDataPath)
}