Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc1bec3c2 | ||
|
|
62bc23808f | ||
|
|
cc53b8ecb4 | ||
|
|
e18300ca59 | ||
|
|
8b06a3d89a | ||
|
|
edf00f55ca | ||
|
|
c59e472cfe | ||
|
|
375af4808f | ||
|
|
aa09dff4ee | ||
|
|
c42fa75996 | ||
|
|
85e8948913 | ||
|
|
0eaa444ba9 | ||
|
|
62d881c772 | ||
|
|
72e7203a56 | ||
|
|
41d7be2fce | ||
|
|
4cfd2c0b41 | ||
|
|
db4256d9b9 | ||
|
|
ebaa28f0f9 | ||
|
|
30974847b7 | ||
|
|
87f000fb45 | ||
|
|
f7eb859a6f | ||
|
|
ac54e88b50 | ||
|
|
55eacee44c | ||
|
|
82c1753986 | ||
|
|
3ff1bffb8e | ||
|
|
a649a70730 | ||
|
|
d07fde4f14 | ||
|
|
dbc9fbe5da | ||
|
|
9c69096b44 | ||
|
|
0b6c7cd909 | ||
|
|
c93fca8a57 | ||
|
|
9b779d284f | ||
|
|
84b3beda05 | ||
|
|
41b031a59b | ||
|
|
03bff4a006 | ||
|
|
6ce8b4ed1e | ||
|
|
c7bb8e0a6b | ||
|
|
1f67eb18fc | ||
|
|
43181d3dfc | ||
|
|
047511497b | ||
|
|
c7f329853d | ||
|
|
86d217ca8b | ||
|
|
f966b775fa | ||
|
|
bd5881025a | ||
|
|
af96110913 | ||
|
|
580ba8eab6 | ||
|
|
b2af182643 | ||
|
|
4f9b09338a | ||
|
|
e346ec266a | ||
|
|
74b2dd183a | ||
|
|
908d38ca0e | ||
|
|
30e29ade93 |
@@ -0,0 +1,6 @@
|
||||
data/
|
||||
*.db
|
||||
*.db-*
|
||||
.git/
|
||||
.claude/
|
||||
node_modules/
|
||||
@@ -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"
|
||||
@@ -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
@@ -0,0 +1,39 @@
|
||||
# Hanzo IAM v2 — proprietary identity service (zip + orm, no Casdoor).
|
||||
# Multi-stage Go build → distroless-style alpine. Pure-Go (CGO_ENABLED=0);
|
||||
# hanzoai/sqlite uses the modernc engine so no cgo/musl toolchain is needed.
|
||||
|
||||
FROM golang:1.26.4 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache the module graph before copying the source.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Per SCALE_STANDARD.md §2 — every Go production Dockerfile that emits JSON to a
|
||||
# client builds with GOEXPERIMENT=jsonv2 (zip's edge JSON path).
|
||||
ARG GO_EXPERIMENT=jsonv2
|
||||
ENV GOEXPERIMENT=${GO_EXPERIMENT}
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/iam2 .
|
||||
|
||||
FROM alpine:latest AS STANDARD
|
||||
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam2"
|
||||
LABEL org.opencontainers.image.title="Hanzo IAM v2"
|
||||
RUN apk add --no-cache ca-certificates && update-ca-certificates \
|
||||
&& adduser -D -u 1000 hanzo \
|
||||
&& mkdir -p /data && chown -R hanzo:hanzo /data
|
||||
USER 1000
|
||||
WORKDIR /
|
||||
COPY --from=build --chown=hanzo:hanzo /out/iam2 /iam2
|
||||
|
||||
# Serves the IAM v2 API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
|
||||
# config with --init-data /etc/iam/init_data.json (mounted from the same
|
||||
# init_data ConfigMap the Casdoor iam uses; ${VAR} creds from the KMS-synced env).
|
||||
EXPOSE 8080 9653
|
||||
ENTRYPOINT ["/iam2"]
|
||||
CMD ["serve", "--db", "/data/iam2.db", "--http", "http://:8080", "--zap", ":9653"]
|
||||
+126
-53
@@ -1,8 +1,11 @@
|
||||
# IAM v2 Migration
|
||||
|
||||
Casdoor fork (`hanzoai/iam`: Beego + xorm, Apache-2.0) → `hanzoai/iam2`:
|
||||
clean-room, proprietary, on the native Hanzo stack. Phased and drift-gated —
|
||||
the identity binary is never rewritten in one shot.
|
||||
clean-room, proprietary, on the native Hanzo stack. Phased and additive — the
|
||||
identity binary is never rewritten in one shot, and v1 stays live and
|
||||
authoritative until the supervised cutover. Parity is proven by tests + golden
|
||||
vectors captured from v1's own code + a route-level parity audit, and by a
|
||||
shadow deployment against real traffic — not by a swap on faith.
|
||||
|
||||
## §1 Why
|
||||
|
||||
@@ -15,66 +18,136 @@ own framework — we own it, and it collapses to one way of doing each thing.
|
||||
|
||||
- **HTTP** — `github.com/zap-proto/zip` (typed `zip.Get[In,Out]` handlers on the
|
||||
`zap-proto/fiber/v3` engine, specificity routing, OpenAPI 3.1 at the edge).
|
||||
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache) over
|
||||
`github.com/hanzoai/base` (collections, realtime, replicate-to-S3). SQLite —
|
||||
never Postgres for the local/default path.
|
||||
- **Authz** — `github.com/hanzoai/authz`, one canonical policy engine, called
|
||||
over ZAP RPC. No in-process copy.
|
||||
- **OIDC/OAuth2** — in-tree port (no external OIDC library). ML-DSA-65 hybrid
|
||||
JWT signing; JWKS cache.
|
||||
- **Inter-service** — `github.com/luxfi/zap` binary RPC. HTTPS is the external
|
||||
edge only; all service↔service is ZAP (platform law).
|
||||
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache). Default
|
||||
is embedded SQLite (`hanzoai/sqlite`, pure-Go, WAL) — never Postgres. The same
|
||||
`orm.DB` abstraction pluggably targets `hanzoai/sql` / `hanzoai/datastore` over
|
||||
ZAP (`--store sql|datastore`), so iam2 gains ZAP-native persistence + snapshots
|
||||
with zero code change once orm's ZAP backend is enabled.
|
||||
- **OIDC/OAuth2** — in-tree (no external OIDC library). RS256 today; ML-DSA-65
|
||||
hybrid JWT signing + real JWKS from the Cert entity.
|
||||
- **Password verify** — algorithm resolved from the stored row (`internal/cred`):
|
||||
argon2id (every live v1 row) + bcrypt (new iam2 rows), verify-only, fail-closed.
|
||||
- **Inter-service** — `zap-proto` binary RPC. HTTPS is the external edge only; all
|
||||
service↔service is ZAP (platform law).
|
||||
- **Authz** — `github.com/hanzoai/authz` policy engine (`internal/authz` gate).
|
||||
|
||||
## §2.1 RFC/IETF-standard surface — no Casdoor verbs (HIP-0111)
|
||||
|
||||
The wire contract is RFC/OpenID-standard only; there are no Casdoor verb aliases
|
||||
(`get-users`, `add-user`, `get-account`, `issue-user-token`, …) and no `access_token`
|
||||
duplicate of the token endpoint. Each capability is served by its standard, all
|
||||
shipped (iam2 tags):
|
||||
|
||||
| Capability | Standard | Endpoint | Tag |
|
||||
|-----------|----------|----------|-----|
|
||||
| Authorize / token | RFC 6749 (code+PKCE, refresh, client_credentials, **password**) | `/v1/iam/oauth/{authorize,token}` | v0.5.0 |
|
||||
| Delegation / on-behalf-of | **RFC 8693 Token Exchange** (replaces `issue-user-token`) | `grant_type=…token-exchange` | v0.7.0 |
|
||||
| Introspection / revocation | RFC 7662 / RFC 7009 | `/v1/iam/oauth/{introspect,revoke}` | v0.6.0 |
|
||||
| AS metadata / discovery / JWKS | RFC 8414 / OIDC Discovery / RFC 7517 | `/.well-known/*` | v0.6.0 |
|
||||
| Account claims | **OIDC UserInfo** (carries owner/organization/email/isAdmin/type — the get-account contract) | `/v1/iam/oauth/userinfo` | v0.9.0 |
|
||||
| Identity provisioning | **SCIM 2.0** (RFC 7644/7643; replaces get-/add-/update-/delete-user) | `/v1/iam/scim/v2/Users` | v0.8.0 (v0.8.1 authz fix) |
|
||||
| Resource indicators / issuer pin | RFC 8707 + `IAM_ISSUER` | token `aud`/`iss` | v0.5.0 |
|
||||
|
||||
Deploy env: `IAM_ISSUER=https://<brand-id>`, `IAM_KEY_MINT_ALLOWED_APPS` (token
|
||||
exchange + `hk-` key mint) and `IAM_ADMIN_MINT_ALLOWED_APPS` (reserved-org targets)
|
||||
— both matched by the globally-unique clientId only.
|
||||
|
||||
Remaining for cutover: migrate the clients (console `IamAdminApi`/`identity.ts`,
|
||||
gateway admin-guard, portal) off the Casdoor verbs onto these standards via
|
||||
`@hanzo/iam` (+ a SCIM client + token-exchange), then retire `internal/compat` and
|
||||
`get-account`. iam2 already serves everything the clients need in standard form.
|
||||
|
||||
## §3 Phases
|
||||
|
||||
| Phase | Scope | Gate to exit |
|
||||
|------:|-------|--------------|
|
||||
| 0 | Scaffold: Base boots, v2 collection namespace claimed, `/v1/iam/v2/health`, `compare` CLI. | Binary builds and boots. |
|
||||
| 1 | Entity schemas (fields + indexes) + CRUD handlers on `zip` + `orm`, per resource. | Per-entity field parity vs v1; handlers pass tests. |
|
||||
| 2 | In-tree OIDC/OAuth2 server: `/v1/iam/oauth/*`, `/v1/iam/.well-known/*`, JWT (ML-DSA-65), JWKS. | Token/userinfo/authorize parity vs v1. |
|
||||
| 3 | Authz via `hanzoai/authz` over ZAP RPC; retire in-process authz. | Policy decisions match v1. |
|
||||
| 4 | Parity: run `iam2 compare` continuously against a v1 read replica. | **drift = 0** (or a known v1-only residual v2 does not model). |
|
||||
| 5 | Cutover: import v1 data, promote `iam2` to the `iam` mount, archive the fork. | Green in prod; rollback path proven. |
|
||||
| Phase | Scope | Exit |
|
||||
|------:|-------|------|
|
||||
| 1 | Entity schemas (full fields) + owner-scoped CRUD on `zip`+`orm`, 13 identity entities. | ✅ Field-complete vs v1; handlers tested. |
|
||||
| 2 | In-tree OIDC/OAuth2: discovery, JWKS, authorize, token (PKCE S256 + JWT), refresh, userinfo, logout; front-door login/get-app-login/auth-methods. | ✅ Core flow (login→code→token→JWT) tested; front-door residual in progress (below). |
|
||||
| 3 | Authz via `hanzoai/authz` gate over the entity CRUD. | ✅ In `internal/authz`. |
|
||||
| — | ~~Drift gate~~ **DROPPED.** Parity is proven by tests + golden vectors (a real v1 argon2id digest verifies) + a route-level parity audit + a shadow deployment — not a row-count diff. The read-only `compare` CLI remains as a diagnostic, not a gate. | — |
|
||||
| 4 | **Bootstrap + embed.** Seed the real config (orgs/apps/providers/certs) from the same `init_data.json` v1 uses (`internal/seed` — 79 apps / 9 orgs). Embed in `hanzoai/cloud` via `server.Mount`, SHADOW-FIRST (own prefix, alongside live Casdoor, non-destructive). | Shadow serves real `get-app-login`/login against seeded config. |
|
||||
| 5 | **Cutover.** Import the user rows (password hashes verify as-is — see §5), flip iam2 onto the canonical `/v1/iam/*`, archive the fork. | Green in prod; rollback proven. |
|
||||
|
||||
Phases 0–4 are additive and non-destructive — v1 stays live and authoritative
|
||||
until Phase 5. Routes carry a `/v1/iam/v2/*` prefix through the transition so
|
||||
they are orthogonal to the live `/v1/iam/*` mount; the prefix collapses at §6.
|
||||
## §4 Front-door residual (gates cutover)
|
||||
|
||||
## §4 Domain model (v1 xorm table → v2 Base collection)
|
||||
The OIDC/OAuth2 protocol surface is complete. HIP-0111 §6's *native front-door* —
|
||||
what the hosted `hanzo.id` portal itself calls, distinct from the OIDC surface
|
||||
client apps use — is now complete: `get-app-login`, `login`, `auth/methods`,
|
||||
`userinfo`, `logout`, `refresh`, `authorize`, `get-account`,
|
||||
`send-verification-code`, `signup`. A backend swap without these takes the
|
||||
portal's account page, email verification, and signup with it, so cutover was
|
||||
gated on them. Serve under `/v1/iam/*` (no `/api/`, no new prefix).
|
||||
|
||||
Thirteen identity entities. Field-completeness is mandatory — a dropped column
|
||||
is lost auth data.
|
||||
The **durable session** is wired (`internal/sessions`): a bare `login`
|
||||
(type=login) issues a signed, revocable session cookie (`hanzo_session`, HMAC
|
||||
keyed off the platform signing cert — no new secret), and `get-account` resolves
|
||||
the caller by cookie first (the portal + admin-guard path) then bearer (the API
|
||||
path) — two credentials, one identity. The cookie's `sid` is registered in the
|
||||
`Session` row and re-checked on every resolve, so logout/rotation revokes it.
|
||||
§4 is closed; iam2 is Phase-4 shadow-embed ready.
|
||||
|
||||
| v1 table (xorm) | v2 collection (Base) | Base kind |
|
||||
|-----------------------|------------------------|-----------|
|
||||
| `user` | `users` | auth |
|
||||
| `organization` | `organizations` | base |
|
||||
| `application` | `applications` | base |
|
||||
| `provider` | `providers` | base |
|
||||
| `role` | `roles` | base |
|
||||
| `permission` | `permissions` | base |
|
||||
| `cert` | `certs` | base |
|
||||
| `key` | `keys` | base |
|
||||
| `webauthn_credential` | `webauthn_credentials` | base |
|
||||
| `session` | `sessions` | base |
|
||||
| `token` | `tokens` | base |
|
||||
| `record` | `audit_logs` | base |
|
||||
| `invitation` | `invitations` | base |
|
||||
The `signup`/`send-verification-code` pair carries two deliberate seams vs v1,
|
||||
each a missing iam2 dependency, not a shortcut: (1) signup lands the user in the
|
||||
app's **existing** org — v1's founder-org mint (`TenantOrgForSignup`) needs an
|
||||
org-create helper + the `Org.Parent` tenant model iam2 has not modeled yet;
|
||||
(2) `send-verification-code` persists a verifiable OTP (the `verifications`
|
||||
entity) but the email/SMS **delivery** is owned by `hanzoai/notify`, not wired
|
||||
into iam2 — the endpoint reports `ok` honestly and never fakes a "sent" claim.
|
||||
|
||||
**Deliberately not modeled by iam2** (they belong to commerce/other services,
|
||||
not identity): `payment`, `plan`, `product`, `subscription`, `pricing`,
|
||||
`model`, `adapter`, `enforcer`, `syncer_*`.
|
||||
Three facts the port must honour, each verified against live v1:
|
||||
- **`get-account` is a security contract, not a convenience.** The gateway's
|
||||
admin-guard derives the **SuperAdmin predicate** from it
|
||||
(`gateway/cmd/admin-guard/main.go`); waitlist-guard derives **approval**. Its
|
||||
response shape (owner/isAdmin/… + no secret material) must match exactly.
|
||||
- **`send-verification-code` takes `multipart/form-data`, not JSON.**
|
||||
- Native **`userinfo`/`logout` are aliases** of the `oauth/*` handlers
|
||||
(`routers/router.go` + `authz_filter.go` collapse them) — register the alias,
|
||||
never fork a second implementation.
|
||||
|
||||
## §5 Drift gate
|
||||
## §5 Credential parity (the cutover landmine, RESOLVED)
|
||||
|
||||
`iam2 compare --legacy <v1-dsn>` opens the v1 database **read-only** (only
|
||||
`SELECT COUNT(*)`), opens the v2 Base store read-only, and prints per-entity
|
||||
row counts plus absolute drift. This is the gate that keeps cutover honest:
|
||||
drift must be 0 before Phase 5 import goes live. No writes, no DDL, ever.
|
||||
Every live v1 row is **argon2id** (`object/organization.go sanitizeOrgPasswordType`
|
||||
rewrites `""`/`bcrypt`/`plain` → `argon2id`; `UpdateUserPassword` stamps it per
|
||||
user). A bcrypt-only verifier handed an argon2id PHC digest returns
|
||||
`ErrHashTooShort` → **100% of logins fail at cutover.** Fixed: `internal/cred`
|
||||
resolves the algorithm **from the row** (`user.PasswordType` → fallback
|
||||
`organization.PasswordType`), matching v1's `object/check.go`, and verifies
|
||||
argon2id + bcrypt, verify-only, fail-closed on any unknown scheme. Proven by a
|
||||
**golden vector** — a digest produced by v1's *own* `Argon2idCredManager`
|
||||
verifies under iam2 (`internal/cred/golden_v1_test.go`), across the v0→v1.0.0
|
||||
library-version gap. So existing users' hashes verify unchanged at import — no
|
||||
password reset, no re-hash on read.
|
||||
|
||||
## §6 Cutover
|
||||
## §6 Domain model (v1 xorm table → v2 orm kind)
|
||||
|
||||
At Phase 5, with drift proven 0: import v1 rows into v2 collections, drop the
|
||||
`/v2` route prefix so `iam2` answers on `/v1/iam/*`, repoint the `iam` image /
|
||||
operator CR / DNS to `iam2`, and archive `hanzoai/iam`. One identity binary,
|
||||
one way, no Casdoor.
|
||||
Fourteen identity entities. Field-completeness is mandatory — a dropped column is
|
||||
lost auth data.
|
||||
|
||||
| v1 table (xorm) | v2 orm kind |
|
||||
|-----------------------|------------------------|
|
||||
| `user` | `users` (auth) |
|
||||
| `organization` | `organizations` |
|
||||
| `application` | `applications` |
|
||||
| `provider` | `providers` |
|
||||
| `role` | `roles` |
|
||||
| `permission` | `permissions` |
|
||||
| `cert` | `certs` |
|
||||
| `key` | `keys` |
|
||||
| `webauthn_credential` | `webauthn_credentials` |
|
||||
| `session` | `sessions` |
|
||||
| `token` | `tokens` |
|
||||
| `record` | `audit_logs` |
|
||||
| `invitation` | `invitations` |
|
||||
| `verification` | `verifications` |
|
||||
|
||||
**Deliberately NOT modeled by iam2** (they belong to other services or are
|
||||
replaced by `hanzoai/authz`): `payment`, `plan`, `product`, `subscription`,
|
||||
`pricing`, `model`, `adapter`, `enforcer`, `syncer_*`, LDAP.
|
||||
|
||||
## §7 Build & deploy
|
||||
|
||||
Builds CGO-free (`hanzoai/sqlite` is pure-Go), pinned to published `hanzoai/orm`
|
||||
+ `zap-proto/zip` (no local replaces). Native CI at `.gitea/workflows/build.yaml`
|
||||
(git.hanzo.ai act_runner) + a mirror `.github/workflows/build.yml`, both
|
||||
self-contained (no reusable-workflow dependency). Canonical pipeline is
|
||||
**git.hanzo.ai + Hanzo GitOps**; GitHub is a downstream mirror.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package feature is the seam enterprise capabilities plug into. A module
|
||||
// (hanzoiam/scim, saml, ldap, …) implements Feature and reads/writes the core's
|
||||
// identity via the injected Store — so it shares ONE identity store with the core
|
||||
// and never carries a second copy. The core NEVER imports a module; dependency
|
||||
// flows one way (module → feature).
|
||||
package feature
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/pkg/model"
|
||||
)
|
||||
|
||||
// Store is the identity surface a feature needs — the union of the calls the
|
||||
// copied Casdoor code makes (object.* → store.*). The core implements it over its
|
||||
// orm store (internal/featurestore). A feature ignores methods it doesn't use.
|
||||
type Store interface {
|
||||
GetUser(ctx context.Context, owner, name string) (*model.User, error)
|
||||
GetUserByID(ctx context.Context, id string) (*model.User, error)
|
||||
GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error)
|
||||
AddUser(ctx context.Context, u *model.User) (bool, error)
|
||||
UpdateUser(ctx context.Context, u *model.User) (bool, error)
|
||||
DeleteUser(ctx context.Context, owner, name string) (bool, error)
|
||||
GetApplication(ctx context.Context, id string) (*model.Application, error)
|
||||
GetOrganization(ctx context.Context, name string) (*model.Organization, error)
|
||||
// GetProvider resolves an identity provider by (owner, name) — the SP-inbound
|
||||
// SAML/OAuth surface (a user signing in through a corporate IdP where Hanzo is
|
||||
// the Service Provider). SAML SP-initiated login reads its IdP config from here.
|
||||
GetProvider(ctx context.Context, owner, name string) (*model.Provider, error)
|
||||
// GetCert resolves a signing cert by (owner, name) — SAML metadata signing, etc.
|
||||
GetCert(ctx context.Context, owner, name string) (*model.Cert, error)
|
||||
// SetPassword sets a user's password: the core hashes the plaintext exactly
|
||||
// once and stores only the one-way digest (never the clear text). Used by SCIM
|
||||
// to provision the `password` attribute. An empty plaintext leaves the digest
|
||||
// untouched. Hashing lives in ONE place (the core) — a module never sees a hash.
|
||||
SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
|
||||
// VerifyPassword reports whether plaintext matches the user's stored digest
|
||||
// (argon2id for migrated v1 rows, bcrypt for v2, per the org's password type).
|
||||
// Used by LDAP bind — verification stays in the core, never in a module.
|
||||
VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
|
||||
}
|
||||
|
||||
// Feature is one pluggable enterprise capability. Mount registers its routes on
|
||||
// the shared app, backed by store. Name is for diagnostics.
|
||||
type Feature interface {
|
||||
Name() string
|
||||
Mount(app *zip.App, store Store) error
|
||||
}
|
||||
|
||||
var registry []Feature
|
||||
|
||||
// Register adds a feature to the set MountAll mounts. Called by the composing
|
||||
// binary (cloud) or a module init — the core decides which enterprise features ship.
|
||||
func Register(f Feature) { registry = append(registry, f) }
|
||||
|
||||
// Registered returns the registered features (diagnostics/tests).
|
||||
func Registered() []Feature { return append([]Feature(nil), registry...) }
|
||||
|
||||
// MountAll mounts every registered feature on app with store, fail-fast: a
|
||||
// registered-but-broken enterprise module surfaces loudly at boot, never a silent no-op.
|
||||
func MountAll(app *zip.App, store Store) error {
|
||||
for _, f := range registry {
|
||||
if err := f.Mount(app, store); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package feature_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/feature"
|
||||
"github.com/hanzoai/iam2/pkg/model"
|
||||
)
|
||||
|
||||
// A registered feature is mounted by MountAll and reaches the app + store; a
|
||||
// module that fails to mount surfaces the error (fail-fast).
|
||||
type fakeFeature struct {
|
||||
name string
|
||||
mounted bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFeature) Name() string { return f.name }
|
||||
func (f *fakeFeature) Mount(app *zip.App, store feature.Store) error {
|
||||
f.mounted = true
|
||||
return f.err
|
||||
}
|
||||
|
||||
type nopStore struct{}
|
||||
|
||||
func (nopStore) GetUser(context.Context, string, string) (*model.User, error) { return nil, nil }
|
||||
func (nopStore) GetUserByID(context.Context, string) (*model.User, error) { return nil, nil }
|
||||
func (nopStore) GetGlobalUsers(context.Context, int, int) ([]*model.User, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (nopStore) AddUser(context.Context, *model.User) (bool, error) { return true, nil }
|
||||
func (nopStore) UpdateUser(context.Context, *model.User) (bool, error) { return true, nil }
|
||||
func (nopStore) DeleteUser(context.Context, string, string) (bool, error) { return true, nil }
|
||||
func (nopStore) GetApplication(context.Context, string) (*model.Application, error) { return nil, nil }
|
||||
func (nopStore) GetOrganization(context.Context, string) (*model.Organization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopStore) GetCert(context.Context, string, string) (*model.Cert, error) { return nil, nil }
|
||||
func (nopStore) GetProvider(context.Context, string, string) (*model.Provider, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopStore) SetPassword(context.Context, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (nopStore) VerifyPassword(context.Context, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestMountAll_MountsRegistered(t *testing.T) {
|
||||
f := &fakeFeature{name: "fake"}
|
||||
feature.Register(f)
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
if err := feature.MountAll(app, nopStore{}); err != nil {
|
||||
t.Fatalf("MountAll: %v", err)
|
||||
}
|
||||
if !f.mounted {
|
||||
t.Fatal("registered feature was not mounted")
|
||||
}
|
||||
found := false
|
||||
for _, r := range feature.Registered() {
|
||||
if r.Name() == "fake" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Registered() did not list the feature")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package applications is the Phase-1 typed CRUD surface for the `applications`
|
||||
// entity. Every operation is a zip typed handler (decode In -> run -> encode
|
||||
// Out) over hanzoai/orm and is owner-scoped by the (owner, name) natural key,
|
||||
// materialized as the orm id "<owner>/<name>". The same In/Out types back both
|
||||
// the REST route and the MCP tools/call projection zip derives from them, so
|
||||
// identity arguments travel in the typed request, not in ad-hoc path parsing.
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// appID is the owner-scoped natural key "<owner>/<name>" — the single source
|
||||
// of an application's orm id. Every handler routes through it so reads and
|
||||
// writes address the exact same row.
|
||||
func appID(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// ApplicationRef identifies one application by its owner-scoped natural key.
|
||||
// It is the input for the get and delete operations.
|
||||
type ApplicationRef struct {
|
||||
Owner string `json:"owner" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// ApplicationQuery filters applications by owner for the list operation.
|
||||
type ApplicationQuery struct {
|
||||
Owner string `json:"owner" validate:"required"`
|
||||
}
|
||||
|
||||
// ApplicationListResult wraps the applications owned by one owner, newest
|
||||
// first.
|
||||
type ApplicationListResult struct {
|
||||
Applications []*schema.Application `json:"applications"`
|
||||
}
|
||||
|
||||
// DeleteResult reports the outcome of a delete operation.
|
||||
type DeleteResult struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// Mount registers the applications CRUD surface on app, closing over db. Reads
|
||||
// use GET, create POST, update PUT, delete DELETE — every one a zip typed
|
||||
// handler.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
zip.Get(app, "/v1/iam/applications", listApplications(db),
|
||||
zip.WithSummary("List applications for an owner"), zip.WithTags("applications"))
|
||||
zip.Get(app, "/v1/iam/application", getApplication(db),
|
||||
zip.WithSummary("Get one application by owner and name"), zip.WithTags("applications"))
|
||||
zip.Post(app, "/v1/iam/application", Create(db),
|
||||
zip.WithSummary("Create an application"), zip.WithTags("applications"))
|
||||
zip.Put(app, "/v1/iam/application", Update(db),
|
||||
zip.WithSummary("Update an application"), zip.WithTags("applications"))
|
||||
zip.Delete(app, "/v1/iam/application", deleteApplication(db),
|
||||
zip.WithSummary("Delete an application"), zip.WithTags("applications"))
|
||||
}
|
||||
|
||||
// listApplications returns every application owned by in.Owner, ordered by
|
||||
// creation time descending.
|
||||
func listApplications(db orm.DB) zip.TypedHandler[ApplicationQuery, ApplicationListResult] {
|
||||
return func(ctx context.Context, in *ApplicationQuery) (*ApplicationListResult, error) {
|
||||
if in.Owner == "" {
|
||||
return nil, zip.ErrBadRequest("owner is required")
|
||||
}
|
||||
apps, err := orm.TypedQuery[schema.Application](db).
|
||||
Filter("Owner=", in.Owner).
|
||||
Order("-CreatedTime").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
for i, app := range apps {
|
||||
apps[i] = app.Mask() // never emit clientSecret in a list response
|
||||
}
|
||||
return &ApplicationListResult{Applications: apps}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// getApplication returns the application at (in.Owner, in.Name).
|
||||
func getApplication(db orm.DB) zip.TypedHandler[ApplicationRef, schema.Application] {
|
||||
return func(ctx context.Context, in *ApplicationRef) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
app, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return app.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create persists a new application under (in.Owner, in.Name), rejecting a
|
||||
// collision on that owner-scoped key. Exported so the Casdoor add-application
|
||||
// alias reuses this exact logic (no duplication); the REST route and the alias
|
||||
// share the one create path.
|
||||
func Create(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
|
||||
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
// Owner-scoped uniqueness: (owner, name) must be free.
|
||||
if _, err := orm.Get[schema.Application](db, id); err == nil {
|
||||
return nil, zip.ErrConflict("application already exists: " + id)
|
||||
} else if !errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// Wire the decoded entity to db under its natural key and persist.
|
||||
in.Init(db)
|
||||
in.SetId(id)
|
||||
if err := in.Create(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return in.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update overwrites the application at (in.Owner, in.Name), preserving its
|
||||
// immutable creation metadata. The (owner, name) identity is fixed by the record,
|
||||
// not editable through the body. Exported so the Casdoor update-application alias
|
||||
// reuses this exact logic (no duplication).
|
||||
func Update(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
|
||||
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
existing, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
in.Init(db)
|
||||
in.SetId(id)
|
||||
in.CreatedTime = existing.CreatedTime
|
||||
in.CreatedAt = existing.CreatedAt
|
||||
if err := in.Update(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return in.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// deleteApplication removes the application at (in.Owner, in.Name).
|
||||
func deleteApplication(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] {
|
||||
return func(ctx context.Context, in *ApplicationRef) (*DeleteResult, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
app, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
if err := app.Delete(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteResult{Deleted: true}, nil
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package authz is the IAM v2 authorization seam in front of the Phase-1 entity
|
||||
// CRUD, which is otherwise unauthenticated — the door an attacker would walk
|
||||
// through to overwrite an admin-owned signing cert and forge tokens. It is two
|
||||
// orthogonal decisions, never braided:
|
||||
//
|
||||
// - AUTHENTICATION — the Guard middleware, mounted ONCE and FIRST via app.Use.
|
||||
// Every non-public request must carry a verified bearer; the resolved
|
||||
// Principal is attached to the request context for the authorization decision
|
||||
// and audit. Public routes pass straight through. Fails closed (401).
|
||||
//
|
||||
// - AUTHORIZATION — the Authorize hook, installed ONCE via app.Authorize. It
|
||||
// runs at the framework's op-invoke seam, on the DECODED typed input the
|
||||
// handler will act on, for REST and MCP alike. The value it authorizes is by
|
||||
// construction the value the handler binds: there is no second parse of the
|
||||
// body for it to diverge from. Fails closed (403).
|
||||
//
|
||||
// Splitting the two removes the defect a single body-reparsing middleware had:
|
||||
// authorizing a target extracted from the raw bytes divergently from where the
|
||||
// handler binds it. A write's target now comes from the one decode the handler
|
||||
// itself runs on. A read's target rides in the query string (a GET has no body
|
||||
// for the op seam to decode), so the Guard authorizes reads there; a read invoked
|
||||
// over MCP DOES decode a target into its input, and the op seam authorizes that.
|
||||
//
|
||||
// Three scopes, never conflated (conflation is privilege escalation):
|
||||
//
|
||||
// - SuperAdmin — the principal's organization is the reserved "admin" org.
|
||||
// The ONLY cross-tenant scope. Required for every write to a platform-owned
|
||||
// (admin/built-in) resource: the signing-cert poisoning gate, admin-scoped
|
||||
// application/provider registration, every reserved surface.
|
||||
// - Org admin — IsAdmin, scoped to its OWN organization. Manages every
|
||||
// resource its org owns; never another org's, never a platform-owned one.
|
||||
// - Regular user — self-service only: reading its own user record.
|
||||
//
|
||||
// One predicate governs SuperAdmin everywhere: the principal's organization is
|
||||
// "admin". That organization comes from the token SUBJECT — the authenticated
|
||||
// principal's own owner/name — never from the token's `owner`/`organization`
|
||||
// claims. Those name the APPLICATION's org and diverge from the user's org for a
|
||||
// shared app, so trusting them would let a tenant user sign in through a shared
|
||||
// admin-org app and read as SuperAdmin. Authenticity, expiry, algorithm, and
|
||||
// signing-key trust are delegated to the same oidc.VerifyToken every protected
|
||||
// route already uses; the org-admin flag comes from the loaded user record, the
|
||||
// authoritative source (it is not a token claim).
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/oidc"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// adminOrg is the reserved organization whose membership IS SuperAdmin — the one
|
||||
// cross-tenant scope, the one predicate. The broader reserved-owner set
|
||||
// {admin, built-in} the poisoning gate protects lives in ONE place,
|
||||
// store.IsSigningCertOwner, shared with the token verifier and the JWKS.
|
||||
const adminOrg = "admin"
|
||||
|
||||
// Principal is the identity a gated request acts as, resolved from a verified
|
||||
// bearer. Org is the tenant (the authenticated principal's own org, from the
|
||||
// subject); User is its name within that org (empty for a machine token); Admin
|
||||
// is the org-admin flag; Super is the SuperAdmin predicate (Org == adminOrg).
|
||||
type Principal struct {
|
||||
Org string
|
||||
User string
|
||||
Admin bool
|
||||
Super bool
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// From returns the Principal the Guard attached to ctx for a gated request, and
|
||||
// whether one is present (public routes carry none).
|
||||
func From(ctx context.Context) (*Principal, bool) {
|
||||
p, ok := ctx.Value(ctxKey{}).(*Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Scope resolves the owner a listing is bound to: a SuperAdmin lists the owner
|
||||
// it asks for (empty = every tenant), anyone else lists only its own org. The
|
||||
// org comes from the verified bearer, so a request parameter can never widen a
|
||||
// read beyond the caller's authority — the one value authorized is the one value
|
||||
// queried. Every owner-scoped lister resolves its owner here.
|
||||
func Scope(ctx context.Context, owner string) (string, error) {
|
||||
p, ok := From(ctx)
|
||||
if !ok {
|
||||
return "", zip.ErrForbidden("no principal")
|
||||
}
|
||||
if p.Super {
|
||||
return owner, nil
|
||||
}
|
||||
return p.Org, nil
|
||||
}
|
||||
|
||||
// Can reports whether the ctx principal may perform `method` on the entity's
|
||||
// (owner, name) — the SAME policy the op-invoke seam (Authorize) applies, exposed
|
||||
// for a RAW handler that does not pass through app.Authorize (e.g. SCIM, whose
|
||||
// writes call the CRUD directly). Owner-pinning via Scope alone is NOT sufficient
|
||||
// for a write: it enforces tenant isolation but not the admin/self clause, so a
|
||||
// raw handler MUST call this. Fails closed when no principal is present.
|
||||
func Can(ctx context.Context, method, entity, owner, name string) bool {
|
||||
p, ok := From(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return authorize(p, method, entity, owner, name)
|
||||
}
|
||||
|
||||
// IsSuper reports whether the ctx principal is a SuperAdmin — used by a raw
|
||||
// handler to gate a privileged field (e.g. provision-don't-promote: only a super
|
||||
// may set isAdmin). Fails closed when no principal is present.
|
||||
func IsSuper(ctx context.Context) bool {
|
||||
p, ok := From(ctx)
|
||||
return ok && p.Super
|
||||
}
|
||||
|
||||
// Fail-closed reasons. The Guard collapses all of them to one opaque 401 so a
|
||||
// prober cannot tell a bad signature from an expired token from a revoked user.
|
||||
var (
|
||||
errNoBearer = errors.New("authz: no bearer")
|
||||
errNoSubject = errors.New("authz: token subject carries no org")
|
||||
errRevoked = errors.New("authz: principal is forbidden or deleted")
|
||||
)
|
||||
|
||||
// publicPaths is the CLOSED set of routes reachable without a bearer — the
|
||||
// pre-authentication OIDC/OAuth2 and front-door surface a browser must reach
|
||||
// before it can hold a token. Everything not listed here is gated: the default
|
||||
// is fail-closed, so a newly mounted route (including the framework's own /mcp
|
||||
// and /openapi projections of the typed handlers) is protected until it is
|
||||
// deliberately published here. userinfo and logout are listed because they
|
||||
// verify their own bearer (userinfo) or must clear a session without a live one
|
||||
// (logout); gating them again would break their own OIDC contract.
|
||||
var publicPaths = map[string]bool{
|
||||
"/healthz": true, // liveness, unversioned
|
||||
"/.well-known/openid-configuration": true, // OIDC discovery (root)
|
||||
"/v1/iam/.well-known/openid-configuration": true, // OIDC discovery (v1)
|
||||
"/.well-known/jwks": true, // JWKS public keys (root)
|
||||
"/v1/iam/.well-known/jwks": true, // JWKS public keys (v1)
|
||||
"/.well-known/oauth-authorization-server": true, // RFC 8414 AS metadata (root)
|
||||
"/v1/iam/.well-known/oauth-authorization-server": true, // RFC 8414 AS metadata (v1)
|
||||
"/v1/iam/oauth/introspect": true, // RFC 7662 (client-authenticated)
|
||||
"/v1/iam/oauth/revoke": true, // RFC 7009 (client-authenticated)
|
||||
"/v1/iam/login": true, // credential login, mints the code
|
||||
"/v1/iam/oauth/authorize": true, // OAuth2 authorize
|
||||
"/v1/iam/oauth/token": true, // OAuth2 token
|
||||
"/v1/iam/oauth/userinfo": true, // self-verifying bearer read
|
||||
"/v1/iam/oauth/logout": true, // end session
|
||||
"/v1/iam/get-app-login": true, // pre-login app config (secrets masked)
|
||||
"/v1/iam/auth/methods": true, // pre-login method list
|
||||
"/v1/iam/mint-user-keys": true, // confidential-client auth (Basic + allow-list), not a bearer
|
||||
"/v1/iam/revoke-user-keys": true, // confidential-client auth (same authorizeMinter seam)
|
||||
// The front-door session/identity surface (oidc.MountFrontDoor). Each handler
|
||||
// RESOLVES the caller itself (callerOf: session cookie first, then bearer) and
|
||||
// SELF-SCOPES to that caller — so, like get-account, they are reachable without a
|
||||
// Guard-verified bearer (the portal + gateway admin-guard call them with a session
|
||||
// cookie) yet never act on anyone but the resolved caller. signup and
|
||||
// send-verification-code are pre-authentication by nature (no token exists yet).
|
||||
"/v1/iam/get-account": true, // anonymous-safe account read (admin-guard contract)
|
||||
"/v1/iam/signup": true, // pre-auth account creation (own policy checks)
|
||||
"/v1/iam/send-verification-code": true, // pre-auth OTP send
|
||||
"/v1/iam/signin": true, // code→session exchange (the code is the credential)
|
||||
"/v1/iam/whoami": true, // lightweight caller identity (self-resolving)
|
||||
"/v1/iam/onboard": true, // first-run org onboarding (self-move only)
|
||||
"/v1/iam/update-preferences": true, // self preferences (writes only the caller's row)
|
||||
"/v1/iam/linked-accounts": true, // the caller's own linked identities
|
||||
}
|
||||
|
||||
// isPublic reports whether path is in the public allowlist. A trailing slash is
|
||||
// trimmed first so /v1/iam/login/ resolves like /v1/iam/login — the same route
|
||||
// fiber serves. It can only ever widen matches to the fixed public set, never
|
||||
// turn a gated path into a public one (no gated path equals a public path plus a
|
||||
// slash), so the fail-closed default holds.
|
||||
func isPublic(path string) bool {
|
||||
if len(path) > 1 {
|
||||
path = strings.TrimRight(path, "/")
|
||||
}
|
||||
return publicPaths[path]
|
||||
}
|
||||
|
||||
// isRead reports whether a method addresses its target through the query string
|
||||
// rather than a body: a GET (or HEAD) has no body for the op-invoke seam to
|
||||
// decode, so its target is authorized in the Guard. Every other method carries a
|
||||
// body decoded once by the op and is authorized at that seam.
|
||||
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
|
||||
|
||||
// ReadTarget extracts the (owner, name) a GET addresses, from the query string.
|
||||
// A native typed read files them as `?owner=&name=`; the Casdoor compat verbs
|
||||
// (get-user, get-organization, …) file them as `?id=<owner>/<name>`. Explicit
|
||||
// owner/name win; the id split is a fallback only when owner is absent, so this
|
||||
// can only make an id-based read's authorization MORE precise than the empty
|
||||
// target it resolves to today (which fail-closed denies every non-super). It
|
||||
// never widens: the tenant rule still pins owner to the principal's org, and the
|
||||
// handler independently re-scopes the query owner through Scope, so a request
|
||||
// that spells one owner in `?owner` and another in `?id` cannot read across
|
||||
// tenants — the authorized owner and the queried owner are both pinned.
|
||||
//
|
||||
// It is exported so the compat read aliases resolve their target through the
|
||||
// SAME function the Guard authorizes with: one extraction, so a handler can
|
||||
// never address a row the Guard did not authorize.
|
||||
func ReadTarget(c *zip.Ctx) (owner, name string) {
|
||||
owner, name = c.Query("owner"), c.Query("name")
|
||||
if owner == "" {
|
||||
if o, n, ok := strings.Cut(c.Query("id"), "/"); ok && o != "" {
|
||||
return o, n
|
||||
}
|
||||
}
|
||||
return owner, name
|
||||
}
|
||||
|
||||
// handlerAuthorizedPrefixes are path subtrees whose target rides in the PATH, not
|
||||
// the query — the Guard authenticates them (a bearer is still required) but does
|
||||
// NOT pre-authorize the read; the handler authorizes on the path id via
|
||||
// authz.Scope. SCIM (RFC 7644, /v1/iam/scim/v2/Users/{id}) is path-targeted, so it
|
||||
// belongs here. This is the read analogue of a write deferring to the op-invoke
|
||||
// seam — the target is authorized where it is bound, not guessed from the query.
|
||||
var handlerAuthorizedPrefixes = []string{"/v1/iam/scim/"}
|
||||
|
||||
// pathAuthorized reports whether path is under a handler-authorized subtree.
|
||||
func pathAuthorized(path string) bool {
|
||||
for _, p := range handlerAuthorizedPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Guard is the AUTHENTICATION middleware. Mount it ONCE and FIRST, via app.Use,
|
||||
// so it wraps every route — the typed CRUD handlers and the framework's /mcp and
|
||||
// /openapi surfaces alike. Public routes pass straight through; every other route
|
||||
// requires a valid bearer (401 otherwise) whose Principal is attached to the
|
||||
// request context for the authorization hook downstream. A read's authorization
|
||||
// target rides in the query string, so reads are authorized here; a write's rides
|
||||
// in the body, decoded once by the op and authorized at the op-invoke seam
|
||||
// (Authorize) on that exact decoded value — this middleware never re-parses a
|
||||
// write body, which is what let the old target extraction diverge from execution.
|
||||
func Guard(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if isPublic(c.Path()) {
|
||||
return c.Continue()
|
||||
}
|
||||
p, err := principal(c, db)
|
||||
if err != nil {
|
||||
return zip.ErrUnauthorized("authentication required")
|
||||
}
|
||||
// A path-targeted resource (SCIM: /Users/{id}) carries its target in the
|
||||
// PATH, not the query — so, like a write whose target rides in the body, the
|
||||
// Guard authenticates (bearer required, principal attached) and the handler
|
||||
// authorizes via authz.Scope on the path id. The Guard never authorizes an
|
||||
// empty query target for these (which would fail-closed deny every non-super
|
||||
// before the handler could scope). Every other read is authorized here.
|
||||
if !pathAuthorized(c.Path()) {
|
||||
rOwner, rName := ReadTarget(c)
|
||||
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), rOwner, rName) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
}
|
||||
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
|
||||
return c.Continue()
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize is the AUTHORIZATION hook, installed via app.Authorize so the
|
||||
// framework runs it at every typed op's invoke seam — after the request is
|
||||
// decoded into its typed In and validated, before the handler runs, for REST and
|
||||
// MCP alike. It authorizes the DECODED target: the exact (owner, name) the
|
||||
// handler will bind, read from the same struct the handler runs on, so the value
|
||||
// authorized cannot diverge from the value written.
|
||||
//
|
||||
// A REST read carries its target in the query string, not the body, so its
|
||||
// decoded In is empty and the Guard already authorized it there — such a call is
|
||||
// admitted here (owner == ""). Every write, and any read invoked over MCP (whose
|
||||
// arguments DO decode a target into In), is authorized against authorize().
|
||||
func Authorize(ctx context.Context, op zip.Op, in any) error {
|
||||
if isPublic(op.Path) {
|
||||
return nil // pre-auth surface; the Guard admitted it without a principal
|
||||
}
|
||||
owner, name := decodedTarget(in)
|
||||
if owner == "" && isRead(op.Method) {
|
||||
return nil // REST read: target rode in the query, authorized by the Guard
|
||||
}
|
||||
p, present := From(ctx)
|
||||
if !present {
|
||||
return zip.ErrForbidden("forbidden") // gated op with no principal: fail closed
|
||||
}
|
||||
if !authorize(p, op.Method, entityOf(op.Path), owner, name) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorize is the pure authorization decision: may p act on a resource owned by
|
||||
// `owner` (named `name`) on the given entity? The order IS the policy:
|
||||
//
|
||||
// 1. SuperAdmin may do anything — the only cross-tenant scope.
|
||||
// 2. A platform-owned resource (admin/built-in — the reserved owners the token
|
||||
// verifier trusts to sign) is writable only by a SuperAdmin. This single
|
||||
// rule is the signing-cert poisoning gate, the admin-scoped app/provider
|
||||
// registration gate, AND the built-in-org gap, all at once: a built-in-org
|
||||
// principal is not SuperAdmin (that is admin only), so it cannot write a
|
||||
// built-in-owned signing cert either.
|
||||
// 3. Tenant isolation: a normal principal may act only within its OWN org. An
|
||||
// empty or foreign owner is refused — the target org is bound to the
|
||||
// principal, never trusted from the request.
|
||||
// 4. Inside its own org, an org admin manages everything; a regular user may
|
||||
// only READ its own user record (self-service). The users entity serves
|
||||
// reads as GET and writes as POST, so gating the self clause to GET keeps a
|
||||
// regular user from writing its own record — a raw entity write would
|
||||
// otherwise let it carry isAdmin and self-promote. Privileged self-mutation
|
||||
// is the Phase-5 provision-don't-promote concern; here it is closed by
|
||||
// denial.
|
||||
func authorize(p *Principal, method, entity, owner, name string) bool {
|
||||
if p.Super {
|
||||
return true
|
||||
}
|
||||
if store.IsSigningCertOwner(owner) {
|
||||
return false
|
||||
}
|
||||
if owner == "" || owner != p.Org {
|
||||
return false
|
||||
}
|
||||
if p.Admin {
|
||||
return true
|
||||
}
|
||||
return method == "GET" && entity == "users" && name != "" && name == p.User
|
||||
}
|
||||
|
||||
// owned is implemented by a typed input whose authorization target is NOT its
|
||||
// top-level Owner/Name. The user create/update body nests the record under
|
||||
// `user`, so its owner is in.User.Owner, not a top-level field; its AuthzTarget
|
||||
// returns exactly what the handler binds — the handler calls the same method — so
|
||||
// the value authorized is by construction the value written. Any future input
|
||||
// that nests its owner implements this too: it is the ONE contract for nesting,
|
||||
// so the seam never guesses which field the handler uses and never mistakes a
|
||||
// read-only enrichment sub-struct (e.g. an application's resolved certObj, which
|
||||
// carries its OWN owner) for the target.
|
||||
type owned interface {
|
||||
AuthzTarget() (owner, name string)
|
||||
}
|
||||
|
||||
// decodedTarget returns the (owner, name) a decoded request addresses — exactly
|
||||
// the values the handler will bind, read from the SAME decoded struct the handler
|
||||
// runs on, so there is no second parse to diverge from. An input that nests its
|
||||
// owner declares it via owned; every other input files its owner at the top level
|
||||
// (directly, or promoted from an embedded record), read reflectively so no entity
|
||||
// needs bespoke wiring and an attacker-supplied nested sub-struct is never a
|
||||
// target.
|
||||
func decodedTarget(in any) (owner, name string) {
|
||||
if o, ok := in.(owned); ok {
|
||||
return o.AuthzTarget()
|
||||
}
|
||||
v := reflect.ValueOf(in)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return "", ""
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return "", ""
|
||||
}
|
||||
return stringField(v, "Owner"), stringField(v, "Name")
|
||||
}
|
||||
|
||||
// stringField returns the string value of the named field (traversing embedded
|
||||
// anonymous fields via FieldByName), or "" when the field is absent or not a
|
||||
// string. FieldByName does not descend named sub-fields, so it reads the record's
|
||||
// own owner, never one nested under an unrelated field.
|
||||
func stringField(v reflect.Value, name string) string {
|
||||
f := v.FieldByName(name)
|
||||
if f.IsValid() && f.Kind() == reflect.String {
|
||||
return f.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// principal resolves the verified bearer into a Principal, failing closed on a
|
||||
// missing/malformed/expired/wrong-key token (oidc.VerifyToken enforces the
|
||||
// algorithm allowlist and trusted signing-cert resolution), a subject with no
|
||||
// org, a store error, or a forbidden/deleted user. Org, Admin, and Super are
|
||||
// read from the LOADED user record — authoritative — never from the token
|
||||
// claims: SuperAdmin is a real, live member of the admin org, not a subject that
|
||||
// merely names one. A subject with no user row (a client_credentials machine
|
||||
// token, or a since-deleted user) authenticates but carries no admin or
|
||||
// SuperAdmin authority and no self-service identity — org-scoped only, which on
|
||||
// the raw CRUD authorizes to nothing until a later phase grants machine
|
||||
// identities explicit scope. This closes the phantom-admin subject: a token for
|
||||
// "admin/<nobody>" resolves to no authority, not SuperAdmin.
|
||||
func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return nil, errNoBearer
|
||||
}
|
||||
ctx := c.Context()
|
||||
claims, err := oidc.VerifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The subject is "<owner>/<name>": the principal's OWN org and name, set
|
||||
// server-side at mint and signed. Never the `owner` claim (the app's org).
|
||||
owner, name, _ := strings.Cut(claims.Subject, "/")
|
||||
if owner == "" {
|
||||
return nil, errNoSubject
|
||||
}
|
||||
u, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return nil, err // fail closed: cannot establish the principal
|
||||
}
|
||||
if u != nil {
|
||||
if u.IsForbidden || u.IsDeleted {
|
||||
return nil, errRevoked
|
||||
}
|
||||
return &Principal{Org: u.Owner, User: u.Name, Admin: u.IsAdmin, Super: u.Owner == adminOrg}, nil
|
||||
}
|
||||
return &Principal{Org: owner}, nil
|
||||
}
|
||||
|
||||
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
|
||||
// "" for anything else (e.g. /mcp). Only the users entity needs distinguishing —
|
||||
// its regular-user self-service rule — so every other segment is treated
|
||||
// uniformly by the tenant rule.
|
||||
func entityOf(path string) string {
|
||||
const p = "/v1/iam/"
|
||||
if !strings.HasPrefix(path, p) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(p):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
return rest[:i]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package compat serves the Casdoor VERB surface (get-users, get-organizations,
|
||||
// …) over iam2's orm store, in the v1 Response envelope. It exists because every
|
||||
// live consumer — the console admin BFF, the gateway admin-api, the hanzo.id
|
||||
// portal — hard-codes the Casdoor verb spellings and the `{status,data,data2}`
|
||||
// envelope, while iam2's native surface is REST (`/v1/iam/users`,
|
||||
// `/v1/iam/users/get`). Without these aliases a backend swap 404s every console
|
||||
// IAM page. The aliases are a thin routing + envelope layer over the SAME orm
|
||||
// store and the SAME schema.Mask redaction the REST handlers use — no CRUD and
|
||||
// no redaction is reimplemented here.
|
||||
//
|
||||
// Authorization is NOT reimplemented either. These paths are not in authz's
|
||||
// public allowlist, so the Guard (app.Use, mounted first) authenticates every
|
||||
// request AND authorizes the read against the exact (owner, name) it addresses —
|
||||
// resolved by the same authz.ReadTarget the handlers use, so a handler can never
|
||||
// reach a row the Guard did not authorize. Each handler then re-scopes the query
|
||||
// owner through authz.Scope: a SuperAdmin may list any owner (empty = all
|
||||
// tenants), everyone else is pinned to their own org, so a request parameter can
|
||||
// never widen a read past the caller's authority.
|
||||
package compat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/authz"
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// Mount registers the Casdoor read-verb aliases. The mask argument is the
|
||||
// entity's schema.Mask method (the ONE redaction contract) for entities that
|
||||
// carry secrets, or nil for those that do not — nil means "no field to strip",
|
||||
// not "skip a needed redaction". Writes ride a companion file.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
// List reads — `?owner=&p=&pageSize=` (Casdoor shape). Owner-scoped by authz.
|
||||
app.Get("/v1/iam/get-organizations", listHandler(db, (*schema.Organization).Mask))
|
||||
app.Get("/v1/iam/get-users", listHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-global-users", listHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-applications", listHandler(db, (*schema.Application).Mask))
|
||||
app.Get("/v1/iam/get-providers", listHandler(db, (*schema.Provider).Mask))
|
||||
app.Get("/v1/iam/get-certs", listHandler(db, (*schema.Cert).Mask))
|
||||
app.Get("/v1/iam/get-roles", listHandler[schema.Role](db, nil))
|
||||
app.Get("/v1/iam/get-permissions", listHandler[schema.Permission](db, nil))
|
||||
app.Get("/v1/iam/get-invitations", listHandler[schema.Invitation](db, nil))
|
||||
app.Get("/v1/iam/get-records", listHandler[schema.AuditLog](db, nil))
|
||||
|
||||
// Single reads — `?id=<owner>/<name>` (or `?owner=&name=`).
|
||||
app.Get("/v1/iam/get-organization", getHandler(db, (*schema.Organization).Mask))
|
||||
app.Get("/v1/iam/get-user", getHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-application", getHandler(db, (*schema.Application).Mask))
|
||||
app.Get("/v1/iam/get-provider", getHandler(db, (*schema.Provider).Mask))
|
||||
app.Get("/v1/iam/get-cert", getHandler(db, (*schema.Cert).Mask))
|
||||
app.Get("/v1/iam/get-role", getHandler[schema.Role](db, nil))
|
||||
app.Get("/v1/iam/get-permission", getHandler[schema.Permission](db, nil))
|
||||
|
||||
// The Casdoor WRITE verbs (companion file), over the same store + authz seam.
|
||||
mountWrites(app, db)
|
||||
}
|
||||
|
||||
// listHandler serves a Casdoor get-<entities> list for one orm kind: it scopes
|
||||
// the owner through authz, queries the store, redacts each row via the entity's
|
||||
// Mask, and wraps the result in the v1 envelope. Per the v1 contract a list
|
||||
// paginates ONLY when BOTH `p` and `pageSize` are present — then the total rides
|
||||
// in data2; otherwise the full owner-scoped set is returned with no data2.
|
||||
//
|
||||
// Scoping note (intentional, fail-closed): iam2's ownership model is mixed —
|
||||
// users/roles/permissions are owned by their tenant org, while organizations/
|
||||
// applications/providers/certs are platform-owned (Owner "admin"). A SuperAdmin
|
||||
// (Scope → the requested owner, empty = all) therefore lists every entity, which
|
||||
// is the console-admin path. A non-super is pinned by Scope to its own org, so it
|
||||
// lists its tenant-owned entities correctly and is refused the platform-owned
|
||||
// lists at the Guard (owner "" or "admin" both deny) — a safe 403, never another
|
||||
// tenant's rows. Non-super, membership-scoped views of the platform-owned
|
||||
// entities (e.g. an org console's own app list keyed on Application.Organization)
|
||||
// are a separate, additive surface, not a silent behavior of this generic lister.
|
||||
func listHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, err := authz.Scope(ctx, c.Query("owner"))
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
|
||||
base := func() *orm.ModelQuery[T] {
|
||||
q := orm.TypedQuery[T](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
page, size, paginated := pageParams(c)
|
||||
if !paginated {
|
||||
rows, err := base().Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, maskAll(rows, mask))
|
||||
}
|
||||
|
||||
total, err := base().Count(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
rows, err := base().Order("Name").Limit(size).Offset((page - 1) * size).GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return c.JSON(200, httpx.Response{Status: "ok", Data: maskAll(rows, mask), Data2: total})
|
||||
}
|
||||
}
|
||||
|
||||
// getHandler serves a Casdoor get-<entity> single read. The target is resolved
|
||||
// by authz.ReadTarget (the same extraction the Guard authorized with), then the
|
||||
// owner is re-scoped through authz.Scope so a non-super can never read another
|
||||
// tenant's row even if it spells one in `?id`.
|
||||
func getHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name := authz.ReadTarget(c)
|
||||
if name == "" {
|
||||
return httpx.Err(c, "id (owner/name) or name is required")
|
||||
}
|
||||
scoped, err := authz.Scope(ctx, owner)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
row, err := orm.TypedQuery[T](db).Filter("Owner=", scoped).Filter("Name=", name).First()
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return httpx.Err(c, "the entity does not exist")
|
||||
}
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if mask != nil {
|
||||
row = mask(row)
|
||||
}
|
||||
return httpx.Ok(c, row)
|
||||
}
|
||||
}
|
||||
|
||||
// maskAll redacts every row through the entity's Mask (a no-op when the entity
|
||||
// has no secrets, i.e. mask is nil). Mask returns a copy, so the slice is
|
||||
// rewritten in place with the masked copies.
|
||||
func maskAll[T any](rows []*T, mask func(*T) *T) []*T {
|
||||
if mask == nil {
|
||||
return rows
|
||||
}
|
||||
for i, r := range rows {
|
||||
rows[i] = mask(r)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// pageParams returns (page, size, paginated). A list paginates ONLY when BOTH
|
||||
// `p` and `pageSize` are present and positive; otherwise the caller returns the
|
||||
// full set (v1 semantics).
|
||||
func pageParams(c *zip.Ctx) (page, size int, paginated bool) {
|
||||
pp, ps := c.Query("p"), c.Query("pageSize")
|
||||
if pp == "" || ps == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
page, _ = strconv.Atoi(pp)
|
||||
size, _ = strconv.Atoi(ps)
|
||||
if page <= 0 || size <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return page, size, true
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/applications"
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/organizations"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/users"
|
||||
)
|
||||
|
||||
// The Casdoor WRITE verbs (add-organization, add-user, update-user,
|
||||
// update-application) the console admin BFF hard-codes, served over the SAME entity
|
||||
// Create/Update logic as the REST surface — no CRUD is reimplemented here. Each is a
|
||||
// TYPED zip op (not a raw handler), which is what preserves authorization: the ONE
|
||||
// authz seam (app.Authorize) runs at every typed op's invoke on the DECODED input, so
|
||||
// a write alias is authorized against the exact (owner, name) it will bind — a super
|
||||
// for a platform-owned org/app, an org-admin for its own users — identical to the REST
|
||||
// twin. The result is wrapped in the casibase {status,msg,data} envelope the clients
|
||||
// parse; the data is the REDACTED entity (each Create/Update returns Mask()).
|
||||
//
|
||||
// Read verbs ride aliases.go; these are the "Writes ride a companion file" half.
|
||||
|
||||
// mountWrites registers the Casdoor write-verb aliases on app. Called from Mount
|
||||
// (aliases.go) so reads and writes share the one Guard/Authorize seam.
|
||||
func mountWrites(app *zip.App, db orm.DB) {
|
||||
orgs := organizations.NewOrganizationAPI(db)
|
||||
usersAPI := users.New(db)
|
||||
appUpdate := applications.Update(db)
|
||||
|
||||
zip.Post(app, "/v1/iam/add-organization",
|
||||
func(ctx context.Context, in *organizations.CreateOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Create(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addOrganization"), zip.WithSummary("Create an organization (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
zip.Post(app, "/v1/iam/add-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Create(ctx, &users.CreateInput{User: in.User, Password: in.Password}))
|
||||
},
|
||||
zip.WithOperationID("addUser"), zip.WithSummary("Create a user (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
zip.Post(app, "/v1/iam/update-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Update(ctx, &users.UpdateInput{User: in.User, Password: in.Password}))
|
||||
},
|
||||
zip.WithOperationID("updateUser"), zip.WithSummary("Update a user (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
zip.Post(app, "/v1/iam/update-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appUpdate(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("updateApplication"), zip.WithSummary("Update an application (Casdoor verb)"), zip.WithTags("compat"))
|
||||
}
|
||||
|
||||
// userBody is the bare-user body the Casdoor add-user/update-user verbs post (the
|
||||
// user's fields at top level, plus an optional plaintext password), distinct from the
|
||||
// REST twin's {user,password} envelope. It embeds schema.User so the authz op-seam
|
||||
// reads the target (Owner, Name) straight off it, then the handler hands the parts to
|
||||
// the ONE users Create/Update path (which bcrypt-hashes the password — never stored
|
||||
// plaintext — and returns the redacted row).
|
||||
type userBody struct {
|
||||
schema.User
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// envelope wraps an entity Create/Update result in the casibase Response the Casdoor
|
||||
// clients parse: {status:"ok", data:<masked entity>} on success, or a 200
|
||||
// {status:"error", msg} on a handler error (the casibase convention — clients branch
|
||||
// on status, not the HTTP code), never an HTTP error status. An authorization refusal
|
||||
// happens earlier, at the op-seam, and surfaces as a 403 the clients already handle.
|
||||
func envelope[T any](entity *T, err error) (*httpx.Response, error) {
|
||||
if err != nil {
|
||||
return &httpx.Response{Status: "error", Msg: err.Error()}, nil
|
||||
}
|
||||
return &httpx.Response{Status: "ok", Data: entity}, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package compat_test
|
||||
|
||||
// End-to-end tests for the Casdoor WRITE verbs + the front-door publicPaths fix,
|
||||
// driven through the REAL mounted router (routes.Mount installs the authz Guard +
|
||||
// Authorize seam). They assert the three write contracts a backend swap depends on:
|
||||
// the {status,ok} envelope every client parses, authorization identical to the REST
|
||||
// twin (super for platform-owned org/app; org-admin for its own users; cross-tenant
|
||||
// refused), and that no secret ever surfaces. Plus: the front-door session routes are
|
||||
// reachable WITHOUT a bearer (the portal/admin-guard call them with a cookie).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// post issues a JSON POST through the real router and returns (status, rawBody).
|
||||
func (h *harness) post(t *testing.T, path, bearer string, body any) (int, string) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := h.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b2, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b2)
|
||||
}
|
||||
|
||||
// okEnvelope decodes a body and asserts status=="ok".
|
||||
func okEnvelope(t *testing.T, status int, body string) {
|
||||
t.Helper()
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
t.Fatalf("not the v1 envelope: %v; body=%s", err, body)
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("status field = %v, want ok; body=%s", m["status"], body)
|
||||
}
|
||||
}
|
||||
|
||||
// add-organization is a platform-owned write — only a SuperAdmin may create one,
|
||||
// through the SAME organizations.Create the REST route uses. The created row is then
|
||||
// readable via the get-organization read alias.
|
||||
func TestAddOrganization_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/add-organization", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "admin", "name": "acme", "displayName": "Acme"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
// It hit the real store — the org is now readable through the get alias.
|
||||
if s, rb := h.get(t, "/v1/iam/get-organization?id=admin/acme", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "acme") {
|
||||
t.Fatalf("created org not readable: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-super is refused at the ONE authz seam (platform-owned resource → super-only),
|
||||
// exactly as the REST twin is.
|
||||
func TestAddOrganization_nonSuperForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, _ := h.post(t, "/v1/iam/add-organization", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "admin", "name": "acme"})
|
||||
if status != 403 {
|
||||
t.Fatalf("org-admin add-organization status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// add-user: an org-admin creates a user in its OWN org through users.Create — the
|
||||
// password is bcrypt-hashed (never returned/stored plaintext) and the row comes back
|
||||
// redacted.
|
||||
func TestAddUser_orgAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "hanzo", "name": "newbie", "password": "S3cret-pw!"})
|
||||
okEnvelope(t, status, body)
|
||||
if strings.Contains(body, "S3cret-pw!") {
|
||||
t.Fatalf("add-user echoed the plaintext password: %s", body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
// Readable through the get alias (same store).
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/newbie", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "newbie") {
|
||||
t.Fatalf("created user not readable: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-tenant create is refused: hanzo's admin cannot add a user under orgb.
|
||||
func TestAddUser_crossTenantForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, _ := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "orgb", "name": "intruder", "password": "x"})
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant add-user status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// update-user overwrites from the body (casdoor semantics) through users.Update; the
|
||||
// change is visible via the get alias and no secret leaks.
|
||||
func TestUpdateUser_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/update-user", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "hanzo", "name": "alice", "displayName": "Alice Updated"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "Alice Updated") {
|
||||
t.Fatalf("update-user not applied: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// update-application is platform-owned → super-only, through applications.Update.
|
||||
func TestUpdateApplication_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/update-application", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "admin", "name": "hanzo-console", "displayName": "Console"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
// The write verbs are gated — no bearer fails closed at the Guard (they are not in the
|
||||
// public allowlist).
|
||||
func TestWriteAliases_requireAuth(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if status, _ := h.post(t, "/v1/iam/add-user", "", map[string]any{"owner": "hanzo", "name": "x"}); status != 401 {
|
||||
t.Fatalf("unauthenticated add-user status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// The FRONT-DOOR session routes are PUBLIC in the Guard: reachable WITHOUT a bearer
|
||||
// (the portal + gateway admin-guard call them with a session cookie). Before this
|
||||
// change they 401'd at the Guard through routes.Mount; now an anonymous caller gets
|
||||
// the casibase {status:"error"} (200), never a 401 and never a leak.
|
||||
func TestFrontDoorPublic_ReachableWithoutBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/v1/iam/get-account"},
|
||||
{"GET", "/v1/iam/whoami"},
|
||||
{"GET", "/v1/iam/linked-accounts"},
|
||||
} {
|
||||
status, body := h.get(t, tc.path, "")
|
||||
if status != 200 {
|
||||
t.Fatalf("%s %s without a bearer status=%d, want 200 (public); body=%s", tc.method, tc.path, status, body)
|
||||
}
|
||||
if !strings.Contains(body, "\"error\"") {
|
||||
t.Fatalf("anonymous %s must be the casibase error envelope; body=%s", tc.path, body)
|
||||
}
|
||||
}
|
||||
// signin (a POST) is public too — anonymous, no code → a 200 error, not a 401.
|
||||
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 200 || !strings.Contains(body, "\"error\"") {
|
||||
t.Fatalf("anonymous signin status=%d body=%s, want 200 error (public)", status, body)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package featurestore implements feature.Store over the iam2 orm store, so the
|
||||
// hanzoiam/* enterprise modules read/write the SAME identity data as the core.
|
||||
// Internal: the core (server.Mount) constructs it and hands the interface to
|
||||
// feature.MountAll — modules never see this package, only the feature.Store seam.
|
||||
package featurestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam2/feature"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
"github.com/hanzoai/iam2/internal/users"
|
||||
"github.com/hanzoai/iam2/pkg/model"
|
||||
)
|
||||
|
||||
type ormStore struct {
|
||||
db orm.DB
|
||||
u *users.API
|
||||
}
|
||||
|
||||
// New returns a feature.Store backed by db (the core's one identity store).
|
||||
func New(db orm.DB) feature.Store { return &ormStore{db: db, u: users.New(db)} }
|
||||
|
||||
func (s *ormStore) GetUser(ctx context.Context, owner, name string) (*model.User, error) {
|
||||
return store.GetUserByName(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetUserByID(_ context.Context, id string) (*model.User, error) {
|
||||
u, err := orm.Get[schema.User](s.db, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error) {
|
||||
total, err := orm.TypedQuery[schema.User](s.db).Count(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.User](s.db).Order("Name")
|
||||
if offset > 0 {
|
||||
q = q.Offset(offset)
|
||||
}
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
list, err := q.GetAll(ctx)
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (s *ormStore) AddUser(ctx context.Context, u *model.User) (bool, error) {
|
||||
if _, err := s.u.Create(ctx, &users.CreateInput{User: *u}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) UpdateUser(ctx context.Context, u *model.User) (bool, error) {
|
||||
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) DeleteUser(ctx context.Context, owner, name string) (bool, error) {
|
||||
out, err := s.u.Delete(ctx, &users.Ref{Owner: owner, Name: name})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out.Deleted, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) GetApplication(ctx context.Context, id string) (*model.Application, error) {
|
||||
if app, err := store.GetApplicationByName(ctx, s.db, "admin", id); err == nil && app != nil {
|
||||
return app, nil
|
||||
}
|
||||
return store.GetApplicationByClientId(ctx, s.db, id)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetOrganization(ctx context.Context, name string) (*model.Organization, error) {
|
||||
return store.GetOrganizationByName(ctx, s.db, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetProvider(ctx context.Context, owner, name string) (*model.Provider, error) {
|
||||
return store.GetProvider(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetCert(ctx context.Context, owner, name string) (*model.Cert, error) {
|
||||
return store.GetCert(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
// SetPassword loads the canonical row and re-saves it with the plaintext, which
|
||||
// users.Update hashes exactly once (empty leaves the digest untouched). Passing
|
||||
// the full existing row means no other field is zeroed by the update.
|
||||
func (s *ormStore) SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
|
||||
u, err := store.GetUserByName(ctx, s.db, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u, Password: plaintext}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyPassword defers to the core's digest-scheme-aware verifier (argon2id v1 /
|
||||
// bcrypt v2), keyed by the org's password type — no hash ever leaves the core.
|
||||
func (s *ormStore) VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
|
||||
u, err := store.GetUserByName(ctx, s.db, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
pwType := ""
|
||||
if org, oerr := store.GetOrganizationByName(ctx, s.db, owner); oerr == nil && org != nil {
|
||||
pwType = org.PasswordType
|
||||
}
|
||||
return users.VerifyPassword(u, plaintext, pwType), nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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[:]))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// Front-door JSON endpoints the @hanzo/iam SDK + hanzo.id portal call: the login
|
||||
// UI descriptors (get-app-login, auth/methods), the account read (get-account),
|
||||
// account creation (signup), and OTP send (send-verification-code). Login itself
|
||||
// is MountLogin; the OIDC/OAuth surface is Mount.
|
||||
const (
|
||||
PathGetAppLogin = "/v1/iam/get-app-login"
|
||||
PathAuthMethods = "/v1/iam/auth/methods"
|
||||
)
|
||||
|
||||
// MountFrontDoor registers the front-door endpoints the hosted hanzo.id portal
|
||||
// and the @hanzo/iam SDK call. Separate from Mount because these need the entity
|
||||
// store; the OIDC discovery/JWKS surface does not.
|
||||
func MountFrontDoor(app *zip.App, db orm.DB) {
|
||||
app.Get(PathGetAppLogin, getAppLogin(db))
|
||||
app.Get(PathAuthMethods, authMethods(db))
|
||||
// get-account is anonymous-safe (returns {status:"error"} unauthenticated)
|
||||
// and a security contract — the gateway admin-guard reads its `owner`.
|
||||
app.Get(PathGetAccount, getAccount(db))
|
||||
// Account creation + email/phone OTP send. signup is JSON; send-verification-code
|
||||
// is multipart/form-data (HIP-0111 §4 invariant), read via fiber's FormValue.
|
||||
app.Post(PathSignup, signupHandler(db))
|
||||
app.Post(PathSendVerificationCode, sendVerificationCode(db))
|
||||
|
||||
// The session/identity front door the console drives once a user is signed in:
|
||||
// signin (the code→session exchange), whoami (lightweight identity), onboard
|
||||
// (first-run org creation + move), update-preferences (self, shallow-merge), and
|
||||
// linked-accounts (the caller's linked identities). Each resolves + self-scopes
|
||||
// the caller (callerOf), so all are public in the Guard like get-account.
|
||||
app.Post(PathSignin, signinHandler(db))
|
||||
app.Get(PathWhoami, whoamiHandler(db))
|
||||
app.Post(PathOnboard, onboardHandler(db))
|
||||
app.Post(PathUpdatePreferences, updatePreferencesHandler(db))
|
||||
app.Get(PathLinkedAccounts, linkedAccountsHandler(db))
|
||||
}
|
||||
|
||||
// getAppLogin resolves an application by clientId and returns it with the
|
||||
// ClientSecret masked and each provider link enriched with its shared provider
|
||||
// record — the canonical source of truth the login UI reads to decide which
|
||||
// sign-in methods to render. Mirrors the v1 Casdoor get-app-login contract
|
||||
// (Response envelope, data = the masked application).
|
||||
func getAppLogin(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if rt := c.Query("responseType"); rt != "" && rt != "code" {
|
||||
return httpx.Err(c, "response_type is required (must be code)")
|
||||
}
|
||||
clientId := c.Query("clientId")
|
||||
if clientId == "" {
|
||||
return httpx.Err(c, "clientId is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
store.EnrichProviders(c.Context(), db, app)
|
||||
return httpx.Ok(c, maskApp(app))
|
||||
}
|
||||
}
|
||||
|
||||
// authMethods reports the enabled sign-in methods for an application so the SDK
|
||||
// <Login> self-configures instead of hard-coding a provider list. This endpoint
|
||||
// does NOT exist in v1 — it is the clean seam that lets one <Login> render the
|
||||
// right buttons for any app. Pure read over the resolved application.
|
||||
func authMethods(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
clientId := c.Query("clientId")
|
||||
if clientId == "" {
|
||||
return httpx.Err(c, "clientId is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
store.EnrichProviders(c.Context(), db, app)
|
||||
|
||||
oauth := []map[string]string{}
|
||||
web3 := false
|
||||
for _, it := range app.Providers {
|
||||
if it == nil || it.Provider == nil || !it.CanSignIn {
|
||||
continue
|
||||
}
|
||||
if !isConfigured(it.Provider) {
|
||||
continue // hidden until real creds land — never a dead-end button
|
||||
}
|
||||
switch strings.ToLower(it.Provider.Category) {
|
||||
case "web3":
|
||||
web3 = true
|
||||
case "oauth":
|
||||
oauth = append(oauth, map[string]string{
|
||||
"name": it.Name,
|
||||
"type": it.Provider.Type,
|
||||
"logo": it.Provider.CustomLogo,
|
||||
})
|
||||
}
|
||||
}
|
||||
return httpx.Ok(c, map[string]any{
|
||||
"password": app.EnablePassword,
|
||||
"code": app.EnableCodeSignin,
|
||||
"webauthn": app.EnableWebAuthn,
|
||||
"web3": web3,
|
||||
"oauth": oauth,
|
||||
"signup": app.EnableSignUp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isConfigured reports whether a provider holds a real (non-placeholder)
|
||||
// credential — the guard that keeps an unconfigured provider's button hidden so
|
||||
// it never dead-ends the OAuth redirect.
|
||||
func isConfigured(p *schema.Provider) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
// Web3 is native challenge/response — no OAuth client to configure.
|
||||
if strings.EqualFold(p.Category, "Web3") {
|
||||
return true
|
||||
}
|
||||
id := strings.ToLower(strings.TrimSpace(p.ClientId))
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(id, "placeholder") &&
|
||||
!strings.HasPrefix(id, "your-") &&
|
||||
!strings.HasPrefix(id, "xxx") &&
|
||||
!strings.Contains(id, "change")
|
||||
}
|
||||
|
||||
// maskApp returns a copy-safe view of the application with the client secret and
|
||||
// every provider's secret removed — get-app-login is called by the browser, so
|
||||
// no secret may cross it.
|
||||
func maskApp(app *schema.Application) *schema.Application {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
masked := *app
|
||||
masked.ClientSecret = ""
|
||||
for _, it := range masked.Providers {
|
||||
if it != nil && it.Provider != nil {
|
||||
p := *it.Provider
|
||||
p.ClientSecret = ""
|
||||
p.ClientSecret2 = ""
|
||||
it.Provider = &p
|
||||
}
|
||||
}
|
||||
return &masked
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// sessionCookieFor drives a bare (type=login) portal sign-in for hanzo/alice and
|
||||
// returns the "name=value" of the session cookie it set — the credential the
|
||||
// front-door session routes resolve the caller from.
|
||||
func sessionCookieFor(t *testing.T, app *zip.App) string {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"organization": {"hanzo"}, "application": {"conf"},
|
||||
"username": {"alice"}, "password": {"pw"}, "type": {"login"},
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathLogin, form))
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("login failed: %s", body)
|
||||
}
|
||||
return cookieKV(resp.Header.Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
// signin exchanges an authorization code for a session and returns the caller's
|
||||
// redacted account — the same envelope get-account returns, so the console's
|
||||
// post<Account>('iam/signin') resolves the signed-in user in one call.
|
||||
func TestSignin_CodeExchangeSetsSessionAndReturnsAccount(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "application": "conf", "clientId": "conf",
|
||||
"username": "alice", "password": "pw",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("login (type=code) minted no code")
|
||||
}
|
||||
|
||||
resp, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code))
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("signin status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if env["sub"] != "hanzo/alice" || env["name"] != "alice" {
|
||||
t.Errorf("signin sub/name = %v/%v, want hanzo/alice / alice", env["sub"], env["name"])
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
if data["owner"] != "hanzo" {
|
||||
t.Errorf("signin data.owner = %v, want hanzo", data["owner"])
|
||||
}
|
||||
if v, ok := data["passwordHash"]; ok && v != "" {
|
||||
t.Errorf("signin leaked passwordHash")
|
||||
}
|
||||
// It establishes the durable session get-account resolves from.
|
||||
cookie := resp.Header.Get("Set-Cookie")
|
||||
if !strings.HasPrefix(cookie, "hanzo_session=") {
|
||||
t.Fatalf("signin did not set the session cookie: %q", cookie)
|
||||
}
|
||||
req := formReqNoBody("GET", PathGetAccount)
|
||||
req.Header.Set("Cookie", cookieKV(cookie))
|
||||
resp2, body2 := do(t, app, req)
|
||||
if resp2.StatusCode != 200 || decode(t, body2)["status"] != "ok" {
|
||||
t.Fatalf("get-account via the signin cookie failed: %s", body2)
|
||||
}
|
||||
}
|
||||
|
||||
// The code is single-use: a replay after redemption is refused (no second session).
|
||||
func TestSignin_ReplayedCodeRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "application": "conf", "clientId": "conf",
|
||||
"username": "alice", "password": "pw",
|
||||
})
|
||||
if _, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code)); decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("first signin should succeed: %s", body)
|
||||
}
|
||||
_, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code))
|
||||
if decode(t, body)["status"] != "error" {
|
||||
t.Fatalf("replayed code must be refused, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// whoami resolves the caller from the session cookie and returns the lightweight
|
||||
// identity; an anonymous caller gets {status:"error"}, never a leak.
|
||||
func TestWhoami_CookieResolvesIdentity(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := formReqNoBody("GET", PathWhoami)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["status"] != "ok" || env["sub"] != "hanzo/alice" {
|
||||
t.Fatalf("whoami via cookie: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
if data["owner"] != "hanzo" || data["name"] != "alice" || data["id"] != "hanzo/alice" {
|
||||
t.Errorf("whoami identity = %v, want owner/name/id = hanzo/alice/hanzo/alice", data)
|
||||
}
|
||||
|
||||
// Anonymous → error, no data.
|
||||
_, anon := do(t, app, formReqNoBody("GET", PathWhoami))
|
||||
if e := decode(t, anon); e["status"] != "error" || e["data"] != nil {
|
||||
t.Fatalf("anonymous whoami must be error with no data: %s", anon)
|
||||
}
|
||||
}
|
||||
|
||||
// update-preferences shallow-merges: a second patch adds a key without clobbering
|
||||
// the first, and the merged object is returned + persisted on the caller's row.
|
||||
func TestUpdatePreferences_ShallowMergeRoundTrip(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
post := func(patch any) map[string]any {
|
||||
req := jsonReq("POST", PathUpdatePreferences, patch)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("update-preferences: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
return data
|
||||
}
|
||||
|
||||
if got := post(map[string]any{"onboarding_completed": true}); got["onboarding_completed"] != true {
|
||||
t.Fatalf("first patch not reflected: %v", got)
|
||||
}
|
||||
got := post(map[string]any{"theme": "dark"})
|
||||
if got["onboarding_completed"] != true || got["theme"] != "dark" {
|
||||
t.Fatalf("second patch clobbered the first (want both keys): %v", got)
|
||||
}
|
||||
|
||||
// Persisted on the row — a fresh read shows the merged blob under hanzo.preferences.
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if u == nil || !strings.Contains(u.Properties[preferencesKey], "onboarding_completed") ||
|
||||
!strings.Contains(u.Properties[preferencesKey], "theme") {
|
||||
t.Fatalf("preferences not persisted: %+v", u.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
// onboard creates the named org and MOVES the caller into it as admin (their owner
|
||||
// becomes the new slug); the console reads {org:<slug>} and re-authenticates.
|
||||
func TestOnboard_CreatesOrgAndMovesCaller(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := jsonReq("POST", PathOnboard, map[string]any{"name": "Acme Inc"})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["org"] != "acme-inc" {
|
||||
t.Fatalf("onboard status=%d body=%s (want {org:acme-inc})", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if org, _ := store.GetOrganizationByName(ctx, db, "acme-inc"); org == nil || org.Owner != "admin" {
|
||||
t.Fatalf("onboard did not create the acme-inc org: %+v", org)
|
||||
}
|
||||
// alice moved out of hanzo and into acme-inc as admin.
|
||||
if old, _ := store.GetUserByName(ctx, db, "hanzo", "alice"); old != nil {
|
||||
t.Errorf("alice was not moved out of hanzo")
|
||||
}
|
||||
moved, _ := store.GetUserByName(ctx, db, "acme-inc", "alice")
|
||||
if moved == nil || !moved.IsAdmin {
|
||||
t.Fatalf("alice not moved into acme-inc as admin: %+v", moved)
|
||||
}
|
||||
}
|
||||
|
||||
// The one-click personal path creates a `<username>` org.
|
||||
func TestOnboard_PersonalOrg(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := jsonReq("POST", PathOnboard, map[string]any{"personal": true})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
if env := decode(t, body); resp.StatusCode != 200 || env["org"] != "alice" {
|
||||
t.Fatalf("personal onboard status=%d body=%s (want {org:alice})", resp.StatusCode, body)
|
||||
}
|
||||
if org, _ := store.GetOrganizationByName(context.Background(), db, "alice"); org == nil || !org.IsPersonal {
|
||||
t.Fatalf("personal org not created/flagged: %+v", org)
|
||||
}
|
||||
}
|
||||
|
||||
// A reserved slug (an IAM system owner) is refused — a customer can never become admin.
|
||||
func TestOnboard_ReservedRefused(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := jsonReq("POST", PathOnboard, map[string]any{"name": "admin"})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode == 200 || !strings.Contains(string(body), "reserved") {
|
||||
t.Fatalf("reserved org must be refused: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// linked-accounts returns the caller's non-empty connector columns and nothing else.
|
||||
func TestLinkedAccounts_ListsConnectorColumns(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
// Link a GitHub identity to alice.
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
u.GitHub = "octocat"
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("link github: %v", err)
|
||||
}
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := formReqNoBody("GET", PathLinkedAccounts)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), "github") || !strings.Contains(string(body), "octocat") {
|
||||
t.Fatalf("linked-accounts: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// linkedAccountsOf reflects only the connector columns — never Owner/Name/Email.
|
||||
func TestLinkedAccountsOf_OnlyConnectors(t *testing.T) {
|
||||
u := &schema.User{Owner: "hanzo", Name: "alice", Email: "a@x.io", GitHub: "octocat", Google: "g-1"}
|
||||
got := linkedAccountsOf(u)
|
||||
seen := map[string]string{}
|
||||
for _, la := range got {
|
||||
seen[la.Provider] = la.Subject
|
||||
}
|
||||
if seen["github"] != "octocat" || seen["google"] != "g-1" {
|
||||
t.Fatalf("missing a linked connector: %v", got)
|
||||
}
|
||||
for _, forbidden := range []string{"owner", "name", "email"} {
|
||||
if _, bad := seen[forbidden]; bad {
|
||||
t.Errorf("linkedAccountsOf leaked a non-connector field %q", forbidden)
|
||||
}
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("want exactly 2 linked accounts, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/sessions"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// PathGetAccount is the native front-door account endpoint — what the hanzo.id
|
||||
// portal's account page and the gateway admin-guard call.
|
||||
//
|
||||
// SECURITY CONTRACT. The gateway admin-guard derives the global-admin
|
||||
// (SuperAdmin) predicate from the `owner` this returns — a caller is a global
|
||||
// admin iff `data.owner == AdminOrg` (gateway/cmd/admin-guard). So the response
|
||||
// shape MUST match v1 exactly — {status, sub, name, data:<user>, data2:<org>} —
|
||||
// and every secret (password hash, access secret, TOTP, recovery codes) MUST be
|
||||
// redacted. Anonymous callers get {status:"error"} (200, casibase convention),
|
||||
// never a leak: the admin-guard reads status=="error" → not-admin, fail-closed.
|
||||
const PathGetAccount = "/v1/iam/get-account"
|
||||
|
||||
// accountResponse mirrors v1's Response for get-account (the casibase envelope).
|
||||
type accountResponse struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Data2 any `json:"data2,omitempty"`
|
||||
}
|
||||
|
||||
// getAccount resolves the signed-in caller and returns their REDACTED account +
|
||||
// organization. Resolution (callerOf) is by session cookie first — the portal +
|
||||
// gateway-admin-guard path — then bearer access token — the API path.
|
||||
func getAccount(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return c.JSON(200, accountResponse{Status: "error", Msg: "please sign in first"})
|
||||
}
|
||||
env, status := accountEnvelopeFor(ctx, db, owner, name)
|
||||
return c.JSON(status, env)
|
||||
}
|
||||
}
|
||||
|
||||
// accountEnvelopeFor builds the get-account envelope for an already-resolved
|
||||
// caller: the REDACTED user + organization in the casibase shape, or an error
|
||||
// envelope when the user or org lookup fails. It is the ONE place the account
|
||||
// envelope is assembled, shared by get-account and signin (the code→session
|
||||
// exchange), so the two can never drift. status is the HTTP code to return with
|
||||
// it (200 for both ok and the casibase "error" convention, 500 on a store fault).
|
||||
func accountEnvelopeFor(ctx context.Context, db orm.DB, owner, name string) (accountResponse, int) {
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return accountResponse{Status: "error", Msg: "server_error"}, 500
|
||||
}
|
||||
if user == nil {
|
||||
return accountResponse{Status: "error", Msg: "the user does not exist"}, 200
|
||||
}
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return accountResponse{Status: "error", Msg: "server_error"}, 500
|
||||
}
|
||||
return accountResponse{
|
||||
Status: "ok",
|
||||
Sub: owner + "/" + name,
|
||||
Name: user.Name,
|
||||
Data: user.Mask(), // owner + isAdmin survive; every secret stripped
|
||||
Data2: org.Mask(), // org master/default passwords masked
|
||||
}, 200
|
||||
}
|
||||
|
||||
// callerOf resolves the signed-in principal by SESSION COOKIE first (the portal
|
||||
// and gateway-admin-guard path) then bearer access token (the API path) — two
|
||||
// credentials, one identity. ok=false means no valid session or token.
|
||||
func callerOf(ctx context.Context, c *zip.Ctx, db orm.DB) (owner, name string, ok bool) {
|
||||
if o, n, ok := sessions.Resolve(ctx, c.Fiber(), db); ok {
|
||||
return o, n, true
|
||||
}
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return "", "", false
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
o, n := splitSub(claims.Subject)
|
||||
return o, n, true
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// RFC 7662 Token Introspection + RFC 7009 Token Revocation — the two standard
|
||||
// token-management endpoints a resource server / confidential client uses. Both
|
||||
// are POST, client-authenticated (client_secret_basic or _post, constant-time),
|
||||
// on the OAuth token surface. Introspection reports whether a token is currently
|
||||
// active — JWT-valid AND its grant row still exists, so a REVOKED token reads
|
||||
// inactive — and returns its claims when active. Revocation deletes the grant
|
||||
// row (the whole refresh-rotation family for a refresh token) and always answers
|
||||
// 200 (RFC 7009 §2.2: an invalid/unknown token is not an error, so the endpoint
|
||||
// is no token-existence oracle).
|
||||
const (
|
||||
PathIntrospect = "/v1/iam/oauth/introspect"
|
||||
PathRevoke = "/v1/iam/oauth/revoke"
|
||||
)
|
||||
|
||||
// MountIntrospectRevoke registers the introspection + revocation endpoints.
|
||||
func MountIntrospectRevoke(app *zip.App, db orm.DB) {
|
||||
app.Post(PathIntrospect, introspectHandler(db))
|
||||
app.Post(PathRevoke, revokeHandler(db))
|
||||
}
|
||||
|
||||
// authConfidentialClient authenticates the calling client and requires it to be
|
||||
// CONFIDENTIAL (holds a verified secret). Introspection and revocation are
|
||||
// privileged token-management operations; a public client may not call them.
|
||||
// Constant-time secret compare; a nil app or empty stored secret fails closed.
|
||||
func authConfidentialClient(ctx context.Context, db orm.DB, c *zip.Ctx) (name string, ok bool) {
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return "", false
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil || app == nil || app.ClientSecret == "" {
|
||||
return "", false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return app.Name, true
|
||||
}
|
||||
|
||||
// introspectHandler implements RFC 7662. Active iff the grant row still exists
|
||||
// (revocation-aware, the same liveness check userinfo makes) AND the JWT verifies
|
||||
// under the trusted keys. The response carries the standard introspection claims;
|
||||
// an inactive/absent/unauthenticated-target token returns only `{active:false}`.
|
||||
func introspectHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
if _, ok := authConfidentialClient(ctx, db, c); !ok {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
|
||||
tokenStr := param(c, "token")
|
||||
if tokenStr == "" {
|
||||
return c.JSON(200, inactiveToken())
|
||||
}
|
||||
h := hashToken(tokenStr)
|
||||
// Liveness: the grant row must still exist (a revoked/rotated token has none).
|
||||
row, _ := store.GetTokenByAccessTokenHash(ctx, db, h)
|
||||
if row == nil {
|
||||
row, _ = store.GetTokenByRefreshHash(ctx, db, h)
|
||||
}
|
||||
if row == nil {
|
||||
return c.JSON(200, inactiveToken())
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, tokenStr)
|
||||
if err != nil {
|
||||
return c.JSON(200, inactiveToken())
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"active": true,
|
||||
"token_type": "Bearer",
|
||||
"scope": claims.Scope,
|
||||
"client_id": claims.Azp,
|
||||
"sub": claims.Subject,
|
||||
"iss": claims.Issuer,
|
||||
"owner": claims.Owner,
|
||||
}
|
||||
if claims.Organization != "" {
|
||||
resp["organization"] = claims.Organization
|
||||
}
|
||||
if claims.Email != "" {
|
||||
resp["username"] = claims.Email
|
||||
}
|
||||
if len(claims.Audience) > 0 {
|
||||
resp["aud"] = claims.Audience
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
resp["exp"] = claims.ExpiresAt.Unix()
|
||||
}
|
||||
if claims.IssuedAt != nil {
|
||||
resp["iat"] = claims.IssuedAt.Unix()
|
||||
}
|
||||
if claims.NotBefore != nil {
|
||||
resp["nbf"] = claims.NotBefore.Unix()
|
||||
}
|
||||
if claims.ID != "" {
|
||||
resp["jti"] = claims.ID
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// revokeHandler implements RFC 7009. A confidential client revokes a token that
|
||||
// was issued to IT (§2.1) — an access token deletes that grant row; a refresh
|
||||
// token revokes the whole rotation family so no further access tokens can be
|
||||
// minted and every sibling dies. A token belonging to another client, or an
|
||||
// unknown token, is a silent 200 (no revocation, no oracle — §2.2).
|
||||
func revokeHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
clientName, ok := authConfidentialClient(ctx, db, c)
|
||||
if !ok {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
|
||||
tokenStr := param(c, "token")
|
||||
if tokenStr == "" {
|
||||
return revoked(c)
|
||||
}
|
||||
h := hashToken(tokenStr)
|
||||
|
||||
if row, _ := store.GetTokenByAccessTokenHash(ctx, db, h); row != nil {
|
||||
if row.Application == clientName {
|
||||
_ = store.DeleteToken(ctx, db, row)
|
||||
}
|
||||
return revoked(c)
|
||||
}
|
||||
if row, _ := store.GetTokenByRefreshHash(ctx, db, h); row != nil {
|
||||
if row.Application == clientName {
|
||||
family, _ := store.ListTokensByRefreshFamily(ctx, db, row.RefreshFamily)
|
||||
for _, t := range family {
|
||||
_ = store.DeleteToken(ctx, db, t)
|
||||
}
|
||||
}
|
||||
return revoked(c)
|
||||
}
|
||||
return revoked(c)
|
||||
}
|
||||
}
|
||||
|
||||
// inactiveToken is the RFC 7662 response for a token that is not active.
|
||||
func inactiveToken() map[string]any { return map[string]any{"active": false} }
|
||||
|
||||
// revoked is the RFC 7009 §2.2 success response: HTTP 200, empty body.
|
||||
func revoked(c *zip.Ctx) error { return c.Status(200).JSON(200, struct{}{}) }
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// RFC 7662 introspection + RFC 7009 revocation, end to end: mint a real token via
|
||||
// the password grant, introspect it (active + claims), revoke it, and confirm it
|
||||
// then reads inactive AND its bearer no longer resolves at userinfo.
|
||||
|
||||
// postForm posts a form to path as the confidential client (client_secret_basic).
|
||||
func postForm(t *testing.T, app *zip.App, path, clientID, secret string, form url.Values) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
req := formReq("POST", path, form)
|
||||
if clientID != "" {
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
|
||||
}
|
||||
resp, body := do(t, app, req)
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
// mintPasswordToken issues an access+refresh token for the seeded user.
|
||||
func mintPasswordToken(t *testing.T, app *zip.App) (access, refresh string) {
|
||||
t.Helper()
|
||||
_, tok := postToken(t, app, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {"hanzo-console"},
|
||||
"client_secret": {"top-secret"},
|
||||
"username": {"alice@hanzo.ai"},
|
||||
"password": {"correct horse"},
|
||||
"scope": {"openid profile email offline_access"},
|
||||
})
|
||||
access, _ = tok["access_token"].(string)
|
||||
refresh, _ = tok["refresh_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("no access_token minted; body=%v", tok)
|
||||
}
|
||||
return access, refresh
|
||||
}
|
||||
|
||||
func TestIntrospect_activeToken_returnsClaims(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
_ = db
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
access, _ := mintPasswordToken(t, app)
|
||||
|
||||
_, ir := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if ir["active"] != true {
|
||||
t.Fatalf("active = %v, want true; body=%v", ir["active"], ir)
|
||||
}
|
||||
if ir["sub"] != "hanzo/alice" {
|
||||
t.Errorf("sub = %v, want hanzo/alice", ir["sub"])
|
||||
}
|
||||
if ir["owner"] != "hanzo" {
|
||||
t.Errorf("owner = %v, want hanzo", ir["owner"])
|
||||
}
|
||||
if ir["token_type"] != "Bearer" {
|
||||
t.Errorf("token_type = %v, want Bearer", ir["token_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospect_requiresConfidentialClientAuth(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
access, _ := mintPasswordToken(t, app)
|
||||
|
||||
// No client auth → 401 (introspection is privileged).
|
||||
resp, _ := postForm(t, app, PathIntrospect, "", "", url.Values{"token": {access}})
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("unauthenticated introspect status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
// Wrong secret → 401.
|
||||
resp2, _ := postForm(t, app, PathIntrospect, "hanzo-console", "WRONG", url.Values{"token": {access}})
|
||||
if resp2.StatusCode != 401 {
|
||||
t.Fatalf("bad-secret introspect status = %d, want 401", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntrospect_garbageToken_inactive(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
|
||||
_, ir := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {"not-a-real-token"}})
|
||||
if ir["active"] != false {
|
||||
t.Fatalf("garbage token active = %v, want false", ir["active"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevoke_accessToken_thenInactive(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
access, _ := mintPasswordToken(t, app)
|
||||
|
||||
// Active before revoke.
|
||||
_, before := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if before["active"] != true {
|
||||
t.Fatalf("token not active before revoke; body=%v", before)
|
||||
}
|
||||
|
||||
// Revoke → 200.
|
||||
resp, _ := postForm(t, app, PathRevoke, "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("revoke status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Inactive after revoke — introspection reflects the deleted grant row.
|
||||
_, after := postForm(t, app, PathIntrospect, "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if after["active"] != false {
|
||||
t.Fatalf("token still active after revoke; body=%v", after)
|
||||
}
|
||||
|
||||
// And the bearer no longer resolves at userinfo (revocation is real).
|
||||
req := formReqNoBody("GET", PathUserInfo)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
if resp, _ := do(t, app, req); resp.StatusCode == 200 {
|
||||
t.Fatalf("userinfo still 200 for a revoked bearer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevoke_unknownToken_is200_noOracle(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
|
||||
// RFC 7009 §2.2: an unknown token is a silent 200, not an error.
|
||||
resp, _ := postForm(t, app, PathRevoke, "hanzo-console", "top-secret", url.Values{"token": {"unknown"}})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("unknown-token revoke status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The confidential-client `hk-` Cloud API-key primitives. A trusted, allow-listed
|
||||
// backend (the console BFF as `hanzo-console`) authenticates as the confidential
|
||||
// CLIENT — not an end-user bearer — and (re)generates or revokes a `?id=<owner>/
|
||||
// <name>` target user's durable `hk-` key. (The on-behalf-of TOKEN minting that
|
||||
// used to live here — `issue-user-token` — is retired in favor of the standard
|
||||
// RFC 8693 Token Exchange grant on /oauth/token, per HIP-0111; it reuses the same
|
||||
// authorizeMinter allow-list + reserved-org gate + SignUserToken defined here.)
|
||||
//
|
||||
// API keys are a PRODUCT credential with no IETF standard, so they stay a first-
|
||||
// party primitive (flagged for a product decision on whether they become long-
|
||||
// lived tokens). They are NOT Bearer-gated (authz.Guard lists them public); each
|
||||
// does its own tighter authentication through the ONE authorizeMinter seam.
|
||||
const (
|
||||
PathMintUserKeys = "/v1/iam/mint-user-keys"
|
||||
PathRevokeUserKeys = "/v1/iam/revoke-user-keys"
|
||||
)
|
||||
|
||||
// MountIssueToken registers the confidential-client API-key primitives. POST-only:
|
||||
// they rotate a credential — never over a cacheable GET (a client_secret in a
|
||||
// query string would reach logs/proxies).
|
||||
func MountIssueToken(app *zip.App, db orm.DB) {
|
||||
app.Post(PathMintUserKeys, mintUserKeysHandler(db))
|
||||
app.Post(PathRevokeUserKeys, revokeUserKeysHandler(db))
|
||||
}
|
||||
|
||||
// mintUserKeysHandler (re)generates the target user's durable `hk-` Cloud API key
|
||||
// (schema.User.AccessKey) and returns it once, over the shared authorizeMinter +
|
||||
// mintTarget seam.
|
||||
func mintUserKeysHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
clientApp, status, msg := authorizeMinter(ctx, db, c)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user, status, msg := mintTarget(ctx, db, c, clientApp)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
key, err := newAccessKey()
|
||||
if err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
user.AccessKey = key
|
||||
user.UpdatedTime = nowFunc().UTC().Format(time.RFC3339)
|
||||
if err := saveUser(ctx, db, user); err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
auditMint(ctx, db, c, "mint-user-keys", clientApp.ClientId, user.Owner+"/"+user.Name)
|
||||
return httpx.Ok(c, map[string]any{"accessKey": key})
|
||||
}
|
||||
}
|
||||
|
||||
// revokeUserKeysHandler clears the target user's `hk-` key (immediate revoke).
|
||||
func revokeUserKeysHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
clientApp, status, msg := authorizeMinter(ctx, db, c)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user, status, msg := mintTarget(ctx, db, c, clientApp)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user.AccessKey = ""
|
||||
user.AccessSecret = ""
|
||||
user.AccessSecretHash = ""
|
||||
user.UpdatedTime = nowFunc().UTC().Format(time.RFC3339)
|
||||
if err := saveUser(ctx, db, user); err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
auditMint(ctx, db, c, "revoke-user-keys", clientApp.ClientId, user.Owner+"/"+user.Name)
|
||||
return httpx.Ok(c, map[string]any{"affected": true})
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeMinter is the ONE authentication seam for the confidential-client
|
||||
// primitives: it authenticates the client (client_secret_basic or _post,
|
||||
// constant-time) and enforces the mint allow-list. status==0 means authorized and
|
||||
// returns the client app; otherwise (status, msg) is the response to render. It
|
||||
// never reveals WHICH check failed beyond auth-vs-permission (401 vs 403).
|
||||
func authorizeMinter(ctx context.Context, db orm.DB, c *zip.Ctx) (*schema.Application, int, string) {
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return nil, 401, "client authentication required"
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return nil, 500, "server_error"
|
||||
}
|
||||
if app == nil || app.ClientSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return nil, 401, "client authentication failed"
|
||||
}
|
||||
// The capability gate: only an ALLOW-LISTED app may act on a user's behalf.
|
||||
// Fail closed — an unset allow-list permits NOTHING (these hand out / rotate a
|
||||
// user's credential; a missing config must never mean "anyone"). Matched by the
|
||||
// globally-unique clientId only (see mintAllowed).
|
||||
if !mintAllowed(clientID) {
|
||||
return nil, 403, "client is not on the user-key mint allow-list"
|
||||
}
|
||||
return app, 0, ""
|
||||
}
|
||||
|
||||
// mintTarget resolves and validates the `?id=<owner>/<name>` target user for the
|
||||
// authenticated clientApp. A missing id or absent user is a v1 business error
|
||||
// (200 + status:error); a revoked (forbidden/deleted) user is a 403 — no
|
||||
// credential is ever minted for it. A RESERVED-org (admin/built-in) target — a
|
||||
// cross-tenant / SuperAdmin identity — additionally requires the separate
|
||||
// admin-mint capability, so even a valid general minter cannot reach an admin-org
|
||||
// user unless explicitly granted (defense-in-depth behind the mint allow-list).
|
||||
func mintTarget(ctx context.Context, db orm.DB, c *zip.Ctx, clientApp *schema.Application) (*schema.User, int, string) {
|
||||
owner, name := splitSub(c.Query("id"))
|
||||
if owner == "" || name == "" {
|
||||
return nil, 200, "id (owner/name) is required"
|
||||
}
|
||||
if store.IsSigningCertOwner(owner) && !adminMintAllowed(clientApp.ClientId) {
|
||||
return nil, 403, "client is not permitted to act for a reserved-org user"
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return nil, 500, "server_error"
|
||||
}
|
||||
if user == nil {
|
||||
return nil, 200, "the user does not exist"
|
||||
}
|
||||
if user.IsForbidden || user.IsDeleted {
|
||||
return nil, 403, "the user is forbidden"
|
||||
}
|
||||
return user, 0, ""
|
||||
}
|
||||
|
||||
// auditMint best-effort records a confidential-primitive event — the
|
||||
// accountability trail for WHO (minter clientId) issued/rotated a credential for
|
||||
// WHOM (target subject). Emitted only on success. A failed audit write never
|
||||
// fails the operation (the credential was already issued); it is a record, not a
|
||||
// gate.
|
||||
func auditMint(ctx context.Context, db orm.DB, c *zip.Ctx, action, minterClientID, targetSub string) {
|
||||
name, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
owner, _ := splitSub(targetSub)
|
||||
log := orm.New[schema.AuditLog](db)
|
||||
log.Owner = owner
|
||||
log.Name = name
|
||||
log.CreatedTime = nowFunc().UTC().Format(time.RFC3339)
|
||||
log.Organization = owner
|
||||
log.User = targetSub
|
||||
log.Action = action
|
||||
log.Object = minterClientID
|
||||
log.Method = "POST"
|
||||
log.RequestUri = c.Path()
|
||||
log.StatusCode = 200
|
||||
log.IsTriggered = true
|
||||
log.SetId(owner + "/" + name)
|
||||
_ = log.CreateCtx(ctx)
|
||||
}
|
||||
|
||||
// mintErr renders the v1 error envelope with a correct HTTP status (the SDK
|
||||
// branches on status; a business error rides a 200, an auth/permission failure
|
||||
// its real 401/403).
|
||||
func mintErr(c *zip.Ctx, status int, msg string) error {
|
||||
return c.JSON(status, httpx.Response{Status: "error", Msg: msg})
|
||||
}
|
||||
|
||||
// mintAllowed reports whether a client is on the IAM_KEY_MINT_ALLOWED_APPS
|
||||
// allow-list. It matches the client's GLOBALLY-unique clientId ONLY — never the
|
||||
// per-owner-unique app Name: a Name match let a tenant org-admin register an app
|
||||
// named like the console in their OWN org and pass the gate, minting an admin-org
|
||||
// (SuperAdmin) token (red-team finding, closed here). An empty/unset list allows
|
||||
// nothing — fail closed.
|
||||
func mintAllowed(clientID string) bool {
|
||||
return appInList("IAM_KEY_MINT_ALLOWED_APPS", clientID)
|
||||
}
|
||||
|
||||
// adminMintAllowed reports whether a client may act on behalf of a RESERVED-org
|
||||
// (admin/built-in) user — a strictly narrower, separately-granted capability than
|
||||
// the general mint list, so a leaked general-minter secret can never reach a
|
||||
// SuperAdmin identity. The console, which legitimately drives admin.hanzo.ai, is
|
||||
// on both lists. Fail closed.
|
||||
func adminMintAllowed(clientID string) bool {
|
||||
return appInList("IAM_ADMIN_MINT_ALLOWED_APPS", clientID)
|
||||
}
|
||||
|
||||
// appInList matches clientID against a comma/space-separated env allow-list, by
|
||||
// exact clientId. Empty/unset → false (fail closed).
|
||||
func appInList(env, clientID string) bool {
|
||||
if clientID == "" {
|
||||
return false
|
||||
}
|
||||
raw := os.Getenv(env)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' }) {
|
||||
if item == clientID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultUserAudience is the audience a user token carries when the caller names
|
||||
// no explicit resource: the target user's own application's clientId (a same-app
|
||||
// consumer accepts it), falling back to the minting client when the user's app
|
||||
// can't be resolved — the caller then pins `?aud=` for a cross-app resource.
|
||||
func defaultUserAudience(ctx context.Context, db orm.DB, user *schema.User, clientApp *schema.Application) string {
|
||||
if user.SignupApplication != "" {
|
||||
// Applications are platform-owned (owner "admin").
|
||||
if ua, err := store.GetApplicationByName(ctx, db, "admin", user.SignupApplication); err == nil && ua != nil {
|
||||
return ua.ClientId
|
||||
}
|
||||
}
|
||||
return clientApp.ClientId
|
||||
}
|
||||
|
||||
// newAccessKey mints an `hk-`-prefixed Cloud API key (the durable credential the
|
||||
// gateway recognizes), a cryptographically-random opaque token behind the prefix.
|
||||
func newAccessKey() (string, error) {
|
||||
tok, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "hk-" + tok, nil
|
||||
}
|
||||
|
||||
// saveUser read-modify-writes the mutated user row by its (owner, name) key,
|
||||
// preserving every other field (orm persists the whole record).
|
||||
func saveUser(ctx context.Context, db orm.DB, user *schema.User) error {
|
||||
existing, err := orm.Get[schema.User](db, user.Owner+"/"+user.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
model := existing.Model
|
||||
*existing = *user
|
||||
existing.Model = model
|
||||
return existing.UpdateCtx(ctx)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// Red-team helpers + the field-preservation guard for the on-behalf-of primitives.
|
||||
// The name-collision priv-esc PoC that motivated the clientId-only allow-list is
|
||||
// now the regression guard TestTokenExchange_nameCollisionAttacker_403 (the
|
||||
// issue-user-token verb it originally attacked is retired in favor of RFC 8693
|
||||
// Token Exchange). seedAttackerApp stays here — it models exactly what a tenant
|
||||
// org-admin can create through POST /v1/iam/application (every field bound from
|
||||
// the body), the precondition that test relies on.
|
||||
|
||||
// seedAttackerApp creates a tenant-owned application the attacker fully controls
|
||||
// (owner=evil, chosen name/clientId/secret) whose Cert points at an EXISTING
|
||||
// trusted platform cert by name — exactly what internal/applications.create binds
|
||||
// from the request body (Owner/Name/ClientId/ClientSecret/Cert all verbatim).
|
||||
func seedAttackerApp(t *testing.T, db orm.DB, owner, name, clientID, secret, platformCert string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner = owner // a NON-reserved tenant org the attacker org-admins
|
||||
a.Name = name
|
||||
a.ClientId = clientID
|
||||
a.ClientSecret = secret
|
||||
a.Organization = owner
|
||||
a.Cert = platformCert // resolved among admin/built-in by GetSigningCert
|
||||
a.ExpireInHours = 1
|
||||
a.SetId(owner + "/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed attacker app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedTeam_mintKeys_preservesPasswordHashAndIsAdmin proves the mint/revoke
|
||||
// read-modify-write (saveUser) does not blank PasswordHash nor flip privilege
|
||||
// bits. GetUserByName returns the FULL row (no mask), so *existing = *user
|
||||
// preserves every field the handler didn't touch.
|
||||
func TestRedTeam_mintKeys_preservesPasswordHashAndIsAdmin(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name, u.Email = "hanzo", "carol", "carol@hanzo.ai"
|
||||
u.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=4$SALTSALT$HASHHASHHASH"
|
||||
u.PasswordType = "argon2id"
|
||||
u.IsAdmin = true
|
||||
u.SetId("hanzo/carol")
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if resp, body := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/carol")); resp.StatusCode != 200 {
|
||||
t.Fatalf("mint status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
got, err := orm.Get[schema.User](db, "hanzo/carol")
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got.PasswordHash != u.PasswordHash {
|
||||
t.Errorf("PasswordHash mutated by mint: %q", got.PasswordHash)
|
||||
}
|
||||
if got.PasswordType != "argon2id" {
|
||||
t.Errorf("PasswordType mutated by mint: %q", got.PasswordType)
|
||||
}
|
||||
if !got.IsAdmin {
|
||||
t.Errorf("IsAdmin flipped false by mint")
|
||||
}
|
||||
if got.AccessKey == "" {
|
||||
t.Errorf("mint did not set AccessKey")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The `hk-` Cloud API-key primitives (mint/revoke). A confidential, allow-listed
|
||||
// client (client_secret_basic) acts on a ?id=<owner>/<name> target user. These are
|
||||
// a product credential, not an RFC token flow — the on-behalf-of TOKEN minting is
|
||||
// RFC 8693 Token Exchange (token_exchange_test.go).
|
||||
|
||||
// seedForbiddenUser seeds a revoked (forbidden) user — no credential may be minted
|
||||
// or rotated for it.
|
||||
func seedForbiddenUser(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsForbidden = true
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed forbidden user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// keyReq builds a POST to a key primitive authenticating clientID/secret via Basic.
|
||||
func keyReq(path, clientID, secret, query string) *http.Request {
|
||||
req := httptest.NewRequest("POST", path+query, nil)
|
||||
req.Host = "hanzo.id"
|
||||
if clientID != "" {
|
||||
req.Header.Set("Authorization", "Basic "+
|
||||
base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// dataMap pulls the `data` object out of the v1 envelope.
|
||||
func dataMap(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
m := decode(t, body)
|
||||
d, _ := m["data"].(map[string]any)
|
||||
return d
|
||||
}
|
||||
|
||||
func TestMintUserKeys_generatesReadableHkKey(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, body := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d; body=%s", resp.StatusCode, body)
|
||||
}
|
||||
key, _ := dataMap(t, body)["accessKey"].(string)
|
||||
if !strings.HasPrefix(key, "hk-") || len(key) < 8 {
|
||||
t.Fatalf("accessKey = %q, want an hk- key", key)
|
||||
}
|
||||
// The minted key is persisted on the user row (get-user / getUserKey read it).
|
||||
u, err := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if u.AccessKey != key {
|
||||
t.Fatalf("persisted AccessKey = %q, want the minted %q", u.AccessKey, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeUserKeys_clearsTheKey(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
|
||||
resp, body := do(t, app, keyReq(PathRevokeUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("revoke status = %d; body=%s", resp.StatusCode, body)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if u.AccessKey != "" {
|
||||
t.Fatalf("AccessKey after revoke = %q, want empty", u.AccessKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintUserKeys_notAllowlisted_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "other")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, _ := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("off-allow-list mint status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintUserKeys_forbiddenUser_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedForbiddenUser(t, db, "hanzo", "banned")
|
||||
|
||||
resp, _ := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/banned"))
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("forbidden-user mint status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -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, "-", ""))]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// JWT token signing. The signing algorithm is a property of the signing Cert's
|
||||
// key, not a global: an RSA cert signs RS256 (the interoperable default that the
|
||||
// live hanzo.id JWKS serves), an EC cert signs ES256/384/512, and a post-quantum
|
||||
// ML-DSA-65 cert signs MLDSA65 (mldsa.go, behind the same jwt.SigningMethod
|
||||
// seam). The classical path is the load-bearing interop path — every existing
|
||||
// verifier reads the RS256 keys published in the JWKS; ML-DSA is additive and
|
||||
// inert until an ML-DSA Cert is configured. Keys come from the Cert entity
|
||||
// (KMS-backed); tests inject an ephemeral in-memory key through the same path.
|
||||
|
||||
// Claims is the iam2 token claim set: the standard registered claims plus the
|
||||
// Hanzo first-class claims the SDK and downstream validators read. owner and
|
||||
// organization are the tenant (both the org slug); scope carries the granted
|
||||
// scopes; nonce is echoed into the id_token; tokenType distinguishes an
|
||||
// access-token from an id-token. A field is emitted only when populated, so one
|
||||
// struct serves both token shapes without leaking empty claims.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Azp string `json:"azp,omitempty"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
}
|
||||
|
||||
// Signer signs tokens with one key under one algorithm. Immutable after
|
||||
// construction; the (method, key, kid, alg) tuple is fixed to the Cert it was
|
||||
// built from so a token can never be signed under a key/alg mismatch.
|
||||
type Signer struct {
|
||||
method jwt.SigningMethod
|
||||
key any // *rsa.PrivateKey | *ecdsa.PrivateKey | *mldsa65.PrivateKey
|
||||
kid string // JWKS key id — the Cert name
|
||||
alg string // JOSE alg — "RS256" | "ES256" | … | "MLDSA65"
|
||||
issuer string
|
||||
}
|
||||
|
||||
// NewSignerFromCert builds a Signer from a Cert, selecting the algorithm from
|
||||
// the cert's key type: RSA → RS256 (or RS512 when the app pins it), EC → ES256/
|
||||
// ES384/ES512 by curve, ML-DSA → MLDSA65. issuer is the canonical OIDC issuer
|
||||
// (https://<host>) that discovery advertises; it is pinned into every token so
|
||||
// id_token `iss` matches the discovery document. app may be nil (the method is
|
||||
// then chosen purely from the key type).
|
||||
func NewSignerFromCert(cert *schema.Cert, app *schema.Application, issuer string) (*Signer, error) {
|
||||
if cert == nil {
|
||||
return nil, errors.New("jwt: nil cert")
|
||||
}
|
||||
// Post-quantum ML-DSA-65 cert: raw key material, own signing method.
|
||||
if isMLDSACert(cert) {
|
||||
key, err := parseMLDSA65PrivateKey(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: SigningMethodMLDSA65, key: key, kid: cert.Name, alg: algMLDSA65, issuer: issuer}, nil
|
||||
}
|
||||
if cert.PrivateKey == "" {
|
||||
return nil, errors.New("jwt: cert has no private key")
|
||||
}
|
||||
key, err := parsePrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method, alg, err := methodForKey(key, pinnedMethod(app))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: method, key: key, kid: cert.Name, alg: alg, issuer: issuer}, nil
|
||||
}
|
||||
|
||||
// NewRSASignerFromCert builds an RS256 Signer from a Cert whose PrivateKey is a
|
||||
// PEM RSA key. Retained as the explicit RSA constructor; NewSignerFromCert is
|
||||
// the general dispatch used by the token endpoint.
|
||||
func NewRSASignerFromCert(cert *schema.Cert, issuer string) (*Signer, error) {
|
||||
if cert == nil || cert.PrivateKey == "" {
|
||||
return nil, errors.New("jwt: cert has no private key")
|
||||
}
|
||||
key, err := parseRSAPrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: cert.Name, alg: "RS256", issuer: issuer}, nil
|
||||
}
|
||||
|
||||
// NewRSASigner builds an RS256 Signer directly from an RSA key (tests and, in
|
||||
// dev, an ephemeral key when no Cert is configured).
|
||||
func NewRSASigner(key *rsa.PrivateKey, kid, issuer string) *Signer {
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: kid, alg: "RS256", issuer: issuer}
|
||||
}
|
||||
|
||||
// Sign issues a signed access token for (app, user) with the given scope. now is
|
||||
// injected for testability; ttl is the token lifetime. The audience is the app's
|
||||
// clientId (validators fail closed when aud != clientId).
|
||||
func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: userID,
|
||||
Audience: audienceFor(app, ""),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Organization: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Azp: app.ClientId,
|
||||
TokenType: "access-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// SignUserToken mints an access token a confidential client issues ON BEHALF OF a
|
||||
// target user — the RFC 8693 Token Exchange grant. Unlike Sign (which stamps the
|
||||
// APP's org as the owner claim), every authority claim here is the TARGET USER's:
|
||||
// the subject and owner are the user's, so a resource server that scopes on the
|
||||
// validated `owner` claim (cloud's SanitizeIdentity) scopes to the USER's tenant,
|
||||
// never the minting client's. `aud` is the caller-resolved audience (an explicit
|
||||
// RFC 8707 resource, else the user's own app) and `azp` records the minting
|
||||
// client. Signed under this signer's trusted cert + canonical issuer, so the same
|
||||
// JWKS verifies it — the token is indistinguishable from one the user obtained
|
||||
// directly, which is the point. The Signer stays decoupled from schema.User: the
|
||||
// handler resolves and passes the values it authorized.
|
||||
func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: subject,
|
||||
Audience: jwt.ClaimStrings{aud},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: owner,
|
||||
Organization: owner,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Azp: azp,
|
||||
TokenType: "access-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// SignID issues an OIDC id_token for (app, user). It differs from the access
|
||||
// token by carrying the echoed nonce and by declaring tokenType "id-token"; the
|
||||
// audience is the client the token was minted for (the RP), and iss matches the
|
||||
// discovery issuer so a standard OIDC client validates it.
|
||||
func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, nonce string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: userID,
|
||||
Audience: jwt.ClaimStrings{app.ClientId},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Organization: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Nonce: nonce,
|
||||
Azp: app.ClientId,
|
||||
TokenType: "id-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// signClaims is the single choke point that turns a claim set into a signed
|
||||
// compact JWS under this signer's fixed (method, key, kid).
|
||||
func (s *Signer) signClaims(claims Claims) (string, error) {
|
||||
tok := jwt.NewWithClaims(s.method, claims)
|
||||
if s.kid != "" {
|
||||
tok.Header["kid"] = s.kid
|
||||
}
|
||||
return tok.SignedString(s.key)
|
||||
}
|
||||
|
||||
// PublicKey returns the signer's RSA public key, or nil for a non-RSA signer
|
||||
// (JWKS + verification read the public key from the Cert, not the Signer).
|
||||
func (s *Signer) PublicKey() *rsa.PublicKey {
|
||||
if k, ok := s.key.(*rsa.PrivateKey); ok {
|
||||
return &k.PublicKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kid returns the key id (the Cert name).
|
||||
func (s *Signer) Kid() string { return s.kid }
|
||||
|
||||
// Alg returns the JOSE algorithm this signer uses (matches the JWKS `alg`).
|
||||
func (s *Signer) Alg() string { return s.alg }
|
||||
|
||||
// audienceFor computes the token audience per RFC 8707: an explicit resource
|
||||
// indicator wins; a shared application scopes the audience to the org; otherwise
|
||||
// the audience is the client id (the value validators check).
|
||||
func audienceFor(app *schema.Application, resource string) jwt.ClaimStrings {
|
||||
if resource != "" {
|
||||
return jwt.ClaimStrings{resource}
|
||||
}
|
||||
if app.IsShared && app.Organization != "" {
|
||||
return jwt.ClaimStrings{app.ClientId + "-org-" + app.Organization}
|
||||
}
|
||||
return jwt.ClaimStrings{app.ClientId}
|
||||
}
|
||||
|
||||
// pinnedMethod is the app's requested signing method (TokenSigningMethod), or ""
|
||||
// to let the key type decide.
|
||||
func pinnedMethod(app *schema.Application) string {
|
||||
if app == nil {
|
||||
return ""
|
||||
}
|
||||
return app.TokenSigningMethod
|
||||
}
|
||||
|
||||
// methodForKey maps a parsed private key (and an optional app-pinned method
|
||||
// within the same family) to a jwt.SigningMethod and its JOSE alg name.
|
||||
func methodForKey(key any, pinned string) (jwt.SigningMethod, string, error) {
|
||||
switch k := key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
if pinned == "RS512" {
|
||||
return jwt.SigningMethodRS512, "RS512", nil
|
||||
}
|
||||
return jwt.SigningMethodRS256, "RS256", nil
|
||||
case *ecdsa.PrivateKey:
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
return jwt.SigningMethodES256, "ES256", nil
|
||||
case 384:
|
||||
return jwt.SigningMethodES384, "ES384", nil
|
||||
case 521:
|
||||
return jwt.SigningMethodES512, "ES512", nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("jwt: unsupported EC curve bit size %d", k.Curve.Params().BitSize)
|
||||
default:
|
||||
return nil, "", errors.New("jwt: unsupported private key type")
|
||||
}
|
||||
}
|
||||
|
||||
// parsePrivateKeyPEM decodes a classical (RSA or EC) PEM private key.
|
||||
func parsePrivateKeyPEM(pemText string) (crypto.Signer, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
if block == nil {
|
||||
return nil, errors.New("jwt: private key is not valid PEM")
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
if k, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt: parse private key: %w", err)
|
||||
}
|
||||
signer, ok := k8.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, errors.New("jwt: PKCS#8 key is not a signing key")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// parseRSAPrivateKeyPEM decodes a PEM RSA private key (PKCS#1 or PKCS#8).
|
||||
func parseRSAPrivateKeyPEM(pemText string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
if block == nil {
|
||||
return nil, errors.New("jwt: private key is not valid PEM")
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt: parse private key: %w", err)
|
||||
}
|
||||
rk, ok := k8.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("jwt: private key is not RSA")
|
||||
}
|
||||
return rk, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// GET /v1/iam/linked-accounts — the caller's linked social/OAuth identities.
|
||||
//
|
||||
// schema.User has NO single "linkedIdentities" array; a linked identity is stored as
|
||||
// the connector's own column (User.GitHub, User.Google, …) holding the federated
|
||||
// subject — the casdoor data model. So the linked accounts ARE those per-connector
|
||||
// columns that are set; this returns [{provider, subject}] for each non-empty one.
|
||||
// Each item carries only the subject string (the schema stores no per-link display
|
||||
// name / avatar / linkedAt), so a richer per-link shape is not available from iam2.
|
||||
// Self-scoped: resolved from the caller (callerOf), never the request.
|
||||
|
||||
// PathLinkedAccounts is the canonical linked-identities endpoint.
|
||||
const PathLinkedAccounts = "/v1/iam/linked-accounts"
|
||||
|
||||
// connectorTags is the set of User json tags that hold a linked federated-identity
|
||||
// subject — the casdoor per-connector columns (schema/user.go "Linked
|
||||
// federated-identity subjects"). ONE list; linked-accounts reflects the non-empty
|
||||
// ones out, so a new connector column is picked up by adding its tag here only.
|
||||
var connectorTags = fields(
|
||||
"github google qq wechat facebook dingtalk weibo gitee linkedin wecom lark gitlab " +
|
||||
"adfs baidu alipay iam infoflow apple azuread azureadb2c slack steam bilibili okta " +
|
||||
"douyin kwai line amazon auth0 battlenet bitbucket box cloudfoundry dailymotion deezer " +
|
||||
"digitalocean discord dropbox eveonline fitbit gitea heroku influxcloud instagram " +
|
||||
"intercom kakao lastfm mailru meetup microsoftonline naver nextcloud onedrive oura " +
|
||||
"patreon paypal salesforce shopify soundcloud spotify strava stripe telegram tiktok " +
|
||||
"tumblr twitch twitter typetalk uber vk wepay xero yahoo yammer yandex zoom " +
|
||||
"custom custom2 custom3 custom4 custom5 custom6 custom7 custom8 custom9 custom10")
|
||||
|
||||
// linkedAccount is one linked social/OAuth identity.
|
||||
type linkedAccount struct {
|
||||
Provider string `json:"provider"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// linkedAccountsHandler returns the caller's linked identities.
|
||||
func linkedAccountsHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
if user == nil {
|
||||
return httpx.Err(c, "the user does not exist")
|
||||
}
|
||||
return httpx.Ok(c, linkedAccountsOf(user))
|
||||
}
|
||||
}
|
||||
|
||||
// linkedAccountsOf reflects a user's non-empty connector columns into the linked list.
|
||||
// Reflection over the ONE connectorTags set avoids a per-field cascade and stays
|
||||
// faithful to whichever columns hold a subject.
|
||||
func linkedAccountsOf(u *schema.User) []linkedAccount {
|
||||
out := []linkedAccount{}
|
||||
v := reflect.ValueOf(*u)
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
|
||||
if !connectorTags[tag] {
|
||||
continue
|
||||
}
|
||||
if s := v.Field(i).String(); s != "" {
|
||||
out = append(out, linkedAccount{Provider: tag, Subject: s})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fields turns a space-separated tag list into a set.
|
||||
func fields(list string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, f := range strings.Fields(list) {
|
||||
m[f] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/sessions"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
"github.com/hanzoai/iam2/internal/users"
|
||||
)
|
||||
|
||||
// The credential login front door: POST /v1/iam/login. The @hanzo/iam SDK +
|
||||
// hanzo.id portal post here with the app/org + username/password (+ the PKCE
|
||||
// authorize params when type=code). On success with type=code we mint a
|
||||
// PKCE-bound authorization code and return it in the Response envelope; the SDK
|
||||
// then exchanges it at /v1/iam/oauth/token. Login by EMAIL or USERNAME.
|
||||
//
|
||||
// This is the interactive-flow counterpart to the token endpoint: login mints
|
||||
// the code, /token redeems it. Password verification is bcrypt (constant-time),
|
||||
// never plaintext, and the hash never crosses a response.
|
||||
|
||||
// PathLogin is the canonical credential-login endpoint.
|
||||
const PathLogin = "/v1/iam/login"
|
||||
|
||||
// loginForm is the request body the SDK/portal posts.
|
||||
type loginForm struct {
|
||||
Application string `json:"application"`
|
||||
Organization string `json:"organization"`
|
||||
Username string `json:"username"` // email OR username
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"` // "code" (PKCE authorize) | "login" (bare session)
|
||||
|
||||
// PKCE authorize passthrough (present when type=code).
|
||||
ClientId string `json:"clientId"`
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
State string `json:"state"`
|
||||
Scope string `json:"scope"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
Resource string `json:"resource"`
|
||||
}
|
||||
|
||||
// MountLogin registers POST /v1/iam/login.
|
||||
func MountLogin(app *zip.App, db orm.DB) {
|
||||
app.Post(PathLogin, loginHandler(db))
|
||||
}
|
||||
|
||||
func loginHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var f loginForm
|
||||
if err := c.Bind(&f); err != nil {
|
||||
return httpx.Err(c, "invalid request body")
|
||||
}
|
||||
if f.Organization == "" || f.Username == "" || f.Password == "" {
|
||||
return httpx.Err(c, "organization, username and password are required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
user, err := resolveLoginUser(ctx, db, f.Organization, f.Username)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// The hash algorithm is a property of the ROW, not a constant: use the
|
||||
// user's PasswordType, falling back to the organization's (v1's
|
||||
// object/check.go contract). Every live v1 row is argon2id — a bcrypt-only
|
||||
// verify would fail every real login at cutover.
|
||||
orgPasswordType := loginOrgPasswordType(ctx, db, f.Organization)
|
||||
// One opaque failure for "no such user" and "wrong password" — no oracle
|
||||
// that reveals whether the account exists.
|
||||
if user == nil || !users.VerifyPassword(user, f.Password, orgPasswordType) {
|
||||
return httpx.Err(c, "the username or password is incorrect")
|
||||
}
|
||||
|
||||
userID := user.Owner + "/" + user.Name
|
||||
|
||||
// type=login: a bare portal sign-in. Establish the durable session the
|
||||
// portal + the gateway admin-guard read via get-account, then report the
|
||||
// user id (the shape the portal expects for a non-OAuth sign-in). The
|
||||
// cookie is best-effort — a session failure never blocks a valid login.
|
||||
if f.Type != "code" {
|
||||
_ = sessions.Set(ctx, c.Fiber(), db, user.Owner, user.Name, f.Application)
|
||||
return httpx.Ok(c, userID)
|
||||
}
|
||||
|
||||
// type=code: mint a PKCE-bound authorization code for the OAuth flow.
|
||||
app, err := resolveLoginApp(ctx, db, f)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
// Tenant isolation: the authenticated user's organization must be
|
||||
// permitted for this application — its own org, a shared app, or an app
|
||||
// that lets users choose their org. Without this a user in one tenant
|
||||
// could obtain a token whose `organization` claim names another tenant.
|
||||
if f.Organization != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
|
||||
return httpx.Err(c, "the user is not permitted to sign in to this application")
|
||||
}
|
||||
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3);
|
||||
// the token endpoint re-checks it. A supplied-but-unregistered URI is
|
||||
// refused — never minted against.
|
||||
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
|
||||
return httpx.Err(c, "invalid redirect_uri")
|
||||
}
|
||||
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
|
||||
if f.CodeChallenge != "" && method != "S256" {
|
||||
return httpx.Err(c, "only S256 PKCE is supported")
|
||||
}
|
||||
// A public client (no secret) must use PKCE — no downgrade.
|
||||
if app.ClientSecret == "" && f.CodeChallenge == "" {
|
||||
return httpx.Err(c, "PKCE is required for public clients")
|
||||
}
|
||||
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// Bind the redirect_uri and nonce onto the code so the token exchange can
|
||||
// re-verify the redirect and echo the nonce into the id_token.
|
||||
code.RedirectUri = f.RedirectUri
|
||||
code.Nonce = f.Nonce
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// The SDK reads data as the authorization code to exchange at /token.
|
||||
return httpx.Ok(c, code.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLoginUser looks a user up by email (contains "@") or username, scoped
|
||||
// to the org.
|
||||
func resolveLoginUser(ctx context.Context, db orm.DB, org, identifier string) (*schema.User, error) {
|
||||
if strings.Contains(identifier, "@") {
|
||||
u, err := store.GetUserByEmail(ctx, db, org, identifier)
|
||||
if err != nil || u != nil {
|
||||
return u, err
|
||||
}
|
||||
// Fall through: some accounts set name = email (email is not indexed as
|
||||
// a separate login) — try name too.
|
||||
}
|
||||
return store.GetUserByName(ctx, db, org, identifier)
|
||||
}
|
||||
|
||||
// resolveLoginApp resolves the OAuth app for a type=code login: by clientId when
|
||||
// present, else by (org, application name).
|
||||
func resolveLoginApp(ctx context.Context, db orm.DB, f loginForm) (*schema.Application, error) {
|
||||
if f.ClientId != "" {
|
||||
return store.GetApplicationByClientId(ctx, db, f.ClientId)
|
||||
}
|
||||
if f.Application != "" {
|
||||
return store.GetApplicationByName(ctx, db, "admin", f.Application)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// loginOrgPasswordType returns the organization's PasswordType — the fallback
|
||||
// when a user row carries none. A missing org yields "" (the user's own type
|
||||
// then decides; if neither is set, cred.Verify fails closed rather than guessing
|
||||
// an algorithm).
|
||||
func loginOrgPasswordType(ctx context.Context, db orm.DB, org string) string {
|
||||
o, err := store.GetOrganizationByName(ctx, db, org)
|
||||
if err != nil || o == nil {
|
||||
return ""
|
||||
}
|
||||
return o.PasswordType
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package oidc serves the IAM v2 OpenID Connect / OAuth2 surface on zip. The
|
||||
// handlers are RAW zip handlers (func(c *zip.Ctx) error), not typed generics,
|
||||
// because the auth surface needs query params, form bodies, redirects, and
|
||||
// headers a JSON-in/JSON-out handler can't reach.
|
||||
//
|
||||
// The surface is the canonical hanzo.id contract, unchanged across the v1→v2
|
||||
// backend swap: discovery + JWKS under .well-known, the oauth/{authorize,token,
|
||||
// userinfo,logout} endpoints, and the front-door {get-app-login, auth/methods,
|
||||
// login} the hosted UI calls. Tokens are signed JWTs (RS256 interop, ES/ML-DSA
|
||||
// behind the same JWKS); every value is verified, never trusted.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Canonical OIDC paths — the single source of truth the @hanzo/iam SDK and every
|
||||
// existing relying party hard-code. iam2 serves them directly; the transition
|
||||
// off v1 is a backend swap behind the same paths, never a parallel version.
|
||||
const (
|
||||
PathAuthorize = "/v1/iam/oauth/authorize"
|
||||
PathToken = "/v1/iam/oauth/token"
|
||||
PathUserInfo = "/v1/iam/oauth/userinfo"
|
||||
PathLogout = "/v1/iam/oauth/logout"
|
||||
PathJWKS = "/v1/iam/.well-known/jwks"
|
||||
PathJWKSRoot = "/.well-known/jwks"
|
||||
PathDiscovery = "/.well-known/openid-configuration"
|
||||
PathDiscoveryV1 = "/v1/iam/.well-known/openid-configuration"
|
||||
PathASMetadata = "/.well-known/oauth-authorization-server" // RFC 8414 (root)
|
||||
PathASMetadataV1 = "/v1/iam/.well-known/oauth-authorization-server" // RFC 8414 (v1)
|
||||
)
|
||||
|
||||
// Mount registers the entire OIDC/OAuth2 surface on app, backed by db. This is
|
||||
// the one entry point the route table calls — discovery, JWKS, the protocol
|
||||
// endpoints, and the front door are all wired here so the surface lives in one
|
||||
// place.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
// Discovery and the JWKS are each served at BOTH the root well-known path
|
||||
// (RFC 8414 §3, where a bare-origin client and the gateway's default look)
|
||||
// and the /v1/iam-prefixed path, matching the live hanzo.id surface. Both
|
||||
// paths are the same handler over the same keys — one key set, two spellings
|
||||
// of where to find it.
|
||||
jwks := jwksHandler(db)
|
||||
app.Get(PathDiscovery, Discovery)
|
||||
app.Get(PathDiscoveryV1, Discovery)
|
||||
// RFC 8414 OAuth Authorization Server Metadata — the same self-consistent
|
||||
// document at the OAuth well-known path (a superset serves it), so an OAuth-only
|
||||
// client that looks for `oauth-authorization-server` finds the AS too.
|
||||
app.Get(PathASMetadata, Discovery)
|
||||
app.Get(PathASMetadataV1, Discovery)
|
||||
app.Get(PathJWKS, jwks)
|
||||
app.Get(PathJWKSRoot, jwks)
|
||||
|
||||
// OAuth2 / OIDC protocol endpoints.
|
||||
app.Get(PathAuthorize, authorizeHandler(db))
|
||||
app.Post(PathAuthorize, authorizeHandler(db))
|
||||
app.Get(PathUserInfo, userinfoHandler(db))
|
||||
app.Post(PathUserInfo, userinfoHandler(db))
|
||||
app.Get(PathLogout, logoutHandler(db))
|
||||
app.Post(PathLogout, logoutHandler(db))
|
||||
|
||||
// The token endpoint, the credential login that mints codes, and the
|
||||
// read-only front door the hosted <Login> self-configures from.
|
||||
MountToken(app, db)
|
||||
MountLogin(app, db)
|
||||
MountFrontDoor(app, db)
|
||||
|
||||
// RFC 7662 introspection + RFC 7009 revocation — the standard token-management
|
||||
// endpoints a resource server / confidential client uses (client-authenticated).
|
||||
MountIntrospectRevoke(app, db)
|
||||
|
||||
// The confidential-client "act on behalf of a user" primitive (the console +
|
||||
// keyless-AI proxies mint their forwarded bearer here). Authenticates the
|
||||
// client itself, so it is not Bearer-gated.
|
||||
MountIssueToken(app, db)
|
||||
}
|
||||
|
||||
// Discovery serves the OIDC discovery document, host-relative (issuer derived
|
||||
// from the request host, the same value the tokens carry as `iss`) so a strict
|
||||
// client never splits origin. It advertises only what iam2 implements: the
|
||||
// authorization-code flow, S256 PKCE, the three supported grants, and the
|
||||
// signing algorithms whose public keys the JWKS actually publishes.
|
||||
func Discovery(c *zip.Ctx) error {
|
||||
iss := tokenIssuer(c)
|
||||
return c.JSON(200, map[string]any{
|
||||
"issuer": iss,
|
||||
"authorization_endpoint": iss + PathAuthorize,
|
||||
"token_endpoint": iss + PathToken,
|
||||
"userinfo_endpoint": iss + PathUserInfo,
|
||||
"introspection_endpoint": iss + PathIntrospect,
|
||||
"revocation_endpoint": iss + PathRevoke,
|
||||
"end_session_endpoint": iss + PathLogout,
|
||||
"jwks_uri": iss + PathJWKS,
|
||||
"response_types_supported": []string{"code"},
|
||||
"response_modes_supported": []string{"query", "fragment", "form_post"},
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token", "client_credentials", "password", grantTypeTokenExchange},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "RS512", "ES256", "ES384", "ES512", "MLDSA65"},
|
||||
"scopes_supported": []string{"openid", "email", "profile", "address", "phone", "offline_access"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"claims_supported": []string{
|
||||
"iss", "sub", "aud", "iat", "exp", "nbf", "jti", "nonce", "azp",
|
||||
"owner", "organization", "scope", "tokenType",
|
||||
"name", "preferred_username", "email", "email_verified",
|
||||
"picture", "address", "phone", "groups", "is_verified",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/organizations"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// POST /v1/iam/onboard — first-run org onboarding. A signed-in user with no org of
|
||||
// their own creates one (named, or a one-click `<username>` personal org) and is
|
||||
// MOVED into it as its admin, so everyone always has an org. Because an IAM user's
|
||||
// org IS their identity, the move re-keys the caller (their subject becomes
|
||||
// slug/name), so the console re-authenticates after a success.
|
||||
//
|
||||
// The response is the console's own {org}/{error} contract (NOT the casibase
|
||||
// envelope): {"org":"<slug>"} on success, an {"error":"..."} with a 4xx/5xx status
|
||||
// on failure — the OrgOnboarding client reads json.org / json.error directly.
|
||||
//
|
||||
// Self-scoped: the caller is resolved from its session/bearer (callerOf), never from
|
||||
// the body, so onboarding only ever moves the caller — its own identity, its own org.
|
||||
|
||||
// PathOnboard is the canonical first-run onboarding endpoint.
|
||||
const PathOnboard = "/v1/iam/onboard"
|
||||
|
||||
// Org slug bounds mirror the console's onboarding policy (src/lib/server/onboarding.ts):
|
||||
// an IAM org name is varchar(100); keep the slug short + readable.
|
||||
const (
|
||||
minOrgSlug = 2
|
||||
maxOrgSlug = 60
|
||||
)
|
||||
|
||||
// reservedOrgs are the IAM SYSTEM owners a customer org may never become — creating
|
||||
// one would collide with a signing-cert owner (admin/built-in) or a system principal
|
||||
// (app). Brand/staff orgs (hanzo/lux/zoo/pars in the console list) are NOT hard-coded
|
||||
// here (iam2 is white-label): an existing one is refused by the create-conflict check.
|
||||
var reservedOrgs = map[string]bool{"admin": true, "built-in": true, "app": true}
|
||||
|
||||
// onboardForm is the request body: a name to create, or personal=true for the
|
||||
// one-click `<username>` org.
|
||||
type onboardForm struct {
|
||||
Name string `json:"name"`
|
||||
Personal bool `json:"personal"`
|
||||
}
|
||||
|
||||
// onboardHandler creates the caller's org and moves them into it as admin.
|
||||
func onboardHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return onboardErr(c, 401, "please sign in first")
|
||||
}
|
||||
|
||||
var f onboardForm
|
||||
_ = c.Bind(&f)
|
||||
|
||||
slug, display, personal := f.Name, strings.TrimSpace(f.Name), false
|
||||
if f.Personal {
|
||||
slug, display, personal = personalOrgSlug(name), name, true
|
||||
} else {
|
||||
slug = slugifyOrg(f.Name)
|
||||
}
|
||||
if len(slug) < minOrgSlug {
|
||||
return onboardErr(c, 400, "use at least 2 letters or numbers")
|
||||
}
|
||||
if reservedOrgs[slug] {
|
||||
return onboardErr(c, 400, "\""+slug+"\" is reserved. choose a different name")
|
||||
}
|
||||
|
||||
// Load the caller BEFORE creating the org, so a missing user is a clean 4xx
|
||||
// with no orphaned org.
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return onboardErr(c, 500, "server_error")
|
||||
}
|
||||
if user == nil {
|
||||
return onboardErr(c, 400, "the user does not exist")
|
||||
}
|
||||
|
||||
// The slug must be free (globally: org names are unique). This is also the
|
||||
// guard that refuses an existing brand/staff org by name.
|
||||
existing, err := store.GetOrganizationByName(ctx, db, slug)
|
||||
if err != nil {
|
||||
return onboardErr(c, 500, "server_error")
|
||||
}
|
||||
if existing != nil {
|
||||
return onboardErr(c, 409, "the organization \""+slug+"\" already exists")
|
||||
}
|
||||
|
||||
// Create the tenant org (platform-owned, Owner "admin") through the ONE org
|
||||
// create path.
|
||||
if display == "" {
|
||||
display = slug
|
||||
}
|
||||
if _, err := organizations.NewOrganizationAPI(db).Create(ctx, &organizations.CreateOrganizationInput{
|
||||
Organization: schema.Organization{
|
||||
Owner: "admin",
|
||||
Name: slug,
|
||||
DisplayName: display,
|
||||
IsPersonal: personal,
|
||||
CreatedTime: onboardNow(),
|
||||
},
|
||||
}); err != nil {
|
||||
return onboardErr(c, 400, err.Error())
|
||||
}
|
||||
|
||||
// Move the caller in as admin. Changing Owner re-keys the identity (the row's
|
||||
// surrogate id is stable; user lookups are by (owner, name)), so the loaded
|
||||
// row updates in place and the caller thereafter resolves under the new org.
|
||||
user.Owner = slug
|
||||
user.IsAdmin = true
|
||||
user.UpdatedTime = onboardNow()
|
||||
if err := user.UpdateCtx(ctx); err != nil {
|
||||
return onboardErr(c, 500, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(200, map[string]string{"org": slug})
|
||||
}
|
||||
}
|
||||
|
||||
// onboardErr writes the console's {"error":...} shape with an HTTP status the
|
||||
// client treats as failure (res.ok=false), never the casibase 200 envelope.
|
||||
func onboardErr(c *zip.Ctx, status int, msg string) error {
|
||||
return c.JSON(status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// onboardNow is the v1-compatible string timestamp for a freshly created/updated row.
|
||||
func onboardNow() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
|
||||
// slugifyOrg normalizes a human org name into an IAM slug — lowercase ASCII
|
||||
// alphanumerics, every other run collapsed to a single '-', trimmed, capped at
|
||||
// maxOrgSlug. Mirrors the console's slugifyOrg for ASCII (the common case); a
|
||||
// non-ASCII rune becomes '-' rather than its NFKD base letter (no stdlib NFKD), so
|
||||
// an accented name yields a valid but possibly different slug than the client preview
|
||||
// — the server slug is authoritative and get-account reports the real org.
|
||||
func slugifyOrg(input string) string {
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(input) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
continue
|
||||
}
|
||||
if !dash {
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
s := strings.Trim(b.String(), "-")
|
||||
if len(s) > maxOrgSlug {
|
||||
s = s[:maxOrgSlug]
|
||||
}
|
||||
return strings.TrimRight(s, "-")
|
||||
}
|
||||
|
||||
// personalOrgSlug is the default one-click org slug for a user: the local part of an
|
||||
// email-like username (dave@x.com → dave), slugified — so a personal org reads as the
|
||||
// person, not their address. Mirrors the console's personalOrgSlug.
|
||||
func personalOrgSlug(username string) string {
|
||||
base := username
|
||||
if i := strings.IndexByte(username, '@'); i > 0 {
|
||||
base = username[:i]
|
||||
}
|
||||
return slugifyOrg(base)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Resource Owner Password Credentials grant (RFC 6749 §4.3) — the durable
|
||||
// first-party console session. Verifies the happy path mints a real, verifiable
|
||||
// token carrying the user's identity, and that every rejection (bad password,
|
||||
// public client, password-disabled app) fails closed. The password is checked
|
||||
// through the SAME algorithm-aware path the login form uses.
|
||||
|
||||
func TestPasswordGrant_mintsTokenForValidCredentials(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
resp, tok := postToken(t, app, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {"hanzo-console"},
|
||||
"client_secret": {"top-secret"},
|
||||
"username": {"alice@hanzo.ai"},
|
||||
"password": {"correct horse"},
|
||||
"scope": {"openid profile email offline_access"},
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%v", resp.StatusCode, tok)
|
||||
}
|
||||
access, _ := tok["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("no access_token; body=%v", tok)
|
||||
}
|
||||
// offline_access → a refresh token; openid → an id_token.
|
||||
if tok["refresh_token"] == nil || tok["refresh_token"] == "" {
|
||||
t.Errorf("offline_access requested but no refresh_token minted")
|
||||
}
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("minted token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" {
|
||||
t.Errorf("subject = %q, want hanzo/alice", claims.Subject)
|
||||
}
|
||||
if claims.Owner != "hanzo" {
|
||||
t.Errorf("owner = %q, want hanzo", claims.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordGrant_wrongPassword_invalidGrant(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
resp, tok := postToken(t, app, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {"hanzo-console"},
|
||||
"client_secret": {"top-secret"},
|
||||
"username": {"alice@hanzo.ai"},
|
||||
"password": {"WRONG"},
|
||||
})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestPasswordGrant_unknownUser_invalidGrant_sameAsBadPassword(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
|
||||
// No user seeded: an unknown user must return the SAME opaque invalid_grant as
|
||||
// a wrong password — no user-enumeration oracle.
|
||||
resp, tok := postToken(t, app, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {"hanzo-console"},
|
||||
"client_secret": {"top-secret"},
|
||||
"username": {"ghost@hanzo.ai"},
|
||||
"password": {"anything"},
|
||||
})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
}
|
||||
|
||||
func TestPasswordGrant_publicClient_rejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// A public (no-secret) client can never use the password grant.
|
||||
seedApp(t, db, appOpts{clientID: "pub"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
resp, tok := postToken(t, app, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {"pub"},
|
||||
"username": {"alice@hanzo.ai"},
|
||||
"password": {"correct horse"},
|
||||
})
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("public-client password grant status = %d, want 401; body=%v", resp.StatusCode, tok)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// POST /v1/iam/update-preferences — the ONE account-backed store for cross-product,
|
||||
// cross-device user customizations (onboarding-completed flag, theme, pinned
|
||||
// favorites, …). The console's PreferencesProvider reads these from the account's
|
||||
// Properties["hanzo.preferences"] JSON blob and write-through-persists partial updates
|
||||
// here. Ported from the v1 me_preferences.go contract.
|
||||
//
|
||||
// SELF-SCOPED (never the body): the target user is ALWAYS the caller resolved from its
|
||||
// session/bearer (callerOf) — never a name/org in the request — so a caller can only
|
||||
// ever write its OWN preferences. MERGE: top-level keys are shallow-merged onto the
|
||||
// stored object, so concurrent products/devices setting DIFFERENT keys don't clobber
|
||||
// each other; the merged object is returned so the caller keeps every other key.
|
||||
|
||||
// PathUpdatePreferences is the canonical self-preferences endpoint.
|
||||
const PathUpdatePreferences = "/v1/iam/update-preferences"
|
||||
|
||||
// preferencesKey is the User.Properties entry holding the cross-product preferences
|
||||
// JSON blob — the backend half of the console contract (PREFS_PROPERTY); keep in
|
||||
// lockstep.
|
||||
const preferencesKey = "hanzo.preferences"
|
||||
|
||||
// preferencesMaxBytes caps the serialized merged blob so a runaway client can't grow
|
||||
// the properties column without bound.
|
||||
const preferencesMaxBytes = 64 * 1024
|
||||
|
||||
// updatePreferencesHandler shallow-merges the posted partial onto the caller's stored
|
||||
// preferences and returns the merged object.
|
||||
func updatePreferencesHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
if user == nil {
|
||||
return httpx.Err(c, "the user does not exist")
|
||||
}
|
||||
|
||||
mergedJSON, merged, err := mergePreferences(user.Properties[preferencesKey], c.Fiber().Body())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if user.Properties == nil {
|
||||
user.Properties = map[string]string{}
|
||||
}
|
||||
user.Properties[preferencesKey] = mergedJSON
|
||||
user.UpdatedTime = onboardNow()
|
||||
if err := user.UpdateCtx(ctx); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, merged)
|
||||
}
|
||||
}
|
||||
|
||||
// mergePreferences shallow-merges a JSON `patch` object onto the `existing`
|
||||
// preferences JSON string, returning the merged JSON plus the merged map. Pure (no
|
||||
// session, no DB): a patch key overwrites ONLY that top-level key (every other stored
|
||||
// key survives); an absent/blank/corrupt `existing` is an empty object (a first write
|
||||
// still lands and self-heals); a non-object patch is rejected; the blob is size-capped.
|
||||
func mergePreferences(existing string, patch []byte) (string, map[string]json.RawMessage, error) {
|
||||
patchMap := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(patch, &patchMap); err != nil {
|
||||
return "", nil, fmt.Errorf("preferences must be a JSON object: %w", err)
|
||||
}
|
||||
|
||||
merged := map[string]json.RawMessage{}
|
||||
if existing != "" {
|
||||
_ = json.Unmarshal([]byte(existing), &merged) // corrupt stored blob → treated as empty
|
||||
}
|
||||
for k, v := range patchMap {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
out, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(out) > preferencesMaxBytes {
|
||||
return "", nil, fmt.Errorf("preferences exceed maximum size of %d bytes", preferencesMaxBytes)
|
||||
}
|
||||
return string(out), merged, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The full portal session path: a bare (type=login) sign-in sets the session
|
||||
// cookie, and get-account resolves the caller FROM that cookie (no bearer). This
|
||||
// is the path the hanzo.id portal + the gateway admin-guard use.
|
||||
func TestSession_LoginCookieResolvesGetAccount(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db) // hanzo/alice, password "pw"
|
||||
|
||||
// 1) Bare portal login → sets the hanzo_session cookie.
|
||||
form := url.Values{
|
||||
"organization": {"hanzo"},
|
||||
"application": {"conf"},
|
||||
"username": {"alice"},
|
||||
"password": {"pw"},
|
||||
"type": {"login"},
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathLogin, form))
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("login status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
cookie := resp.Header.Get("Set-Cookie")
|
||||
if !strings.HasPrefix(cookie, "hanzo_session=") {
|
||||
t.Fatalf("login did not set the session cookie: %q", cookie)
|
||||
}
|
||||
for _, want := range []string{"HttpOnly", "secure", "SameSite=Lax", "path=/"} {
|
||||
if !strings.Contains(cookie, want) {
|
||||
t.Errorf("session cookie missing %s: %q", want, cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) get-account WITH the cookie (no bearer) → resolves alice, redacted.
|
||||
req := formReqNoBody("GET", PathGetAccount)
|
||||
req.Header.Set("Cookie", cookieKV(cookie))
|
||||
resp2, body2 := do(t, app, req)
|
||||
env := decode(t, body2)
|
||||
if resp2.StatusCode != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("get-account via cookie status=%d body=%s", resp2.StatusCode, body2)
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
if data["owner"] != "hanzo" || env["name"] != "alice" {
|
||||
t.Fatalf("cookie session resolved wrong principal: owner=%v name=%v", data["owner"], env["name"])
|
||||
}
|
||||
if v, ok := data["passwordHash"]; ok && v != "" {
|
||||
t.Errorf("cookie get-account leaked passwordHash")
|
||||
}
|
||||
}
|
||||
|
||||
// A FORGED session cookie (attacker flips owner→admin) does not authenticate:
|
||||
// the signature fails, get-account stays anonymous. The whole security point.
|
||||
func TestSession_ForgedCookieRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
// A hand-built cookie with a bogus payload + mac — no valid signature exists
|
||||
// without the platform cert key.
|
||||
req := formReqNoBody("GET", PathGetAccount)
|
||||
req.Header.Set("Cookie", "hanzo_session=eyJvIjoiYWRtaW4ifQ.deadbeef")
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "error" {
|
||||
t.Fatalf("forged cookie must not authenticate: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// cookieKV extracts "name=value" from a Set-Cookie header for the request echo.
|
||||
func cookieKV(setCookie string) string {
|
||||
if i := strings.Index(setCookie, ";"); i >= 0 {
|
||||
return setCookie[:i]
|
||||
}
|
||||
return setCookie
|
||||
}
|
||||
|
||||
var _ = http.MethodGet
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/sessions"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The code→session exchange: POST /v1/iam/signin. After the authorize/login flow
|
||||
// redirects back with `?code&state`, the console posts them here (code+state ride
|
||||
// the query — what the @hanzo/iam client sends — or a JSON body) and iam2 redeems
|
||||
// the code for a durable SESSION, returning the account envelope like get-account.
|
||||
//
|
||||
// This is the session-establishment counterpart to the OAuth token endpoint: the
|
||||
// token endpoint trades a code (with client auth + PKCE) for an access token; signin
|
||||
// trades it for the portal's signed hanzo_session cookie. Possession of the 256-bit
|
||||
// single-use code is the proof — no client secret crosses this call — so the code is
|
||||
// BURNED on use: a replay establishes no second session.
|
||||
|
||||
// PathSignin is the canonical code→session endpoint.
|
||||
const PathSignin = "/v1/iam/signin"
|
||||
|
||||
// signinForm is the JSON body a non-query caller may post; the canonical @hanzo/iam
|
||||
// client sends code+state on the query string.
|
||||
type signinForm struct {
|
||||
Code string `json:"code"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// signinHandler redeems an authorization code for a session and returns the account.
|
||||
func signinHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// Query first (the redirect-flow client posts code+state as query params),
|
||||
// then a JSON body for programmatic callers.
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
var f signinForm
|
||||
_ = c.Bind(&f)
|
||||
code = f.Code
|
||||
}
|
||||
if code == "" {
|
||||
return httpx.Err(c, "code is required")
|
||||
}
|
||||
|
||||
tok, err := store.GetTokenByCode(ctx, db, code)
|
||||
if err != nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
now := nowFunc()
|
||||
// One opaque answer for unknown / already-redeemed / expired — no oracle, and
|
||||
// the same replay+expiry guards RedeemCode enforces (minus client/PKCE, which
|
||||
// this session exchange does not present).
|
||||
if tok == nil || tok.CodeIsUsed || (tok.CodeExpireIn != 0 && now.Unix() > tok.CodeExpireIn) {
|
||||
return httpx.Err(c, "the authorization code is invalid or expired")
|
||||
}
|
||||
owner, name := splitSub(tok.User)
|
||||
if owner == "" || name == "" {
|
||||
return httpx.Err(c, "the authorization code has no subject")
|
||||
}
|
||||
|
||||
// Burn the code (single-use) BEFORE establishing the session, so a replay
|
||||
// loses the race and mints nothing.
|
||||
tok.CodeIsUsed = true
|
||||
if err := store.SaveToken(ctx, db, tok); err != nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
|
||||
// Establish the durable session the portal + gateway admin-guard read via
|
||||
// get-account. Its sid is registered for revocation (sessions.Set).
|
||||
if err := sessions.Set(ctx, c.Fiber(), db, owner, name, tok.Application); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
|
||||
env, status := accountEnvelopeFor(ctx, db, owner, name)
|
||||
return c.JSON(status, env)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
"github.com/hanzoai/iam2/internal/users"
|
||||
)
|
||||
|
||||
// The token endpoint: POST /v1/iam/oauth/token. It dispatches the three grant
|
||||
// types iam2 issues — authorization_code (PKCE-verified, single-use code →
|
||||
// access JWT + id_token when openid + rotating refresh), refresh_token
|
||||
// (rotation with reuse detection, refresh.go), and client_credentials
|
||||
// (machine-to-machine, no user, no refresh). Every response carries no-store
|
||||
// caching; every error follows the RFC 6749 §5.2 taxonomy (invalid_client → 401
|
||||
// with WWW-Authenticate, everything else → 400). Implicit is permanently absent.
|
||||
|
||||
// nowFunc is indirected so tests can pin time. Production uses time.Now.
|
||||
var nowFunc = time.Now
|
||||
|
||||
// tokenResponse is the RFC 6749 §5.1 / OIDC success body.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IdToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// MountToken registers the ONE token endpoint: POST /v1/iam/oauth/token (the
|
||||
// RFC 6749 / discovery `token_endpoint`). No legacy `access_token` alias — every
|
||||
// client posts to the standard path; the stack is fixed to it, not shimmed.
|
||||
func MountToken(app *zip.App, db orm.DB) {
|
||||
app.Post(PathToken, tokenHandler(db))
|
||||
}
|
||||
|
||||
// param reads an OAuth parameter from the query first, then the form body
|
||||
// (application/x-www-form-urlencoded — what NextAuth and most clients send).
|
||||
func param(c *zip.Ctx, key string) string {
|
||||
if v := c.Query(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Fiber().FormValue(key)
|
||||
}
|
||||
|
||||
// tokenError writes the RFC 6749 §5.2 error body with the right status.
|
||||
func tokenError(c *zip.Ctx, status int, code, desc string) error {
|
||||
body := map[string]string{"error": code}
|
||||
if desc != "" {
|
||||
body["error_description"] = desc
|
||||
}
|
||||
return c.JSON(status, body)
|
||||
}
|
||||
|
||||
// tokenErrorClient answers a client-authentication failure: 401 + the
|
||||
// WWW-Authenticate challenge, per RFC 6749 §5.2.
|
||||
func tokenErrorClient(c *zip.Ctx, desc string) error {
|
||||
c.SetHeader("WWW-Authenticate", `Basic realm="OAuth2"`)
|
||||
return tokenError(c, 401, "invalid_client", desc)
|
||||
}
|
||||
|
||||
// setTokenCacheHeaders forbids caching of any token response (RFC 6749 §5.1).
|
||||
func setTokenCacheHeaders(c *zip.Ctx) {
|
||||
c.SetHeader("Cache-Control", "no-store")
|
||||
c.SetHeader("Pragma", "no-cache")
|
||||
}
|
||||
|
||||
func tokenHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
switch param(c, "grant_type") {
|
||||
case "authorization_code":
|
||||
return authorizationCodeGrant(c, db)
|
||||
case "refresh_token":
|
||||
return refreshTokenGrant(c, db)
|
||||
case "client_credentials":
|
||||
return clientCredentialsGrant(c, db)
|
||||
case "password":
|
||||
return passwordGrant(c, db)
|
||||
case grantTypeTokenExchange:
|
||||
return tokenExchangeGrant(c, db)
|
||||
case "":
|
||||
return tokenError(c, 400, "invalid_request", "grant_type is required")
|
||||
default:
|
||||
return tokenError(c, 400, "unsupported_grant_type", "unsupported grant_type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authorizationCodeGrant redeems a single-use, PKCE-bound authorization code for
|
||||
// an access token (+ id_token when openid + rotating refresh).
|
||||
func authorizationCodeGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
code := param(c, "code")
|
||||
if code == "" {
|
||||
return tokenError(c, 400, "invalid_request", "code is required")
|
||||
}
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
verifier := param(c, "code_verifier")
|
||||
redirectURI := param(c, "redirect_uri")
|
||||
|
||||
tok, err := store.GetTokenByCode(ctx, db, code)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
app, err := resolveTokenApp(ctx, db, tok)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
// Unknown code OR its app vanished — one opaque answer, no oracle.
|
||||
return tokenError(c, 400, "invalid_grant", "invalid authorization code")
|
||||
}
|
||||
|
||||
// The presented client must be the code's client.
|
||||
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
|
||||
return tokenError(c, 400, "invalid_grant", "client mismatch")
|
||||
}
|
||||
// Confidential client: verify the secret (constant-time). A public client
|
||||
// (PKCE, no stored secret) may present none.
|
||||
if app.ClientSecret != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
}
|
||||
// redirect_uri binding (RFC 6749 §4.1.3): if the code carries one, the token
|
||||
// request must present the same one.
|
||||
if tok.RedirectUri != "" {
|
||||
if redirectURI == "" || subtle.ConstantTimeCompare([]byte(redirectURI), []byte(tok.RedirectUri)) != 1 {
|
||||
return tokenError(c, 400, "invalid_grant", "redirect_uri mismatch")
|
||||
}
|
||||
}
|
||||
// Core guard: replay / expiry / client / PKCE.
|
||||
if err := RedeemCode(tok, app.Name, verifier, now); err != nil {
|
||||
return redeemErrToResponse(c, err)
|
||||
}
|
||||
// A public client MUST have used PKCE — never let a no-secret grant through
|
||||
// without a challenge (downgrade / code injection defense).
|
||||
if app.ClientSecret == "" && tok.CodeChallenge == "" {
|
||||
return tokenError(c, 400, "invalid_grant", "PKCE is required for public clients")
|
||||
}
|
||||
|
||||
// One-shot: burn the code, then mint the grant's tokens onto the same row.
|
||||
tok.CodeIsUsed = true
|
||||
resp, err := issueTokens(ctx, db, c, app, tok, newFamilyID(tok), now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.SaveToken(ctx, db, tok); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// clientCredentialsGrant issues a machine-to-machine access token. The subject
|
||||
// is the application itself; there is no end user, no id_token, and no refresh
|
||||
// token (RFC 6749 §4.4 + OIDC — an id_token requires an authenticated user).
|
||||
func clientCredentialsGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return tokenErrorClient(c, "client authentication required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
// A public client (no secret) can never use client_credentials.
|
||||
if app == nil || app.ClientSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
if isInternalApp(app) {
|
||||
return tokenErrorClient(c, "client is not permitted on this endpoint")
|
||||
}
|
||||
|
||||
scope := param(c, "scope")
|
||||
ttl := appTTL(app)
|
||||
signer, err := signerFor(ctx, db, app, tokenIssuer(c))
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
sub := app.GetId() // <appOwner>/<appName>, per v1
|
||||
access, err := signer.Sign(app, sub, "", app.Name, scope, ttl, now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
row := &schema.Token{
|
||||
Owner: app.Owner,
|
||||
Application: app.Name,
|
||||
Organization: app.Organization,
|
||||
User: sub,
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
AccessTokenHash: hashToken(access),
|
||||
}
|
||||
row.Name = "cc-" + hashToken(access)[:32]
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, tokenResponse{
|
||||
AccessToken: access,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
Scope: scope,
|
||||
})
|
||||
}
|
||||
|
||||
// passwordGrant issues tokens for a Resource Owner Password Credentials request
|
||||
// (RFC 6749 §4.3) — the durable first-party console session (session.ts posts
|
||||
// grant_type=password with the confidential client + username/password). It is a
|
||||
// TRUSTED flow: confidential clients only (a public client + password grant is a
|
||||
// phishing footgun), the app must have password login enabled, and the password
|
||||
// is verified through the SAME algorithm-aware, per-row path the login form uses
|
||||
// (argon2id for every live v1 row, bcrypt for new rows). One generic failure for
|
||||
// unknown-user and bad-password — no user-enumeration oracle.
|
||||
func passwordGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return tokenErrorClient(c, "client authentication required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil || app.ClientSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
if isInternalApp(app) {
|
||||
return tokenErrorClient(c, "client is not permitted on this endpoint")
|
||||
}
|
||||
if !app.IsPasswordEnabled() {
|
||||
return tokenError(c, 400, "unauthorized_client", "password grant is not enabled for this client")
|
||||
}
|
||||
|
||||
username, password := param(c, "username"), param(c, "password")
|
||||
if username == "" || password == "" {
|
||||
return tokenError(c, 400, "invalid_request", "username and password are required")
|
||||
}
|
||||
// Org scope: an explicit `organization` wins; otherwise the client's own org
|
||||
// (a same-org first-party login — the console's default).
|
||||
org := param(c, "organization")
|
||||
if org == "" {
|
||||
org = app.Organization
|
||||
}
|
||||
|
||||
user, err := resolveLoginUser(ctx, db, org, username)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
orgPasswordType := loginOrgPasswordType(ctx, db, org)
|
||||
if user == nil || !users.VerifyPassword(user, password, orgPasswordType) {
|
||||
return tokenError(c, 400, "invalid_grant", "the username or password is incorrect")
|
||||
}
|
||||
if user.IsForbidden || user.IsDeleted {
|
||||
return tokenError(c, 400, "invalid_grant", "the user is forbidden")
|
||||
}
|
||||
|
||||
// Build a fresh grant row (owner/name upfront so newFamilyID has a stable id),
|
||||
// then mint through the ONE shared token path (access + id_token on openid +
|
||||
// rotating refresh) so the password grant's token shape never drifts.
|
||||
name, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
row := &schema.Token{
|
||||
Owner: user.Owner,
|
||||
Name: "pwd-" + name[:32],
|
||||
Application: app.Name,
|
||||
Organization: user.Owner,
|
||||
User: user.Owner + "/" + user.Name,
|
||||
Scope: param(c, "scope"),
|
||||
}
|
||||
resp, err := issueTokens(ctx, db, c, app, row, newFamilyID(row), now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// issueTokens mints the grant's tokens onto row: a signed access JWT, an
|
||||
// id_token when the openid scope is present (echoing the stored nonce), and a
|
||||
// rotating opaque refresh token joined to `family`. row already carries the
|
||||
// grant identity (Owner/Application/User/Scope/Nonce). It is the single path
|
||||
// both the code grant and refresh rotation mint through, so the token shape can
|
||||
// never drift between them.
|
||||
func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Application, row *schema.Token, family string, now time.Time) (tokenResponse, error) {
|
||||
ttl := appTTL(app)
|
||||
signer, err := signerFor(ctx, db, app, tokenIssuer(c))
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
email, name := userProfile(ctx, db, row.User)
|
||||
|
||||
access, err := signer.Sign(app, row.User, email, name, row.Scope, ttl, now)
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
refresh, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
|
||||
// Persist only the SHA-256 hashes, never the reusable plaintext tokens: a
|
||||
// database dump then exposes no usable bearer or refresh credential. Lookups
|
||||
// (userinfo, refresh) go through the hash siblings.
|
||||
row.AccessToken = ""
|
||||
row.AccessTokenHash = hashToken(access)
|
||||
row.RefreshToken = ""
|
||||
row.RefreshTokenHash = hashToken(refresh)
|
||||
row.RefreshFamily = family
|
||||
row.RefreshConsumed = false
|
||||
row.RefreshExpireIn = now.Add(refreshTTL(app)).Unix()
|
||||
row.ExpiresIn = int(ttl.Seconds())
|
||||
row.TokenType = "Bearer"
|
||||
|
||||
resp := tokenResponse{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
Scope: row.Scope,
|
||||
}
|
||||
if hasScope(row.Scope, "openid") {
|
||||
idt, err := signer.SignID(app, row.User, email, name, row.Scope, row.Nonce, ttl, now)
|
||||
if err != nil {
|
||||
return tokenResponse{}, err
|
||||
}
|
||||
resp.IdToken = idt
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// clientAuth extracts client credentials, preferring client_secret_post (body /
|
||||
// query) and falling back to client_secret_basic (Authorization: Basic).
|
||||
func clientAuth(c *zip.Ctx) (id, secret string) {
|
||||
id, secret = param(c, "client_id"), param(c, "client_secret")
|
||||
if id != "" {
|
||||
return id, secret
|
||||
}
|
||||
if bid, bsecret, ok := parseBasicAuth(c.Header("Authorization")); ok {
|
||||
return bid, bsecret
|
||||
}
|
||||
return id, secret
|
||||
}
|
||||
|
||||
// parseBasicAuth decodes an HTTP Basic client-authentication header. Per RFC
|
||||
// 6749 §2.3.1 the id and secret are form-urlencoded before base64.
|
||||
func parseBasicAuth(header string) (id, secret string, ok bool) {
|
||||
const p = "Basic "
|
||||
if len(header) <= len(p) || !strings.EqualFold(header[:len(p)], p) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(p):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
idPart, secretPart, found := strings.Cut(string(raw), ":")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
if u, err := url.QueryUnescape(idPart); err == nil {
|
||||
idPart = u
|
||||
}
|
||||
if u, err := url.QueryUnescape(secretPart); err == nil {
|
||||
secretPart = u
|
||||
}
|
||||
return idPart, secretPart, true
|
||||
}
|
||||
|
||||
// resolveTokenApp loads the application a token row belongs to. Returns (nil,nil)
|
||||
// for an unknown code so the handler answers invalid_grant without leaking which
|
||||
// of code/app was missing.
|
||||
func resolveTokenApp(ctx context.Context, db orm.DB, tok *schema.Token) (*schema.Application, error) {
|
||||
if tok == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return store.GetApplicationByName(ctx, db, tok.Owner, tok.Application)
|
||||
}
|
||||
|
||||
// appTTL is the access-token lifetime for an app (ExpireInHours, default 1h).
|
||||
func appTTL(app *schema.Application) time.Duration {
|
||||
if app.ExpireInHours > 0 {
|
||||
return time.Duration(app.ExpireInHours * float64(time.Hour))
|
||||
}
|
||||
return time.Hour
|
||||
}
|
||||
|
||||
// refreshTTL is the refresh-token lifetime (RefreshExpireInHours); when unset it
|
||||
// clamps to the access-token lifetime, matching v1.
|
||||
func refreshTTL(app *schema.Application) time.Duration {
|
||||
if app.RefreshExpireInHours > 0 {
|
||||
return time.Duration(app.RefreshExpireInHours * float64(time.Hour))
|
||||
}
|
||||
return appTTL(app)
|
||||
}
|
||||
|
||||
// signerFor loads the application's signing cert from the trusted platform
|
||||
// signing-cert owners and builds a Signer with the given canonical issuer. Using
|
||||
// the same trusted resolution as the JWKS and verification keeps the three
|
||||
// consistent: a token is signed by a key iam2 will also publish and verify.
|
||||
func signerFor(ctx context.Context, db orm.DB, app *schema.Application, issuer string) (*Signer, error) {
|
||||
cert, err := store.GetSigningCert(ctx, db, app.Cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert == nil {
|
||||
return nil, errors.New("token: application has no trusted signing cert")
|
||||
}
|
||||
return NewSignerFromCert(cert, app, issuer)
|
||||
}
|
||||
|
||||
// signAccessToken signs a bare access token for a token row under the given
|
||||
// issuer — the direct sign path the end-to-end test drives.
|
||||
func signAccessToken(ctx context.Context, db orm.DB, app *schema.Application, tok *schema.Token, issuer string, ttl time.Duration, now time.Time) (string, error) {
|
||||
signer, err := signerFor(ctx, db, app, issuer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signer.Sign(app, tok.User, "", "", tok.Scope, ttl, now)
|
||||
}
|
||||
|
||||
// tokenIssuer is the canonical OIDC issuer for this request — the value discovery
|
||||
// advertises and every token carries as `iss`. A deployment PINS it with the
|
||||
// IAM_ISSUER env (e.g. `https://hanzo.id`): the embedded KMS + every resource
|
||||
// server validate `iss` against a fixed expected value, so a hanzo deployment
|
||||
// serving both `hanzo.id` and `iam.hanzo.ai` must emit ONE stable issuer, not a
|
||||
// host-derived one. Pinning also closes the header-influenced-iss vector (a
|
||||
// request's X-Forwarded-Host can no longer steer `iss`). Unset → host-relative
|
||||
// (dev / multi-tenant), so discovery stays split-origin-safe when no pin applies.
|
||||
func tokenIssuer(c *zip.Ctx) string {
|
||||
if iss := strings.TrimSpace(os.Getenv("IAM_ISSUER")); iss != "" {
|
||||
return strings.TrimRight(iss, "/")
|
||||
}
|
||||
if h := httpx.EffectiveHost(c); h != "" {
|
||||
return "https://" + h
|
||||
}
|
||||
return "https://hanzo.id"
|
||||
}
|
||||
|
||||
// userProfile loads a user's email and display name for the token claims.
|
||||
func userProfile(ctx context.Context, db orm.DB, userID string) (email, name string) {
|
||||
owner, uname := splitSub(userID)
|
||||
if owner == "" || uname == "" {
|
||||
return "", ""
|
||||
}
|
||||
u, err := store.GetUserByName(ctx, db, owner, uname)
|
||||
if err != nil || u == nil {
|
||||
return "", ""
|
||||
}
|
||||
name = u.DisplayName
|
||||
if name == "" {
|
||||
name = u.Name
|
||||
}
|
||||
return u.Email, name
|
||||
}
|
||||
|
||||
// splitSub splits a subject "owner/name" into its two parts.
|
||||
func splitSub(sub string) (owner, name string) {
|
||||
owner, name, _ = strings.Cut(sub, "/")
|
||||
return owner, name
|
||||
}
|
||||
|
||||
// hashToken is the SHA-256 hex digest used to index a stored token by a
|
||||
// presented bearer/refresh string without keeping the plaintext on the lookup
|
||||
// path.
|
||||
func hashToken(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// hasScope reports whether a space-delimited scope string contains want.
|
||||
func hasScope(scope, want string) bool {
|
||||
for _, s := range strings.Fields(scope) {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newFamilyID derives a stable refresh-family id for a fresh grant from the
|
||||
// code row's identity — the anchor every rotation of this grant shares.
|
||||
func newFamilyID(tok *schema.Token) string {
|
||||
return tok.Owner + "/" + tok.Name
|
||||
}
|
||||
|
||||
// isInternalApp reports whether an application is an internal service identity
|
||||
// (<org>-iam), which may never obtain a token on the public token endpoint.
|
||||
func isInternalApp(app *schema.Application) bool {
|
||||
return strings.HasSuffix(app.Name, "-iam")
|
||||
}
|
||||
|
||||
// redeemErrToResponse maps a RedeemCode error to the RFC 6749 error body.
|
||||
func redeemErrToResponse(c *zip.Ctx, err error) error {
|
||||
switch err {
|
||||
case ErrCodeUsed:
|
||||
return tokenError(c, 400, "invalid_grant", "authorization code already used")
|
||||
case ErrCodeExpired:
|
||||
return tokenError(c, 400, "invalid_grant", "authorization code expired")
|
||||
case ErrClientMismatch:
|
||||
return tokenError(c, 400, "invalid_grant", "client mismatch")
|
||||
case ErrPKCEMismatch, ErrPKCEMissing, ErrPKCEPlainRejected:
|
||||
return tokenError(c, 400, "invalid_grant", "PKCE verification failed")
|
||||
case ErrCodeUnknown:
|
||||
return tokenError(c, 400, "invalid_grant", "invalid authorization code")
|
||||
default:
|
||||
return tokenError(c, 400, "invalid_grant", "authorization code rejected")
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// RFC 8693 OAuth 2.0 Token Exchange — the standard delegation / act-on-behalf-of
|
||||
// flow, and the replacement for the Casdoor `issue-user-token` verb (HIP-0111).
|
||||
// A trusted, allow-listed confidential client presents a `subject_token`
|
||||
// identifying the end user it acts for and receives a fresh access token bound to
|
||||
// that user and re-scoped (RFC 8707 `resource`/`audience`) to a downstream
|
||||
// resource server — indistinguishable from a token the user obtained directly, so
|
||||
// a BFF can forward it and the resource server scopes on the validated `owner`.
|
||||
//
|
||||
// It reuses the same red-hardened controls as the other on-behalf-of primitives:
|
||||
// the mint allow-list is matched by the globally-unique clientId ONLY, acting for
|
||||
// a reserved-org (`admin`/`built-in`) subject needs the separate admin capability,
|
||||
// and every exchange is audit-logged.
|
||||
|
||||
const (
|
||||
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token"
|
||||
subjectTokenTypeAccess = "urn:ietf:params:oauth:token-type:access_token"
|
||||
subjectTokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token"
|
||||
subjectTokenTypeJWTToken = "urn:ietf:params:oauth:token-type:jwt"
|
||||
)
|
||||
|
||||
// tokenExchangeGrant handles grant_type=urn:ietf:params:oauth:grant-type:token-exchange.
|
||||
func tokenExchangeGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
// 1) Authenticate the acting client and enforce the exchange capability. A
|
||||
// public client can never exchange; the allow-list is keyed on clientId only.
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return tokenErrorClient(c, "client authentication required")
|
||||
}
|
||||
clientApp, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if clientApp == nil || clientApp.ClientSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(clientApp.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
if !mintAllowed(clientApp.ClientId) {
|
||||
return tokenError(c, 403, "unauthorized_client", "client is not permitted for token exchange")
|
||||
}
|
||||
|
||||
// 2) The subject_token identifies the user to act for (RFC 8693 §2.1, required).
|
||||
// Its type, if given, must be a token type we verify.
|
||||
subjectToken := param(c, "subject_token")
|
||||
if subjectToken == "" {
|
||||
return tokenError(c, 400, "invalid_request", "subject_token is required")
|
||||
}
|
||||
if st := param(c, "subject_token_type"); st != "" &&
|
||||
st != subjectTokenTypeAccess && st != subjectTokenTypeIDToken && st != subjectTokenTypeJWTToken {
|
||||
return tokenError(c, 400, "invalid_request", "unsupported subject_token_type")
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, subjectToken)
|
||||
if err != nil {
|
||||
return tokenError(c, 400, "invalid_grant", "subject_token is invalid or expired")
|
||||
}
|
||||
owner, name := splitSub(claims.Subject)
|
||||
if owner == "" || name == "" {
|
||||
return tokenError(c, 400, "invalid_grant", "subject_token carries no subject")
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if user == nil || user.IsForbidden || user.IsDeleted {
|
||||
return tokenError(c, 400, "invalid_grant", "the subject is unknown or forbidden")
|
||||
}
|
||||
|
||||
// 3) A reserved-org (admin/built-in) subject is a cross-tenant / SuperAdmin
|
||||
// identity — gate it behind the separate admin-exchange capability.
|
||||
if store.IsSigningCertOwner(owner) && !adminMintAllowed(clientApp.ClientId) {
|
||||
return tokenError(c, 403, "access_denied", "not permitted to act for a reserved-org subject")
|
||||
}
|
||||
|
||||
// 4) The requested audience (RFC 8707) — resource wins, then audience, else the
|
||||
// subject's own app. requested_token_type, if given, must be an access token.
|
||||
if rt := param(c, "requested_token_type"); rt != "" && rt != tokenTypeAccessToken {
|
||||
return tokenError(c, 400, "invalid_request", "only an access_token may be requested")
|
||||
}
|
||||
aud := param(c, "resource")
|
||||
if aud == "" {
|
||||
aud = param(c, "audience")
|
||||
}
|
||||
if aud == "" {
|
||||
aud = defaultUserAudience(ctx, db, user, clientApp)
|
||||
}
|
||||
|
||||
// 5) Mint for the subject, scoped to the requested audience, azp = the acting
|
||||
// client (records who exchanged). Signed under the trusted cert + issuer.
|
||||
signer, err := signerFor(ctx, db, clientApp, tokenIssuer(c))
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
ttl := appTTL(clientApp)
|
||||
scope := param(c, "scope")
|
||||
subject := owner + "/" + name
|
||||
display := user.DisplayName
|
||||
if display == "" {
|
||||
display = user.Name
|
||||
}
|
||||
access, err := signer.SignUserToken(subject, owner, aud, clientApp.ClientId, user.Email, display, scope, ttl, now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
|
||||
row := &schema.Token{
|
||||
Owner: owner,
|
||||
Application: clientApp.Name,
|
||||
Organization: owner,
|
||||
User: subject,
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
AccessTokenHash: hashToken(access),
|
||||
}
|
||||
row.Name = "tx-" + hashToken(access)[:32]
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
auditMint(ctx, db, c, "token-exchange", clientApp.ClientId, subject)
|
||||
|
||||
// 6) RFC 8693 §2.2 response.
|
||||
return c.JSON(200, map[string]any{
|
||||
"access_token": access,
|
||||
"issued_token_type": tokenTypeAccessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(ttl.Seconds()),
|
||||
"scope": scope,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// RFC 8693 Token Exchange — the standard on-behalf-of flow (replaces the retired
|
||||
// issue-user-token verb). An allow-listed confidential client presents a valid
|
||||
// subject_token and receives a token bound to that subject, re-scoped to a
|
||||
// downstream resource. These tests pin the happy path AND every red-hardened
|
||||
// control: the allow-list is clientId-only (the closed CRITICAL), a reserved-org
|
||||
// subject needs the separate admin capability, public clients are refused, and an
|
||||
// invalid subject_token yields invalid_grant.
|
||||
|
||||
// exchange posts a token-exchange grant as the confidential client (basic auth).
|
||||
func exchange(t *testing.T, app *zip.App, clientID, secret string, extra url.Values) (int, map[string]any) {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"grant_type": {grantTypeTokenExchange},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {secret},
|
||||
}
|
||||
for k, v := range extra {
|
||||
form[k] = v
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp.StatusCode, decode(t, body)
|
||||
}
|
||||
|
||||
// subjectTokenFor mints a real access token for (org, name) via the password
|
||||
// grant through `viaClient` — a valid subject_token an exchange can present.
|
||||
func subjectTokenFor(t *testing.T, app *zip.App, viaClient, secret, org, username, password string) string {
|
||||
t.Helper()
|
||||
_, tok := postToken(t, app, url.Values{
|
||||
"grant_type": {"password"},
|
||||
"client_id": {viaClient},
|
||||
"client_secret": {secret},
|
||||
"organization": {org},
|
||||
"username": {username},
|
||||
"password": {password},
|
||||
"scope": {"openid profile"},
|
||||
})
|
||||
st, _ := tok["access_token"].(string)
|
||||
if st == "" {
|
||||
t.Fatalf("could not mint a subject_token; body=%v", tok)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestTokenExchange_mintsForSubject(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
status, tok := exchange(t, app, "hanzo-console", "top-secret", url.Values{
|
||||
"subject_token": {subject},
|
||||
"subject_token_type": {subjectTokenTypeAccess},
|
||||
"resource": {"hanzo-cloud"},
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d; body=%v", status, tok)
|
||||
}
|
||||
if tok["issued_token_type"] != tokenTypeAccessToken {
|
||||
t.Errorf("issued_token_type = %v, want %s", tok["issued_token_type"], tokenTypeAccessToken)
|
||||
}
|
||||
access, _ := tok["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("no access_token; body=%v", tok)
|
||||
}
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("exchanged token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
|
||||
t.Errorf("subject/owner = %q/%q, want hanzo/alice / hanzo", claims.Subject, claims.Owner)
|
||||
}
|
||||
if claims.Azp != "hanzo-console" {
|
||||
t.Errorf("azp = %q, want hanzo-console (the acting client)", claims.Azp)
|
||||
}
|
||||
// The RFC 8707 resource became the aud.
|
||||
audOK := false
|
||||
for _, a := range claims.Audience {
|
||||
if a == "hanzo-cloud" {
|
||||
audOK = true
|
||||
}
|
||||
}
|
||||
if !audOK {
|
||||
t.Errorf("aud = %v, want it to contain the requested resource hanzo-cloud", claims.Audience)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExchange_notAllowlisted_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "some-other-app")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
// hanzo-console holds a valid subject_token but is off the exchange allow-list.
|
||||
status, _ := exchange(t, app, "hanzo-console", "top-secret", url.Values{"subject_token": {subject}})
|
||||
if status != 403 {
|
||||
t.Fatalf("off-allow-list exchange status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchange_nameCollisionAttacker_403 is the regression guard for the
|
||||
// closed CRITICAL: an attacker app named like the console but with the attacker's
|
||||
// OWN clientId is refused at the allow-list (matched by clientId only), even though
|
||||
// it holds a valid subject_token for its own tenant user.
|
||||
func TestTokenExchange_nameCollisionAttacker_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
// Attacker: a DIFFERENT clientId, but NAME collides with the allow-listed one.
|
||||
seedAttackerApp(t, db, "evil", "hanzo-console", "evil-pwn", "attacker-knows-this", "cert-hanzo-console")
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
status, body := exchange(t, app, "evil-pwn", "attacker-knows-this", url.Values{
|
||||
"subject_token": {subject},
|
||||
"resource": {"hanzo-cloud"},
|
||||
})
|
||||
if status != 403 {
|
||||
t.Fatalf("PRIV-ESC REOPENED: name-collision client admitted (status=%d) — allow-list must key on clientId only; body=%v", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExchange_reservedOrgSubject_requiresAdminCapability(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console") // general only
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
// An admin-org user with a password, so we can mint their subject_token.
|
||||
seedUserInOrg(t, db, "admin", "root", "root@hanzo.ai", "admin pw")
|
||||
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "admin", "root@hanzo.ai", "admin pw")
|
||||
|
||||
status, _ := exchange(t, app, "hanzo-console", "top-secret", url.Values{
|
||||
"subject_token": {subject},
|
||||
"resource": {"hanzo-cloud"},
|
||||
})
|
||||
if status != 403 {
|
||||
t.Fatalf("reserved-org exchange without admin capability status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExchange_reservedOrgSubject_admitsWithAdminCapability(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_ADMIN_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUserInOrg(t, db, "admin", "root", "root@hanzo.ai", "admin pw")
|
||||
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "admin", "root@hanzo.ai", "admin pw")
|
||||
|
||||
status, tok := exchange(t, app, "hanzo-console", "top-secret", url.Values{
|
||||
"subject_token": {subject},
|
||||
"resource": {"hanzo-cloud"},
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("legit admin exchange status = %d; body=%v", status, tok)
|
||||
}
|
||||
if access, _ := tok["access_token"].(string); access == "" {
|
||||
t.Fatalf("no admin token minted; body=%v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExchange_invalidSubjectToken_invalidGrant(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
|
||||
status, tok := exchange(t, app, "hanzo-console", "top-secret", url.Values{"subject_token": {"garbage.not.a.jwt"}})
|
||||
if status != 400 || tok["error"] != "invalid_grant" {
|
||||
t.Fatalf("invalid subject_token → status=%d error=%v, want 400 invalid_grant", status, tok["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExchange_publicClient_rejected(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "pub")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub"}) // no secret → public
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
status, _ := exchange(t, app, "pub", "", url.Values{"subject_token": {"x"}})
|
||||
if status != 401 {
|
||||
t.Fatalf("public-client exchange status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExchange_emitsAudit(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse")
|
||||
subject := subjectTokenFor(t, app, "hanzo-console", "top-secret", "hanzo", "alice@hanzo.ai", "correct horse")
|
||||
|
||||
exchange(t, app, "hanzo-console", "top-secret", url.Values{"subject_token": {subject}, "resource": {"hanzo-cloud"}})
|
||||
|
||||
logs, err := orm.TypedQuery[schema.AuditLog](db).Filter("Action=", "token-exchange").GetAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("query audit: %v", err)
|
||||
}
|
||||
if len(logs) != 1 || logs[0].User != "hanzo/alice" || logs[0].Object != "hanzo-console" {
|
||||
t.Fatalf("token-exchange audit = %+v, want one row {user:hanzo/alice, minter:hanzo-console}", logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// loginParams builds a type=code login body for org "hanzo" / user alice.
|
||||
func loginParams(clientID, scope string) map[string]string {
|
||||
return map[string]string{
|
||||
"organization": "hanzo",
|
||||
"username": "alice",
|
||||
"password": "pw",
|
||||
"clientId": clientID,
|
||||
"redirectUri": testRedirect,
|
||||
"scope": scope,
|
||||
"nonce": "nonce-1",
|
||||
}
|
||||
}
|
||||
|
||||
// The confidential authorization-code flow, end to end over HTTP: login mints a
|
||||
// code, the token endpoint exchanges it for a verifiable access token, an
|
||||
// id_token that echoes the nonce, and a refresh token — with no-store caching.
|
||||
func TestAuthCodeFlow_ConfidentialHappyPath(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
code, resp, body := loginForCode(t, app, loginParams("conf", "openid profile email"))
|
||||
if code == "" {
|
||||
t.Fatalf("login did not mint a code: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"code": {code},
|
||||
"client_id": {"conf"},
|
||||
"client_secret": {"s3cret"},
|
||||
"redirect_uri": {testRedirect},
|
||||
}
|
||||
tokResp, tok := exchangeCode(t, app, form)
|
||||
if tokResp.StatusCode != 200 {
|
||||
t.Fatalf("token status = %d, body = %v", tokResp.StatusCode, tok)
|
||||
}
|
||||
if cc := tokResp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
if tok["token_type"] != "Bearer" || tok["access_token"] == nil ||
|
||||
tok["id_token"] == nil || tok["refresh_token"] == nil {
|
||||
t.Fatalf("token response missing fields: %v", tok)
|
||||
}
|
||||
|
||||
// The access token verifies through iam2's own verify path with the right
|
||||
// issuer, audience, subject, and tenant.
|
||||
access := tok["access_token"].(string)
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("verify access token: %v", err)
|
||||
}
|
||||
if claims.Issuer != "https://hanzo.id" {
|
||||
t.Errorf("iss = %q, want https://hanzo.id", claims.Issuer)
|
||||
}
|
||||
if len(claims.Audience) != 1 || claims.Audience[0] != "conf" {
|
||||
t.Errorf("aud = %v, want [conf]", claims.Audience)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
|
||||
t.Errorf("sub/owner = %q/%q, want hanzo/alice + hanzo", claims.Subject, claims.Owner)
|
||||
}
|
||||
|
||||
// The id_token echoes the request nonce.
|
||||
idClaims, err := verifyToken(context.Background(), db, tok["id_token"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("verify id_token: %v", err)
|
||||
}
|
||||
if idClaims.Nonce != "nonce-1" {
|
||||
t.Errorf("id_token nonce = %q, want nonce-1", idClaims.Nonce)
|
||||
}
|
||||
}
|
||||
|
||||
// The public client flow requires and verifies PKCE; a tampered verifier fails.
|
||||
func TestAuthCodeFlow_PublicPKCE(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
verifier := "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
params := loginParams("pub", "openid")
|
||||
params["codeChallenge"] = ComputeS256Challenge(verifier)
|
||||
params["codeChallengeMethod"] = "S256"
|
||||
|
||||
t.Run("valid verifier", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_verifier": {verifier}})
|
||||
if resp.StatusCode != 200 || tok["access_token"] == nil {
|
||||
t.Fatalf("valid PKCE exchange failed: %d %v", resp.StatusCode, tok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tampered verifier", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}, "code_verifier": {"the-WRONG-verifier-000000000000000000000000"}})
|
||||
if resp.StatusCode != 400 || tok["error"] != "invalid_grant" {
|
||||
t.Fatalf("tampered PKCE: status=%d err=%v, want 400 invalid_grant", resp.StatusCode, tok["error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing verifier", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, params)
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"pub"}, "redirect_uri": {testRedirect}})
|
||||
if resp.StatusCode != 400 || tok["error"] != "invalid_grant" {
|
||||
t.Fatalf("missing verifier: status=%d err=%v", resp.StatusCode, tok["error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The RFC 6749 §5.2 error taxonomy: invalid_client → 401 + WWW-Authenticate,
|
||||
// every other error → 400, each with the right code.
|
||||
func TestToken_ErrorTaxonomy(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
t.Run("missing grant_type", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{})
|
||||
requireError(t, resp, tok, 400, "invalid_request")
|
||||
})
|
||||
t.Run("unsupported grant_type", func(t *testing.T) {
|
||||
// A grant iam2 does not implement (device_code) — password IS supported now.
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}})
|
||||
requireError(t, resp, tok, 400, "unsupported_grant_type")
|
||||
})
|
||||
t.Run("unknown code", func(t *testing.T) {
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {"nope"}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
})
|
||||
t.Run("wrong client secret is invalid_client 401", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"WRONG"}, "redirect_uri": {testRedirect}})
|
||||
requireError(t, resp, tok, 401, "invalid_client")
|
||||
if resp.Header.Get("WWW-Authenticate") == "" {
|
||||
t.Error("401 invalid_client must carry WWW-Authenticate")
|
||||
}
|
||||
})
|
||||
t.Run("redirect_uri mismatch", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {"https://app.example/other"}})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
})
|
||||
t.Run("code is single-use", func(t *testing.T) {
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
form := url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}}
|
||||
if resp, _ := exchangeCode(t, app, cloneValues(form)); resp.StatusCode != 200 {
|
||||
t.Fatalf("first exchange failed: %d", resp.StatusCode)
|
||||
}
|
||||
resp, tok := exchangeCode(t, app, cloneValues(form))
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
})
|
||||
}
|
||||
|
||||
// A code past its TTL is refused.
|
||||
func TestToken_ExpiredCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
base := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, base)
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", "openid"))
|
||||
|
||||
// Advance past the 5-minute code TTL.
|
||||
nowFuncSet(t, base.Add(codeTTL+time.Minute))
|
||||
resp, tok := exchangeCode(t, app, url.Values{"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect}})
|
||||
requireError(t, resp, tok, 400, "invalid_grant")
|
||||
}
|
||||
|
||||
// client_credentials issues a machine token (no user, no id_token, no refresh);
|
||||
// a public client or a bad secret is refused 401.
|
||||
func TestClientCredentials(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}})
|
||||
|
||||
t.Run("post credentials", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"}, "scope": {"read"}})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, body = %v", resp.StatusCode, tok)
|
||||
}
|
||||
if tok["refresh_token"] != nil || tok["id_token"] != nil {
|
||||
t.Errorf("client_credentials must not issue refresh/id_token: %v", tok)
|
||||
}
|
||||
claims, err := verifyToken(context.Background(), db, tok["access_token"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Subject != "admin/svc" || claims.Owner != "hanzo" {
|
||||
t.Errorf("sub/owner = %q/%q, want admin/svc + hanzo", claims.Subject, claims.Owner)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("basic auth", func(t *testing.T) {
|
||||
req := formReq("POST", PathToken, url.Values{"grant_type": {"client_credentials"}})
|
||||
req.SetBasicAuth("svc", "svc-secret")
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("basic-auth client_credentials: status %d, body %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong secret", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"nope"}})
|
||||
requireError(t, resp, tok, 401, "invalid_client")
|
||||
})
|
||||
|
||||
t.Run("public client refused", func(t *testing.T) {
|
||||
resp, tok := postToken(t, app, url.Values{"grant_type": {"client_credentials"}, "client_id": {"pub"}})
|
||||
requireError(t, resp, tok, 401, "invalid_client")
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func postToken(t *testing.T, app *zip.App, form url.Values) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
func requireError(t *testing.T, resp *http.Response, tok map[string]any, status int, code string) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != status {
|
||||
t.Fatalf("status = %d, want %d (body %v)", resp.StatusCode, status, tok)
|
||||
}
|
||||
if tok["error"] != code {
|
||||
t.Fatalf("error = %v, want %q", tok["error"], code)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneValues(v url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, vs := range v {
|
||||
out[k] = append([]string(nil), vs...)
|
||||
}
|
||||
// exchangeCode re-sets grant_type; drop it so the clone re-adds cleanly.
|
||||
out.Del("grant_type")
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/httpx"
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// The userinfo endpoint: GET/POST /v1/iam/oauth/userinfo. A bearer must satisfy
|
||||
// two independent checks — the grant still exists (the token row is looked up by
|
||||
// the SHA-256 hash of the presented token, so a revoked or rotated grant is
|
||||
// already dead) AND the JWT signature verifies under the issuing cert. It then
|
||||
// returns exactly the OIDC claims the token's granted scopes authorize; the
|
||||
// subject is taken from the signed `sub`, so the response can only ever describe
|
||||
// the token's own principal (no cross-tenant read).
|
||||
|
||||
// userinfoHandler serves the userinfo endpoint.
|
||||
func userinfoHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return userinfoUnauthorized(c, "a bearer access token is required")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
row, err := store.GetTokenByAccessTokenHash(ctx, db, hashToken(bearer))
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
if row == nil {
|
||||
return userinfoUnauthorized(c, "the access token is invalid or revoked")
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return userinfoUnauthorized(c, "the access token is invalid")
|
||||
}
|
||||
|
||||
owner, name := splitSub(claims.Subject)
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
return c.JSON(200, buildUserinfo(user, claims, row, tokenIssuer(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// buildUserinfo assembles the scope-gated claim set. The identifiers (sub, iss,
|
||||
// aud, owner, organization) are always present; every profile/email/address/
|
||||
// phone claim appears only when its scope was granted and the field is set.
|
||||
func buildUserinfo(u *schema.User, claims *Claims, row *schema.Token, iss string) map[string]any {
|
||||
aud := ""
|
||||
if len(claims.Audience) > 0 {
|
||||
aud = claims.Audience[0]
|
||||
}
|
||||
info := map[string]any{
|
||||
"sub": claims.Subject,
|
||||
"iss": iss,
|
||||
"aud": aud,
|
||||
"owner": claims.Owner,
|
||||
}
|
||||
if claims.Organization != "" {
|
||||
info["organization"] = claims.Organization
|
||||
}
|
||||
// A client_credentials token (or a since-deleted user) has no profile.
|
||||
if u == nil {
|
||||
return info
|
||||
}
|
||||
// isAdmin is the SuperAdmin-predicate input the gateway admin-guard derives its
|
||||
// decision from (with owner==adminOrg). It describes the token's OWN principal —
|
||||
// read from the loaded user record (authoritative, never a token claim, matching
|
||||
// the authz Principal) — so UserInfo is a drop-in for the retired get-account
|
||||
// security contract. Emitted regardless of scope (it is identity, not profile),
|
||||
// as `isAdmin` — the exact key the admin-guard + @hanzo/iam SDK read.
|
||||
info["isAdmin"] = u.IsAdmin
|
||||
// type distinguishes a real account from an auto-created anonymous session (the
|
||||
// console's `type === "anonymous-user"` check); always present.
|
||||
if u.Type != "" {
|
||||
info["type"] = u.Type
|
||||
}
|
||||
scope := row.Scope
|
||||
if hasScope(scope, "profile") {
|
||||
putIf(info, "preferred_username", u.Name)
|
||||
putIf(info, "name", u.DisplayName)
|
||||
putIf(info, "picture", u.Avatar)
|
||||
putIf(info, "real_name", u.RealName)
|
||||
if len(u.Groups) > 0 {
|
||||
info["groups"] = u.Groups
|
||||
}
|
||||
if u.IsVerified {
|
||||
info["is_verified"] = true
|
||||
}
|
||||
}
|
||||
if hasScope(scope, "email") && u.Email != "" {
|
||||
info["email"] = u.Email
|
||||
info["email_verified"] = u.EmailVerified
|
||||
}
|
||||
if hasScope(scope, "address") && u.Location != "" {
|
||||
info["address"] = u.Location
|
||||
}
|
||||
if hasScope(scope, "phone") && u.Phone != "" {
|
||||
info["phone"] = u.Phone
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// putIf sets key only when v is non-empty (omitempty for a map).
|
||||
func putIf(m map[string]any, key, v string) {
|
||||
if v != "" {
|
||||
m[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
// userinfoUnauthorized answers an invalid/absent bearer with the OIDC 401 shape
|
||||
// and the Bearer challenge, leaking nothing about why beyond the token being
|
||||
// unusable.
|
||||
func userinfoUnauthorized(c *zip.Ctx, desc string) error {
|
||||
c.SetHeader("WWW-Authenticate", `Bearer error="invalid_token", error_description="`+desc+`"`)
|
||||
return c.JSON(401, map[string]string{"error": "invalid_token", "error_description": desc})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// seedRichUser creates alice with the profile fields userinfo projects.
|
||||
func seedRichUser(t *testing.T, db orm.DB) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner = "hanzo"
|
||||
u.Name = "alice"
|
||||
u.Email = "alice@hanzo.ai"
|
||||
u.EmailVerified = true
|
||||
u.DisplayName = "Alice Example"
|
||||
u.Phone = "+15551234567"
|
||||
u.Location = "San Francisco"
|
||||
u.PasswordHash = string(hash)
|
||||
u.PasswordType = "bcrypt"
|
||||
u.SetId("hanzo/alice")
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed rich user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// accessTokenFor runs the confidential flow and returns the access token.
|
||||
func accessTokenFor(t *testing.T, app *zip.App, scope string) string {
|
||||
t.Helper()
|
||||
code, _, _ := loginForCode(t, app, loginParams("conf", scope))
|
||||
_, tok := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {"conf"}, "client_secret": {"s3cret"}, "redirect_uri": {testRedirect},
|
||||
})
|
||||
access, _ := tok["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatal("no access token issued")
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func userinfo(t *testing.T, app *zip.App, bearer string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := formReqNoBody("GET", PathUserInfo)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, body := do(t, app, req)
|
||||
return resp.StatusCode, decode(t, body)
|
||||
}
|
||||
|
||||
// userinfo returns exactly the claims the granted scopes authorize.
|
||||
func TestUserinfo_ClaimsByScope(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid profile email phone address")
|
||||
status, info := userinfo(t, app, access)
|
||||
if status != 200 {
|
||||
t.Fatalf("userinfo status = %d, body %v", status, info)
|
||||
}
|
||||
want := map[string]any{
|
||||
"sub": "hanzo/alice",
|
||||
"iss": "https://hanzo.id",
|
||||
"aud": "conf",
|
||||
"owner": "hanzo",
|
||||
"organization": "hanzo",
|
||||
"preferred_username": "alice",
|
||||
"name": "Alice Example",
|
||||
"email": "alice@hanzo.ai",
|
||||
"email_verified": true,
|
||||
"phone": "+15551234567",
|
||||
"address": "San Francisco",
|
||||
}
|
||||
for k, v := range want {
|
||||
if info[k] != v {
|
||||
t.Errorf("userinfo[%q] = %v, want %v", k, info[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UserInfo carries the get-account security contract: isAdmin (the gateway
|
||||
// admin-guard's SuperAdmin-predicate input, with owner==adminOrg) + type, from the
|
||||
// loaded user record, so UserInfo is a drop-in for the retired get-account. isAdmin
|
||||
// is present regardless of scope (identity, not profile).
|
||||
func TestUserinfo_AdminGuardContract(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
// Promote the seeded user to an admin with a concrete type.
|
||||
u, err := orm.Get[schema.User](db, "hanzo/alice")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
u.IsAdmin = true
|
||||
u.Type = "normal-user"
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("promote: %v", err)
|
||||
}
|
||||
|
||||
// Even a minimal (openid-only) scope carries the identity claims.
|
||||
access := accessTokenFor(t, app, "openid")
|
||||
status, info := userinfo(t, app, access)
|
||||
if status != 200 {
|
||||
t.Fatalf("status %d: %v", status, info)
|
||||
}
|
||||
if info["isAdmin"] != true {
|
||||
t.Errorf("userinfo[isAdmin] = %v, want true (the admin-guard SuperAdmin input)", info["isAdmin"])
|
||||
}
|
||||
if info["type"] != "normal-user" {
|
||||
t.Errorf("userinfo[type] = %v, want normal-user", info["type"])
|
||||
}
|
||||
if info["owner"] != "hanzo" {
|
||||
t.Errorf("userinfo[owner] = %v, want hanzo", info["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// A non-admin's userinfo carries isAdmin:false (never absent — the admin-guard
|
||||
// must be able to read a definite false, not infer it from a missing key).
|
||||
func TestUserinfo_NonAdminIsExplicitFalse(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db) // alice, IsAdmin=false
|
||||
_, info := userinfo(t, app, accessTokenFor(t, app, "openid"))
|
||||
if v, ok := info["isAdmin"]; !ok || v != false {
|
||||
t.Errorf("userinfo[isAdmin] = %v (present=%v), want an explicit false", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A narrow scope yields only the identifiers — no profile/email leakage.
|
||||
func TestUserinfo_ScopeGating(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid")
|
||||
status, info := userinfo(t, app, access)
|
||||
if status != 200 {
|
||||
t.Fatalf("status %d", status)
|
||||
}
|
||||
for _, leaked := range []string{"email", "preferred_username", "name", "phone", "address"} {
|
||||
if _, ok := info[leaked]; ok {
|
||||
t.Errorf("scope=openid must not expose %q (got %v)", leaked, info[leaked])
|
||||
}
|
||||
}
|
||||
if info["sub"] != "hanzo/alice" {
|
||||
t.Errorf("sub missing: %v", info["sub"])
|
||||
}
|
||||
}
|
||||
|
||||
// No/invalid bearer → 401 invalid_token with the Bearer challenge.
|
||||
func TestUserinfo_Unauthorized(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
t.Run("no bearer", func(t *testing.T) {
|
||||
req := formReqNoBody("GET", PathUserInfo)
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 401 || decode(t, body)["error"] != "invalid_token" {
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if resp.Header.Get("WWW-Authenticate") == "" {
|
||||
t.Error("401 must carry WWW-Authenticate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("garbage bearer", func(t *testing.T) {
|
||||
status, info := userinfo(t, app, "not.a.jwt")
|
||||
if status != 401 || info["error"] != "invalid_token" {
|
||||
t.Fatalf("status=%d err=%v", status, info["error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The store keeps only token hashes — never the reusable plaintext bearer or
|
||||
// refresh token — so a database dump exposes no usable credential.
|
||||
func TestTokens_StoredAsHashesOnly(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "pub", redirectURIs: []string{testRedirect}, refreshHours: 24})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
tok := grantViaPKCE(t, app, "pub", "openid offline_access")
|
||||
access := tok["access_token"].(string)
|
||||
refresh := tok["refresh_token"].(string)
|
||||
|
||||
row, err := store.GetTokenByAccessTokenHash(context.Background(), db, hashToken(access))
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("locate token row: %v (nil=%v)", err, row == nil)
|
||||
}
|
||||
if row.AccessToken != "" || row.RefreshToken != "" {
|
||||
t.Fatalf("plaintext tokens must not be persisted: access=%q refresh=%q", row.AccessToken, row.RefreshToken)
|
||||
}
|
||||
if row.AccessTokenHash != hashToken(access) || row.RefreshTokenHash != hashToken(refresh) {
|
||||
t.Fatal("token hashes must be persisted for lookup")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the token row revokes the bearer even though the JWT itself is still
|
||||
// within its lifetime — userinfo looks the grant up by hash first.
|
||||
func TestUserinfo_RevokedTokenRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid profile")
|
||||
if status, _ := userinfo(t, app, access); status != 200 {
|
||||
t.Fatalf("token should work before revocation: %d", status)
|
||||
}
|
||||
|
||||
// Revoke: delete the stored grant.
|
||||
row, err := store.GetTokenByAccessTokenHash(context.Background(), db, hashToken(access))
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("locate token row: %v (nil=%v)", err, row == nil)
|
||||
}
|
||||
if err := store.DeleteToken(context.Background(), db, row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, info := userinfo(t, app, access); status != 401 || info["error"] != "invalid_token" {
|
||||
t.Fatalf("revoked token: status=%d err=%v, want 401 invalid_token", status, info["error"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/store"
|
||||
)
|
||||
|
||||
// GET /v1/iam/whoami — the current caller's identity, lighter than get-account:
|
||||
// just who you are (owner, name, id, isAdmin), not the full masked profile + org.
|
||||
// Resolution is the same callerOf as get-account (session cookie first, then bearer
|
||||
// access token), so one identity is reported whichever credential carried it. An
|
||||
// anonymous caller gets the casibase {status:"error"} (200), never a leak.
|
||||
|
||||
// PathWhoami is the canonical lightweight-identity endpoint.
|
||||
const PathWhoami = "/v1/iam/whoami"
|
||||
|
||||
// whoamiHandler reports the resolved caller's identity in the casibase envelope.
|
||||
func whoamiHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return c.JSON(200, accountResponse{Status: "error", Msg: "please sign in first"})
|
||||
}
|
||||
// The identity essentials. isAdmin/displayName enrich best-effort from the
|
||||
// user row — absent (a machine token, or a since-deleted user), the subject
|
||||
// alone is still a truthful identity.
|
||||
data := map[string]any{"owner": owner, "name": name, "id": owner + "/" + name}
|
||||
if u, err := store.GetUserByName(ctx, db, owner, name); err == nil && u != nil {
|
||||
data["id"] = u.Owner + "/" + u.Name
|
||||
data["isAdmin"] = u.IsAdmin
|
||||
data["displayName"] = u.DisplayName
|
||||
}
|
||||
return c.JSON(200, accountResponse{
|
||||
Status: "ok",
|
||||
Sub: owner + "/" + name,
|
||||
Name: name,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package roles serves the IAM v2 CRUD surface for the `roles` entity: a named
|
||||
// grant bundle owner-scoped by (owner, name). Every operation is a typed zip
|
||||
// handler over hanzoai/orm; the orm string key is "owner/name". Reads scope to
|
||||
// one owner (organization); writes address one role by its (owner, name) key.
|
||||
package roles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam2/internal/schema"
|
||||
)
|
||||
|
||||
// Handler binds the roles operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
// Mount registers the roles CRUD routes on app against db.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/roles", h.List, zip.WithSummary("List roles for an owner"), zip.WithTags("roles"))
|
||||
zip.Post(app, "/v1/iam/roles", h.Create, zip.WithSummary("Create a role"), zip.WithTags("roles"))
|
||||
zip.Post(app, "/v1/iam/roles/get", h.Get, zip.WithSummary("Get one role"), zip.WithTags("roles"))
|
||||
zip.Post(app, "/v1/iam/roles/update", h.Update, zip.WithSummary("Update a role"), zip.WithTags("roles"))
|
||||
zip.Post(app, "/v1/iam/roles/delete", h.Delete, zip.WithSummary("Delete a role"), zip.WithTags("roles"))
|
||||
}
|
||||
|
||||
// Ref addresses one role by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Input is the writable projection of a role (the v1 add/update-role body). It
|
||||
// keeps the wire contract clean of the orm.Model bookkeeping fields.
|
||||
type Input struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
Users []string `json:"users"`
|
||||
Groups []string `json:"groups"`
|
||||
Roles []string `json:"roles"`
|
||||
Domains []string `json:"domains"`
|
||||
IsEnabled bool `json:"isEnabled"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of roles.
|
||||
type ListOutput struct {
|
||||
Roles []*schema.Role `json:"roles"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// apply copies the mutable domain fields of an Input onto a role. The identity
|
||||
// fields (owner, name) and the created stamp are set only on Create, never
|
||||
// overwritten by an update.
|
||||
func apply(dst *schema.Role, in *Input) {
|
||||
dst.DisplayName = in.DisplayName
|
||||
dst.Description = in.Description
|
||||
dst.Users = in.Users
|
||||
dst.Groups = in.Groups
|
||||
dst.Roles = in.Roles
|
||||
dst.Domains = in.Domains
|
||||
dst.IsEnabled = in.IsEnabled
|
||||
}
|
||||
|
||||
// List returns the roles for one owner, newest first. An empty owner lists
|
||||
// every role (the unscoped admin view).
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
q := orm.TypedQuery[schema.Role](h.db)
|
||||
if in.Owner != "" {
|
||||
q = q.Filter("owner", in.Owner)
|
||||
}
|
||||
roles, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &ListOutput{Roles: roles, Total: len(roles)}, nil
|
||||
}
|
||||
|
||||
// Get returns one role addressed by (owner, name).
|
||||
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Role, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
role, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Create persists a new role. It rejects a duplicate (owner, name).
|
||||
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Role, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
switch _, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("role already exists")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
role := orm.New[schema.Role](h.db)
|
||||
role.Owner = in.Owner
|
||||
role.Name = in.Name
|
||||
role.CreatedTime = in.CreatedTime
|
||||
if role.CreatedTime == "" {
|
||||
role.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
apply(role, in)
|
||||
role.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := role.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Update mutates an existing role's grant set. Identity and created stamp are
|
||||
// immutable; a missing role is a 404.
|
||||
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Role, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
role, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
apply(role, in)
|
||||
if err := role.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Delete removes one role addressed by (owner, name).
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
role, err := orm.Get[schema.Role](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := role.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return zip.ErrNotFound("role not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
+82
-12
@@ -2,27 +2,97 @@
|
||||
|
||||
// Package routes mounts the IAM v2 HTTP surface on a zip App.
|
||||
//
|
||||
// Phase 0 serves only GET /v1/iam/v2/health. Resource handlers for users,
|
||||
// organizations, applications, roles, permissions, and keys land in Phase 1
|
||||
// as typed zip handlers (zip.Get[In,Out]); the OIDC/OAuth2 surface
|
||||
// (/v1/iam/oauth/*, /v1/iam/.well-known/*) lands in Phase 2.
|
||||
// Phase 1 serves GET /healthz plus the typed CRUD surface for all
|
||||
// thirteen identity entities (users, organizations, applications, providers,
|
||||
// roles, permissions, certs, keys, webauthn credentials, sessions, tokens,
|
||||
// audit logs, invitations). Each entity owns its own package under internal/;
|
||||
// every package exposes one uniform entry point — Mount(app, db) — so this
|
||||
// file is the single place the whole resource surface is wired.
|
||||
//
|
||||
// The /v1/iam/v2 prefix keeps these routes orthogonal to the live v1 mount at
|
||||
// /v1/iam/* during the transition; it collapses at Phase 5 cutover.
|
||||
// The OIDC/OAuth2 surface (/v1/iam/oauth/*, /v1/iam/.well-known/*) lands in
|
||||
// Phase 2. The /v1/iam prefix keeps these routes orthogonal to the live v1
|
||||
// mount at /v1/iam/* during the transition; it collapses at Phase 5 cutover.
|
||||
package routes
|
||||
|
||||
import "github.com/zap-proto/zip"
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
// Mount registers every Phase-0 route on app.
|
||||
func Mount(app *zip.App) {
|
||||
app.Get("/v1/iam/v2/health", health)
|
||||
"github.com/hanzoai/iam2/internal/applications"
|
||||
"github.com/hanzoai/iam2/internal/auditlogs"
|
||||
"github.com/hanzoai/iam2/internal/authz"
|
||||
"github.com/hanzoai/iam2/internal/certs"
|
||||
"github.com/hanzoai/iam2/internal/compat"
|
||||
"github.com/hanzoai/iam2/internal/invitations"
|
||||
"github.com/hanzoai/iam2/internal/keys"
|
||||
"github.com/hanzoai/iam2/internal/oidc"
|
||||
"github.com/hanzoai/iam2/internal/organizations"
|
||||
"github.com/hanzoai/iam2/internal/permission"
|
||||
"github.com/hanzoai/iam2/internal/providers"
|
||||
"github.com/hanzoai/iam2/internal/roles"
|
||||
"github.com/hanzoai/iam2/internal/scim"
|
||||
"github.com/hanzoai/iam2/internal/sessions"
|
||||
"github.com/hanzoai/iam2/internal/tokens"
|
||||
"github.com/hanzoai/iam2/internal/users"
|
||||
"github.com/hanzoai/iam2/internal/webauthn"
|
||||
)
|
||||
|
||||
// Mount registers every Phase-1 route on app, threading the entity store db
|
||||
// into each entity's typed CRUD handlers.
|
||||
func Mount(app *zip.App, db orm.DB) {
|
||||
// Phase 3 — the authorization seam, in two orthogonal halves (see internal/authz):
|
||||
// - Guard (app.Use) AUTHENTICATES every request first: public OIDC/front-door
|
||||
// routes pass; every other route needs a verified bearer, and the resolved
|
||||
// Principal is attached to the context. It also authorizes reads, whose
|
||||
// target rides in the query string.
|
||||
// - Authorize (app.Authorize) AUTHORIZES writes at the framework's op-invoke
|
||||
// seam, on the DECODED input the handler binds — for REST and MCP alike, so
|
||||
// the value authorized is the value written. Writes to the reserved
|
||||
// admin/built-in owners (the signing-cert poisoning gate) stay SuperAdmin-only.
|
||||
app.Use(authz.Guard(db))
|
||||
app.Authorize(authz.Authorize)
|
||||
|
||||
app.Get("/healthz", health)
|
||||
|
||||
// Phase 2 — the full OIDC/OAuth2 surface at the canonical /v1/iam/* paths
|
||||
// (discovery, JWKS, authorize, token, userinfo, logout) plus the front door
|
||||
// (get-app-login, auth/methods, login) the @hanzo/iam <Login> self-configures
|
||||
// from. One entry point wires the whole identity core.
|
||||
oidc.Mount(app, db)
|
||||
|
||||
users.Mount(app, db)
|
||||
organizations.Mount(app, db)
|
||||
applications.Mount(app, db)
|
||||
providers.Mount(app, db)
|
||||
roles.Mount(app, db)
|
||||
permission.Mount(app, db)
|
||||
certs.Mount(app, db)
|
||||
keys.Mount(app, db)
|
||||
webauthn.Mount(app, db)
|
||||
sessions.Mount(app, db)
|
||||
tokens.Mount(app, db)
|
||||
auditlogs.Mount(app, db)
|
||||
invitations.Mount(app, db)
|
||||
|
||||
// Casdoor verb-alias layer: the get-users / get-organizations / … spellings
|
||||
// (in the v1 {status,data,data2} envelope) every live console/gateway/portal
|
||||
// client hard-codes, served over the SAME store, redaction, and authz as the
|
||||
// REST surface above. This is what makes the backend swap transparent — no
|
||||
// client changes at cutover. Mounted after the entity CRUD so both share the
|
||||
// one Guard/Authorize seam wired at the top.
|
||||
compat.Mount(app, db)
|
||||
|
||||
// SCIM 2.0 (RFC 7644/7643) — the STANDARD identity-provisioning surface that
|
||||
// replaces the Casdoor entity verbs (HIP-0111). Authenticated by the Guard;
|
||||
// each handler owner-scopes via authz.Scope on the path target.
|
||||
scim.Mount(app, db)
|
||||
}
|
||||
|
||||
// health is the Phase-0 liveness probe.
|
||||
// health is the Phase-1 liveness probe.
|
||||
func health(c *zip.Ctx) error {
|
||||
return c.JSON(200, map[string]string{
|
||||
"status": "ok",
|
||||
"phase": "0",
|
||||
"phase": "1",
|
||||
"binary": "iam2",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package schema
|
||||
|
||||
import "github.com/hanzoai/orm"
|
||||
|
||||
// Session is an authenticated login session (v1 Casdoor `session`, v2 kind
|
||||
// "sessions"). One row records every live browser-session cookie a single
|
||||
// principal holds against one application, so a targeted sign-out, an
|
||||
// exclusive sign-in, or a duplicate-login check can enumerate and destroy
|
||||
// them. Identity is the (Owner, Name, Application) triple — v1 joins it into
|
||||
// "owner/name/application" and the orm string id is composed the same way, so
|
||||
// concurrent sessions for one user across different applications never
|
||||
// collide. Field-complete against the v1 row: no cookie list or key part is
|
||||
// dropped, or live sessions would be orphaned on cutover.
|
||||
//
|
||||
// SessionId is the append-only list of active cookie ids. It carries
|
||||
// orm:"serialize" so the column backends (hanzoai/sql, hanzoai/datastore)
|
||||
// persist it through the SessionId_ string sibling; the default SQLite store
|
||||
// round-trips the slice inside the entity JSON blob and leaves the sibling
|
||||
// empty. orm.Model supplies id/createdAt/updatedAt/deleted; CreatedTime below
|
||||
// is the v1 string timestamp, kept distinct from orm's typed CreatedAt.
|
||||
type Session struct {
|
||||
orm.Model[Session]
|
||||
|
||||
Owner string `json:"owner" orm:"varchar(100) notnull pk"`
|
||||
Name string `json:"name" orm:"varchar(100) notnull pk"`
|
||||
Application string `json:"application" orm:"varchar(100) notnull pk"`
|
||||
CreatedTime string `json:"createdTime" orm:"varchar(100)"`
|
||||
|
||||
SessionId []string `json:"sessionId" orm:"serialize" datastore:"-"`
|
||||
SessionId_ string `json:"-" orm:"mediumtext"`
|
||||
|
||||
// ExclusiveSignin is a transient control flag (v1 xorm:"-"): a caller sets
|
||||
// it on a create to collapse SessionId down to the single incoming cookie
|
||||
// instead of appending. It is never stored — a persisted session always
|
||||
// carries it false, so orm:"-" keeps it off the column backends and
|
||||
// omitempty keeps it out of the SQLite JSON blob.
|
||||
ExclusiveSignin bool `json:"exclusiveSignin,omitempty" orm:"-"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user