Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
989cf963ee | ||
|
|
3dbfb5a964 | ||
|
|
2cb582a71e | ||
|
|
dee9bdd85d | ||
|
|
a5e8201deb | ||
|
|
a5d5929c0b | ||
|
|
abbacae449 | ||
|
|
7a536e2574 | ||
|
|
cfb14c6061 | ||
|
|
e0f660109f | ||
|
|
0fc493a287 | ||
|
|
ace20fe3d7 | ||
|
|
1e0e42d46a | ||
|
|
090eaac837 | ||
|
|
12d8e21f9e | ||
|
|
2d9b6994da | ||
|
|
5c6b0c5c9b | ||
|
|
10ad2f9b01 | ||
|
|
4bcad0093b | ||
|
|
18ea27c06b | ||
|
|
348752cbe8 | ||
|
|
9384751b5e | ||
|
|
f9c74210f2 | ||
|
|
022ddc67ec | ||
|
|
2bcf45cc8a | ||
|
|
b1ef416e0d | ||
|
|
ae25191765 | ||
|
|
417c9de375 | ||
|
|
16fda7dda6 | ||
|
|
9d2f83679d | ||
|
|
0107baaf00 | ||
|
|
864c3a41ba | ||
|
|
7f350483ee | ||
|
|
8653396356 | ||
|
|
4e8062dd0e | ||
|
|
209348ad03 | ||
|
|
22f1324017 | ||
|
|
e8474f198e | ||
|
|
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,74 @@
|
||||
# Native Hanzo CI — git.hanzo.ai (Gitea Actions). Self-contained: plain docker
|
||||
# buildx build+push, NO GitHub-specific reusable workflow, so it runs on a Gitea
|
||||
# act_runner (and equally on any standard runner). Hanzo GitOps then reconciles
|
||||
# the image tag onto the cluster.
|
||||
#
|
||||
# Registry: pushes to the Hanzo container registry. REGISTRY + IMAGE + creds come
|
||||
# from repo/org Actions secrets (REGISTRY_USER / REGISTRY_TOKEN), provisioned from
|
||||
# KMS — never inline. Falls back to ghcr.io during the mirror transition.
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # the automatic GITHUB_TOKEN is denied ghcr write without this
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
# The ARC self-hosted scale set, as every working hanzo build uses. GitHub-hosted
|
||||
# `ubuntu-latest` is billing-frozen for the org, so a run on it never starts —
|
||||
# which is why every prior iam2 build silently failed to produce an image.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image + version
|
||||
id: meta
|
||||
run: |
|
||||
echo "registry=${REGISTRY:-ghcr.io}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${REGISTRY:-ghcr.io}/hanzoai/iam" >> "$GITHUB_OUTPUT"
|
||||
ref="${GITHUB_REF##*/}"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) ver="$ref" ;; # v0.1.0
|
||||
*) ver="sha-$(echo "$GITHUB_SHA" | cut -c1-7)" ;;
|
||||
esac
|
||||
echo "version=$ver" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Registry login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.meta.outputs.registry }}
|
||||
# GH_PAT (admin:org + write:packages) — the automatic GITHUB_TOKEN is
|
||||
# denied write to ghcr.io/hanzoai/* (permission_denied: write_package),
|
||||
# the same reason hanzoai/cloud logs in with GH_PAT.
|
||||
username: hanzo-dev
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Build + push (amd64; pure-Go, jsonv2 per SCALE_STANDARD)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
target: STANDARD
|
||||
push: true
|
||||
build-args: |
|
||||
GO_EXPERIMENT=jsonv2
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
# iam2's private modules (hanzoai/orm → hanzoai/dbx, hanzoai/sqlite) are
|
||||
# fetched inside the build via this token — the Dockerfile mounts it as
|
||||
# GIT_AUTH_TOKEN. GH_PAT (admin:org read) is what hanzoai/cloud uses to
|
||||
# fetch the same private cross-repo modules; the automatic GITHUB_TOKEN
|
||||
# cannot (it only reads the repo it runs in), which is why dbx 404'd.
|
||||
secrets: |
|
||||
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
@@ -10,6 +10,8 @@ iam-v2
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# committed test fixtures (encrypted-source migrator canvas vector)
|
||||
!cmd/migrate-v1/testdata/*.db
|
||||
|
||||
# env / local
|
||||
.env
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Hanzo IAM v2 — proprietary identity service (zip + orm, no Casdoor).
|
||||
# Multi-stage Go build → distroless-style alpine. Pure-Go (CGO_ENABLED=0);
|
||||
# hanzoai/sqlite uses the modernc engine so no cgo/musl toolchain is needed.
|
||||
|
||||
FROM golang:1.26.4 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache the module graph before copying the source. iam2 imports private hanzoai
|
||||
# modules (hanzoai/orm, hanzoai/sqlite), so mark them private (direct fetch, no
|
||||
# sumdb) and — when a GIT_AUTH_TOKEN is mounted — rewrite github.com to an
|
||||
# authenticated fetch so `go mod download` can read them. Same pattern as
|
||||
# hanzoai/cloud; without the token it is a no-op (a public-only build still works).
|
||||
ENV GOPRIVATE=github.com/hanzoai/*
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
|
||||
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi && \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Per SCALE_STANDARD.md §2 — every Go production Dockerfile that emits JSON to a
|
||||
# client builds with GOEXPERIMENT=jsonv2 (zip's edge JSON path).
|
||||
ARG GO_EXPERIMENT=jsonv2
|
||||
ENV GOEXPERIMENT=${GO_EXPERIMENT}
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/iam2 .
|
||||
|
||||
FROM alpine:latest AS STANDARD
|
||||
LABEL org.opencontainers.image.source="https://github.com/hanzoai/iam2"
|
||||
LABEL org.opencontainers.image.title="Hanzo IAM v2"
|
||||
RUN apk add --no-cache ca-certificates && update-ca-certificates \
|
||||
&& adduser -D -u 1000 hanzo \
|
||||
&& mkdir -p /data && chown -R hanzo:hanzo /data
|
||||
USER 1000
|
||||
WORKDIR /
|
||||
COPY --from=build --chown=hanzo:hanzo /out/iam2 /iam2
|
||||
|
||||
# Serves the IAM v2 API over ZAP (:9653) + the HTTP edge (:8080). Bootstrap the
|
||||
# config with --init-data /etc/iam/init_data.json (mounted from the same
|
||||
# init_data ConfigMap the Casdoor iam uses; ${VAR} creds from the KMS-synced env).
|
||||
EXPOSE 8080 9653
|
||||
ENTRYPOINT ["/iam2"]
|
||||
CMD ["serve", "--db", "/data/iam2.db", "--http", "http://:8080", "--zap", ":9653"]
|
||||
+149
-53
@@ -1,8 +1,11 @@
|
||||
# IAM v2 Migration
|
||||
|
||||
Casdoor fork (`hanzoai/iam`: Beego + xorm, Apache-2.0) → `hanzoai/iam2`:
|
||||
clean-room, proprietary, on the native Hanzo stack. Phased and drift-gated —
|
||||
the identity binary is never rewritten in one shot.
|
||||
clean-room, proprietary, on the native Hanzo stack. Phased and additive — the
|
||||
identity binary is never rewritten in one shot, and v1 stays live and
|
||||
authoritative until the supervised cutover. Parity is proven by tests + golden
|
||||
vectors captured from v1's own code + a route-level parity audit, and by a
|
||||
shadow deployment against real traffic — not by a swap on faith.
|
||||
|
||||
## §1 Why
|
||||
|
||||
@@ -15,66 +18,159 @@ own framework — we own it, and it collapses to one way of doing each thing.
|
||||
|
||||
- **HTTP** — `github.com/zap-proto/zip` (typed `zip.Get[In,Out]` handlers on the
|
||||
`zap-proto/fiber/v3` engine, specificity routing, OpenAPI 3.1 at the edge).
|
||||
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache) over
|
||||
`github.com/hanzoai/base` (collections, realtime, replicate-to-S3). SQLite —
|
||||
never Postgres for the local/default path.
|
||||
- **Authz** — `github.com/hanzoai/authz`, one canonical policy engine, called
|
||||
over ZAP RPC. No in-process copy.
|
||||
- **OIDC/OAuth2** — in-tree port (no external OIDC library). ML-DSA-65 hybrid
|
||||
JWT signing; JWKS cache.
|
||||
- **Inter-service** — `github.com/luxfi/zap` binary RPC. HTTPS is the external
|
||||
edge only; all service↔service is ZAP (platform law).
|
||||
- **Storage** — `github.com/hanzoai/orm` (typed Go records + KV cache). Default
|
||||
is embedded SQLite (`hanzoai/sqlite`, pure-Go, WAL) — never Postgres. The same
|
||||
`orm.DB` abstraction pluggably targets `hanzoai/sql` / `hanzoai/datastore` over
|
||||
ZAP (`--store sql|datastore`), so iam2 gains ZAP-native persistence + snapshots
|
||||
with zero code change once orm's ZAP backend is enabled.
|
||||
- **OIDC/OAuth2** — in-tree (no external OIDC library). RS256 today; ML-DSA-65
|
||||
hybrid JWT signing + real JWKS from the Cert entity.
|
||||
- **Password verify** — algorithm resolved from the stored row (`internal/cred`):
|
||||
argon2id (every live v1 row) + bcrypt (new iam2 rows), verify-only, fail-closed.
|
||||
- **Inter-service** — `zap-proto` binary RPC. HTTPS is the external edge only; all
|
||||
service↔service is ZAP (platform law).
|
||||
- **Authz** — `github.com/hanzoai/authz` policy engine (`internal/authz` gate).
|
||||
|
||||
## §2.1 RFC/IETF-standard surface — no Casdoor verbs (HIP-0111)
|
||||
|
||||
The wire contract is RFC/OpenID-standard only; there are no Casdoor verb aliases
|
||||
(`get-users`, `add-user`, `get-account`, `issue-user-token`, …) and no `access_token`
|
||||
duplicate of the token endpoint. Each capability is served by its standard, all
|
||||
shipped (iam2 tags):
|
||||
|
||||
| Capability | Standard | Endpoint | Tag |
|
||||
|-----------|----------|----------|-----|
|
||||
| Authorize / token | RFC 6749 (code+PKCE, refresh, client_credentials, **password**) | `/v1/iam/oauth/{authorize,token}` | v0.5.0 |
|
||||
| Delegation / on-behalf-of | **RFC 8693 Token Exchange** (replaces `issue-user-token`) | `grant_type=…token-exchange` | v0.7.0 |
|
||||
| Introspection / revocation | RFC 7662 / RFC 7009 | `/v1/iam/oauth/{introspect,revoke}` | v0.6.0 |
|
||||
| AS metadata / discovery / JWKS | RFC 8414 / OIDC Discovery / RFC 7517 | `/.well-known/*` | v0.6.0 |
|
||||
| Account claims | **OIDC UserInfo** (carries owner/organization/email/isAdmin/type — the get-account contract) | `/v1/iam/oauth/userinfo` | v0.9.0 |
|
||||
| Identity provisioning | **SCIM 2.0** (RFC 7644/7643; replaces get-/add-/update-/delete-user) | `/v1/iam/scim/v2/Users` | v0.8.0 (v0.8.1 authz fix) |
|
||||
| Resource indicators / issuer pin | RFC 8707 + `IAM_ISSUER` | token `aud`/`iss` | v0.5.0 |
|
||||
| Social sign-in / federation | **OIDC/OAuth2 Relying Party** (Authorization-Code + PKCE; Google = OIDC Discovery, GitHub = OAuth2 + userinfo) | authorize `?provider=<name>` → `/v1/iam/oauth/callback` | v0.15.0 |
|
||||
|
||||
Deploy env: `IAM_ISSUER=https://<brand-id>`, `IAM_KEY_MINT_ALLOWED_APPS` (token
|
||||
exchange + `hk-` key mint) and `IAM_ADMIN_MINT_ALLOWED_APPS` (reserved-org targets)
|
||||
— both matched by the globally-unique clientId only.
|
||||
|
||||
**Federation (social sign-in), v0.15.0.** iam2 completes a Google/GitHub sign-in
|
||||
as a standard OIDC/OAuth2 Relying Party — no Casdoor verbs, no tokens-in-query.
|
||||
The authorize endpoint, once it has validated the client and its EXACT
|
||||
redirect_uri, treats a `?provider=<providerName>` request as a federation
|
||||
kickoff: it stashes the app-leg request in a single-use, expiring,
|
||||
browser-bound `FederationState` (state = an opaque 256-bit row key; a `hanzo_fed`
|
||||
HttpOnly+Secure+SameSite=Lax cookie binds it to the initiating browser) and 302s
|
||||
to the IdP with iam2's callback as the redirect_uri, an IdP-leg S256 PKCE
|
||||
verifier, and (OIDC) a nonce. The fixed public callback `/v1/iam/oauth/callback`
|
||||
resolves + burns the transaction (expiry + browser-binding checked), exchanges
|
||||
the IdP code, and VERIFIES the response — for OIDC the id_token signature
|
||||
(against the discovered JWKS, alg pinned to RS/ES), issuer, audience (= our
|
||||
client id), expiry, and nonce; for GitHub the userinfo + a GitHub-verified
|
||||
primary email. It then LINKS or PROVISIONS a local user (match by provider
|
||||
subject, else by VERIFIED email, else provision — never `isAdmin`, federated
|
||||
accounts carry no password) and mints iam2's OWN authorization code bound to the
|
||||
original PKCE/redirect/nonce, so the relying party's existing PKCE code→token
|
||||
exchange completes unchanged. Provider credentials/endpoints come from the
|
||||
existing `providers` rows (`clientId`/`clientSecret`/`type`/`scopes`/`issuerUrl`
|
||||
or the `custom*Url` overrides); the linked subject is persisted on the User's
|
||||
per-connector column (`google`/`github`/…).
|
||||
|
||||
Remaining for cutover: migrate the clients (console `IamAdminApi`/`identity.ts`,
|
||||
gateway admin-guard, portal) off the Casdoor verbs onto these standards via
|
||||
`@hanzo/iam` (+ a SCIM client + token-exchange), then retire `internal/compat` and
|
||||
`get-account`. iam2 already serves everything the clients need in standard form.
|
||||
|
||||
## §3 Phases
|
||||
|
||||
| Phase | Scope | Gate to exit |
|
||||
|------:|-------|--------------|
|
||||
| 0 | Scaffold: Base boots, v2 collection namespace claimed, `/v1/iam/v2/health`, `compare` CLI. | Binary builds and boots. |
|
||||
| 1 | Entity schemas (fields + indexes) + CRUD handlers on `zip` + `orm`, per resource. | Per-entity field parity vs v1; handlers pass tests. |
|
||||
| 2 | In-tree OIDC/OAuth2 server: `/v1/iam/oauth/*`, `/v1/iam/.well-known/*`, JWT (ML-DSA-65), JWKS. | Token/userinfo/authorize parity vs v1. |
|
||||
| 3 | Authz via `hanzoai/authz` over ZAP RPC; retire in-process authz. | Policy decisions match v1. |
|
||||
| 4 | Parity: run `iam2 compare` continuously against a v1 read replica. | **drift = 0** (or a known v1-only residual v2 does not model). |
|
||||
| 5 | Cutover: import v1 data, promote `iam2` to the `iam` mount, archive the fork. | Green in prod; rollback path proven. |
|
||||
| Phase | Scope | Exit |
|
||||
|------:|-------|------|
|
||||
| 1 | Entity schemas (full fields) + owner-scoped CRUD on `zip`+`orm`, 13 identity entities. | ✅ Field-complete vs v1; handlers tested. |
|
||||
| 2 | In-tree OIDC/OAuth2: discovery, JWKS, authorize, token (PKCE S256 + JWT), refresh, userinfo, logout; front-door login/get-app-login/auth-methods. | ✅ Core flow (login→code→token→JWT) tested; front-door residual in progress (below). |
|
||||
| 3 | Authz via `hanzoai/authz` gate over the entity CRUD. | ✅ In `internal/authz`. |
|
||||
| — | ~~Drift gate~~ **DROPPED.** Parity is proven by tests + golden vectors (a real v1 argon2id digest verifies) + a route-level parity audit + a shadow deployment — not a row-count diff. The read-only `compare` CLI remains as a diagnostic, not a gate. | — |
|
||||
| 4 | **Bootstrap + embed.** Seed the real config (orgs/apps/providers/certs) from the same `init_data.json` v1 uses (`internal/seed` — 79 apps / 9 orgs). Embed in `hanzoai/cloud` via `server.Mount`, SHADOW-FIRST (own prefix, alongside live Casdoor, non-destructive). | Shadow serves real `get-app-login`/login against seeded config. |
|
||||
| 5 | **Cutover.** Import the user rows (password hashes verify as-is — see §5), flip iam2 onto the canonical `/v1/iam/*`, archive the fork. | Green in prod; rollback proven. |
|
||||
|
||||
Phases 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,311 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
hsqlite "github.com/hanzoai/sqlite"
|
||||
"github.com/hanzoai/sqlcipher"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// sqlcipherInteropKey is the fixed raw key testdata/c-4.5.6.db was written under
|
||||
// by the real C libsqlcipher 4.5.6 (see testdata/README.txt). The test decrypts
|
||||
// that vector ONLY to obtain a reserved-page (header byte 20 == 80) plaintext
|
||||
// canvas, which pure Go cannot originate; the canvas's own schema is discarded.
|
||||
var sqlcipherInteropKey = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
|
||||
func mustHex(s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// reservedCanvas returns a fresh reserved-page PLAINTEXT SQLite database: the
|
||||
// DecryptFile output of the C-written interop vector. modernc preserves its
|
||||
// 80-byte reserve on write, so seeding a schema into it and re-EncryptFile'ing
|
||||
// yields an encrypted shard the test fully controls — exercising the real
|
||||
// DeriveKey→UnwrapDEK→DecryptFile decrypt chain without any prod data.
|
||||
func reservedCanvas(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
enc, err := os.ReadFile(filepath.Join("testdata", "c-4.5.6.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("read canvas fixture: %v", err)
|
||||
}
|
||||
var plain bytes.Buffer
|
||||
if err := sqlcipher.DecryptFile(&plain, bytes.NewReader(enc), sqlcipher.RawKey(sqlcipherInteropKey), sqlcipher.Params{}); err != nil {
|
||||
t.Fatalf("decrypt canvas fixture: %v", err)
|
||||
}
|
||||
if b := plain.Bytes(); len(b) < 21 || b[20] != sqlcipher.Reserve {
|
||||
t.Fatalf("canvas is not reserved (header byte 20 = %d, want %d)", plain.Bytes()[20], sqlcipher.Reserve)
|
||||
}
|
||||
return plain.Bytes()
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropAllTables clears the canvas's inherited schema so the test starts clean.
|
||||
func dropAllTables(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
t.Fatalf("list canvas tables: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatalf("scan table name: %v", err)
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
rows.Close()
|
||||
for _, n := range names {
|
||||
mustExec(t, db, `DROP TABLE IF EXISTS "`+n+`"`)
|
||||
}
|
||||
}
|
||||
|
||||
// writeEncryptedShard produces one encrypted, envelope-wrapped shard at dbPath
|
||||
// (+ dbPath+".dek"): seed a schema into a reserved canvas, EncryptFile it under a
|
||||
// fresh random DEK, then WRAP that DEK under the KEK derived from (master, pt,
|
||||
// pid) — the exact inverse of decryptToTemp, so migrate-v1's encrypted path reads
|
||||
// it back byte-for-byte.
|
||||
func writeEncryptedShard(t *testing.T, dbPath string, master []byte, pt hsqlite.PrincipalType, pid string, seed func(*sql.DB)) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, reservedCanvas(t), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open canvas: %v", err)
|
||||
}
|
||||
dropAllTables(t, db)
|
||||
seed(db)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close canvas: %v", err)
|
||||
}
|
||||
|
||||
plain, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain[20] != sqlcipher.Reserve {
|
||||
t.Fatalf("modernc dropped the reserve (byte 20 = %d): cannot EncryptFile", plain[20])
|
||||
}
|
||||
|
||||
dek := make([]byte, 32)
|
||||
if _, err := rand.Read(dek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enc bytes.Buffer
|
||||
if err := sqlcipher.EncryptFile(&enc, bytes.NewReader(plain), sqlcipher.RawKey(dek), nil, sqlcipher.Params{}); err != nil {
|
||||
t.Fatalf("encrypt shard: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, enc.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kek, err := hsqlite.DeriveKey(master, pt, pid)
|
||||
if err != nil {
|
||||
t.Fatalf("derive KEK: %v", err)
|
||||
}
|
||||
wrapped, err := hsqlite.WrapDEK(kek, dek, hsqlite.PrincipalAAD(pt, pid))
|
||||
if err != nil {
|
||||
t.Fatalf("wrap DEK: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath+".dek", wrapped, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildEncryptedDatadir lays out a sharded encrypted source: a GLOBAL shard
|
||||
// (two orgs + a cert) and two PER-ORG shards (hanzo/z, acme/root), each user
|
||||
// carrying the golden argon2id digest. It returns the datadir and the cert's
|
||||
// PEM material so the caller can assert verbatim key survival.
|
||||
func buildEncryptedDatadir(t *testing.T, master []byte) (datadir, certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
datadir = t.TempDir()
|
||||
certPEM, keyPEM = genRSAPEM(t)
|
||||
|
||||
// GLOBAL shard: orgs + cert, principal (global, "iam").
|
||||
writeEncryptedShard(t, filepath.Join(datadir, "iam.db"), master, hsqlite.PrincipalGlobal, globalPrincipalID, func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "organization"(owner text, name text, created_time text, display_name text, password_type text, init_score integer)`)
|
||||
mustExec(t, db, `INSERT INTO "organization" VALUES(?,?,?,?,?,?)`, "admin", "hanzo", "2020-01-02T03:04:05Z", "Hanzo", "argon2id", 100)
|
||||
mustExec(t, db, `INSERT INTO "organization" VALUES(?,?,?,?,?,?)`, "admin", "acme", "2020-01-03T03:04:05Z", "Acme", "argon2id", 0)
|
||||
mustExec(t, db, `CREATE TABLE "cert"(owner text, name text, created_time text, type text, crypto_algorithm text, bit_size integer, certificate text, private_key text)`)
|
||||
mustExec(t, db, `INSERT INTO "cert" VALUES(?,?,?,?,?,?,?,?)`, "admin", "cert-hanzo", "2020-01-02T03:04:05Z", "x509", "RS256", 2048, certPEM, keyPEM)
|
||||
})
|
||||
|
||||
// PER-ORG shard hanzo: user z (own argon2id type), principal (org, "hanzo").
|
||||
writeEncryptedShard(t, filepath.Join(datadir, "orgs", "hanzo", "iam.db"), master, hsqlite.PrincipalOrg, "hanzo", func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, password_salt text, email text, display_name text, is_admin integer)`)
|
||||
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
"hanzo", "z", "2020-01-02T03:04:05Z", "uuid-0001", goldenDigest, "argon2id", "the-salt", "z@hanzo.ai", "Z", 1)
|
||||
})
|
||||
|
||||
// PER-ORG shard acme: user root, principal (org, "acme") — proves shard MERGE
|
||||
// and per-org KEK isolation (its DEK is wrapped under acme's KEK, not hanzo's).
|
||||
writeEncryptedShard(t, filepath.Join(datadir, "orgs", "acme", "iam.db"), master, hsqlite.PrincipalOrg, "acme", func(db *sql.DB) {
|
||||
mustExec(t, db, `CREATE TABLE "user"(owner text, name text, created_time text, id text, password text, password_type text, password_salt text, email text, display_name text, is_admin integer)`)
|
||||
mustExec(t, db, `INSERT INTO "user" VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
"acme", "root", "2020-01-02T03:04:05Z", "uuid-0002", goldenDigest, "argon2id", "salt2", "root@acme.io", "Root", 1)
|
||||
})
|
||||
|
||||
return datadir, certPEM, keyPEM
|
||||
}
|
||||
|
||||
// TestEncryptedSource_GoldenChain is the end-to-end credential-parity proof for
|
||||
// the ENCRYPTED, SHARDED source path: DeriveKey → UnwrapDEK → DecryptFile →
|
||||
// Migrate → cred.Verify, across a global shard and two org shards, with the
|
||||
// golden argon2id digest verifying under the clean verifier after it entered the
|
||||
// clean store ONLY by being decrypted from an encrypted shard. It also proves the
|
||||
// per-shard decrypted temps are shredded.
|
||||
func TestEncryptedSource_GoldenChain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
master := make([]byte, 32)
|
||||
if _, err := rand.Read(master); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
datadir, _, keyPEM := buildEncryptedDatadir(t, master)
|
||||
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(master))
|
||||
workDir := t.TempDir()
|
||||
dest := t.TempDir()
|
||||
|
||||
if err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", workDir, dest, false, nil); err != nil {
|
||||
t.Fatalf("runEncrypted: %v", err)
|
||||
}
|
||||
|
||||
dst, err := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen dest: %v", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// ---- Global shard: both orgs + the cert's signing key landed. ----
|
||||
org, err := store.GetOrganizationByName(ctx, dst, "hanzo")
|
||||
if err != nil || org == nil {
|
||||
t.Fatalf("org hanzo not migrated from global shard: %v", err)
|
||||
}
|
||||
if org.PasswordType != "argon2id" {
|
||||
t.Errorf("org.PasswordType = %q, want argon2id", org.PasswordType)
|
||||
}
|
||||
if acme, err := store.GetOrganizationByName(ctx, dst, "acme"); err != nil || acme == nil {
|
||||
t.Fatalf("org acme not migrated from global shard: %v", err)
|
||||
}
|
||||
cert, err := store.GetCert(ctx, dst, "admin", "cert-hanzo")
|
||||
if err != nil || cert == nil {
|
||||
t.Fatalf("cert not migrated from global shard: %v", err)
|
||||
}
|
||||
if cert.PrivateKey != keyPEM {
|
||||
t.Fatalf("cert.PrivateKey NOT verbatim through encrypt→decrypt→migrate:\n got %q\nwant %q", cert.PrivateKey, keyPEM)
|
||||
}
|
||||
|
||||
// ---- Org shard hanzo: THE non-negotiable golden argon2id verify. ----
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("user hanzo/z not migrated from org shard: %v", err)
|
||||
}
|
||||
if u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("user.PasswordHash NOT verbatim:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
|
||||
}
|
||||
typ := cred.Resolve(u.PasswordType, org.PasswordType)
|
||||
if !cred.Verify(typ, goldenPassword, u.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the argon2id hash decrypted from the encrypted shard — login would fail at cutover")
|
||||
}
|
||||
if cred.Verify(typ, "wrong-password", u.PasswordHash) {
|
||||
t.Fatal("cred.Verify ACCEPTED a wrong password against the decrypted hash")
|
||||
}
|
||||
|
||||
// ---- Org shard acme MERGED into the same store; its user verifies too. ----
|
||||
root, err := store.GetUserByName(ctx, dst, "acme", "root")
|
||||
if err != nil || root == nil {
|
||||
t.Fatalf("user acme/root not migrated (shards did not merge): %v", err)
|
||||
}
|
||||
if !cred.Verify(cred.Resolve(root.PasswordType, "argon2id"), goldenPassword, root.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the acme user's decrypted hash")
|
||||
}
|
||||
|
||||
// ---- Every decrypted temp was shredded: the work-dir is empty. ----
|
||||
left, err := os.ReadDir(workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) != 0 {
|
||||
t.Errorf("work-dir not clean after run — %d decrypted temp(s) left: %v", len(left), left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedSource_WrongMasterFailsLoud proves a wrong master key fails at
|
||||
// UnwrapDEK and NEVER proceeds to write garbage: the run errors and the dest
|
||||
// store is left empty.
|
||||
func TestEncryptedSource_WrongMasterFailsLoud(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
master := make([]byte, 32)
|
||||
if _, err := rand.Read(master); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
datadir, _, _ := buildEncryptedDatadir(t, master)
|
||||
|
||||
// A different, valid-shaped key — must not unwrap any shard's DEK.
|
||||
wrong := make([]byte, 32)
|
||||
wrong[0] = master[0] ^ 0xff
|
||||
copy(wrong[1:], master[1:])
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", hex.EncodeToString(wrong))
|
||||
dest := t.TempDir()
|
||||
|
||||
err := runEncrypted(ctx, datadir, "MIGRATE_V1_TEST_MASTER_KEY", t.TempDir(), dest, false, nil)
|
||||
if err == nil {
|
||||
t.Fatal("wrong master key must fail loudly, got nil error")
|
||||
}
|
||||
|
||||
// Nothing was written: the dest store has no users.
|
||||
dst, oerr := store.Open("sqlite", filepath.Join(dest, "iam2.db"))
|
||||
if oerr != nil {
|
||||
t.Fatalf("reopen dest: %v", oerr)
|
||||
}
|
||||
defer dst.Close()
|
||||
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u != nil {
|
||||
t.Fatal("a wrong master key still wrote a user — must abort before any write")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptedSource_BadMasterKeyEnv rejects a malformed key env WITHOUT ever
|
||||
// echoing the value.
|
||||
func TestEncryptedSource_BadMasterKeyEnv(t *testing.T) {
|
||||
for _, tc := range []struct{ name, val string }{
|
||||
{"empty", ""},
|
||||
{"not-hex", "zznothex"},
|
||||
{"short", "00112233"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("MIGRATE_V1_TEST_MASTER_KEY", tc.val)
|
||||
if _, err := loadMasterKey("MIGRATE_V1_TEST_MASTER_KEY"); err == nil {
|
||||
t.Fatalf("%s master key must be rejected", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// This file adds the ENCRYPTED, SHARDED source path to migrate-v1. Production
|
||||
// IAM stores its identity data as SQLCipher-encrypted SQLite with envelope
|
||||
// encryption, sharded:
|
||||
//
|
||||
// <datadir>/iam.db GLOBAL shard: certs, applications, organizations
|
||||
// <datadir>/iam.db.dek wrapped-DEK sidecar for the global shard
|
||||
// <datadir>/orgs/<slug>/iam.db PER-ORG shard: that org's users/apps/...
|
||||
// <datadir>/orgs/<slug>/iam.db.dek wrapped-DEK sidecar for the org shard
|
||||
//
|
||||
// Each shard is decrypted to a 0600 temp with the SAME pure-Go recipe:
|
||||
//
|
||||
// 1. kek = sqlite.DeriveKey(master, principalType, principalID) (HKDF-SHA256)
|
||||
// 2. blob = read <db>.dek (wrapped DEK)
|
||||
// 3. dek = sqlite.UnwrapDEK(kek, blob, sqlite.PrincipalAAD(...)) (AES-256-GCM)
|
||||
// 4. sqlcipher.DecryptFile(tmp, db, sqlcipher.RawKey(dek), {}) (page codec)
|
||||
//
|
||||
// then fed to the EXISTING Migrate engine (opened read-only with modernc) and
|
||||
// SHREDDED. Shards merge into one --dest store because Migrate upserts by
|
||||
// natural key (owner/name) and is idempotent.
|
||||
//
|
||||
// DRIVER-COLLISION RESOLUTION (why this is one binary, no os/exec helper):
|
||||
// DeriveKey, UnwrapDEK, PrincipalAAD and the PrincipalGlobal/PrincipalOrg consts
|
||||
// are PURE functions in the ROOT github.com/hanzoai/sqlite package — the SAME
|
||||
// package migrate.go already imports (blank) for the "sqlite" database/sql
|
||||
// driver. Promoting that dependency to a named import here registers NO new
|
||||
// driver: under CGO_ENABLED=0 hanzoai/sqlite's !cgo backend and orm's store both
|
||||
// route through modernc's SINGLE sql.Register("sqlite", …), so there is exactly
|
||||
// one registrant and no "Register called twice" panic. github.com/hanzoai/sqlcipher
|
||||
// registers no database/sql driver at all (it is a pure page/file codec). So the
|
||||
// decrypt and the plaintext read coexist in one process with zero collision, and
|
||||
// the crypto stays in hanzoai/sqlite — never re-implemented here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
hsqlite "github.com/hanzoai/sqlite"
|
||||
"github.com/hanzoai/sqlcipher"
|
||||
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// globalPrincipalID is the KEK principal id for the cross-org GLOBAL shard
|
||||
// (certs, applications, organizations). It MUST byte-match the fork so a DEK
|
||||
// wrapped under the production master key unwraps here: the Casdoor fork pins it
|
||||
// as a const in object/ormer.go — `const globalPrincipalID = "iam"`. A drift
|
||||
// here silently fails UnwrapDEK on the global shard, so it is duplicated as a
|
||||
// deliberate, documented constant rather than imported (the fork is not a dep).
|
||||
const globalPrincipalID = "iam"
|
||||
|
||||
// encShard is one encrypted SQLite shard to decrypt and migrate: its on-disk
|
||||
// path (and implied `<path>.dek` sidecar) plus the (principalType, principalID)
|
||||
// whose KEK wraps its DEK.
|
||||
type encShard struct {
|
||||
label string // human label for the per-shard report ("global", "org:<slug>")
|
||||
path string // the encrypted db; its wrapped-DEK sidecar is path + ".dek"
|
||||
pt hsqlite.PrincipalType
|
||||
pid string
|
||||
}
|
||||
|
||||
// runEncrypted migrates the sharded ENCRYPTED source at datadir into the clean
|
||||
// --dest store: global shard first (orgs/certs/apps), then every orgs/<slug>
|
||||
// shard (users), each decrypted to a shredded temp and merged by upsert. It is
|
||||
// the encrypted-source sibling of run() and shares the exact same Migrate engine
|
||||
// and store-open path, so an encrypted cutover and a plaintext one produce a
|
||||
// byte-identical clean store.
|
||||
func runEncrypted(ctx context.Context, datadir, keyEnv, workDir, dest string, dryRun bool, only []string) error {
|
||||
master, err := loadMasterKey(keyEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zero(master) // scrub the master key from memory when done
|
||||
|
||||
shards, err := discoverShards(datadir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst, err := store.Open("sqlite", storePath(dest))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open clean store: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
fmt.Fprintf(os.Stdout,
|
||||
"migrate-v1: encrypted source %q — %d shard(s). NOTE: decrypting each shard's checkpointed MAIN db file; a real cutover checkpoints WAL first. This run does NOT merge -wal.\n",
|
||||
datadir, len(shards))
|
||||
|
||||
for _, sh := range shards {
|
||||
results, err := migrateEncryptedShard(ctx, sh, master, workDir, dst, dryRun, only)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shard %s (%s): %w", sh.label, sh.path, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "\n=== shard %s (%s) ===", sh.label, sh.path)
|
||||
printReport(os.Stdout, results, dryRun)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadMasterKey reads the 64-hex KMS master key from the NAMED env var and
|
||||
// decodes it to the 32 raw bytes DeriveKey expects. The key value is NEVER
|
||||
// echoed — not in an error, not in a log — only its length is ever reported.
|
||||
func loadMasterKey(env string) ([]byte, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(env))
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("master key: env %s is empty (expected 64 hex chars)", env)
|
||||
}
|
||||
key, err := hex.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("master key from %s: not valid hex", env) // never print the value
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("master key from %s: must be 32 bytes (64 hex chars), got %d", env, len(key))
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// discoverShards enumerates the encrypted shards in dependency order: the global
|
||||
// db first, then every orgs/<slug>/iam.db (slugs sorted for deterministic runs).
|
||||
// A missing global db is fatal (the layout root is wrong); a missing orgs/ dir is
|
||||
// fine (global-only datadir); an org dir without an iam.db is skipped, not fatal.
|
||||
func discoverShards(datadir string) ([]encShard, error) {
|
||||
global := filepath.Join(datadir, "iam.db")
|
||||
if _, err := os.Stat(global); err != nil {
|
||||
return nil, fmt.Errorf("global shard %s: %w", global, err)
|
||||
}
|
||||
shards := []encShard{{label: "global", path: global, pt: hsqlite.PrincipalGlobal, pid: globalPrincipalID}}
|
||||
|
||||
orgsDir := filepath.Join(datadir, "orgs")
|
||||
entries, err := os.ReadDir(orgsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return shards, nil // global-only layout is valid
|
||||
}
|
||||
return nil, fmt.Errorf("read orgs dir %s: %w", orgsDir, err)
|
||||
}
|
||||
slugs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
slugs = append(slugs, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(slugs)
|
||||
for _, slug := range slugs {
|
||||
p := filepath.Join(orgsDir, slug, "iam.db")
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
continue // an org dir carrying no iam.db is not a shard
|
||||
}
|
||||
shards = append(shards, encShard{label: "org:" + slug, path: p, pt: hsqlite.PrincipalOrg, pid: slug})
|
||||
}
|
||||
return shards, nil
|
||||
}
|
||||
|
||||
// migrateEncryptedShard decrypts one shard to a shredded temp and runs the
|
||||
// Migrate engine over it. The temp holds plaintext credential material, so it is
|
||||
// shredded on EVERY path including error (deferred immediately after creation).
|
||||
func migrateEncryptedShard(ctx context.Context, sh encShard, master []byte, workDir string, dst orm.DB, dryRun bool, only []string) ([]*EntityResult, error) {
|
||||
tmp, err := decryptToTemp(sh, master, workDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer shred(tmp)
|
||||
|
||||
src, err := openLegacy(tmp) // read-only modernc open — the SAME reader the plaintext path uses
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
if err := src.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("open decrypted shard read-only: %w", err)
|
||||
}
|
||||
return Migrate(ctx, src, dst, only, options{dryRun: dryRun})
|
||||
}
|
||||
|
||||
// decryptToTemp runs the envelope-decrypt recipe for one shard and writes the
|
||||
// plaintext SQLite bytes to a fresh 0600 temp under workDir (OS temp when empty),
|
||||
// returning the temp path. A WRONG master key fails LOUDLY at UnwrapDEK (the
|
||||
// AES-256-GCM auth tag rejects a KEK derived from garbage); a wrong DEK or a
|
||||
// corrupt page fails at DecryptFile (per-page HMAC → sqlcipher.ErrKey). It never
|
||||
// returns a temp on error, and never logs the key or the DEK.
|
||||
func decryptToTemp(sh encShard, master []byte, workDir string) (string, error) {
|
||||
kek, err := hsqlite.DeriveKey(master, sh.pt, sh.pid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("derive KEK: %w", err)
|
||||
}
|
||||
defer zero(kek)
|
||||
|
||||
wrapped, err := os.ReadFile(sh.path + ".dek")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read wrapped-DEK sidecar %s.dek: %w", sh.path, err)
|
||||
}
|
||||
dek, err := hsqlite.UnwrapDEK(kek, wrapped, hsqlite.PrincipalAAD(sh.pt, sh.pid))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unwrap DEK (wrong master key, or corrupt/foreign sidecar): %w", err)
|
||||
}
|
||||
defer zero(dek)
|
||||
|
||||
in, err := os.Open(sh.path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.CreateTemp(workDir, "iam-migrate-*.db") // 0600
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create work temp: %w", err)
|
||||
}
|
||||
tmp := out.Name()
|
||||
// sqlcipher.Params{} = SQLCipher 4 defaults (4096-byte pages), which the CGO
|
||||
// production backend writes. DecryptFile reads the salt from page 1 of the
|
||||
// source and emits a plaintext db any SQLite build (here: modernc) can open.
|
||||
if err := sqlcipher.DecryptFile(out, in, sqlcipher.RawKey(dek), sqlcipher.Params{}); err != nil {
|
||||
out.Close()
|
||||
shred(tmp)
|
||||
return "", fmt.Errorf("decrypt shard: %w", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
shred(tmp)
|
||||
return "", err
|
||||
}
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
// zero scrubs key material from a byte slice.
|
||||
func zero(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// shred overwrites a decrypted temp with zeros and removes it. The temp holds
|
||||
// plaintext credential material (password digests, signing keys), so it must not
|
||||
// survive the run. Best-effort by design: a stat/open failure still attempts the
|
||||
// remove, so a shred never blocks the migration.
|
||||
func shred(path string) {
|
||||
if fi, err := os.Stat(path); err == nil && fi.Size() > 0 {
|
||||
if f, err := os.OpenFile(path, os.O_WRONLY, 0o600); err == nil {
|
||||
buf := make([]byte, 32*1024)
|
||||
remaining := fi.Size()
|
||||
for remaining > 0 {
|
||||
n := int64(len(buf))
|
||||
if n > remaining {
|
||||
n = remaining
|
||||
}
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
break
|
||||
}
|
||||
remaining -= n
|
||||
}
|
||||
_ = f.Sync()
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Command migrate-v1 is the Phase-5 cutover migrator: it reads the legacy
|
||||
// Casdoor-fork identity store (a SQLite iam.db) and writes every identity record
|
||||
// into the clean-room IAM v2 store, PRESERVING credentials and signing keys
|
||||
// byte-for-byte. A wrong password hash locks a user out; a wrong signing cert
|
||||
// breaks every live token and the JWKS — so correctness, not cleverness, is the
|
||||
// whole job.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// migrate-v1 --src /path/to/legacy/iam.db --dest /path/to/clean/data-dir \
|
||||
// [--dry-run] [--only users,orgs,apps,certs,providers]
|
||||
//
|
||||
// The source is opened READ-ONLY. The destination is opened through the exact
|
||||
// store-open path the server uses (store.Open), so the migrated store is
|
||||
// byte-for-byte the store the server will serve. Every entity is UPSERTed by its
|
||||
// natural key (owner/name): the tool is idempotent — re-running is a no-op, and
|
||||
// --dry-run reports counts + a redacted sample without writing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
|
||||
_ "github.com/hanzoai/iam/internal/schema" // registers the v2 entity kinds
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
fs := flag.NewFlagSet("migrate-v1", flag.ContinueOnError)
|
||||
var (
|
||||
srcPath = fs.String("src", "", "path to a PLAINTEXT legacy Casdoor SQLite iam.db (opened read-only); mutually exclusive with --src-datadir")
|
||||
srcDatadir = fs.String("src-datadir", "", "root of the ENCRYPTED sharded source (<dir>/iam.db + <dir>/orgs/*/iam.db, each with a .dek sidecar); mutually exclusive with --src")
|
||||
masterKeyEnv = fs.String("src-master-key-env", "IAM_KMS_MASTER_KEY", "NAME of the env var holding the 64-hex KMS master key (read for --src-datadir; never taken as an arg or logged)")
|
||||
workDir = fs.String("work-dir", "", "directory for decrypted temp files (default: OS temp); each is created 0600 and shredded after use")
|
||||
dest = fs.String("dest", "", "clean IAM v2 data-dir (the store is <dest>/iam2.db) or a .db path")
|
||||
dryRun = fs.Bool("dry-run", false, "count + sample per entity without writing")
|
||||
only = fs.String("only", "", "comma list of entities: users,orgs,apps,certs,providers,roles,permissions (default all)")
|
||||
)
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
switch {
|
||||
case *srcPath != "" && *srcDatadir != "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --src and --src-datadir are mutually exclusive")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *srcPath == "" && *srcDatadir == "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: one of --src (plaintext) or --src-datadir (encrypted sharded) is required")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
case *dest == "":
|
||||
fmt.Fprintln(os.Stderr, "migrate-v1: --dest is required")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var err error
|
||||
if *srcDatadir != "" {
|
||||
err = runEncrypted(ctx, *srcDatadir, *masterKeyEnv, *workDir, *dest, *dryRun, splitOnly(*only))
|
||||
} else {
|
||||
err = run(ctx, *srcPath, *dest, *dryRun, splitOnly(*only))
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-v1: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, srcPath, dest string, dryRun bool, only []string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source iam.db: %w", err)
|
||||
}
|
||||
|
||||
src, err := openLegacy(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
if err := src.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("open legacy iam.db read-only: %w", err)
|
||||
}
|
||||
|
||||
dst, err := store.Open("sqlite", storePath(dest))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open clean store: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
results, err := Migrate(ctx, src, dst, only, options{dryRun: dryRun})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printReport(os.Stdout, results, dryRun)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openLegacy opens the legacy iam.db strictly read-only via a file: URI, so the
|
||||
// migrator can never mutate the source (and can run against a live-ish copy).
|
||||
func openLegacy(path string) (*sql.DB, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+abs+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open legacy iam.db: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// storePath maps the --dest data-dir to the SQLite file the server uses
|
||||
// (<dest>/iam2.db), or takes dest verbatim when it already names a .db file.
|
||||
func storePath(dest string) string {
|
||||
if strings.HasSuffix(dest, ".db") {
|
||||
return dest
|
||||
}
|
||||
return filepath.Join(dest, "iam2.db")
|
||||
}
|
||||
|
||||
// splitOnly parses the comma-separated --only value.
|
||||
func splitOnly(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printReport writes the per-entity summary: rows read vs written, skips with
|
||||
// reasons, and legacy columns that had no clean-schema home (the pre-flip gap
|
||||
// list). In --dry-run it also prints the redacted sample per entity.
|
||||
func printReport(w *os.File, results []*EntityResult, dryRun bool) {
|
||||
mode := "MIGRATE"
|
||||
if dryRun {
|
||||
mode = "DRY-RUN (no writes)"
|
||||
}
|
||||
fmt.Fprintf(w, "\niam2 migrate-v1 — %s\n\n", mode)
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "entity\tlegacy_table\tread\tcreated\tupdated\tunchanged\tskipped")
|
||||
var tr, tc, tu, tun, ts int
|
||||
for _, r := range results {
|
||||
table := r.Table
|
||||
if r.TableMissing {
|
||||
table = "(missing)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\n",
|
||||
r.Entity, table, r.Read, r.Created, r.Updated, r.Unchanged, r.Skipped)
|
||||
tr, tc, tu, tun, ts = tr+r.Read, tc+r.Created, tu+r.Updated, tun+r.Unchanged, ts+r.Skipped
|
||||
}
|
||||
fmt.Fprintf(tw, "TOTAL\t\t%d\t%d\t%d\t%d\t%d\n", tr, tc, tu, tun, ts)
|
||||
tw.Flush()
|
||||
|
||||
for _, r := range results {
|
||||
if len(r.Reasons) > 0 {
|
||||
fmt.Fprintf(w, "\n%s notes:\n", r.Entity)
|
||||
for reason, n := range r.Reasons {
|
||||
fmt.Fprintf(w, " %-28s %d\n", reason, n)
|
||||
}
|
||||
}
|
||||
if len(r.UnmappedCols) > 0 {
|
||||
fmt.Fprintf(w, "\n%s legacy columns with no clean-schema field (not migrated):\n %s\n",
|
||||
r.Entity, strings.Join(r.UnmappedCols, ", "))
|
||||
}
|
||||
if dryRun && r.Sample != "" {
|
||||
fmt.Fprintf(w, "\n%s sample (redacted):\n%s\n", r.Entity, r.Sample)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
// Registers the "sqlite" database/sql driver name — the SAME package orm's
|
||||
// store routes through, so importing it here is a no-op second reference,
|
||||
// never a second sql.Register (which would panic). Under CGO_ENABLED=0 the
|
||||
// registration is modernc's; the source iam.db is opened read-only with it.
|
||||
_ "github.com/hanzoai/sqlite"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// EntityResult is the per-entity outcome of a migration pass.
|
||||
type EntityResult struct {
|
||||
Entity string // canonical entity name (e.g. "users")
|
||||
Table string // resolved legacy table name (empty if missing)
|
||||
TableMissing bool // the legacy DB has no table for this entity
|
||||
Read int // rows read from the legacy table
|
||||
Created int // rows created in the clean store
|
||||
Updated int // rows whose clean row differed and was overwritten
|
||||
Unchanged int // rows already byte-identical (idempotent no-op)
|
||||
Skipped int // rows skipped (see Reasons)
|
||||
Reasons map[string]int // reason -> count (skips, coercions, defaults)
|
||||
UnmappedCols []string // legacy columns with no clean-schema field
|
||||
Sample string // a redacted sample row (dry-run only)
|
||||
}
|
||||
|
||||
// options controls a migration pass.
|
||||
type options struct {
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// entitySpec binds a clean entity type to its legacy table(s) and the two
|
||||
// name-mismatch escape hatches: colAliases (a clean field fed by a differently
|
||||
// named legacy column) and sensitive (fields masked in any printed sample).
|
||||
// selectors are the --only names that pick this spec.
|
||||
type entitySpec struct {
|
||||
name string
|
||||
selectors []string
|
||||
tables []string
|
||||
colAliases map[string][]string
|
||||
sensitive map[string]bool
|
||||
run func(context.Context, *sql.DB, orm.DB, entitySpec, options) (*EntityResult, error)
|
||||
}
|
||||
|
||||
// specs is the ordered entity registry. Order is dependency order:
|
||||
// organizations own everything; certs sign the tokens applications mint;
|
||||
// applications reference certs; providers are linked by applications; users
|
||||
// live under organizations; roles/permissions reference users. A downstream
|
||||
// entity is never migrated before the entity it points at.
|
||||
func specs() []entitySpec {
|
||||
return []entitySpec{
|
||||
{
|
||||
name: "organizations",
|
||||
selectors: []string{"organizations", "organization", "orgs", "org"},
|
||||
tables: []string{"organization", "organizations"},
|
||||
sensitive: set("passwordSalt", "masterPassword", "defaultPassword",
|
||||
"masterVerificationCode", "passwordObfuscatorKey", "kerberosKeytab"),
|
||||
run: runner[schema.Organization](),
|
||||
},
|
||||
{
|
||||
name: "certs",
|
||||
selectors: []string{"certs", "cert", "certificates"},
|
||||
tables: []string{"cert", "certs"},
|
||||
// PrivateKey + AccessSecret are the JWKS signing material and ACME
|
||||
// credential — copied verbatim, never printed.
|
||||
sensitive: set("privateKey", "accessSecret"),
|
||||
run: runner[schema.Cert](),
|
||||
},
|
||||
{
|
||||
name: "applications",
|
||||
selectors: []string{"applications", "application", "apps", "app"},
|
||||
tables: []string{"application", "applications"},
|
||||
sensitive: set("clientSecret"),
|
||||
run: runner[schema.Application](),
|
||||
},
|
||||
{
|
||||
name: "providers",
|
||||
selectors: []string{"providers", "provider"},
|
||||
tables: []string{"provider", "providers"},
|
||||
sensitive: set("clientSecret", "clientSecret2"),
|
||||
run: runner[schema.Provider](),
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
selectors: []string{"users", "user"},
|
||||
tables: []string{"user", "users"},
|
||||
// THE credential-critical mapping: Casdoor stores the password DIGEST
|
||||
// in a column literally named `password`; the clean schema renamed the
|
||||
// field to PasswordHash (json "passwordHash"). Normalization can't bridge
|
||||
// that rename, so it is declared explicitly. Miss this and every user's
|
||||
// hash is dropped — 100% login failure at cutover.
|
||||
colAliases: map[string][]string{"passwordHash": {"password"}},
|
||||
sensitive: set("passwordHash", "passwordSalt", "accessSecret",
|
||||
"accessSecretHash", "accessToken", "originalToken",
|
||||
"originalRefreshToken", "totpSecret", "recoveryCodes"),
|
||||
run: runner[schema.User](),
|
||||
},
|
||||
{
|
||||
name: "roles",
|
||||
selectors: []string{"roles", "role"},
|
||||
tables: []string{"role", "roles"},
|
||||
run: runner[schema.Role](),
|
||||
},
|
||||
{
|
||||
name: "permissions",
|
||||
selectors: []string{"permissions", "permission", "perms"},
|
||||
tables: []string{"permission", "permissions"},
|
||||
run: runner[schema.Permission](),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runner binds a clean entity type T to the generic engine.
|
||||
func runner[T any]() func(context.Context, *sql.DB, orm.DB, entitySpec, options) (*EntityResult, error) {
|
||||
return func(ctx context.Context, src *sql.DB, dst orm.DB, spec entitySpec, opt options) (*EntityResult, error) {
|
||||
return migrateEntity[T](ctx, src, dst, spec, opt)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate runs the selected entities (empty only == all) against dst in
|
||||
// dependency order. It is the pure engine: callers open src/dst and print the
|
||||
// results, so it is directly testable without touching the filesystem.
|
||||
func Migrate(ctx context.Context, src *sql.DB, dst orm.DB, only []string, opt options) ([]*EntityResult, error) {
|
||||
chosen, err := selectSpecs(only)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*EntityResult, 0, len(chosen))
|
||||
for _, spec := range chosen {
|
||||
res, err := spec.run(ctx, src, dst, spec, opt)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("migrate %s: %w", spec.name, err)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// selectSpecs resolves the --only selectors to specs (in registry order). An
|
||||
// unrecognized selector is an error — a typo must never silently skip an entity.
|
||||
func selectSpecs(only []string) ([]entitySpec, error) {
|
||||
all := specs()
|
||||
if len(only) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, o := range only {
|
||||
o = strings.ToLower(strings.TrimSpace(o))
|
||||
if o != "" {
|
||||
want[o] = true
|
||||
}
|
||||
}
|
||||
matched := map[string]bool{}
|
||||
var chosen []entitySpec
|
||||
for _, spec := range all {
|
||||
for _, sel := range spec.selectors {
|
||||
if want[sel] {
|
||||
chosen = append(chosen, spec)
|
||||
matched[sel] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for sel := range want {
|
||||
if !matched[sel] {
|
||||
return nil, fmt.Errorf("unknown --only entity %q (valid: users, orgs, apps, certs, providers, roles, permissions)", sel)
|
||||
}
|
||||
}
|
||||
return chosen, nil
|
||||
}
|
||||
|
||||
// migrateEntity reads every row of the legacy table for T and upserts it into
|
||||
// the clean store, mapping legacy columns to clean fields by normalized name
|
||||
// (plus the spec's explicit column aliases). Credential and key material is
|
||||
// copied verbatim — the row is reconstructed as JSON and unmarshaled into T, so
|
||||
// bytes never pass through a lossy typed conversion.
|
||||
func migrateEntity[T any](ctx context.Context, src *sql.DB, dst orm.DB, spec entitySpec, opt options) (*EntityResult, error) {
|
||||
res := &EntityResult{Entity: spec.name, Reasons: map[string]int{}}
|
||||
|
||||
table, err := resolveTable(ctx, src, spec.tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if table == "" {
|
||||
res.TableMissing = true
|
||||
return res, nil
|
||||
}
|
||||
res.Table = table
|
||||
|
||||
cols, err := tableColumns(ctx, src, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read columns of %q: %w", table, err)
|
||||
}
|
||||
fields := entityFields[T]()
|
||||
matched, consumed := matchColumns(fields, cols, spec.colAliases)
|
||||
for _, c := range cols {
|
||||
if !consumed[c] {
|
||||
res.UnmappedCols = append(res.UnmappedCols, c)
|
||||
}
|
||||
}
|
||||
if !hasField(matched, "name") {
|
||||
return nil, fmt.Errorf("legacy table %q has no column mapping to 'name' — cannot key rows", table)
|
||||
}
|
||||
|
||||
quoted := make([]string, len(matched))
|
||||
for i, f := range matched {
|
||||
quoted[i] = quoteIdent(f.column)
|
||||
}
|
||||
query := "SELECT " + strings.Join(quoted, ", ") + " FROM " + quoteIdent(table)
|
||||
rows, err := src.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select %q: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
vals := make([]sql.NullString, len(matched))
|
||||
dest := make([]any, len(matched))
|
||||
for i := range vals {
|
||||
dest[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
return nil, fmt.Errorf("scan %q: %w", table, err)
|
||||
}
|
||||
res.Read++
|
||||
|
||||
row := make(map[string]json.RawMessage, len(matched))
|
||||
for i, f := range matched {
|
||||
raw, ok := rawForField(f, vals[i])
|
||||
if !ok {
|
||||
if f.isJSON && vals[i].Valid {
|
||||
if t := strings.TrimSpace(vals[i].String); t != "" && t != "null" && !json.Valid([]byte(t)) {
|
||||
res.Reasons["invalid_json:"+f.jsonName]++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
row[f.jsonName] = raw
|
||||
}
|
||||
|
||||
owner := jsonUnquote(row["owner"])
|
||||
name := jsonUnquote(row["name"])
|
||||
if name == "" {
|
||||
res.Skipped++
|
||||
res.Reasons["empty_name"]++
|
||||
continue
|
||||
}
|
||||
if owner == "" {
|
||||
owner = "admin"
|
||||
row["owner"] = json.RawMessage(`"admin"`)
|
||||
res.Reasons["owner_defaulted_admin"]++
|
||||
}
|
||||
id := owner + "/" + name
|
||||
|
||||
blob, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal %s: %w", id, err)
|
||||
}
|
||||
|
||||
action, err := upsert[T](dst, id, blob, opt.dryRun)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert %s: %w", id, err)
|
||||
}
|
||||
switch action {
|
||||
case actionCreate:
|
||||
res.Created++
|
||||
case actionUpdate:
|
||||
res.Updated++
|
||||
case actionUnchanged:
|
||||
res.Unchanged++
|
||||
}
|
||||
if opt.dryRun && res.Sample == "" && action != actionUnchanged {
|
||||
res.Sample = redactSample(id, action, row, spec.sensitive)
|
||||
}
|
||||
}
|
||||
return res, rows.Err()
|
||||
}
|
||||
|
||||
const (
|
||||
actionCreate = "create"
|
||||
actionUpdate = "update"
|
||||
actionUnchanged = "unchanged"
|
||||
)
|
||||
|
||||
// upsert creates a row when absent, overwrites it when the clean row differs,
|
||||
// and is a true no-op when it already matches (idempotent re-run). In dry-run it
|
||||
// resolves the action without ever writing. The natural key is the id
|
||||
// (owner/name); the OIDC `sub` the clean iam mints is owner/name too, so the
|
||||
// legacy per-row UUID never enters the key.
|
||||
func upsert[T any](dst orm.DB, id string, blob []byte, dry bool) (string, error) {
|
||||
existing, err := orm.Get[T](dst, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
if dry {
|
||||
return actionCreate, nil
|
||||
}
|
||||
if _, _, err := orm.GetOrCreate[T](dst, id, apply[T](blob)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return actionCreate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed, err := wouldChange(existing, blob)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !changed {
|
||||
return actionUnchanged, nil
|
||||
}
|
||||
if dry {
|
||||
return actionUpdate, nil
|
||||
}
|
||||
if _, err := orm.GetOrUpdate[T](dst, id, apply[T](blob)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return actionUpdate, nil
|
||||
}
|
||||
|
||||
// apply returns a mutator that overlays the legacy row's JSON onto a clean
|
||||
// entity. It sets only the fields present in blob (the orm.Model key/timestamps
|
||||
// are absent from blob, so they are never disturbed).
|
||||
func apply[T any](blob []byte) func(*T) {
|
||||
return func(d *T) { _ = json.Unmarshal(blob, d) }
|
||||
}
|
||||
|
||||
// wouldChange reports whether overlaying blob onto existing changes its
|
||||
// serialized form. Because blob carries only domain fields, the orm.Model
|
||||
// key/timestamps are held constant, so a re-run with identical source data is a
|
||||
// true no-op (no write, no UpdatedAt churn).
|
||||
func wouldChange[T any](existing *T, blob []byte) (bool, error) {
|
||||
before, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
clone := new(T)
|
||||
if err := json.Unmarshal(before, clone); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(blob, clone); err != nil {
|
||||
return false, err
|
||||
}
|
||||
after, err := json.Marshal(clone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !bytes.Equal(before, after), nil
|
||||
}
|
||||
|
||||
// fieldSpec is one clean-schema field and the legacy column feeding it.
|
||||
type fieldSpec struct {
|
||||
jsonName string
|
||||
goName string
|
||||
kind reflect.Kind
|
||||
isJSON bool
|
||||
column string // resolved legacy column (set by matchColumns)
|
||||
}
|
||||
|
||||
// entityFields reflects T's stored fields. The embedded orm.Model[T] is skipped
|
||||
// (its promoted json keys id/createdAt/updatedAt/deleted are the storage key and
|
||||
// stamps, never sourced from the legacy row), as are json:"-" and unnamed
|
||||
// (json:"") fields.
|
||||
func entityFields[T any]() []fieldSpec {
|
||||
t := reflect.TypeFor[T]()
|
||||
var out []fieldSpec
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.Anonymous {
|
||||
continue // embedded orm.Model[T]
|
||||
}
|
||||
name := strings.Split(f.Tag.Get("json"), ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
out = append(out, fieldSpec{
|
||||
jsonName: name,
|
||||
goName: f.Name,
|
||||
kind: f.Type.Kind(),
|
||||
isJSON: isJSONKind(f.Type.Kind()),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isJSONKind reports whether a field serializes as JSON text in a legacy column
|
||||
// (slices, maps, structs, pointers) rather than a scalar.
|
||||
func isJSONKind(k reflect.Kind) bool {
|
||||
switch k {
|
||||
case reflect.Slice, reflect.Array, reflect.Map, reflect.Struct, reflect.Ptr, reflect.Interface:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchColumns pairs each clean field with a legacy column. It matches on a
|
||||
// normalized key (lowercase, underscores/dashes stripped) so xorm's snake_case
|
||||
// columns line up with camelCase json tags regardless of the exact mapper
|
||||
// ("created_time"⇔"createdTime", "git_hub"⇔"github"), then falls back to the
|
||||
// spec's explicit aliases. A column is consumed by at most one field.
|
||||
func matchColumns(fields []fieldSpec, cols []string, aliases map[string][]string) (matched []fieldSpec, consumed map[string]bool) {
|
||||
byNorm := make(map[string]string, len(cols))
|
||||
for _, c := range cols {
|
||||
byNorm[normalize(c)] = c
|
||||
}
|
||||
consumed = map[string]bool{}
|
||||
for _, f := range fields {
|
||||
cands := []string{normalize(f.jsonName), normalize(f.goName)}
|
||||
for _, a := range aliases[f.jsonName] {
|
||||
cands = append(cands, normalize(a))
|
||||
}
|
||||
for _, cand := range cands {
|
||||
col, ok := byNorm[cand]
|
||||
if !ok || consumed[col] {
|
||||
continue
|
||||
}
|
||||
f.column = col
|
||||
matched = append(matched, f)
|
||||
consumed[col] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return matched, consumed
|
||||
}
|
||||
|
||||
func hasField(matched []fieldSpec, jsonName string) bool {
|
||||
for _, f := range matched {
|
||||
if f.jsonName == jsonName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rawForField converts a scanned legacy value into the JSON encoding for the
|
||||
// clean field, or reports (nil,false) to omit it (NULL, empty, or the field's
|
||||
// zero value — omitempty makes absent and zero identical, keeping the blob
|
||||
// minimal and re-runs exactly idempotent). JSON-typed columns are passed through
|
||||
// verbatim when valid; scalars are re-encoded through their Go kind.
|
||||
func rawForField(f fieldSpec, ns sql.NullString) (json.RawMessage, bool) {
|
||||
if !ns.Valid {
|
||||
return nil, false
|
||||
}
|
||||
s := ns.String
|
||||
|
||||
if f.isJSON {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" || t == "null" || !json.Valid([]byte(t)) {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(t), true
|
||||
}
|
||||
|
||||
switch f.kind {
|
||||
case reflect.String:
|
||||
if s == "" {
|
||||
return nil, false
|
||||
}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
case reflect.Bool:
|
||||
if s == "1" || strings.EqualFold(s, "true") {
|
||||
return json.RawMessage("true"), true
|
||||
}
|
||||
return nil, false // false is the zero value; omit
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
if fv, ferr := strconv.ParseFloat(strings.TrimSpace(s), 64); ferr == nil {
|
||||
n = int64(fv)
|
||||
} else {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(strconv.FormatInt(n, 10)), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
fv, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil || fv == 0 {
|
||||
return nil, false
|
||||
}
|
||||
b, err := json.Marshal(fv)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// normalize collapses a column or field name to its comparison key: lowercase
|
||||
// with underscores, dashes, and spaces removed.
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r == '_' || r == '-' || r == ' ' {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsonUnquote decodes a JSON string value, returning "" for anything else.
|
||||
func jsonUnquote(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveTable returns the first candidate table that exists in the legacy DB
|
||||
// (matched on the normalized name), or "" when none do.
|
||||
func resolveTable(ctx context.Context, db *sql.DB, candidates []string) (string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table'`)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
existing := map[string]string{}
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
existing[normalize(n)] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if actual, ok := existing[normalize(c)]; ok {
|
||||
return actual, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// tableColumns returns the column names of a legacy table via PRAGMA.
|
||||
func tableColumns(ctx context.Context, db *sql.DB, table string) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, "PRAGMA table_info("+quoteIdent(table)+")")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dflt sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
// quoteIdent double-quotes a SQLite identifier (table names come from
|
||||
// sqlite_master, not user input, but quoting keeps odd names safe).
|
||||
func quoteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
// set builds a lookup set from keys.
|
||||
func set(keys ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
m[k] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// redactSample renders a one-row sample for --dry-run with secret fields masked
|
||||
// (a password digest or private key must never reach a log or a terminal).
|
||||
func redactSample(id, action string, row map[string]json.RawMessage, sensitive map[string]bool) string {
|
||||
view := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
if sensitive[k] {
|
||||
view[k] = fmt.Sprintf("<redacted:%d bytes>", len(v))
|
||||
continue
|
||||
}
|
||||
view[k] = json.RawMessage(v)
|
||||
}
|
||||
b, err := json.MarshalIndent(map[string]any{"id": id, "action": action, "fields": view}, " ", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Golden argon2id digest, mirrored VERBATIM from internal/cred/golden_v1_test.go.
|
||||
// It was produced by v1's own Argon2idCredManager. Proving cred.Verify succeeds
|
||||
// against the MIGRATED hash — a hash that entered the clean store only through
|
||||
// this migrator — is the full end-to-end credential-parity assertion.
|
||||
const (
|
||||
goldenPassword = "golden-test-password-1"
|
||||
goldenDigest = "$argon2id$v=19$m=65536,t=1,p=2$oOen09XtFBqKnv2/K4q5mQ$iZKRwt09CdXDXr4E1CQtRoF/nWzgI810tMFUUiKHugo"
|
||||
)
|
||||
|
||||
// newLegacyDB builds a tiny synthetic legacy Casdoor iam.db with snake_case
|
||||
// columns (as xorm emits) — including the credential-critical `password` column
|
||||
// and the Casdoor `id` UUID that the clean schema has no home for.
|
||||
func newLegacyDB(t *testing.T, certPEM, keyPEM string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "iam.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE organization (
|
||||
owner text, name text, created_time text, display_name text,
|
||||
password_type text, init_score integer, is_personal integer)`,
|
||||
`CREATE TABLE cert (
|
||||
owner text, name text, created_time text, type text,
|
||||
crypto_algorithm text, bit_size integer, certificate text, private_key text)`,
|
||||
`CREATE TABLE application (
|
||||
owner text, name text, created_time text, display_name text,
|
||||
organization text, cert text, client_id text, client_secret text,
|
||||
enable_password integer, redirect_uris text)`,
|
||||
`CREATE TABLE provider (
|
||||
owner text, name text, created_time text, category text, type text,
|
||||
client_id text, client_secret text, user_mapping text)`,
|
||||
`CREATE TABLE user (
|
||||
owner text, name text, created_time text, updated_time text, id text,
|
||||
password text, password_type text, password_salt text, email text,
|
||||
display_name text, is_admin integer, signup_application text, github text)`,
|
||||
`CREATE TABLE role (
|
||||
owner text, name text, created_time text, display_name text,
|
||||
users text, is_enabled integer)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("create table: %v\n%s", err, s)
|
||||
}
|
||||
}
|
||||
|
||||
exec := func(q string, args ...any) {
|
||||
if _, err := db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO organization VALUES(?,?,?,?,?,?,?)`,
|
||||
"admin", "hanzo", "2020-01-02T03:04:05Z", "Hanzo", "argon2id", 100, 0)
|
||||
exec(`INSERT INTO cert VALUES(?,?,?,?,?,?,?,?)`,
|
||||
"admin", "cert-hanzo", "2020-01-02T03:04:05Z", "x509", "RS256", 2048, certPEM, keyPEM)
|
||||
exec(`INSERT INTO application VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
"admin", "app-hanzo", "2020-01-02T03:04:05Z", "Hanzo App", "hanzo", "cert-hanzo",
|
||||
"client-abc", "secret-xyz", 1, `["https://hanzo.ai/callback"]`)
|
||||
exec(`INSERT INTO provider VALUES(?,?,?,?,?,?,?,?)`,
|
||||
"admin", "provider-github", "2020-01-02T03:04:05Z", "OAuth", "GitHub",
|
||||
"gh-id", "gh-secret", `{"id":"id","username":"login"}`)
|
||||
// User z: own password_type=argon2id.
|
||||
exec(`INSERT INTO user VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
"hanzo", "z", "2020-01-02T03:04:05Z", "2020-02-02T03:04:05Z", "uuid-0001",
|
||||
goldenDigest, "argon2id", "the-salt", "z@hanzo.ai", "Z", 1, "app-hanzo", "z-gh")
|
||||
// User fallback: EMPTY password_type — verification must fall back to the org's.
|
||||
exec(`INSERT INTO user VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
"hanzo", "fallback", "2020-01-03T03:04:05Z", "2020-02-03T03:04:05Z", "uuid-0002",
|
||||
goldenDigest, "", "", "fallback@hanzo.ai", "Fallback", 0, "app-hanzo", "")
|
||||
exec(`INSERT INTO role VALUES(?,?,?,?,?,?)`,
|
||||
"hanzo", "role-admin", "2020-01-02T03:04:05Z", "Admins", `["hanzo/z"]`, 1)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func genRSAPEM(t *testing.T) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}))
|
||||
// A stand-in certificate PEM — content is opaque to the migrator; what matters
|
||||
// is that the multi-line PEM survives byte-for-byte.
|
||||
certPEM = "-----BEGIN CERTIFICATE-----\nMIIB=stub=cert=material=\nfor=jwks=parity=test\n-----END CERTIFICATE-----\n"
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func openDest(t *testing.T) (orm.DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := store.Open("sqlite", filepath.Join(dir, "iam2.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open dest: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db, dir
|
||||
}
|
||||
|
||||
func TestMigrate_PreservesCredentialsAndKeysVerbatim(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
results, err := Migrate(ctx, src, dst, nil, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
byEntity := indexResults(results)
|
||||
|
||||
// ---- Organization: password_type must survive (it is the fallback scheme). ----
|
||||
org, err := store.GetOrganizationByName(ctx, dst, "hanzo")
|
||||
if err != nil || org == nil {
|
||||
t.Fatalf("org not migrated: %v", err)
|
||||
}
|
||||
if org.PasswordType != "argon2id" {
|
||||
t.Errorf("org.PasswordType = %q, want argon2id", org.PasswordType)
|
||||
}
|
||||
if org.CreatedTime != "2020-01-02T03:04:05Z" {
|
||||
t.Errorf("org.CreatedTime = %q, want the v1 stamp verbatim", org.CreatedTime)
|
||||
}
|
||||
if org.InitScore != 100 {
|
||||
t.Errorf("org.InitScore = %d, want 100", org.InitScore)
|
||||
}
|
||||
|
||||
// ---- Cert: PrivateKey + Certificate byte-for-byte (JWKS parity). ----
|
||||
cert, err := store.GetCert(ctx, dst, "admin", "cert-hanzo")
|
||||
if err != nil || cert == nil {
|
||||
t.Fatalf("cert not migrated: %v", err)
|
||||
}
|
||||
if cert.PrivateKey != keyPEM {
|
||||
t.Fatalf("cert.PrivateKey NOT verbatim:\n got %q\nwant %q", cert.PrivateKey, keyPEM)
|
||||
}
|
||||
if cert.Certificate != certPEM {
|
||||
t.Fatalf("cert.Certificate NOT verbatim:\n got %q\nwant %q", cert.Certificate, certPEM)
|
||||
}
|
||||
if cert.BitSize != 2048 || cert.CryptoAlgorithm != "RS256" {
|
||||
t.Errorf("cert metadata drift: bitSize=%d alg=%q", cert.BitSize, cert.CryptoAlgorithm)
|
||||
}
|
||||
|
||||
// ---- User z: PasswordHash verbatim, and cred.Verify succeeds end-to-end. ----
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("user z not migrated: %v", err)
|
||||
}
|
||||
if u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("user.PasswordHash NOT verbatim:\n got %q\nwant %q", u.PasswordHash, goldenDigest)
|
||||
}
|
||||
if u.PasswordType != "argon2id" {
|
||||
t.Errorf("user.PasswordType = %q, want argon2id", u.PasswordType)
|
||||
}
|
||||
if u.PasswordSalt != "the-salt" {
|
||||
t.Errorf("user.PasswordSalt = %q, want the-salt", u.PasswordSalt)
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
t.Error("user.IsAdmin = false, want true (bool 1 -> true)")
|
||||
}
|
||||
if u.GitHub != "z-gh" {
|
||||
t.Errorf("user.GitHub = %q, want z-gh (federated connector column)", u.GitHub)
|
||||
}
|
||||
if u.Email != "z@hanzo.ai" {
|
||||
t.Errorf("user.Email = %q", u.Email)
|
||||
}
|
||||
// THE assertion that matters: the migrated hash verifies under the clean
|
||||
// verifier, resolving the scheme from the row exactly as login does.
|
||||
typ := cred.Resolve(u.PasswordType, org.PasswordType)
|
||||
if !cred.Verify(typ, goldenPassword, u.PasswordHash) {
|
||||
t.Fatal("cred.Verify REJECTED the migrated argon2id hash — login would fail at cutover")
|
||||
}
|
||||
if cred.Verify(typ, "wrong-password", u.PasswordHash) {
|
||||
t.Fatal("cred.Verify ACCEPTED a wrong password against the migrated hash")
|
||||
}
|
||||
|
||||
// ---- User fallback: empty type resolves through the org's argon2id. ----
|
||||
fb, err := store.GetUserByName(ctx, dst, "hanzo", "fallback")
|
||||
if err != nil || fb == nil {
|
||||
t.Fatalf("user fallback not migrated: %v", err)
|
||||
}
|
||||
if fb.PasswordType != "" {
|
||||
t.Errorf("fallback user should keep empty PasswordType, got %q", fb.PasswordType)
|
||||
}
|
||||
fbType := cred.Resolve(fb.PasswordType, org.PasswordType)
|
||||
if fbType != "argon2id" {
|
||||
t.Fatalf("org fallback resolve = %q, want argon2id", fbType)
|
||||
}
|
||||
if !cred.Verify(fbType, goldenPassword, fb.PasswordHash) {
|
||||
t.Fatal("org-fallback verification of migrated hash failed")
|
||||
}
|
||||
|
||||
// ---- Application + Provider round-trip (incl. a JSON-typed column). ----
|
||||
app, err := store.GetApplicationByName(ctx, dst, "admin", "app-hanzo")
|
||||
if err != nil || app == nil {
|
||||
t.Fatalf("app not migrated: %v", err)
|
||||
}
|
||||
if app.ClientId != "client-abc" || app.Cert != "cert-hanzo" || app.Organization != "hanzo" {
|
||||
t.Errorf("app fields drift: clientId=%q cert=%q org=%q", app.ClientId, app.Cert, app.Organization)
|
||||
}
|
||||
if len(app.RedirectUris) != 1 || app.RedirectUris[0] != "https://hanzo.ai/callback" {
|
||||
t.Errorf("app.RedirectUris JSON column not decoded: %#v", app.RedirectUris)
|
||||
}
|
||||
prov, err := store.GetProvider(ctx, dst, "admin", "provider-github")
|
||||
if err != nil || prov == nil {
|
||||
t.Fatalf("provider not migrated: %v", err)
|
||||
}
|
||||
wantMap := map[string]string{"id": "id", "username": "login"}
|
||||
if !reflect.DeepEqual(prov.UserMapping, wantMap) {
|
||||
t.Errorf("provider.UserMapping = %#v, want %#v", prov.UserMapping, wantMap)
|
||||
}
|
||||
|
||||
// ---- The Casdoor `id` UUID has NO clean home: it must be flagged as a gap. ----
|
||||
if got := byEntity["users"]; got == nil || !contains(got.UnmappedCols, "id") {
|
||||
t.Errorf("expected legacy user column 'id' reported as unmapped (the old OIDC sub), got %v",
|
||||
gapCols(got))
|
||||
}
|
||||
|
||||
// ---- Row counts: everything read was created. ----
|
||||
assertCounts(t, byEntity["users"], 2, 2, 0, 0)
|
||||
assertCounts(t, byEntity["organizations"], 1, 1, 0, 0)
|
||||
assertCounts(t, byEntity["certs"], 1, 1, 0, 0)
|
||||
assertCounts(t, byEntity["applications"], 1, 1, 0, 0)
|
||||
assertCounts(t, byEntity["providers"], 1, 1, 0, 0)
|
||||
}
|
||||
|
||||
func TestMigrate_Idempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
if _, err := Migrate(ctx, src, dst, nil, options{}); err != nil {
|
||||
t.Fatalf("first pass: %v", err)
|
||||
}
|
||||
firstCounts := kindCounts(ctx, t, dst)
|
||||
|
||||
// Second pass must be a pure no-op: nothing created, nothing updated.
|
||||
results, err := Migrate(ctx, src, dst, nil, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("second pass: %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Created != 0 || r.Updated != 0 {
|
||||
t.Errorf("%s: re-run not idempotent (created=%d updated=%d), want 0/0",
|
||||
r.Entity, r.Created, r.Updated)
|
||||
}
|
||||
if r.Read != r.Unchanged {
|
||||
t.Errorf("%s: read=%d but unchanged=%d — re-run should classify every row unchanged",
|
||||
r.Entity, r.Read, r.Unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
// No duplicate rows: per-kind counts are identical after the second pass.
|
||||
secondCounts := kindCounts(ctx, t, dst)
|
||||
if !reflect.DeepEqual(firstCounts, secondCounts) {
|
||||
t.Errorf("row counts changed on re-run: %v -> %v", firstCounts, secondCounts)
|
||||
}
|
||||
|
||||
// And the hash is still exact after two passes.
|
||||
u, err := store.GetUserByName(ctx, dst, "hanzo", "z")
|
||||
if err != nil || u == nil || u.PasswordHash != goldenDigest {
|
||||
t.Fatalf("hash drifted after re-run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_DryRunWritesNothing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
results, err := Migrate(ctx, src, dst, nil, options{dryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run: %v", err)
|
||||
}
|
||||
// Dry-run predicts creates but writes nothing.
|
||||
for _, r := range results {
|
||||
if r.TableMissing {
|
||||
continue
|
||||
}
|
||||
if r.Created != r.Read {
|
||||
t.Errorf("%s: dry-run should predict create for every row (read=%d created=%d)",
|
||||
r.Entity, r.Read, r.Created)
|
||||
}
|
||||
}
|
||||
counts := kindCounts(ctx, t, dst)
|
||||
for kind, n := range counts {
|
||||
if n != 0 {
|
||||
t.Errorf("dry-run wrote %d rows of kind %q, want 0", n, kind)
|
||||
}
|
||||
}
|
||||
// Second (real) run after a dry-run still creates everything.
|
||||
real, err := Migrate(ctx, src, dst, nil, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("real after dry: %v", err)
|
||||
}
|
||||
for _, r := range indexResultsSlice(real) {
|
||||
if r.TableMissing {
|
||||
continue
|
||||
}
|
||||
if r.Created != r.Read {
|
||||
t.Errorf("%s: real run after dry-run should create every row", r.Entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_OnlySelectsSubset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
certPEM, keyPEM := genRSAPEM(t)
|
||||
srcPath := newLegacyDB(t, certPEM, keyPEM)
|
||||
|
||||
src, err := sql.Open("sqlite", srcPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open src: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := openDest(t)
|
||||
|
||||
results, err := Migrate(ctx, src, dst, []string{"users", "orgs"}, options{})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate subset: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 entities migrated, got %d", len(results))
|
||||
}
|
||||
// certs must NOT have been touched.
|
||||
if c, _ := store.GetCert(ctx, dst, "admin", "cert-hanzo"); c != nil {
|
||||
t.Error("--only users,orgs should not migrate certs")
|
||||
}
|
||||
if u, _ := store.GetUserByName(ctx, dst, "hanzo", "z"); u == nil {
|
||||
t.Error("--only users,orgs must migrate users")
|
||||
}
|
||||
|
||||
// An unknown selector is a hard error, never a silent skip.
|
||||
if _, err := Migrate(ctx, src, dst, []string{"widgets"}, options{}); err == nil {
|
||||
t.Error("unknown --only entity must error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func indexResults(rs []*EntityResult) map[string]*EntityResult {
|
||||
m := map[string]*EntityResult{}
|
||||
for _, r := range rs {
|
||||
m[r.Entity] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func indexResultsSlice(rs []*EntityResult) []*EntityResult { return rs }
|
||||
|
||||
func assertCounts(t *testing.T, r *EntityResult, read, created, updated, unchanged int) {
|
||||
t.Helper()
|
||||
if r == nil {
|
||||
t.Fatalf("nil result")
|
||||
}
|
||||
if r.Read != read || r.Created != created || r.Updated != updated || r.Unchanged != unchanged {
|
||||
t.Errorf("%s counts = read %d/created %d/updated %d/unchanged %d, want %d/%d/%d/%d",
|
||||
r.Entity, r.Read, r.Created, r.Updated, r.Unchanged, read, created, updated, unchanged)
|
||||
}
|
||||
}
|
||||
|
||||
func kindCounts(ctx context.Context, t *testing.T, db orm.DB) map[string]int64 {
|
||||
t.Helper()
|
||||
out := map[string]int64{}
|
||||
for _, kind := range schema.Kinds() {
|
||||
n, err := db.Query(kind).Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count %s: %v", kind, err)
|
||||
}
|
||||
if n > 0 {
|
||||
out[kind] = int64(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gapCols(r *EntityResult) []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.UnmappedCols
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
c-4.5.6.db is a real database written by the C libsqlcipher 4.5.6 library,
|
||||
copied verbatim from github.com/hanzoai/sqlcipher@v0.1.0/testdata. It is keyed
|
||||
with the raw key 000102...1e1f (see enc_test.go: sqlcipherInteropKey).
|
||||
|
||||
The encrypted-source migrator test uses it ONLY as a source of SQLCipher's
|
||||
80-byte per-page reserve (SQLite header byte 20): DecryptFile yields a reserved
|
||||
plaintext canvas, modernc PRESERVES that reserve when the test overwrites the
|
||||
schema, and EncryptFile can then produce an encrypted shard the test fully
|
||||
controls. Pure Go cannot ORIGINATE reserved pages, so this vector bootstraps
|
||||
them. Its own schema/contents are irrelevant — the test wipes them.
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package feature is the seam enterprise capabilities plug into. A module
|
||||
// (hanzoiam/scim, saml, ldap, …) implements Feature and reads/writes the core's
|
||||
// identity via the injected Store — so it shares ONE identity store with the core
|
||||
// and never carries a second copy. The core NEVER imports a module; dependency
|
||||
// flows one way (module → feature).
|
||||
package feature
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
// Store is the identity surface a feature needs — the union of the calls the
|
||||
// copied Casdoor code makes (object.* → store.*). The core implements it over its
|
||||
// orm store (internal/featurestore). A feature ignores methods it doesn't use.
|
||||
type Store interface {
|
||||
GetUser(ctx context.Context, owner, name string) (*model.User, error)
|
||||
GetUserByID(ctx context.Context, id string) (*model.User, error)
|
||||
GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error)
|
||||
AddUser(ctx context.Context, u *model.User) (bool, error)
|
||||
UpdateUser(ctx context.Context, u *model.User) (bool, error)
|
||||
DeleteUser(ctx context.Context, owner, name string) (bool, error)
|
||||
GetApplication(ctx context.Context, id string) (*model.Application, error)
|
||||
GetOrganization(ctx context.Context, name string) (*model.Organization, error)
|
||||
// GetProvider resolves an identity provider by (owner, name) — the SP-inbound
|
||||
// SAML/OAuth surface (a user signing in through a corporate IdP where Hanzo is
|
||||
// the Service Provider). SAML SP-initiated login reads its IdP config from here.
|
||||
GetProvider(ctx context.Context, owner, name string) (*model.Provider, error)
|
||||
// GetCert resolves a signing cert by (owner, name) — SAML metadata signing, etc.
|
||||
GetCert(ctx context.Context, owner, name string) (*model.Cert, error)
|
||||
// SetPassword sets a user's password: the core hashes the plaintext exactly
|
||||
// once and stores only the one-way digest (never the clear text). Used by SCIM
|
||||
// to provision the `password` attribute. An empty plaintext leaves the digest
|
||||
// untouched. Hashing lives in ONE place (the core) — a module never sees a hash.
|
||||
SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
|
||||
// VerifyPassword reports whether plaintext matches the user's stored digest
|
||||
// (argon2id for migrated v1 rows, bcrypt for v2, per the org's password type).
|
||||
// Used by LDAP bind — verification stays in the core, never in a module.
|
||||
VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error)
|
||||
}
|
||||
|
||||
// Feature is one pluggable enterprise capability. Mount registers its routes on
|
||||
// the shared app, backed by store. Name is for diagnostics.
|
||||
type Feature interface {
|
||||
Name() string
|
||||
Mount(app *zip.App, store Store) error
|
||||
}
|
||||
|
||||
var registry []Feature
|
||||
|
||||
// Register adds a feature to the set MountAll mounts. Called by the composing
|
||||
// binary (cloud) or a module init — the core decides which enterprise features ship.
|
||||
func Register(f Feature) { registry = append(registry, f) }
|
||||
|
||||
// Registered returns the registered features (diagnostics/tests).
|
||||
func Registered() []Feature { return append([]Feature(nil), registry...) }
|
||||
|
||||
// MountAll mounts every registered feature on app with store, fail-fast: a
|
||||
// registered-but-broken enterprise module surfaces loudly at boot, never a silent no-op.
|
||||
func MountAll(app *zip.App, store Store) error {
|
||||
for _, f := range registry {
|
||||
if err := f.Mount(app, store); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package feature_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/feature"
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
// A registered feature is mounted by MountAll and reaches the app + store; a
|
||||
// module that fails to mount surfaces the error (fail-fast).
|
||||
type fakeFeature struct {
|
||||
name string
|
||||
mounted bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFeature) Name() string { return f.name }
|
||||
func (f *fakeFeature) Mount(app *zip.App, store feature.Store) error {
|
||||
f.mounted = true
|
||||
return f.err
|
||||
}
|
||||
|
||||
type nopStore struct{}
|
||||
|
||||
func (nopStore) GetUser(context.Context, string, string) (*model.User, error) { return nil, nil }
|
||||
func (nopStore) GetUserByID(context.Context, string) (*model.User, error) { return nil, nil }
|
||||
func (nopStore) GetGlobalUsers(context.Context, int, int) ([]*model.User, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (nopStore) AddUser(context.Context, *model.User) (bool, error) { return true, nil }
|
||||
func (nopStore) UpdateUser(context.Context, *model.User) (bool, error) { return true, nil }
|
||||
func (nopStore) DeleteUser(context.Context, string, string) (bool, error) { return true, nil }
|
||||
func (nopStore) GetApplication(context.Context, string) (*model.Application, error) { return nil, nil }
|
||||
func (nopStore) GetOrganization(context.Context, string) (*model.Organization, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopStore) GetCert(context.Context, string, string) (*model.Cert, error) { return nil, nil }
|
||||
func (nopStore) GetProvider(context.Context, string, string) (*model.Provider, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopStore) SetPassword(context.Context, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (nopStore) VerifyPassword(context.Context, string, string, string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestMountAll_MountsRegistered(t *testing.T) {
|
||||
f := &fakeFeature{name: "fake"}
|
||||
feature.Register(f)
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
if err := feature.MountAll(app, nopStore{}); err != nil {
|
||||
t.Fatalf("MountAll: %v", err)
|
||||
}
|
||||
if !f.mounted {
|
||||
t.Fatal("registered feature was not mounted")
|
||||
}
|
||||
found := false
|
||||
for _, r := range feature.Registered() {
|
||||
if r.Name() == "fake" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Registered() did not list the feature")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/hanzoai/iam2
|
||||
module github.com/hanzoai/iam
|
||||
|
||||
go 1.26.4
|
||||
|
||||
@@ -8,7 +8,8 @@ go 1.26.4
|
||||
require (
|
||||
github.com/hanzoai/orm v0.6.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zap-proto/zip v1.6.0
|
||||
github.com/zap-proto/zip v1.8.3
|
||||
golang.org/x/crypto v0.53.0
|
||||
)
|
||||
|
||||
// Migration-only: linked solely in `go build -tags migration` so `iam2 compare`
|
||||
@@ -19,10 +20,24 @@ 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/hanzoai/sqlcipher v0.1.0
|
||||
github.com/hanzoai/sqlite v0.2.1
|
||||
github.com/luxfi/crypto v1.20.1
|
||||
github.com/luxwallet/connect/go v0.1.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/zap-proto/fiber/v3 v3.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
|
||||
@@ -33,45 +48,49 @@ require (
|
||||
github.com/gofiber/utils/v2 v2.0.4 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/rpc v1.2.1 // indirect
|
||||
github.com/hanzoai/dbx v1.16.0 // indirect
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0 // indirect
|
||||
github.com/hanzoai/sqlite v0.2.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/luxfi/accel v1.2.4 // indirect
|
||||
github.com/luxfi/cache v1.2.1 // indirect
|
||||
github.com/luxfi/codec v1.1.4 // indirect
|
||||
github.com/luxfi/container v0.0.4 // indirect
|
||||
github.com/luxfi/ids v1.2.10 // indirect
|
||||
github.com/luxfi/log v1.4.3 // indirect
|
||||
github.com/luxfi/math v1.4.1 // indirect
|
||||
github.com/luxfi/math/big v0.1.0 // indirect
|
||||
github.com/luxfi/metric v1.5.7 // indirect
|
||||
github.com/luxfi/mock v0.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.47 // indirect
|
||||
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.70.0 // indirect
|
||||
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
|
||||
github.com/zap-proto/go v1.3.0 // indirect
|
||||
github.com/zap-proto/http v0.2.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.1 // indirect
|
||||
)
|
||||
|
||||
// Local checkouts during the migration so iam2 stays in sync with patches
|
||||
// landing in orm and zip. Switch to pinned vX.Y.Z once the v2 surface
|
||||
// stabilises (Phase 1).
|
||||
replace (
|
||||
github.com/hanzoai/orm => ../orm
|
||||
github.com/zap-proto/zip => ../../zap-proto/zip
|
||||
)
|
||||
|
||||
@@ -2,18 +2,28 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
@@ -36,14 +46,24 @@ github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
|
||||
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
|
||||
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
|
||||
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
|
||||
github.com/hanzoai/dbx v1.16.0 h1:C8wsb9BIiit4nYnXizpcB4SyzVaepPkQFwq5i9fxAV0=
|
||||
github.com/hanzoai/dbx v1.16.0/go.mod h1:ynP6HSiDDoFZ8M3DC+XvSglBPFRygfTd/gjTWabh4yA=
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0 h1:vO2SD8dV0+H9WWCVKV9KHaWZq4yeMsZruohrsZN9448=
|
||||
github.com/hanzoai/kv-go/v9 v9.18.0/go.mod h1:S+Li20E6Bskpw6r+c8WWhfi4hCr8SVV32qPXO0wdl+E=
|
||||
github.com/hanzoai/orm v0.6.1 h1:PELYVy+kTVuA7hqn1y3IQqR1Q5cTk008Wh4CLn9Isok=
|
||||
github.com/hanzoai/orm v0.6.1/go.mod h1:7tXULhLKymkAwlC+jASS66tlLEzU2sdCXX1sRFPoAFs=
|
||||
github.com/hanzoai/sqlcipher v0.1.0 h1:V9gKG3ZltN2ZCteDrOnXWfOeEe/YDhhUm9AorQEAuBo=
|
||||
github.com/hanzoai/sqlcipher v0.1.0/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
|
||||
github.com/hanzoai/sqlite v0.2.1 h1:PqUty8+NhJsfwzT5K/U6vgFSIykM1vM0GMLeoH2KWio=
|
||||
github.com/hanzoai/sqlite v0.2.1/go.mod h1:SVhzKrbEovivr/sEaL/Wgw81a7Xfy6gSoOMzuRCvt7s=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -58,24 +78,53 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
|
||||
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
|
||||
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
|
||||
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
|
||||
github.com/luxfi/codec v1.1.4 h1:Yl8ZalMNkqo7cD6R9AjczAajkLOmsjyZ9+DASVYHrvg=
|
||||
github.com/luxfi/codec v1.1.4/go.mod h1:oGQ3j6E8c2P0pL0irYtWkrB1hmDUFIE0puXHK4gV5KI=
|
||||
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
|
||||
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
|
||||
github.com/luxfi/crypto v1.20.1 h1:d0/jW7vVVQZbeGJNVmtMKkrhjTM6BtqEOWH234iUghM=
|
||||
github.com/luxfi/crypto v1.20.1/go.mod h1:bLCBuIV/KDjPytld7jSYe1WbfWknPQXcivq88Qo96QU=
|
||||
github.com/luxfi/ids v1.2.10 h1:f1WILZE199ayMuqnEyB2WP1qfMZkmozOQXSVYtB3e5k=
|
||||
github.com/luxfi/ids v1.2.10/go.mod h1:QBIwy3OHvrtskbUqKh1+OYRa6PsyR7f7oNX33sOfK7w=
|
||||
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
|
||||
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
|
||||
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
|
||||
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
|
||||
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
|
||||
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
|
||||
github.com/luxfi/metric v1.5.7 h1:LoSPEUpak2SLcynF+LT2cXjl9ECp4nY+Lia9zudmDv4=
|
||||
github.com/luxfi/metric v1.5.7/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
|
||||
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||
github.com/luxwallet/connect/go v0.1.4 h1:Gmyl+MkrDxGI9jUjSzRt2yL/CL32apcLxVUdvoJdD7A=
|
||||
github.com/luxwallet/connect/go v0.1.4/go.mod h1:ReVK757g7VqTfcbUNg5SinpjBCzMgilEYm+Gux8tdmo=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 h1:yfQ2sO9WJXUAIUR+g7NUkxJSKCAFJcR5sUDu+ZmjTZI=
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -100,32 +149,78 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
|
||||
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
|
||||
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
|
||||
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
|
||||
github.com/zap-proto/http v0.2.0 h1:WiTqJ7Wh0O2qA3DNhvyi0b9F4j2wX8ctZDlW46WMxWQ=
|
||||
github.com/zap-proto/http v0.2.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
github.com/zap-proto/zip v1.8.3 h1:oSDtwtgOGaQJPwolZ/Ga4YRR/Mips+n6CpowX4V9BW4=
|
||||
github.com/zap-proto/zip v1.8.3/go.mod h1:TJ8ZwpwLQphqr1pYRr2cjzL8DbMmUljRKmrAPzM9S+4=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package applications is the Phase-1 typed CRUD surface for the `applications`
|
||||
// entity. Every operation is a zip typed handler (decode In -> run -> encode
|
||||
// Out) over hanzoai/orm and is owner-scoped by the (owner, name) natural key,
|
||||
// materialized as the orm id "<owner>/<name>". The same In/Out types back both
|
||||
// the REST route and the MCP tools/call projection zip derives from them, so
|
||||
// identity arguments travel in the typed request, not in ad-hoc path parsing.
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// authorizeOrganization gates the Organization an application will SERVE (the
|
||||
// tenant a credential minted through it lands in), not just its registry Owner:
|
||||
// the op-invoke authz hook authorizes the top-level Owner, but Organization is a
|
||||
// separate field that a tenant admin could otherwise set to the reserved admin
|
||||
// org (a SuperAdmin-minting app) or to a victim tenant. On a gated HTTP request
|
||||
// the Guard attached a Principal; a non-super may point an app only at its OWN
|
||||
// org. A server-internal call (bootstrap/seed) carries no Principal and is
|
||||
// trusted, so an unauthenticated context is left to the surrounding trust
|
||||
// boundary rather than blocked here.
|
||||
func authorizeOrganization(ctx context.Context, in *schema.Application) error {
|
||||
if in.Organization == "" {
|
||||
return nil // an org-less app mints no cross-tenant/SuperAdmin identity
|
||||
}
|
||||
p, ok := authz.From(ctx)
|
||||
if !ok {
|
||||
return nil // server-internal (no principal) — trusted caller
|
||||
}
|
||||
if !authz.CanSetOrg(p, in.Organization) {
|
||||
return zip.ErrForbidden("not authorized to set the application organization to " + in.Organization)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureClientIdUnique rejects a create/update whose clientId is already held by a
|
||||
// DIFFERENT application (any owner). clientId is the GLOBAL key the mint and Basic-auth
|
||||
// resolvers authenticate against, so it must be unique across every owner, not merely
|
||||
// within one — otherwise a tenant could register a row whose clientId collides with a
|
||||
// platform console's and (on a backend whose duplicate-row order is unspecified) shadow
|
||||
// it. A JSON-document store has no per-field column to carry a DB UNIQUE index, so the
|
||||
// invariant is enforced here at the write, exactly as the (owner,name) natural key is.
|
||||
// An empty clientId cannot collide (a public app authenticates no confidential grant);
|
||||
// the self-row (same owner,name) is skipped so an update that keeps its own clientId is
|
||||
// never a self-collision.
|
||||
func ensureClientIdUnique(ctx context.Context, db orm.DB, clientId, owner, name string) error {
|
||||
if clientId == "" {
|
||||
return nil
|
||||
}
|
||||
existing, err := store.ListApplicationsByClientId(ctx, db, clientId)
|
||||
if err != nil {
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
for _, a := range existing {
|
||||
if a.Owner != owner || a.Name != name {
|
||||
return zip.ErrConflict("clientId already in use: " + clientId)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appID is the owner-scoped natural key "<owner>/<name>" — the single source
|
||||
// of an application's orm id. Every handler routes through it so reads and
|
||||
// writes address the exact same row.
|
||||
func appID(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// ApplicationRef identifies one application by its owner-scoped natural key.
|
||||
// It is the input for the get and delete operations.
|
||||
type ApplicationRef struct {
|
||||
Owner string `json:"owner" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// ApplicationQuery filters applications by owner for the list operation.
|
||||
type ApplicationQuery struct {
|
||||
Owner string `json:"owner" validate:"required"`
|
||||
}
|
||||
|
||||
// ApplicationListResult wraps the applications owned by one owner, newest
|
||||
// first.
|
||||
type ApplicationListResult struct {
|
||||
Applications []*schema.Application `json:"applications"`
|
||||
}
|
||||
|
||||
// DeleteResult reports the outcome of a delete operation.
|
||||
type DeleteResult struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// Route registers the applications CRUD surface on app, closing over db. Reads
|
||||
// use GET, create POST, update PUT, delete DELETE — every one a zip typed
|
||||
// handler.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
zip.Get(app, "/v1/iam/applications", listApplications(db),
|
||||
zip.WithSummary("List applications for an owner"), zip.WithTags("applications"))
|
||||
zip.Get(app, "/v1/iam/application", getApplication(db),
|
||||
zip.WithSummary("Get one application by owner and name"), zip.WithTags("applications"))
|
||||
zip.Post(app, "/v1/iam/application", Create(db),
|
||||
zip.WithSummary("Create an application"), zip.WithTags("applications"))
|
||||
zip.Put(app, "/v1/iam/application", Update(db),
|
||||
zip.WithSummary("Update an application"), zip.WithTags("applications"))
|
||||
zip.Delete(app, "/v1/iam/application", deleteApplication(db),
|
||||
zip.WithSummary("Delete an application"), zip.WithTags("applications"))
|
||||
}
|
||||
|
||||
// listApplications returns every application owned by in.Owner, ordered by
|
||||
// creation time descending.
|
||||
func listApplications(db orm.DB) zip.TypedHandler[ApplicationQuery, ApplicationListResult] {
|
||||
return func(ctx context.Context, in *ApplicationQuery) (*ApplicationListResult, error) {
|
||||
if in.Owner == "" {
|
||||
return nil, zip.ErrBadRequest("owner is required")
|
||||
}
|
||||
apps, err := orm.TypedQuery[schema.Application](db).
|
||||
Filter("Owner=", in.Owner).
|
||||
Order("-CreatedTime").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
for i, app := range apps {
|
||||
apps[i] = app.Mask() // never emit clientSecret in a list response
|
||||
}
|
||||
return &ApplicationListResult{Applications: apps}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// getApplication returns the application at (in.Owner, in.Name).
|
||||
func getApplication(db orm.DB) zip.TypedHandler[ApplicationRef, schema.Application] {
|
||||
return func(ctx context.Context, in *ApplicationRef) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
app, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return app.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create persists a new application under (in.Owner, in.Name), rejecting a
|
||||
// collision on that owner-scoped key. Exported so the Casdoor add-application
|
||||
// alias reuses this exact logic (no duplication); the REST route and the alias
|
||||
// share the one create path.
|
||||
func Create(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
|
||||
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if err := authorizeOrganization(ctx, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
// Owner-scoped uniqueness: (owner, name) must be free.
|
||||
if _, err := orm.Get[schema.Application](db, id); err == nil {
|
||||
return nil, zip.ErrConflict("application already exists: " + id)
|
||||
} else if !errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// Global uniqueness: clientId is the mint/Basic-auth resolution key, so it must
|
||||
// be free across ALL owners — the invariant the confidential-client gates rely on.
|
||||
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wire the decoded entity to db under its natural key and persist.
|
||||
in.Init(db)
|
||||
in.SetId(id)
|
||||
if err := in.Create(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return in.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update overwrites the application at (in.Owner, in.Name), preserving its
|
||||
// immutable creation metadata. The (owner, name) identity is fixed by the record,
|
||||
// not editable through the body. Exported so the Casdoor update-application alias
|
||||
// reuses this exact logic (no duplication).
|
||||
func Update(db orm.DB) zip.TypedHandler[schema.Application, schema.Application] {
|
||||
return func(ctx context.Context, in *schema.Application) (*schema.Application, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if err := authorizeOrganization(ctx, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
existing, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// Global clientId uniqueness (see Create): an update may keep its own clientId
|
||||
// but must never steal another app's.
|
||||
if err := ensureClientIdUnique(ctx, db, in.ClientId, in.Owner, in.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
in.Init(db)
|
||||
in.SetId(id)
|
||||
in.CreatedTime = existing.CreatedTime
|
||||
in.CreatedAt = existing.CreatedAt
|
||||
if err := in.Update(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return in.Mask(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// deleteApplication removes the application at (in.Owner, in.Name).
|
||||
// Delete exposes the delete handler so the Casdoor `delete-application` verb alias
|
||||
// (internal/compat) can reuse it — one delete path, wrapped in the compat envelope.
|
||||
func Delete(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] { return deleteApplication(db) }
|
||||
|
||||
func deleteApplication(db orm.DB) zip.TypedHandler[ApplicationRef, DeleteResult] {
|
||||
return func(ctx context.Context, in *ApplicationRef) (*DeleteResult, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
id := appID(in.Owner, in.Name)
|
||||
|
||||
app, err := orm.Get[schema.Application](db, id)
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("application not found: " + id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
if err := app.Delete(); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteResult{Deleted: true}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package applications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
func memDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "apptest.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// The clientId global-uniqueness guard on create: a create may not take a clientId
|
||||
// already held by a DIFFERENT (owner,name), so a tenant can never register a row that
|
||||
// collides with a platform console's confidential-client key. This is the store-layer
|
||||
// enforcement of the invariant the mint/Basic-auth gates rely on (a JSON-document
|
||||
// store has no column for a DB UNIQUE index). A background ctx carries no principal,
|
||||
// so authorizeOrganization is the trusted server-internal path and the guard is
|
||||
// exercised in isolation.
|
||||
func TestCreate_RejectsDuplicateClientId(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
create := Create(db)
|
||||
|
||||
// The legit platform console.
|
||||
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
|
||||
t.Fatalf("seed console: %v", err)
|
||||
}
|
||||
|
||||
// A tenant tries to register a DIFFERENT (owner,name) with the SAME clientId.
|
||||
if _, err := create(ctx, &schema.Application{Owner: "evil", Name: "evil-console", ClientId: "hanzo-console"}); err == nil {
|
||||
t.Fatal("HIGH REOPENED: a colliding clientId was accepted on create")
|
||||
}
|
||||
|
||||
// A distinct clientId under a tenant is fine (no false positive).
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
|
||||
t.Fatalf("a distinct clientId must be accepted: %v", err)
|
||||
}
|
||||
|
||||
// A public app (no clientId) never collides with another public app.
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-a"}); err != nil {
|
||||
t.Fatalf("empty clientId #1: %v", err)
|
||||
}
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "pub-b"}); err != nil {
|
||||
t.Fatalf("empty clientId #2 must not collide with #1: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update may keep its OWN clientId (the self-row is skipped, never a self-collision)
|
||||
// but must not steal another app's.
|
||||
func TestUpdate_ClientIdCollision(t *testing.T) {
|
||||
db := memDB(t)
|
||||
ctx := context.Background()
|
||||
create, update := Create(db), Update(db)
|
||||
|
||||
if _, err := create(ctx, &schema.Application{Owner: "admin", Name: "hanzo-console", ClientId: "hanzo-console"}); err != nil {
|
||||
t.Fatalf("seed console: %v", err)
|
||||
}
|
||||
if _, err := create(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app"}); err != nil {
|
||||
t.Fatalf("seed tenant app: %v", err)
|
||||
}
|
||||
|
||||
// hanzo-app keeps its own clientId on update — allowed (self-row skipped).
|
||||
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-app", DisplayName: "renamed"}); err != nil {
|
||||
t.Fatalf("keeping own clientId on update must be allowed: %v", err)
|
||||
}
|
||||
|
||||
// hanzo-app tries to STEAL the console's clientId — rejected.
|
||||
if _, err := update(ctx, &schema.Application{Owner: "hanzo", Name: "hanzo-app", ClientId: "hanzo-console"}); err == nil {
|
||||
t.Fatal("HIGH REOPENED: an update stole another app's clientId")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package auditlogs serves the IAM v2 CRUD surface for the `audit_logs` entity:
|
||||
// an append-only action record owner-scoped by (owner, name). Every operation
|
||||
// is a typed zip handler over hanzoai/orm; the orm string key is "owner/name".
|
||||
// Reads scope to one owner (organization); writes address one log by its
|
||||
// (owner, name) key. Rows are written once at request time — the update path
|
||||
// exists only for administrative correction, never for normal operation.
|
||||
package auditlogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Handler binds the audit-log operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
// Route registers the audit-log CRUD routes on app against db.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/audit-logs", h.List, zip.WithSummary("List audit logs for an owner"), zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs", h.Create, zip.WithSummary("Create an audit log"), zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs/get", h.Get, zip.WithSummary("Get one audit log"), zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs/update", h.Update, zip.WithSummary("Update an audit log"), zip.WithTags("audit-logs"))
|
||||
zip.Post(app, "/v1/iam/audit-logs/delete", h.Delete, zip.WithSummary("Delete an audit log"), zip.WithTags("audit-logs"))
|
||||
}
|
||||
|
||||
// Ref addresses one audit log by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Input is the writable projection of an audit log (the v1 add/update-record
|
||||
// body). It keeps the wire contract clean of the orm.Model bookkeeping fields
|
||||
// and of the v1 integer surrogate id, which the orm string key supersedes.
|
||||
type Input struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
Organization string `json:"organization"`
|
||||
ClientIp string `json:"clientIp"`
|
||||
User string `json:"user"`
|
||||
Method string `json:"method"`
|
||||
RequestUri string `json:"requestUri"`
|
||||
Action string `json:"action"`
|
||||
Language string `json:"language"`
|
||||
Object string `json:"object"`
|
||||
Response string `json:"response"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
IsTriggered bool `json:"isTriggered"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of audit logs, newest first.
|
||||
type ListOutput struct {
|
||||
AuditLogs []*schema.AuditLog `json:"auditLogs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// apply copies the mutable domain fields of an Input onto an audit log. The
|
||||
// identity fields (owner, name) and the created stamp are set only on Create,
|
||||
// never overwritten by an update.
|
||||
func apply(dst *schema.AuditLog, in *Input) {
|
||||
dst.Organization = in.Organization
|
||||
dst.ClientIp = in.ClientIp
|
||||
dst.User = in.User
|
||||
dst.Method = in.Method
|
||||
dst.RequestUri = in.RequestUri
|
||||
dst.Action = in.Action
|
||||
dst.Language = in.Language
|
||||
dst.Object = in.Object
|
||||
dst.Response = in.Response
|
||||
dst.StatusCode = in.StatusCode
|
||||
dst.IsTriggered = in.IsTriggered
|
||||
}
|
||||
|
||||
// List returns the audit logs for one owner, newest first. An empty owner lists
|
||||
// every log (the unscoped admin view).
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
q := orm.TypedQuery[schema.AuditLog](h.db)
|
||||
if in.Owner != "" {
|
||||
q = q.Filter("owner", in.Owner)
|
||||
}
|
||||
logs, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &ListOutput{AuditLogs: logs, Total: len(logs)}, nil
|
||||
}
|
||||
|
||||
// Get returns one audit log addressed by (owner, name).
|
||||
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.AuditLog, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Create persists a new audit log. It rejects a duplicate (owner, name).
|
||||
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.AuditLog, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
switch _, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("audit log already exists")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
log := orm.New[schema.AuditLog](h.db)
|
||||
log.Owner = in.Owner
|
||||
log.Name = in.Name
|
||||
log.CreatedTime = in.CreatedTime
|
||||
if log.CreatedTime == "" {
|
||||
log.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
apply(log, in)
|
||||
log.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := log.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Update mutates an existing audit log in place. Identity and created stamp are
|
||||
// immutable; a missing log is a 404. Audit rows are append-only in normal
|
||||
// operation — this path is for administrative correction only.
|
||||
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.AuditLog, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
apply(log, in)
|
||||
if err := log.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Delete removes one audit log addressed by (owner, name).
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
log, err := orm.Get[schema.AuditLog](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := log.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return zip.ErrNotFound("audit log not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package authz is the IAM v2 authorization seam in front of the Phase-1 entity
|
||||
// CRUD, which is otherwise unauthenticated — the door an attacker would walk
|
||||
// through to overwrite an admin-owned signing cert and forge tokens. It is two
|
||||
// orthogonal decisions, never braided:
|
||||
//
|
||||
// - AUTHENTICATION — the Guard middleware, mounted ONCE via app.Use, AFTER the
|
||||
// public group and BEFORE the authed routes. Public (pre-authentication)
|
||||
// routes are registered first, so a matched one terminates fiber's middleware
|
||||
// walk and the Guard never runs on it — public vs gated is structural (which
|
||||
// group a route is on), not an allow-list. Every request the Guard wraps must
|
||||
// carry a verified bearer; the resolved Principal is attached to the request
|
||||
// context for the authorization decision and audit. Fails closed (401).
|
||||
//
|
||||
// - AUTHORIZATION — the Authorize hook, installed ONCE via app.Authorize. It
|
||||
// runs at the framework's op-invoke seam, on the DECODED typed input the
|
||||
// handler will act on, for REST and MCP alike. The value it authorizes is by
|
||||
// construction the value the handler binds: there is no second parse of the
|
||||
// body for it to diverge from. Fails closed (403).
|
||||
//
|
||||
// Splitting the two removes the defect a single body-reparsing middleware had:
|
||||
// authorizing a target extracted from the raw bytes divergently from where the
|
||||
// handler binds it. A write's target now comes from the one decode the handler
|
||||
// itself runs on. A read's target rides in the query string (a GET has no body
|
||||
// for the op seam to decode), so the Guard authorizes reads there; a read invoked
|
||||
// over MCP DOES decode a target into its input, and the op seam authorizes that.
|
||||
//
|
||||
// Three scopes, never conflated (conflation is privilege escalation):
|
||||
//
|
||||
// - SuperAdmin — the principal's organization is the reserved "admin" org.
|
||||
// The ONLY cross-tenant scope. Required for every write to a platform-owned
|
||||
// (admin/built-in) resource: the signing-cert poisoning gate, admin-scoped
|
||||
// application/provider registration, every reserved surface.
|
||||
// - Org admin — IsAdmin, scoped to its OWN organization. Manages every
|
||||
// resource its org owns; never another org's, never a platform-owned one.
|
||||
// - Regular user — self-service only: reading its own user record.
|
||||
//
|
||||
// One predicate governs SuperAdmin everywhere: the principal's organization is
|
||||
// "admin". That organization comes from the token SUBJECT — the authenticated
|
||||
// principal's own owner/name — never from the token's `owner`/`organization`
|
||||
// claims. Those name the APPLICATION's org and diverge from the user's org for a
|
||||
// shared app, so trusting them would let a tenant user sign in through a shared
|
||||
// admin-org app and read as SuperAdmin. Authenticity, expiry, algorithm, and
|
||||
// signing-key trust are delegated to the same oidc.VerifyToken every protected
|
||||
// route already uses; the org-admin flag comes from the loaded user record, the
|
||||
// authoritative source (it is not a token claim).
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/oidc"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// adminOrg is the reserved organization whose membership IS SuperAdmin — the one
|
||||
// cross-tenant scope, the one predicate. The broader reserved-owner set
|
||||
// {admin, built-in} the poisoning gate protects lives in ONE place,
|
||||
// store.IsSigningCertOwner, shared with the token verifier and the JWKS.
|
||||
const adminOrg = "admin"
|
||||
|
||||
// Principal is the identity a gated request acts as, resolved from a verified
|
||||
// bearer. Org is the tenant (the authenticated principal's own org, from the
|
||||
// subject); User is its name within that org (empty for a machine token); Admin
|
||||
// is the org-admin flag; Super is the SuperAdmin predicate (Org == adminOrg).
|
||||
type Principal struct {
|
||||
Org string
|
||||
User string
|
||||
// App is the application NAME when the request authenticated as a confidential
|
||||
// client (client_secret_basic), and "" for every human. An app principal is
|
||||
// never Admin and never Super — its whole authority is its capability allowlist
|
||||
// (cap.go), so a leaked client credential can neither read another tenant nor
|
||||
// touch signing material.
|
||||
App string
|
||||
// AppOwner is the OWNING organization of that application row — "admin"/"built-in"
|
||||
// for a platform app, the tenant's own org for a customer app. It is NOT App's
|
||||
// served Organization. A capability (cap.go Allowed) is granted ONLY when this is
|
||||
// a reserved platform signing owner, so a tenant that registers an app whose NAME
|
||||
// (or clientId) collides with a platform console inherits none of its authority:
|
||||
// the allowlist keys on the name, and the owner-pin binds that name to the
|
||||
// platform. Empty for every human.
|
||||
AppOwner string
|
||||
Admin bool
|
||||
Super bool
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// From returns the Principal the Guard attached to ctx for a gated request, and
|
||||
// whether one is present (public routes carry none).
|
||||
func From(ctx context.Context) (*Principal, bool) {
|
||||
p, ok := ctx.Value(ctxKey{}).(*Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Scope resolves the owner a listing is bound to: a SuperAdmin lists the owner
|
||||
// it asks for (empty = every tenant), anyone else lists only its own org. The
|
||||
// org comes from the verified bearer, so a request parameter can never widen a
|
||||
// read beyond the caller's authority — the one value authorized is the one value
|
||||
// queried. Every owner-scoped lister resolves its owner here.
|
||||
func Scope(ctx context.Context, owner string) (string, error) {
|
||||
p, ok := From(ctx)
|
||||
if !ok {
|
||||
return "", zip.ErrForbidden("no principal")
|
||||
}
|
||||
if p.Super {
|
||||
return owner, nil
|
||||
}
|
||||
return p.Org, nil
|
||||
}
|
||||
|
||||
// Can reports whether the ctx principal may perform `method` on the entity's
|
||||
// (owner, name) — the SAME policy the op-invoke seam (Authorize) applies, exposed
|
||||
// for a RAW handler that does not pass through app.Authorize (e.g. SCIM, whose
|
||||
// writes call the CRUD directly). Owner-pinning via Scope alone is NOT sufficient
|
||||
// for a write: it enforces tenant isolation but not the admin/self clause, so a
|
||||
// raw handler MUST call this. Fails closed when no principal is present.
|
||||
func Can(ctx context.Context, method, entity, owner, name string) bool {
|
||||
p, ok := From(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return authorize(p, method, entity, owner, name)
|
||||
}
|
||||
|
||||
// IsSuper reports whether the ctx principal is a SuperAdmin — used by a raw
|
||||
// handler to gate a privileged field (e.g. provision-don't-promote: only a super
|
||||
// may set isAdmin). Fails closed when no principal is present.
|
||||
func IsSuper(ctx context.Context) bool {
|
||||
p, ok := From(ctx)
|
||||
return ok && p.Super
|
||||
}
|
||||
|
||||
// CanSetOrg reports whether principal p may point a resource at organization
|
||||
// `org` — the tenant an application SERVES (the org every credential minted
|
||||
// through that app lands in), authorized EXACTLY as an owner target through the
|
||||
// one policy: a SuperAdmin may set any org; anyone else only their OWN org, never
|
||||
// a reserved platform org (admin/built-in — the SuperAdmin/signing vector) nor
|
||||
// another tenant (cross-tenant mint). It is the gate the application create/update
|
||||
// path applies to the Organization FIELD — closing the hole where authorizing only
|
||||
// the top-level Owner let a tenant admin register an app whose Organization named
|
||||
// the admin org (SuperAdmin) or a victim tenant. Fails closed on a nil principal.
|
||||
func CanSetOrg(p *Principal, org string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return authorize(p, "POST", "applications", org, "")
|
||||
}
|
||||
|
||||
// Optional resolves the Principal a PUBLIC route's caller happens to carry, or
|
||||
// nil when the request is anonymous or its bearer does not verify. The Guard
|
||||
// admits a public path WITHOUT resolving a principal (a browser must reach the
|
||||
// pre-auth surface before it holds a token), so From() is empty there — a public
|
||||
// handler that legitimately honors an authenticated caller resolves it here.
|
||||
//
|
||||
// It is the same fail-closed resolution every gated route runs (one verifier,
|
||||
// one user load, one revocation check); only the outcome differs — a bad bearer
|
||||
// is nil rather than a 401, because the caller's flow continues anonymously.
|
||||
// A handler must therefore treat a nil Principal as "anonymous", never as an
|
||||
// error, and must never widen authority on the strength of this alone: it proves
|
||||
// only WHO the caller is, not that the caller INTENDED this request (the wallet
|
||||
// link branch pairs it with a same-site check for exactly that reason).
|
||||
func Optional(c *zip.Ctx, db orm.DB) *Principal {
|
||||
p, err := principal(c, db)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Fail-closed reasons. The Guard collapses all of them to one opaque 401 so a
|
||||
// prober cannot tell a bad signature from an expired token from a revoked user.
|
||||
var (
|
||||
errNoBearer = errors.New("authz: no bearer")
|
||||
errNoSubject = errors.New("authz: token subject carries no org")
|
||||
errRevoked = errors.New("authz: principal is forbidden or deleted")
|
||||
)
|
||||
|
||||
// isRead reports whether a method addresses its target through the query string
|
||||
// rather than a body: a GET (or HEAD) has no body for the op-invoke seam to
|
||||
// decode, so its target is authorized in the Guard. Every other method carries a
|
||||
// body decoded once by the op and is authorized at that seam.
|
||||
func isRead(method string) bool { return method == "GET" || method == "HEAD" }
|
||||
|
||||
// ReadTarget extracts the (owner, name) a GET addresses, from the query string.
|
||||
// A native typed read files them as `?owner=&name=`; the Casdoor compat verbs
|
||||
// (get-user, get-organization, …) file them as `?id=<owner>/<name>`. Explicit
|
||||
// owner/name win; the id split is a fallback only when owner is absent, so this
|
||||
// can only make an id-based read's authorization MORE precise than the empty
|
||||
// target it resolves to today (which fail-closed denies every non-super). It
|
||||
// never widens: the tenant rule still pins owner to the principal's org, and the
|
||||
// handler independently re-scopes the query owner through Scope, so a request
|
||||
// that spells one owner in `?owner` and another in `?id` cannot read across
|
||||
// tenants — the authorized owner and the queried owner are both pinned.
|
||||
//
|
||||
// It is exported so the compat read aliases resolve their target through the
|
||||
// SAME function the Guard authorizes with: one extraction, so a handler can
|
||||
// never address a row the Guard did not authorize.
|
||||
func ReadTarget(c *zip.Ctx) (owner, name string) {
|
||||
owner, name = c.Query("owner"), c.Query("name")
|
||||
if owner == "" {
|
||||
if o, n, ok := strings.Cut(c.Query("id"), "/"); ok && o != "" {
|
||||
return o, n
|
||||
}
|
||||
}
|
||||
return owner, name
|
||||
}
|
||||
|
||||
// handlerAuthorizedPrefixes are path subtrees whose target rides in the PATH, not
|
||||
// the query — the Guard authenticates them (a bearer is still required) but does
|
||||
// NOT pre-authorize the read; the handler authorizes on the path id via
|
||||
// authz.Scope. SCIM (RFC 7644, /v1/iam/scim/v2/Users/{id}) is path-targeted, so it
|
||||
// belongs here. This is the read analogue of a write deferring to the op-invoke
|
||||
// seam — the target is authorized where it is bound, not guessed from the query.
|
||||
// get-organization-projects (and its workspace tier, get-organization-workspaces)
|
||||
// is the Casdoor read verb whose target rides in ?organization= (the
|
||||
// ScopeSwitcher's project/workspace list), not ?owner=/?id=/the path, so the Guard
|
||||
// cannot pre-authorize it generically; the handler scopes it through authz.Scope
|
||||
// instead (the read analogue of SCIM's path-targeted authorization).
|
||||
var handlerAuthorizedPrefixes = []string{"/v1/iam/scim/", "/v1/iam/get-organization-projects", "/v1/iam/get-organization-workspaces", "/v1/iam/service-accounts", "/v1/iam/memberships"}
|
||||
|
||||
// pathAuthorized reports whether path is under a handler-authorized subtree.
|
||||
func pathAuthorized(path string) bool {
|
||||
for _, p := range handlerAuthorizedPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Guard is the AUTHENTICATION middleware. Mount it via app.Use AFTER the public
|
||||
// group and BEFORE the authed routes: the public (pre-authentication) routes are
|
||||
// registered first, so a matched public route terminates fiber's middleware walk
|
||||
// and the Guard never runs on it — public vs gated is decided structurally, by
|
||||
// which group a route is registered on, not by an allow-list. Every route the
|
||||
// Guard does wrap — the typed CRUD handlers and the framework's /mcp and /openapi
|
||||
// surfaces alike — requires a valid bearer (401 otherwise) whose Principal is
|
||||
// attached to the request context for the authorization hook downstream. A read's
|
||||
// authorization target rides in the query string, so reads are authorized here; a
|
||||
// write's rides in the body, decoded once by the op and authorized at the op-invoke
|
||||
// seam (Authorize) on that exact decoded value — this middleware never re-parses a
|
||||
// write body, which is what let the old target extraction diverge from execution.
|
||||
func Guard(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
p, err := principal(c, db)
|
||||
if err != nil {
|
||||
return zip.ErrUnauthorized("authentication required")
|
||||
}
|
||||
// A path-targeted resource (SCIM: /Users/{id}) carries its target in the
|
||||
// PATH, not the query — so, like a write whose target rides in the body, the
|
||||
// Guard authenticates (bearer required, principal attached) and the handler
|
||||
// authorizes via authz.Scope on the path id. The Guard never authorizes an
|
||||
// empty query target for these (which would fail-closed deny every non-super
|
||||
// before the handler could scope). Every other read is authorized here.
|
||||
if !pathAuthorized(c.Path()) {
|
||||
rOwner, rName := ReadTarget(c)
|
||||
if isRead(c.Method()) && !authorize(p, c.Method(), entityOf(c.Path()), rOwner, rName) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
}
|
||||
c.SetContext(context.WithValue(c.Context(), ctxKey{}, p))
|
||||
return c.Continue()
|
||||
}
|
||||
}
|
||||
|
||||
// Authorize is the AUTHORIZATION hook, installed via app.Authorize so the
|
||||
// framework runs it at every typed op's invoke seam — after the request is
|
||||
// decoded into its typed In and validated, before the handler runs, for REST and
|
||||
// MCP alike. It authorizes the DECODED target: the exact (owner, name) the
|
||||
// handler will bind, read from the same struct the handler runs on, so the value
|
||||
// authorized cannot diverge from the value written.
|
||||
//
|
||||
// A REST read carries its target in the query string, not the body, so its
|
||||
// decoded In is empty and the Guard already authorized it there — such a call is
|
||||
// admitted here (owner == ""). Every write, and any read invoked over MCP (whose
|
||||
// arguments DO decode a target into In), is authorized against authorize().
|
||||
//
|
||||
// Every typed op is authed by construction — the public surface is raw handlers
|
||||
// in the pre-Guard group, none of which is a typed op — so this hook needs no
|
||||
// public bypass: whenever it runs, the Guard has already run and attached a
|
||||
// principal (over REST, before the op; over MCP, on the gated /mcp route).
|
||||
func Authorize(ctx context.Context, op zip.Op, in any) error {
|
||||
owner, name := decodedTarget(in)
|
||||
if owner == "" && isRead(op.Method) {
|
||||
return nil // REST read: target rode in the query, authorized by the Guard
|
||||
}
|
||||
p, present := From(ctx)
|
||||
if !present {
|
||||
return zip.ErrForbidden("forbidden") // gated op with no principal: fail closed
|
||||
}
|
||||
if !authorize(p, op.Method, entityOf(op.Path), owner, name) {
|
||||
return zip.ErrForbidden("forbidden")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorize is the pure authorization decision: may p act on a resource owned by
|
||||
// `owner` (named `name`) on the given entity? The order IS the policy:
|
||||
//
|
||||
// 1. SuperAdmin may do anything — the only cross-tenant scope.
|
||||
// 2. A platform-owned resource — one under a RESERVED system org (store.IsReservedOrg:
|
||||
// admin/built-in, the signing owners, PLUS "app", the service-principal org) — is
|
||||
// writable only by a SuperAdmin. This single rule is the signing-cert poisoning
|
||||
// gate, the admin-scoped app/provider registration gate, the built-in-org gap, AND
|
||||
// the service-org ("app") consistency the self-service surfaces already enforce, 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; and no capability app nor "app"-org
|
||||
// admin can land a user under owner="app" (a platform identity) — the raw CRUD now
|
||||
// consults the SAME predicate signup/onboarding do, so the reserved set never
|
||||
// drifts between surfaces.
|
||||
// 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.IsReservedOrg(owner) {
|
||||
// The ONE exception to the reserved-owner gate is the tenant registry: every
|
||||
// organization row is filed under the admin owner, but an org row is the
|
||||
// TENANT'S own record, not platform trust material — a tenant reads its own
|
||||
// org, its admin edits it, and an org-admin-capable confidential client
|
||||
// manages orgs during onboarding (v1 requireAppCapability(CapOrgAdmin)).
|
||||
// Certs, applications, providers, and users under a reserved owner
|
||||
// (admin/built-in/app) stay SuperAdmin-only.
|
||||
if entity != "organizations" {
|
||||
return false
|
||||
}
|
||||
if p.App != "" {
|
||||
return Allowed(p, CapOrgAdmin)
|
||||
}
|
||||
return name == p.Org && (isRead(method) || p.Admin)
|
||||
}
|
||||
// A confidential client's authority is its capability allowlist and nothing
|
||||
// else — never Super, never Admin; an unmapped entity or unset allowlist denies.
|
||||
if p.App != "" {
|
||||
return Allowed(p, capFor(entity))
|
||||
}
|
||||
if owner == "" || owner != p.Org {
|
||||
return false
|
||||
}
|
||||
if p.Admin {
|
||||
return true
|
||||
}
|
||||
return method == "GET" && entity == "users" && name != "" && name == p.User
|
||||
}
|
||||
|
||||
// owned is implemented by a typed input whose authorization target is NOT its
|
||||
// top-level Owner/Name. The user create/update body nests the record under
|
||||
// `user`, so its owner is in.User.Owner, not a top-level field; its AuthzTarget
|
||||
// returns exactly what the handler binds — the handler calls the same method — so
|
||||
// the value authorized is by construction the value written. Any future input
|
||||
// that nests its owner implements this too: it is the ONE contract for nesting,
|
||||
// so the seam never guesses which field the handler uses and never mistakes a
|
||||
// read-only enrichment sub-struct (e.g. an application's resolved certObj, which
|
||||
// carries its OWN owner) for the target.
|
||||
type owned interface {
|
||||
AuthzTarget() (owner, name string)
|
||||
}
|
||||
|
||||
// decodedTarget returns the (owner, name) a decoded request addresses — exactly
|
||||
// the values the handler will bind, read from the SAME decoded struct the handler
|
||||
// runs on, so there is no second parse to diverge from. An input that nests its
|
||||
// owner declares it via owned; every other input files its owner at the top level
|
||||
// (directly, or promoted from an embedded record), read reflectively so no entity
|
||||
// needs bespoke wiring and an attacker-supplied nested sub-struct is never a
|
||||
// target.
|
||||
func decodedTarget(in any) (owner, name string) {
|
||||
if o, ok := in.(owned); ok {
|
||||
return o.AuthzTarget()
|
||||
}
|
||||
v := reflect.ValueOf(in)
|
||||
for v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return "", ""
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
if v.Kind() != reflect.Struct {
|
||||
return "", ""
|
||||
}
|
||||
return stringField(v, "Owner"), stringField(v, "Name")
|
||||
}
|
||||
|
||||
// stringField returns the string value of the named field (traversing embedded
|
||||
// anonymous fields via FieldByName), or "" when the field is absent or not a
|
||||
// string. FieldByName does not descend named sub-fields, so it reads the record's
|
||||
// own owner, never one nested under an unrelated field.
|
||||
func stringField(v reflect.Value, name string) string {
|
||||
f := v.FieldByName(name)
|
||||
if f.IsValid() && f.Kind() == reflect.String {
|
||||
return f.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// principal resolves the verified bearer into a Principal, failing closed on a
|
||||
// missing/malformed/expired/wrong-key token (oidc.VerifyToken enforces the
|
||||
// algorithm allowlist and trusted signing-cert resolution), a subject with no
|
||||
// org, a store error, or a forbidden/deleted user. Org, Admin, and Super are
|
||||
// read from the LOADED user record — authoritative — never from the token
|
||||
// claims: SuperAdmin is a real, live member of the admin org, not a subject that
|
||||
// merely names one. A subject with no user row (a client_credentials machine
|
||||
// token, or a since-deleted user) authenticates but carries no admin or
|
||||
// SuperAdmin authority and no self-service identity — org-scoped only, which on
|
||||
// the raw CRUD authorizes to nothing until a later phase grants machine
|
||||
// identities explicit scope. This closes the phantom-admin subject: a token for
|
||||
// "admin/<nobody>" resolves to no authority, not SuperAdmin.
|
||||
func principal(c *zip.Ctx, db orm.DB) (*Principal, error) {
|
||||
if p, ok := app(c, db); ok {
|
||||
return p, nil
|
||||
}
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return nil, errNoBearer
|
||||
}
|
||||
ctx := c.Context()
|
||||
claims, err := oidc.VerifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The subject is "<owner>/<name>": the principal's OWN org and name, set
|
||||
// server-side at mint and signed. Never the `owner` claim (the app's org).
|
||||
owner, name, _ := strings.Cut(claims.Subject, "/")
|
||||
if owner == "" {
|
||||
return nil, errNoSubject
|
||||
}
|
||||
u, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return nil, err // fail closed: cannot establish the principal
|
||||
}
|
||||
if u != nil {
|
||||
if u.IsForbidden || u.IsDeleted {
|
||||
return nil, errRevoked
|
||||
}
|
||||
return &Principal{Org: u.Owner, User: u.Name, Admin: u.IsAdmin, Super: u.Owner == adminOrg}, nil
|
||||
}
|
||||
return &Principal{Org: owner}, nil
|
||||
}
|
||||
|
||||
// app resolves an `Authorization: Basic <clientId>:<clientSecret>` credential into
|
||||
// a confidential-client Principal — the transport every live server-side consumer
|
||||
// authenticates with (RFC 6749 §2.3.1 client_secret_basic; cloud reads
|
||||
// IAM_MINT_CLIENT_ID/SECRET and sends exactly this). The application NAME is the
|
||||
// identity, because the capability allowlists key on the name.
|
||||
//
|
||||
// It is deliberately NOT an authority: the returned Principal is never Admin and
|
||||
// never Super, so the ONLY thing it can do is what its name is allowlisted for
|
||||
// (authorize → Allowed). This is what keeps the v1 "every confidential client is a
|
||||
// global admin" hole closed as the transport is re-added.
|
||||
//
|
||||
// Fail-closed: an unparseable header, an unknown clientId, an application with no
|
||||
// registered secret, an empty presented secret (a public client must never
|
||||
// authenticate as an app), or a mismatch all report false — the caller then finds
|
||||
// no bearer either and answers 401. The comparison is constant-time.
|
||||
func app(c *zip.Ctx, db orm.DB) (*Principal, bool) {
|
||||
id, secret, ok := httpx.Basic(c)
|
||||
if !ok || id == "" || secret == "" {
|
||||
return nil, false
|
||||
}
|
||||
a, err := store.GetApplicationByClientId(c.Context(), db, id)
|
||||
if err != nil || a == nil || a.ClientSecret == "" {
|
||||
return nil, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(a.ClientSecret), []byte(secret)) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
// AppOwner is the app row's OWNING org (a.Owner: "admin"/"built-in" for a platform
|
||||
// app), NOT a.Organization (the tenant it SERVES). cap.go pins every capability to
|
||||
// this being a reserved signing owner, so a tenant-owned app named/clientId'd like
|
||||
// a console holds nothing. Org carries the served tenant, as before.
|
||||
return &Principal{App: a.Name, AppOwner: a.Owner, Org: a.Organization}, true
|
||||
}
|
||||
|
||||
// entityOf returns the resource segment of an /v1/iam/<entity>[/verb] path, or
|
||||
// "" for anything else (e.g. /mcp). Only the users entity needs distinguishing —
|
||||
// its regular-user self-service rule — so every other segment is treated
|
||||
// uniformly by the tenant rule.
|
||||
func entityOf(path string) string {
|
||||
const p = "/v1/iam/"
|
||||
if !strings.HasPrefix(path, p) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(p):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
return rest[:i]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// The confidential-client authorization policy: an app principal's ENTIRE
|
||||
// authority is its capability allowlist — never Super, never Admin, never a
|
||||
// tenant. This is the v1 "every client credential is a global admin" hole, held
|
||||
// closed. authorize() IS the decision; this table is its truth for app principals.
|
||||
func TestAuthorizeAppCapabilities(t *testing.T) {
|
||||
// The allowlists reserve each capability to a named admin-owned app.
|
||||
t.Setenv("IAM_USER_ADMIN_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-team")
|
||||
t.Setenv("IAM_SA_LIST_ALLOWED_APPS", "hanzo-reader")
|
||||
|
||||
console := &Principal{App: "hanzo-console", AppOwner: "admin", Org: "admin"} // admin-owned: user+org admin caps
|
||||
nobody := &Principal{App: "rogue-app", AppOwner: "hanzo", Org: "hanzo"} // in no allowlist
|
||||
// attacker: a tenant that registered <its-org>/hanzo-console — the SAME allow-listed
|
||||
// NAME, but owned by a NON-signing org. The owner-pin (cap.go) denies every capability,
|
||||
// so the public signup→onboard→register-app→Basic-auth escalation is inert.
|
||||
attacker := &Principal{App: "hanzo-console", AppOwner: "evil", Org: "evil"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
p *Principal
|
||||
method string
|
||||
entity string
|
||||
owner string
|
||||
name2 string
|
||||
want bool
|
||||
}{
|
||||
// A capability-holding app may act on its mapped entity — cross-tenant by
|
||||
// design (a platform console onboards any customer org).
|
||||
{"console writes users in any org", console, "POST", "users", "orgb", "x", true},
|
||||
{"console writes org (reserved-owner exception)", console, "POST", "organizations", "admin", "hanzo", true},
|
||||
{"console reads org", console, "GET", "organizations", "admin", "hanzo", true},
|
||||
|
||||
// An app NEVER reaches signing material or unmapped entities, allowlisted
|
||||
// or not — capFor has no mapping, so the allowlist is vacuously empty.
|
||||
{"console -> certs denied", console, "POST", "certs", "admin", "k", false},
|
||||
{"console -> providers denied", console, "POST", "providers", "hanzo", "p", false},
|
||||
{"console -> tokens denied", console, "POST", "tokens", "hanzo", "t", false},
|
||||
|
||||
// An app in NO allowlist holds nothing — a leaked credential is inert.
|
||||
{"rogue -> users denied", nobody, "POST", "users", "hanzo", "x", false},
|
||||
{"rogue -> orgs denied", nobody, "POST", "organizations", "admin", "hanzo", false},
|
||||
{"rogue -> own-org users denied", nobody, "POST", "users", "hanzo", "x", false},
|
||||
|
||||
// A user under a reserved owner is NEVER writable by an app — provision,
|
||||
// never promote (no capability moves a user into the admin org).
|
||||
{"console -> admin-org user denied", console, "POST", "users", "admin", "x", false},
|
||||
{"console -> built-in user denied", console, "POST", "users", "built-in", "x", false},
|
||||
// [INFO] consistency: even the LEGIT admin-owned console may not land a user in
|
||||
// the reserved service-principal org "app" — a platform identity, super-only. The
|
||||
// raw CRUD now consults IsReservedOrg, the SAME predicate signup/onboarding use.
|
||||
{"console -> app-org user denied", console, "POST", "users", "app", "x", false},
|
||||
|
||||
// RED PoC, now DENIED: a tenant-owned app spoofing the console NAME holds NOTHING.
|
||||
// The owner-pin refuses a non-signing owner BEFORE any allowlist name match, so a
|
||||
// leaked/forged tenant credential named like the console cannot act on any tenant.
|
||||
{"attacker spoof -> victim users denied", attacker, "POST", "users", "victim", "x", false},
|
||||
{"attacker spoof -> org write denied", attacker, "POST", "organizations", "admin", "victim", false},
|
||||
{"attacker spoof -> org delete denied", attacker, "DELETE", "organizations", "admin", "victim", false},
|
||||
{"attacker spoof -> org read denied", attacker, "GET", "organizations", "admin", "victim", false},
|
||||
{"attacker spoof -> own-org users denied", attacker, "POST", "users", "evil", "x", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
|
||||
t.Fatalf("authorize(App=%q,%s,%s,%s/%s) = %v, want %v",
|
||||
c.p.App, c.method, c.entity, c.owner, c.name2, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An app principal is structurally never Super/Admin, so it can never take the
|
||||
// human privileged paths even if a future bug set the flags.
|
||||
if console.Super || console.Admin {
|
||||
t.Fatal("an app principal must never carry Super/Admin")
|
||||
}
|
||||
|
||||
// RED's four assertions, VERBATIM — the exact PoC principal &Principal{App:"hanzo-console"}
|
||||
// (no owning signing org). Every one fired == true before the owner-pin; every one must
|
||||
// be false now. A bare app principal is inert regardless of the NAME it presents.
|
||||
red := &Principal{App: "hanzo-console"}
|
||||
for _, a := range []struct{ method, entity, owner, name string }{
|
||||
{"POST", "users", "victim", "x"},
|
||||
{"POST", "organizations", "admin", "victim"},
|
||||
{"DELETE", "organizations", "admin", "victim"},
|
||||
{"GET", "organizations", "admin", "victim"},
|
||||
} {
|
||||
if authorize(red, a.method, a.entity, a.owner, a.name) {
|
||||
t.Fatalf("RED PoC REOPENED: authorize(App=hanzo-console,%s,%s,%s/%s) GRANTED — the owner-pin failed",
|
||||
a.method, a.entity, a.owner, a.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The capability primitives, fail-secure to the letter.
|
||||
func TestCapabilityPrimitives(t *testing.T) {
|
||||
t.Setenv("IAM_ORG_ADMIN_APPS", "hanzo-console, brand-console")
|
||||
|
||||
t.Run("Allowed named", func(t *testing.T) {
|
||||
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapOrgAdmin) {
|
||||
t.Fatal("a named, admin-owned app must hold its capability")
|
||||
}
|
||||
})
|
||||
t.Run("Allowed unnamed denied", func(t *testing.T) {
|
||||
if Allowed(&Principal{App: "other", AppOwner: "admin"}, CapOrgAdmin) {
|
||||
t.Fatal("an unnamed app must hold nothing") // admin-owned, so the NAME check alone denies
|
||||
}
|
||||
})
|
||||
t.Run("Allowed unset env denied", func(t *testing.T) {
|
||||
if Allowed(&Principal{App: "hanzo-console", AppOwner: "admin"}, CapKeyMint) { // IAM_KEY_MINT_ALLOWED_APPS unset here
|
||||
t.Fatal("an unset allowlist must deny every app")
|
||||
}
|
||||
})
|
||||
t.Run("Allowed owner-pin denies a non-signing owner", func(t *testing.T) {
|
||||
// hanzo-console IS on IAM_ORG_ADMIN_APPS, but these rows are NOT admin/built-in owned.
|
||||
if Allowed(&Principal{App: "hanzo-console", AppOwner: "hanzo"}, CapOrgAdmin) {
|
||||
t.Fatal("a tenant-owned app must hold nothing even with an allow-listed name")
|
||||
}
|
||||
if Allowed(&Principal{App: "hanzo-console"}, CapOrgAdmin) { // AppOwner "" — no owning signing org at all
|
||||
t.Fatal("an app with no owning signing org must hold nothing")
|
||||
}
|
||||
// built-in is the other reserved signing owner — a built-in-owned allow-listed app is legit.
|
||||
if !Allowed(&Principal{App: "hanzo-console", AppOwner: "built-in"}, CapOrgAdmin) {
|
||||
t.Fatal("a built-in-owned allow-listed app must hold its capability")
|
||||
}
|
||||
})
|
||||
t.Run("Allowed non-app is vacuous", func(t *testing.T) {
|
||||
if !Allowed(&Principal{Org: "hanzo"}, CapOrgAdmin) {
|
||||
t.Fatal("a human holds capabilities vacuously; the org policy decides")
|
||||
}
|
||||
})
|
||||
t.Run("BoundToOrg prefix", func(t *testing.T) {
|
||||
p := &Principal{App: "hanzo-team"}
|
||||
if !BoundToOrg(p, "hanzo") {
|
||||
t.Fatal("hanzo-team must be bound to hanzo")
|
||||
}
|
||||
if BoundToOrg(p, "lux") {
|
||||
t.Fatal("hanzo-team must NOT be bound to lux")
|
||||
}
|
||||
if BoundToOrg(&Principal{App: "hanzo"}, "hanzo") {
|
||||
t.Fatal("an exact-name app (no agent segment) is bound to nothing")
|
||||
}
|
||||
})
|
||||
t.Run("capFor mapping", func(t *testing.T) {
|
||||
if capFor("organizations") != CapOrgAdmin || capFor("users") != CapUserAdmin {
|
||||
t.Fatal("org/user entities must map to their capability")
|
||||
}
|
||||
if capFor("certs") != (Cap{}) || capFor("providers") != (Cap{}) {
|
||||
t.Fatal("an unmapped entity must map to the empty (deny-all) capability")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package authz_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The eight required cases, each through the real mounted router. Sub names map
|
||||
// to seeded principals: admin/root = SuperAdmin, hanzo/boss = org admin,
|
||||
// hanzo/alice = regular user, orgb/bob = a foreign org's admin.
|
||||
|
||||
// 1. An unauthenticated CRUD write is refused before any handler runs.
|
||||
func TestUnauthenticatedWriteIs401(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user", "POST", "/v1/iam/users", user("hanzo", "x")},
|
||||
{"write cert", "POST", "/v1/iam/certs", cert("admin", signingKid)},
|
||||
{"register app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x"}},
|
||||
{"delete user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
|
||||
{"update cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
|
||||
{"create org", "POST", "/v1/iam/organizations", map[string]any{"owner": "admin", "name": "x"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, "", c.body); got != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s no bearer = %d, want 401", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. A valid principal in orgB writing an orgA-owned entity is refused (tenant
|
||||
// isolation): the target org is bound to the principal, never the body.
|
||||
func TestCrossOrgWriteIs403(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
bob := h.token(t, "orgb/bob") // org admin, but of orgb
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user in hanzo", "POST", "/v1/iam/users", user("hanzo", "mole")},
|
||||
{"update user in hanzo", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
|
||||
{"delete user in hanzo", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "alice"}},
|
||||
{"create role in hanzo", "POST", "/v1/iam/roles", map[string]any{"owner": "hanzo", "name": "r"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, bob, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("orgb principal %s %s = %d, want 403", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. THE poisoning gate. A non-SuperAdmin — org admin OR regular user OR a
|
||||
// built-in-org member — writing an admin/built-in-owned signing cert is refused.
|
||||
// Every cert write verb is covered, and the update/delete target the LIVE
|
||||
// signing cert, so a bypass would truly overwrite the platform key.
|
||||
func TestSigningCertPoisoningIs403(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
principals := map[string]string{
|
||||
"org admin (hanzo/boss)": h.token(t, "hanzo/boss"),
|
||||
"regular user (hanzo/alice)": h.token(t, "hanzo/alice"),
|
||||
"built-in member (built-in/svc)": h.token(t, "built-in/svc"),
|
||||
}
|
||||
writes := []struct {
|
||||
name, path string
|
||||
body any
|
||||
}{
|
||||
{"create admin cert", "/v1/iam/certs", cert("admin", "cert-forge")},
|
||||
{"overwrite live admin cert", "/v1/iam/certs/update", cert("admin", signingKid)},
|
||||
{"delete live admin cert", "/v1/iam/certs/delete", map[string]any{"owner": "admin", "name": signingKid}},
|
||||
{"create built-in cert", "/v1/iam/certs", cert("built-in", "cert-forge")},
|
||||
{"overwrite built-in cert", "/v1/iam/certs/update", cert("built-in", "anything")},
|
||||
}
|
||||
for who, tok := range principals {
|
||||
for _, w := range writes {
|
||||
t.Run(who+" "+w.name, func(t *testing.T) {
|
||||
if got := h.do(t, "POST", w.path, tok, w.body); got != http.StatusForbidden {
|
||||
t.Fatalf("%s writing %s = %d, want 403 (poisoning gate)", who, w.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. A SuperAdmin (org == admin) may write the admin signing cert and act across
|
||||
// any org. The guard admits it; the handler then succeeds (2xx). The rotation
|
||||
// case overwrites the LIVE signing cert with a complete body (key preserved) —
|
||||
// the legitimate operation the poisoning gate exists to reserve to SuperAdmins.
|
||||
func TestSuperAdminWritesAdminCertAndCrossOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
rotate := map[string]any{
|
||||
"owner": "admin", "name": signingKid,
|
||||
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
|
||||
}
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create a new admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-fresh")},
|
||||
{"rotate the live admin signing cert", "POST", "/v1/iam/certs/update", rotate},
|
||||
{"create a user in any org", "POST", "/v1/iam/users", user("hanzo", "hire-by-root")},
|
||||
{"create a user in another org", "POST", "/v1/iam/users", user("orgb", "hire-by-root")},
|
||||
{"register an admin-owned app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "root-app", "clientId": "root-app"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := h.do(t, c.method, c.path, root, c.body)
|
||||
if got < 200 || got >= 300 {
|
||||
t.Fatalf("SuperAdmin %s %s = %d, want 2xx", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. An org admin manages its OWN org's users and apps (2xx) but not another
|
||||
// org's (403). This is the org-admin tier: org-scoped, never cross-tenant.
|
||||
func TestOrgAdminManagesOwnOrgOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
|
||||
allow := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user in own org", "POST", "/v1/iam/users", user("hanzo", "newhire")},
|
||||
{"update self org's user", "POST", "/v1/iam/users/update", user("hanzo", "alice")},
|
||||
{"register app in own org", "POST", "/v1/iam/application", map[string]any{"owner": "hanzo", "name": "hanzo-app", "clientId": "hanzo-app"}},
|
||||
}
|
||||
for _, c := range allow {
|
||||
t.Run("allow/"+c.name, func(t *testing.T) {
|
||||
got := h.do(t, c.method, c.path, boss, c.body)
|
||||
if got < 200 || got >= 300 {
|
||||
t.Fatalf("org admin %s %s (own org) = %d, want 2xx", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deny := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"create user in another org", "POST", "/v1/iam/users", user("orgb", "mole")},
|
||||
{"register app in another org", "POST", "/v1/iam/application", map[string]any{"owner": "orgb", "name": "x", "clientId": "x"}},
|
||||
{"write a platform (admin) app", "POST", "/v1/iam/application", map[string]any{"owner": "admin", "name": "x", "clientId": "x"}},
|
||||
}
|
||||
for _, c := range deny {
|
||||
t.Run("deny/"+c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, boss, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("org admin %s %s (foreign) = %d, want 403", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. A regular user may read its own user record (guard admits it) but not touch
|
||||
// another's, and may NOT write even its own record — a raw self-write would let
|
||||
// it carry isAdmin and self-promote, so writes are refused outright.
|
||||
func TestRegularUserSelfServiceOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
|
||||
// Reading own record: the guard admits it (not 401/403). The Phase-1 GET
|
||||
// handler binds no query, so the status is the handler's, never the guard's
|
||||
// forbid — the point here is that the guard did NOT block self-read.
|
||||
if got := h.do(t, "GET", "/v1/iam/users/get?owner=hanzo&name=alice", alice, nil); got == http.StatusForbidden || got == http.StatusUnauthorized {
|
||||
t.Fatalf("regular self-read = %d, want the guard to admit it (not 401/403)", got)
|
||||
}
|
||||
|
||||
// Everything else a regular user might try is refused.
|
||||
deny := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"read another user", "GET", "/v1/iam/users/get?owner=hanzo&name=boss", nil},
|
||||
{"list the org's users", "GET", "/v1/iam/users?owner=hanzo", nil},
|
||||
{"update own record (self-promote)", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
|
||||
{"create a user", "POST", "/v1/iam/users", user("hanzo", "puppet")},
|
||||
{"delete another user", "POST", "/v1/iam/users/delete", map[string]any{"owner": "hanzo", "name": "boss"}},
|
||||
{"read another org", "GET", "/v1/iam/users/get?owner=orgb&name=bob", nil},
|
||||
}
|
||||
for _, c := range deny {
|
||||
t.Run("deny/"+c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, alice, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("regular user %s %s = %d, want 403", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Public routes are reachable with NO bearer — the pre-auth OIDC/OAuth and
|
||||
// front-door surface a browser must reach before it holds a token. "Reachable"
|
||||
// means NOT the guard's 401: the endpoint's own handler answers (which may be a
|
||||
// 400 for a missing param — that is the handler, past the guard).
|
||||
func TestPublicRoutesNeedNoBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
public := []struct{ method, path string }{
|
||||
{"GET", "/healthz"},
|
||||
{"GET", "/.well-known/openid-configuration"},
|
||||
{"GET", "/v1/iam/.well-known/openid-configuration"},
|
||||
{"GET", "/v1/iam/.well-known/jwks"},
|
||||
{"GET", "/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (root)
|
||||
{"GET", "/v1/iam/.well-known/oauth-authorization-server"}, // RFC 8414 AS metadata (v1)
|
||||
{"POST", "/v1/iam/login"},
|
||||
{"GET", "/v1/iam/oauth/authorize"},
|
||||
{"POST", "/v1/iam/oauth/token"},
|
||||
{"GET", "/v1/iam/get-app-login"},
|
||||
{"GET", "/v1/iam/auth/methods"},
|
||||
{"POST", "/v1/iam/oauth/logout"},
|
||||
// The front-door session/identity surface — each self-resolves the caller
|
||||
// (session cookie, else bearer) and answers anonymously (200 {status:error}
|
||||
// or a handler 400), never the Guard's 401. These are the routes the old
|
||||
// publicPaths list had to be patched to include; now they are public purely
|
||||
// because oidc.Route registers them on the pre-Guard group.
|
||||
{"GET", "/v1/iam/get-account"},
|
||||
{"POST", "/v1/iam/signin"},
|
||||
{"GET", "/v1/iam/whoami"},
|
||||
{"GET", "/v1/iam/linked-accounts"},
|
||||
{"POST", "/v1/iam/signup"},
|
||||
{"POST", "/v1/iam/send-verification-code"},
|
||||
{"POST", "/v1/iam/update-preferences"},
|
||||
}
|
||||
for _, c := range public {
|
||||
t.Run(c.method+" "+c.path, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, "", map[string]any{}); got == http.StatusUnauthorized {
|
||||
t.Fatalf("public %s %s = 401, want the endpoint reachable without a bearer", c.method, c.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
// userinfo is bearer-gated but self-verifying: no bearer → its OWN 401
|
||||
// (WWW-Authenticate), which is correct and must not be double-gated away.
|
||||
if got := h.do(t, "GET", "/v1/iam/oauth/userinfo", "", nil); got != http.StatusUnauthorized {
|
||||
t.Fatalf("userinfo no bearer = %d, want its own 401", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Bad bearers are refused with the same opaque 401 (no oracle): expired,
|
||||
// wrong algorithm (HMAC / none — never in the allowlist), a kid that names no
|
||||
// trusted cert, and a good-shape token under the wrong key. This reuses the
|
||||
// Phase-2 verifier defenses verbatim.
|
||||
func TestBadBearersAre401(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
other := genRSA(t)
|
||||
path, body := "/v1/iam/users", user("hanzo", "x")
|
||||
|
||||
bad := map[string]string{
|
||||
"expired": h.mint(t, "admin/root", time.Now().Add(-time.Hour)),
|
||||
"forged kid": mintKid(t, h.key, "cert-nonexistent", "admin/root"),
|
||||
"wrong key": mintKid(t, other, signingKid, "admin/root"),
|
||||
"hmac alg": signHS256(t, signingKid, "admin/root"),
|
||||
"alg none": forgeNone(signingKid, "admin/root"),
|
||||
"garbage": "not.a.jwt",
|
||||
}
|
||||
for name, tok := range bad {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := h.do(t, "POST", path, tok, body); got != http.StatusUnauthorized {
|
||||
t.Fatalf("bad bearer %q = %d, want 401", name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A revoked (forbidden) user's otherwise-valid token is refused too.
|
||||
t.Run("revoked user", func(t *testing.T) {
|
||||
if got := h.do(t, "POST", path, h.token(t, "hanzo/ghost"), body); got != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked user = %d, want 401", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Org-confusion escalation defense: a token minted through a SHARED admin-org
|
||||
// app carries owner/organization = "admin" while its subject is a tenant user.
|
||||
// The guard authorizes from the subject (the real user's org), never the owner
|
||||
// claim, so this token is a hanzo REGULAR user — it cannot write an admin cert
|
||||
// or reach across orgs, exactly as if the misleading claim were absent.
|
||||
func TestOwnerClaimCannotEscalate(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// alice is a regular hanzo user; the token lies that owner == admin.
|
||||
tok := h.sharedAppToken(t, "hanzo/alice", "admin")
|
||||
cases := []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"write admin signing cert", "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
|
||||
{"overwrite live admin cert", "POST", "/v1/iam/certs/update", cert("admin", signingKid)},
|
||||
{"create a user cross-org", "POST", "/v1/iam/users", user("orgb", "mole")},
|
||||
{"promote self in own org", "POST", "/v1/iam/users/update", map[string]any{"user": map[string]any{"owner": "hanzo", "name": "alice", "isAdmin": true}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, tok, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("owner-claim=admin %s %s = %d, want 403 (claim must not escalate)", c.method, c.path, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A verified token whose subject names NO live user — a machine token, a
|
||||
// since-deleted user, or a forged-looking "admin/<nobody>" — authenticates but
|
||||
// carries no authority: SuperAdmin requires a real member of the admin org, so
|
||||
// the phantom-admin subject is refused everywhere.
|
||||
func TestPhantomSubjectHasNoAuthority(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ghostAdmin := h.token(t, "admin/nobody") // no such user seeded
|
||||
ghostTenant := h.token(t, "hanzo/nobody")
|
||||
cases := []struct {
|
||||
name, tok, method, path string
|
||||
body any
|
||||
}{
|
||||
{"phantom admin -> admin cert", ghostAdmin, "POST", "/v1/iam/certs", cert("admin", "cert-forge")},
|
||||
{"phantom admin -> user in admin org", ghostAdmin, "POST", "/v1/iam/users", user("admin", "x")},
|
||||
{"phantom admin -> user in a tenant", ghostAdmin, "POST", "/v1/iam/users", user("hanzo", "x")},
|
||||
{"phantom tenant -> user in own org", ghostTenant, "POST", "/v1/iam/users", user("hanzo", "x")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := h.do(t, c.method, c.path, c.tok, c.body); got != http.StatusForbidden {
|
||||
t.Fatalf("%s = %d, want 403 (phantom subject has no authority)", c.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The framework's generic side doors (MCP tool-call, OpenAPI doc) are gated by
|
||||
// the same fail-closed default — proven on a REAL, installed route and a REAL
|
||||
// tool INVOCATION, not just the envelope path. newHarness calls app.Prepare(), so
|
||||
// /mcp and /openapi are actually registered (the old test hit a route that was
|
||||
// never mounted, so the guard's 401 masked the fact the invocation was untested),
|
||||
// and the tool id is the framework's real one (post_v1_iam_certs), so a
|
||||
// regression that let a tool arguments-mask through would FAIL here, not pass.
|
||||
func TestFrameworkSideDoorsAreGated(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
forge := cert("admin", "cert-forge") // {owner:"admin", …} — the poisoning target
|
||||
|
||||
// No bearer reaches /mcp at all: the guard authenticates the envelope before
|
||||
// any dispatch, so it is 401 — never an unauthorized invocation, never a 404.
|
||||
if got := h.do(t, "POST", "/mcp", "", mcpEnvelope("post_v1_iam_certs", forge)); got != http.StatusUnauthorized {
|
||||
t.Fatalf("POST /mcp no bearer = %d, want 401 (guard fail-closed)", got)
|
||||
}
|
||||
// The OpenAPI doc — now a real installed route — is gated too.
|
||||
if got := h.do(t, "GET", "/.well-known/openapi.json", "", nil); got != http.StatusUnauthorized {
|
||||
t.Fatalf("GET openapi.json no bearer = %d, want 401", got)
|
||||
}
|
||||
|
||||
// A non-SuperAdmin driving the REAL cert tool is refused at the op-invoke seam
|
||||
// (isError), and — the assertion that matters — NOTHING is written.
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
|
||||
t.Fatalf("MCP post_v1_iam_certs (non-super) = status %d isError %v, want 200/true (refused at op seam)", status, isErr)
|
||||
}
|
||||
if h.certExists(t, "admin", "cert-forge") {
|
||||
t.Fatal("MCP cert-forge PERSISTED an admin-owned cert — the /mcp side door is OPEN")
|
||||
}
|
||||
}
|
||||
|
||||
// THE critical bug (finding #1), proven closed at the REST seam. The users entity
|
||||
// is the one input that nests its owner, so an org admin who masks a benign
|
||||
// top-level owner over a nested admin/isAdmin record must NOT create a platform
|
||||
// SuperAdmin. The write is refused (403) AND — the assertion the vacuous test
|
||||
// lacked — the store holds no such row afterward. Query the store, not the status.
|
||||
func TestUserOwnerMaskIsRefused(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss") // org admin of hanzo — authorized for "hanzo" only
|
||||
|
||||
// The PoC verbatim: top-level owner is the attacker's OWN org (which the guard
|
||||
// would authorize), the nested record targets the reserved admin org with
|
||||
// isAdmin — a platform SuperAdmin (owner=="admin" IS the predicate) if it landed.
|
||||
createMask := map[string]any{
|
||||
"owner": "hanzo",
|
||||
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
|
||||
"password": "x",
|
||||
}
|
||||
if got := h.do(t, "POST", "/v1/iam/users", boss, createMask); got != http.StatusForbidden {
|
||||
t.Fatalf("users create owner-mask = %d, want 403", got)
|
||||
}
|
||||
if h.userExists(t, "admin", "red-super") {
|
||||
t.Fatal("owner-mask PERSISTED admin/red-super — total-account-takeover path is OPEN")
|
||||
}
|
||||
|
||||
// The same mask, aimed cross-tenant: inject a user into a foreign org.
|
||||
crossOrgMask := map[string]any{
|
||||
"owner": "hanzo",
|
||||
"user": map[string]any{"owner": "orgb", "name": "mole"},
|
||||
"password": "x",
|
||||
}
|
||||
if got := h.do(t, "POST", "/v1/iam/users", boss, crossOrgMask); got != http.StatusForbidden {
|
||||
t.Fatalf("users create cross-org mask = %d, want 403", got)
|
||||
}
|
||||
if h.userExists(t, "orgb", "mole") {
|
||||
t.Fatal("owner-mask injected a user into orgb (cross-tenant)")
|
||||
}
|
||||
|
||||
// Hijack an EXISTING admin-org user via /users/update (nested owner=admin):
|
||||
// refused, and the victim's privilege/credentials are untouched.
|
||||
hijack := map[string]any{
|
||||
"user": map[string]any{"owner": "admin", "name": "root", "isAdmin": true},
|
||||
"password": "attacker-chosen",
|
||||
}
|
||||
if got := h.do(t, "POST", "/v1/iam/users/update", boss, hijack); got != http.StatusForbidden {
|
||||
t.Fatalf("users update hijack of admin/root = %d, want 403", got)
|
||||
}
|
||||
if h.userIsAdmin(t, "admin", "root") {
|
||||
t.Fatal("update hijack flipped admin/root.isAdmin — privilege takeover via /users/update")
|
||||
}
|
||||
}
|
||||
|
||||
// The MCP arguments-mask (finding #2), proven closed at the SAME op-invoke seam —
|
||||
// the design claim "the guard gates /mcp" made real, independent of the prod
|
||||
// MCP.Disabled flag (this harness leaves MCP ENABLED). A non-SuperAdmin driving
|
||||
// the real tools with admin-targeted arguments is refused and writes nothing; a
|
||||
// SuperAdmin drives the same tool successfully, so the seam refuses by AUTHORITY,
|
||||
// not by blanket-denying every MCP call.
|
||||
func TestMCPArgumentsMaskIsRefused(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
attackerPEM := rsaKeyToPEM(t, genRSA(t))
|
||||
|
||||
// a) cert-forge over MCP arguments: an admin signing cert with an attacker key.
|
||||
forge := map[string]any{
|
||||
"owner": "admin", "name": "cert-forge",
|
||||
"cryptoAlgorithm": "RS256", "privateKey": attackerPEM,
|
||||
}
|
||||
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_certs", forge); status != http.StatusOK || !isErr {
|
||||
t.Fatalf("MCP cert-forge (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
|
||||
}
|
||||
if h.certExists(t, "admin", "cert-forge") {
|
||||
t.Fatal("MCP cert-forge PERSISTED an admin signing cert with an attacker key")
|
||||
}
|
||||
|
||||
// b) the users owner-mask over MCP arguments: a nested admin SuperAdmin record.
|
||||
userMask := map[string]any{
|
||||
"owner": "hanzo",
|
||||
"user": map[string]any{"owner": "admin", "name": "red-super", "isAdmin": true},
|
||||
"password": "x",
|
||||
}
|
||||
if status, isErr := h.mcpToolCall(t, boss, "post_v1_iam_users", userMask); status != http.StatusOK || !isErr {
|
||||
t.Fatalf("MCP users owner-mask (non-super) = status %d isError %v, want 200/true (refused)", status, isErr)
|
||||
}
|
||||
if h.userExists(t, "admin", "red-super") {
|
||||
t.Fatal("MCP users owner-mask PERSISTED admin/red-super — total takeover via /mcp")
|
||||
}
|
||||
|
||||
// Control: a SuperAdmin drives the SAME cert tool successfully — the seam
|
||||
// discriminates by authority; it does not just refuse everything over MCP.
|
||||
root := h.token(t, "admin/root")
|
||||
legit := map[string]any{
|
||||
"owner": "admin", "name": "cert-legit",
|
||||
"cryptoAlgorithm": "RS256", "privateKey": rsaKeyToPEM(t, h.key),
|
||||
}
|
||||
if status, isErr := h.mcpToolCall(t, root, "post_v1_iam_certs", legit); status != http.StatusOK || isErr {
|
||||
t.Fatalf("MCP cert create by SuperAdmin = status %d isError %v, want 200/false (allowed)", status, isErr)
|
||||
}
|
||||
if !h.certExists(t, "admin", "cert-legit") {
|
||||
t.Fatal("SuperAdmin MCP cert create did not persist — the seam is over-refusing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package authz_test
|
||||
|
||||
// End-to-end authorization tests driven through the REAL mounted router
|
||||
// (routes.Mount, which installs authz.Guard after the public group, so gating is
|
||||
// structural — the public routes registered before it are never reached by it).
|
||||
// Every case is a wire request
|
||||
// a client could send: a status code is the whole contract. Tokens are genuine
|
||||
// RS256 JWTs signed by the seeded admin signing cert, so they pass the exact
|
||||
// oidc.VerifyToken the guard reuses — nothing here is mocked.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo" // the seeded admin signing cert's name = JWKS kid
|
||||
|
||||
// Two RSA keys, generated once for the whole suite: the trust-anchor key the
|
||||
// signing cert holds, and a distinct "other" key for the wrong-key bearer test.
|
||||
// Keygen is the slow part and the crypto under test is identical whichever key
|
||||
// it is, so caching them keeps the suite (and -race) fast.
|
||||
var (
|
||||
anchorKeyOnce, otherKeyOnce sync.Once
|
||||
anchorKey, otherKey *rsa.PrivateKey
|
||||
)
|
||||
|
||||
func trustKey() *rsa.PrivateKey {
|
||||
anchorKeyOnce.Do(func() { anchorKey = mustRSA() })
|
||||
return anchorKey
|
||||
}
|
||||
|
||||
func mustRSA() *rsa.PrivateKey {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// harness holds the mounted app, the RSA key the signing cert holds (so a test
|
||||
// can mint a token any principal would carry), and the store (so a test can
|
||||
// assert that a refused write persisted NOTHING — the real security property, not
|
||||
// just a status code).
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
// userExists reports whether a user row (owner, name) is persisted — used to
|
||||
// prove a refused create/update wrote nothing.
|
||||
func (h *harness) userExists(t *testing.T, owner, name string) bool {
|
||||
t.Helper()
|
||||
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
return u != nil
|
||||
}
|
||||
|
||||
// certExists reports whether a cert row (owner, name) is persisted.
|
||||
func (h *harness) certExists(t *testing.T, owner, name string) bool {
|
||||
t.Helper()
|
||||
c, err := store.GetCert(context.Background(), h.db, owner, name)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup cert %s/%s: %v", owner, name, err)
|
||||
}
|
||||
return c != nil
|
||||
}
|
||||
|
||||
// userIsAdmin reports the persisted isAdmin flag of (owner, name) — used to prove
|
||||
// a refused update did NOT flip a victim's privilege.
|
||||
func (h *harness) userIsAdmin(t *testing.T, owner, name string) bool {
|
||||
t.Helper()
|
||||
u, err := store.GetUserByName(context.Background(), h.db, owner, name)
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("expected user %s/%s to exist: %v", owner, name, err)
|
||||
}
|
||||
return u.IsAdmin
|
||||
}
|
||||
|
||||
// newHarness opens a fresh SQLite store, seeds the trust anchor (an admin-owned
|
||||
// RS256 signing cert) plus a cast of principals across three orgs, and mounts
|
||||
// the full router — guard and all. MCP is left ENABLED here (unlike prod) so the
|
||||
// tests prove the guard, not a disabled feature, closes the /mcp side door.
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds() // force kind registration
|
||||
key := trustKey()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "authz.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Trust anchor: the admin-owned signing cert the verifier and JWKS trust.
|
||||
// Poisoning tests target THIS row, so a bypassed guard would really overwrite
|
||||
// the live signing key.
|
||||
seedCert(t, db, "admin", signingKid, rsaKeyToPEM(t, key))
|
||||
|
||||
// Principals: one per scope, plus a revoked user and a cross-tenant org.
|
||||
seedUser(t, db, "admin", "root", false, false, false) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true, false, false) // org admin of hanzo
|
||||
seedUser(t, db, "hanzo", "alice", false, false, false) // regular user in hanzo
|
||||
seedUser(t, db, "orgb", "bob", true, false, false) // org admin of orgb (cross-tenant)
|
||||
seedUser(t, db, "hanzo", "ghost", true, true, false) // forbidden — revoked
|
||||
seedUser(t, db, "built-in", "svc", true, false, false) // built-in org, NOT SuperAdmin
|
||||
|
||||
app := zip.New(zip.Config{AppName: "authz-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
// Install the deferred framework projections (/mcp, /openapi) for real, so the
|
||||
// side-door tests drive the ACTUAL routes — the same surface a served app
|
||||
// exposes — not a route that never got registered. MCP is left ENABLED here
|
||||
// (unlike prod) so the tests prove the guard, not a disabled feature, closes it.
|
||||
app.Prepare()
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
// mint signs an RS256 bearer for subject `sub` (an "owner/name") with the given
|
||||
// expiry, under the trusted kid — the exact shape a real token carries.
|
||||
func (h *harness) mint(t *testing.T, sub string, exp time.Time) string {
|
||||
t.Helper()
|
||||
return signRS256(t, h.key, signingKid, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": exp.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// token is a convenience for a valid, hour-long bearer for sub.
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
return h.mint(t, sub, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
// sharedAppToken mints a valid bearer whose owner/organization claims say
|
||||
// ownerClaim (as a token minted through a SHARED admin-org app would) while the
|
||||
// subject names a different, tenant user. The guard must authorize from the
|
||||
// subject, never these claims — the org-confusion escalation defense.
|
||||
func (h *harness) sharedAppToken(t *testing.T, sub, ownerClaim string) string {
|
||||
t.Helper()
|
||||
return signRS256(t, h.key, signingKid, jwt.MapClaims{
|
||||
"sub": sub, "owner": ownerClaim, "organization": ownerClaim, "exp": future(),
|
||||
})
|
||||
}
|
||||
|
||||
// do issues one request through the real router and returns the status code.
|
||||
func (h *harness) do(t *testing.T, method, path, bearer string, body any) int {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
req.Host = "hanzo.id"
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := h.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// mcpEnvelope builds a JSON-RPC 2.0 tools/call for the framework tool `tool`
|
||||
// (its real op id, e.g. "post_v1_iam_certs") with `args` as the tool arguments —
|
||||
// the same body an MCP agent would POST to /mcp.
|
||||
func mcpEnvelope(tool string, args any) map[string]any {
|
||||
return map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": args},
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolCall fires an MCP tools/call for `tool` with `args` through the REAL
|
||||
// mounted /mcp route and reports the HTTP status plus whether the op-invoke
|
||||
// authorizer refused it. A refusal at the op seam surfaces as an isError result
|
||||
// with HTTP 200 (MCP reports handler errors in-band), never a transport 403, so
|
||||
// a refused write shows up as isError==true — the status stays 200.
|
||||
func (h *harness) mcpToolCall(t *testing.T, bearer, tool string, args any) (status int, isError bool) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(mcpEnvelope(tool, args))
|
||||
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := h.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("mcp tools/call %s: %v", tool, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out struct {
|
||||
Result struct {
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out.Result.IsError
|
||||
}
|
||||
|
||||
// ---- seed helpers ----------------------------------------------------------
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin, forbidden, deleted bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin, u.IsForbidden, u.IsDeleted = admin, forbidden, deleted
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func rsaKeyToPEM(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
|
||||
func signRS256(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func future() int64 { return time.Now().Add(time.Hour).Unix() }
|
||||
|
||||
// mintKid signs an hour-long RS256 token for sub under an arbitrary key and kid,
|
||||
// for the forged-kid and wrong-key bearer tests.
|
||||
func mintKid(t *testing.T, key *rsa.PrivateKey, kid, sub string) string {
|
||||
return signRS256(t, key, kid, jwt.MapClaims{"sub": sub, "exp": future()})
|
||||
}
|
||||
|
||||
// genRSA returns the suite's cached "other" key — a valid key that is NOT the
|
||||
// trust anchor, for the wrong-signature bearer test.
|
||||
func genRSA(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
otherKeyOnce.Do(func() { otherKey = mustRSA() })
|
||||
return otherKey
|
||||
}
|
||||
|
||||
// signHS256 forges an HMAC-signed token carrying the trusted kid. The verifier's
|
||||
// algorithm allowlist has no HMAC family, so it is rejected before any key is
|
||||
// consulted (the classic alg-confusion downgrade, closed).
|
||||
func signHS256(t *testing.T, kid, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": sub, "exp": future()})
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString([]byte("attacker-chosen-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("hs256 sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// forgeNone hand-builds an alg:none token (header.claims. with an empty
|
||||
// signature) — the unsigned-token attack. "none" is absent from the allowlist,
|
||||
// so it never verifies.
|
||||
func forgeNone(kid, sub string) string {
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
head := enc(map[string]any{"alg": "none", "typ": "JWT", "kid": kid})
|
||||
body := enc(map[string]any{"sub": sub, "exp": future()})
|
||||
return head + "." + body + "."
|
||||
}
|
||||
|
||||
// cert is a minimal signing-cert create/update/delete body.
|
||||
func cert(owner, name string) map[string]any {
|
||||
return map[string]any{"owner": owner, "name": name, "cryptoAlgorithm": "RS256"}
|
||||
}
|
||||
|
||||
// user wraps a create/update user body ({user:{...}, password}).
|
||||
func user(owner, name string) map[string]any {
|
||||
return map[string]any{"user": map[string]any{"owner": owner, "name": name}, "password": "x"}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
// The pure policy, tested exhaustively and independent of HTTP. authorize IS the
|
||||
// security decision; this table is its full truth.
|
||||
func TestAuthorizePolicy(t *testing.T) {
|
||||
super := &Principal{Org: "admin", User: "root", Super: true}
|
||||
orgAdmin := &Principal{Org: "hanzo", User: "boss", Admin: true}
|
||||
regular := &Principal{Org: "hanzo", User: "alice"}
|
||||
builtin := &Principal{Org: "built-in", User: "svc", Admin: true} // NOT super
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
p *Principal
|
||||
method string
|
||||
entity string
|
||||
owner string
|
||||
name2 string
|
||||
want bool
|
||||
}{
|
||||
// SuperAdmin: unrestricted, including the reserved owners and cross-org.
|
||||
{"super writes admin cert", super, "POST", "certs", "admin", "k", true},
|
||||
{"super writes built-in cert", super, "POST", "certs", "built-in", "k", true},
|
||||
{"super cross-org user", super, "POST", "users", "orgb", "x", true},
|
||||
|
||||
// Poisoning gate: no non-super may write a reserved-owner resource.
|
||||
{"org admin -> admin cert", orgAdmin, "POST", "certs", "admin", "k", false},
|
||||
{"org admin -> built-in cert", orgAdmin, "POST", "certs", "built-in", "k", false},
|
||||
{"regular -> admin cert", regular, "POST", "certs", "admin", "k", false},
|
||||
{"built-in member -> built-in cert", builtin, "POST", "certs", "built-in", "k", false},
|
||||
{"built-in member -> admin app", builtin, "POST", "application", "admin", "a", false},
|
||||
|
||||
// Tenant isolation: own org only.
|
||||
{"org admin own org", orgAdmin, "POST", "users", "hanzo", "x", true},
|
||||
{"org admin foreign org", orgAdmin, "POST", "users", "orgb", "x", false},
|
||||
{"org admin empty owner", orgAdmin, "POST", "certs", "", "k", false},
|
||||
|
||||
// Regular user: read own record only; no writes, no others, no self-promote.
|
||||
{"regular read own", regular, "GET", "users", "hanzo", "alice", true},
|
||||
{"regular read other", regular, "GET", "users", "hanzo", "boss", false},
|
||||
{"regular list org", regular, "GET", "users", "hanzo", "", false},
|
||||
{"regular write own (self-promote)", regular, "POST", "users", "hanzo", "alice", false},
|
||||
{"regular read own non-user entity", regular, "GET", "roles", "hanzo", "alice", false},
|
||||
{"regular read foreign org self-name", regular, "GET", "users", "orgb", "alice", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := authorize(c.p, c.method, c.entity, c.owner, c.name2); got != c.want {
|
||||
t.Fatalf("authorize(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SuperAdmin is exactly org=="admin"; built-in is NOT super — the built-in gap
|
||||
// the poisoning gate must close depends on this.
|
||||
func TestSuperIsAdminOrgOnly(t *testing.T) {
|
||||
if (&Principal{Org: "built-in", Super: false}).Super {
|
||||
t.Fatal("built-in must not be SuperAdmin")
|
||||
}
|
||||
// A built-in-org principal fails the reserved-owner write even for its own org.
|
||||
if authorize(&Principal{Org: "built-in", Admin: true}, "POST", "certs", "built-in", "k") {
|
||||
t.Fatal("built-in admin must not write built-in signing certs")
|
||||
}
|
||||
}
|
||||
|
||||
// Public vs gated is no longer a path allow-list this package owns — it is
|
||||
// STRUCTURAL, decided by which group a route is registered on in routes.Mount
|
||||
// (the public group before the Guard, everything else after it). The boundary is
|
||||
// therefore proven end-to-end over the real mounted router: TestPublicRoutesNeedNoBearer
|
||||
// (public routes reachable without a bearer), TestUnauthenticatedWriteIs401 /
|
||||
// TestCrossOrgWriteIs403 (authed routes gated), and TestFrameworkSideDoorsAreGated
|
||||
// (/mcp + /openapi gated) in authz_cases_test.go.
|
||||
|
||||
func TestEntityOf(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/v1/iam/users": "users",
|
||||
"/v1/iam/users/get": "users",
|
||||
"/v1/iam/users/update": "users",
|
||||
"/v1/iam/certs/delete": "certs",
|
||||
"/v1/iam/application": "application",
|
||||
"/v1/iam/audit-logs": "audit-logs",
|
||||
"/mcp": "",
|
||||
"/healthz": "",
|
||||
"/v1/iam/": "",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := entityOf(path); got != want {
|
||||
t.Errorf("entityOf(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,128 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package authz
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Confidential-client capabilities — the port of the v1 gate (object/app_authz.go
|
||||
// + controllers/app_mutation_guard.go requireAppCapability) that revoked the
|
||||
// "every client credential is a global admin" privilege.
|
||||
//
|
||||
// A Cap is a named authority an app principal holds ONLY when its application
|
||||
// name is listed in the allowlist Env names. It is the ONLY thing an app
|
||||
// principal's authority is made of: an app is never a SuperAdmin and never an
|
||||
// org admin (see Principal), so a leaked client credential grants exactly the
|
||||
// capabilities its NAME was allowlisted for and nothing more.
|
||||
//
|
||||
// The key is the application NAME, not its (owner, name) row. That alone would let
|
||||
// ANY owner's app claim a listed name, so Allowed ALSO pins the app's OWNING org to
|
||||
// a reserved platform signing owner (store.IsSigningCertOwner): the name is thereby
|
||||
// reserved to the platform's admin-owned app, and a tenant that registers
|
||||
// <theirOrg>/hanzo-console — same name, its own owner — inherits none of its grants.
|
||||
// The pin is what ENFORCES that reservation; the name match alone was the escalation.
|
||||
|
||||
// Cap is one capability: a Name for diagnostics and the Env var holding its
|
||||
// comma-separated allowlist of application names.
|
||||
type Cap struct {
|
||||
Name string
|
||||
Env string
|
||||
}
|
||||
|
||||
// The capability set, matching the live allowlists byte-for-byte
|
||||
// (universe infra/k8s/operator/crs/iam.yaml). Every one is fail-secure: an unset
|
||||
// or empty allowlist denies EVERY app.
|
||||
var (
|
||||
// CapKeyMint gates minting, rotating, or revoking a credential on another
|
||||
// principal's behalf — the service-account administration boundary, since a
|
||||
// minted key is an org-billing credential.
|
||||
CapKeyMint = Cap{Name: "key-mint", Env: "IAM_KEY_MINT_ALLOWED_APPS"}
|
||||
|
||||
// CapUserAdmin gates cross-user account mutation (owner, isAdmin, email,
|
||||
// type, credentials) — cloud moves an onboarding user into the org it just
|
||||
// created through this.
|
||||
CapUserAdmin = Cap{Name: "user", Env: "IAM_USER_ADMIN_APPS"}
|
||||
|
||||
// CapOrgAdmin gates organization create/read/update/delete. Unlike the
|
||||
// signing-material capabilities this one is populated in every environment:
|
||||
// the brand consoles legitimately create customer orgs during onboarding.
|
||||
CapOrgAdmin = Cap{Name: "organization", Env: "IAM_ORG_ADMIN_APPS"}
|
||||
|
||||
// CapServiceAccountRead gates LISTING an org's service accounts — names and
|
||||
// metadata only, never secrets. It is the read-only counterpart to
|
||||
// CapKeyMint (a read cap can never mint, rotate, or delete a credential) and
|
||||
// is additionally tenant-bound by BoundToOrg.
|
||||
CapServiceAccountRead = Cap{Name: "service-account-read", Env: "IAM_SA_LIST_ALLOWED_APPS"}
|
||||
)
|
||||
|
||||
// Allowed reports whether p holds c.
|
||||
//
|
||||
// A non-app principal holds every capability vacuously: this gate concerns
|
||||
// confidential clients ONLY, and a human's authority is decided by the org
|
||||
// policy in authorize(). Conflating the two would either lock every human out or
|
||||
// hand every app a human's scope.
|
||||
//
|
||||
// Fail-secure, exactly as v1: an app whose allowlist is unset, empty, or does
|
||||
// not name it holds nothing.
|
||||
func Allowed(p *Principal, c Cap) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p.App == "" {
|
||||
return true // not an app; the org policy decides
|
||||
}
|
||||
// The owner-pin: an app holds a platform capability ONLY when its OWNING org is a
|
||||
// reserved platform signing owner (admin/built-in). Every allow-listed console is
|
||||
// admin-owned, so this never revokes a legitimate grant — but it binds the NAME
|
||||
// allowlist to the platform: a tenant that registers <theirOrg>/hanzo-console
|
||||
// (same name, its OWN owner) is not a signing owner, so it inherits nothing. This
|
||||
// is the single gate that turns the allowlist's NAME key from a spoofable label
|
||||
// into an authority reserved to the admin-owned app.
|
||||
if !store.IsSigningCertOwner(p.AppOwner) {
|
||||
return false
|
||||
}
|
||||
if c.Env == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.Split(os.Getenv(c.Env), ",") {
|
||||
if strings.TrimSpace(item) == p.App {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BoundToOrg reports whether an app principal is bound to org by the
|
||||
// <org>-<app> naming convention — app/hanzo-team may act on organization=hanzo
|
||||
// and on no other tenant's. The org is derived from the (allowlist-reserved)
|
||||
// application NAME, so the binding holds regardless of the app row's owner, and
|
||||
// it is the same prefix rule the service-account names it reads obey.
|
||||
func BoundToOrg(p *Principal, org string) bool {
|
||||
if p == nil || org == "" {
|
||||
return false
|
||||
}
|
||||
prefix := org + "-"
|
||||
return len(p.App) > len(prefix) && strings.HasPrefix(p.App, prefix)
|
||||
}
|
||||
|
||||
// capFor maps an entity to the capability a confidential client needs to act on
|
||||
// it. An entity with NO mapping grants an app nothing: unmapped denies exactly
|
||||
// as an unset allowlist does, which IS v1's live behaviour for every capability
|
||||
// the deployment leaves empty — certs, providers, tokens, syncers, webhooks are
|
||||
// all deny-all by design, because no client credential should ever reach signing
|
||||
// material. Only the two entities a live confidential client touches are mapped:
|
||||
// the brand consoles create customer orgs, and cloud moves the onboarding user
|
||||
// into the org it just created.
|
||||
func capFor(entity string) Cap {
|
||||
switch entity {
|
||||
case "organizations":
|
||||
return CapOrgAdmin
|
||||
case "users":
|
||||
return CapUserAdmin
|
||||
}
|
||||
return Cap{}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package bootstrap serves the operator-driven service-account provisioning
|
||||
// endpoints — `POST /v1/iam/admin/{applications,users}/upsert`. The Hanzo K8s
|
||||
// operator (operator-core) reconciles an IAM CR's spec.applications[]/users[] here,
|
||||
// wiring the service-account OAuth apps that KMS/signers authenticate with, with NO
|
||||
// human admin in the loop. It is idempotent (create OR update by the natural key)
|
||||
// so a ~30s reconcile is a no-op once converged.
|
||||
//
|
||||
// Auth is a UNIFIED SERVICE TOKEN presented as `Authorization: Bearer <token>`,
|
||||
// validated constant-time against the first non-empty of HANZO_API_KEY /
|
||||
// KMS_SERVICE_TOKEN / IAM_SERVICE_TOKEN — the same pipeline the old iam used. The
|
||||
// token is system-level (bypasses the org-membership gate), so these routes live in
|
||||
// the PUBLIC group (before the Guard) and self-authenticate here. An unset token
|
||||
// fails closed: no service token configured → no bootstrap.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/internal/cred"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Route registers the bootstrap upsert endpoints on the PUBLIC group r (they
|
||||
// self-authenticate via the service token, not a bearer principal).
|
||||
func Route(r zip.Router, db orm.DB) {
|
||||
r.Post("/v1/iam/admin/applications/upsert", upsertApplication(db))
|
||||
r.Post("/v1/iam/admin/users/upsert", upsertUser(db))
|
||||
}
|
||||
|
||||
// serviceToken returns the configured unified service token, or "" (fail closed).
|
||||
func serviceToken() string {
|
||||
for _, key := range []string{"HANZO_API_KEY", "KMS_SERVICE_TOKEN", "IAM_SERVICE_TOKEN"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authService validates the Bearer service token (constant-time). An unset expected
|
||||
// token, or a mismatch, is unauthorized.
|
||||
func authService(c *zip.Ctx) bool {
|
||||
expected := serviceToken()
|
||||
if expected == "" {
|
||||
return false
|
||||
}
|
||||
const p = "Bearer "
|
||||
h := c.Header("Authorization")
|
||||
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
|
||||
return false
|
||||
}
|
||||
got := strings.TrimSpace(h[len(p):])
|
||||
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func unauthorized(c *zip.Ctx) error {
|
||||
return c.JSON(401, map[string]any{"status": "error", "msg": "a valid service token is required"})
|
||||
}
|
||||
|
||||
// appUpsertReq is the operator's application upsert body (operator-core UpsertRequest).
|
||||
type appUpsertReq struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
ClientId string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
GrantTypes []string `json:"grantTypes"`
|
||||
RedirectUris []string `json:"redirectUris"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Cert string `json:"cert"`
|
||||
}
|
||||
|
||||
// upsertApplication idempotently creates or updates a service-account application,
|
||||
// keyed by (Owner="admin", Name) — applications are platform-owned. Returns
|
||||
// {status:"ok", action:"created"|"updated", data:{name, organization, clientId,
|
||||
// clientSecret}} — the shape operator-core parses. A missing clientSecret preserves
|
||||
// the existing one (no rotation on a steady-state reconcile) or is generated.
|
||||
func upsertApplication(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !authService(c) {
|
||||
return unauthorized(c)
|
||||
}
|
||||
ctx := c.Context()
|
||||
var req appUpsertReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body: "+err.Error()))
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
return c.JSON(400, errResp("name is required"))
|
||||
}
|
||||
|
||||
existing, err := store.GetApplicationByName(ctx, db, "admin", req.Name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if req.ClientSecret == "" {
|
||||
if existing != nil && existing.ClientSecret != "" {
|
||||
req.ClientSecret = existing.ClientSecret
|
||||
} else {
|
||||
req.ClientSecret = randomSecret()
|
||||
}
|
||||
}
|
||||
if req.ClientId == "" {
|
||||
req.ClientId = req.Name // <org>-<app> convention: clientId == name
|
||||
}
|
||||
|
||||
action := "created"
|
||||
if existing != nil {
|
||||
action = "updated"
|
||||
existing.ClientId = req.ClientId
|
||||
existing.ClientSecret = req.ClientSecret
|
||||
existing.Organization = pick(req.Organization, existing.Organization)
|
||||
if req.DisplayName != "" {
|
||||
existing.DisplayName = req.DisplayName
|
||||
}
|
||||
if len(req.GrantTypes) > 0 {
|
||||
existing.GrantTypes = req.GrantTypes
|
||||
}
|
||||
if len(req.RedirectUris) > 0 {
|
||||
existing.RedirectUris = req.RedirectUris
|
||||
}
|
||||
if req.Cert != "" {
|
||||
existing.Cert = req.Cert
|
||||
}
|
||||
existing.EnablePassword = true
|
||||
if err := existing.UpdateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
} else {
|
||||
a := orm.New[schema.Application](db)
|
||||
model := a.Model
|
||||
a.Owner, a.Name = "admin", req.Name
|
||||
a.ClientId, a.ClientSecret = req.ClientId, req.ClientSecret
|
||||
a.Organization, a.DisplayName = req.Organization, pick(req.DisplayName, req.Name)
|
||||
a.GrantTypes, a.RedirectUris, a.Cert = req.GrantTypes, req.RedirectUris, req.Cert
|
||||
a.EnablePassword, a.ExpireInHours = true, 1
|
||||
a.Model = model
|
||||
a.SetId("admin/" + req.Name)
|
||||
if err := a.CreateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok", "action": action,
|
||||
"data": map[string]any{
|
||||
"name": req.Name, "organization": req.Organization,
|
||||
"clientId": req.ClientId, "clientSecret": req.ClientSecret,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// userUpsertReq is the operator's user upsert body.
|
||||
type userUpsertReq struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
PasswordType string `json:"passwordType"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// upsertUser idempotently creates or updates a user keyed by (Owner, Name). The
|
||||
// password is argon2id-hashed (SOTA; never stored plaintext); an empty password preserves
|
||||
// the existing credential. Returns {status:"ok", action, data:{owner, name}}.
|
||||
func upsertUser(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if !authService(c) {
|
||||
return unauthorized(c)
|
||||
}
|
||||
ctx := c.Context()
|
||||
var req userUpsertReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body: "+err.Error()))
|
||||
}
|
||||
req.Owner, req.Name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
|
||||
if req.Owner == "" || req.Name == "" {
|
||||
return c.JSON(400, errResp("owner and name are required"))
|
||||
}
|
||||
|
||||
var hash string
|
||||
if req.Password != "" {
|
||||
h, err := cred.Hash(req.Password)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
hash = h
|
||||
}
|
||||
|
||||
existing, err := store.GetUserByName(ctx, db, req.Owner, req.Name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
action := "created"
|
||||
if existing != nil {
|
||||
action = "updated"
|
||||
existing.DisplayName = pick(req.DisplayName, existing.DisplayName)
|
||||
existing.Email = pick(req.Email, existing.Email)
|
||||
existing.Phone = pick(req.Phone, existing.Phone)
|
||||
existing.IsAdmin = req.IsAdmin
|
||||
if hash != "" {
|
||||
existing.PasswordHash, existing.PasswordType, existing.PasswordSalt = hash, cred.TypeArgon2id, ""
|
||||
}
|
||||
existing.UpdatedTime = now()
|
||||
if err := existing.UpdateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
} else {
|
||||
u := orm.New[schema.User](db)
|
||||
model := u.Model
|
||||
u.Owner, u.Name = req.Owner, req.Name
|
||||
u.DisplayName, u.Email, u.Phone, u.IsAdmin = req.DisplayName, req.Email, req.Phone, req.IsAdmin
|
||||
if hash != "" {
|
||||
u.PasswordHash, u.PasswordType = hash, cred.TypeArgon2id
|
||||
}
|
||||
u.CreatedTime, u.UpdatedTime = now(), now()
|
||||
u.Model = model
|
||||
u.SetId(req.Owner + "/" + req.Name)
|
||||
if err := u.CreateCtx(ctx); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"status": "ok", "action": action,
|
||||
"data": map[string]any{"owner": req.Owner, "name": req.Name},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// decode reads the raw JSON body (content-type independent) into v.
|
||||
func decode(c *zip.Ctx, v any) error {
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return errors.New("empty request body")
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
|
||||
|
||||
// pick returns a if non-empty (trimmed), else b.
|
||||
func pick(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// randomSecret returns a 32-byte URL-safe random client secret.
|
||||
func randomSecret() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
const svcToken = "svc-token-secret-value"
|
||||
|
||||
func boot(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
t.Setenv("IAM_SERVICE_TOKEN", svcToken)
|
||||
_ = schema.Kinds()
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "boot.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
app := zip.New(zip.Config{AppName: "bootstrap-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
return app, db
|
||||
}
|
||||
|
||||
func post(t *testing.T, app *zip.App, path, token, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
func TestUpsertApplication_createThenIdempotentUpdate(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
body := `{"organization":"hanzo","name":"hanzo-kms","clientId":"hanzo-kms","grantTypes":["client_credentials"]}`
|
||||
|
||||
// Create — a secret is generated, action=created.
|
||||
st, m := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
|
||||
if st != 200 || m["status"] != "ok" || m["action"] != "created" {
|
||||
t.Fatalf("create: status=%d body=%v", st, m)
|
||||
}
|
||||
data, _ := m["data"].(map[string]any)
|
||||
secret, _ := data["clientSecret"].(string)
|
||||
if secret == "" {
|
||||
t.Fatalf("no clientSecret generated: %v", data)
|
||||
}
|
||||
if a, _ := store.GetApplicationByName(context.Background(), db, "admin", "hanzo-kms"); a == nil {
|
||||
t.Fatalf("app not persisted")
|
||||
}
|
||||
|
||||
// Re-upsert with NO secret — idempotent: action=updated, the SAME secret is
|
||||
// preserved (no rotation storm on a steady-state reconcile).
|
||||
st2, m2 := post(t, app, "/v1/iam/admin/applications/upsert", svcToken, body)
|
||||
if st2 != 200 || m2["action"] != "updated" {
|
||||
t.Fatalf("re-upsert: status=%d body=%v", st2, m2)
|
||||
}
|
||||
data2, _ := m2["data"].(map[string]any)
|
||||
if data2["clientSecret"] != secret {
|
||||
t.Fatalf("clientSecret rotated on idempotent re-upsert: %v → %v", secret, data2["clientSecret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUser_createHashesPassword(t *testing.T) {
|
||||
app, db := boot(t)
|
||||
body := `{"owner":"hanzo","name":"svc-signer","password":"s3cret","isAdmin":false}`
|
||||
st, m := post(t, app, "/v1/iam/admin/users/upsert", svcToken, body)
|
||||
if st != 200 || m["action"] != "created" {
|
||||
t.Fatalf("create user: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "svc-signer")
|
||||
if u == nil || u.PasswordHash == "" || u.PasswordHash == "s3cret" {
|
||||
t.Fatalf("password not hashed: %+v", u)
|
||||
}
|
||||
if u.PasswordType != "argon2id" {
|
||||
t.Fatalf("passwordType = %q, want argon2id", u.PasswordType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrap_requiresServiceToken(t *testing.T) {
|
||||
app, _ := boot(t)
|
||||
body := `{"name":"x"}`
|
||||
// No token → 401.
|
||||
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "", body); st != 401 {
|
||||
t.Fatalf("no-token status = %d, want 401", st)
|
||||
}
|
||||
// Wrong token → 401.
|
||||
if st, _ := post(t, app, "/v1/iam/admin/applications/upsert", "wrong-token", body); st != 401 {
|
||||
t.Fatalf("wrong-token status = %d, want 401", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package certs serves the IAM v2 CRUD surface for the `certs` entity: a
|
||||
// signing / TLS certificate owner-scoped by (owner, name). Every operation is a
|
||||
// typed zip handler over hanzoai/orm; the orm string key is "owner/name". Reads
|
||||
// scope to one owner (organization); writes address one cert by its (owner,
|
||||
// name) key.
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Handler binds the certs operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
// Route registers the certs CRUD routes on app against db. Reads are zip.Get,
|
||||
// writes are zip.Post; the create/update body is the schema.Cert row itself, so
|
||||
// the wire contract and the stored entity never drift.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/certs", h.List, zip.WithSummary("List certs for an owner"), zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs", h.Create, zip.WithSummary("Create a cert"), zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs/get", h.Get, zip.WithSummary("Get one cert"), zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs/update", h.Update, zip.WithSummary("Update a cert"), zip.WithTags("certs"))
|
||||
zip.Post(app, "/v1/iam/certs/delete", h.Delete, zip.WithSummary("Delete a cert"), zip.WithTags("certs"))
|
||||
}
|
||||
|
||||
// Ref addresses one cert by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of certs.
|
||||
type ListOutput struct {
|
||||
Certs []*schema.Cert `json:"certs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// List returns the certs the caller may read, newest first, secrets masked. The
|
||||
// owner is resolved by authz.Scope from the authenticated principal — a tenant
|
||||
// reads only its own org, a SuperAdmin reads the owner it asks for — so a query
|
||||
// parameter can never widen a listing beyond the bearer's authority.
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
owner, err := authz.Scope(ctx, in.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.Cert](h.db)
|
||||
if owner != "" {
|
||||
q = q.Filter("owner", owner)
|
||||
}
|
||||
certs, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
out := make([]*schema.Cert, len(certs))
|
||||
for i, c := range certs {
|
||||
out[i] = c.Mask()
|
||||
}
|
||||
return &ListOutput{Certs: out, Total: len(out)}, nil
|
||||
}
|
||||
|
||||
// Get returns one cert addressed by (owner, name), secrets masked.
|
||||
func (h *Handler) Get(_ context.Context, in *Ref) (*schema.Cert, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return cert.Mask(), nil
|
||||
}
|
||||
|
||||
// Create persists a new cert. It rejects a duplicate (owner, name) and stamps
|
||||
// CreatedTime when the caller leaves it blank.
|
||||
func (h *Handler) Create(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
switch _, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("cert already exists")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
// orm.New wires the store and applies defaults; overlay the decoded row,
|
||||
// then restore the wired Model so its db handle survives the assignment.
|
||||
cert := orm.New[schema.Cert](h.db)
|
||||
model := cert.Model
|
||||
*cert = *in
|
||||
cert.Model = model
|
||||
if cert.CreatedTime == "" {
|
||||
cert.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
cert.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := cert.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Update overwrites a cert's mutable fields. Identity (owner, name) and the
|
||||
// CreatedTime stamp are immutable; a missing cert is a 404.
|
||||
func (h *Handler) Update(ctx context.Context, in *schema.Cert) (*schema.Cert, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
// Keep the loaded Model (id, createdAt, key, snapshot) and the original
|
||||
// creation stamp; overlay the decoded domain fields onto them.
|
||||
model := cert.Model
|
||||
created := cert.CreatedTime
|
||||
*cert = *in
|
||||
cert.Model = model
|
||||
cert.Owner, cert.Name = in.Owner, in.Name
|
||||
if created != "" {
|
||||
cert.CreatedTime = created
|
||||
}
|
||||
if err := cert.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// Delete removes one cert addressed by (owner, name).
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
cert, err := orm.Get[schema.Cert](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := cert.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return zip.ErrNotFound("cert not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
@@ -44,6 +44,8 @@ var mapping = []pair{
|
||||
{"token", "tokens"},
|
||||
{"record", "audit_logs"},
|
||||
{"invitation", "invitations"},
|
||||
{"web3_nonce", "challenges"},
|
||||
{"wallet_link", "wallets"},
|
||||
}
|
||||
|
||||
// Run writes a tab-aligned per-entity drift report to w. ctx bounds every
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package compat serves the Casdoor VERB surface (get-users, get-organizations,
|
||||
// …) over iam2's orm store, in the v1 Response envelope. It exists because every
|
||||
// live consumer — the console admin BFF, the gateway admin-api, the hanzo.id
|
||||
// portal — hard-codes the Casdoor verb spellings and the `{status,data,data2}`
|
||||
// envelope, while iam2's native surface is REST (`/v1/iam/users`,
|
||||
// `/v1/iam/users/get`). Without these aliases a backend swap 404s every console
|
||||
// IAM page. The aliases are a thin routing + envelope layer over the SAME orm
|
||||
// store and the SAME schema.Mask redaction the REST handlers use — no CRUD and
|
||||
// no redaction is reimplemented here.
|
||||
//
|
||||
// Authorization is NOT reimplemented either. These paths are not in authz's
|
||||
// public allowlist, so the Guard (app.Use, mounted first) authenticates every
|
||||
// request AND authorizes the read against the exact (owner, name) it addresses —
|
||||
// resolved by the same authz.ReadTarget the handlers use, so a handler can never
|
||||
// reach a row the Guard did not authorize. Each handler then re-scopes the query
|
||||
// owner through authz.Scope: a SuperAdmin may list any owner (empty = all
|
||||
// tenants), everyone else is pinned to their own org, so a request parameter can
|
||||
// never widen a read past the caller's authority.
|
||||
package compat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Route registers the Casdoor read-verb aliases. The mask argument is the
|
||||
// entity's schema.Mask method (the ONE redaction contract) for entities that
|
||||
// carry secrets, or nil for those that do not — nil means "no field to strip",
|
||||
// not "skip a needed redaction". Writes ride a companion file.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
// List reads — `?owner=&p=&pageSize=` (Casdoor shape). Owner-scoped by authz.
|
||||
app.Get("/v1/iam/get-organizations", listHandler(db, (*schema.Organization).Mask))
|
||||
app.Get("/v1/iam/get-users", listHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-global-users", listHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-applications", listHandler(db, (*schema.Application).Mask))
|
||||
app.Get("/v1/iam/get-providers", listHandler(db, (*schema.Provider).Mask))
|
||||
app.Get("/v1/iam/get-certs", listHandler(db, (*schema.Cert).Mask))
|
||||
app.Get("/v1/iam/get-roles", listHandler[schema.Role](db, nil))
|
||||
app.Get("/v1/iam/get-permissions", listHandler[schema.Permission](db, nil))
|
||||
app.Get("/v1/iam/get-invitations", listHandler[schema.Invitation](db, nil))
|
||||
app.Get("/v1/iam/get-records", listHandler[schema.AuditLog](db, nil))
|
||||
|
||||
// Single reads — `?id=<owner>/<name>` (or `?owner=&name=`).
|
||||
app.Get("/v1/iam/get-organization", getHandler(db, (*schema.Organization).Mask))
|
||||
app.Get("/v1/iam/get-user", getHandler(db, (*schema.User).Mask))
|
||||
app.Get("/v1/iam/get-application", getHandler(db, (*schema.Application).Mask))
|
||||
app.Get("/v1/iam/get-provider", getHandler(db, (*schema.Provider).Mask))
|
||||
app.Get("/v1/iam/get-cert", getHandler(db, (*schema.Cert).Mask))
|
||||
app.Get("/v1/iam/get-role", getHandler[schema.Role](db, nil))
|
||||
app.Get("/v1/iam/get-permission", getHandler[schema.Permission](db, nil))
|
||||
|
||||
// get-organization-projects — the console ScopeSwitcher's project list, keyed by
|
||||
// ?organization= (not ?owner=). Its target rides in ?organization, which the Guard
|
||||
// does not inspect generically, so this path is handler-authorized (authz's
|
||||
// handlerAuthorizedPrefixes): the Guard authenticates, and this handler scopes the
|
||||
// requested org through authz.Scope — a non-super is pinned to its own org, so any
|
||||
// authenticated member lists exactly its own org's projects (the ScopeSwitcher is
|
||||
// shown to every user, not only admins, so this read is intentionally not
|
||||
// admin-gated the way the generic listers are).
|
||||
app.Get("/v1/iam/get-organization-projects", orgProjectsHandler(db))
|
||||
|
||||
// get-organization-workspaces — the console ScopeSwitcher's workspace list, the
|
||||
// tier above projects in the Organization → Workspace → Project hierarchy. Keyed
|
||||
// by ?organization= exactly like get-organization-projects, so it is
|
||||
// handler-authorized (authz's handlerAuthorizedPrefixes) the same way: the Guard
|
||||
// authenticates, and this handler scopes the requested org through authz.Scope so
|
||||
// a request parameter can never widen the read past the caller's tenant.
|
||||
app.Get("/v1/iam/get-organization-workspaces", orgWorkspacesHandler(db))
|
||||
|
||||
// The Casdoor WRITE verbs (companion file), over the same store + authz seam.
|
||||
routeWrites(app, db)
|
||||
}
|
||||
|
||||
// orgProjectsHandler serves get-organization-projects: the org's project list for
|
||||
// the console ScopeSwitcher. The requested org rides in ?organization= (or ?owner=
|
||||
// as a fallback); authz.Scope pins a non-super to its own org, so a request
|
||||
// parameter can never widen the read past the caller's tenant. Projects carry no
|
||||
// secrets, so no Mask is applied.
|
||||
func orgProjectsHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
requested := c.Query("organization")
|
||||
if requested == "" {
|
||||
requested = c.Query("owner")
|
||||
}
|
||||
owner, err := authz.Scope(ctx, requested)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
q := orm.TypedQuery[schema.Project](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
rows, err := q.Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// orgWorkspacesHandler serves get-organization-workspaces: the org's workspace
|
||||
// list for the console ScopeSwitcher. The requested org rides in ?organization=
|
||||
// (or ?owner= as a fallback); authz.Scope pins a non-super to its own org, so a
|
||||
// request parameter can never widen the read past the caller's tenant. Workspaces
|
||||
// carry no secrets, so no Mask is applied.
|
||||
func orgWorkspacesHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
requested := c.Query("organization")
|
||||
if requested == "" {
|
||||
requested = c.Query("owner")
|
||||
}
|
||||
owner, err := authz.Scope(ctx, requested)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
q := orm.TypedQuery[schema.Workspace](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
rows, err := q.Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// listHandler serves a Casdoor get-<entities> list for one orm kind: it scopes
|
||||
// the owner through authz, queries the store, redacts each row via the entity's
|
||||
// Mask, and wraps the result in the v1 envelope. Per the v1 contract a list
|
||||
// paginates ONLY when BOTH `p` and `pageSize` are present — then the total rides
|
||||
// in data2; otherwise the full owner-scoped set is returned with no data2.
|
||||
//
|
||||
// Scoping note (intentional, fail-closed): iam2's ownership model is mixed —
|
||||
// users/roles/permissions are owned by their tenant org, while organizations/
|
||||
// applications/providers/certs are platform-owned (Owner "admin"). A SuperAdmin
|
||||
// (Scope → the requested owner, empty = all) therefore lists every entity, which
|
||||
// is the console-admin path. A non-super is pinned by Scope to its own org, so it
|
||||
// lists its tenant-owned entities correctly and is refused the platform-owned
|
||||
// lists at the Guard (owner "" or "admin" both deny) — a safe 403, never another
|
||||
// tenant's rows. Non-super, membership-scoped views of the platform-owned
|
||||
// entities (e.g. an org console's own app list keyed on Application.Organization)
|
||||
// are a separate, additive surface, not a silent behavior of this generic lister.
|
||||
func listHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, err := authz.Scope(ctx, c.Query("owner"))
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
|
||||
base := func() *orm.ModelQuery[T] {
|
||||
q := orm.TypedQuery[T](db)
|
||||
if owner != "" {
|
||||
q = q.Filter("Owner=", owner)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
page, size, paginated := pageParams(c)
|
||||
if !paginated {
|
||||
rows, err := base().Order("Name").GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, maskAll(rows, mask))
|
||||
}
|
||||
|
||||
total, err := base().Count(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
rows, err := base().Order("Name").Limit(size).Offset((page - 1) * size).GetAll(ctx)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return c.JSON(200, httpx.Response{Status: "ok", Data: maskAll(rows, mask), Data2: total})
|
||||
}
|
||||
}
|
||||
|
||||
// getHandler serves a Casdoor get-<entity> single read. The target is resolved
|
||||
// by authz.ReadTarget (the same extraction the Guard authorized with), then the
|
||||
// owner is re-scoped through authz.Scope so a non-super can never read another
|
||||
// tenant's row even if it spells one in `?id`.
|
||||
func getHandler[T any](db orm.DB, mask func(*T) *T) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name := authz.ReadTarget(c)
|
||||
if name == "" {
|
||||
return httpx.Err(c, "id (owner/name) or name is required")
|
||||
}
|
||||
scoped, err := authz.Scope(ctx, owner)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
row, err := orm.TypedQuery[T](db).Filter("Owner=", scoped).Filter("Name=", name).First()
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return httpx.Err(c, "the entity does not exist")
|
||||
}
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if mask != nil {
|
||||
row = mask(row)
|
||||
}
|
||||
return httpx.Ok(c, row)
|
||||
}
|
||||
}
|
||||
|
||||
// maskAll redacts every row through the entity's Mask (a no-op when the entity
|
||||
// has no secrets, i.e. mask is nil). Mask returns a copy, so the slice is
|
||||
// rewritten in place with the masked copies.
|
||||
func maskAll[T any](rows []*T, mask func(*T) *T) []*T {
|
||||
if mask == nil {
|
||||
return rows
|
||||
}
|
||||
for i, r := range rows {
|
||||
rows[i] = mask(r)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// pageParams returns (page, size, paginated). A list paginates ONLY when BOTH
|
||||
// `p` and `pageSize` are present and positive; otherwise the caller returns the
|
||||
// full set (v1 semantics).
|
||||
func pageParams(c *zip.Ctx) (page, size int, paginated bool) {
|
||||
pp, ps := c.Query("p"), c.Query("pageSize")
|
||||
if pp == "" || ps == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
page, _ = strconv.Atoi(pp)
|
||||
size, _ = strconv.Atoi(ps)
|
||||
if page <= 0 || size <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return page, size, true
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package compat_test
|
||||
|
||||
// End-to-end tests for the Casdoor verb aliases, driven through the REAL mounted
|
||||
// router (routes.Mount installs the authz Guard between the public group and the
|
||||
// authed routes; compat is registered after it, so gated). Every
|
||||
// case is a wire request a live console/gateway client sends. The assertions are
|
||||
// the three contracts a backend swap depends on: the v1 {status,data,data2}
|
||||
// envelope shape, owner-scoping that no request parameter can widen, and — the
|
||||
// security one — that NO secret material ever appears in a response body.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo"
|
||||
|
||||
// Distinctive secret sentinels: if any of these strings appears in ANY response
|
||||
// body, redaction failed and a real credential leaked.
|
||||
const (
|
||||
secretUserHash = "$argon2id$SENTINEL_USER_PW_HASH"
|
||||
secretOrgMaster = "SENTINEL_ORG_MASTER_PW"
|
||||
secretAppClient = "SENTINEL_APP_CLIENT_SECRET"
|
||||
secretProvClient = "SENTINEL_PROVIDER_CLIENT_SECRET"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "compat.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Trust anchor (admin-owned RS256 signing cert = JWKS kid).
|
||||
seedCert(t, db, "admin", signingKid, pemOf(t, key))
|
||||
|
||||
// Principals across two orgs: a SuperAdmin, an org-admin, a regular user.
|
||||
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
|
||||
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
|
||||
seedUser(t, db, "orgb", "bob", true) // org-admin of a second tenant
|
||||
|
||||
// Secret-bearing rows: every one carries a sentinel that must never surface.
|
||||
// users already seeded carry a password hash sentinel (set in seedUser).
|
||||
seedOrg(t, db, "hanzo") // Owner="admin", Name="hanzo", MasterPassword sentinel
|
||||
seedApp(t, db, "hanzo-console") // Owner="admin", ClientSecret sentinel
|
||||
seedProvider(t, db, "provider-gh") // Owner="admin", ClientSecret sentinel
|
||||
|
||||
app := zip.New(zip.Config{AppName: "compat-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = signingKid
|
||||
s, err := tok.SignedString(h.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// get issues a GET through the real router and returns (status, rawBody).
|
||||
func (h *harness) get(t *testing.T, path, bearer string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := h.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// envelope is the v1 Response shape the clients parse.
|
||||
type envelope struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Data2 json.RawMessage `json:"data2"`
|
||||
}
|
||||
|
||||
// ---- assertions ------------------------------------------------------------
|
||||
|
||||
func TestGetUsers_super_envelopeAndNoSecretLeak(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
t.Fatalf("body is not the v1 envelope: %v; body=%s", err, body)
|
||||
}
|
||||
if env.Status != "ok" {
|
||||
t.Fatalf("status field = %q, want ok", env.Status)
|
||||
}
|
||||
// SuperAdmin, no owner filter → every user across every org (4 seeded).
|
||||
if len(env.Data) != 4 {
|
||||
t.Fatalf("super get-users returned %d users, want 4", len(env.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_paged_data2IsTotal(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, body := h.get(t, "/v1/iam/get-users?p=1&pageSize=2", h.token(t, "admin/root"))
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
t.Fatalf("not the v1 envelope: %v", err)
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("page 1 pageSize 2 returned %d rows, want 2", len(env.Data))
|
||||
}
|
||||
// data2 carries the FULL owner-scoped total (4), not the page length.
|
||||
var total int
|
||||
if err := json.Unmarshal(env.Data2, &total); err != nil {
|
||||
t.Fatalf("data2 is not an int total: %v (data2=%s)", err, env.Data2)
|
||||
}
|
||||
if total != 4 {
|
||||
t.Fatalf("data2 total = %d, want 4", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_unpaged_hasNoData2(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
_, body := h.get(t, "/v1/iam/get-users", h.token(t, "admin/root"))
|
||||
// v1 omits data2 entirely when the list is not paginated.
|
||||
if strings.Contains(body, "\"data2\"") {
|
||||
t.Fatalf("unpaged list must omit data2; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrganizations_super_listsAll_masked(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-organizations", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d; body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
// The masked org keeps its "***" sentinel, proving Mask ran (not the raw pw).
|
||||
if !strings.Contains(body, "***") {
|
||||
t.Fatalf("expected the masked '***' marker in the org list; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications_super_noClientSecret(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-applications", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
func TestGetProviders_super_noClientSecret(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.get(t, "/v1/iam/get-providers", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
func TestGetUser_byId_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// The Casdoor `?id=<owner>/<name>` shape — resolved by authz.ReadTarget.
|
||||
status, body := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
if !strings.Contains(body, "alice") {
|
||||
t.Fatalf("get-user?id=hanzo/alice did not return alice; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_orgAdmin_scopedToOwnOrg(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// An org-admin MUST pass its own owner (the Guard denies an empty owner for a
|
||||
// non-super); it then sees only its org's users.
|
||||
status, body := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/boss"))
|
||||
if status != 200 {
|
||||
t.Fatalf("status=%d body=%s", status, body)
|
||||
}
|
||||
var env envelope
|
||||
_ = json.Unmarshal([]byte(body), &env)
|
||||
if len(env.Data) != 2 { // hanzo/boss + hanzo/alice, never orgb/bob
|
||||
t.Fatalf("org-admin get-users?owner=hanzo returned %d, want 2 (own org only)", len(env.Data))
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
func TestGetUsers_orgAdmin_crossTenantDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// hanzo's admin cannot list orgb's users — the Guard refuses a foreign owner.
|
||||
status, _ := h.get(t, "/v1/iam/get-users?owner=orgb", h.token(t, "hanzo/boss"))
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant get-users status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_byId_crossTenantDenied(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// The `?id=` fallback must not open a cross-tenant hole: hanzo's admin naming
|
||||
// orgb/bob is refused at the Guard, exactly as the ?owner= form is.
|
||||
status, _ := h.get(t, "/v1/iam/get-user?id=orgb/bob", h.token(t, "hanzo/boss"))
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant get-user?id=orgb/bob status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers_regularUser_cannotList(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// A non-admin user may not enumerate its org's users (the self-service rule is
|
||||
// a single-record read, never a list).
|
||||
status, _ := h.get(t, "/v1/iam/get-users?owner=hanzo", h.token(t, "hanzo/alice"))
|
||||
if status != 403 {
|
||||
t.Fatalf("regular-user get-users status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications_nonSuper_deniedOnPlatformOwned(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// Applications are platform-owned (Owner "admin"); a non-super gets a safe 403
|
||||
// at the Guard, never another tenant's app rows.
|
||||
status, _ := h.get(t, "/v1/iam/get-applications", h.token(t, "hanzo/boss"))
|
||||
if status != 403 {
|
||||
t.Fatalf("non-super get-applications status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatAliases_requireAuth(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// No bearer → the Guard fails closed (compat is registered after the Guard).
|
||||
if status, _ := h.get(t, "/v1/iam/get-users", ""); status != 401 {
|
||||
t.Fatalf("unauthenticated get-users status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoSecretLeak fails if any seeded secret sentinel appears in the body —
|
||||
// the single most important property of the whole layer.
|
||||
func assertNoSecretLeak(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{secretUserHash, secretOrgMaster, secretAppClient, secretProvClient} {
|
||||
if strings.Contains(body, secret) {
|
||||
t.Fatalf("SECRET LEAK: %q appeared in a response body:\n%s", secret, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seed helpers ----------------------------------------------------------
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin = admin
|
||||
u.PasswordHash = secretUserHash // the sentinel that must never surface
|
||||
u.PasswordType = "argon2id"
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrg(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", name // orgs are platform-owned
|
||||
o.MasterPassword = secretOrgMaster
|
||||
o.SetId("admin/" + name)
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed org: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedApp(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name = "admin", name
|
||||
a.Organization = "hanzo"
|
||||
a.ClientSecret = secretAppClient
|
||||
a.SetId("admin/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedProvider(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
p := orm.New[schema.Provider](db)
|
||||
p.Owner, p.Name = "admin", name
|
||||
p.ClientSecret = secretProvClient
|
||||
p.SetId("admin/" + name)
|
||||
if err := p.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/applications"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/organizations"
|
||||
"github.com/hanzoai/iam/internal/projects"
|
||||
"github.com/hanzoai/iam/internal/providers"
|
||||
"github.com/hanzoai/iam/internal/roles"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
"github.com/hanzoai/iam/internal/workspaces"
|
||||
)
|
||||
|
||||
// The Casdoor WRITE verbs (add-organization, add-user, update-user,
|
||||
// update-application) the console admin BFF hard-codes, served over the SAME entity
|
||||
// Create/Update logic as the REST surface — no CRUD is reimplemented here. Each is a
|
||||
// TYPED zip op (not a raw handler), which is what preserves authorization: the ONE
|
||||
// authz seam (app.Authorize) runs at every typed op's invoke on the DECODED input, so
|
||||
// a write alias is authorized against the exact (owner, name) it will bind — a super
|
||||
// for a platform-owned org/app, an org-admin for its own users — identical to the REST
|
||||
// twin. The result is wrapped in the casibase {status,msg,data} envelope the clients
|
||||
// parse; the data is the REDACTED entity (each Create/Update returns Mask()).
|
||||
//
|
||||
// Read verbs ride aliases.go; these are the "Writes ride a companion file" half.
|
||||
|
||||
// routeWrites registers the Casdoor write-verb aliases on app. Called from Route
|
||||
// (aliases.go) so reads and writes share the one Guard/Authorize seam.
|
||||
func routeWrites(app *zip.App, db orm.DB) {
|
||||
orgs := organizations.NewOrganizationAPI(db)
|
||||
usersAPI := users.New(db)
|
||||
appCreate, appUpdate, appDelete := applications.Create(db), applications.Update(db), applications.Delete(db)
|
||||
rolesH := roles.New(db)
|
||||
projectsH := projects.New(db)
|
||||
workspacesH := workspaces.New(db)
|
||||
provAdd, provUpdate, provDelete := providers.Add(db), providers.Update(db), providers.Delete(db)
|
||||
|
||||
zip.Post(app, "/v1/iam/add-organization",
|
||||
func(ctx context.Context, in *organizations.CreateOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Create(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("addOrganization"), zip.WithSummary("Create an organization (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
zip.Post(app, "/v1/iam/add-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Create(ctx, &users.CreateInput{User: in.User, Password: in.Password}))
|
||||
},
|
||||
zip.WithOperationID("addUser"), zip.WithSummary("Create a user (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
zip.Post(app, "/v1/iam/update-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Update(ctx, &users.UpdateInput{User: in.User, Password: in.Password}))
|
||||
},
|
||||
zip.WithOperationID("updateUser"), zip.WithSummary("Update a user (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
zip.Post(app, "/v1/iam/update-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appUpdate(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("updateApplication"), zip.WithSummary("Update an application (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// delete-user — the console IamAdminApi + /org/iam admin mutation.
|
||||
zip.Post(app, "/v1/iam/delete-user",
|
||||
func(ctx context.Context, in *userBody) (*httpx.Response, error) {
|
||||
return envelope(usersAPI.Delete(ctx, &users.Ref{Owner: in.Owner, Name: in.Name}))
|
||||
},
|
||||
zip.WithOperationID("deleteUser"), zip.WithSummary("Delete a user (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Applications: add-/delete- (update-application already above).
|
||||
zip.Post(app, "/v1/iam/add-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) { return envelope(appCreate(ctx, in)) },
|
||||
zip.WithOperationID("addApplication"), zip.WithSummary("Create an application (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-application",
|
||||
func(ctx context.Context, in *schema.Application) (*httpx.Response, error) {
|
||||
return envelope(appDelete(ctx, &applications.ApplicationRef{Owner: in.Owner, Name: in.Name}))
|
||||
},
|
||||
zip.WithOperationID("deleteApplication"), zip.WithSummary("Delete an application (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Providers: add-/update-/delete- (console admin Providers page).
|
||||
zip.Post(app, "/v1/iam/add-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provAdd(ctx, in)) },
|
||||
zip.WithOperationID("addProvider"), zip.WithSummary("Create a provider (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/update-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provUpdate(ctx, in)) },
|
||||
zip.WithOperationID("updateProvider"), zip.WithSummary("Update a provider (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-provider",
|
||||
func(ctx context.Context, in *schema.Provider) (*httpx.Response, error) { return envelope(provDelete(ctx, in)) },
|
||||
zip.WithOperationID("deleteProvider"), zip.WithSummary("Delete a provider (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Roles: add-/update-/delete- (console admin Roles page).
|
||||
zip.Post(app, "/v1/iam/add-role",
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) { return envelope(rolesH.Create(ctx, in)) },
|
||||
zip.WithOperationID("addRole"), zip.WithSummary("Create a role (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/update-role",
|
||||
func(ctx context.Context, in *roles.Input) (*httpx.Response, error) { return envelope(rolesH.Update(ctx, in)) },
|
||||
zip.WithOperationID("updateRole"), zip.WithSummary("Update a role (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-role",
|
||||
func(ctx context.Context, in *roles.Ref) (*httpx.Response, error) { return envelope(rolesH.Delete(ctx, in)) },
|
||||
zip.WithOperationID("deleteRole"), zip.WithSummary("Delete a role (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Projects: add-/delete- (console ScopeSwitcher; the read rides get-organization-projects
|
||||
// in aliases.go). Owner is the org, so app.Authorize gates a write to an org-admin
|
||||
// of that org — the same clause as add-role.
|
||||
zip.Post(app, "/v1/iam/add-project",
|
||||
func(ctx context.Context, in *projects.Input) (*httpx.Response, error) { return envelope(projectsH.Create(ctx, in)) },
|
||||
zip.WithOperationID("addProject"), zip.WithSummary("Create a project (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-project",
|
||||
func(ctx context.Context, in *projects.Ref) (*httpx.Response, error) { return envelope(projectsH.Delete(ctx, in)) },
|
||||
zip.WithOperationID("deleteProject"), zip.WithSummary("Delete a project (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Workspaces: add-/delete- (console ScopeSwitcher; the read rides
|
||||
// get-organization-workspaces in aliases.go). Owner is the org, so app.Authorize
|
||||
// gates a write to an org-admin of that org — the same clause as add-project.
|
||||
zip.Post(app, "/v1/iam/add-workspace",
|
||||
func(ctx context.Context, in *workspaces.Input) (*httpx.Response, error) { return envelope(workspacesH.Create(ctx, in)) },
|
||||
zip.WithOperationID("addWorkspace"), zip.WithSummary("Create a workspace (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-workspace",
|
||||
func(ctx context.Context, in *workspaces.Ref) (*httpx.Response, error) { return envelope(workspacesH.Delete(ctx, in)) },
|
||||
zip.WithOperationID("deleteWorkspace"), zip.WithSummary("Delete a workspace (Casdoor verb)"), zip.WithTags("compat"))
|
||||
|
||||
// Organizations: update-/delete- (add-organization already above).
|
||||
zip.Post(app, "/v1/iam/update-organization",
|
||||
func(ctx context.Context, in *organizations.UpdateOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Update(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("updateOrganization"), zip.WithSummary("Update an organization (Casdoor verb)"), zip.WithTags("compat"))
|
||||
zip.Post(app, "/v1/iam/delete-organization",
|
||||
func(ctx context.Context, in *organizations.DeleteOrganizationInput) (*httpx.Response, error) {
|
||||
return envelope(orgs.Delete(ctx, in))
|
||||
},
|
||||
zip.WithOperationID("deleteOrganization"), zip.WithSummary("Delete an organization (Casdoor verb)"), zip.WithTags("compat"))
|
||||
}
|
||||
|
||||
// userBody is the bare-user body the Casdoor add-user/update-user verbs post (the
|
||||
// user's fields at top level, plus an optional plaintext password), distinct from the
|
||||
// REST twin's {user,password} envelope. It embeds schema.User so the authz op-seam
|
||||
// reads the target (Owner, Name) straight off it, then the handler hands the parts to
|
||||
// the ONE users Create/Update path (which bcrypt-hashes the password — never stored
|
||||
// plaintext — and returns the redacted row).
|
||||
type userBody struct {
|
||||
schema.User
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// envelope wraps an entity Create/Update result in the casibase Response the Casdoor
|
||||
// clients parse: {status:"ok", data:<masked entity>} on success, or a 200
|
||||
// {status:"error", msg} on a handler error (the casibase convention — clients branch
|
||||
// on status, not the HTTP code), never an HTTP error status. An authorization refusal
|
||||
// happens earlier, at the op-seam, and surfaces as a 403 the clients already handle.
|
||||
func envelope[T any](entity *T, err error) (*httpx.Response, error) {
|
||||
if err != nil {
|
||||
return &httpx.Response{Status: "error", Msg: err.Error()}, nil
|
||||
}
|
||||
return &httpx.Response{Status: "ok", Data: entity}, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package compat_test
|
||||
|
||||
// End-to-end tests for the Casdoor WRITE verbs + the structurally-public front
|
||||
// door, driven through the REAL mounted router (routes.Mount installs the authz
|
||||
// Guard + Authorize seam; the front door is registered on the pre-Guard public
|
||||
// group). They assert the three write contracts a backend swap depends on:
|
||||
// the {status,ok} envelope every client parses, authorization identical to the REST
|
||||
// twin (super for platform-owned org/app; org-admin for its own users; cross-tenant
|
||||
// refused), and that no secret ever surfaces. Plus: the front-door session routes are
|
||||
// reachable WITHOUT a bearer (the portal/admin-guard call them with a cookie).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// post issues a JSON POST through the real router and returns (status, rawBody).
|
||||
func (h *harness) post(t *testing.T, path, bearer string, body any) (int, string) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := h.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b2, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode, string(b2)
|
||||
}
|
||||
|
||||
// okEnvelope decodes a body and asserts status=="ok".
|
||||
func okEnvelope(t *testing.T, status int, body string) {
|
||||
t.Helper()
|
||||
if status != 200 {
|
||||
t.Fatalf("status = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
t.Fatalf("not the v1 envelope: %v; body=%s", err, body)
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("status field = %v, want ok; body=%s", m["status"], body)
|
||||
}
|
||||
}
|
||||
|
||||
// add-organization is a platform-owned write — only a SuperAdmin may create one,
|
||||
// through the SAME organizations.Create the REST route uses. The created row is then
|
||||
// readable via the get-organization read alias.
|
||||
func TestAddOrganization_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/add-organization", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "admin", "name": "acme", "displayName": "Acme"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
|
||||
// It hit the real store — the org is now readable through the get alias.
|
||||
if s, rb := h.get(t, "/v1/iam/get-organization?id=admin/acme", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "acme") {
|
||||
t.Fatalf("created org not readable: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-super is refused at the ONE authz seam (platform-owned resource → super-only),
|
||||
// exactly as the REST twin is.
|
||||
func TestAddOrganization_nonSuperForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, _ := h.post(t, "/v1/iam/add-organization", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "admin", "name": "acme"})
|
||||
if status != 403 {
|
||||
t.Fatalf("org-admin add-organization status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// add-user: an org-admin creates a user in its OWN org through users.Create — the
|
||||
// password is bcrypt-hashed (never returned/stored plaintext) and the row comes back
|
||||
// redacted.
|
||||
func TestAddUser_orgAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "hanzo", "name": "newbie", "password": "S3cret-pw!"})
|
||||
okEnvelope(t, status, body)
|
||||
if strings.Contains(body, "S3cret-pw!") {
|
||||
t.Fatalf("add-user echoed the plaintext password: %s", body)
|
||||
}
|
||||
assertNoSecretLeak(t, body)
|
||||
// Readable through the get alias (same store).
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/newbie", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "newbie") {
|
||||
t.Fatalf("created user not readable: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-tenant create is refused: hanzo's admin cannot add a user under orgb.
|
||||
func TestAddUser_crossTenantForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, _ := h.post(t, "/v1/iam/add-user", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "orgb", "name": "intruder", "password": "x"})
|
||||
if status != 403 {
|
||||
t.Fatalf("cross-tenant add-user status = %d, want 403", status)
|
||||
}
|
||||
}
|
||||
|
||||
// update-user overwrites from the body (casdoor semantics) through users.Update; the
|
||||
// change is visible via the get alias and no secret leaks.
|
||||
func TestUpdateUser_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/update-user", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "hanzo", "name": "alice", "displayName": "Alice Updated"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/alice", h.token(t, "admin/root")); s != 200 || !strings.Contains(rb, "Alice Updated") {
|
||||
t.Fatalf("update-user not applied: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// update-application is platform-owned → super-only, through applications.Update.
|
||||
func TestUpdateApplication_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, body := h.post(t, "/v1/iam/update-application", h.token(t, "admin/root"),
|
||||
map[string]any{"owner": "admin", "name": "hanzo-console", "displayName": "Console"})
|
||||
okEnvelope(t, status, body)
|
||||
assertNoSecretLeak(t, body)
|
||||
}
|
||||
|
||||
// The write verbs are gated — no bearer fails closed at the Guard (they are
|
||||
// registered after it).
|
||||
func TestWriteAliases_requireAuth(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if status, _ := h.post(t, "/v1/iam/add-user", "", map[string]any{"owner": "hanzo", "name": "x"}); status != 401 {
|
||||
t.Fatalf("unauthenticated add-user status = %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// The FRONT-DOOR session routes are structurally PUBLIC — registered on the
|
||||
// pre-Guard group, so reachable WITHOUT a bearer (the portal + gateway admin-guard
|
||||
// call them with a session cookie). An anonymous caller gets the casibase
|
||||
// {status:"error"} (200), never a 401 and never a leak.
|
||||
func TestFrontDoorPublic_ReachableWithoutBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/v1/iam/get-account"},
|
||||
{"GET", "/v1/iam/whoami"},
|
||||
{"GET", "/v1/iam/linked-accounts"},
|
||||
} {
|
||||
status, body := h.get(t, tc.path, "")
|
||||
if status != 200 {
|
||||
t.Fatalf("%s %s without a bearer status=%d, want 200 (public); body=%s", tc.method, tc.path, status, body)
|
||||
}
|
||||
if !strings.Contains(body, "\"error\"") {
|
||||
t.Fatalf("anonymous %s must be the casibase error envelope; body=%s", tc.path, body)
|
||||
}
|
||||
}
|
||||
// signin (a POST) is public too — anonymous, no code → a 200 error, not a 401.
|
||||
if status, body := h.post(t, "/v1/iam/signin", "", map[string]any{}); status != 200 || !strings.Contains(body, "\"error\"") {
|
||||
t.Fatalf("anonymous signin status=%d body=%s, want 200 error (public)", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- C2 parity write-verb aliases (the console admin mutations) ---
|
||||
|
||||
// delete-user: a full lifecycle through the Casdoor verb (add → delete → gone).
|
||||
func TestDeleteUser_lifecycle(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
if s, b := h.post(t, "/v1/iam/add-user", root, map[string]any{"owner": "hanzo", "name": "tmp", "password": "x"}); s != 200 {
|
||||
t.Fatalf("add-user status=%d body=%s", s, b)
|
||||
}
|
||||
h.postAssertOK(t, "/v1/iam/delete-user", root, map[string]any{"owner": "hanzo", "name": "tmp"})
|
||||
if s, rb := h.get(t, "/v1/iam/get-user?id=hanzo/tmp", root); s == 200 && strings.Contains(rb, "\"name\":\"tmp\"") {
|
||||
t.Fatalf("user still present after delete-user: %s", rb)
|
||||
}
|
||||
}
|
||||
|
||||
// add-provider is platform-owned — only a SuperAdmin creates one, over the SAME
|
||||
// providers.Add the REST route uses.
|
||||
func TestAddProvider_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
h.postAssertOK(t, "/v1/iam/add-provider", root,
|
||||
map[string]any{"owner": "admin", "name": "provider-test", "category": "OAuth", "type": "GitHub"})
|
||||
if s, rb := h.get(t, "/v1/iam/get-provider?id=admin/provider-test", root); s != 200 || !strings.Contains(rb, "provider-test") {
|
||||
t.Fatalf("get-provider after add: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// add-provider by a non-super is refused (platform-owned write).
|
||||
func TestAddProvider_nonSuperForbidden(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
s, _ := h.post(t, "/v1/iam/add-provider", h.token(t, "hanzo/boss"),
|
||||
map[string]any{"owner": "admin", "name": "evil", "category": "OAuth", "type": "GitHub"})
|
||||
if s != 403 {
|
||||
t.Fatalf("non-super add-provider status=%d, want 403", s)
|
||||
}
|
||||
}
|
||||
|
||||
// add-role is tenant-owned — an org-admin creates one in its OWN org.
|
||||
func TestAddRole_orgAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
boss := h.token(t, "hanzo/boss")
|
||||
h.postAssertOK(t, "/v1/iam/add-role", boss,
|
||||
map[string]any{"owner": "hanzo", "name": "editors", "displayName": "Editors"})
|
||||
if s, rb := h.get(t, "/v1/iam/get-role?id=hanzo/editors", boss); s != 200 || !strings.Contains(rb, "editors") {
|
||||
t.Fatalf("get-role after add: status=%d body=%s", s, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// update-organization is platform-owned — SuperAdmin only.
|
||||
func TestUpdateOrganization_super(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
root := h.token(t, "admin/root")
|
||||
h.postAssertOK(t, "/v1/iam/update-organization", root,
|
||||
map[string]any{"owner": "admin", "name": "hanzo", "displayName": "Hanzo Updated"})
|
||||
}
|
||||
|
||||
// postAssertOK posts and asserts the {status:ok} envelope.
|
||||
func (h *harness) postAssertOK(t *testing.T, path, bearer string, body any) {
|
||||
s, b := h.post(t, path, bearer, body)
|
||||
okEnvelope(t, s, b)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package cred verifies a stored password digest against a plaintext, resolving
|
||||
// the algorithm FROM THE STORED ROW — never from a constant.
|
||||
//
|
||||
// Why this exists: v1 stamps `argon2id` on effectively every live row (the org's
|
||||
// PasswordType is rewritten to argon2id on create/update, and UpdateUserPassword
|
||||
// stamps it per user). A bcrypt-only verifier handed an argon2id PHC string
|
||||
// returns ErrHashTooShort, so a bcrypt-only login fails 100% of real users at
|
||||
// cutover. v1 resolves per row — user.PasswordType, falling back to the
|
||||
// organization's — and dispatches to the matching manager. iam2 does the same.
|
||||
//
|
||||
// Hashing is argon2id ONLY (SOTA). Verify stays scheme-aware so pre-existing
|
||||
// bcrypt and v1 argon2id rows keep validating, but every NEW or updated digest
|
||||
// this package mints is argon2id — one way to hash, the strongest one. Re-hashing
|
||||
// a verified bcrypt row to argon2id (upgrade-on-login) is a separate, deliberate
|
||||
// decision, not a side effect of a read.
|
||||
package cred
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Supported password types. These are the two schemes Hanzo actually stores:
|
||||
// argon2id (every live v1 row) and bcrypt (what iam2 mints for new users).
|
||||
// Anything else fails CLOSED — a silent "true" on an unrecognized scheme would
|
||||
// be an auth bypass, and a silent "false" we can't explain is a support
|
||||
// nightmare, so Verify reports Unsupported distinctly.
|
||||
const (
|
||||
TypeArgon2id = "argon2id"
|
||||
TypeBcrypt = "bcrypt"
|
||||
)
|
||||
|
||||
// Resolve returns the password type for a row: the user's own, else the
|
||||
// organization's, else "" (caller decides — never guess a default, since a wrong
|
||||
// guess is either a failed login or, worse, a bypass).
|
||||
func Resolve(userType, orgType string) string {
|
||||
if userType != "" {
|
||||
return userType
|
||||
}
|
||||
return orgType
|
||||
}
|
||||
|
||||
// Supported reports whether Verify can handle this password type.
|
||||
func Supported(passwordType string) bool {
|
||||
switch passwordType {
|
||||
case TypeArgon2id, TypeBcrypt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify reports whether plaintext matches the stored digest under passwordType.
|
||||
// Both supported schemes carry their own parameters in the digest (bcrypt's
|
||||
// $2a$… and argon2id's $argon2id$v=19$… PHC string), so no external salt is
|
||||
// needed; salt is accepted for the legacy per-row salt schemes v1 also supports
|
||||
// and is currently unused.
|
||||
//
|
||||
// Fails closed: an unknown/empty type, an empty hash, or a malformed digest
|
||||
// returns false.
|
||||
func Verify(passwordType, plaintext, hashed string) bool {
|
||||
if hashed == "" || !Supported(passwordType) {
|
||||
return false
|
||||
}
|
||||
switch passwordType {
|
||||
case TypeArgon2id:
|
||||
// ComparePasswordAndHash is constant-time internally and parses the PHC
|
||||
// parameters from the digest itself; a malformed digest returns an error,
|
||||
// which we treat as "no match" (never a panic, never a pass).
|
||||
match, err := argon2id.ComparePasswordAndHash(plaintext, hashed)
|
||||
return err == nil && match
|
||||
case TypeBcrypt:
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plaintext)) == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hashParams are the argon2id cost parameters for every new digest — OWASP-aligned
|
||||
// SOTA (64 MiB memory, 2 passes, parallelism 1), tuned so a login stays well under
|
||||
// ~100ms while resisting GPU/ASIC cracking. The parameters + a per-hash random salt
|
||||
// ride INSIDE the PHC string, so Verify reads them from the digest itself — changing
|
||||
// these never invalidates an already-stored hash.
|
||||
var hashParams = &argon2id.Params{
|
||||
Memory: 64 * 1024, // 64 MiB
|
||||
Iterations: 2,
|
||||
Parallelism: 1,
|
||||
SaltLength: 16,
|
||||
KeyLength: 32,
|
||||
}
|
||||
|
||||
// Hash derives a one-way argon2id (PHC) digest from a plaintext password — the
|
||||
// SOTA scheme every new/updated Hanzo password uses, stamped TypeArgon2id. The
|
||||
// cost parameters and a per-hash random salt are embedded in the returned string,
|
||||
// so Verify needs no external salt or config. The plaintext is never logged or
|
||||
// stored; only this one-way digest is.
|
||||
func Hash(plaintext string) (string, error) {
|
||||
return argon2id.CreateHash(plaintext, hashParams)
|
||||
}
|
||||
|
||||
// ConstantTimeEqual is a small helper for comparing non-hash secrets (e.g. a
|
||||
// verification code) without leaking length/position through timing.
|
||||
func ConstantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
@@ -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,365 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package e2e_test drives the WHOLE iam2 surface through the real mounted router
|
||||
// (routes.Route) as one integrated journey — the behavioral parity proof that the
|
||||
// old Casdoor IAM's clients work against iam2. Unlike the per-package unit tests,
|
||||
// this chains the real flows a live client runs in sequence: OIDC discovery →
|
||||
// PKCE login → code→token → userinfo → introspect → revoke; the admin console's
|
||||
// get-account → get-organizations → get-users (the Casdoor compat surface); SCIM
|
||||
// 2.0 provisioning; and RFC 8693 token exchange. Every step asserts the response
|
||||
// CONTRACT the client depends on.
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/oidc"
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
kid = "cert-hanzo"
|
||||
redirectURI = "https://console.hanzo.ai/auth/callback"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func boot(t *testing.T) *env {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "e2e.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
seedCert(t, db, key)
|
||||
// A confidential console app: password login + PKCE, in the hanzo org.
|
||||
seedApp(t, db)
|
||||
seedOrg(t, db, "admin")
|
||||
seedOrg(t, db, "hanzo")
|
||||
seedUser(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw", false)
|
||||
seedUser(t, db, "admin", "root", "root@hanzo.ai", "pw", true) // SuperAdmin
|
||||
|
||||
app := zip.New(zip.Config{AppName: "iam2-e2e", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
return &env{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
// TestJourney_OIDCFlow is the full OAuth2/OIDC round trip a client SDK runs.
|
||||
func TestJourney_OIDCFlow(t *testing.T) {
|
||||
e := boot(t)
|
||||
|
||||
// 1) Discovery is self-consistent (one issuer, the endpoints a strict client pins).
|
||||
disc := e.getJSON(t, "/.well-known/openid-configuration", "")
|
||||
if disc["issuer"] == "" || disc["token_endpoint"] == "" || disc["jwks_uri"] == "" {
|
||||
t.Fatalf("discovery incomplete: %v", disc)
|
||||
}
|
||||
if disc["introspection_endpoint"] == "" || disc["revocation_endpoint"] == "" {
|
||||
t.Fatalf("discovery missing RFC 7662/7009 endpoints: %v", disc)
|
||||
}
|
||||
// RFC 8414 AS metadata served at its own well-known.
|
||||
if as := e.getJSON(t, "/.well-known/oauth-authorization-server", ""); as["issuer"] == "" {
|
||||
t.Fatalf("RFC 8414 AS metadata missing")
|
||||
}
|
||||
// 2) JWKS publishes a verification key.
|
||||
jwks := e.getJSON(t, "/v1/iam/.well-known/jwks", "")
|
||||
if keys, _ := jwks["keys"].([]any); len(keys) == 0 {
|
||||
t.Fatalf("JWKS has no keys: %v", jwks)
|
||||
}
|
||||
|
||||
// 3) PKCE login → single-use code.
|
||||
verifier := "e2e-verifier-0000000000000000000000000000000000000"
|
||||
code := e.login(t, verifier)
|
||||
|
||||
// 4) Redeem the code → access token (+ id_token on openid, refresh on offline).
|
||||
tok := e.token(t, url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code},
|
||||
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"redirect_uri": {redirectURI}, "code_verifier": {verifier},
|
||||
})
|
||||
access, _ := tok["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("no access_token: %v", tok)
|
||||
}
|
||||
|
||||
// 5) UserInfo carries the identity + the admin-guard contract (owner, isAdmin).
|
||||
info := e.getJSON(t, "/v1/iam/oauth/userinfo", access)
|
||||
if info["sub"] != "hanzo/alice" || info["owner"] != "hanzo" {
|
||||
t.Fatalf("userinfo sub/owner wrong: %v", info)
|
||||
}
|
||||
if _, ok := info["isAdmin"]; !ok {
|
||||
t.Fatalf("userinfo missing the isAdmin claim (admin-guard contract): %v", info)
|
||||
}
|
||||
|
||||
// 6) Introspection (RFC 7662): active, with the standard claims.
|
||||
ir := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if ir["active"] != true || ir["sub"] != "hanzo/alice" {
|
||||
t.Fatalf("introspect not active/wrong sub: %v", ir)
|
||||
}
|
||||
|
||||
// 7) Revocation (RFC 7009): the token dies — introspect flips to inactive.
|
||||
e.form(t, "/v1/iam/oauth/revoke", "hanzo-console", "top-secret", url.Values{"token": {access}})
|
||||
if after := e.form(t, "/v1/iam/oauth/introspect", "hanzo-console", "top-secret", url.Values{"token": {access}}); after["active"] != false {
|
||||
t.Fatalf("token still active after revoke: %v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourney_PasswordGrant_and_TokenExchange proves the two non-interactive grants
|
||||
// the console/BFF rely on.
|
||||
func TestJourney_PasswordGrant_and_TokenExchange(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
e := boot(t)
|
||||
|
||||
// Password grant → a first-party session token for alice.
|
||||
pw := e.token(t, url.Values{
|
||||
"grant_type": {"password"}, "client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"username": {"alice@hanzo.ai"}, "password": {"pw"}, "scope": {"openid profile"},
|
||||
})
|
||||
subjectToken, _ := pw["access_token"].(string)
|
||||
if subjectToken == "" {
|
||||
t.Fatalf("password grant failed: %v", pw)
|
||||
}
|
||||
|
||||
// RFC 8693 token exchange: the BFF exchanges alice's token for one scoped to a
|
||||
// downstream resource, still bound to alice.
|
||||
xe := e.token(t, url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
|
||||
"client_id": {"hanzo-console"}, "client_secret": {"top-secret"},
|
||||
"subject_token": {subjectToken}, "resource": {"hanzo-cloud"},
|
||||
})
|
||||
if xe["issued_token_type"] != "urn:ietf:params:oauth:token-type:access_token" || xe["access_token"] == "" {
|
||||
t.Fatalf("token exchange failed: %v", xe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourney_AdminConsole_CasdoorSurface proves the old admin console's calls work:
|
||||
// get-account (the security contract), get-organizations (OrgSwitcher), get-users.
|
||||
func TestJourney_AdminConsole_CasdoorSurface(t *testing.T) {
|
||||
e := boot(t)
|
||||
root := e.mint(t, "admin/root") // a SuperAdmin bearer
|
||||
|
||||
// get-account — {status:ok, data:<masked user>} with owner + isAdmin.
|
||||
acct := e.getJSON(t, "/v1/iam/get-account", root)
|
||||
if acct["status"] != "ok" {
|
||||
t.Fatalf("get-account status: %v", acct)
|
||||
}
|
||||
|
||||
// get-organizations — the OrgSwitcher workhorse; SuperAdmin sees all.
|
||||
orgs := e.getJSON(t, "/v1/iam/get-organizations", root)
|
||||
if orgs["status"] != "ok" {
|
||||
t.Fatalf("get-organizations status: %v", orgs)
|
||||
}
|
||||
if data, _ := orgs["data"].([]any); len(data) < 2 {
|
||||
t.Fatalf("get-organizations returned %d orgs, want >=2 (admin+hanzo)", len(data))
|
||||
}
|
||||
|
||||
// get-users scoped to an org — no secret leaks.
|
||||
usersBody := e.getRaw(t, "/v1/iam/get-users?owner=hanzo", root)
|
||||
if strings.Contains(usersBody, "passwordHash") || strings.Contains(usersBody, "\"password\"") {
|
||||
t.Fatalf("get-users leaked a secret: %s", usersBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourney_SCIMProvisioning proves the RFC-standard provisioning path an IdP uses.
|
||||
func TestJourney_SCIMProvisioning(t *testing.T) {
|
||||
e := boot(t)
|
||||
root := e.mint(t, "admin/root")
|
||||
|
||||
create := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"newhire",` +
|
||||
`"active":true,"password":"pw","urn:ietf:params:scim:schemas:extension:hanzo:2.0:User":{"owner":"hanzo"}}`
|
||||
st, body := e.req(t, "POST", "/v1/iam/scim/v2/Users", root, create, "application/scim+json")
|
||||
if st != 201 {
|
||||
t.Fatalf("SCIM create status = %d: %s", st, body)
|
||||
}
|
||||
if st, _ := e.req(t, "GET", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 200 {
|
||||
t.Fatalf("SCIM get status = %d", st)
|
||||
}
|
||||
if st, _ := e.req(t, "DELETE", "/v1/iam/scim/v2/Users/hanzo/newhire", root, "", ""); st != 204 {
|
||||
t.Fatalf("SCIM delete status = %d", st)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- flow helpers ----
|
||||
|
||||
func (e *env) login(t *testing.T, verifier string) string {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"type": "code", "organization": "hanzo", "username": "alice@hanzo.ai", "password": "pw",
|
||||
"clientId": "hanzo-console", "redirectUri": redirectURI, "scope": "openid profile email offline_access",
|
||||
"codeChallenge": oidc.ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
st, resp := e.req(t, "POST", "/v1/iam/login", "", string(body), "application/json")
|
||||
if st != 200 {
|
||||
t.Fatalf("login status = %d: %s", st, resp)
|
||||
}
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(resp), &m)
|
||||
code, _ := m["data"].(string)
|
||||
if code == "" {
|
||||
t.Fatalf("login returned no code: %s", resp)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func (e *env) token(t *testing.T, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
st, body := e.req(t, "POST", "/v1/iam/oauth/token", "", form.Encode(), "application/x-www-form-urlencoded")
|
||||
_ = st
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) form(t *testing.T, path, clientID, secret string, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
|
||||
resp, err := e.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("form %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) req(t *testing.T, method, path, bearer, body, contentType string) (int, string) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
req.Host = "hanzo.id"
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := e.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (e *env) getJSON(t *testing.T, path, bearer string) map[string]any {
|
||||
t.Helper()
|
||||
_, body := e.req(t, "GET", path, bearer, "", "")
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *env) getRaw(t *testing.T, path, bearer string) string {
|
||||
t.Helper()
|
||||
_, body := e.req(t, "GET", path, bearer, "", "")
|
||||
return body
|
||||
}
|
||||
|
||||
// mint signs an RS256 bearer for sub under the seeded cert — a valid principal the
|
||||
// Guard admits (used for the compat/SCIM admin calls, which need a verified bearer
|
||||
// but not a persisted grant row).
|
||||
func (e *env) mint(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub, "iat": time.Now().Add(-time.Minute).Unix(), "exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = kid
|
||||
s, err := tok.SignedString(e.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- seed helpers ----
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, key *rsa.PrivateKey) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name, c.CryptoAlgorithm = "admin", kid, "RS256"
|
||||
c.PrivateKey = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
c.SetId("admin/" + kid)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedApp(t *testing.T, db orm.DB) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name, a.ClientId, a.ClientSecret = "admin", "hanzo-console", "hanzo-console", "top-secret"
|
||||
a.Organization, a.Cert, a.EnablePassword = "hanzo", kid, true
|
||||
a.RedirectUris = []string{redirectURI}
|
||||
a.ExpireInHours = 1
|
||||
a.SetId("admin/hanzo-console")
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedOrg(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", name
|
||||
o.SetId("admin/" + name)
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed org %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name, email, password string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name, u.Email, u.IsAdmin = owner, name, email, admin
|
||||
hash, herr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if herr != nil {
|
||||
t.Fatalf("hash: %v", herr)
|
||||
}
|
||||
u.PasswordHash, u.PasswordType = string(hash), "bcrypt"
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package featurestore implements feature.Store over the iam2 orm store, so the
|
||||
// hanzoiam/* enterprise modules read/write the SAME identity data as the core.
|
||||
// Internal: the core (server.Mount) constructs it and hands the interface to
|
||||
// feature.MountAll — modules never see this package, only the feature.Store seam.
|
||||
package featurestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/feature"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
"github.com/hanzoai/iam/pkg/model"
|
||||
)
|
||||
|
||||
type ormStore struct {
|
||||
db orm.DB
|
||||
u *users.API
|
||||
}
|
||||
|
||||
// New returns a feature.Store backed by db (the core's one identity store).
|
||||
func New(db orm.DB) feature.Store { return &ormStore{db: db, u: users.New(db)} }
|
||||
|
||||
func (s *ormStore) GetUser(ctx context.Context, owner, name string) (*model.User, error) {
|
||||
return store.GetUserByName(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetUserByID(_ context.Context, id string) (*model.User, error) {
|
||||
u, err := orm.Get[schema.User](s.db, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) GetGlobalUsers(ctx context.Context, offset, limit int) ([]*model.User, int, error) {
|
||||
total, err := orm.TypedQuery[schema.User](s.db).Count(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := orm.TypedQuery[schema.User](s.db).Order("Name")
|
||||
if offset > 0 {
|
||||
q = q.Offset(offset)
|
||||
}
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
list, err := q.GetAll(ctx)
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (s *ormStore) AddUser(ctx context.Context, u *model.User) (bool, error) {
|
||||
if _, err := s.u.Create(ctx, &users.CreateInput{User: *u}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) UpdateUser(ctx context.Context, u *model.User) (bool, error) {
|
||||
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) DeleteUser(ctx context.Context, owner, name string) (bool, error) {
|
||||
out, err := s.u.Delete(ctx, &users.Ref{Owner: owner, Name: name})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out.Deleted, nil
|
||||
}
|
||||
|
||||
func (s *ormStore) GetApplication(ctx context.Context, id string) (*model.Application, error) {
|
||||
if app, err := store.GetApplicationByName(ctx, s.db, "admin", id); err == nil && app != nil {
|
||||
return app, nil
|
||||
}
|
||||
return store.GetApplicationByClientId(ctx, s.db, id)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetOrganization(ctx context.Context, name string) (*model.Organization, error) {
|
||||
return store.GetOrganizationByName(ctx, s.db, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetProvider(ctx context.Context, owner, name string) (*model.Provider, error) {
|
||||
return store.GetProvider(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
func (s *ormStore) GetCert(ctx context.Context, owner, name string) (*model.Cert, error) {
|
||||
return store.GetCert(ctx, s.db, owner, name)
|
||||
}
|
||||
|
||||
// SetPassword loads the canonical row and re-saves it with the plaintext, which
|
||||
// users.Update hashes exactly once (empty leaves the digest untouched). Passing
|
||||
// the full existing row means no other field is zeroed by the update.
|
||||
func (s *ormStore) SetPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
|
||||
u, err := store.GetUserByName(ctx, s.db, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := s.u.Update(ctx, &users.UpdateInput{User: *u, Password: plaintext}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyPassword defers to the core's digest-scheme-aware verifier (argon2id v1 /
|
||||
// bcrypt v2), keyed by the org's password type — no hash ever leaves the core.
|
||||
func (s *ormStore) VerifyPassword(ctx context.Context, owner, name, plaintext string) (bool, error) {
|
||||
u, err := store.GetUserByName(ctx, s.db, owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
pwType := ""
|
||||
if org, oerr := store.GetOrganizationByName(ctx, s.db, owner); oerr == nil && org != nil {
|
||||
pwType = org.PasswordType
|
||||
}
|
||||
return users.VerifyPassword(u, plaintext, pwType), nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package httpx is the shared HTTP layer for the IAM v2 handlers: the
|
||||
// Casdoor-compatible Response envelope that the @hanzo/iam SDK and the hanzo.id
|
||||
// portal consume, plus small helpers over zip.Ctx. Every front-door JSON
|
||||
// endpoint (get-app-login, login, signup) returns this shape; the OIDC
|
||||
// endpoints (token/authorize/userinfo) use their own RFC 6749 shapes.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Response is the Casdoor-compatible envelope. status is "ok" or "error"; a
|
||||
// non-ok status rides on a 200 (every SDK branches on status, not the HTTP
|
||||
// code — preserving that contract keeps the clients unchanged at cutover).
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Data any `json:"data"`
|
||||
Data2 any `json:"data2,omitempty"`
|
||||
Data3 any `json:"data3,omitempty"`
|
||||
}
|
||||
|
||||
// Ok writes 200 { status:"ok", data }.
|
||||
func Ok(c *zip.Ctx, data any, more ...any) error {
|
||||
r := Response{Status: "ok", Data: data}
|
||||
if len(more) > 0 {
|
||||
r.Data2 = more[0]
|
||||
}
|
||||
return c.JSON(200, r)
|
||||
}
|
||||
|
||||
// Err writes 200 { status:"error", msg } — the SDK contract (branch on status,
|
||||
// not HTTP code).
|
||||
func Err(c *zip.Ctx, msg string) error {
|
||||
return c.JSON(200, Response{Status: "error", Msg: msg})
|
||||
}
|
||||
|
||||
// Bearer returns the token from an `Authorization: Bearer <token>` header, or "".
|
||||
func Bearer(c *zip.Ctx) string {
|
||||
const p = "Bearer "
|
||||
h := c.Header("Authorization")
|
||||
if len(h) > len(p) && h[:len(p)] == p {
|
||||
return h[len(p):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Basic returns the (id, secret) an `Authorization: Basic <base64>` header carries,
|
||||
// and whether it carried one — RFC 7617: base64 of "<id>:<secret>", split on the
|
||||
// FIRST colon so a secret may contain one. This is the ONE Basic parser; a caller
|
||||
// bound by RFC 6749 §2.3.1 (client_secret_basic, whose halves are form-urlencoded
|
||||
// before the base64) form-decodes the two values afterwards.
|
||||
func Basic(c *zip.Ctx) (id, secret string, ok bool) {
|
||||
const p = "Basic "
|
||||
h := c.Header("Authorization")
|
||||
if len(h) <= len(p) || !strings.EqualFold(h[:len(p)], p) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(h[len(p):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
id, secret, found := strings.Cut(string(raw), ":")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
return id, secret, true
|
||||
}
|
||||
|
||||
// EffectiveHost is the request host used to build a host-relative issuer, so
|
||||
// discovery/JWKS never split-origin (HIP-0111). Honors X-Forwarded-Host when
|
||||
// the request came through the ingress/gateway.
|
||||
func EffectiveHost(c *zip.Ctx) string {
|
||||
if h := c.Header("X-Forwarded-Host"); h != "" {
|
||||
return h
|
||||
}
|
||||
return c.Header("Host")
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package invitations serves the IAM v2 CRUD surface for the `invitations`
|
||||
// entity: a pending org-membership invite owner-scoped by (owner, name). Every
|
||||
// operation is a typed zip handler over hanzoai/orm; the orm string key is
|
||||
// "owner/name". Reads scope to one owner (organization); writes address one
|
||||
// invitation by its (owner, name) key.
|
||||
package invitations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Handler binds the invitations operations to one orm store.
|
||||
type Handler struct {
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
// Route registers the invitations CRUD routes on app against db.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
h := &Handler{db: db}
|
||||
zip.Get(app, "/v1/iam/invitations", h.List, zip.WithSummary("List invitations for an owner"), zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations", h.Create, zip.WithSummary("Create an invitation"), zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations/get", h.Get, zip.WithSummary("Get one invitation"), zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations/update", h.Update, zip.WithSummary("Update an invitation"), zip.WithTags("invitations"))
|
||||
zip.Post(app, "/v1/iam/invitations/delete", h.Delete, zip.WithSummary("Delete an invitation"), zip.WithTags("invitations"))
|
||||
}
|
||||
|
||||
// Ref addresses one invitation by its owner-scoped natural key.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Input is the writable projection of an invitation (the v1 add/update-invitation
|
||||
// body). It keeps the wire contract clean of the orm.Model bookkeeping fields.
|
||||
type Input struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime string `json:"createdTime"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Code string `json:"code"`
|
||||
IsRegexp bool `json:"isRegexp"`
|
||||
Quota int `json:"quota"`
|
||||
UsedCount int `json:"usedCount"`
|
||||
Application string `json:"application"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
SignupGroup string `json:"signupGroup"`
|
||||
DefaultCode string `json:"defaultCode"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// ListInput scopes a listing to one owner (organization).
|
||||
type ListInput struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListOutput is the owner-scoped page of invitations.
|
||||
type ListOutput struct {
|
||||
Invitations []*schema.Invitation `json:"invitations"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteOutput reports the delete result.
|
||||
type DeleteOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// key builds the orm string key from the (owner, name) natural key.
|
||||
func key(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// apply copies the mutable domain fields of an Input onto an invitation. The
|
||||
// identity fields (owner, name) and the created stamp are set only on Create,
|
||||
// never overwritten by an update.
|
||||
func apply(dst *schema.Invitation, in *Input) {
|
||||
dst.UpdatedTime = in.UpdatedTime
|
||||
dst.DisplayName = in.DisplayName
|
||||
dst.Code = in.Code
|
||||
dst.IsRegexp = in.IsRegexp
|
||||
dst.Quota = in.Quota
|
||||
dst.UsedCount = in.UsedCount
|
||||
dst.Application = in.Application
|
||||
dst.Username = in.Username
|
||||
dst.Email = in.Email
|
||||
dst.Phone = in.Phone
|
||||
dst.SignupGroup = in.SignupGroup
|
||||
dst.DefaultCode = in.DefaultCode
|
||||
dst.State = in.State
|
||||
}
|
||||
|
||||
// List returns the invitations for one owner, newest first. An empty owner
|
||||
// lists every invitation (the unscoped admin view).
|
||||
func (h *Handler) List(ctx context.Context, in *ListInput) (*ListOutput, error) {
|
||||
q := orm.TypedQuery[schema.Invitation](h.db)
|
||||
if in.Owner != "" {
|
||||
q = q.Filter("owner", in.Owner)
|
||||
}
|
||||
invitations, err := q.Order("-createdTime").GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &ListOutput{Invitations: invitations, Total: len(invitations)}, nil
|
||||
}
|
||||
|
||||
// Get returns one invitation addressed by (owner, name).
|
||||
func (h *Handler) Get(ctx context.Context, in *Ref) (*schema.Invitation, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// Create persists a new invitation. It rejects a duplicate (owner, name).
|
||||
func (h *Handler) Create(ctx context.Context, in *Input) (*schema.Invitation, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
switch _, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name)); {
|
||||
case err == nil:
|
||||
return nil, zip.ErrConflict("invitation already exists")
|
||||
case !errors.Is(err, orm.ErrNotFound):
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
invitation := orm.New[schema.Invitation](h.db)
|
||||
invitation.Owner = in.Owner
|
||||
invitation.Name = in.Name
|
||||
invitation.CreatedTime = in.CreatedTime
|
||||
if invitation.CreatedTime == "" {
|
||||
invitation.CreatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
apply(invitation, in)
|
||||
invitation.SetId(key(in.Owner, in.Name))
|
||||
|
||||
if err := invitation.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// Update mutates an existing invitation. Identity and created stamp are
|
||||
// immutable; a missing invitation is a 404.
|
||||
func (h *Handler) Update(ctx context.Context, in *Input) (*schema.Invitation, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
apply(invitation, in)
|
||||
if err := invitation.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// Delete removes one invitation addressed by (owner, name).
|
||||
func (h *Handler) Delete(ctx context.Context, in *Ref) (*DeleteOutput, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
invitation, err := orm.Get[schema.Invitation](h.db, key(in.Owner, in.Name))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
if err := invitation.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
// mapErr translates an orm lookup error into the matching HTTP status.
|
||||
func mapErr(err error) error {
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return zip.ErrNotFound("invitation not found")
|
||||
}
|
||||
return zip.ErrInternal(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package keys serves the owner-scoped CRUD surface for the `keys` entity
|
||||
// (v1 Casdoor `key`) as typed zip handlers over hanzoai/orm.
|
||||
//
|
||||
// Identity is the (owner, name) pair; it maps onto the orm storage id as
|
||||
// "owner/name", exactly as the v1 record addressed itself. Reads are
|
||||
// zip.Get[In,Out], writes are zip.Post[In,Out]; every handler closes over the
|
||||
// one orm.DB entity store so the typed signatures carry no transport or
|
||||
// storage plumbing.
|
||||
package keys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Route registers the key CRUD routes on app, binding each handler to db.
|
||||
// Called from routes.Mount once it is threaded the entity store.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
zip.Get(app, "/v1/iam/keys", list(db),
|
||||
zip.WithSummary("List keys in an owner"), zip.WithTags("keys"))
|
||||
zip.Get(app, "/v1/iam/key", get(db),
|
||||
zip.WithSummary("Get a key by (owner, name)"), zip.WithTags("keys"))
|
||||
zip.Post(app, "/v1/iam/key", create(db),
|
||||
zip.WithSummary("Create a key"), zip.WithTags("keys"))
|
||||
zip.Post(app, "/v1/iam/key/update", update(db),
|
||||
zip.WithSummary("Update a key"), zip.WithTags("keys"))
|
||||
zip.Post(app, "/v1/iam/key/delete", del(db),
|
||||
zip.WithSummary("Delete a key"), zip.WithTags("keys"))
|
||||
}
|
||||
|
||||
// ListRequest scopes a listing to one owner.
|
||||
type ListRequest struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// ListResponse is the owner-scoped key set, newest first.
|
||||
type ListResponse struct {
|
||||
Keys []schema.Key `json:"keys"`
|
||||
}
|
||||
|
||||
// Ref addresses one key by its (owner, name) identity.
|
||||
type Ref struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// DeleteResponse reports whether the key was removed.
|
||||
type DeleteResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// id joins the owner-scoped natural key into the orm storage id — the same
|
||||
// "owner/name" identity the v1 record used.
|
||||
func id(owner, name string) string { return owner + "/" + name }
|
||||
|
||||
// list returns every key under in.Owner, newest first.
|
||||
func list(db orm.DB) zip.TypedHandler[ListRequest, ListResponse] {
|
||||
return func(ctx context.Context, in *ListRequest) (*ListResponse, error) {
|
||||
if in.Owner == "" {
|
||||
return nil, zip.ErrBadRequest("owner is required")
|
||||
}
|
||||
items, err := orm.TypedQuery[schema.Key](db).
|
||||
Filter("Owner=", in.Owner).
|
||||
Order("-CreatedTime").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
out := &ListResponse{Keys: make([]schema.Key, 0, len(items))}
|
||||
for _, k := range items {
|
||||
out.Keys = append(out.Keys, *k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// get resolves one key by (owner, name).
|
||||
func get(db orm.DB) zip.TypedHandler[Ref, schema.Key] {
|
||||
return func(_ context.Context, in *Ref) (*schema.Key, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
|
||||
// create inserts a new key under (owner, name), minting any missing pk-/sk-
|
||||
// credential halves. It refuses to overwrite an existing key.
|
||||
func create(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
|
||||
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
if _, err := orm.Get[schema.Key](db, id(in.Owner, in.Name)); err == nil {
|
||||
return nil, zip.ErrConflict("key already exists: " + id(in.Owner, in.Name))
|
||||
} else if !errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
|
||||
k := orm.New[schema.Key](db)
|
||||
k.SetId(id(in.Owner, in.Name))
|
||||
k.Owner, k.Name = in.Owner, in.Name
|
||||
apply(k, in)
|
||||
if k.AccessKey == "" {
|
||||
k.AccessKey = Mint("pk", k.State)
|
||||
}
|
||||
if k.AccessSecret == "" {
|
||||
k.AccessSecret = Mint("sk", k.State)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
k.CreatedTime, k.UpdatedTime = now, now
|
||||
|
||||
if err := k.CreateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
|
||||
// update overwrites the mutable fields of an existing key, keyed by
|
||||
// (owner, name), and re-stamps UpdatedTime.
|
||||
func update(db orm.DB) zip.TypedHandler[schema.Key, schema.Key] {
|
||||
return func(ctx context.Context, in *schema.Key) (*schema.Key, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
apply(k, in)
|
||||
k.UpdatedTime = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := k.UpdateCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
|
||||
// del removes a key by (owner, name).
|
||||
func del(db orm.DB) zip.TypedHandler[Ref, DeleteResponse] {
|
||||
return func(ctx context.Context, in *Ref) (*DeleteResponse, error) {
|
||||
if in.Owner == "" || in.Name == "" {
|
||||
return nil, zip.ErrBadRequest("owner and name are required")
|
||||
}
|
||||
k, err := orm.Get[schema.Key](db, id(in.Owner, in.Name))
|
||||
if errors.Is(err, orm.ErrNotFound) {
|
||||
return nil, zip.ErrNotFound("key not found: " + id(in.Owner, in.Name))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
if err := k.DeleteCtx(ctx); err != nil {
|
||||
return nil, zip.ErrInternal(err.Error())
|
||||
}
|
||||
return &DeleteResponse{Deleted: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// apply copies the caller-settable fields from src onto dst, leaving the
|
||||
// (owner, name) identity, storage id, and audit stamps under handler control.
|
||||
func apply(dst, src *schema.Key) {
|
||||
dst.DisplayName = src.DisplayName
|
||||
dst.Type = src.Type
|
||||
dst.Organization = src.Organization
|
||||
dst.Application = src.Application
|
||||
dst.User = src.User
|
||||
dst.AccessKey = src.AccessKey
|
||||
dst.AccessSecret = src.AccessSecret
|
||||
dst.ExpireTime = src.ExpireTime
|
||||
dst.State = src.State
|
||||
}
|
||||
|
||||
// mint generates a prefixed credential half — "{pk|sk}-{live|test}-{random}"
|
||||
// — mirroring the v1 key format. State == "test" selects the test env.
|
||||
func Mint(prefix, state string) string {
|
||||
env := "live"
|
||||
if state == "test" {
|
||||
env = "test"
|
||||
}
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("%s-%s-%s", prefix, env, hex.EncodeToString(b[:]))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package memberships serves the (User × Org × Role) tenancy relation — which
|
||||
// orgs an identity may act in, and with what coarse role. It is the set a token
|
||||
// carries as the `orgs` claim, which is what lets the edge authorize an
|
||||
// org-switch statelessly (X-Org-Id ∈ orgs).
|
||||
//
|
||||
// A user's HOME org (User.Owner) is always an implicit membership — the token
|
||||
// consumer treats it as one — so an explicit row is only ever needed for a TEAM
|
||||
// org the identity was invited into. The boot backfill seeds the home row anyway,
|
||||
// so an org's roster is complete from one query.
|
||||
//
|
||||
// This is the transport face. The relation's operations are store's
|
||||
// (EnsureMembership, MembershipsByUser/ByOrg), because the token mint needs them
|
||||
// too and it sits below the authorization seam this face sits above.
|
||||
package memberships
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Path is the verb face: GET lists by ?user= or ?org=, POST ensures one.
|
||||
const Path = "/v1/iam/memberships"
|
||||
|
||||
// unauthorized is v1's refusal message, verbatim.
|
||||
const unauthorized = "auth:Unauthorized operation"
|
||||
|
||||
// Route registers the membership surface on app, backed by db.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
app.Get(Path, list(db))
|
||||
app.Post(Path, ensure(db))
|
||||
}
|
||||
|
||||
// request is the ensure body.
|
||||
type request struct {
|
||||
User string `json:"user"` // "<homeOrg>/<username>"
|
||||
Org string `json:"org"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// list serves GET /v1/iam/memberships?user=<owner/name> or ?org=<slug> — one
|
||||
// identity's orgs, or one org's roster.
|
||||
//
|
||||
// Both are org-scoped: a non-SuperAdmin may ask about ITS OWN org's roster, or
|
||||
// about a user whose home org is its own, and nothing else. The bound comes from
|
||||
// the verified credential via authz.Scope, so a request parameter can never
|
||||
// widen it — a membership row names who may act and spend in an org, so a
|
||||
// cross-tenant read is a customer roster leak.
|
||||
func list(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
user, org := c.Query("user"), c.Query("org")
|
||||
if (user == "") == (org == "") {
|
||||
return httpx.Err(c, "exactly one of user or org is required")
|
||||
}
|
||||
if org != "" {
|
||||
if !scoped(ctx, org) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
rows, err := store.MembershipsByOrg(ctx, db, org)
|
||||
return listed(c, rows, err)
|
||||
}
|
||||
// A user id is "<homeOrg>/<name>": its home org is the tenant bound here.
|
||||
home, _, found := strings.Cut(user, "/")
|
||||
if !found || home == "" {
|
||||
return httpx.Err(c, "user must be <owner>/<name>")
|
||||
}
|
||||
if !scoped(ctx, home) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
rows, err := store.MembershipsByUser(ctx, db, user)
|
||||
return listed(c, rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure serves POST /v1/iam/memberships — grant an identity the right to act in
|
||||
// an org. Granting membership IS the org's authority to give, so it takes the
|
||||
// same gate a write to that org's own registry row takes: a SuperAdmin, an admin
|
||||
// of the org itself, or an org-admin-capable confidential client. One rule, one
|
||||
// place (internal/authz).
|
||||
func ensure(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
var in request
|
||||
if err := c.Bind(&in); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if in.User == "" || in.Org == "" {
|
||||
return httpx.Err(c, "user and org are required")
|
||||
}
|
||||
switch in.Role {
|
||||
case store.RoleOwner, store.RoleAdmin, store.RoleMember:
|
||||
case "":
|
||||
in.Role = store.RoleMember
|
||||
default:
|
||||
return httpx.Err(c, "role must be owner, admin, or member")
|
||||
}
|
||||
if !authz.Can(ctx, "POST", "organizations", store.MembershipOwner, in.Org) {
|
||||
return httpx.Err(c, unauthorized)
|
||||
}
|
||||
added, err := store.EnsureMembership(ctx, db, in.User, in.Org, in.Role)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, added)
|
||||
}
|
||||
}
|
||||
|
||||
// scoped reports whether the caller may read the membership rows of org — i.e.
|
||||
// whether resolving the scope from its own verified credential yields exactly
|
||||
// the org it asked for. A SuperAdmin gets what it asks for; anyone else gets its
|
||||
// own org, so any other request fails the equality and is refused.
|
||||
func scoped(ctx context.Context, org string) bool {
|
||||
got, err := authz.Scope(ctx, org)
|
||||
return err == nil && got == org
|
||||
}
|
||||
|
||||
// listed writes a membership listing, or the error envelope on failure.
|
||||
func listed(c *zip.Ctx, rows []*schema.Membership, err error) error {
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return c.JSON(200, httpx.Response{Status: "ok", Data: rows, Data2: len(rows)})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package factor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Package factor is the pure multi-factor DOMAIN — what a factor IS, whether a
|
||||
// passcode verifies, which factors a user has, whether the org demands one, and
|
||||
// how that state is written. It is the ONE implementation both the enrollment
|
||||
// surface (internal/mfa) and the login-time second-factor gate (internal/oidc)
|
||||
// call, so the Verify the challenge runs is the one enrollment's setup check uses
|
||||
// and the Save every MFA write goes through cannot drift apart.
|
||||
//
|
||||
// It is a LEAF: it imports only store + schema, never authz or oidc. That is what
|
||||
// lets the gate (in oidc, which authz imports) use it without an import cycle,
|
||||
// while the enrollment surface (which does need authz) uses it too — one domain,
|
||||
// two callers, no duplication. Radius and push are deliberately absent: no v2
|
||||
// provider transport serves them, and a factor listed as available but unservable
|
||||
// is an unusable challenge.
|
||||
|
||||
// The factor types, verbatim from v1 (object/mfa.go:42-48). "app" is TOTP — the
|
||||
// name is v1's and it is on the wire, so it does not get "improved".
|
||||
const (
|
||||
App = "app"
|
||||
SMS = "sms"
|
||||
Email = "email"
|
||||
)
|
||||
|
||||
// Types lists the factors this package can project, in v1's order. It bounds
|
||||
// AllProps: a factor absent here is never offered on a challenge.
|
||||
var Types = []string{SMS, Email, App}
|
||||
|
||||
// errNoUser is the ONE answer to an unresolvable MFA subject.
|
||||
var errNoUser = errors.New("user doesn't exist")
|
||||
|
||||
// Enroll generates a fresh TOTP secret for userID ("owner/name") and the
|
||||
// otpauth:// URL that encodes it, using the RFC 6238 defaults every authenticator
|
||||
// app assumes (the same totp.Generate defaults the enrollment surface uses). It
|
||||
// persists NOTHING: enrollment is stateless and client-held until enable commits
|
||||
// it.
|
||||
func Enroll(userID, issuer string) (secret, url string, err error) {
|
||||
if issuer == "" {
|
||||
issuer = "Hanzo"
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: userID})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// Verify reports whether passcode is currently valid for secret. It is the ONE
|
||||
// TOTP verification point — enrollment's setup check and the login challenge call
|
||||
// this same function, so they cannot drift apart. totp.Validate accepts the
|
||||
// adjacent windows (skew 1), tolerating clock drift.
|
||||
func Verify(secret, passcode string) bool {
|
||||
if secret == "" || passcode == "" {
|
||||
return false
|
||||
}
|
||||
return totp.Validate(passcode, secret)
|
||||
}
|
||||
|
||||
// recoveryBytes is the entropy behind one recovery code: 20 bytes → 32 base32
|
||||
// characters, the same strength as the TOTP secret it backs up.
|
||||
const recoveryBytes = 20
|
||||
|
||||
// MintRecovery returns one fresh recovery code, in the clear, for the user to write
|
||||
// down. It asks crypto/rand for a secret directly (not a formatted identifier).
|
||||
func MintRecovery() (string, error) {
|
||||
b := make([]byte, recoveryBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// HashRecovery is the digest a recovery code is STORED as. A recovery code is a
|
||||
// bearer credential verified by equality alone, so — unlike the TOTP secret, which
|
||||
// the verifier needs back in the clear — it hashes like a password.
|
||||
func HashRecovery(plain string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
return string(h), err
|
||||
}
|
||||
|
||||
// HashRecoveryCodes digests each plaintext recovery code for storage — enrollment
|
||||
// hands the user the plaintext (the QR's backup code) exactly once and keeps only
|
||||
// the digest, so a database dump exposes no usable recovery credential.
|
||||
func HashRecoveryCodes(plain []string) ([]string, error) {
|
||||
out := make([]string, 0, len(plain))
|
||||
for _, p := range plain {
|
||||
h, err := HashRecovery(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UseRecovery consumes one of the user's recovery codes, reporting whether code
|
||||
// matched. A hit is DELETED from u.RecoveryCodes in place — one-time use — and the
|
||||
// caller persists the row.
|
||||
//
|
||||
// Stored codes are bcrypt digests, but every code migrated from v1 is PLAINTEXT
|
||||
// (object/mfa.go:81 compares in the clear), so a stored value that is not a digest
|
||||
// is compared literally. The algorithm is a property of the stored value, never a
|
||||
// constant — the same rule the password path lives by. A legacy hit is spent and
|
||||
// removed like any other, so the plaintext dies on first use.
|
||||
func UseRecovery(u *schema.User, code string) bool {
|
||||
if u == nil || code == "" {
|
||||
return false
|
||||
}
|
||||
for i, stored := range u.RecoveryCodes {
|
||||
if !recoveryMatches(stored, code) {
|
||||
continue
|
||||
}
|
||||
u.RecoveryCodes = append(u.RecoveryCodes[:i:i], u.RecoveryCodes[i+1:]...)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recoveryMatches compares one presented code against one stored value, choosing
|
||||
// the comparison from what the value IS: a bcrypt digest is verified with bcrypt,
|
||||
// a v1-era plaintext by equality.
|
||||
func recoveryMatches(stored, code string) bool {
|
||||
if isBcrypt(stored) {
|
||||
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(code)) == nil
|
||||
}
|
||||
return stored != "" && stored == code
|
||||
}
|
||||
|
||||
// isBcrypt reports whether s is a bcrypt digest by asking the library's own parser
|
||||
// (bcrypt.Cost), so the answer comes from the format itself rather than a guess.
|
||||
func isBcrypt(s string) bool {
|
||||
_, err := bcrypt.Cost([]byte(s))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Enabled reports whether the user has multi-factor sign-in on. The predicate is
|
||||
// PreferredMfaType != "" and nothing else (v1 object/user.go:1641): the per-factor
|
||||
// enabled flags say which factors exist, not whether the gate runs.
|
||||
func Enabled(u *schema.User) bool { return u != nil && u.PreferredMfaType != "" }
|
||||
|
||||
// Prompt reports whether the organization REQUIRES a factor the user has not
|
||||
// enrolled yet — the sign-in must divert to enrollment before it can finish. The
|
||||
// user's own MfaItems override the org's entirely when present (not merge: v1
|
||||
// object/organization.go:770-792), so a per-user policy is a replacement.
|
||||
func Prompt(org *schema.Organization, u *schema.User) bool {
|
||||
if org == nil || u == nil {
|
||||
return false
|
||||
}
|
||||
items := org.MfaItems
|
||||
if len(u.MfaItems) > 0 {
|
||||
items = u.MfaItems
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == nil || item.Rule != "Required" {
|
||||
continue
|
||||
}
|
||||
switch item.Name {
|
||||
case Email:
|
||||
if !u.MfaEmailEnabled {
|
||||
return true
|
||||
}
|
||||
case SMS:
|
||||
if !u.MfaPhoneEnabled {
|
||||
return true
|
||||
}
|
||||
case App:
|
||||
if u.TotpSecret == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Props projects one factor of the user for a client, ALWAYS masked: Secret and
|
||||
// RecoveryCodes are never populated (and are json:"-" besides). The login-gate
|
||||
// verifier reads u.TotpSecret directly, so this projection has no unmasked mode to
|
||||
// misuse.
|
||||
func Props(u *schema.User, mfaType string) *schema.MfaProps {
|
||||
p := &schema.MfaProps{MfaType: mfaType}
|
||||
if u == nil {
|
||||
return p
|
||||
}
|
||||
switch mfaType {
|
||||
case SMS:
|
||||
p.Enabled = u.MfaPhoneEnabled
|
||||
if p.Enabled {
|
||||
p.CountryCode = u.CountryCode
|
||||
}
|
||||
case Email:
|
||||
p.Enabled = u.MfaEmailEnabled
|
||||
case App:
|
||||
p.Enabled = u.TotpSecret != ""
|
||||
}
|
||||
if !p.Enabled {
|
||||
return &schema.MfaProps{MfaType: mfaType}
|
||||
}
|
||||
p.IsPreferred = u.PreferredMfaType == mfaType
|
||||
return p
|
||||
}
|
||||
|
||||
// AllProps projects every factor this package serves, masked, in v1's order.
|
||||
func AllProps(u *schema.User) []*schema.MfaProps {
|
||||
all := make([]*schema.MfaProps, 0, len(Types))
|
||||
for _, t := range Types {
|
||||
all = append(all, Props(u, t))
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// Copy overwrites dst's multi-factor state with src's, and nothing else. It is the
|
||||
// ONE declaration of which columns ARE multi-factor state, so every writer agrees
|
||||
// on the set by construction: Save overlays a caller's factors onto the STORED row
|
||||
// through this, which is what makes an MFA write column-scoped — the request's user
|
||||
// value never reaches the store, so it cannot carry isAdmin along and self-promote.
|
||||
func Copy(dst, src *schema.User) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.PreferredMfaType = src.PreferredMfaType
|
||||
dst.RecoveryCodes = src.RecoveryCodes
|
||||
dst.TotpSecret = src.TotpSecret
|
||||
dst.MfaPhoneEnabled = src.MfaPhoneEnabled
|
||||
dst.MfaEmailEnabled = src.MfaEmailEnabled
|
||||
dst.MfaRadiusEnabled = src.MfaRadiusEnabled
|
||||
dst.MfaRadiusUsername = src.MfaRadiusUsername
|
||||
dst.MfaRadiusProvider = src.MfaRadiusProvider
|
||||
dst.MfaPushEnabled = src.MfaPushEnabled
|
||||
dst.MfaPushReceiver = src.MfaPushReceiver
|
||||
dst.MfaPushProvider = src.MfaPushProvider
|
||||
dst.MfaRememberDeadline = src.MfaRememberDeadline
|
||||
}
|
||||
|
||||
// Save writes u's multi-factor state — and ONLY that — onto its stored row. It is
|
||||
// the single write point for every MFA mutation the login gate makes: spend a
|
||||
// recovery code, remember a device. The scoping is what makes it safe: the row is
|
||||
// loaded fresh and Copy overlays exactly the multi-factor columns, so an isAdmin,
|
||||
// a balance, or a password digest arriving on an MFA request reaches nothing.
|
||||
func Save(ctx context.Context, db orm.DB, u *schema.User) error {
|
||||
if u == nil {
|
||||
return errNoUser
|
||||
}
|
||||
stored, err := store.GetUserByName(ctx, db, u.Owner, u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored == nil {
|
||||
return errNoUser
|
||||
}
|
||||
Copy(stored, u)
|
||||
return stored.UpdateCtx(ctx)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package mfa serves the TOTP multi-factor enrollment surface — the account
|
||||
// security page's initiate → verify → enable flow (RFC 6238 TOTP), plus
|
||||
// delete-mfa and set-preferred-mfa. Enrollment is SELF-SERVICE: every handler
|
||||
// acts on the AUTHENTICATED caller's own user record (authz.From), so the routes
|
||||
// mount AFTER the Guard — they need the Principal. Touching a DIFFERENT user's
|
||||
// MFA requires admin authority over that org, authorized through the SAME seam a
|
||||
// SCIM write uses (authz.Can); the general user-write policy correctly refuses a
|
||||
// non-admin writing a user row, so self-enrollment is authorized by
|
||||
// self-ownership (target == principal), NOT by that policy.
|
||||
//
|
||||
// The handshake is STATELESS across the three calls: initiate mints a TOTP
|
||||
// secret + otpauth URL + recovery code and hands them to the client; the client
|
||||
// renders the QR, the authenticator app derives a passcode, verify checks it
|
||||
// against the SAME secret the client echoes back, and enable persists the secret
|
||||
// + recovery code to the user. No pending secret is parked server-side between
|
||||
// calls — it is client-held until enable commits it.
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/authz"
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The TOTP factor type ("app") and the domain helpers are factor.App et al (internal/mfa/factor).
|
||||
|
||||
// Route registers the MFA endpoints on app. They are RAW handlers (not typed
|
||||
// ops), so — like SCIM — each authorizes itself; callers mount app AFTER the
|
||||
// Guard so a verified Principal rides the request context.
|
||||
func Route(app *zip.App, db orm.DB) {
|
||||
app.Post("/v1/iam/mfa/setup/initiate", initiate(db))
|
||||
app.Post("/v1/iam/mfa/setup/verify", verify(db))
|
||||
app.Post("/v1/iam/mfa/setup/enable", enable(db))
|
||||
app.Post("/v1/iam/delete-mfa", disable(db))
|
||||
app.Post("/v1/iam/set-preferred-mfa", setPreferred(db))
|
||||
}
|
||||
|
||||
// setupReq is the union of fields the enrollment handshake posts. owner/name
|
||||
// address the target user (default: the caller itself); secret/passcode/
|
||||
// recoveryCodes carry the client-held enrollment material; mfaType selects the
|
||||
// preferred factor for set-preferred-mfa.
|
||||
type setupReq struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
Passcode string `json:"passcode"`
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
MfaType string `json:"mfaType"`
|
||||
}
|
||||
|
||||
// target resolves the (owner, name) an MFA request addresses and authorizes it:
|
||||
// the caller may always manage its OWN record; touching another user's MFA
|
||||
// requires admin authority over that org (authz.Can — the seam SCIM writes use).
|
||||
// An unauthenticated caller fails closed (the Guard already required a bearer, so
|
||||
// this is defense in depth). Returns a zip error to return verbatim on refusal.
|
||||
func target(c *zip.Ctx, req *setupReq) (owner, name string, err error) {
|
||||
p, present := authz.From(c.Context())
|
||||
if !present {
|
||||
return "", "", zip.ErrUnauthorized("authentication required")
|
||||
}
|
||||
owner, name = strings.TrimSpace(req.Owner), strings.TrimSpace(req.Name)
|
||||
if owner == "" || name == "" {
|
||||
owner, name = p.Org, p.User // default: the caller itself
|
||||
}
|
||||
self := owner == p.Org && name == p.User
|
||||
if !self && !authz.Can(c.Context(), "PUT", "users", owner, name) {
|
||||
return "", "", zip.ErrForbidden("forbidden")
|
||||
}
|
||||
return owner, name, nil
|
||||
}
|
||||
|
||||
// initiate mints a fresh TOTP secret + otpauth URL + a single recovery code and
|
||||
// returns them for the client to display (QR + backup code). Nothing is
|
||||
// persisted — the secret is committed only by enable. Response:
|
||||
// {status:"ok", data:{secret, url, recoveryCodes:[code]}}.
|
||||
func initiate(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
_ = decode(c, &req) // body optional: owner/name default to the caller
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer(owner), AccountName: name})
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("failed to generate secret"))
|
||||
}
|
||||
code, err := recoveryCode()
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(map[string]any{
|
||||
"secret": key.Secret(),
|
||||
"url": key.URL(),
|
||||
"recoveryCodes": []string{code},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// verify checks a passcode against the client-echoed secret (RFC 6238, ±1 step).
|
||||
// A valid code → {status:"ok"}; an invalid one → 200 {status:"error"} (the
|
||||
// casibase convention: clients branch on status, not the HTTP code).
|
||||
func verify(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body"))
|
||||
}
|
||||
if _, _, err := target(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Secret == "" || req.Passcode == "" {
|
||||
return c.JSON(200, errResp("secret and passcode are required"))
|
||||
}
|
||||
if !totp.Validate(req.Passcode, req.Secret) {
|
||||
return c.JSON(200, errResp("the code is incorrect"))
|
||||
}
|
||||
return c.JSON(200, okData(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// enable commits the client-held secret + recovery code to the target user and
|
||||
// marks TOTP the preferred factor. An idempotent overwrite of the MFA fields.
|
||||
func enable(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body"))
|
||||
}
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Secret == "" {
|
||||
return c.JSON(200, errResp("secret is required"))
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if u == nil {
|
||||
return c.JSON(404, errResp("user not found"))
|
||||
}
|
||||
u.TotpSecret = req.Secret
|
||||
hashed, herr := factor.HashRecoveryCodes(req.RecoveryCodes)
|
||||
if herr != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
u.RecoveryCodes = hashed
|
||||
u.PreferredMfaType = factor.App
|
||||
if err := u.UpdateCtx(c.Context()); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(map[string]any{"preferredMfaType": factor.App}))
|
||||
}
|
||||
}
|
||||
|
||||
// disable clears every TOTP field on the target user (delete-mfa).
|
||||
func disable(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
_ = decode(c, &req) // body optional: owner/name default to the caller
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if u == nil {
|
||||
return c.JSON(404, errResp("user not found"))
|
||||
}
|
||||
u.TotpSecret = ""
|
||||
u.RecoveryCodes = nil
|
||||
u.PreferredMfaType = ""
|
||||
if err := u.UpdateCtx(c.Context()); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// setPreferred selects which enrolled factor is preferred (set-preferred-mfa).
|
||||
func setPreferred(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var req setupReq
|
||||
if err := decode(c, &req); err != nil {
|
||||
return c.JSON(400, errResp("invalid body"))
|
||||
}
|
||||
owner, name, err := target(c, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.MfaType) == "" {
|
||||
return c.JSON(200, errResp("mfaType is required"))
|
||||
}
|
||||
u, err := store.GetUserByName(c.Context(), db, owner, name)
|
||||
if err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
if u == nil {
|
||||
return c.JSON(404, errResp("user not found"))
|
||||
}
|
||||
u.PreferredMfaType = req.MfaType
|
||||
if err := u.UpdateCtx(c.Context()); err != nil {
|
||||
return c.JSON(500, errResp("server_error"))
|
||||
}
|
||||
return c.JSON(200, okData(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func decode(c *zip.Ctx, v any) error {
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return errors.New("empty request body")
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// issuer is the otpauth issuer label the authenticator app shows: an explicit
|
||||
// IAM_MFA_ISSUER override (white-label brand), else the account's org, else Hanzo.
|
||||
func issuer(owner string) string {
|
||||
if v := strings.TrimSpace(os.Getenv("IAM_MFA_ISSUER")); v != "" {
|
||||
return v
|
||||
}
|
||||
if owner != "" {
|
||||
return owner
|
||||
}
|
||||
return "Hanzo"
|
||||
}
|
||||
|
||||
// recoveryCode returns a 160-bit base32 single-use backup code.
|
||||
func recoveryCode() (string, error) {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func okData(data any) map[string]any {
|
||||
m := map[string]any{"status": "ok"}
|
||||
if data != nil {
|
||||
m["data"] = data
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]any { return map[string]any{"status": "error", "msg": msg} }
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package mfa_test
|
||||
|
||||
// TOTP MFA tests driven through the REAL mounted router (routes.Route installs
|
||||
// the Guard, then mfa.Route after it). Every case is a wire request the account
|
||||
// security page sends. The assertions pin the enrollment contract (initiate mints
|
||||
// a secret the client can turn into a valid passcode; enable persists it) and the
|
||||
// security one: enrollment is self-service on your OWN record, and a regular user
|
||||
// can NEVER touch another user's MFA — that needs admin authority.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/routes"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
const signingKid = "cert-hanzo"
|
||||
|
||||
type harness struct {
|
||||
app *zip.App
|
||||
key *rsa.PrivateKey
|
||||
db orm.DB
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
_ = schema.Kinds()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
db, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(dir, "mfa.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
seedCert(t, db, "admin", signingKid, pemOf(t, key))
|
||||
seedUser(t, db, "admin", "root", true) // SuperAdmin (org == admin)
|
||||
seedUser(t, db, "hanzo", "boss", true) // org-admin of hanzo
|
||||
seedUser(t, db, "hanzo", "alice", false) // regular user in hanzo
|
||||
|
||||
app := zip.New(zip.Config{AppName: "mfa-test", DisableStartupMessage: true})
|
||||
routes.Route(app, db)
|
||||
app.Prepare()
|
||||
return &harness{app: app, key: key, db: db}
|
||||
}
|
||||
|
||||
func (h *harness) token(t *testing.T, sub string) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
tok.Header["kid"] = signingKid
|
||||
s, err := tok.SignedString(h.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *harness) do(t *testing.T, path, bearer, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest("POST", path, r)
|
||||
req.Host = "hanzo.id"
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := h.app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return resp.StatusCode, m
|
||||
}
|
||||
|
||||
// dataString reads m.data.<key> as a string.
|
||||
func dataString(m map[string]any, key string) string {
|
||||
d, _ := m["data"].(map[string]any)
|
||||
s, _ := d[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// TestMFA_enrollLifecycle: a regular user enrolls TOTP on her own account —
|
||||
// initiate mints a secret she can turn into a valid passcode, verify accepts it,
|
||||
// enable persists it, disable clears it.
|
||||
func TestMFA_enrollLifecycle(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
|
||||
// initiate — a secret, an otpauth URL, and a recovery code.
|
||||
st, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
|
||||
if st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("initiate: status=%d body=%v", st, m)
|
||||
}
|
||||
secret := dataString(m, "secret")
|
||||
if secret == "" {
|
||||
t.Fatalf("initiate returned no secret: %v", m)
|
||||
}
|
||||
if url := dataString(m, "url"); !strings.HasPrefix(url, "otpauth://totp/") {
|
||||
t.Fatalf("initiate url is not an otpauth URI: %q", url)
|
||||
}
|
||||
d, _ := m["data"].(map[string]any)
|
||||
codes, _ := d["recoveryCodes"].([]any)
|
||||
if len(codes) == 0 || codes[0].(string) == "" {
|
||||
t.Fatalf("initiate returned no recovery code: %v", d)
|
||||
}
|
||||
recovery := codes[0].(string)
|
||||
|
||||
// verify — a code derived from the secret is accepted.
|
||||
code, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("totp code: %v", err)
|
||||
}
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/verify", alice,
|
||||
`{"secret":"`+secret+`","passcode":"`+code+`"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("verify valid code: status=%d body=%v", st, m)
|
||||
}
|
||||
|
||||
// enable — the secret + recovery code land on alice's row; TOTP is preferred.
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/enable", alice,
|
||||
`{"secret":"`+secret+`","recoveryCodes":["`+recovery+`"]}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("enable: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
|
||||
if u == nil || u.TotpSecret != secret {
|
||||
t.Fatalf("enable did not persist TotpSecret: %+v", u)
|
||||
}
|
||||
if u.PreferredMfaType != "app" {
|
||||
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
|
||||
}
|
||||
if len(u.RecoveryCodes) == 0 {
|
||||
t.Fatalf("enable did not persist recovery codes")
|
||||
}
|
||||
|
||||
// disable — every TOTP field is cleared.
|
||||
if st, m := h.do(t, "/v1/iam/delete-mfa", alice, `{}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("disable: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ = store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
|
||||
if u.TotpSecret != "" || u.PreferredMfaType != "" || len(u.RecoveryCodes) != 0 {
|
||||
t.Fatalf("disable did not clear MFA fields: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_verifyRejectsBadCode: an incorrect passcode is refused (status:error at
|
||||
// 200 — the casibase convention the console branches on).
|
||||
func TestMFA_verifyRejectsBadCode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
_, m := h.do(t, "/v1/iam/mfa/setup/initiate", alice, `{}`)
|
||||
secret := dataString(m, "secret")
|
||||
|
||||
st, body := h.do(t, "/v1/iam/mfa/setup/verify", alice,
|
||||
`{"secret":"`+secret+`","passcode":"000000"}`)
|
||||
if st != 200 || body["status"] != "error" {
|
||||
t.Fatalf("bad code should be rejected: status=%d body=%v", st, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_crossUserRequiresAdmin: a regular user cannot enroll/disable MFA on
|
||||
// ANOTHER user — the general user-write policy refuses it (403). An org-admin and
|
||||
// a super over that user CAN.
|
||||
func TestMFA_crossUserRequiresAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice") // regular
|
||||
boss := h.token(t, "hanzo/boss") // org-admin of hanzo
|
||||
super := h.token(t, "admin/root") // SuperAdmin
|
||||
|
||||
// alice → boss's MFA: forbidden.
|
||||
body := `{"owner":"hanzo","name":"boss"}`
|
||||
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", alice, body); st != 403 {
|
||||
t.Fatalf("regular user initiating another user's MFA: status=%d, want 403", st)
|
||||
}
|
||||
if st, _ := h.do(t, "/v1/iam/delete-mfa", alice, body); st != 403 {
|
||||
t.Fatalf("regular user disabling another user's MFA: status=%d, want 403", st)
|
||||
}
|
||||
|
||||
// org-admin → a user in the SAME org: allowed.
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", boss,
|
||||
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("org-admin initiating a same-org user's MFA: status=%d body=%v", st, m)
|
||||
}
|
||||
// super → anyone: allowed.
|
||||
if st, m := h.do(t, "/v1/iam/mfa/setup/initiate", super,
|
||||
`{"owner":"hanzo","name":"alice"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("super initiating a user's MFA: status=%d body=%v", st, m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_setPreferred: a user selects a preferred factor on her own account.
|
||||
func TestMFA_setPreferred(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
alice := h.token(t, "hanzo/alice")
|
||||
if st, m := h.do(t, "/v1/iam/set-preferred-mfa", alice, `{"mfaType":"app"}`); st != 200 || m["status"] != "ok" {
|
||||
t.Fatalf("set-preferred-mfa: status=%d body=%v", st, m)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), h.db, "hanzo", "alice")
|
||||
if u.PreferredMfaType != "app" {
|
||||
t.Fatalf("preferredMfaType = %q, want app", u.PreferredMfaType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA_requiresBearer: no token → the Guard refuses before the handler.
|
||||
func TestMFA_requiresBearer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if st, _ := h.do(t, "/v1/iam/mfa/setup/initiate", "", `{}`); st != 401 {
|
||||
t.Fatalf("no-bearer initiate: status=%d, want 401", st)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seed helpers (mirror the SCIM harness) ----
|
||||
|
||||
func seedCert(t *testing.T, db orm.DB, owner, name, privPEM string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner, c.Name = owner, name
|
||||
c.PrivateKey = privPEM
|
||||
c.SetId(owner + "/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db orm.DB, owner, name string, admin bool) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsAdmin = admin
|
||||
u.PasswordHash = "$argon2id$SENTINEL"
|
||||
u.PasswordType = "argon2id"
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pemOf(t *testing.T, k *rsa.PrivateKey) string {
|
||||
t.Helper()
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The authorization endpoint: GET/POST /v1/iam/oauth/authorize — the front door
|
||||
// of the authorization-code flow. iam2 validates the request BEFORE it trusts
|
||||
// any redirect: an unknown client_id or an unregistered redirect_uri is answered
|
||||
// in place and NEVER redirected to (RFC 6749 §4.1.2.1), closing the open-redirect
|
||||
// and code-injection surface that a bare pass-through would leave open. A
|
||||
// well-formed request is delegated to the hosted login UI (matching v1), which
|
||||
// collects credentials and posts to /v1/iam/login; that endpoint mints the
|
||||
// PKCE-bound code and the browser lands back on the registered redirect_uri.
|
||||
|
||||
// hostedLoginPath is the default hosted-login route the authorize endpoint hands
|
||||
// a validated request to when the application pins no SigninUrl of its own.
|
||||
const hostedLoginPath = "/login/oauth/authorize"
|
||||
|
||||
// authorizeRequest is the parsed authorize query.
|
||||
type authorizeRequest struct {
|
||||
responseType string
|
||||
clientID string
|
||||
redirectURI string
|
||||
scope string
|
||||
state string
|
||||
nonce string
|
||||
codeChallenge string
|
||||
codeChallengeMethod string
|
||||
resource string
|
||||
responseMode string
|
||||
provider string
|
||||
}
|
||||
|
||||
func authorizeHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
q := authorizeParams(c)
|
||||
|
||||
// 1. Resolve the client. Without a known client there is no trusted
|
||||
// redirect target, so the error is shown in place — never redirected.
|
||||
if q.clientID == "" {
|
||||
return authorizeUserError(c, "client_id is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, q.clientID)
|
||||
if err != nil {
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
if app == nil {
|
||||
return authorizeUserError(c, "unknown client_id")
|
||||
}
|
||||
// 2. redirect_uri must EXACTLY match a registered URI before it can ever
|
||||
// be used as a redirect target. A mismatch is answered in place.
|
||||
if q.redirectURI == "" || !app.IsRedirectUriValid(q.redirectURI) {
|
||||
return authorizeUserError(c, "invalid redirect_uri")
|
||||
}
|
||||
|
||||
// The redirect target is now trusted: protocol errors redirect back to it
|
||||
// with error+state (RFC 6749 §4.1.2.1).
|
||||
if q.responseType != "code" {
|
||||
return authorizeErrorRedirect(c, q, "unsupported_response_type", "only response_type=code is supported")
|
||||
}
|
||||
method := normalizeChallengeMethod(q.codeChallenge, q.codeChallengeMethod)
|
||||
if q.codeChallenge != "" && method != "S256" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "only S256 PKCE is supported")
|
||||
}
|
||||
if app.ClientSecret == "" && q.codeChallenge == "" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "PKCE is required for public clients")
|
||||
}
|
||||
|
||||
// A request that names a social `provider` is federated to that external
|
||||
// IdP (Google/GitHub, …) instead of the hosted credential login. The
|
||||
// client + redirect_uri + PKCE policy above are already enforced, so the
|
||||
// federation broker starts from a validated request and a trusted target.
|
||||
if q.provider != "" {
|
||||
return beginFederation(c, db, app, q, method)
|
||||
}
|
||||
|
||||
// Delegate to the hosted login with a clean, re-encoded request. The login
|
||||
// page posts credentials to /v1/iam/login, which mints the code.
|
||||
return c.Redirect(302, hostedLoginTarget(app)+"?"+authorizeForwardQuery(q, method))
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeParams reads the authorize parameters from the query (GET) or form
|
||||
// body (POST).
|
||||
func authorizeParams(c *zip.Ctx) authorizeRequest {
|
||||
return authorizeRequest{
|
||||
responseType: param(c, "response_type"),
|
||||
clientID: param(c, "client_id"),
|
||||
redirectURI: param(c, "redirect_uri"),
|
||||
scope: param(c, "scope"),
|
||||
state: param(c, "state"),
|
||||
nonce: param(c, "nonce"),
|
||||
codeChallenge: param(c, "code_challenge"),
|
||||
codeChallengeMethod: param(c, "code_challenge_method"),
|
||||
resource: param(c, "resource"),
|
||||
responseMode: param(c, "response_mode"),
|
||||
provider: param(c, "provider"),
|
||||
}
|
||||
}
|
||||
|
||||
// hostedLoginTarget is the login URL a validated request is delegated to — the
|
||||
// application's own SigninUrl when set, else the default hosted-login route.
|
||||
func hostedLoginTarget(app *schema.Application) string {
|
||||
if app.SigninUrl != "" {
|
||||
return app.SigninUrl
|
||||
}
|
||||
return hostedLoginPath
|
||||
}
|
||||
|
||||
// authorizeForwardQuery re-encodes the validated request as a clean query string
|
||||
// for the hosted login — reconstructed from known parameters so nothing
|
||||
// unexpected is passed through.
|
||||
func authorizeForwardQuery(q authorizeRequest, method string) string {
|
||||
v := url.Values{}
|
||||
v.Set("response_type", "code")
|
||||
v.Set("client_id", q.clientID)
|
||||
v.Set("redirect_uri", q.redirectURI)
|
||||
setIfPresent(v, "scope", q.scope)
|
||||
setIfPresent(v, "state", q.state)
|
||||
setIfPresent(v, "nonce", q.nonce)
|
||||
if q.codeChallenge != "" {
|
||||
v.Set("code_challenge", q.codeChallenge)
|
||||
v.Set("code_challenge_method", method)
|
||||
}
|
||||
setIfPresent(v, "resource", q.resource)
|
||||
setIfPresent(v, "response_mode", q.responseMode)
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
// authorizeErrorRedirect bounces a protocol error back to the (already
|
||||
// validated) redirect_uri with error+state, in the requested response mode.
|
||||
func authorizeErrorRedirect(c *zip.Ctx, q authorizeRequest, code, desc string) error {
|
||||
v := url.Values{}
|
||||
v.Set("error", code)
|
||||
setIfPresent(v, "error_description", desc)
|
||||
setIfPresent(v, "state", q.state)
|
||||
|
||||
sep := "?"
|
||||
switch {
|
||||
case q.responseMode == "fragment":
|
||||
sep = "#"
|
||||
case strings.Contains(q.redirectURI, "?"):
|
||||
sep = "&"
|
||||
}
|
||||
return c.Redirect(302, q.redirectURI+sep+v.Encode())
|
||||
}
|
||||
|
||||
// authorizeUserError answers a request whose client_id/redirect_uri could not be
|
||||
// validated: the resource owner is informed in place and the request is NOT
|
||||
// redirected anywhere (RFC 6749 §4.1.2.1). The message is server-controlled.
|
||||
func authorizeUserError(c *zip.Ctx, msg string) error {
|
||||
c.SetHeader("Content-Type", "text/plain; charset=utf-8")
|
||||
return c.String(400, "authorization error: "+msg)
|
||||
}
|
||||
|
||||
// normalizeChallengeMethod maps an omitted PKCE method to S256 when a challenge
|
||||
// is present (S256 is the only method iam2 supports); an explicit non-S256
|
||||
// method is returned unchanged so the caller rejects the downgrade.
|
||||
func normalizeChallengeMethod(challenge, method string) string {
|
||||
if challenge == "" {
|
||||
return method
|
||||
}
|
||||
if method == "" || strings.EqualFold(method, "null") {
|
||||
return "S256"
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
// setIfPresent sets a query value only when non-empty.
|
||||
func setIfPresent(v url.Values, key, value string) {
|
||||
if value != "" {
|
||||
v.Set(key, value)
|
||||
}
|
||||
}
|
||||
@@ -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/iam/internal/schema"
|
||||
)
|
||||
|
||||
// certkey resolves the PUBLIC half of a signing Cert and encodes it as a JWK.
|
||||
// It is the one place cert → public-key happens, shared by the JWKS endpoint
|
||||
// (which publishes the key so relying parties can verify) and token
|
||||
// verification (which checks a bearer against it). The public key is read from
|
||||
// the Cert's published x509 certificate when present, else derived from the key
|
||||
// pair; private material never crosses this boundary.
|
||||
|
||||
// certPublicKey returns a Cert's public key, its JOSE alg, and (for x509 certs)
|
||||
// the base64 DER chain for the JWK `x5c`. An ML-DSA cert yields a raw ML-DSA
|
||||
// public key and no chain.
|
||||
func certPublicKey(cert *schema.Cert) (pub crypto.PublicKey, alg string, x5c []string, err error) {
|
||||
if cert == nil {
|
||||
return nil, "", nil, errors.New("jwks: nil cert")
|
||||
}
|
||||
if isMLDSACert(cert) {
|
||||
pk, err := mldsa65PublicFromCert(cert)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return pk, algMLDSA65, nil, nil
|
||||
}
|
||||
if cert.Certificate != "" {
|
||||
block, _ := pem.Decode([]byte(cert.Certificate))
|
||||
if block != nil {
|
||||
x509Cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
a, err := classicalAlg(x509Cert.PublicKey, cert.CryptoAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return x509Cert.PublicKey, a, []string{base64.StdEncoding.EncodeToString(x509Cert.Raw)}, nil
|
||||
}
|
||||
}
|
||||
// Dev/test cert that stores only the private key: derive the public half.
|
||||
signer, err := parsePrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
a, err := classicalAlg(signer.Public(), cert.CryptoAlgorithm)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return signer.Public(), a, nil, nil
|
||||
}
|
||||
|
||||
// certToJWK encodes a Cert's public key as a JWK map: {kty, alg, use:"sig", kid,
|
||||
// key params, x5c?}. kid is the Cert name (what token headers carry), matching
|
||||
// the live hanzo.id JWKS.
|
||||
func certToJWK(cert *schema.Cert) (map[string]any, error) {
|
||||
pub, alg, x5c, err := certPublicKey(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jwk map[string]any
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
jwk = rsaJWK(k)
|
||||
case *ecdsa.PublicKey:
|
||||
jwk, err = ecJWK(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *mldsa65.PublicKey:
|
||||
jwk = map[string]any{"kty": "MLDSA", "x": base64.RawURLEncoding.EncodeToString(k.Bytes())}
|
||||
default:
|
||||
return nil, errors.New("jwks: unsupported public key type")
|
||||
}
|
||||
jwk["use"] = "sig"
|
||||
jwk["kid"] = cert.Name
|
||||
jwk["alg"] = alg
|
||||
if len(x5c) > 0 {
|
||||
jwk["x5c"] = x5c
|
||||
}
|
||||
return jwk, nil
|
||||
}
|
||||
|
||||
// rsaJWK encodes an RSA public key's modulus and exponent (RFC 7518 §6.3).
|
||||
func rsaJWK(k *rsa.PublicKey) map[string]any {
|
||||
return map[string]any{
|
||||
"kty": "RSA",
|
||||
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
// ecJWK encodes an EC public key's curve and fixed-width coordinates (RFC 7518
|
||||
// §6.2) and returns the curve's JOSE alg.
|
||||
func ecJWK(k *ecdsa.PublicKey) (map[string]any, error) {
|
||||
var crv string
|
||||
var size int
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
crv, size = "P-256", 32
|
||||
case 384:
|
||||
crv, size = "P-384", 48
|
||||
case 521:
|
||||
crv, size = "P-521", 66
|
||||
default:
|
||||
return nil, errors.New("jwks: unsupported EC curve")
|
||||
}
|
||||
return map[string]any{
|
||||
"kty": "EC",
|
||||
"crv": crv,
|
||||
"x": base64.RawURLEncoding.EncodeToString(leftPad(k.X.Bytes(), size)),
|
||||
"y": base64.RawURLEncoding.EncodeToString(leftPad(k.Y.Bytes(), size)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// classicalAlg maps a classical public key (and the Cert's declared algorithm,
|
||||
// when it agrees with the key family) to a JOSE alg. The key type is
|
||||
// authoritative; the declared value only refines RSA (RS256 default, RS512 when
|
||||
// pinned).
|
||||
func classicalAlg(pub crypto.PublicKey, declared string) (string, error) {
|
||||
switch k := pub.(type) {
|
||||
case *rsa.PublicKey:
|
||||
if strings.EqualFold(declared, "RS512") {
|
||||
return "RS512", nil
|
||||
}
|
||||
return "RS256", nil
|
||||
case *ecdsa.PublicKey:
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
return "ES256", nil
|
||||
case 384:
|
||||
return "ES384", nil
|
||||
case 521:
|
||||
return "ES512", nil
|
||||
}
|
||||
return "", errors.New("jwks: unsupported EC curve")
|
||||
default:
|
||||
return "", errors.New("jwks: unsupported public key type")
|
||||
}
|
||||
}
|
||||
|
||||
// leftPad left-zero-pads b to size bytes (EC coordinates are fixed-width).
|
||||
func leftPad(b []byte, size int) []byte {
|
||||
if len(b) >= size {
|
||||
return b
|
||||
}
|
||||
out := make([]byte, size)
|
||||
copy(out[size-len(b):], b)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// The login-challenge lifecycle: the ONE primitive for a sign-in that has proven
|
||||
// one thing and must prove another before a token exists. The MFA gate mints one
|
||||
// when a password verifies but the second factor is outstanding; the matching
|
||||
// finish takes it.
|
||||
//
|
||||
// v1 keeps this in a beego cookie session; v2 has no key/value session store, so
|
||||
// the state is a server-side row (schema.LoginChallenge) and the client holds only
|
||||
// its opaque id. It is a SIBLING of Token, never a Token with borrowed fields:
|
||||
// /token resolves a grant by Code, so a challenge filed there would sit on the
|
||||
// redemption path wearing a fictional Application.
|
||||
|
||||
// challengeTTL bounds a half-finished ceremony. Five minutes is the authorization
|
||||
// code's own bound — long enough to read a code off a phone, short enough that an
|
||||
// abandoned challenge is not a standing key to an account whose password is
|
||||
// already known.
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// The challenge kinds. Each names the proof still outstanding, and a taker demands
|
||||
// its own kind: a challenge minted for one purpose must never satisfy another.
|
||||
const (
|
||||
KindMfa = "mfa"
|
||||
KindFederation = "federation"
|
||||
)
|
||||
|
||||
// ErrChallenge is the ONE opaque failure for every way a challenge can be refused
|
||||
// — unknown, expired, spent, or the wrong kind. They collapse to one answer so a
|
||||
// prober cannot tell a spent challenge from a forged one.
|
||||
var ErrChallenge = errors.New("the multi-factor session has expired")
|
||||
|
||||
// challengeOwner files every challenge under the reserved admin org. A challenge
|
||||
// is the authorization server's own state, not a tenant record: it is never
|
||||
// listed, never served by an entity route, and its subject is the only tenancy
|
||||
// that matters (and rides inside it, verified).
|
||||
const challengeOwner = "admin"
|
||||
|
||||
// MintChallenge persists a fresh challenge for subject ("owner/name") and returns
|
||||
// its opaque id. payload is the kind's own state — the just-used verification type
|
||||
// for the MFA gate. now is injected for testability.
|
||||
func MintChallenge(ctx context.Context, db orm.DB, kind, subject, payload string, now time.Time) (string, error) {
|
||||
id, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c := orm.New[schema.LoginChallenge](db)
|
||||
c.Owner = challengeOwner
|
||||
c.Name = id
|
||||
c.CreatedTime = now.UTC().Format(time.RFC3339)
|
||||
c.Kind = kind
|
||||
c.Subject = subject
|
||||
c.Payload = payload
|
||||
c.ExpireIn = now.Add(challengeTTL).Unix()
|
||||
c.SetId(challengeOwner + "/" + id)
|
||||
if err := c.CreateCtx(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// TakeChallenge resolves and SPENDS a challenge of the given kind, returning it.
|
||||
// Taking is the only read: a challenge that is found is immediately marked used,
|
||||
// so a replay of the same id loses whether it races or follows. The caller gets
|
||||
// the subject from the returned row and nowhere else — never from a request
|
||||
// parameter, so a body naming another user cannot redirect the ceremony.
|
||||
//
|
||||
// Every refusal is ErrChallenge.
|
||||
func TakeChallenge(ctx context.Context, db orm.DB, id, kind string, now time.Time) (*schema.LoginChallenge, error) {
|
||||
if id == "" {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
c, err := orm.Get[schema.LoginChallenge](db, challengeOwner+"/"+id)
|
||||
if err != nil || c == nil {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
if c.Used || c.Kind != kind || now.Unix() > c.ExpireIn {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
c.Used = true
|
||||
if err := c.UpdateCtx(ctx); err != nil {
|
||||
return nil, ErrChallenge
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// challengeCookie carries the challenge id to the client exactly the way v1 carries
|
||||
// its beego session: a host-only, HttpOnly cookie the browser returns on the
|
||||
// finishing request. Script cannot read it; it is bound to the ceremony's own
|
||||
// short life.
|
||||
const challengeCookie = "hanzo_challenge"
|
||||
|
||||
// SetChallenge writes the challenge id for the finishing request to return.
|
||||
// HttpOnly keeps script out of it; SameSite=Lax lets the portal's own POST carry
|
||||
// it while refusing a cross-site one; the MaxAge matches the row's TTL so the
|
||||
// browser forgets it exactly when the server does.
|
||||
func SetChallenge(c *zip.Ctx, id string) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: challengeCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
MaxAge: int(challengeTTL / time.Second),
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearChallenge expires the cookie once its challenge is spent, so a finished
|
||||
// ceremony leaves nothing behind to replay.
|
||||
func ClearChallenge(c *zip.Ctx) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: challengeCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ReadChallenge returns the challenge id a finishing request presents: the body
|
||||
// field when one is given (an SDK holding no cookie jar), else the cookie the
|
||||
// browser returned. ONE function, ONE precedence — the id is the bearer of the
|
||||
// ceremony either way, and the row it names is single-use, short-lived, and
|
||||
// carries its own subject, so neither source can widen what it proves.
|
||||
func ReadChallenge(c *zip.Ctx, fromBody string) string {
|
||||
if fromBody != "" {
|
||||
return fromBody
|
||||
}
|
||||
return c.Fiber().Cookies(challengeCookie)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Authorization-code lifecycle over the Token entity. A code is a short-lived,
|
||||
// single-use bearer of the right to mint tokens for one (app, user); PKCE binds
|
||||
// it to the client instance that started the flow, and the single-use + expiry
|
||||
// guards close replay.
|
||||
|
||||
// codeTTL bounds how long an authorization code is redeemable (RFC 6749 §4.1.2
|
||||
// recommends ≤ 10 min; we use 5).
|
||||
const codeTTL = 5 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrCodeUnknown — no token row carries this code.
|
||||
ErrCodeUnknown = errors.New("oauth: authorization code not found")
|
||||
// ErrCodeUsed — the code was already redeemed (replay). Per RFC 6749 §4.1.2
|
||||
// a reused code SHOULD also revoke previously-issued tokens; the caller does
|
||||
// that when it detects this error.
|
||||
ErrCodeUsed = errors.New("oauth: authorization code already used")
|
||||
// ErrCodeExpired — the code is past its TTL.
|
||||
ErrCodeExpired = errors.New("oauth: authorization code expired")
|
||||
// ErrClientMismatch — the redeeming client_id is not the one the code was
|
||||
// minted for.
|
||||
ErrClientMismatch = errors.New("oauth: client_id does not match the authorization code")
|
||||
)
|
||||
|
||||
// newOpaqueToken returns a 256-bit URL-safe random token (code / access token).
|
||||
func newOpaqueToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// MintCode builds (does not persist) a Token row representing a fresh
|
||||
// authorization code bound to (app, user), the PKCE challenge, scope, and
|
||||
// resource. The caller persists it via the store. now is injected for
|
||||
// testability.
|
||||
func MintCode(app *schema.Application, userID, scope, challenge, method, resource string, now time.Time) (*schema.Token, error) {
|
||||
code, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If a challenge is present, pin the method to S256 — never store "plain".
|
||||
if challenge != "" && method != "S256" {
|
||||
return nil, ErrPKCEPlainRejected
|
||||
}
|
||||
// The token row is keyed by the application's OWNER (its registry owner, e.g.
|
||||
// "admin"), so (Owner, Application) is the application's natural key and the
|
||||
// token endpoint resolves the app back unambiguously. Organization records the
|
||||
// tenant the grant belongs to.
|
||||
return &schema.Token{
|
||||
Owner: app.Owner,
|
||||
Organization: app.Organization,
|
||||
Application: app.Name,
|
||||
User: userID,
|
||||
Code: code,
|
||||
Scope: scope,
|
||||
TokenType: "Bearer",
|
||||
CodeChallenge: challenge,
|
||||
CodeChallengeMethod: method,
|
||||
CodeIsUsed: false,
|
||||
CodeExpireIn: now.Add(codeTTL).Unix(),
|
||||
Resource: resource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RedeemCode validates an authorization_code exchange against the stored token
|
||||
// row and returns nil iff the code may be used. It is the single guard the
|
||||
// token endpoint calls; on success the caller MUST immediately mark the row used
|
||||
// (MarkUsed) inside the same transaction so a concurrent replay loses.
|
||||
//
|
||||
// Checks, in order (each fail-closed):
|
||||
// 1. row exists (caller passes nil → ErrCodeUnknown)
|
||||
// 2. not already used (replay)
|
||||
// 3. not expired
|
||||
// 4. client_id matches (constant-time)
|
||||
// 5. PKCE: verifier derives the stored challenge (S256; plain refused; a public
|
||||
// client that stored a challenge must present a verifier)
|
||||
func RedeemCode(tok *schema.Token, clientAppName, verifier string, now time.Time) error {
|
||||
if tok == nil {
|
||||
return ErrCodeUnknown
|
||||
}
|
||||
if tok.CodeIsUsed {
|
||||
return ErrCodeUsed
|
||||
}
|
||||
if tok.CodeExpireIn != 0 && now.Unix() > tok.CodeExpireIn {
|
||||
return ErrCodeExpired
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(tok.Application), []byte(clientAppName)) != 1 {
|
||||
return ErrClientMismatch
|
||||
}
|
||||
return VerifyPKCE(verifier, tok.CodeChallenge, tok.CodeChallengeMethod)
|
||||
}
|
||||
|
||||
// IssueAccessToken fills the row with a freshly-minted access token + expiry and
|
||||
// marks the code used — the atomic success step after RedeemCode. now injected
|
||||
// for tests. ttlSeconds is the access-token lifetime.
|
||||
func IssueAccessToken(tok *schema.Token, ttlSeconds int, now time.Time) error {
|
||||
at, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok.AccessToken = at
|
||||
tok.ExpiresIn = ttlSeconds
|
||||
tok.CodeIsUsed = true // one-shot: any subsequent RedeemCode → ErrCodeUsed
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
func testApp() *schema.Application {
|
||||
a := &schema.Application{Organization: "hanzo"}
|
||||
a.Name = "hanzo-console"
|
||||
a.ClientId = "hanzo-console"
|
||||
return a
|
||||
}
|
||||
|
||||
func TestMintCode_BindsPKCEAndExpiry(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-abc-000000000000000000000000000000000"
|
||||
ch := ComputeS256Challenge(verifier)
|
||||
tok, err := MintCode(testApp(), "hanzo/alice", "openid profile", ch, "S256", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Code == "" || len(tok.Code) < 40 {
|
||||
t.Fatalf("code not a 256-bit token: %q", tok.Code)
|
||||
}
|
||||
if tok.CodeIsUsed {
|
||||
t.Fatal("fresh code must not be used")
|
||||
}
|
||||
if tok.CodeExpireIn != now.Add(codeTTL).Unix() {
|
||||
t.Fatalf("expiry = %d, want %d", tok.CodeExpireIn, now.Add(codeTTL).Unix())
|
||||
}
|
||||
if tok.Application != "hanzo-console" || tok.User != "hanzo/alice" {
|
||||
t.Fatalf("binding wrong: app=%q user=%q", tok.Application, tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCode_RefusesPlain(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
if _, err := MintCode(testApp(), "u", "", "some-challenge", "plain", "", now); !errors.Is(err, ErrPKCEPlainRejected) {
|
||||
t.Fatalf("mint with plain: got %v, want ErrPKCEPlainRejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_HappyPath(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-happy-0000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "hanzo/alice", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now.Add(30*time.Second)); err != nil {
|
||||
t.Fatalf("valid redemption rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_ReplayRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-replay-000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
// First redemption + issue marks it used.
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := IssueAccessToken(tok, 3600, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replay must now fail.
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, now); !errors.Is(err, ErrCodeUsed) {
|
||||
t.Fatalf("replay: got %v, want ErrCodeUsed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_ExpiredRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-exp-00000000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
past := now.Add(codeTTL + time.Second)
|
||||
if err := RedeemCode(tok, "hanzo-console", verifier, past); !errors.Is(err, ErrCodeExpired) {
|
||||
t.Fatalf("expired code: got %v, want ErrCodeExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_ClientMismatchRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
verifier := "verifier-cli-00000000000000000000000000000000000"
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge(verifier), "S256", "", now)
|
||||
if err := RedeemCode(tok, "some-other-app", verifier, now); !errors.Is(err, ErrClientMismatch) {
|
||||
t.Fatalf("client mismatch: got %v, want ErrClientMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_WrongVerifierRejected(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("the-right-verifier-0000000000000000000000000"), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", "the-WRONG-verifier-0000000000000000000000000", now); !errors.Is(err, ErrPKCEMismatch) {
|
||||
t.Fatalf("wrong verifier: got %v, want ErrPKCEMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_PublicClientMustPresentVerifier(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
// Code minted WITH a challenge (public client) but token request omits the verifier.
|
||||
tok, _ := MintCode(testApp(), "u", "openid", ComputeS256Challenge("v-000000000000000000000000000000000000000000000"), "S256", "", now)
|
||||
if err := RedeemCode(tok, "hanzo-console", "", now); !errors.Is(err, ErrPKCEMissing) {
|
||||
t.Fatalf("missing verifier: got %v, want ErrPKCEMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemCode_UnknownCode(t *testing.T) {
|
||||
if err := RedeemCode(nil, "hanzo-console", "v", time.Now()); !errors.Is(err, ErrCodeUnknown) {
|
||||
t.Fatalf("nil token: got %v, want ErrCodeUnknown", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAccessToken_MintsAndMarksUsed(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tok, _ := MintCode(testApp(), "u", "openid", "", "", "", now)
|
||||
if err := IssueAccessToken(tok, 3600, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.AccessToken == "" || len(tok.AccessToken) < 40 {
|
||||
t.Fatalf("access token not minted: %q", tok.AccessToken)
|
||||
}
|
||||
if !tok.CodeIsUsed {
|
||||
t.Fatal("code must be marked used after issue")
|
||||
}
|
||||
if tok.ExpiresIn != 3600 {
|
||||
t.Fatalf("expiresIn = %d, want 3600", tok.ExpiresIn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The RFC 8628 device authorization grant: how a machine with no browser and no
|
||||
// keyboard signs in (`hanzo login` on a GPU box, over ssh, in CI). Three legs,
|
||||
// each landing on an EXISTING seam rather than a parallel stack:
|
||||
//
|
||||
// 1. POST /v1/iam/oauth/device — the device asks for a device_code + a short
|
||||
// user_code and shows the human a verification URI.
|
||||
// 2. POST /v1/iam/login {type:"device"} — the human, on any other machine,
|
||||
// proves who they are and approves the user_code (login.go).
|
||||
// 3. POST /v1/iam/oauth/token grant_type=…:device_code — the device polls and
|
||||
// mints through issueTokens, the same path every other grant mints through.
|
||||
//
|
||||
// A device authorization IS a pending authorization code, so it is a Token row
|
||||
// (Code=device_code, UserCode=user_code, User empty until approved) — not a
|
||||
// process-local map, which would die on restart and never work across replicas.
|
||||
//
|
||||
// Client authentication follows RFC 8628 §3.1 (request) and §3.4 (poll), which
|
||||
// both defer to RFC 6749 §3.2.1: a CONFIDENTIAL client (one with a registered
|
||||
// secret) authenticates at both legs exactly as it would at the token endpoint;
|
||||
// a PUBLIC device client (no secret — the usual CLI) is bound by its client_id
|
||||
// alone. The verification_uri page a human opens is public; the JSON legs here
|
||||
// are not a browser surface.
|
||||
|
||||
// The device grant's vocabulary. deviceCodeTTL and devicePollInterval are each
|
||||
// read by the device request, the poll, and Discovery, so the lifetime a client
|
||||
// is told and the lifetime enforced can never drift.
|
||||
const (
|
||||
// deviceGrant is the RFC 8628 grant_type identifier.
|
||||
deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
// deviceCodeTTL bounds a device_code/user_code pair: long enough for a human
|
||||
// to open the link on a phone, sign in, and approve. It is deliberately NOT
|
||||
// codeTTL (5 min) — an authorization code is redeemed by software in seconds,
|
||||
// a device code waits on a person.
|
||||
deviceCodeTTL = 15 * time.Minute
|
||||
// devicePollInterval is the minimum seconds between token-endpoint polls
|
||||
// (RFC 8628 §3.5 `interval`).
|
||||
devicePollInterval = 5
|
||||
)
|
||||
|
||||
// user_code generation. The alphabet is RFC 8628 §6.1 "unambiguous": no I, L, O,
|
||||
// 0 or 1, because a human reads this off one screen and types it into another.
|
||||
// Its 32 symbols make the 5-bit mask below a UNIFORM draw — a modulo over a
|
||||
// non-power-of-two alphabet would bias the code and cost entropy — so 8
|
||||
// characters carry a full 40 bits. The live portal normalizes a typed code to
|
||||
// exactly this alphabet, uppercasing and stripping separators
|
||||
// (id pkgs/auth/src/client.ts normalizeUserCode), so the minted code is the
|
||||
// canonical form: uppercase, no dashes.
|
||||
const (
|
||||
userCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
userCodeLen = 8
|
||||
userCodeTries = 5
|
||||
)
|
||||
|
||||
// errUserCodeExhausted — every generated user_code collided with a live one.
|
||||
// Astronomically unlikely (40 bits against the handful of pending codes); it
|
||||
// fails closed rather than reusing a code.
|
||||
var errUserCodeExhausted = errors.New("device: could not generate a free user_code")
|
||||
|
||||
// deviceResponse is the RFC 8628 §3.2 device authorization response. The field
|
||||
// names are load-bearing: both CLIs decode exactly this shape and hard-fail on
|
||||
// an empty device_code/user_code (cloud/cli/device.go, codex-rs
|
||||
// login/src/oidc_device_auth.rs).
|
||||
type deviceResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationUri string `json:"verification_uri"`
|
||||
VerificationUriComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// routeDevice registers POST /v1/iam/oauth/device on the PUBLIC group r
|
||||
// (registered before the Guard, exactly like the token endpoint): the endpoint
|
||||
// authenticates the CLIENT inline — a confidential client by its secret, a
|
||||
// public device client by its client_id — so it needs no bearer and joins no
|
||||
// allow-list, membership in this group is what makes it reachable.
|
||||
func routeDevice(r zip.Router, db orm.DB) {
|
||||
r.Post(PathDevice, deviceHandler(db))
|
||||
}
|
||||
|
||||
// deviceHandler serves the device authorization request (RFC 8628 §3.1): it
|
||||
// mints the device_code/user_code pair and tells the device where to send its
|
||||
// human. A confidential client must authenticate its secret here (§3.1 → RFC
|
||||
// 6749 §3.2.1); a public device client presents only its client_id. The row it
|
||||
// creates grants nothing until a human approves it.
|
||||
func deviceHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
return tokenError(c, 400, "invalid_client", "client_id is invalid")
|
||||
}
|
||||
// A confidential client (one with a registered secret) MUST authenticate
|
||||
// (RFC 8628 §3.1 → RFC 6749 §3.2.1). A public device client has no secret
|
||||
// and is identified by its client_id alone.
|
||||
if app.ClientSecret != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
if !appGrants(app, deviceGrant) {
|
||||
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
|
||||
}
|
||||
|
||||
deviceCode, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
userCode, err := newUserCode(ctx, db)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
|
||||
// One row IS the pending authorization: Code is the device_code the
|
||||
// machine polls with, UserCode the code its human transcribes, and an
|
||||
// empty User means nobody has approved yet.
|
||||
row := &schema.Token{
|
||||
Owner: app.Owner,
|
||||
Application: app.Name,
|
||||
Organization: app.Organization,
|
||||
Code: deviceCode,
|
||||
UserCode: userCode,
|
||||
Scope: param(c, "scope"),
|
||||
TokenType: "Bearer",
|
||||
CodeExpireIn: nowFunc().Add(deviceCodeTTL).Unix(),
|
||||
}
|
||||
row.Name = "dc-" + deviceCode[:24]
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
|
||||
// Both URIs point at the SPA approval page a human opens, never at this
|
||||
// JSON API. The complete form is a PATH segment because that is the route
|
||||
// the page is mounted on (/login/oauth/device/:userCode).
|
||||
verify := tokenIssuer(c) + PathDeviceVerify
|
||||
return c.JSON(200, deviceResponse{
|
||||
DeviceCode: deviceCode,
|
||||
UserCode: userCode,
|
||||
VerificationUri: verify,
|
||||
VerificationUriComplete: verify + "/" + userCode,
|
||||
ExpiresIn: int(deviceCodeTTL.Seconds()),
|
||||
Interval: devicePollInterval,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deviceCodeGrant is the device's poll (RFC 8628 §3.4), dispatched from the one
|
||||
// token endpoint. It authenticates the client (confidential by secret, public by
|
||||
// client_id) and answers `authorization_pending` until a human approves, then
|
||||
// mints exactly once. The human who authenticated and approved at the
|
||||
// verification URI IS the end-user authentication.
|
||||
func deviceCodeGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
presented := param(c, "device_code")
|
||||
if presented == "" {
|
||||
return tokenError(c, 400, "invalid_request", "device_code is required")
|
||||
}
|
||||
row, err := store.GetTokenByCode(ctx, db, presented)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
// Unknown, not a device authorization, or already redeemed. isDevice is what
|
||||
// stops an authorization code being redeemed HERE, where neither its PKCE
|
||||
// challenge nor its redirect_uri is verified.
|
||||
if row == nil || !isDevice(row) || row.CodeIsUsed {
|
||||
return deviceDead(c)
|
||||
}
|
||||
// Expired — reap it on the way past, so a dead authorization does not linger.
|
||||
if expired(row.CodeExpireIn, now) {
|
||||
_ = store.DeleteToken(ctx, db, row)
|
||||
return deviceDead(c)
|
||||
}
|
||||
|
||||
app, err := resolveTokenApp(ctx, db, row)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
return tokenError(c, 400, "invalid_grant", "the device code is invalid")
|
||||
}
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if deviceClientMismatch(app, clientID) {
|
||||
return tokenError(c, 400, "invalid_grant", "the device_code was not issued to this client")
|
||||
}
|
||||
// A confidential client authenticates on EVERY poll (RFC 8628 §3.4 → RFC 6749
|
||||
// §3.2.1), checked before the pending/mint split so an unauthenticated
|
||||
// confidential poll never even learns the grant's approval state. A public
|
||||
// device client has no secret and is bound by its client_id alone (above).
|
||||
if app.ClientSecret != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
// Re-gated at redemption, not only at the request: an application whose device
|
||||
// grant was withdrawn between the two must not still mint.
|
||||
if !appGrants(app, deviceGrant) {
|
||||
return tokenError(c, 400, "unsupported_grant_type", "the application does not permit the device grant")
|
||||
}
|
||||
// Not approved yet: leave the row exactly as it is — the device keeps polling.
|
||||
if row.User == "" {
|
||||
return tokenError(c, 400, "authorization_pending", "the device authorization is pending approval")
|
||||
}
|
||||
|
||||
// One-shot: burn the approval BEFORE minting, so any later poll finds the row
|
||||
// already redeemed rather than minting a second token off one approval. Like
|
||||
// the authorization-code grant beside it this is a read-modify-write, not a
|
||||
// compare-and-swap: two polls landing inside the same write window could still
|
||||
// both mint. They mint the same user, app, scope and refresh family, so the
|
||||
// duplicate is contained (revoking the family revokes both) — a real CAS is a
|
||||
// property the Token row would have to carry for every grant, not just this one.
|
||||
row.CodeIsUsed = true
|
||||
if err := store.SaveToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
resp, err := issueTokens(ctx, db, c, app, row, newFamilyID(row), now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.SaveToken(ctx, db, row); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// approveDevice binds an authenticated human's identity onto a pending device
|
||||
// authorization — the act that lets the device's next poll mint. The row's
|
||||
// application and scope stay authoritative for that mint: the portal app the
|
||||
// browser happens to be on is irrelevant to WHAT is being approved, so it is
|
||||
// never read here. Called from the login handler once the credential check has
|
||||
// already proven who the approver is.
|
||||
func approveDevice(c *zip.Ctx, db orm.DB, user *schema.User, userCode string) error {
|
||||
// ONE opaque refusal for unknown / not-a-device / expired / already-approved /
|
||||
// already-redeemed. The user_code is only 40 bits — the one secret in this
|
||||
// flow — so an answer that distinguished those cases would turn this page into
|
||||
// an oracle for hunting live codes.
|
||||
const refuse = "the user code is invalid or expired"
|
||||
|
||||
ctx := c.Context()
|
||||
row, err := store.GetTokenByUserCode(ctx, db, userCode)
|
||||
if err != nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
if row == nil || !isDevice(row) || row.CodeIsUsed || row.User != "" ||
|
||||
expired(row.CodeExpireIn, nowFunc()) {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
// Tenant boundary: a user in org A must not approve a device sign-in bound to
|
||||
// an app in org B (a confused deputy — brands seed same-named superusers). The
|
||||
// org compared is the DEVICE row's, captured when the code was issued. A
|
||||
// SuperAdmin — a member of the reserved admin org, the one predicate — crosses
|
||||
// tenants deliberately: that is the identity an operator signs a CLI into any
|
||||
// brand's app with. An unresolvable tenant fails closed.
|
||||
if row.Organization == "" {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
if !store.IsSuperAdmin(user.Owner) && user.Owner != row.Organization {
|
||||
return httpx.Err(c, "your organization may not approve this device sign-in")
|
||||
}
|
||||
|
||||
row.User = user.Owner + "/" + user.Name
|
||||
if err := store.SaveToken(ctx, db, row); err != nil {
|
||||
return httpx.Err(c, refuse)
|
||||
}
|
||||
return httpx.Ok(c, row.User)
|
||||
}
|
||||
|
||||
// deviceDead is the one answer for a device_code that cannot be redeemed —
|
||||
// unknown, not a device authorization, already redeemed, or expired. To the
|
||||
// client those are the same fact (this code is dead, start over), so they get
|
||||
// the same words: sharing one answer makes that structural rather than a
|
||||
// coincidence of copied strings.
|
||||
func deviceDead(c *zip.Ctx) error {
|
||||
return tokenError(c, 400, "expired_token", "the device code is expired or already redeemed")
|
||||
}
|
||||
|
||||
// isDevice reports whether a code row is an RFC 8628 device authorization rather
|
||||
// than an authorization code. Both kinds live in Token.Code, so every grant
|
||||
// checks the kind before redeeming: an authorization code must never be redeemed
|
||||
// at the device grant, which verifies neither PKCE nor redirect_uri, and a
|
||||
// device code must never be redeemed at the authorization-code grant, which
|
||||
// would mint on a row no human has approved. The user_code IS the
|
||||
// discriminator — only a device authorization has one.
|
||||
func isDevice(tok *schema.Token) bool { return tok != nil && tok.UserCode != "" }
|
||||
|
||||
// deviceClientMismatch reports whether clientID is NOT the client the device
|
||||
// authorization was issued to (RFC 8628 §3.4). Without this an approval for app
|
||||
// A is redeemable as app B: a confused deputy that hands the caller a token for
|
||||
// the wrong audience. Pure, so the binding is unit-testable.
|
||||
func deviceClientMismatch(app *schema.Application, clientID string) bool {
|
||||
return app == nil ||
|
||||
subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1
|
||||
}
|
||||
|
||||
// appGrants reports whether app permits grant — the per-application grant gate
|
||||
// (v1 IsGrantTypeValid, object/token_oauth.go:605). A grant must be DECLARED on
|
||||
// the application to be usable, so an app that never enabled the device grant can
|
||||
// never mint a device token. Fail-closed by construction: every live application
|
||||
// declares its grant set, so an app with none permits none.
|
||||
func appGrants(app *schema.Application, grant string) bool {
|
||||
if app == nil {
|
||||
return false
|
||||
}
|
||||
for _, g := range app.GrantTypes {
|
||||
if g == grant {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expired reports whether a unix deadline has passed. A zero deadline never
|
||||
// expires (the v1 convention for "unset").
|
||||
func expired(deadline int64, now time.Time) bool {
|
||||
return deadline != 0 && now.Unix() > deadline
|
||||
}
|
||||
|
||||
// newUserCode mints a user_code that no live row already carries. Each attempt
|
||||
// REGENERATES the candidate — a loop that re-tests one fixed code could never
|
||||
// clear a collision.
|
||||
func newUserCode(ctx context.Context, db orm.DB) (string, error) {
|
||||
for range userCodeTries {
|
||||
code, err := randomUserCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
row, err := store.GetTokenByUserCode(ctx, db, code)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row == nil {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", errUserCodeExhausted
|
||||
}
|
||||
|
||||
// randomUserCode draws userCodeLen symbols uniformly from userCodeAlphabet.
|
||||
func randomUserCode() (string, error) {
|
||||
buf := make([]byte, userCodeLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = userCodeAlphabet[buf[i]&0x1f]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The RFC 8628 device grant, driven through the real router exactly as the two
|
||||
// live CLIs drive it: client_id/scope as QUERY params on the device request
|
||||
// (cloud/cli/device.go, codex-rs oidc_device_auth.rs), then a form-encoded poll
|
||||
// at the one token endpoint.
|
||||
|
||||
// deviceGrants is the grant set a device-capable app declares — what hanzo-app
|
||||
// carries in the live seed.
|
||||
var deviceGrants = []string{"authorization_code", "refresh_token", deviceGrant}
|
||||
|
||||
// seedDeviceApp seeds a public, device-capable app plus a user in its org.
|
||||
func seedDeviceApp(t *testing.T, db orm.DB, clientID string) {
|
||||
t.Helper()
|
||||
seedApp(t, db, appOpts{clientID: clientID, grants: deviceGrants})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
}
|
||||
|
||||
// requestDevice drives POST /v1/iam/oauth/device the way a PUBLIC device client
|
||||
// does: client_id/scope only, no secret.
|
||||
func requestDevice(t *testing.T, app *zip.App, clientID, scope string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return requestDeviceSecret(t, app, clientID, "", scope)
|
||||
}
|
||||
|
||||
// requestDeviceSecret drives the device request with an optional client_secret —
|
||||
// the confidential-client leg (RFC 8628 §3.1). An empty secret is the public
|
||||
// case.
|
||||
func requestDeviceSecret(t *testing.T, app *zip.App, clientID, secret, scope string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
q := url.Values{"client_id": {clientID}, "scope": {scope}, "response_type": {"device_code"}}
|
||||
if secret != "" {
|
||||
q.Set("client_secret", secret)
|
||||
}
|
||||
resp, body := do(t, app, formReqNoBody("POST", PathDevice+"?"+q.Encode()))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
// pollDevice drives one device poll at the token endpoint (public client).
|
||||
func pollDevice(t *testing.T, app *zip.App, clientID, deviceCode string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return pollDeviceSecret(t, app, clientID, "", deviceCode)
|
||||
}
|
||||
|
||||
// pollDeviceSecret drives one device poll with an optional client_secret — the
|
||||
// confidential-client leg (RFC 8628 §3.4).
|
||||
func pollDeviceSecret(t *testing.T, app *zip.App, clientID, secret, deviceCode string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"grant_type": {deviceGrant},
|
||||
"client_id": {clientID},
|
||||
"device_code": {deviceCode},
|
||||
}
|
||||
if secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
|
||||
// approveAs drives the human approval leg: POST /v1/iam/login {type:"device"}.
|
||||
func approveAs(t *testing.T, app *zip.App, org, user, userCode string) map[string]any {
|
||||
t.Helper()
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": org, "username": user, "password": "pw",
|
||||
"type": "device", "userCode": userCode,
|
||||
}))
|
||||
return decode(t, body)
|
||||
}
|
||||
|
||||
// The device response carries exactly the keys both CLIs decode, with the TTL
|
||||
// and poll interval the server actually enforces. cloud/cli/device.go hard-fails
|
||||
// on an empty device_code/user_code, so an error envelope here is a dead CLI.
|
||||
func TestDevice_RequestShape(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
|
||||
resp, m := requestDevice(t, app, "hanzo-app", "openid profile")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d: %v", resp.StatusCode, m)
|
||||
}
|
||||
deviceCode, _ := m["device_code"].(string)
|
||||
userCode, _ := m["user_code"].(string)
|
||||
if deviceCode == "" || userCode == "" {
|
||||
t.Fatalf("device_code/user_code must be non-empty: %v", m)
|
||||
}
|
||||
if m["expires_in"] != float64(900) {
|
||||
t.Errorf("expires_in = %v, want 900", m["expires_in"])
|
||||
}
|
||||
if m["interval"] != float64(5) {
|
||||
t.Errorf("interval = %v, want 5", m["interval"])
|
||||
}
|
||||
// verification_uri_complete must be the PATH form: the SPA route is
|
||||
// /login/oauth/device/:userCode.
|
||||
verify, _ := m["verification_uri"].(string)
|
||||
if verify != "https://hanzo.id"+PathDeviceVerify {
|
||||
t.Errorf("verification_uri = %q", verify)
|
||||
}
|
||||
if got, want := m["verification_uri_complete"], verify+"/"+userCode; got != want {
|
||||
t.Errorf("verification_uri_complete = %v, want %v", got, want)
|
||||
}
|
||||
if resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
|
||||
// The user_code must be transcribable AND survive the portal's
|
||||
// normalization (uppercase, separators stripped) unchanged — a code the
|
||||
// portal rewrites is a code the lookup can never find.
|
||||
if len(userCode) != userCodeLen {
|
||||
t.Errorf("user_code %q: length %d, want %d", userCode, len(userCode), userCodeLen)
|
||||
}
|
||||
if got := strings.ToUpper(strings.ReplaceAll(userCode, "-", "")); got != userCode {
|
||||
t.Errorf("user_code %q is not already normalized (portal would send %q)", userCode, got)
|
||||
}
|
||||
for _, r := range userCode {
|
||||
if !strings.ContainsRune(userCodeAlphabet, r) {
|
||||
t.Errorf("user_code %q contains ambiguous symbol %q", userCode, r)
|
||||
}
|
||||
}
|
||||
|
||||
// The pending grant is a persisted row, not process-local state.
|
||||
row, err := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("device authorization was not persisted: %v", err)
|
||||
}
|
||||
if row.User != "" {
|
||||
t.Errorf("a fresh device authorization must be unapproved, got user %q", row.User)
|
||||
}
|
||||
if row.UserCode != userCode {
|
||||
t.Errorf("row.UserCode = %q, want %q", row.UserCode, userCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery advertises the device endpoint and grant so a discovery-driven
|
||||
// client can find them.
|
||||
func TestDevice_Discovery(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
_, body := do(t, app, formReqNoBody("GET", PathDiscovery))
|
||||
d := decode(t, body)
|
||||
if d["device_authorization_endpoint"] != "https://hanzo.id"+PathDevice {
|
||||
t.Errorf("device_authorization_endpoint = %v, want %v", d["device_authorization_endpoint"], "https://hanzo.id"+PathDevice)
|
||||
}
|
||||
gts, _ := d["grant_types_supported"].([]any)
|
||||
found := false
|
||||
for _, g := range gts {
|
||||
if g == deviceGrant {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("grant_types_supported missing %q: %v", deviceGrant, gts)
|
||||
}
|
||||
}
|
||||
|
||||
// Before approval the poll answers authorization_pending and LEAVES the row —
|
||||
// the CLI polls on this answer, so consuming the row would end the login.
|
||||
func TestDevice_PollPendingIsRepeatable(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode := da["device_code"].(string)
|
||||
|
||||
for i := range 3 {
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("poll %d: status %d, want 400", i, resp.StatusCode)
|
||||
}
|
||||
if m["error"] != "authorization_pending" {
|
||||
t.Fatalf("poll %d: error = %v, want authorization_pending", i, m["error"])
|
||||
}
|
||||
// A 401 would send the CLI down its terminal error path.
|
||||
if resp.Header.Get("WWW-Authenticate") != "" {
|
||||
t.Fatalf("poll %d: a pending poll must not carry a WWW-Authenticate challenge", i)
|
||||
}
|
||||
}
|
||||
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row == nil {
|
||||
t.Fatal("a pending poll must not consume the device authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point, end to end: approve once, mint once. The SECOND poll of an
|
||||
// approved code must fail — one approval is one token.
|
||||
func TestDevice_ApproveThenPollMintsExactlyOnce(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid profile")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
|
||||
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", m)
|
||||
}
|
||||
// The approval binds the approver onto the row — identity comes from there,
|
||||
// never from the polling device.
|
||||
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
if row == nil || row.User != "hanzo/alice" {
|
||||
t.Fatalf("approval must bind the approver onto the row, got %+v", row)
|
||||
}
|
||||
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("approved poll: status %d: %v", resp.StatusCode, m)
|
||||
}
|
||||
access, _ := m["access_token"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("approved poll must mint an access_token: %v", m)
|
||||
}
|
||||
if id, _ := m["id_token"].(string); id == "" {
|
||||
t.Error("the openid scope must mint an id_token")
|
||||
}
|
||||
if rt, _ := m["refresh_token"].(string); rt == "" {
|
||||
t.Error("the device grant must mint a refresh token")
|
||||
}
|
||||
// The minted token describes the approver, and is usable.
|
||||
claims, err := verifyToken(tctx(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("minted access token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" {
|
||||
t.Errorf("sub = %q, want hanzo/alice", claims.Subject)
|
||||
}
|
||||
|
||||
// One approval, one token: a replayed poll gets nothing.
|
||||
resp2, m2 := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp2.StatusCode != 400 || m2["error"] != "expired_token" {
|
||||
t.Fatalf("second poll: %d %v, want 400 expired_token", resp2.StatusCode, m2)
|
||||
}
|
||||
if _, ok := m2["access_token"]; ok {
|
||||
t.Fatal("a redeemed device code must never mint twice")
|
||||
}
|
||||
}
|
||||
|
||||
// A CONFIDENTIAL device client authenticates at BOTH legs (RFC 8628 §3.1 request,
|
||||
// §3.4 poll). Without its secret the request is refused and the poll — even of an
|
||||
// approved code — mints nothing; with the secret both succeed.
|
||||
func TestDevice_ConfidentialClientAuth(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf-cli", secret: "s3cret", grants: deviceGrants})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
// §3.1: a confidential client's device request without its secret is refused.
|
||||
resp, m := requestDevice(t, app, "conf-cli", "openid")
|
||||
if resp.StatusCode != 401 || m["error"] != "invalid_client" {
|
||||
t.Fatalf("unauthenticated device request: %d %v, want 401 invalid_client", resp.StatusCode, m)
|
||||
}
|
||||
if m["device_code"] != nil {
|
||||
t.Fatal("a refused device request must not mint a device_code")
|
||||
}
|
||||
|
||||
// With the secret it succeeds.
|
||||
resp, m = requestDeviceSecret(t, app, "conf-cli", "s3cret", "openid")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("authenticated device request: %d %v", resp.StatusCode, m)
|
||||
}
|
||||
deviceCode, userCode := m["device_code"].(string), m["user_code"].(string)
|
||||
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", am)
|
||||
}
|
||||
|
||||
// §3.4: the poll without the secret is refused — even though the code is
|
||||
// approved — and mints nothing.
|
||||
presp, pm := pollDevice(t, app, "conf-cli", deviceCode)
|
||||
if presp.StatusCode != 401 || pm["error"] != "invalid_client" {
|
||||
t.Fatalf("unauthenticated poll: %d %v, want 401 invalid_client", presp.StatusCode, pm)
|
||||
}
|
||||
if _, ok := pm["access_token"]; ok {
|
||||
t.Fatal("an unauthenticated confidential poll must never mint")
|
||||
}
|
||||
|
||||
// With the secret the poll mints.
|
||||
presp, pm = pollDeviceSecret(t, app, "conf-cli", "s3cret", deviceCode)
|
||||
if presp.StatusCode != 200 || pm["access_token"] == nil {
|
||||
t.Fatalf("authenticated poll must mint: %d %v", presp.StatusCode, pm)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant boundary: a user in org B must not approve a device sign-in bound to an
|
||||
// app in org A. A SuperAdmin — a member of the reserved admin org — may, because
|
||||
// that is the identity an operator signs a CLI into any brand with.
|
||||
func TestDevice_ApprovalTenantBoundary(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
org string // approver's org; the device app lives in "hanzo"
|
||||
allow bool
|
||||
}{
|
||||
{"same org approves", "hanzo", true},
|
||||
{"foreign org refused", "lux", false},
|
||||
{"superadmin crosses tenants", "admin", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants}) // org "hanzo"
|
||||
seedUserInOrg(t, db, tc.org, "eve", "eve@"+tc.org+".example", "pw")
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
|
||||
m := approveAs(t, app, tc.org, "eve", userCode)
|
||||
row, _ := store.GetTokenByCode(tctx(), db, deviceCode)
|
||||
|
||||
if !tc.allow {
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("cross-tenant approval must be refused, got %v", m)
|
||||
}
|
||||
// The store is the proof: refused means NOT approved.
|
||||
if row.User != "" {
|
||||
t.Fatalf("refused approval must not bind a user, got %q", row.User)
|
||||
}
|
||||
// And the device must still not be able to mint.
|
||||
if _, p := pollDevice(t, app, "hanzo-app", deviceCode); p["error"] != "authorization_pending" {
|
||||
t.Fatalf("a refused approval must leave the device pending, got %v", p)
|
||||
}
|
||||
return
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("approval must succeed, got %v", m)
|
||||
}
|
||||
if row.User != tc.org+"/eve" {
|
||||
t.Fatalf("row.User = %q, want %q", row.User, tc.org+"/eve")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8628 §3.4: a device_code is redeemable only by the client it was issued
|
||||
// to. Otherwise an approval for app A is redeemable as app B — a token for the
|
||||
// wrong audience.
|
||||
func TestDevice_ClientBinding(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
seedApp(t, db, appOpts{clientID: "other-app", grants: deviceGrants})
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", m)
|
||||
}
|
||||
|
||||
resp, m := pollDevice(t, app, "other-app", deviceCode)
|
||||
if resp.StatusCode != 400 || m["error"] != "invalid_grant" {
|
||||
t.Fatalf("foreign client redemption: %d %v, want 400 invalid_grant", resp.StatusCode, m)
|
||||
}
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("a device_code must never be redeemable by another client")
|
||||
}
|
||||
// The rightful client can still redeem — the binding refused, it did not burn.
|
||||
if _, own := pollDevice(t, app, "hanzo-app", deviceCode); own["access_token"] == nil {
|
||||
t.Fatalf("the issuing client must still redeem its own code: %v", own)
|
||||
}
|
||||
}
|
||||
|
||||
// An expired device_code is dead even once approved.
|
||||
func TestDevice_Expiry(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
start := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, start)
|
||||
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
if m := approveAs(t, app, "hanzo", "alice", userCode); m["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", m)
|
||||
}
|
||||
|
||||
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
|
||||
resp, m := pollDevice(t, app, "hanzo-app", deviceCode)
|
||||
if resp.StatusCode != 400 || m["error"] != "expired_token" {
|
||||
t.Fatalf("expired poll: %d %v, want 400 expired_token", resp.StatusCode, m)
|
||||
}
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("an expired device code must never mint")
|
||||
}
|
||||
if row, _ := store.GetTokenByCode(tctx(), db, deviceCode); row != nil {
|
||||
t.Error("an expired device authorization should be reaped on the poll that finds it")
|
||||
}
|
||||
}
|
||||
|
||||
// The per-application grant gate: an app that never declared the device grant
|
||||
// can neither start a device flow nor redeem one. Gated at BOTH ends, so a row
|
||||
// created while the grant was enabled cannot mint after it is withdrawn.
|
||||
func TestDevice_GrantGate(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// A real app, fully functional — it simply never declared the device grant.
|
||||
seedApp(t, db, appOpts{clientID: "web-only", grants: []string{"authorization_code", "refresh_token"}})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, m := requestDevice(t, app, "web-only", "openid")
|
||||
if resp.StatusCode != 400 || m["error"] != "unsupported_grant_type" {
|
||||
t.Fatalf("request: %d %v, want 400 unsupported_grant_type", resp.StatusCode, m)
|
||||
}
|
||||
if m["device_code"] != nil {
|
||||
t.Fatal("a refused device request must not mint a device_code")
|
||||
}
|
||||
|
||||
// And at the poll: forge a device row for the app, as if the grant had been
|
||||
// enabled and then withdrawn, and prove the redemption is still refused.
|
||||
seedApp(t, db, appOpts{clientID: "was-enabled", grants: deviceGrants})
|
||||
_, da := requestDevice(t, app, "was-enabled", "openid")
|
||||
deviceCode, userCode := da["device_code"].(string), da["user_code"].(string)
|
||||
if am := approveAs(t, app, "hanzo", "alice", userCode); am["status"] != "ok" {
|
||||
t.Fatalf("approval failed: %v", am)
|
||||
}
|
||||
withdrawGrants(t, db, "was-enabled")
|
||||
|
||||
resp2, m2 := pollDevice(t, app, "was-enabled", deviceCode)
|
||||
if resp2.StatusCode != 400 || m2["error"] != "unsupported_grant_type" {
|
||||
t.Fatalf("poll after withdrawal: %d %v, want 400 unsupported_grant_type", resp2.StatusCode, m2)
|
||||
}
|
||||
if _, ok := m2["access_token"]; ok {
|
||||
t.Fatal("an app without the device grant must never mint a device token")
|
||||
}
|
||||
}
|
||||
|
||||
// The user_code is the only secret in the approval flow (40 bits), so unknown,
|
||||
// expired, and already-approved codes must be indistinguishable — otherwise the
|
||||
// approval page is an oracle for hunting live codes.
|
||||
func TestDevice_UserCodeRefusalIsNonDifferential(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedDeviceApp(t, db, "hanzo-app")
|
||||
start := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, start)
|
||||
|
||||
// (a) unknown
|
||||
unknown := approveAs(t, app, "hanzo", "alice", "ZZZZZZZZ")
|
||||
|
||||
// (b) already approved
|
||||
_, da := requestDevice(t, app, "hanzo-app", "openid")
|
||||
if m := approveAs(t, app, "hanzo", "alice", da["user_code"].(string)); m["status"] != "ok" {
|
||||
t.Fatalf("first approval must succeed: %v", m)
|
||||
}
|
||||
reapproved := approveAs(t, app, "hanzo", "alice", da["user_code"].(string))
|
||||
|
||||
// (c) expired
|
||||
_, da2 := requestDevice(t, app, "hanzo-app", "openid")
|
||||
nowFuncSet(t, start.Add(deviceCodeTTL+time.Second))
|
||||
expiredCode := approveAs(t, app, "hanzo", "alice", da2["user_code"].(string))
|
||||
|
||||
for _, m := range []map[string]any{unknown, reapproved, expiredCode} {
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("must be refused: %v", m)
|
||||
}
|
||||
}
|
||||
if unknown["msg"] != reapproved["msg"] || unknown["msg"] != expiredCode["msg"] {
|
||||
t.Fatalf("refusals differ — an oracle: unknown=%q reapproved=%q expired=%q",
|
||||
unknown["msg"], reapproved["msg"], expiredCode["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
// An AUTHORIZATION code must never be redeemable at the device grant. The device
|
||||
// grant verifies neither PKCE nor redirect_uri, so accepting one there would
|
||||
// defeat both for any app that permits the device grant — a stolen code would
|
||||
// mint tokens with no verifier.
|
||||
func TestDevice_AuthorizationCodeIsNotRedeemableAsDeviceCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", grants: deviceGrants, redirectURIs: []string{testRedirect}})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
verifier := "device-xchg-verifier-00000000000000000000000000000"
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"clientId": "hanzo-app", "redirectUri": testRedirect, "scope": "openid",
|
||||
"codeChallenge": ComputeS256Challenge(verifier), "codeChallengeMethod": "S256",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("setup: no authorization code minted")
|
||||
}
|
||||
|
||||
resp, m := pollDevice(t, app, "hanzo-app", code)
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("PKCE BYPASS: an authorization code was redeemed at the device grant")
|
||||
}
|
||||
if resp.StatusCode != 400 || m["error"] != "expired_token" {
|
||||
t.Fatalf("got %d %v, want 400 expired_token", resp.StatusCode, m)
|
||||
}
|
||||
// The real exchange still works — the guard refused, it did not burn the code.
|
||||
if _, tm := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {"hanzo-app"},
|
||||
"code_verifier": {verifier}, "redirect_uri": {testRedirect},
|
||||
}); tm["access_token"] == nil {
|
||||
t.Fatalf("the legitimate code exchange must still succeed: %v", tm)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror image: a DEVICE code must never be redeemable at the
|
||||
// authorization-code grant, which would mint on a row no human has approved.
|
||||
func TestDevice_DeviceCodeIsNotRedeemableAsAuthorizationCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// A CONFIDENTIAL app: its secret would otherwise satisfy the code grant's
|
||||
// client check, and an unapproved device row carries no PKCE challenge to
|
||||
// stop it. The device request authenticates that same secret (§3.1).
|
||||
seedApp(t, db, appOpts{clientID: "conf-app", secret: "s3cret", grants: deviceGrants})
|
||||
seedUserInOrg(t, db, "hanzo", "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
_, da := requestDeviceSecret(t, app, "conf-app", "s3cret", "openid")
|
||||
deviceCode := da["device_code"].(string)
|
||||
|
||||
_, m := exchangeCode(t, app, url.Values{
|
||||
"code": {deviceCode}, "client_id": {"conf-app"}, "client_secret": {"s3cret"},
|
||||
})
|
||||
if _, ok := m["access_token"]; ok {
|
||||
t.Fatal("APPROVAL BYPASS: an unapproved device code minted at the authorization-code grant")
|
||||
}
|
||||
if m["error"] != "invalid_grant" {
|
||||
t.Fatalf("error = %v, want invalid_grant", m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown client_id is refused — and mints nothing.
|
||||
func TestDevice_UnknownClient(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
resp, m := requestDevice(t, app, "no-such-client", "openid")
|
||||
if resp.StatusCode != 400 || m["error"] != "invalid_client" {
|
||||
t.Fatalf("got %d %v, want 400 invalid_client", resp.StatusCode, m)
|
||||
}
|
||||
if m["device_code"] != nil {
|
||||
t.Fatal("an unknown client must not mint a device_code")
|
||||
}
|
||||
}
|
||||
|
||||
// appGrants is the pure gate both ends call.
|
||||
func TestAppGrants(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
declare []string
|
||||
want bool
|
||||
}{
|
||||
{"declared", deviceGrants, true},
|
||||
{"not declared", []string{"authorization_code", "refresh_token"}, false},
|
||||
{"none declared", nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := appGrants(&schema.Application{GrantTypes: tc.declare}, deviceGrant); got != tc.want {
|
||||
t.Fatalf("appGrants(%v) = %v, want %v", tc.declare, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if appGrants(nil, deviceGrant) {
|
||||
t.Fatal("a nil application must permit nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// user_codes are drawn fresh each time — a generator that reuses one value could
|
||||
// never clear a collision.
|
||||
func TestRandomUserCode_Distinct(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 64 {
|
||||
code, err := randomUserCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("user_code %q repeated — the draw is not random", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawGrants strips an application's declared grants in place.
|
||||
func withdrawGrants(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
a, err := orm.Get[schema.Application](db, "admin/"+name)
|
||||
if err != nil {
|
||||
t.Fatalf("load app %s: %v", name, err)
|
||||
}
|
||||
a.GrantTypes = nil
|
||||
if err := a.UpdateCtx(tctx()); err != nil {
|
||||
t.Fatalf("withdraw grants: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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 NEVER follows X-Forwarded-Host: it is resolved from the TRUSTED
|
||||
// request host (zip.Ctx.Host(), which ignores X-Forwarded-Host) through the pinned
|
||||
// issuer resolver, so a client-supplied X-Forwarded-Host cannot steer `iss`. Here
|
||||
// the trusted host is hanzo.id (formReqNoBody) and no issuer map is configured, so
|
||||
// the spoofed header is discarded and the issuer stays host-relative to hanzo.id.
|
||||
func TestDiscovery_IssuerIgnoresForwardedHost(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://hanzo.id" {
|
||||
t.Fatalf("issuer = %v, want https://hanzo.id (X-Forwarded-Host must not steer iss)", 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,635 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// Identity federation — iam2 as an OIDC/OAuth2 Relying Party to external IdPs.
|
||||
//
|
||||
// A social sign-in is a DETOUR inside the ordinary authorization-code flow. The
|
||||
// authorize endpoint, having already validated the client and its EXACT
|
||||
// redirect_uri (so there is a trusted target before anything is trusted), hands
|
||||
// a request that names a `provider` to beginFederation, which stashes the whole
|
||||
// app-leg request server-side and sends the browser to the IdP. When the IdP
|
||||
// returns to the fixed callback, iam2 verifies the response, LINKS or PROVISIONS
|
||||
// a local user, and mints ITS OWN authorization code — bound to the original
|
||||
// PKCE challenge, redirect_uri, and nonce — exactly as a password login would.
|
||||
// The relying party's existing PKCE code→token exchange then completes unchanged.
|
||||
//
|
||||
// The whole surface lives on the PUBLIC group (before the Guard): it
|
||||
// self-authenticates through the single-use, browser-bound, expiring state, not
|
||||
// a bearer. Every failure is fail-closed; no IdP token or secret is ever logged.
|
||||
|
||||
// PathFederationCallback is the fixed IdP return endpoint. One callback for every
|
||||
// provider — the provider is recovered from the server-side transaction the
|
||||
// state keys, never from a spoofable URL segment. It is the redirect_uri iam2
|
||||
// registers with each external IdP.
|
||||
const PathFederationCallback = "/v1/iam/oauth/callback"
|
||||
|
||||
// PathMfaVerify is the hosted 2FA PAGE (a route in the SPA, not an API path) the
|
||||
// federation callback sends a second-factor-enrolled user's browser to. The page
|
||||
// collects the factor and POSTs it to PathFederationMfa; the challenge id rides
|
||||
// the httpOnly cookie the callback set, never a URL segment.
|
||||
const PathMfaVerify = "/login/mfa"
|
||||
|
||||
// fedCookieName is the per-transaction anti-forgery cookie the begin leg sets and
|
||||
// the callback checks — the browser binding that defeats login-CSRF.
|
||||
const fedCookieName = "hanzo_fed"
|
||||
|
||||
// fedStateTTL bounds how long a federation transaction (and its cookie) is
|
||||
// redeemable. Short, because it only has to survive one IdP round-trip.
|
||||
const fedStateTTL = 10 * time.Minute
|
||||
|
||||
// routeFederation registers the IdP callback on the PUBLIC group r. GET only: the
|
||||
// IdP returns via a top-level browser redirect (Google/GitHub), on which the
|
||||
// SameSite=Lax browser-binding cookie IS sent. A cross-site form_post (POST) would
|
||||
// NOT carry a Lax cookie, so the bind check would fail closed — rather than ship a
|
||||
// half-working POST path, form_post support is a deliberate future change (it needs
|
||||
// SameSite=None + its own CSRF analysis). The callback self-authenticates via the
|
||||
// single-use state + the browser cookie.
|
||||
func routeFederation(r zip.Router, db orm.DB) {
|
||||
r.Get(PathFederationCallback, federationCallbackHandler(db))
|
||||
}
|
||||
|
||||
// beginFederation starts an Authorization-Code federation. It is entered from
|
||||
// authorizeHandler ONLY after the client_id and exact redirect_uri are validated
|
||||
// and the response_type/PKCE policy is enforced, so a protocol error may now be
|
||||
// redirected to the trusted redirect_uri (RFC 6749 §4.1.2.1). It resolves the
|
||||
// named provider, mints a single-use transaction, sets the browser-binding
|
||||
// cookie, and sends the browser to the IdP.
|
||||
func beginFederation(c *zip.Ctx, db orm.DB, app *schema.Application, q authorizeRequest, method string) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// A federated (external) identity may never be minted into a reserved system
|
||||
// org (the SuperAdmin vector) nor into a tenant an attacker-owned app has no
|
||||
// right to serve. Refuse BEFORE starting the round-trip (fail fast, no IdP
|
||||
// traffic) — defense in depth behind the application-write org authorization.
|
||||
if !federationOrgAllowed(app) {
|
||||
return authorizeErrorRedirect(c, q, "access_denied", "federation is not permitted for this application")
|
||||
}
|
||||
|
||||
store.EnrichProviders(ctx, db, app)
|
||||
prov := federationProvider(app, q.provider)
|
||||
if prov == nil {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "unknown or unavailable provider")
|
||||
}
|
||||
if idpKind(prov) == "" {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "provider is not a supported federation type")
|
||||
}
|
||||
if _, ok := connectorFor(prov.Type); !ok {
|
||||
return authorizeErrorRedirect(c, q, "invalid_request", "provider has no local identity binding")
|
||||
}
|
||||
|
||||
state, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
verifier, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
nonce, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
bindSecret, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
|
||||
now := nowFunc()
|
||||
st := &schema.FederationState{
|
||||
Owner: providerOwner(prov),
|
||||
Name: state,
|
||||
CreatedTime: now.UTC().Format(time.RFC3339),
|
||||
Provider: prov.Name,
|
||||
ClientId: q.clientID,
|
||||
RedirectUri: q.redirectURI,
|
||||
AppState: q.state,
|
||||
Scope: q.scope,
|
||||
AppNonce: q.nonce,
|
||||
CodeChallenge: q.codeChallenge,
|
||||
CodeChallengeMethod: method,
|
||||
Resource: q.resource,
|
||||
IdpVerifier: verifier,
|
||||
IdpNonce: nonce,
|
||||
BindHash: hashToken(bindSecret),
|
||||
ExpireIn: now.Add(fedStateTTL).Unix(),
|
||||
}
|
||||
|
||||
// Build the IdP authorize URL BEFORE persisting so a discovery/config failure
|
||||
// never leaves an orphaned transaction row.
|
||||
idpURL, err := idpAuthorizeURL(ctx, prov, st, federationCallbackURL(c))
|
||||
if err != nil {
|
||||
return authorizeErrorRedirect(c, q, "temporarily_unavailable", "the identity provider is unavailable")
|
||||
}
|
||||
if err := store.PersistFederationState(ctx, db, st); err != nil {
|
||||
return authorizeErrorRedirect(c, q, "server_error", "")
|
||||
}
|
||||
setBindCookie(c, bindSecret)
|
||||
return c.Redirect(302, idpURL)
|
||||
}
|
||||
|
||||
// federationCallbackHandler completes the round-trip: it resolves and burns the
|
||||
// single-use transaction (checking expiry + browser binding), exchanges and
|
||||
// verifies the IdP response, links or provisions the local user, and mints the
|
||||
// iam2 authorization code the relying party expects — then redirects to the
|
||||
// original redirect_uri with code + state.
|
||||
func federationCallbackHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
state := param(c, "state")
|
||||
if state == "" {
|
||||
return authorizeUserError(c, "missing state")
|
||||
}
|
||||
st, err := store.GetFederationState(ctx, db, state)
|
||||
if err != nil {
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
// Until the state resolves there is NO trusted redirect target, so an
|
||||
// invalid/expired/replayed state is answered in place, never redirected.
|
||||
if st == nil || st.Used || (st.ExpireIn != 0 && now.Unix() > st.ExpireIn) {
|
||||
return authorizeUserError(c, "the federation session is invalid or expired")
|
||||
}
|
||||
// Browser binding: the callback must present the same anti-forgery cookie
|
||||
// the begin leg set in THIS browser (constant-time) — the login-CSRF /
|
||||
// session-fixation defense. A stolen or injected state without the cookie
|
||||
// stops here.
|
||||
raw := readBindCookie(c)
|
||||
if raw == "" || subtle.ConstantTimeCompare([]byte(hashToken(raw)), []byte(st.BindHash)) != 1 {
|
||||
return authorizeUserError(c, "the federation session could not be verified")
|
||||
}
|
||||
// Burn the transaction now (single-use). A concurrent replay reads Used and
|
||||
// loses; a later replay finds nothing.
|
||||
st.Used = true
|
||||
if err := store.SaveFederationState(ctx, db, st); err != nil {
|
||||
return authorizeUserError(c, "internal error")
|
||||
}
|
||||
clearBindCookie(c)
|
||||
|
||||
// Resolve the relying-party app (the trusted redirect target) and re-check
|
||||
// its redirect_uri against the live allow-list — never trust the stored
|
||||
// value blindly (defense in depth against a tampered row).
|
||||
app, err := store.GetApplicationByClientId(ctx, db, st.ClientId)
|
||||
if err != nil || app == nil {
|
||||
return authorizeUserError(c, "the client application is unavailable")
|
||||
}
|
||||
if !app.IsRedirectUriValid(st.RedirectUri) {
|
||||
return authorizeUserError(c, "invalid redirect_uri")
|
||||
}
|
||||
// Re-assert the reserved-org / tenant-legitimacy gate at the mint boundary,
|
||||
// never trusting that the begin leg still holds or that the app row is honest.
|
||||
if !federationOrgAllowed(app) {
|
||||
return fedErrorRedirect(c, st, "access_denied", "federation is not permitted for this application")
|
||||
}
|
||||
prov, err := store.GetProvider(ctx, db, st.Owner, st.Provider)
|
||||
if err != nil || prov == nil {
|
||||
return fedErrorRedirect(c, st, "temporarily_unavailable", "the identity provider is unavailable")
|
||||
}
|
||||
|
||||
// An IdP-reported denial (user declined / error) is surfaced to the RP as
|
||||
// access_denied, not a server error.
|
||||
if e := param(c, "error"); e != "" {
|
||||
return fedErrorRedirect(c, st, "access_denied", "the identity provider denied the request")
|
||||
}
|
||||
code := param(c, "code")
|
||||
if code == "" {
|
||||
return fedErrorRedirect(c, st, "invalid_request", "the identity provider returned no code")
|
||||
}
|
||||
|
||||
identity, err := idpExchange(ctx, prov, st, code, federationCallbackURL(c), now)
|
||||
if err != nil || identity.subject == "" {
|
||||
return fedErrorRedirect(c, st, "access_denied", "the identity provider could not be verified")
|
||||
}
|
||||
|
||||
user, err := linkOrProvision(ctx, db, app, prov, identity)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
if user.IsForbidden || user.IsDeleted {
|
||||
return fedErrorRedirect(c, st, "access_denied", "the account is not permitted")
|
||||
}
|
||||
|
||||
// The resume parameters — the ORIGINAL authorize request — pinned so the mint
|
||||
// (now, or after a second factor) uses exactly these and nothing a later
|
||||
// request could supply.
|
||||
p := fedResumeParams{
|
||||
ClientId: st.ClientId,
|
||||
RedirectUri: st.RedirectUri,
|
||||
AppState: st.AppState,
|
||||
Scope: st.Scope,
|
||||
AppNonce: st.AppNonce,
|
||||
CodeChallenge: st.CodeChallenge,
|
||||
CodeChallengeMethod: st.CodeChallengeMethod,
|
||||
Resource: st.Resource,
|
||||
}
|
||||
|
||||
// Second-factor gate: a federated login must NOT skip the factor a password
|
||||
// login would demand (the MFA gate, mfa_gate.go). If the resolved user owes a
|
||||
// factor, mint NOTHING here — park the resume, bound to the user and these
|
||||
// pinned params, and send the browser to the hosted 2FA page.
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
if factor.Prompt(org, user) {
|
||||
// The organization requires a factor this federated user has not enrolled;
|
||||
// a federated login cannot enroll one inline, so it fails closed.
|
||||
return fedErrorRedirect(c, st, "access_denied", "two-factor authentication must be set up before signing in")
|
||||
}
|
||||
if factor.Enabled(user) && !remembered(user, now) {
|
||||
return federationChallenge(c, db, st, user, p, now)
|
||||
}
|
||||
|
||||
// No second factor owed — complete exactly as before, through the one mint.
|
||||
loc, err := federationMint(ctx, db, app, user, p, now)
|
||||
if err != nil {
|
||||
return fedMintErrorRedirect(c, st, err)
|
||||
}
|
||||
return c.Redirect(302, loc)
|
||||
}
|
||||
}
|
||||
|
||||
// fedResumeParams is the ORIGINAL iam2 authorize request, pinned server-side so
|
||||
// the code minted after a federated login (immediately, or after a second factor)
|
||||
// binds to exactly these values — never to anything a later request supplies.
|
||||
type fedResumeParams struct {
|
||||
ClientId string `json:"clientId"`
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
AppState string `json:"appState"`
|
||||
Scope string `json:"scope"`
|
||||
AppNonce string `json:"appNonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
Resource string `json:"resource"`
|
||||
}
|
||||
|
||||
// errPKCERequired is the one distinguished mint error a caller maps to an OAuth
|
||||
// invalid_request; every other mint failure is an opaque server_error.
|
||||
var errPKCERequired = errors.New("federation: PKCE is required for public clients")
|
||||
|
||||
// federationMint mints iam2's own authorization code — the SAME artifact a
|
||||
// password login mints — bound to the pinned app-leg PKCE, redirect_uri and nonce,
|
||||
// and returns the RP redirect (redirect_uri?code&state). It is the ONE mint path
|
||||
// both the no-factor completion and the post-2FA resume reach, so a federated code
|
||||
// can never be minted two different ways.
|
||||
func federationMint(ctx context.Context, db orm.DB, app *schema.Application, user *schema.User, p fedResumeParams, now time.Time) (string, error) {
|
||||
// A public client must have carried a PKCE challenge, re-asserted at the mint so
|
||||
// a minted code is never redeemable without proof.
|
||||
if app.ClientSecret == "" && p.CodeChallenge == "" {
|
||||
return "", errPKCERequired
|
||||
}
|
||||
userID := user.Owner + "/" + user.Name
|
||||
codeRow, err := MintCode(app, userID, p.Scope, p.CodeChallenge, p.CodeChallengeMethod, p.Resource, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
codeRow.RedirectUri = p.RedirectUri
|
||||
codeRow.Nonce = p.AppNonce
|
||||
if err := store.PersistToken(ctx, db, codeRow); err != nil {
|
||||
return "", err
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("code", codeRow.Code)
|
||||
setIfPresent(v, "state", p.AppState)
|
||||
return joinQuery(p.RedirectUri, v), nil
|
||||
}
|
||||
|
||||
// federationChallenge parks a resolved-but-not-yet-second-factored federated login.
|
||||
// The pending state IS a LoginChallenge (KindFederation) — the same single-use,
|
||||
// expiring, subject-pinned lifecycle the password MFA gate uses, so there is ONE
|
||||
// challenge concept — carrying the resume params as its payload. The browser is
|
||||
// sent to the hosted 2FA page; the challenge id rides the httpOnly cookie, never a
|
||||
// URL segment.
|
||||
func federationChallenge(c *zip.Ctx, db orm.DB, st *schema.FederationState, user *schema.User, p fedResumeParams, now time.Time) error {
|
||||
payload, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
id, err := MintChallenge(c.Context(), db, KindFederation, user.Owner+"/"+user.Name, string(payload), now)
|
||||
if err != nil {
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
SetChallenge(c, id)
|
||||
return c.Redirect(302, federationBaseURL(c)+PathMfaVerify)
|
||||
}
|
||||
|
||||
// fedMintErrorRedirect maps a federationMint error to the RP redirect_uri.
|
||||
func fedMintErrorRedirect(c *zip.Ctx, st *schema.FederationState, err error) error {
|
||||
if err == errPKCERequired {
|
||||
return fedErrorRedirect(c, st, "invalid_request", "PKCE is required for public clients")
|
||||
}
|
||||
return fedErrorRedirect(c, st, "server_error", "")
|
||||
}
|
||||
|
||||
// linkOrProvision resolves the local identity for a verified federated login,
|
||||
// PROVISION-DON'T-PROMOTE: (1) an account already linked to this provider
|
||||
// subject, else (2) an existing account matched by a VERIFIED IdP email (linked
|
||||
// now), else (3) a freshly provisioned account. It NEVER sets isAdmin and never
|
||||
// grants an existing account anything — federation only authenticates.
|
||||
func linkOrProvision(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, id federatedIdentity) (*schema.User, error) {
|
||||
// Innermost guard on the mint itself: never provision/link a federated identity
|
||||
// into a reserved system org (SuperAdmin) or a tenant this app may not serve.
|
||||
// This layer assumes the two before it (app-write authorization + the begin/
|
||||
// callback checks) both failed.
|
||||
if !federationOrgAllowed(app) {
|
||||
return nil, errors.New("federation: provisioning into this organization is not permitted")
|
||||
}
|
||||
org := app.Organization
|
||||
binding, ok := connectorFor(prov.Type)
|
||||
if !ok {
|
||||
return nil, errors.New("federation: provider has no local identity binding")
|
||||
}
|
||||
|
||||
// 1. Already linked by the provider's stable subject — the authoritative match
|
||||
// for a returning federated user (immune to email churn/ambiguity).
|
||||
if u, err := store.GetUserByConnector(ctx, db, org, binding.field, id.subject); err != nil {
|
||||
return nil, err
|
||||
} else if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// 2. Link to an existing account ONLY on a VERIFIED IdP email. An unverified
|
||||
// email never links (it would let an unproven address take over an account).
|
||||
if id.emailVerified && id.email != "" {
|
||||
if u, err := store.GetUserByEmail(ctx, db, org, id.email); err != nil {
|
||||
return nil, err
|
||||
} else if u != nil {
|
||||
*binding.ref(u) = id.subject
|
||||
u.EmailVerified = true
|
||||
if err := saveUser(ctx, db, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provision a fresh account. Federated accounts carry NO password (the
|
||||
// digest stays empty, so password login fails closed) and are never admin.
|
||||
return provisionFederatedUser(ctx, db, app, prov, binding, id)
|
||||
}
|
||||
|
||||
// provisionFederatedUser creates a new federated account through the ONE
|
||||
// canonical user-create path (users.Create, no password → no login-able digest),
|
||||
// stamping the provider subject on its connector column. The username is
|
||||
// system-generated and collision-checked; the email's verified flag is carried
|
||||
// straight from the IdP.
|
||||
func provisionFederatedUser(ctx context.Context, db orm.DB, app *schema.Application, prov *schema.Provider, binding connectorBinding, id federatedIdentity) (*schema.User, error) {
|
||||
org := app.Organization
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
name := federatedUsername(id.email, prov.Type)
|
||||
taken, err := userExists(ctx, db, org, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
continue
|
||||
}
|
||||
u := schema.User{
|
||||
Owner: org,
|
||||
Name: name,
|
||||
Type: "normal-user",
|
||||
DisplayName: firstNonEmpty(id.displayName, name),
|
||||
Email: id.email,
|
||||
EmailVerified: id.emailVerified,
|
||||
Avatar: id.avatar,
|
||||
SignupApplication: app.Name,
|
||||
RegisterType: "Federation",
|
||||
RegisterSource: org + "/" + prov.Name,
|
||||
}
|
||||
*binding.ref(&u) = id.subject
|
||||
return users.New(db).Create(ctx, &users.CreateInput{User: u})
|
||||
}
|
||||
return nil, errors.New("federation: could not allocate a unique username")
|
||||
}
|
||||
|
||||
// federationProvider resolves the app's ProviderItem named name to its shared
|
||||
// Provider record, requiring the link to be sign-in-enabled and configured with
|
||||
// real credentials — otherwise the request never dead-ends at the IdP.
|
||||
func federationProvider(app *schema.Application, name string) *schema.Provider {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
for _, it := range app.Providers {
|
||||
if it == nil || it.Name != name || !it.CanSignIn || it.Provider == nil {
|
||||
continue
|
||||
}
|
||||
if !isConfigured(it.Provider) {
|
||||
continue
|
||||
}
|
||||
return it.Provider
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// providerOwner is the Provider record's owner, defaulting to the admin org where
|
||||
// providers are seeded.
|
||||
func providerOwner(p *schema.Provider) string {
|
||||
if p.Owner != "" {
|
||||
return p.Owner
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
// federationCallbackURL is the iam2 callback iam2 registers with the IdP and
|
||||
// re-presents at the token exchange. It is PINNED from config, never steered by a
|
||||
// request header, so an attacker cannot redirect the IdP leg via X-Forwarded-Host.
|
||||
func federationCallbackURL(c *zip.Ctx) string {
|
||||
return federationBaseURL(c) + PathFederationCallback
|
||||
}
|
||||
|
||||
// federationBaseURL is the pinned public origin the IdP callback is registered
|
||||
// under — the SAME per-brand issuer the tokens carry, resolved through the ONE
|
||||
// issuer resolver (issuer.go) keyed on the TRUSTED request host (c.Host(), which
|
||||
// ignores X-Forwarded-Host). So a brand's federation callback is registered at
|
||||
// that brand's pinned origin, header-immune and never steered to an attacker
|
||||
// origin. See resolveIssuer for the fail-closed resolution order.
|
||||
func federationBaseURL(c *zip.Ctx) string {
|
||||
return resolveIssuer(c.Host())
|
||||
}
|
||||
|
||||
// federationOrgAllowed reports whether a federated (external) identity may be
|
||||
// provisioned or linked into the application's Organization. Two invariants,
|
||||
// both fail-closed:
|
||||
//
|
||||
// 1. NEVER a reserved system org (admin/built-in/app). A social sign-in that
|
||||
// landed a user in the admin org would make that user a SuperAdmin — the
|
||||
// critical escalation. Federation is customer sign-in; system orgs are seeded
|
||||
// / onboarded / SuperAdmin-managed, never reached by an external login.
|
||||
// 2. Tenant legitimacy. A platform app (admin/built-in-owned, and thus only
|
||||
// SuperAdmin-creatable) may serve any non-reserved tenant. A tenant-registered
|
||||
// app may only land users in the org it legitimately serves — its OWN org, a
|
||||
// shared app, or one with an explicit org-choice mode — mirroring the
|
||||
// login/signup tenant gate, so an attacker-owned app cannot mint or link
|
||||
// identities into a victim tenant.
|
||||
//
|
||||
// This is defense in depth behind the application-write authorization (authz
|
||||
// authorizes the Organization field on create/update); this layer assumes that
|
||||
// one was bypassed and still refuses the escalation.
|
||||
func federationOrgAllowed(app *schema.Application) bool {
|
||||
org := strings.TrimSpace(app.Organization)
|
||||
if org == "" || store.IsReservedOrg(org) {
|
||||
return false
|
||||
}
|
||||
if store.IsSigningCertOwner(app.Owner) {
|
||||
return true // platform app — SuperAdmin-configured, may serve any tenant
|
||||
}
|
||||
if app.IsShared || app.OrgChoiceMode != "" {
|
||||
return true
|
||||
}
|
||||
return org == app.Owner
|
||||
}
|
||||
|
||||
// fedSuccessRedirect returns the browser to the relying party's redirect_uri with
|
||||
// the iam2 authorization code and the original app state (RFC 6749 §4.1.2).
|
||||
func fedSuccessRedirect(c *zip.Ctx, st *schema.FederationState, code string) error {
|
||||
v := url.Values{}
|
||||
v.Set("code", code)
|
||||
setIfPresent(v, "state", st.AppState)
|
||||
return c.Redirect(302, joinQuery(st.RedirectUri, v))
|
||||
}
|
||||
|
||||
// fedErrorRedirect returns an OAuth error to the relying party's redirect_uri
|
||||
// (already allow-list-validated) with the original app state.
|
||||
func fedErrorRedirect(c *zip.Ctx, st *schema.FederationState, code, desc string) error {
|
||||
v := url.Values{}
|
||||
v.Set("error", code)
|
||||
setIfPresent(v, "error_description", desc)
|
||||
setIfPresent(v, "state", st.AppState)
|
||||
return c.Redirect(302, joinQuery(st.RedirectUri, v))
|
||||
}
|
||||
|
||||
// setBindCookie writes the per-transaction anti-forgery cookie: HttpOnly + Secure,
|
||||
// SameSite=Lax (so it IS sent on the IdP's top-level GET back to the callback),
|
||||
// scoped to the callback path, expiring with the transaction.
|
||||
func setBindCookie(c *zip.Ctx, value string) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: fedCookieName,
|
||||
Value: value,
|
||||
Path: PathFederationCallback,
|
||||
MaxAge: int(fedStateTTL / time.Second),
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// readBindCookie returns the anti-forgery cookie value, or "" when absent.
|
||||
func readBindCookie(c *zip.Ctx) string { return c.Fiber().Cookies(fedCookieName) }
|
||||
|
||||
// clearBindCookie expires the anti-forgery cookie once the transaction is
|
||||
// consumed, so it can never be replayed.
|
||||
func clearBindCookie(c *zip.Ctx) {
|
||||
c.Fiber().Cookie(&fiber.Cookie{
|
||||
Name: fedCookieName,
|
||||
Value: "",
|
||||
Path: PathFederationCallback,
|
||||
MaxAge: -1,
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
SameSite: fiber.CookieSameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// connectorBinding ties a provider type to the User's per-connector identity
|
||||
// column: the EXACT lowercase orm/json field name to filter on and a pointer
|
||||
// accessor to read/set the stored subject.
|
||||
type connectorBinding struct {
|
||||
field string
|
||||
ref func(*schema.User) *string
|
||||
}
|
||||
|
||||
// connectorRegistry maps a provider Type to its User connector column. The field
|
||||
// name is the EXACT json/orm name (already lowercase): orm's filter lowercases
|
||||
// only the FIRST rune, so a Go field name like "GitHub" would query '$.gitHub'
|
||||
// (the tag is 'github') — passing the exact json name is the one correct way, and
|
||||
// this registry is its single source of truth. Only the connectors iam2 can
|
||||
// federate are listed; anything else fails closed.
|
||||
var connectorRegistry = map[string]connectorBinding{
|
||||
"google": {"google", func(u *schema.User) *string { return &u.Google }},
|
||||
"github": {"github", func(u *schema.User) *string { return &u.GitHub }},
|
||||
"gitlab": {"gitlab", func(u *schema.User) *string { return &u.Gitlab }},
|
||||
"gitee": {"gitee", func(u *schema.User) *string { return &u.Gitee }},
|
||||
"bitbucket": {"bitbucket", func(u *schema.User) *string { return &u.Bitbucket }},
|
||||
"facebook": {"facebook", func(u *schema.User) *string { return &u.Facebook }},
|
||||
"apple": {"apple", func(u *schema.User) *string { return &u.Apple }},
|
||||
"linkedin": {"linkedin", func(u *schema.User) *string { return &u.LinkedIn }},
|
||||
"discord": {"discord", func(u *schema.User) *string { return &u.Discord }},
|
||||
"slack": {"slack", func(u *schema.User) *string { return &u.Slack }},
|
||||
"okta": {"okta", func(u *schema.User) *string { return &u.Okta }},
|
||||
"azuread": {"azuread", func(u *schema.User) *string { return &u.AzureAD }},
|
||||
"microsoftonline": {"microsoftonline", func(u *schema.User) *string { return &u.MicrosoftOnline }},
|
||||
}
|
||||
|
||||
// connectorFor resolves the connector binding for a provider type (case-folded),
|
||||
// or (zero, false) when the type has no local identity column.
|
||||
func connectorFor(providerType string) (connectorBinding, bool) {
|
||||
b, ok := connectorRegistry[strings.ToLower(strings.TrimSpace(providerType))]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// federatedUsername generates a valid, human-friendly, collision-resistant
|
||||
// username for a provisioned account: the email local-part (or provider name)
|
||||
// sanitized to a handle, guaranteed to start with a letter (so it passes the
|
||||
// username policy), plus a random suffix so concurrent provisions never collide.
|
||||
func federatedUsername(email, providerType string) string {
|
||||
base := ""
|
||||
if at := strings.IndexByte(email, '@'); at > 0 {
|
||||
base = email[:at]
|
||||
}
|
||||
base = sanitizeHandle(base)
|
||||
if base == "" {
|
||||
base = sanitizeHandle(providerType)
|
||||
}
|
||||
if base == "" {
|
||||
base = "user"
|
||||
}
|
||||
if base[0] < 'a' || base[0] > 'z' {
|
||||
base = "u" + base
|
||||
}
|
||||
return base + "-" + randHex(4)
|
||||
}
|
||||
|
||||
// randHex returns 2n lowercase hex chars of cryptographic randomness.
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// sanitizeHandle reduces s to a lowercase [a-z0-9._-] handle, capped at 24 chars.
|
||||
func sanitizeHandle(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '_' || ch == '-' {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
if len(out) > 24 {
|
||||
out = out[:24]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// The Relying-Party side of federation: iam2 as an OIDC/OAuth2 CLIENT of an
|
||||
// external identity provider. Two dialects, one contract (federatedIdentity):
|
||||
//
|
||||
// - OIDC (Google + any provider with an IssuerUrl): OIDC Discovery resolves the
|
||||
// endpoints and JWKS; the end user is authenticated by the id_token, whose
|
||||
// SIGNATURE (against the published JWKS), issuer, audience, expiry, and nonce
|
||||
// are all verified before a single claim is trusted. email_verified is read
|
||||
// from the signed token.
|
||||
// - GitHub (OAuth2, no id_token): the code is exchanged for an access token,
|
||||
// then the user + verified-email endpoints are read. Only a GitHub-verified,
|
||||
// primary email is treated as verified.
|
||||
//
|
||||
// Every outbound call is hardened: a bounded-timeout client that never follows
|
||||
// redirects (a 3xx on a token/JWKS endpoint is answered as a failure, not
|
||||
// chased), a response-body size cap, an https-except-loopback URL guard, and
|
||||
// alg-pinned JWT verification (RS/ES only — never `none`, never an HMAC that a
|
||||
// public key could be abused as the secret for). No secret or token is logged.
|
||||
|
||||
// federatedIdentity is the VERIFIED identity an external IdP asserts about the
|
||||
// end user — the only thing the broker trusts out of the round-trip. Subject is
|
||||
// the IdP's stable, opaque user id (the connector-column value); Email is linked
|
||||
// against a local account ONLY when EmailVerified is true.
|
||||
type federatedIdentity struct {
|
||||
subject string
|
||||
email string
|
||||
emailVerified bool
|
||||
displayName string
|
||||
avatar string
|
||||
}
|
||||
|
||||
// federationHTTPClient is the hardened client every IdP call rides. The timeout
|
||||
// bounds a slow/hostile IdP; CheckRedirect refuses to chase a redirect (an IdP
|
||||
// token/userinfo/JWKS endpoint answering 3xx is a fault, not a hop), closing the
|
||||
// SSRF-via-redirect vector; and the dialer Control refuses to connect to a
|
||||
// private/loopback/link-local/metadata address AT DIAL TIME — after DNS
|
||||
// resolution, on the ACTUAL connecting IP — so a hostile IssuerUrl/Custom*Url (or
|
||||
// a DNS-rebinding hostname) cannot make iam2 reach an internal service or the
|
||||
// cloud metadata endpoint.
|
||||
var federationHTTPClient = &http.Client{
|
||||
Timeout: 12 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: federationDialControl,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 8 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
MaxIdleConns: 8,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// federationDialAllowsPrivate relaxes the SSRF dial guard to permit
|
||||
// private/loopback addresses. It is a TEST SEAM ONLY (the mock IdPs bind to
|
||||
// 127.0.0.1); production code never sets it, so the guard is always fully armed
|
||||
// in a real deployment.
|
||||
var federationDialAllowsPrivate = false
|
||||
|
||||
// federationDialControl is the net.Dialer.Control hook: it inspects the resolved
|
||||
// address every connection actually dials and refuses a private, loopback,
|
||||
// link-local, ULA, unspecified, multicast, or CGNAT target — the SSRF gate that a
|
||||
// literal-URL check cannot provide because it sees the post-DNS IP (defeating
|
||||
// DNS-rebinding). Fails closed on an unparseable address.
|
||||
func federationDialControl(_, address string, _ syscall.RawConn) error {
|
||||
if federationDialAllowsPrivate {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return errors.New("federation: refusing an unparseable dial address")
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return errors.New("federation: dial host did not resolve to an IP")
|
||||
}
|
||||
if ipBlockedForFederation(ip) {
|
||||
return errors.New("federation: refusing to dial a private/loopback/link-local address")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipBlockedForFederation reports whether an IP is in a range iam2 must never
|
||||
// fetch from during federation. net.IP.IsPrivate covers RFC1918 and IPv6 ULA
|
||||
// (fc00::/7); IsLinkLocalUnicast covers 169.254.0.0/16 (incl. the 169.254.169.254
|
||||
// cloud-metadata address) and fe80::/10.
|
||||
func ipBlockedForFederation(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || isCGNAT(ip)
|
||||
}
|
||||
|
||||
// isCGNAT reports whether ip is in 100.64.0.0/10 (carrier-grade NAT), a shared
|
||||
// range net.IP.IsPrivate does not cover.
|
||||
func isCGNAT(ip net.IP) bool {
|
||||
v4 := ip.To4()
|
||||
return v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127
|
||||
}
|
||||
|
||||
// maxIdPBodyBytes caps every IdP response read — a hostile or broken IdP cannot
|
||||
// exhaust memory (discovery/JWKS/token/userinfo are all a few KB).
|
||||
const maxIdPBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// defaultGoogleIssuer is the OIDC issuer for a Google provider that pins none of
|
||||
// its own — the value the id_token carries as `iss` and the discovery origin.
|
||||
const defaultGoogleIssuer = "https://accounts.google.com"
|
||||
|
||||
// GitHub's fixed OAuth2 endpoints (overridable per-Provider for GitHub
|
||||
// Enterprise / tests via Custom{Auth,Token,UserInfo}Url).
|
||||
const (
|
||||
githubAuthorizeEndpoint = "https://github.com/login/oauth/authorize"
|
||||
githubTokenEndpoint = "https://github.com/login/oauth/access_token"
|
||||
githubUserEndpoint = "https://api.github.com/user"
|
||||
)
|
||||
|
||||
// idpKind classifies a provider into its federation dialect. A provider with an
|
||||
// explicit OIDC issuer — or Google — is OIDC; GitHub is OAuth2+userinfo.
|
||||
// Anything else is unsupported and fails closed (""), never guessed.
|
||||
func idpKind(p *schema.Provider) string {
|
||||
switch {
|
||||
case strings.EqualFold(p.Type, "GitHub"):
|
||||
return "github"
|
||||
case strings.EqualFold(p.Type, "Google") || strings.TrimSpace(p.IssuerUrl) != "":
|
||||
return "oidc"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// idpAuthorizeURL builds the IdP authorization-endpoint URL the browser is sent
|
||||
// to at the begin leg — dialect-dispatched, with iam2's callback as the IdP
|
||||
// redirect_uri, our single-use state, IdP-leg PKCE, and (OIDC) the nonce.
|
||||
func idpAuthorizeURL(ctx context.Context, p *schema.Provider, st *schema.FederationState, callback string) (string, error) {
|
||||
switch idpKind(p) {
|
||||
case "oidc":
|
||||
cfg, err := oidcResolve(ctx, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return oidcAuthorizeURL(cfg, p, st, callback), nil
|
||||
case "github":
|
||||
return githubAuthorizeURL(p, st, callback), nil
|
||||
default:
|
||||
return "", fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// idpExchange completes the callback leg: it exchanges the IdP authorization
|
||||
// code and returns the VERIFIED identity, or an error if any verification fails.
|
||||
func idpExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
|
||||
switch idpKind(p) {
|
||||
case "oidc":
|
||||
cfg, err := oidcResolve(ctx, p)
|
||||
if err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
return oidcExchange(ctx, cfg, p, st, code, callback, now)
|
||||
case "github":
|
||||
return githubExchange(ctx, p, st, code, callback)
|
||||
default:
|
||||
return federatedIdentity{}, fmt.Errorf("federation: provider %q is not a supported federation type", p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OIDC dialect (Google + any IssuerUrl provider) ---
|
||||
|
||||
// oidcConfig is the resolved OIDC endpoint set for a provider.
|
||||
type oidcConfig struct {
|
||||
issuer string
|
||||
authURL string
|
||||
tokenURL string
|
||||
jwksURL string
|
||||
}
|
||||
|
||||
// oidcResolve determines the issuer and runs OIDC Discovery to fill the endpoint
|
||||
// set. A per-Provider Custom{Auth,Token}Url overrides the discovered
|
||||
// authorize/token endpoint (a provider that publishes discovery but pins a
|
||||
// vanity endpoint); the JWKS URI always comes from the signed discovery document
|
||||
// so id_token verification keys are never attacker-chosen.
|
||||
func oidcResolve(ctx context.Context, p *schema.Provider) (oidcConfig, error) {
|
||||
issuer := strings.TrimRight(strings.TrimSpace(p.IssuerUrl), "/")
|
||||
if issuer == "" && strings.EqualFold(p.Type, "Google") {
|
||||
issuer = defaultGoogleIssuer
|
||||
}
|
||||
if issuer == "" {
|
||||
return oidcConfig{}, errors.New("federation: OIDC provider has no issuerUrl")
|
||||
}
|
||||
disco, err := oidcDiscover(ctx, issuer)
|
||||
if err != nil {
|
||||
return oidcConfig{}, err
|
||||
}
|
||||
cfg := oidcConfig{
|
||||
issuer: issuer,
|
||||
authURL: disco.AuthorizationEndpoint,
|
||||
tokenURL: disco.TokenEndpoint,
|
||||
jwksURL: disco.JwksURI,
|
||||
}
|
||||
if v := strings.TrimSpace(p.CustomAuthUrl); v != "" {
|
||||
cfg.authURL = v
|
||||
}
|
||||
if v := strings.TrimSpace(p.CustomTokenUrl); v != "" {
|
||||
cfg.tokenURL = v
|
||||
}
|
||||
if cfg.authURL == "" || cfg.tokenURL == "" || cfg.jwksURL == "" {
|
||||
return oidcConfig{}, errors.New("federation: OIDC discovery is missing required endpoints")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// oidcDiscoveryDocument is the subset of the OIDC Discovery document iam2 reads.
|
||||
type oidcDiscoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JwksURI string `json:"jwks_uri"`
|
||||
}
|
||||
|
||||
// oidcDiscover fetches and validates the issuer's discovery document. The
|
||||
// document's own `issuer` MUST equal the configured issuer (OIDC Discovery §4.3)
|
||||
// — a mismatch means the origin is impersonating another issuer, so it fails
|
||||
// closed.
|
||||
func oidcDiscover(ctx context.Context, issuer string) (oidcDiscoveryDocument, error) {
|
||||
var doc oidcDiscoveryDocument
|
||||
if err := getJSON(ctx, issuer+"/.well-known/openid-configuration", &doc); err != nil {
|
||||
return doc, err
|
||||
}
|
||||
if strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(issuer, "/") {
|
||||
return oidcDiscoveryDocument{}, fmt.Errorf("federation: discovery issuer mismatch")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// oidcAuthorizeURL builds the OIDC authorization request: response_type=code,
|
||||
// the app-or-default scope, our callback, the single-use state, S256 PKCE, and
|
||||
// the nonce that the returned id_token must echo.
|
||||
func oidcAuthorizeURL(cfg oidcConfig, p *schema.Provider, st *schema.FederationState, callback string) string {
|
||||
v := url.Values{}
|
||||
v.Set("response_type", "code")
|
||||
v.Set("client_id", p.ClientId)
|
||||
v.Set("redirect_uri", callback)
|
||||
// The OIDC leg MUST request openid, or the IdP returns no id_token and the
|
||||
// exchange fails closed — force it in even if the provider's configured scopes
|
||||
// omit it, so a scope misconfiguration can never silently disable verification.
|
||||
v.Set("scope", ensureOpenID(providerScopes(p, "openid email profile")))
|
||||
v.Set("state", st.Name)
|
||||
v.Set("nonce", st.IdpNonce)
|
||||
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
return joinQuery(cfg.authURL, v)
|
||||
}
|
||||
|
||||
// oidcTokenResponse is the token-endpoint response the OIDC exchange reads.
|
||||
type oidcTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// oidcExchange redeems the code at the token endpoint (proving the IdP-leg PKCE
|
||||
// verifier), then VERIFIES the id_token — signature against the discovered JWKS,
|
||||
// issuer, audience (== our client id), expiry, and nonce — before trusting any
|
||||
// claim. The identity comes from the signed id_token, never from an unverified
|
||||
// userinfo body.
|
||||
func oidcExchange(ctx context.Context, cfg oidcConfig, p *schema.Provider, st *schema.FederationState, code, callback string, now time.Time) (federatedIdentity, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", callback)
|
||||
form.Set("client_id", p.ClientId)
|
||||
form.Set("client_secret", p.ClientSecret)
|
||||
form.Set("code_verifier", st.IdpVerifier)
|
||||
|
||||
var tr oidcTokenResponse
|
||||
if err := postFormJSON(ctx, cfg.tokenURL, form, nil, &tr); err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
if tr.IDToken == "" {
|
||||
return federatedIdentity{}, errors.New("federation: OIDC token response carried no id_token")
|
||||
}
|
||||
claims, err := verifyIDToken(ctx, tr.IDToken, cfg.jwksURL, cfg.issuer, p.ClientId, st.IdpNonce, now)
|
||||
if err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
return federatedIdentity{
|
||||
subject: claims.Subject,
|
||||
email: strings.ToLower(strings.TrimSpace(claims.Email)),
|
||||
emailVerified: truthy(claims.EmailVerified),
|
||||
displayName: claims.Name,
|
||||
avatar: claims.Picture,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// idTokenClaims is the id_token claim set iam2 reads. Nonce is a top-level OIDC
|
||||
// claim (not a registered JWT claim), verified against the transaction's stored
|
||||
// nonce. email_verified is `any` because providers send it as a JSON bool or
|
||||
// (legacy) the string "true".
|
||||
type idTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Nonce string `json:"nonce"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified any `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// verifyIDToken parses and fully validates an id_token. The signing method is
|
||||
// PINNED to the asymmetric set (RS/ES) so a `none` token or an HMAC-with-public-
|
||||
// key confusion attack is rejected outright; the key comes from the issuer's
|
||||
// JWKS, selected by `kid`; issuer, audience, and expiry are enforced by the
|
||||
// parser (now is injected for testability); and the nonce is compared in
|
||||
// constant time. A subject-less token is refused.
|
||||
func verifyIDToken(ctx context.Context, idToken, jwksURL, issuer, audience, nonce string, now time.Time) (idTokenClaims, error) {
|
||||
var claims idTokenClaims
|
||||
tok, err := jwt.ParseWithClaims(idToken, &claims, jwksKeyfunc(ctx, jwksURL),
|
||||
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
|
||||
jwt.WithIssuer(issuer),
|
||||
jwt.WithAudience(audience),
|
||||
jwt.WithExpirationRequired(),
|
||||
jwt.WithTimeFunc(func() time.Time { return now }),
|
||||
)
|
||||
if err != nil || !tok.Valid {
|
||||
return idTokenClaims{}, fmt.Errorf("federation: id_token verification failed")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
|
||||
return idTokenClaims{}, errors.New("federation: id_token nonce mismatch")
|
||||
}
|
||||
if strings.TrimSpace(claims.Subject) == "" {
|
||||
return idTokenClaims{}, errors.New("federation: id_token has no subject")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// --- GitHub dialect (OAuth2 + userinfo) ---
|
||||
|
||||
// githubAuthorizeURL builds GitHub's OAuth2 authorization request. GitHub OAuth
|
||||
// Apps support neither PKCE nor a nonce, so the single-use, browser-bound state
|
||||
// carries the CSRF defense; PKCE is added only when the provider opts in
|
||||
// (EnablePkce) for a compatible deployment.
|
||||
func githubAuthorizeURL(p *schema.Provider, st *schema.FederationState, callback string) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", p.ClientId)
|
||||
v.Set("redirect_uri", callback)
|
||||
v.Set("scope", providerScopes(p, "read:user user:email"))
|
||||
v.Set("state", st.Name)
|
||||
v.Set("allow_signup", "true")
|
||||
if p.EnablePkce {
|
||||
v.Set("code_challenge", ComputeS256Challenge(st.IdpVerifier))
|
||||
v.Set("code_challenge_method", "S256")
|
||||
}
|
||||
return joinQuery(firstNonEmpty(p.CustomAuthUrl, githubAuthorizeEndpoint), v)
|
||||
}
|
||||
|
||||
// githubTokenResponse is GitHub's (JSON, via Accept) token response.
|
||||
type githubTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// githubUser / githubEmail are the userinfo shapes iam2 reads.
|
||||
type githubUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type githubEmail struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// githubExchange redeems the code for an access token, then reads the user and
|
||||
// the verified-email list. The subject is GitHub's immutable numeric id; an
|
||||
// email is treated as verified ONLY when GitHub reports it verified (primary
|
||||
// preferred), so a local account is never linked to an unproven address.
|
||||
func githubExchange(ctx context.Context, p *schema.Provider, st *schema.FederationState, code, callback string) (federatedIdentity, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", callback)
|
||||
form.Set("client_id", p.ClientId)
|
||||
form.Set("client_secret", p.ClientSecret)
|
||||
if p.EnablePkce {
|
||||
form.Set("code_verifier", st.IdpVerifier)
|
||||
}
|
||||
|
||||
var tr githubTokenResponse
|
||||
if err := postFormJSON(ctx, firstNonEmpty(p.CustomTokenUrl, githubTokenEndpoint), form, http.Header{"Accept": {"application/json"}}, &tr); err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
if tr.Error != "" || tr.AccessToken == "" {
|
||||
return federatedIdentity{}, errors.New("federation: GitHub token exchange failed")
|
||||
}
|
||||
|
||||
userURL := firstNonEmpty(p.CustomUserInfoUrl, githubUserEndpoint)
|
||||
var gu githubUser
|
||||
if err := getJSONBearer(ctx, userURL, tr.AccessToken, &gu); err != nil {
|
||||
return federatedIdentity{}, err
|
||||
}
|
||||
if gu.ID == 0 {
|
||||
return federatedIdentity{}, errors.New("federation: GitHub user has no id")
|
||||
}
|
||||
|
||||
email, verified := githubPrimaryEmail(ctx, userURL, tr.AccessToken, gu.Email)
|
||||
name := gu.Name
|
||||
if name == "" {
|
||||
name = gu.Login
|
||||
}
|
||||
return federatedIdentity{
|
||||
subject: strconv.FormatInt(gu.ID, 10),
|
||||
email: strings.ToLower(strings.TrimSpace(email)),
|
||||
emailVerified: verified,
|
||||
displayName: name,
|
||||
avatar: gu.AvatarURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// githubPrimaryEmail resolves the address to link on: the GitHub /user/emails
|
||||
// list's primary-and-verified entry (then any verified entry). It returns
|
||||
// (email, verified); when nothing is verified it returns verified=false so the
|
||||
// broker provisions a fresh account rather than link by an unproven email. A
|
||||
// failure to read the list is not fatal — it degrades to unverified.
|
||||
func githubPrimaryEmail(ctx context.Context, userURL, token, fallback string) (string, bool) {
|
||||
var emails []githubEmail
|
||||
if err := getJSONBearer(ctx, strings.TrimRight(userURL, "/")+"/emails", token, &emails); err == nil {
|
||||
var anyVerified string
|
||||
for _, e := range emails {
|
||||
if !e.Verified {
|
||||
continue
|
||||
}
|
||||
if e.Primary {
|
||||
return e.Email, true
|
||||
}
|
||||
if anyVerified == "" {
|
||||
anyVerified = e.Email
|
||||
}
|
||||
}
|
||||
if anyVerified != "" {
|
||||
return anyVerified, true
|
||||
}
|
||||
}
|
||||
// No verified address available — the profile email is unproven.
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
// --- hardened HTTP + JWKS ---
|
||||
|
||||
// getJSON GETs a URL and decodes a JSON body, with the safety guard, a 200-only
|
||||
// contract, and a body-size cap.
|
||||
func getJSON(ctx context.Context, rawURL string, out any) error {
|
||||
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(req, out)
|
||||
}
|
||||
|
||||
// getJSONBearer GETs a bearer-authenticated JSON endpoint (GitHub userinfo).
|
||||
func getJSONBearer(ctx context.Context, rawURL, token string, out any) error {
|
||||
req, err := newIdPRequest(ctx, http.MethodGet, rawURL, nil, http.Header{
|
||||
"Authorization": {"Bearer " + token},
|
||||
"Accept": {"application/vnd.github+json"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(req, out)
|
||||
}
|
||||
|
||||
// postFormJSON POSTs a urlencoded form and decodes a JSON body.
|
||||
func postFormJSON(ctx context.Context, rawURL string, form url.Values, header http.Header, out any) error {
|
||||
req, err := newIdPRequest(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()), header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if req.Header.Get("Accept") == "" {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
return doJSON(req, out)
|
||||
}
|
||||
|
||||
// newIdPRequest builds a context-bound request to a guard-checked URL with a
|
||||
// stable User-Agent and the caller's headers.
|
||||
func newIdPRequest(ctx context.Context, method, rawURL string, body io.Reader, header http.Header) (*http.Request, error) {
|
||||
safe, err := requireSafeURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, safe, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "hanzo-iam2-federation")
|
||||
for k, vs := range header {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// doJSON executes a request and decodes a 200 JSON body under the size cap. A
|
||||
// non-200 status is a hard failure — no partial trust in an error body.
|
||||
func doJSON(req *http.Request, out any) error {
|
||||
resp, err := federationHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("federation: idp request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxIdPBodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("federation: reading idp response failed")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("federation: idp returned status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("federation: decoding idp response failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireSafeURL parses rawURL and enforces the transport guard: http(s) only,
|
||||
// a non-empty host, and https EXCEPT for loopback (so the production path is
|
||||
// always TLS while tests may target 127.0.0.1). This also rejects file://, and
|
||||
// any non-web scheme — an SSRF/exfiltration hygiene gate on the (admin-supplied)
|
||||
// endpoint configuration.
|
||||
func requireSafeURL(rawURL string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("federation: invalid idp url")
|
||||
}
|
||||
if u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("federation: idp url must be http(s) with a host")
|
||||
}
|
||||
if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
|
||||
return "", fmt.Errorf("federation: idp url must use https")
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// isLoopbackHost reports whether host is a loopback name/address.
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jwkSet / jwk are the JSON Web Key Set shapes iam2 verifies id_tokens against.
|
||||
type jwkSet struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
// jwksKeyfunc returns a jwt.Keyfunc that fetches the issuer's JWKS and selects
|
||||
// the verification key by the token's `kid`. When the token carries a kid, an
|
||||
// exact match is required; a kid-less token is accepted only against a
|
||||
// single-key set. The fetch happens inside the closure so it is bounded by the
|
||||
// same hardened client and request context.
|
||||
func jwksKeyfunc(ctx context.Context, jwksURL string) jwt.Keyfunc {
|
||||
return func(t *jwt.Token) (any, error) {
|
||||
var set jwkSet
|
||||
if err := getJSON(ctx, jwksURL, &set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
if len(set.Keys) != 1 {
|
||||
return nil, errors.New("federation: id_token has no kid and JWKS is not single-key")
|
||||
}
|
||||
return set.Keys[0].publicKey()
|
||||
}
|
||||
for _, k := range set.Keys {
|
||||
if k.Kid == kid {
|
||||
return k.publicKey()
|
||||
}
|
||||
}
|
||||
return nil, errors.New("federation: no JWKS key matches the id_token kid")
|
||||
}
|
||||
}
|
||||
|
||||
// publicKey materializes a JWK into a crypto public key (RSA or EC). Only the
|
||||
// two families iam2 signs with are supported; any other key type is refused.
|
||||
func (k jwk) publicKey() (any, error) {
|
||||
switch k.Kty {
|
||||
case "RSA":
|
||||
n, err := b64uBigInt(k.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eb, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.E, "="))
|
||||
if err != nil {
|
||||
return nil, errors.New("federation: bad JWKS RSA exponent")
|
||||
}
|
||||
e := 0
|
||||
for _, b := range eb {
|
||||
e = e<<8 | int(b)
|
||||
}
|
||||
if e == 0 {
|
||||
return nil, errors.New("federation: zero JWKS RSA exponent")
|
||||
}
|
||||
return &rsa.PublicKey{N: n, E: e}, nil
|
||||
case "EC":
|
||||
curve, err := ecCurve(k.Crv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, err := b64uBigInt(k.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
y, err := b64uBigInt(k.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("federation: unsupported JWKS key type %q", k.Kty)
|
||||
}
|
||||
}
|
||||
|
||||
// ecCurve maps a JWK curve name to its elliptic.Curve.
|
||||
func ecCurve(crv string) (elliptic.Curve, error) {
|
||||
switch crv {
|
||||
case "P-256":
|
||||
return elliptic.P256(), nil
|
||||
case "P-384":
|
||||
return elliptic.P384(), nil
|
||||
case "P-521":
|
||||
return elliptic.P521(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("federation: unsupported JWKS curve %q", crv)
|
||||
}
|
||||
}
|
||||
|
||||
// b64uBigInt decodes a base64url (unpadded) big-endian integer — the JWK
|
||||
// encoding for RSA modulus/exponent and EC coordinates.
|
||||
func b64uBigInt(s string) (*big.Int, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "="))
|
||||
if err != nil {
|
||||
return nil, errors.New("federation: bad JWKS integer encoding")
|
||||
}
|
||||
return new(big.Int).SetBytes(b), nil
|
||||
}
|
||||
|
||||
// --- small shared helpers ---
|
||||
|
||||
// providerScopes returns the provider's configured scopes, or a dialect default
|
||||
// when it configures none.
|
||||
func providerScopes(p *schema.Provider, fallback string) string {
|
||||
if s := strings.TrimSpace(p.Scopes); s != "" {
|
||||
return s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ensureOpenID guarantees the space-delimited scope contains "openid" (the OIDC
|
||||
// requirement for an id_token), prepending it when absent.
|
||||
func ensureOpenID(scope string) string {
|
||||
for _, s := range strings.Fields(scope) {
|
||||
if s == "openid" {
|
||||
return scope
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace("openid " + scope)
|
||||
}
|
||||
|
||||
// joinQuery appends encoded query values to a base URL, honoring an existing
|
||||
// query string.
|
||||
func joinQuery(base string, v url.Values) string {
|
||||
sep := "?"
|
||||
if strings.Contains(base, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return base + sep + v.Encode()
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank string.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truthy interprets an id_token email_verified value (bool or string form).
|
||||
func truthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(t, "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The second-factor RESUME for a federated login. When the federation callback
|
||||
// resolves a user who owes a factor, it mints nothing — it parks the resume in a
|
||||
// single-use, expiring, subject-pinned LoginChallenge (KindFederation) and sends
|
||||
// the browser to the hosted 2FA page. This endpoint is where that page posts the
|
||||
// factor: it verifies it through the SAME factor seam the password MFA gate uses
|
||||
// and, only then, mints the code for the PINNED authorize request.
|
||||
//
|
||||
// It closes the hole a live MFA gate would otherwise leave open: without it, a
|
||||
// 2FA-enrolled user signing in through Google/GitHub would skip the second factor
|
||||
// a password login demands.
|
||||
|
||||
// PathFederationMfa is the resume endpoint. Public (before the Guard): it
|
||||
// self-authenticates through the challenge the callback set — a single-use,
|
||||
// expiring, subject-pinned token — exactly as the callback self-authenticates via
|
||||
// its state. The challenge id rides the httpOnly, SameSite=Lax cookie, so a
|
||||
// cross-site POST carries no challenge and fails closed.
|
||||
const PathFederationMfa = "/v1/iam/oauth/federation/mfa"
|
||||
|
||||
// routeFederationMfa registers the resume endpoint on the PUBLIC group r.
|
||||
func routeFederationMfa(r zip.Router, db orm.DB) {
|
||||
r.Post(PathFederationMfa, federationMfaHandler(db))
|
||||
}
|
||||
|
||||
// fedMfaForm is the resume body. It carries the FACTOR and nothing else — no user
|
||||
// id and no redirect_uri, BY DESIGN: the target user and the whole authorize
|
||||
// request are pinned in the challenge (Subject + Payload), so there is
|
||||
// structurally no request field that could swap them mid-flow.
|
||||
type fedMfaForm struct {
|
||||
Challenge string `json:"challenge"`
|
||||
MfaType string `json:"mfaType"`
|
||||
Passcode string `json:"passcode"`
|
||||
RecoveryCode string `json:"recoveryCode"`
|
||||
}
|
||||
|
||||
// federationMfaHandler finishes a parked federated login: it spends the challenge,
|
||||
// loads the PINNED user from its subject (never the request), verifies the second
|
||||
// factor through the shared factor seam, and only then mints the code for the
|
||||
// pinned authorize request. Fail-closed at every step; taking the challenge spends
|
||||
// it, so a wrong factor burns it and a fresh federation is required to retry —
|
||||
// exactly as the password gate behaves.
|
||||
func federationMfaHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var f fedMfaForm
|
||||
if err := c.Bind(&f); err != nil {
|
||||
return httpx.Err(c, "invalid request body")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
ch, err := TakeChallenge(ctx, db, ReadChallenge(c, f.Challenge), KindFederation, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
ClearChallenge(c)
|
||||
|
||||
owner, name, _ := strings.Cut(ch.Subject, "/")
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if user == nil || user.IsForbidden || user.IsDeleted {
|
||||
return httpx.Err(c, ErrChallenge.Error())
|
||||
}
|
||||
|
||||
// Verify the factor — the ONE factor seam, shared with the password gate.
|
||||
switch {
|
||||
case f.Passcode != "":
|
||||
if f.MfaType != factor.App {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
if !factor.Verify(user.TotpSecret, f.Passcode) {
|
||||
return httpx.Err(c, "the multi-factor authentication code is incorrect")
|
||||
}
|
||||
case f.RecoveryCode != "":
|
||||
if !factor.UseRecovery(user, f.RecoveryCode) {
|
||||
return httpx.Err(c, "the recovery code is incorrect")
|
||||
}
|
||||
if err := factor.Save(ctx, db, user); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
default:
|
||||
return httpx.Err(c, "missing passcode or recovery code")
|
||||
}
|
||||
|
||||
// Resume the ORIGINAL authorize request from the PINNED payload — never from
|
||||
// the request. Re-resolve the app and re-validate its redirect_uri against the
|
||||
// live allow-list (defense in depth against a tampered row).
|
||||
var p fedResumeParams
|
||||
if err := json.Unmarshal([]byte(ch.Payload), &p); err != nil {
|
||||
return httpx.Err(c, "internal error")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, p.ClientId)
|
||||
if err != nil || app == nil {
|
||||
return httpx.Err(c, "the client application is unavailable")
|
||||
}
|
||||
if !app.IsRedirectUriValid(p.RedirectUri) {
|
||||
return httpx.Err(c, "invalid redirect_uri")
|
||||
}
|
||||
loc, err := federationMint(ctx, db, app, user, p, nowFunc())
|
||||
if err != nil {
|
||||
if err == errPKCERequired {
|
||||
return httpx.Err(c, "PKCE is required for public clients")
|
||||
}
|
||||
return httpx.Err(c, "internal error")
|
||||
}
|
||||
// The SPA navigates the browser to this RP redirect (redirect_uri?code&state).
|
||||
return httpx.Ok(c, loc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
)
|
||||
|
||||
// The federated-login second-factor gate, driven through the REAL mounted routes
|
||||
// and the same httptest mock IdP the federation suite uses. The contract that
|
||||
// matters is a store fact: an MFA-enrolled user who signs in through an external
|
||||
// IdP gets NO authorization code until the factor lands — the hole a live MFA gate
|
||||
// would otherwise leave open.
|
||||
|
||||
// fedEnroll seeds a user (in org hanzo) with the given email AND a live TOTP
|
||||
// factor, returning the secret. The email must match the mock IdP so the callback
|
||||
// links to this account by verified email.
|
||||
func fedEnroll(t *testing.T, db orm.DB, name, email string) string {
|
||||
t.Helper()
|
||||
seedUser(t, db, name, email, "pw")
|
||||
secret, _, err := factor.Enroll("hanzo/"+name, "Hanzo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := userRow(t, db, name)
|
||||
u.TotpSecret = secret
|
||||
u.PreferredMfaType = factor.App
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// (a) An MFA-enrolled user signing in through federation is CHALLENGED, not minted:
|
||||
// the callback sends the browser to the 2FA page, sets the challenge cookie, and
|
||||
// persists no token. Presenting the factor then mints the code for that user.
|
||||
func TestFederationMfa_EnrolledUserChallengedThenResumes(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
secret := fedEnroll(t, db, "alice", "alice@example.com")
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := resp.Header.Get("Location")
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("callback status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
// The load-bearing negative: the callback must NOT have minted a code to the RP.
|
||||
if strings.HasPrefix(loc, testRedirect) {
|
||||
t.Fatalf("PASSWORD-FREE MFA BYPASS: federation minted a code for a 2FA user without the factor: %q", loc)
|
||||
}
|
||||
if !strings.HasSuffix(loc, PathMfaVerify) {
|
||||
t.Fatalf("a 2FA-enrolled federated login must go to the 2FA page, got %q", loc)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted before the second factor", n)
|
||||
}
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
// Present the factor → the code is minted and the RP redirect returned.
|
||||
req := jsonReq("POST", PathFederationMfa, map[string]string{"mfaType": factor.App, "passcode": passcode(t, secret)})
|
||||
req.Header.Set("Cookie", challengeCookie+"="+id)
|
||||
_, body := do(t, app, req)
|
||||
mm := decode(t, body)
|
||||
if mm["status"] != "ok" {
|
||||
t.Fatalf("resume with a valid factor failed: %v", mm["msg"])
|
||||
}
|
||||
rurl, _ := mm["data"].(string)
|
||||
if !strings.HasPrefix(rurl, testRedirect) {
|
||||
t.Fatalf("resume must return the RP redirect, got %q", rurl)
|
||||
}
|
||||
cb, _ := url.Parse(rurl)
|
||||
code := cb.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Fatal("resume returned no authorization code")
|
||||
}
|
||||
if cb.Query().Get("state") != fedAppState {
|
||||
t.Errorf("app state not echoed on resume: %q", cb.Query().Get("state"))
|
||||
}
|
||||
tok, err := store2GetTokenByCode(db, code)
|
||||
if err != nil || tok == nil {
|
||||
t.Fatalf("minted code resolves to no token: %v", err)
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("code bound to %q, want hanzo/alice", tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
// A recovery code answers the federated challenge too, and is consumed once.
|
||||
func TestFederationMfa_RecoveryCodeResumes(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
fedEnroll(t, db, "alice", "alice@example.com")
|
||||
plain, err := factor.MintRecovery()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := factor.HashRecovery(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := userRow(t, db, "alice")
|
||||
u.RecoveryCodes = []string{hash}
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := fedResumeParams{ClientId: "webapp", RedirectUri: testRedirect, AppState: fedAppState, Scope: "openid"}
|
||||
payload, _ := json.Marshal(p)
|
||||
id, err := MintChallenge(context.Background(), db, KindFederation, "hanzo/alice", string(payload), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := jsonReq("POST", PathFederationMfa, map[string]string{"recoveryCode": plain})
|
||||
req.Header.Set("Cookie", challengeCookie+"="+id)
|
||||
_, body := do(t, app, req)
|
||||
if m := decode(t, body); m["status"] != "ok" {
|
||||
t.Fatalf("recovery-code resume failed: %v", m["msg"])
|
||||
}
|
||||
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
|
||||
t.Fatalf("recovery code not consumed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// (b) A user with NO factor flows straight through federation exactly as before —
|
||||
// the gate is invisible to everyone else.
|
||||
func TestFederationMfa_UnenrolledUserFlowsThrough(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "bob", "alice@example.com", "pw") // matches the mock email, NO factor
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
cb, _ := url.Parse(loc)
|
||||
if cb.Query().Get("code") == "" {
|
||||
t.Fatalf("an unenrolled federated login must mint a code directly, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// (c) The federation challenge is single-use and expiring.
|
||||
func TestFederationMfa_ChallengeSingleUseAndExpiring(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
secret := fedEnroll(t, db, "alice", "alice@example.com")
|
||||
|
||||
mk := func(now time.Time) string {
|
||||
p := fedResumeParams{ClientId: "webapp", RedirectUri: testRedirect, AppState: fedAppState, Scope: "openid"}
|
||||
payload, _ := json.Marshal(p)
|
||||
id, err := MintChallenge(context.Background(), db, KindFederation, "hanzo/alice", string(payload), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
resume := func(id string) map[string]any {
|
||||
req := jsonReq("POST", PathFederationMfa, map[string]string{"mfaType": factor.App, "passcode": passcode(t, secret)})
|
||||
req.Header.Set("Cookie", challengeCookie+"="+id)
|
||||
_, body := do(t, app, req)
|
||||
return decode(t, body)
|
||||
}
|
||||
|
||||
// single-use: first spends, second is refused.
|
||||
id := mk(time.Now())
|
||||
if m := resume(id); m["status"] != "ok" {
|
||||
t.Fatalf("first resume failed: %v", m["msg"])
|
||||
}
|
||||
if m := resume(id); m["status"] != "error" {
|
||||
t.Fatalf("a spent federation challenge was accepted again: %v", m)
|
||||
}
|
||||
|
||||
// expiring: a challenge past its TTL is refused before any factor is checked.
|
||||
start := time.Unix(1_800_000_000, 0)
|
||||
nowFuncSet(t, start)
|
||||
id2 := mk(start)
|
||||
nowFuncSet(t, start.Add(challengeTTL+time.Second))
|
||||
if m := resume(id2); m["status"] != "error" {
|
||||
t.Fatalf("an expired federation challenge was accepted: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// (d) The target user and redirect_uri are PINNED in the challenge: the resume
|
||||
// body has no field that can swap them, and extra request fields are ignored.
|
||||
func TestFederationMfa_UserAndRedirectPinned(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedApp(t, db, appOpts{clientID: "evil", secret: "x", redirectURIs: []string{"https://evil.example/cb"}})
|
||||
secret := fedEnroll(t, db, "alice", "alice@example.com")
|
||||
seedUser(t, db, "mallory", "mallory@example.com", "pw")
|
||||
|
||||
p := fedResumeParams{ClientId: "webapp", RedirectUri: testRedirect, AppState: fedAppState, Scope: "openid"}
|
||||
payload, _ := json.Marshal(p)
|
||||
id, err := MintChallenge(context.Background(), db, KindFederation, "hanzo/alice", string(payload), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The body tries to steer the ceremony at another user, app, and redirect.
|
||||
req := jsonReq("POST", PathFederationMfa, map[string]string{
|
||||
"mfaType": factor.App, "passcode": passcode(t, secret),
|
||||
"username": "mallory", "name": "mallory", "clientId": "evil", "redirectUri": "https://evil.example/cb",
|
||||
})
|
||||
req.Header.Set("Cookie", challengeCookie+"="+id)
|
||||
_, body := do(t, app, req)
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("resume failed: %v", m["msg"])
|
||||
}
|
||||
rurl, _ := m["data"].(string)
|
||||
if !strings.HasPrefix(rurl, testRedirect) {
|
||||
t.Fatalf("redirect_uri not pinned — the body steered it to %q", rurl)
|
||||
}
|
||||
cb, _ := url.Parse(rurl)
|
||||
tok, err := store2GetTokenByCode(db, cb.Query().Get("code"))
|
||||
if err != nil || tok == nil {
|
||||
t.Fatalf("no token for the minted code: %v", err)
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("target user not pinned — code bound to %q", tok.User)
|
||||
}
|
||||
if tok.RedirectUri != testRedirect {
|
||||
t.Fatalf("redirect_uri not pinned on the code: %q", tok.RedirectUri)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing/forged challenge fails closed — no user, no code.
|
||||
func TestFederationMfa_NoChallengeFailsClosed(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
req := jsonReq("POST", PathFederationMfa, map[string]string{"mfaType": factor.App, "passcode": "000000"})
|
||||
_, body := do(t, app, req) // no cookie, no body challenge
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("a resume with no challenge must fail closed, got %v", m)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token(s) minted with no challenge", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Federation is driven through the REAL mounted routes (authorize → IdP → callback
|
||||
// → code → token). The external IdP is an httptest server — a real HTTP RP round
|
||||
// trip with a real OIDC discovery document, a real JWKS, and a real RS256-signed
|
||||
// id_token whose signature/issuer/audience/nonce iam2 actually verifies (Google
|
||||
// dialect), plus a real GitHub userinfo + verified-email exchange. No live
|
||||
// Google/GitHub is contacted.
|
||||
|
||||
const (
|
||||
fedAppState = "app-state-xyz"
|
||||
fedVerifier = "verifier-abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
fedGoogleCID = "google-oauth-client"
|
||||
fedGitHubCID = "github-oauth-client"
|
||||
fedProvGoogle = "provider-google"
|
||||
fedProvGitHub = "provider-github"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock OIDC IdP (Google-shaped): discovery + JWKS + RS256 id_token.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type mockOIDC struct {
|
||||
*httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
wrongKey *rsa.PrivateKey
|
||||
kid string
|
||||
clientID string
|
||||
|
||||
mu sync.Mutex
|
||||
sub string
|
||||
email string
|
||||
name string
|
||||
emailVerified bool
|
||||
nonce string // baked into the id_token; wired from the authorize redirect
|
||||
signWrong bool // sign with wrongKey → signature must fail
|
||||
noneAlg bool // emit an alg=none (unsigned) id_token → must be rejected
|
||||
issuerOverride string // override id_token iss (issuer-confusion test)
|
||||
audOverride string // override id_token aud (audience test)
|
||||
tokenForm url.Values
|
||||
}
|
||||
|
||||
// allowPrivateFederationDial relaxes the SSRF dial guard for the test's duration
|
||||
// so the httptest mock IdPs (bound to 127.0.0.1) are reachable — the same
|
||||
// package-var test-injection pattern as nowFuncSet. Production never flips it.
|
||||
func allowPrivateFederationDial(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := federationDialAllowsPrivate
|
||||
federationDialAllowsPrivate = true
|
||||
t.Cleanup(func() { federationDialAllowsPrivate = prev })
|
||||
}
|
||||
|
||||
func newMockOIDC(t *testing.T, clientID string) *mockOIDC {
|
||||
t.Helper()
|
||||
allowPrivateFederationDial(t)
|
||||
m := &mockOIDC{
|
||||
key: mustGenRSA(t),
|
||||
wrongKey: mustGenRSA(t),
|
||||
kid: "mock-oidc-kid",
|
||||
clientID: clientID,
|
||||
sub: "google-sub-1001",
|
||||
email: "alice@example.com",
|
||||
name: "Alice Example",
|
||||
emailVerified: true,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{
|
||||
"issuer": m.URL,
|
||||
"authorization_endpoint": m.URL + "/authorize",
|
||||
"token_endpoint": m.URL + "/token",
|
||||
"userinfo_endpoint": m.URL + "/userinfo",
|
||||
"jwks_uri": m.URL + "/jwks",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
||||
pub := m.key.PublicKey
|
||||
writeJSON(w, map[string]any{"keys": []map[string]any{{
|
||||
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": m.kid,
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
}}})
|
||||
})
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
m.mu.Lock()
|
||||
m.tokenForm = r.Form
|
||||
iss := firstNonEmpty(m.issuerOverride, m.URL)
|
||||
aud := firstNonEmpty(m.audOverride, m.clientID)
|
||||
claims := jwt.MapClaims{
|
||||
"iss": iss, "sub": m.sub, "aud": aud,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"nonce": m.nonce,
|
||||
"email": m.email,
|
||||
"email_verified": m.emailVerified,
|
||||
"name": m.name,
|
||||
}
|
||||
signKey := m.key
|
||||
if m.signWrong {
|
||||
signKey = m.wrongKey
|
||||
}
|
||||
noneAlg := m.noneAlg
|
||||
m.mu.Unlock()
|
||||
// The alg=none forgery: an unsigned token whose header claims no signature.
|
||||
if noneAlg {
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||
idt, _ := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
writeJSON(w, map[string]any{"access_token": "x", "id_token": idt, "token_type": "Bearer"})
|
||||
return
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = m.kid
|
||||
idt, err := tok.SignedString(signKey)
|
||||
if err != nil {
|
||||
http.Error(w, "sign", 500)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"access_token": "idp-at-" + randHex(6), "id_token": idt, "token_type": "Bearer"})
|
||||
})
|
||||
m.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock GitHub IdP (OAuth2 + userinfo + verified emails).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type mockGitHub struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
id int64
|
||||
login string
|
||||
name string
|
||||
profileEmail string
|
||||
emails []map[string]any // {email, primary, verified}
|
||||
tokenForm url.Values
|
||||
}
|
||||
|
||||
func newMockGitHub(t *testing.T) *mockGitHub {
|
||||
t.Helper()
|
||||
allowPrivateFederationDial(t)
|
||||
m := &mockGitHub{
|
||||
id: 424242,
|
||||
login: "octocat",
|
||||
name: "The Octocat",
|
||||
emails: []map[string]any{
|
||||
{"email": "octo-unverified@example.com", "primary": false, "verified": false},
|
||||
{"email": "octo@example.com", "primary": true, "verified": true},
|
||||
},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
m.mu.Lock()
|
||||
m.tokenForm = r.Form
|
||||
m.mu.Unlock()
|
||||
writeJSON(w, map[string]any{"access_token": "gho-" + randHex(6), "token_type": "bearer", "scope": "read:user,user:email"})
|
||||
})
|
||||
mux.HandleFunc("/user/emails", func(w http.ResponseWriter, _ *http.Request) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
writeJSON(w, m.emails)
|
||||
})
|
||||
mux.HandleFunc("/user", func(w http.ResponseWriter, _ *http.Request) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
writeJSON(w, map[string]any{"id": m.id, "login": m.login, "name": m.name, "email": m.profileEmail, "avatar_url": "https://avatars/x"})
|
||||
})
|
||||
m.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seeds + drivers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// seedOIDCProvider seeds a Google-dialect Provider row whose OIDC issuer points
|
||||
// at the mock, and links it (sign-in enabled) onto the app.
|
||||
func seedOIDCProvider(t *testing.T, db orm.DB, appClientID string, m *mockOIDC) {
|
||||
t.Helper()
|
||||
p := orm.New[schema.Provider](db)
|
||||
p.Owner, p.Name = "admin", fedProvGoogle
|
||||
p.Category, p.Type = "OAuth", "Google"
|
||||
p.ClientId, p.ClientSecret = m.clientID, "google-secret-do-not-log"
|
||||
p.IssuerUrl = m.URL
|
||||
p.SetId("admin/" + fedProvGoogle)
|
||||
if err := p.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed google provider: %v", err)
|
||||
}
|
||||
linkProvider(t, db, appClientID, fedProvGoogle)
|
||||
}
|
||||
|
||||
// seedGitHubProvider seeds a GitHub-dialect Provider row whose endpoints point at
|
||||
// the mock, and links it onto the app.
|
||||
func seedGitHubProvider(t *testing.T, db orm.DB, appClientID string, m *mockGitHub) {
|
||||
t.Helper()
|
||||
p := orm.New[schema.Provider](db)
|
||||
p.Owner, p.Name = "admin", fedProvGitHub
|
||||
p.Category, p.Type = "OAuth", "GitHub"
|
||||
p.ClientId, p.ClientSecret = fedGitHubCID, "github-secret-do-not-log"
|
||||
p.CustomAuthUrl = m.URL + "/authorize"
|
||||
p.CustomTokenUrl = m.URL + "/token"
|
||||
p.CustomUserInfoUrl = m.URL + "/user"
|
||||
p.SetId("admin/" + fedProvGitHub)
|
||||
if err := p.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed github provider: %v", err)
|
||||
}
|
||||
linkProvider(t, db, appClientID, fedProvGitHub)
|
||||
}
|
||||
|
||||
// linkProvider appends a sign-in-enabled ProviderItem to an app and persists it.
|
||||
func linkProvider(t *testing.T, db orm.DB, appClientID, providerName string) {
|
||||
t.Helper()
|
||||
a, err := orm.Get[schema.Application](db, "admin/"+appClientID)
|
||||
if err != nil {
|
||||
t.Fatalf("load app: %v", err)
|
||||
}
|
||||
a.Providers = append(a.Providers, &schema.ProviderItem{Owner: "admin", Name: providerName, CanSignIn: true, CanSignUp: true})
|
||||
if err := a.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("link provider: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// beginAuthorize drives GET /v1/iam/oauth/authorize with a provider hint and
|
||||
// returns the IdP-authorize query (from the 302 Location) and the anti-forgery
|
||||
// cookie the response set. It asserts the request is a 302 to an IdP.
|
||||
func beginAuthorize(t *testing.T, app *zip.App, clientID, provider string) (url.Values, string) {
|
||||
t.Helper()
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {testRedirect},
|
||||
"scope": {"openid email profile"},
|
||||
"state": {fedAppState},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
"provider": {provider},
|
||||
}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("authorize(provider) status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse IdP authorize location: %v", err)
|
||||
}
|
||||
// A federation kickoff redirects to an ABSOLUTE external IdP URL, never to the
|
||||
// relative hosted-login path a credential flow uses.
|
||||
if !loc.IsAbs() || loc.Host == "" {
|
||||
t.Fatalf("authorize(provider) must redirect to an external IdP; got %q", loc.String())
|
||||
}
|
||||
return loc.Query(), cookieKV(resp.Header.Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
// callback drives GET /v1/iam/oauth/callback with the given state/code and the
|
||||
// anti-forgery cookie.
|
||||
func callback(t *testing.T, app *zip.App, state, code, cookie string) *http.Response {
|
||||
t.Helper()
|
||||
q := url.Values{"state": {state}, "code": {code}}
|
||||
req := formReqNoBody("GET", PathFederationCallback+"?"+q.Encode())
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
resp, _ := do(t, app, req)
|
||||
return resp
|
||||
}
|
||||
|
||||
// countUsers returns the number of users in the hanzo org.
|
||||
func countUsers(t *testing.T, db orm.DB) int {
|
||||
t.Helper()
|
||||
n, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Count(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("count users: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The authorize endpoint, given a provider hint, redirects the browser to the
|
||||
// external IdP with response_type=code, our callback, a single-use state, S256
|
||||
// PKCE, and (OIDC) a nonce — and sets the HttpOnly browser-binding cookie. The
|
||||
// client_secret is NEVER on this browser-facing redirect.
|
||||
func TestFederation_AuthorizeRedirectsToOIDCWithStatePKCENonce(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("response_type = %q", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("client_id") != fedGoogleCID {
|
||||
t.Errorf("client_id = %q, want the provider's IdP client id", q.Get("client_id"))
|
||||
}
|
||||
if !strings.HasSuffix(q.Get("redirect_uri"), PathFederationCallback) {
|
||||
t.Errorf("redirect_uri = %q, want our callback", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("state") == "" {
|
||||
t.Error("state must be present (single-use CSRF token)")
|
||||
}
|
||||
if q.Get("nonce") == "" {
|
||||
t.Error("OIDC nonce must be present")
|
||||
}
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("IdP-leg PKCE missing: challenge=%q method=%q", q.Get("code_challenge"), q.Get("code_challenge_method"))
|
||||
}
|
||||
if cookie == "" || !strings.HasPrefix(cookie, fedCookieName+"=") {
|
||||
t.Errorf("anti-forgery cookie missing: %q", cookie)
|
||||
}
|
||||
// The provider secret must never cross to the browser.
|
||||
if strings.Contains(q.Encode(), "google-secret-do-not-log") {
|
||||
t.Fatal("client_secret leaked into the browser-facing IdP redirect")
|
||||
}
|
||||
// State is server-side single-use.
|
||||
if st, _ := store.GetFederationState(context.Background(), db, q.Get("state")); st == nil {
|
||||
t.Fatal("federation state row was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub authorize carries state (its CSRF defense) but no nonce (OAuth2, no
|
||||
// id_token) and no PKCE by default.
|
||||
func TestFederation_AuthorizeRedirectsToGitHub(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockGitHub(t)
|
||||
seedGitHubProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGitHub)
|
||||
if q.Get("state") == "" {
|
||||
t.Error("GitHub authorize must carry state")
|
||||
}
|
||||
if q.Get("nonce") != "" {
|
||||
t.Error("GitHub (OAuth2) must not carry an OIDC nonce")
|
||||
}
|
||||
if cookie == "" {
|
||||
t.Error("anti-forgery cookie must be set")
|
||||
}
|
||||
}
|
||||
|
||||
// Full OIDC round-trip: a first-time login PROVISIONS a user (no password, not
|
||||
// admin) and mints an iam2 authorization code; the relying party's existing PKCE
|
||||
// code→token exchange then completes unchanged and carries the new user's sub.
|
||||
func TestFederation_OIDCCallbackProvisionsUserAndIssuesCode(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
before := countUsers(t, db)
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce") // wire the transaction's real IdP nonce into the id_token
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
cb, _ := url.Parse(loc)
|
||||
code := cb.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Fatalf("callback must redirect with an iam2 code; got %q", loc)
|
||||
}
|
||||
if cb.Query().Get("state") != fedAppState {
|
||||
t.Errorf("app state not echoed: %q", cb.Query().Get("state"))
|
||||
}
|
||||
|
||||
// A user was provisioned, linked by the Google subject, no password, no admin.
|
||||
u, err := store.GetUserByConnector(context.Background(), db, "hanzo", "google", m.sub)
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("provisioned user not found by connector subject: %v", err)
|
||||
}
|
||||
if u.PasswordHash != "" {
|
||||
t.Error("federated user must have NO password hash")
|
||||
}
|
||||
if u.IsAdmin {
|
||||
t.Fatal("federation must NEVER set isAdmin")
|
||||
}
|
||||
if !u.EmailVerified || u.Email != m.email {
|
||||
t.Errorf("verified email not carried: verified=%v email=%q", u.EmailVerified, u.Email)
|
||||
}
|
||||
if countUsers(t, db) != before+1 {
|
||||
t.Fatalf("expected exactly one new user")
|
||||
}
|
||||
|
||||
// The iam2 code redeems through the ordinary PKCE token exchange, unchanged.
|
||||
tokResp, tok := exchangeCode(t, app, url.Values{
|
||||
"code": {code}, "client_id": {"webapp"}, "redirect_uri": {testRedirect}, "code_verifier": {fedVerifier},
|
||||
})
|
||||
if tokResp.StatusCode != 200 {
|
||||
t.Fatalf("iam2 code exchange failed: %d %v", tokResp.StatusCode, tok)
|
||||
}
|
||||
if tok["access_token"] == nil {
|
||||
t.Fatal("no access_token from the iam2 code exchange")
|
||||
}
|
||||
// The token subject is the provisioned user; no IdP token leaks into it.
|
||||
if body := tokenBody(tok); strings.Contains(body, "idp-at-") || strings.Contains(body, "google-secret-do-not-log") {
|
||||
t.Fatal("IdP access token / client secret leaked into the iam2 token response")
|
||||
}
|
||||
}
|
||||
|
||||
// The GitHub dialect: token exchange + /user + /user/emails, provisioning by the
|
||||
// primary VERIFIED email.
|
||||
func TestFederation_GitHubCallbackProvisionsViaVerifiedEmail(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockGitHub(t)
|
||||
seedGitHubProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGitHub)
|
||||
resp := callback(t, app, q.Get("state"), "gh-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if code := mustQuery(t, loc).Get("code"); code == "" {
|
||||
t.Fatalf("GitHub federation did not mint an iam2 code: %q", loc)
|
||||
}
|
||||
u, err := store.GetUserByConnector(context.Background(), db, "hanzo", "github", "424242")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("GitHub user not provisioned by subject: %v", err)
|
||||
}
|
||||
if u.Email != "octo@example.com" || !u.EmailVerified {
|
||||
t.Errorf("expected the primary verified email; got %q verified=%v", u.Email, u.EmailVerified)
|
||||
}
|
||||
// The GitHub client secret only ever went to the token endpoint (server-side).
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.tokenForm.Get("client_secret") != "github-secret-do-not-log" {
|
||||
t.Errorf("expected the secret at the token endpoint, form=%v", m.tokenForm)
|
||||
}
|
||||
}
|
||||
|
||||
// A returning federated user (same subject) is matched by subject — no duplicate
|
||||
// account is created on the second login.
|
||||
func TestFederation_ReloginBySubjectIsStable(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
runOIDCLogin(t, app, db, m, "webapp", nil)
|
||||
after1 := countUsers(t, db)
|
||||
runOIDCLogin(t, app, db, m, "webapp", nil)
|
||||
if countUsers(t, db) != after1 {
|
||||
t.Fatalf("second login by the same subject must not create a new user")
|
||||
}
|
||||
}
|
||||
|
||||
// A verified IdP email that matches an EXISTING local account LINKS to it (sets
|
||||
// the connector column) instead of creating a duplicate.
|
||||
func TestFederation_LinksExistingAccountByVerifiedEmail(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@example.com", "pw") // pre-existing password account, same email
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
before := countUsers(t, db)
|
||||
runOIDCLogin(t, app, db, m, "webapp", nil)
|
||||
if countUsers(t, db) != before {
|
||||
t.Fatalf("verified-email login must link, not create a duplicate")
|
||||
}
|
||||
// The Google subject is now linked onto the pre-existing account.
|
||||
linked, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if linked == nil || linked.Google != m.sub {
|
||||
t.Fatalf("connector subject not linked onto the existing account: %+v", linked)
|
||||
}
|
||||
}
|
||||
|
||||
// email_verified:false must NOT auto-link by email — it provisions a fresh
|
||||
// account, so an unproven address can never take over an existing one.
|
||||
func TestFederation_UnverifiedEmailDoesNotAutoLink(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "victim", "victim@example.com", "pw")
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
m.email = "victim@example.com"
|
||||
m.emailVerified = false
|
||||
m.sub = "attacker-sub-9"
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
before := countUsers(t, db)
|
||||
runOIDCLogin(t, app, db, m, "webapp", nil)
|
||||
|
||||
// The victim account was NOT linked.
|
||||
victim, _ := store.GetUserByName(context.Background(), db, "hanzo", "victim")
|
||||
if victim == nil || victim.Google != "" {
|
||||
t.Fatalf("unverified email must not link onto the victim account: %+v", victim)
|
||||
}
|
||||
// A fresh account was provisioned instead.
|
||||
if countUsers(t, db) != before+1 {
|
||||
t.Fatalf("expected a freshly provisioned account, not a takeover")
|
||||
}
|
||||
if u, _ := store.GetUserByConnector(context.Background(), db, "hanzo", "google", "attacker-sub-9"); u == nil {
|
||||
t.Fatal("federated identity should have been provisioned onto its own account")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown/forged state is answered in place (no trusted redirect target) and
|
||||
// never completes.
|
||||
func TestFederation_UnknownStateRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
// A cookie alone cannot substitute for a real server-side state row.
|
||||
resp := callback(t, app, "totally-made-up-state", "idp-code", fedCookieName+"=whatever")
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("unknown state status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if resp.Header.Get("Location") != "" {
|
||||
t.Fatalf("unknown state must NOT redirect anywhere; got %q", resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// A consumed state cannot be replayed — the second callback mints nothing.
|
||||
func TestFederation_ReplayedStateRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
first := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
requireRedirect(t, first, testRedirect) // success
|
||||
|
||||
replay := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
if replay.StatusCode != 400 || replay.Header.Get("Location") != "" {
|
||||
t.Fatalf("replayed state must be refused in place; status=%d loc=%q", replay.StatusCode, replay.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// Without the browser-binding cookie the callback is refused (login-CSRF /
|
||||
// session-fixation defense): a state injected into another browser cannot land.
|
||||
func TestFederation_MissingOrWrongBindCookieRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
// No cookie.
|
||||
noCookie := callback(t, app, q.Get("state"), "idp-code-1", "")
|
||||
if noCookie.StatusCode != 400 || noCookie.Header.Get("Location") != "" {
|
||||
t.Fatalf("callback without the bind cookie must be refused; status=%d", noCookie.StatusCode)
|
||||
}
|
||||
// Wrong cookie value.
|
||||
wrong := callback(t, app, q.Get("state"), "idp-code-1", fedCookieName+"=not-the-secret")
|
||||
if wrong.StatusCode != 400 || wrong.Header.Get("Location") != "" {
|
||||
t.Fatalf("callback with a wrong bind cookie must be refused; status=%d", wrong.StatusCode)
|
||||
}
|
||||
// The state was not consumed by the failed attempts — the legit browser still works.
|
||||
_ = cookie
|
||||
}
|
||||
|
||||
// An id_token whose nonce does not match the transaction's nonce is rejected —
|
||||
// the login does not complete and no account is created/linked.
|
||||
func TestFederation_OIDCNonceMismatchRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
before := countUsers(t, db)
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = "a-different-nonce-than-issued" // tamper: id_token nonce != state.IdpNonce
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
|
||||
t.Fatalf("nonce mismatch must fail closed (error, no code); got %q", loc)
|
||||
}
|
||||
if countUsers(t, db) != before {
|
||||
t.Fatal("a nonce-mismatched login must not provision an account")
|
||||
}
|
||||
}
|
||||
|
||||
// An id_token whose signature does not verify against the published JWKS is
|
||||
// rejected — proving the signature check is real, not stubbed.
|
||||
func TestFederation_OIDCBadSignatureRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
m.signWrong = true // sign with a key NOT in the JWKS
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
before := countUsers(t, db)
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
|
||||
t.Fatalf("bad signature must fail closed; got %q", loc)
|
||||
}
|
||||
if countUsers(t, db) != before {
|
||||
t.Fatal("a signature-invalid login must not provision an account")
|
||||
}
|
||||
}
|
||||
|
||||
// An alg=none (unsigned) id_token is rejected — the signing method is pinned to
|
||||
// the asymmetric set, so the classic JWT downgrade never authenticates anyone.
|
||||
func TestFederation_OIDCAlgNoneRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
m.noneAlg = true
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
before := countUsers(t, db)
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
|
||||
t.Fatalf("alg=none must fail closed; got %q", loc)
|
||||
}
|
||||
if countUsers(t, db) != before {
|
||||
t.Fatal("an unsigned id_token must not provision an account")
|
||||
}
|
||||
}
|
||||
|
||||
// An id_token minted for a different audience (not our client id) is rejected.
|
||||
func TestFederation_OIDCWrongAudienceRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
m.audOverride = "some-other-client"
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q, cookie := beginAuthorize(t, app, "webapp", fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-1", cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if q2 := mustQuery(t, loc); q2.Get("error") == "" || q2.Get("code") != "" {
|
||||
t.Fatalf("wrong audience must fail closed; got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-allow-listed redirect_uri is refused at the authorize leg IN PLACE (never
|
||||
// redirected), and no federation transaction is created for it.
|
||||
func TestFederation_NonAllowlistedRedirectUriRefused(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
|
||||
q := url.Values{
|
||||
"response_type": {"code"}, "client_id": {"webapp"},
|
||||
"redirect_uri": {"https://evil.example/steal"},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"provider": {fedProvGoogle},
|
||||
}
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+q.Encode()))
|
||||
if resp.StatusCode != 400 || resp.Header.Get("Location") != "" {
|
||||
t.Fatalf("bad redirect_uri must be answered in place; status=%d loc=%q", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// runOIDCLogin drives a full successful OIDC federation login (authorize →
|
||||
// callback) and asserts it lands an iam2 code. mutate may tweak the mock after
|
||||
// the nonce is wired.
|
||||
func runOIDCLogin(t *testing.T, app *zip.App, db orm.DB, m *mockOIDC, clientID string, mutate func()) {
|
||||
t.Helper()
|
||||
q, cookie := beginAuthorize(t, app, clientID, fedProvGoogle)
|
||||
m.mu.Lock()
|
||||
m.nonce = q.Get("nonce")
|
||||
m.mu.Unlock()
|
||||
if mutate != nil {
|
||||
mutate()
|
||||
}
|
||||
resp := callback(t, app, q.Get("state"), "idp-code-"+randHex(3), cookie)
|
||||
loc := requireRedirect(t, resp, testRedirect)
|
||||
if mustQuery(t, loc).Get("code") == "" {
|
||||
t.Fatalf("federation login did not mint an iam2 code: %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func mustQuery(t *testing.T, loc string) url.Values {
|
||||
t.Helper()
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Fatalf("parse location %q: %v", loc, err)
|
||||
}
|
||||
return u.Query()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RED-TEAM PoCs — F1: federation must never mint a SuperAdmin or cross-tenant
|
||||
// identity. These reproduce the reported exploits and assert they are REFUSED.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// seedFederationTarget seeds a (possibly malicious) app — appOwner/appClientID
|
||||
// pointing its Organization at `serves` — linked to a Google-dialect provider
|
||||
// (owned by provOwner) whose OIDC issuer is the mock IdP.
|
||||
func seedFederationTarget(t *testing.T, db orm.DB, appClientID, appOwner, serves, provOwner string, m *mockOIDC) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
p := orm.New[schema.Provider](db)
|
||||
p.Owner, p.Name = provOwner, fedProvGoogle
|
||||
p.Category, p.Type = "OAuth", "Google"
|
||||
p.ClientId, p.ClientSecret = m.clientID, "secret-do-not-log"
|
||||
p.IssuerUrl = m.URL
|
||||
p.SetId(provOwner + "/" + fedProvGoogle)
|
||||
if err := p.CreateCtx(ctx); err != nil {
|
||||
t.Fatalf("seed provider: %v", err)
|
||||
}
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner, a.Name, a.ClientId = appOwner, appClientID, appClientID
|
||||
a.Organization = serves
|
||||
a.EnablePassword = true
|
||||
a.ExpireInHours = 1
|
||||
a.RedirectUris = []string{testRedirect}
|
||||
a.Providers = []*schema.ProviderItem{{Owner: provOwner, Name: fedProvGoogle, CanSignIn: true}}
|
||||
a.SetId(appOwner + "/" + appClientID)
|
||||
if err := a.CreateCtx(ctx); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func federationAuthorizeQuery(clientID string) url.Values {
|
||||
return url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {testRedirect},
|
||||
"code_challenge": {ComputeS256Challenge(fedVerifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {fedAppState},
|
||||
"provider": {fedProvGoogle},
|
||||
}
|
||||
}
|
||||
|
||||
// assertFederationRefused asserts a federation kickoff was refused: bounced back
|
||||
// to the relying party with an OAuth error, NEVER redirected to the IdP, NEVER a
|
||||
// code.
|
||||
func assertFederationRefused(t *testing.T, resp *http.Response, m *mockOIDC) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("want a 302 refusal redirect, got %d", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, testRedirect) {
|
||||
t.Fatalf("refusal must redirect to the relying party, not the IdP: %q", loc)
|
||||
}
|
||||
if m != nil && strings.HasPrefix(loc, m.URL) {
|
||||
t.Fatal("a refused federation must never reach the IdP")
|
||||
}
|
||||
q := mustQuery(t, loc)
|
||||
if q.Get("error") == "" {
|
||||
t.Fatalf("refusal must carry an OAuth error: %q", loc)
|
||||
}
|
||||
if q.Get("code") != "" {
|
||||
t.Fatal("a refused federation must not mint a code")
|
||||
}
|
||||
}
|
||||
|
||||
func countUsersIn(t *testing.T, db orm.DB, org string) int {
|
||||
t.Helper()
|
||||
n, err := orm.TypedQuery[schema.User](db).Filter("Owner=", org).Count(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("count users in %q: %v", org, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PoC 1 — an attacker-owned app whose Organization names the reserved admin org
|
||||
// would provision User{Owner:"admin"} = SuperAdmin. Federation must refuse it.
|
||||
func TestRedTeam_FederationMintsSuperAdmin(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
m := newMockOIDC(t, fedGoogleCID) // the attacker's OWN Google account
|
||||
seedFederationTarget(t, db, "evil-app", "attackerorg", "admin", "admin", m)
|
||||
|
||||
beforeAdmin := countUsersIn(t, db, "admin")
|
||||
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("evil-app").Encode()))
|
||||
assertFederationRefused(t, resp, m)
|
||||
|
||||
if countUsersIn(t, db, "admin") != beforeAdmin {
|
||||
t.Fatal("PoC: federation provisioned a user into the admin org (SuperAdmin mint)")
|
||||
}
|
||||
// Defense in depth: the innermost mint refuses this app directly too.
|
||||
evil, _ := store.GetApplicationByClientId(tctx(), db, "evil-app")
|
||||
prov, _ := store.GetProvider(tctx(), db, "admin", fedProvGoogle)
|
||||
if _, err := linkOrProvision(tctx(), db, evil, prov, federatedIdentity{subject: "s1", email: "a@b.com", emailVerified: true}); err == nil {
|
||||
t.Fatal("PoC: linkOrProvision minted an identity into the admin org")
|
||||
}
|
||||
}
|
||||
|
||||
// PoC 2 — an attacker-owned app whose Organization names a VICTIM tenant, driven
|
||||
// by a tenant-owned IdP that asserts the victim's verified email, would link the
|
||||
// attacker's identity onto the victim's account. Federation must refuse it.
|
||||
func TestRedTeam_FederationCrossTenantTakeover(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedUserInOrg(t, db, "victimorg", "ceo", "ceo@victim.com", "pw")
|
||||
|
||||
m := newMockOIDC(t, fedGoogleCID) // attacker's tenant-owned IdP...
|
||||
m.email = "ceo@victim.com" // ...asserting the victim's email, "verified"
|
||||
m.emailVerified = true
|
||||
m.sub = "attacker-controlled-sub"
|
||||
seedFederationTarget(t, db, "evil-app", "attackerorg", "victimorg", "attackerorg", m)
|
||||
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("evil-app").Encode()))
|
||||
assertFederationRefused(t, resp, m)
|
||||
|
||||
victim, _ := store.GetUserByName(tctx(), db, "victimorg", "ceo")
|
||||
if victim == nil || victim.Google != "" {
|
||||
t.Fatalf("PoC: cross-tenant identity linked onto the victim account: %+v", victim)
|
||||
}
|
||||
}
|
||||
|
||||
// PoC 3 — the fully tenant-owned variant: the attacker's OWN app AND OWN provider
|
||||
// (no platform resource referenced) still cannot point Organization at admin.
|
||||
func TestRedTeam_FederationMintsSuperAdmin_TenantOwnedApp(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedFederationTarget(t, db, "evil-app", "attackerorg", "admin", "attackerorg", m)
|
||||
|
||||
beforeAdmin := countUsersIn(t, db, "admin")
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("evil-app").Encode()))
|
||||
assertFederationRefused(t, resp, m)
|
||||
if countUsersIn(t, db, "admin") != beforeAdmin {
|
||||
t.Fatal("PoC: a tenant-owned app federated a user into the admin org")
|
||||
}
|
||||
}
|
||||
|
||||
// The legitimate case still works: a platform app (admin-owned) serving a real
|
||||
// tenant federates fine — proving the guard refuses only the escalation, not the
|
||||
// happy path.
|
||||
func TestFederation_PlatformAppLegitimateOrgAllowed(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}}) // Owner=admin, Org=hanzo
|
||||
m := newMockOIDC(t, fedGoogleCID)
|
||||
seedOIDCProvider(t, db, "webapp", m)
|
||||
runOIDCLogin(t, app, db, m, "webapp", nil)
|
||||
if u, _ := store.GetUserByConnector(tctx(), db, "hanzo", "google", m.sub); u == nil {
|
||||
t.Fatal("a legitimate platform-app federation must still provision a user")
|
||||
}
|
||||
}
|
||||
|
||||
// F2 — SSRF: an org-admin-writable IssuerUrl pointing at the cloud-metadata
|
||||
// endpoint must be refused at DIAL time (the guard is armed; no private-dial seam
|
||||
// here), so federation fails closed to the relying party and never fetches it.
|
||||
func TestFederation_SSRFPrivateIssuerRefused(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "webapp", redirectURIs: []string{testRedirect}})
|
||||
p := orm.New[schema.Provider](db)
|
||||
p.Owner, p.Name = "admin", fedProvGoogle
|
||||
p.Category, p.Type = "OAuth", "Google"
|
||||
p.ClientId, p.ClientSecret = "cid", "secret"
|
||||
p.IssuerUrl = "https://169.254.169.254" // link-local cloud metadata over TLS
|
||||
p.SetId("admin/" + fedProvGoogle)
|
||||
if err := p.CreateCtx(tctx()); err != nil {
|
||||
t.Fatalf("seed provider: %v", err)
|
||||
}
|
||||
linkProvider(t, db, "webapp", fedProvGoogle)
|
||||
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathAuthorize+"?"+federationAuthorizeQuery("webapp").Encode()))
|
||||
if resp.StatusCode != 302 {
|
||||
t.Fatalf("want 302, got %d", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, testRedirect) || mustQuery(t, loc).Get("error") == "" {
|
||||
t.Fatalf("SSRF to metadata must fail closed to the RP with an error: %q", loc)
|
||||
}
|
||||
if strings.Contains(loc, "169.254") {
|
||||
t.Fatal("must not redirect the browser to the metadata endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func tokenBody(tok map[string]any) string {
|
||||
b, _ := json.Marshal(tok)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// PathUnlink removes a federated link from an account: POST /v1/iam/unlink. It is
|
||||
// the inverse of the linkOrProvision law (federation.go) — and the account is only
|
||||
// ever LEFT unlinked, never re-linked here, so re-linking still runs the full
|
||||
// verified-subject / verified-email law.
|
||||
const PathUnlink = "/v1/iam/unlink"
|
||||
|
||||
// routeUnlink registers POST /v1/iam/unlink on the PUBLIC group. It is not
|
||||
// anonymous — it SELF-AUTHENTICATES through callerOf (session cookie, else a
|
||||
// verified bearer), exactly as get-account and userinfo do, because an oidc
|
||||
// handler cannot import authz (authz imports oidc). A caller callerOf cannot
|
||||
// resolve is refused.
|
||||
func routeUnlink(r zip.Router, db orm.DB) {
|
||||
r.Post(PathUnlink, unlink(db))
|
||||
}
|
||||
|
||||
// unlinkForm is the request body, matching v1's shape.
|
||||
type unlinkForm struct {
|
||||
ProviderType string `json:"providerType"`
|
||||
User struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
// unlink clears one provider link from one account. Two principals may do it, and
|
||||
// only two: the account holder itself, and a SuperAdmin (a member of the reserved
|
||||
// admin org, the one predicate). An ORG ADMIN deliberately may NOT — unlinking is
|
||||
// not tenant administration, it is unpicking someone's own sign-in method, so the
|
||||
// generic org-admin rule is the wrong answer here.
|
||||
//
|
||||
// A holder unlinking itself must also be permitted by the application — the
|
||||
// provider link's CanUnlink flag — so an organization that mandates federated
|
||||
// sign-in cannot have its users strand themselves. A SuperAdmin is not bound by
|
||||
// that flag; it is the platform's own recovery path. Fail-closed throughout.
|
||||
func unlink(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var f unlinkForm
|
||||
if err := c.Bind(&f); err != nil {
|
||||
return httpx.Err(c, "invalid request body")
|
||||
}
|
||||
ctx := c.Context()
|
||||
caller, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "Please login first")
|
||||
}
|
||||
self := caller == f.User.Owner && name == f.User.Name
|
||||
super := store.IsSuperAdmin(caller)
|
||||
if !self && !super {
|
||||
return httpx.Err(c, "you are not permitted to unlink another user's account")
|
||||
}
|
||||
|
||||
u, err := store.GetUserByName(ctx, db, f.User.Owner, f.User.Name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if u == nil {
|
||||
return httpx.Err(c, "the user does not exist")
|
||||
}
|
||||
|
||||
// Read/write the link through the ONE connector registry (federation.go),
|
||||
// never by reflecting the provider type onto a Go field name — the exact
|
||||
// class of bug that made v1's GitLab unlink silently no-op (the type
|
||||
// "GitLab" vs the column `Gitlab`).
|
||||
b, known := connectorFor(f.ProviderType)
|
||||
if !known {
|
||||
return httpx.Err(c, "the provider type "+f.ProviderType+" can't be unlinked")
|
||||
}
|
||||
if *b.ref(u) == "" {
|
||||
return httpx.Err(c, "please link first")
|
||||
}
|
||||
if self && !super && !canUnlink(ctx, db, u, f.ProviderType) {
|
||||
return httpx.Err(c, "this provider can't be unlinked")
|
||||
}
|
||||
|
||||
*b.ref(u) = ""
|
||||
if err := saveUser(ctx, db, u); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// canUnlink reports whether the account's own sign-up application permits
|
||||
// unlinking this provider. An account whose application is gone, or which has no
|
||||
// link of that type declared, cannot self-unlink — the same fail-closed answer v1
|
||||
// gives.
|
||||
func canUnlink(ctx context.Context, db orm.DB, u *schema.User, providerType string) bool {
|
||||
app, err := store.GetApplicationByName(ctx, db, "admin", u.SignupApplication)
|
||||
if err != nil || app == nil {
|
||||
return false
|
||||
}
|
||||
store.EnrichProviders(ctx, db, app)
|
||||
for _, it := range app.Providers {
|
||||
if it != nil && it.Provider != nil && it.Provider.Type == providerType {
|
||||
return it.CanUnlink
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Unlink self-authenticates (session cookie / bearer) on the public OIDC surface,
|
||||
// so the harness only needs the one mounted group — the same one the confidential
|
||||
// flow mints the bearer from.
|
||||
func newUnlinkServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-unlink-test", DisableStartupMessage: true})
|
||||
Route(app.Group(""), db) // public: authorize/login/token AND the self-authenticating unlink
|
||||
return app, db
|
||||
}
|
||||
|
||||
// linkGitHub declares a GitHub provider on the "conf" app (CanUnlink toggled) and
|
||||
// stamps a GitHub subject onto the user, whose SignupApplication is that app.
|
||||
func linkGitHub(t *testing.T, db orm.DB, user, subject string, canUnlink bool) {
|
||||
t.Helper()
|
||||
pv := orm.New[schema.Provider](db)
|
||||
pv.Owner, pv.Name, pv.Category, pv.Type = "admin", "prov-github-unlink", "OAuth", "GitHub"
|
||||
pv.SetId("admin/prov-github-unlink")
|
||||
// Idempotent across sub-tests sharing a db is not needed (each test opens its own).
|
||||
if err := pv.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed provider: %v", err)
|
||||
}
|
||||
a, err := orm.Get[schema.Application](db, "admin/conf")
|
||||
if err != nil {
|
||||
t.Fatalf("load conf app: %v", err)
|
||||
}
|
||||
a.Providers = append(a.Providers, &schema.ProviderItem{Owner: "admin", Name: "prov-github-unlink", CanSignIn: true, CanUnlink: canUnlink})
|
||||
if err := a.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("link provider: %v", err)
|
||||
}
|
||||
u := userRow(t, db, user)
|
||||
u.GitHub = subject
|
||||
u.SignupApplication = "conf"
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("stamp connector: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func doUnlink(t *testing.T, app *zip.App, bearer, providerType, owner, name string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := jsonReq("POST", PathUnlink, map[string]any{
|
||||
"providerType": providerType,
|
||||
"user": map[string]string{"owner": owner, "name": name},
|
||||
})
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
resp, body := do(t, app, req)
|
||||
return resp.StatusCode, decode(t, body)
|
||||
}
|
||||
|
||||
// The account holder unlinks its own GitHub link when the app permits it; the
|
||||
// connector column is cleared.
|
||||
func TestUnlink_SelfClearsLinkWhenPermitted(t *testing.T) {
|
||||
app, db := newUnlinkServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
linkGitHub(t, db, "alice", "gh-alice", true)
|
||||
|
||||
access := accessTokenFor(t, app, "openid") // bearer for hanzo/alice
|
||||
if _, m := doUnlink(t, app, access, "GitHub", "hanzo", "alice"); m["status"] != "ok" {
|
||||
t.Fatalf("self-unlink failed: %v", m["msg"])
|
||||
}
|
||||
if got := userRow(t, db, "alice").GitHub; got != "" {
|
||||
t.Fatalf("self-unlink did not clear the connector, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A holder cannot unlink ANOTHER account (not self, not super), and the target's
|
||||
// link survives.
|
||||
func TestUnlink_CrossUserRefused(t *testing.T) {
|
||||
app, db := newUnlinkServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
|
||||
linkGitHub(t, db, "bob", "gh-bob", true)
|
||||
|
||||
access := accessTokenFor(t, app, "openid") // bearer for hanzo/alice
|
||||
status, m := doUnlink(t, app, access, "GitHub", "hanzo", "bob")
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("cross-user unlink must be refused, got %v (status %d)", m, status)
|
||||
}
|
||||
if got := userRow(t, db, "bob").GitHub; got != "gh-bob" {
|
||||
t.Fatalf("a non-owner removed bob's link: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When the application forbids unlinking (CanUnlink=false), a self-unlink is
|
||||
// refused — an org that mandates federated sign-in keeps its users linked.
|
||||
func TestUnlink_SelfRefusedWhenAppForbids(t *testing.T) {
|
||||
app, db := newUnlinkServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
linkGitHub(t, db, "alice", "gh-alice", false)
|
||||
|
||||
access := accessTokenFor(t, app, "openid")
|
||||
if _, m := doUnlink(t, app, access, "GitHub", "hanzo", "alice"); m["status"] != "error" {
|
||||
t.Fatalf("self-unlink must be refused when the app forbids it, got %v", m)
|
||||
}
|
||||
if got := userRow(t, db, "alice").GitHub; got != "gh-alice" {
|
||||
t.Fatalf("a forbidden unlink still cleared the connector: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An unauthenticated request is refused (the SDK envelope carries status:error on
|
||||
// a 200, the casibase contract) and clears nothing.
|
||||
func TestUnlink_RequiresAuthentication(t *testing.T) {
|
||||
app, db := newUnlinkServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
linkGitHub(t, db, "alice", "gh-alice", true)
|
||||
|
||||
req := jsonReq("POST", PathUnlink, map[string]any{"providerType": "GitHub", "user": map[string]string{"owner": "hanzo", "name": "alice"}})
|
||||
_, body := do(t, app, req) // no bearer, no session cookie
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("unlink without authentication must be refused, got %v", m)
|
||||
}
|
||||
if got := userRow(t, db, "alice").GitHub; got != "gh-alice" {
|
||||
t.Fatal("an unauthenticated request cleared the connector")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Front-door JSON endpoints the @hanzo/iam SDK + hanzo.id portal call: the login
|
||||
// UI descriptors (get-app-login, auth/methods), the account read (get-account),
|
||||
// account creation (signup), and OTP send (send-verification-code). Login itself
|
||||
// is routeLogin; the OIDC/OAuth surface is Route.
|
||||
const (
|
||||
PathGetAppLogin = "/v1/iam/get-app-login"
|
||||
PathAuthMethods = "/v1/iam/auth/methods"
|
||||
)
|
||||
|
||||
// routeFrontDoor registers the front-door endpoints the hosted hanzo.id portal
|
||||
// and the @hanzo/iam SDK call, on the PUBLIC group r. Each handler RESOLVES the
|
||||
// caller itself (callerOf: session cookie first, then bearer) and SELF-SCOPES to
|
||||
// that caller, so — like the rest of this group — they are reachable without a
|
||||
// Guard-verified bearer yet never act on anyone but the resolved caller.
|
||||
func routeFrontDoor(r zip.Router, db orm.DB) {
|
||||
r.Get(PathGetAppLogin, getAppLogin(db))
|
||||
r.Get(PathAuthMethods, authMethods(db))
|
||||
// get-account is anonymous-safe (returns {status:"error"} unauthenticated)
|
||||
// and a security contract — the gateway admin-guard reads its `owner`.
|
||||
r.Get(PathGetAccount, getAccount(db))
|
||||
// Account creation + email/phone OTP send. signup is JSON; send-verification-code
|
||||
// is multipart/form-data (HIP-0111 §4 invariant), read via fiber's FormValue.
|
||||
r.Post(PathSignup, signupHandler(db))
|
||||
r.Post(PathSendVerificationCode, sendVerificationCode(db))
|
||||
|
||||
// The session/identity front door the console drives once a user is signed in:
|
||||
// signin (the code→session exchange), whoami (lightweight identity), onboard
|
||||
// (first-run org creation + move), update-preferences (self, shallow-merge), and
|
||||
// linked-accounts (the caller's linked identities).
|
||||
r.Post(PathSignin, signinHandler(db))
|
||||
r.Get(PathWhoami, whoamiHandler(db))
|
||||
r.Post(PathOnboard, onboardHandler(db))
|
||||
r.Post(PathUpdatePreferences, updatePreferencesHandler(db))
|
||||
r.Get(PathLinkedAccounts, linkedAccountsHandler(db))
|
||||
}
|
||||
|
||||
// getAppLogin resolves an application by clientId and returns it with the
|
||||
// ClientSecret masked and each provider link enriched with its shared provider
|
||||
// record — the canonical source of truth the login UI reads to decide which
|
||||
// sign-in methods to render. Mirrors the v1 Casdoor get-app-login contract
|
||||
// (Response envelope, data = the masked application).
|
||||
func getAppLogin(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
if rt := c.Query("responseType"); rt != "" && rt != "code" {
|
||||
return httpx.Err(c, "response_type is required (must be code)")
|
||||
}
|
||||
clientId := c.Query("clientId")
|
||||
if clientId == "" {
|
||||
return httpx.Err(c, "clientId is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
store.EnrichProviders(c.Context(), db, app)
|
||||
return httpx.Ok(c, maskApp(app))
|
||||
}
|
||||
}
|
||||
|
||||
// authMethods reports the enabled sign-in methods for an application so the SDK
|
||||
// <Login> self-configures instead of hard-coding a provider list. This endpoint
|
||||
// does NOT exist in v1 — it is the clean seam that lets one <Login> render the
|
||||
// right buttons for any app. Pure read over the resolved application.
|
||||
func authMethods(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
clientId := c.Query("clientId")
|
||||
if clientId == "" {
|
||||
return httpx.Err(c, "clientId is required")
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(c.Context(), db, clientId)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
store.EnrichProviders(c.Context(), db, app)
|
||||
|
||||
oauth := []map[string]string{}
|
||||
web3 := false
|
||||
for _, it := range app.Providers {
|
||||
if it == nil || it.Provider == nil || !it.CanSignIn {
|
||||
continue
|
||||
}
|
||||
if !isConfigured(it.Provider) {
|
||||
continue // hidden until real creds land — never a dead-end button
|
||||
}
|
||||
switch strings.ToLower(it.Provider.Category) {
|
||||
case "web3":
|
||||
web3 = true
|
||||
case "oauth":
|
||||
oauth = append(oauth, map[string]string{
|
||||
"name": it.Name,
|
||||
"type": it.Provider.Type,
|
||||
"logo": it.Provider.CustomLogo,
|
||||
})
|
||||
}
|
||||
}
|
||||
return httpx.Ok(c, map[string]any{
|
||||
"password": app.EnablePassword,
|
||||
"code": app.EnableCodeSignin,
|
||||
"webauthn": app.EnableWebAuthn,
|
||||
"web3": web3,
|
||||
"oauth": oauth,
|
||||
"signup": app.EnableSignUp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isConfigured reports whether a provider holds a real (non-placeholder)
|
||||
// credential — the guard that keeps an unconfigured provider's button hidden so
|
||||
// it never dead-ends the OAuth redirect.
|
||||
func isConfigured(p *schema.Provider) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
// Web3 is native challenge/response — no OAuth client to configure.
|
||||
if strings.EqualFold(p.Category, "Web3") {
|
||||
return true
|
||||
}
|
||||
id := strings.ToLower(strings.TrimSpace(p.ClientId))
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(id, "placeholder") &&
|
||||
!strings.HasPrefix(id, "your-") &&
|
||||
!strings.HasPrefix(id, "xxx") &&
|
||||
!strings.Contains(id, "change")
|
||||
}
|
||||
|
||||
// maskApp returns a copy-safe view of the application with the client secret and
|
||||
// every provider's secret removed — get-app-login is called by the browser, so
|
||||
// no secret may cross it.
|
||||
func maskApp(app *schema.Application) *schema.Application {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
masked := *app
|
||||
masked.ClientSecret = ""
|
||||
for _, it := range masked.Providers {
|
||||
if it != nil && it.Provider != nil {
|
||||
p := *it.Provider
|
||||
p.ClientSecret = ""
|
||||
p.ClientSecret2 = ""
|
||||
it.Provider = &p
|
||||
}
|
||||
}
|
||||
return &masked
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// sessionCookieFor drives a bare (type=login) portal sign-in for hanzo/alice and
|
||||
// returns the "name=value" of the session cookie it set — the credential the
|
||||
// front-door session routes resolve the caller from.
|
||||
func sessionCookieFor(t *testing.T, app *zip.App) string {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"organization": {"hanzo"}, "application": {"conf"},
|
||||
"username": {"alice"}, "password": {"pw"}, "type": {"login"},
|
||||
}
|
||||
resp, body := do(t, app, formReq("POST", PathLogin, form))
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("login failed: %s", body)
|
||||
}
|
||||
return cookieKV(resp.Header.Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
// signin exchanges an authorization code for a session and returns the caller's
|
||||
// redacted account — the same envelope get-account returns, so the console's
|
||||
// post<Account>('iam/signin') resolves the signed-in user in one call.
|
||||
func TestSignin_CodeExchangeSetsSessionAndReturnsAccount(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "application": "conf", "clientId": "conf",
|
||||
"username": "alice", "password": "pw",
|
||||
})
|
||||
if code == "" {
|
||||
t.Fatal("login (type=code) minted no code")
|
||||
}
|
||||
|
||||
resp, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code))
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("signin status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if env["sub"] != "hanzo/alice" || env["name"] != "alice" {
|
||||
t.Errorf("signin sub/name = %v/%v, want hanzo/alice / alice", env["sub"], env["name"])
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
if data["owner"] != "hanzo" {
|
||||
t.Errorf("signin data.owner = %v, want hanzo", data["owner"])
|
||||
}
|
||||
if v, ok := data["passwordHash"]; ok && v != "" {
|
||||
t.Errorf("signin leaked passwordHash")
|
||||
}
|
||||
// It establishes the durable session get-account resolves from.
|
||||
cookie := resp.Header.Get("Set-Cookie")
|
||||
if !strings.HasPrefix(cookie, "hanzo_session=") {
|
||||
t.Fatalf("signin did not set the session cookie: %q", cookie)
|
||||
}
|
||||
req := formReqNoBody("GET", PathGetAccount)
|
||||
req.Header.Set("Cookie", cookieKV(cookie))
|
||||
resp2, body2 := do(t, app, req)
|
||||
if resp2.StatusCode != 200 || decode(t, body2)["status"] != "ok" {
|
||||
t.Fatalf("get-account via the signin cookie failed: %s", body2)
|
||||
}
|
||||
}
|
||||
|
||||
// The code is single-use: a replay after redemption is refused (no second session).
|
||||
func TestSignin_ReplayedCodeRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
code, _, _ := loginForCode(t, app, map[string]string{
|
||||
"organization": "hanzo", "application": "conf", "clientId": "conf",
|
||||
"username": "alice", "password": "pw",
|
||||
})
|
||||
if _, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code)); decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("first signin should succeed: %s", body)
|
||||
}
|
||||
_, body := do(t, app, formReqNoBody("POST", PathSignin+"?code="+code))
|
||||
if decode(t, body)["status"] != "error" {
|
||||
t.Fatalf("replayed code must be refused, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// whoami resolves the caller from the session cookie and returns the lightweight
|
||||
// identity; an anonymous caller gets {status:"error"}, never a leak.
|
||||
func TestWhoami_CookieResolvesIdentity(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := formReqNoBody("GET", PathWhoami)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["status"] != "ok" || env["sub"] != "hanzo/alice" {
|
||||
t.Fatalf("whoami via cookie: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
if data["owner"] != "hanzo" || data["name"] != "alice" || data["id"] != "hanzo/alice" {
|
||||
t.Errorf("whoami identity = %v, want owner/name/id = hanzo/alice/hanzo/alice", data)
|
||||
}
|
||||
|
||||
// Anonymous → error, no data.
|
||||
_, anon := do(t, app, formReqNoBody("GET", PathWhoami))
|
||||
if e := decode(t, anon); e["status"] != "error" || e["data"] != nil {
|
||||
t.Fatalf("anonymous whoami must be error with no data: %s", anon)
|
||||
}
|
||||
}
|
||||
|
||||
// update-preferences shallow-merges: a second patch adds a key without clobbering
|
||||
// the first, and the merged object is returned + persisted on the caller's row.
|
||||
func TestUpdatePreferences_ShallowMergeRoundTrip(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
post := func(patch any) map[string]any {
|
||||
req := jsonReq("POST", PathUpdatePreferences, patch)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("update-preferences: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
data, _ := env["data"].(map[string]any)
|
||||
return data
|
||||
}
|
||||
|
||||
if got := post(map[string]any{"onboarding_completed": true}); got["onboarding_completed"] != true {
|
||||
t.Fatalf("first patch not reflected: %v", got)
|
||||
}
|
||||
got := post(map[string]any{"theme": "dark"})
|
||||
if got["onboarding_completed"] != true || got["theme"] != "dark" {
|
||||
t.Fatalf("second patch clobbered the first (want both keys): %v", got)
|
||||
}
|
||||
|
||||
// Persisted on the row — a fresh read shows the merged blob under hanzo.preferences.
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if u == nil || !strings.Contains(u.Properties[preferencesKey], "onboarding_completed") ||
|
||||
!strings.Contains(u.Properties[preferencesKey], "theme") {
|
||||
t.Fatalf("preferences not persisted: %+v", u.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
// onboard creates the named org and MOVES the caller into it as admin (their owner
|
||||
// becomes the new slug); the console reads {org:<slug>} and re-authenticates.
|
||||
func TestOnboard_CreatesOrgAndMovesCaller(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := jsonReq("POST", PathOnboard, map[string]any{"name": "Acme Inc"})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
env := decode(t, body)
|
||||
if resp.StatusCode != 200 || env["org"] != "acme-inc" {
|
||||
t.Fatalf("onboard status=%d body=%s (want {org:acme-inc})", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if org, _ := store.GetOrganizationByName(ctx, db, "acme-inc"); org == nil || org.Owner != "admin" {
|
||||
t.Fatalf("onboard did not create the acme-inc org: %+v", org)
|
||||
}
|
||||
// alice moved out of hanzo and into acme-inc as admin.
|
||||
if old, _ := store.GetUserByName(ctx, db, "hanzo", "alice"); old != nil {
|
||||
t.Errorf("alice was not moved out of hanzo")
|
||||
}
|
||||
moved, _ := store.GetUserByName(ctx, db, "acme-inc", "alice")
|
||||
if moved == nil || !moved.IsAdmin {
|
||||
t.Fatalf("alice not moved into acme-inc as admin: %+v", moved)
|
||||
}
|
||||
}
|
||||
|
||||
// The one-click personal path creates a `<username>` org.
|
||||
func TestOnboard_PersonalOrg(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := jsonReq("POST", PathOnboard, map[string]any{"personal": true})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
if env := decode(t, body); resp.StatusCode != 200 || env["org"] != "alice" {
|
||||
t.Fatalf("personal onboard status=%d body=%s (want {org:alice})", resp.StatusCode, body)
|
||||
}
|
||||
if org, _ := store.GetOrganizationByName(context.Background(), db, "alice"); org == nil || !org.IsPersonal {
|
||||
t.Fatalf("personal org not created/flagged: %+v", org)
|
||||
}
|
||||
}
|
||||
|
||||
// A reserved slug (an IAM system owner) is refused — a customer can never become admin.
|
||||
func TestOnboard_ReservedRefused(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := jsonReq("POST", PathOnboard, map[string]any{"name": "admin"})
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode == 200 || !strings.Contains(string(body), "reserved") {
|
||||
t.Fatalf("reserved org must be refused: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// linked-accounts returns the caller's non-empty connector columns and nothing else.
|
||||
func TestLinkedAccounts_ListsConnectorColumns(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
// Link a GitHub identity to alice.
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
u.GitHub = "octocat"
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("link github: %v", err)
|
||||
}
|
||||
cookie := sessionCookieFor(t, app)
|
||||
|
||||
req := formReqNoBody("GET", PathLinkedAccounts)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), "github") || !strings.Contains(string(body), "octocat") {
|
||||
t.Fatalf("linked-accounts: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// linkedAccountsOf reflects only the connector columns — never Owner/Name/Email.
|
||||
func TestLinkedAccountsOf_OnlyConnectors(t *testing.T) {
|
||||
u := &schema.User{Owner: "hanzo", Name: "alice", Email: "a@x.io", GitHub: "octocat", Google: "g-1"}
|
||||
got := linkedAccountsOf(u)
|
||||
seen := map[string]string{}
|
||||
for _, la := range got {
|
||||
seen[la.Provider] = la.Subject
|
||||
}
|
||||
if seen["github"] != "octocat" || seen["google"] != "g-1" {
|
||||
t.Fatalf("missing a linked connector: %v", got)
|
||||
}
|
||||
for _, forbidden := range []string{"owner", "name", "email"} {
|
||||
if _, bad := seen[forbidden]; bad {
|
||||
t.Errorf("linkedAccountsOf leaked a non-connector field %q", forbidden)
|
||||
}
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("want exactly 2 linked accounts, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/sessions"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// PathGetAccount is the native front-door account endpoint — what the hanzo.id
|
||||
// portal's account page and the gateway admin-guard call.
|
||||
//
|
||||
// SECURITY CONTRACT. The gateway admin-guard derives the global-admin
|
||||
// (SuperAdmin) predicate from the `owner` this returns — a caller is a global
|
||||
// admin iff `data.owner == AdminOrg` (gateway/cmd/admin-guard). So the response
|
||||
// shape MUST match v1 exactly — {status, sub, name, data:<user>, data2:<org>} —
|
||||
// and every secret (password hash, access secret, TOTP, recovery codes) MUST be
|
||||
// redacted. Anonymous callers get {status:"error"} (200, casibase convention),
|
||||
// never a leak: the admin-guard reads status=="error" → not-admin, fail-closed.
|
||||
const PathGetAccount = "/v1/iam/get-account"
|
||||
|
||||
// accountResponse mirrors v1's Response for get-account (the casibase envelope).
|
||||
type accountResponse struct {
|
||||
Status string `json:"status"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Data2 any `json:"data2,omitempty"`
|
||||
}
|
||||
|
||||
// getAccount resolves the signed-in caller and returns their REDACTED account +
|
||||
// organization. Resolution (callerOf) is by session cookie first — the portal +
|
||||
// gateway-admin-guard path — then bearer access token — the API path.
|
||||
func getAccount(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return c.JSON(200, accountResponse{Status: "error", Msg: "please sign in first"})
|
||||
}
|
||||
env, status := accountEnvelopeFor(ctx, db, owner, name)
|
||||
return c.JSON(status, env)
|
||||
}
|
||||
}
|
||||
|
||||
// accountEnvelopeFor builds the get-account envelope for an already-resolved
|
||||
// caller: the REDACTED user + organization in the casibase shape, or an error
|
||||
// envelope when the user or org lookup fails. It is the ONE place the account
|
||||
// envelope is assembled, shared by get-account and signin (the code→session
|
||||
// exchange), so the two can never drift. status is the HTTP code to return with
|
||||
// it (200 for both ok and the casibase "error" convention, 500 on a store fault).
|
||||
func accountEnvelopeFor(ctx context.Context, db orm.DB, owner, name string) (accountResponse, int) {
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return accountResponse{Status: "error", Msg: "server_error"}, 500
|
||||
}
|
||||
if user == nil {
|
||||
return accountResponse{Status: "error", Msg: "the user does not exist"}, 200
|
||||
}
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return accountResponse{Status: "error", Msg: "server_error"}, 500
|
||||
}
|
||||
return accountResponse{
|
||||
Status: "ok",
|
||||
Sub: owner + "/" + name,
|
||||
Name: user.Name,
|
||||
Data: user.Mask(), // owner + isAdmin survive; every secret stripped
|
||||
Data2: org.Mask(), // org master/default passwords masked
|
||||
}, 200
|
||||
}
|
||||
|
||||
// callerOf resolves the signed-in principal by SESSION COOKIE first (the portal
|
||||
// and gateway-admin-guard path) then bearer access token (the API path) — two
|
||||
// credentials, one identity. ok=false means no valid session or token.
|
||||
func callerOf(ctx context.Context, c *zip.Ctx, db orm.DB) (owner, name string, ok bool) {
|
||||
if o, n, ok := sessions.Resolve(ctx, c.Fiber(), db); ok {
|
||||
return o, n, true
|
||||
}
|
||||
bearer := httpx.Bearer(c)
|
||||
if bearer == "" {
|
||||
return "", "", false
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, bearer)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
o, n := splitSub(claims.Subject)
|
||||
return o, n, true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// getAccountReq drives GET /v1/iam/get-account with an optional bearer,
|
||||
// returning the status code and decoded envelope.
|
||||
func getAccountReq(t *testing.T, app *zip.App, bearer string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := formReqNoBody("GET", PathGetAccount)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, body := do(t, app, req)
|
||||
return resp.StatusCode, decode(t, body)
|
||||
}
|
||||
|
||||
// The bearer path resolves the caller and returns a REDACTED account whose
|
||||
// `owner` (the admin-guard's SuperAdmin input) is correct and whose secrets are
|
||||
// stripped — the security contract, end to end through the real router.
|
||||
func TestGetAccount_BearerReturnsRedactedAccount(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
access := accessTokenFor(t, app, "openid profile email")
|
||||
status, env := getAccountReq(t, app, access)
|
||||
if status != 200 || env["status"] != "ok" {
|
||||
t.Fatalf("status=%d env=%v, want 200 ok", status, env)
|
||||
}
|
||||
if env["sub"] != "hanzo/alice" || env["name"] != "alice" {
|
||||
t.Errorf("sub/name = %v/%v, want hanzo/alice / alice", env["sub"], env["name"])
|
||||
}
|
||||
data, ok := env["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data is not an object: %v", env["data"])
|
||||
}
|
||||
// The admin-guard reads data.owner — it MUST be present and correct.
|
||||
if data["owner"] != "hanzo" {
|
||||
t.Errorf("data.owner = %v, want hanzo (the admin-guard SuperAdmin input)", data["owner"])
|
||||
}
|
||||
// Every secret MUST be stripped — a leak here hands out password hashes.
|
||||
for _, secret := range []string{"passwordHash", "passwordSalt", "accessSecret", "accessSecretHash", "totpSecret", "accessToken"} {
|
||||
if v, present := data[secret]; present && v != "" {
|
||||
t.Errorf("get-account leaked %q = %v", secret, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous callers get {status:"error"} (200, casibase convention) — never a
|
||||
// leak and never a 5xx. The admin-guard reads status=="error" → not-admin,
|
||||
// fail-closed. Same for an invalid bearer.
|
||||
func TestGetAccount_AnonymousIsErrorNotLeak(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}})
|
||||
seedRichUser(t, db)
|
||||
|
||||
for name, bearer := range map[string]string{"no bearer": "", "garbage bearer": "not-a-real-token"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
status, env := getAccountReq(t, app, bearer)
|
||||
if status != 200 || env["status"] != "error" {
|
||||
t.Fatalf("status=%d env=%v, want 200 error", status, env)
|
||||
}
|
||||
if _, leaked := env["data"]; leaked {
|
||||
t.Errorf("anonymous get-account must carry no data, got %v", env["data"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Redact keeps the admin-guard fields (owner, isAdmin) while stripping every
|
||||
// secret — the invariant get-account relies on. Unit-level, no db/login flow.
|
||||
func TestUserMask_KeepsAdminFieldsStripsSecrets(t *testing.T) {
|
||||
u := &schema.User{
|
||||
Owner: "admin",
|
||||
Name: "root",
|
||||
IsAdmin: true,
|
||||
PasswordHash: "$argon2id$v=19$…",
|
||||
PasswordSalt: "salt",
|
||||
AccessSecret: "sk_live_abc",
|
||||
TotpSecret: "JBSWY3DPEHPK3PXP",
|
||||
}
|
||||
got := u.Mask()
|
||||
if got.Owner != "admin" || !got.IsAdmin {
|
||||
t.Errorf("Mask dropped an admin-guard field: owner=%q isAdmin=%v", got.Owner, got.IsAdmin)
|
||||
}
|
||||
if got.PasswordHash != "" || got.PasswordSalt != "" || got.AccessSecret != "" || got.TotpSecret != "" {
|
||||
t.Errorf("Mask left a secret: %+v", got)
|
||||
}
|
||||
// Mask returns a COPY — the login-verify path must still see the live hash on
|
||||
// the original row, so masking a response can never blank it.
|
||||
if u.PasswordHash == "" {
|
||||
t.Errorf("Mask must NOT mutate the receiver, but the original's PasswordHash was cleared")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// HTTP-level test harness: mount the whole OIDC surface on a fresh store and
|
||||
// drive it through the real router (app.Fiber().Test), so every test exercises
|
||||
// the wire contract a client sees — status codes, headers, redirects, bodies.
|
||||
|
||||
// sharedKey is one RSA key reused across tests (keygen is the slow part; the
|
||||
// crypto under test is identical regardless of which key it is).
|
||||
var (
|
||||
sharedKeyOnce sync.Once
|
||||
sharedKeyVal *rsa.PrivateKey
|
||||
)
|
||||
|
||||
func sharedKey(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
sharedKeyOnce.Do(func() {
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sharedKeyVal = k
|
||||
})
|
||||
return sharedKeyVal
|
||||
}
|
||||
|
||||
// appOpts configures a seeded OAuth application.
|
||||
type appOpts struct {
|
||||
clientID string
|
||||
secret string // "" → public (PKCE) client
|
||||
redirectURIs []string
|
||||
refreshHours float64
|
||||
shared bool // IsShared → accepts users from any org
|
||||
signup bool // EnableSignUp → the app allows new-account creation
|
||||
grants []string // declared OAuth grants; a grant absent here is refused
|
||||
}
|
||||
|
||||
// tctx is the background context used by the test seed helpers.
|
||||
func tctx() context.Context { return context.Background() }
|
||||
|
||||
// newServer mounts the full OIDC surface on a fresh SQLite store.
|
||||
func newServer(t *testing.T) (*zip.App, orm.DB) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
|
||||
// The whole OIDC surface is the pre-authentication PUBLIC group; a root
|
||||
// (empty-prefix) router registers it at its absolute paths, no Guard.
|
||||
Route(app.Group(""), db)
|
||||
return app, db
|
||||
}
|
||||
|
||||
// seedRSACert creates a named RS256 signing cert holding the shared key.
|
||||
func seedRSACert(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "admin"
|
||||
c.Name = name
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId("admin/" + name)
|
||||
if err := c.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedApp creates an application (org "hanzo") with the given options and a
|
||||
// shared RS256 cert.
|
||||
func seedApp(t *testing.T, db orm.DB, o appOpts) *schema.Application {
|
||||
t.Helper()
|
||||
seedRSACert(t, db, "cert-"+o.clientID)
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner = "admin"
|
||||
a.Name = o.clientID
|
||||
a.ClientId = o.clientID
|
||||
a.ClientSecret = o.secret
|
||||
a.Organization = "hanzo"
|
||||
a.Cert = "cert-" + o.clientID
|
||||
a.EnablePassword = true
|
||||
a.EnableSignUp = o.signup
|
||||
a.ExpireInHours = 1
|
||||
a.RefreshExpireInHours = o.refreshHours
|
||||
a.RedirectUris = o.redirectURIs
|
||||
a.IsShared = o.shared
|
||||
a.GrantTypes = o.grants
|
||||
a.SetId("admin/" + o.clientID)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed app: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// --- HTTP helpers ---
|
||||
|
||||
func formReq(method, path string, form url.Values) *http.Request {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Host = "hanzo.id"
|
||||
return req
|
||||
}
|
||||
|
||||
func formReqNoBody(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Host = "hanzo.id"
|
||||
return req
|
||||
}
|
||||
|
||||
func jsonReq(method, path string, body any) *http.Request {
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Host = "hanzo.id"
|
||||
return req
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *zip.App, req *http.Request) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("test request %s %s: %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return resp, body
|
||||
}
|
||||
|
||||
func decode(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("decode json %q: %v", string(body), err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// loginForCode drives POST /v1/iam/login (type=code) and returns the minted
|
||||
// authorization code from the Response envelope.
|
||||
func loginForCode(t *testing.T, app *zip.App, f map[string]string) (string, *http.Response, []byte) {
|
||||
t.Helper()
|
||||
f["type"] = "code"
|
||||
resp, body := do(t, app, jsonReq("POST", PathLogin, f))
|
||||
m := decode(t, body)
|
||||
code, _ := m["data"].(string)
|
||||
return code, resp, body
|
||||
}
|
||||
|
||||
// exchangeCode drives POST /v1/iam/oauth/token for the authorization_code grant.
|
||||
func exchangeCode(t *testing.T, app *zip.App, form url.Values) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
form.Set("grant_type", "authorization_code")
|
||||
resp, body := do(t, app, formReq("POST", PathToken, form))
|
||||
return resp, decode(t, body)
|
||||
}
|
||||
@@ -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,162 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// RFC 7662 Token Introspection + RFC 7009 Token Revocation — the two standard
|
||||
// token-management endpoints a resource server / confidential client uses. Both
|
||||
// are POST, client-authenticated (client_secret_basic or _post, constant-time),
|
||||
// on the OAuth token surface. Introspection reports whether a token is currently
|
||||
// active — JWT-valid AND its grant row still exists, so a REVOKED token reads
|
||||
// inactive — and returns its claims when active. Revocation deletes the grant
|
||||
// row (the whole refresh-rotation family for a refresh token) and always answers
|
||||
// 200 (RFC 7009 §2.2: an invalid/unknown token is not an error, so the endpoint
|
||||
// is no token-existence oracle).
|
||||
const (
|
||||
PathIntrospect = "/v1/iam/oauth/introspect"
|
||||
PathRevoke = "/v1/iam/oauth/revoke"
|
||||
)
|
||||
|
||||
// routeIntrospectRevoke registers the introspection + revocation endpoints on the
|
||||
// PUBLIC group r (client-authenticated, not Bearer-gated).
|
||||
func routeIntrospectRevoke(r zip.Router, db orm.DB) {
|
||||
r.Post(PathIntrospect, introspectHandler(db))
|
||||
r.Post(PathRevoke, revokeHandler(db))
|
||||
}
|
||||
|
||||
// authConfidentialClient authenticates the calling client and requires it to be
|
||||
// CONFIDENTIAL (holds a verified secret). Introspection and revocation are
|
||||
// privileged token-management operations; a public client may not call them.
|
||||
// Constant-time secret compare; a nil app or empty stored secret fails closed.
|
||||
func authConfidentialClient(ctx context.Context, db orm.DB, c *zip.Ctx) (name string, ok bool) {
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return "", false
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil || app == nil || app.ClientSecret == "" {
|
||||
return "", false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return app.Name, true
|
||||
}
|
||||
|
||||
// introspectHandler implements RFC 7662. Active iff the grant row still exists
|
||||
// (revocation-aware, the same liveness check userinfo makes) AND the JWT verifies
|
||||
// under the trusted keys. The response carries the standard introspection claims;
|
||||
// an inactive/absent/unauthenticated-target token returns only `{active:false}`.
|
||||
func introspectHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
if _, ok := authConfidentialClient(ctx, db, c); !ok {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
|
||||
tokenStr := param(c, "token")
|
||||
if tokenStr == "" {
|
||||
return c.JSON(200, inactiveToken())
|
||||
}
|
||||
h := hashToken(tokenStr)
|
||||
// Liveness: the grant row must still exist (a revoked/rotated token has none).
|
||||
row, _ := store.GetTokenByAccessTokenHash(ctx, db, h)
|
||||
if row == nil {
|
||||
row, _ = store.GetTokenByRefreshHash(ctx, db, h)
|
||||
}
|
||||
if row == nil {
|
||||
return c.JSON(200, inactiveToken())
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, tokenStr)
|
||||
if err != nil {
|
||||
return c.JSON(200, inactiveToken())
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"active": true,
|
||||
"token_type": "Bearer",
|
||||
"scope": claims.Scope,
|
||||
"client_id": claims.Azp,
|
||||
"sub": claims.Subject,
|
||||
"iss": claims.Issuer,
|
||||
"owner": claims.Owner,
|
||||
}
|
||||
if claims.Organization != "" {
|
||||
resp["organization"] = claims.Organization
|
||||
}
|
||||
if claims.Email != "" {
|
||||
resp["username"] = claims.Email
|
||||
}
|
||||
if len(claims.Audience) > 0 {
|
||||
resp["aud"] = claims.Audience
|
||||
}
|
||||
if claims.ExpiresAt != nil {
|
||||
resp["exp"] = claims.ExpiresAt.Unix()
|
||||
}
|
||||
if claims.IssuedAt != nil {
|
||||
resp["iat"] = claims.IssuedAt.Unix()
|
||||
}
|
||||
if claims.NotBefore != nil {
|
||||
resp["nbf"] = claims.NotBefore.Unix()
|
||||
}
|
||||
if claims.ID != "" {
|
||||
resp["jti"] = claims.ID
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// revokeHandler implements RFC 7009. A confidential client revokes a token that
|
||||
// was issued to IT (§2.1) — an access token deletes that grant row; a refresh
|
||||
// token revokes the whole rotation family so no further access tokens can be
|
||||
// minted and every sibling dies. A token belonging to another client, or an
|
||||
// unknown token, is a silent 200 (no revocation, no oracle — §2.2).
|
||||
func revokeHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
setTokenCacheHeaders(c)
|
||||
ctx := c.Context()
|
||||
clientName, ok := authConfidentialClient(ctx, db, c)
|
||||
if !ok {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
|
||||
tokenStr := param(c, "token")
|
||||
if tokenStr == "" {
|
||||
return revoked(c)
|
||||
}
|
||||
h := hashToken(tokenStr)
|
||||
|
||||
if row, _ := store.GetTokenByAccessTokenHash(ctx, db, h); row != nil {
|
||||
if row.Application == clientName {
|
||||
_ = store.DeleteToken(ctx, db, row)
|
||||
}
|
||||
return revoked(c)
|
||||
}
|
||||
if row, _ := store.GetTokenByRefreshHash(ctx, db, h); row != nil {
|
||||
if row.Application == clientName {
|
||||
family, _ := store.ListTokensByRefreshFamily(ctx, db, row.RefreshFamily)
|
||||
for _, t := range family {
|
||||
_ = store.DeleteToken(ctx, db, t)
|
||||
}
|
||||
}
|
||||
return revoked(c)
|
||||
}
|
||||
return revoked(c)
|
||||
}
|
||||
}
|
||||
|
||||
// inactiveToken is the RFC 7662 response for a token that is not active.
|
||||
func inactiveToken() map[string]any { return map[string]any{"active": false} }
|
||||
|
||||
// revoked is the RFC 7009 §2.2 success response: HTTP 200, empty body.
|
||||
func revoked(c *zip.Ctx) error { return c.Status(200).JSON(200, struct{}{}) }
|
||||
@@ -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,189 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// The per-host OIDC issuer resolver.
|
||||
//
|
||||
// iam2 runs as ONE multi-tenant instance behind the ingress for every brand host
|
||||
// — hanzo.id, lux.id, id.zoo.network, pars.id, and their iam.* aliases. Each
|
||||
// brand must emit its OWN issuer so a relying party that discovered via lux.id
|
||||
// validates lux.id-issued tokens (`iss` is the boundary an RP pins). A single
|
||||
// pinned IAM_ISSUER cannot serve that: every non-matching brand's tokens would
|
||||
// carry the wrong `iss` and fail RP validation.
|
||||
//
|
||||
// This resolver is the ONE seam every issuer read routes through — the token
|
||||
// `iss` claim, the discovery document `issuer` (and the `jwks_uri` derived from
|
||||
// it), and the federation callback origin. The issuer it returns is ALWAYS a
|
||||
// trusted CONFIG value (a map entry or the default), NEVER a string interpolated
|
||||
// from the request, so a client-supplied Host/X-Forwarded-Host can at most SELECT
|
||||
// an already-configured brand's issuer — never inject an arbitrary or foreign one.
|
||||
// That is the "header-immune issuer" property, preserved and generalized to N
|
||||
// brands.
|
||||
|
||||
// issuerResolver maps a brand host to the OIDC issuer iam2 emits for it. It is
|
||||
// built ONCE from config (IAM_ISSUER + IAM_ISSUER_MAP) and is immutable
|
||||
// thereafter — a value, not a place, so every request goroutine reads it without
|
||||
// a lock and no request can mutate it.
|
||||
type issuerResolver struct {
|
||||
def string // default issuer (IAM_ISSUER), normalized; "" only in pure-dev
|
||||
byHost map[string]string // normalized brand host -> normalized pinned issuer
|
||||
}
|
||||
|
||||
// newIssuerResolver builds a resolver from the default issuer and the JSON
|
||||
// host→issuer map. Both inputs are trusted CONFIG (env / flags), never request
|
||||
// data.
|
||||
//
|
||||
// Fail-closed by construction:
|
||||
//
|
||||
// - An empty mapJSON yields a resolver that returns def for every host —
|
||||
// EXACTLY the single-issuer behavior that predates the map (backward
|
||||
// compatible; zero behavior change when IAM_ISSUER_MAP is unset).
|
||||
// - A non-empty map REQUIRES a non-empty default. A map without a default would
|
||||
// let an unknown host fall through to a host-relative issuer (fail-open); we
|
||||
// refuse that configuration at startup instead, so an unknown/spoofed Host in
|
||||
// map mode ALWAYS lands on the pinned default, never an echoed host.
|
||||
// - A malformed map, or an entry whose host or issuer is empty or whose issuer
|
||||
// is not an absolute https URL, is a hard error. A misconfigured issuer map
|
||||
// must fail the boot LOUD, never silently mint tokens under the wrong `iss`.
|
||||
func newIssuerResolver(defaultIssuer, mapJSON string) (*issuerResolver, error) {
|
||||
r := &issuerResolver{def: normalizeIssuer(defaultIssuer)}
|
||||
mapJSON = strings.TrimSpace(mapJSON)
|
||||
if mapJSON == "" {
|
||||
return r, nil
|
||||
}
|
||||
var raw map[string]string
|
||||
if err := json.Unmarshal([]byte(mapJSON), &raw); err != nil {
|
||||
return nil, fmt.Errorf("IAM_ISSUER_MAP: invalid JSON: %w", err)
|
||||
}
|
||||
if len(raw) > 0 && r.def == "" {
|
||||
return nil, fmt.Errorf("IAM_ISSUER_MAP is set but IAM_ISSUER (the fail-closed default) is empty: " +
|
||||
"an unknown host would have no pinned issuer to fall back to")
|
||||
}
|
||||
r.byHost = make(map[string]string, len(raw))
|
||||
for host, iss := range raw {
|
||||
h := normalizeHost(host)
|
||||
v := normalizeIssuer(iss)
|
||||
if h == "" || v == "" {
|
||||
return nil, fmt.Errorf("IAM_ISSUER_MAP: empty host or issuer in entry %q:%q", host, iss)
|
||||
}
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
return nil, fmt.Errorf("IAM_ISSUER_MAP: issuer for host %q must be an absolute https URL, got %q", h, v)
|
||||
}
|
||||
r.byHost[h] = v
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// issuerFor returns the pinned issuer for host. Resolution order — every branch
|
||||
// yields a trusted config value except the last, which is reachable only with NO
|
||||
// config at all:
|
||||
//
|
||||
// 1. host is a configured brand → that brand's PINNED issuer (trusted config).
|
||||
// 2. otherwise the default issuer (IAM_ISSUER) when one is pinned — the
|
||||
// fail-closed landing spot for an unknown/spoofed Host. The attacker-supplied
|
||||
// Host is NEVER echoed as the issuer.
|
||||
// 3. no config at all (map empty AND default empty → pure dev) → host-relative
|
||||
// from the TRUSTED host, so a dev box with no pin still serves a coherent
|
||||
// discovery / JWKS / iss triple. host here is zip.Ctx.Host(), which ignores
|
||||
// X-Forwarded-Host, so even this dev branch cannot be steered by a header.
|
||||
//
|
||||
// The (2)-before-(3) ordering plus newIssuerResolver's "map ⇒ default" rule
|
||||
// guarantee branch (3) is unreachable whenever ANY issuer is configured.
|
||||
func (r *issuerResolver) issuerFor(host string) string {
|
||||
if r == nil { // defensive: an unbuilt resolver still fails closed to the dev default
|
||||
return devIssuer("")
|
||||
}
|
||||
if iss, ok := r.byHost[normalizeHost(host)]; ok {
|
||||
return iss
|
||||
}
|
||||
if r.def != "" {
|
||||
return r.def
|
||||
}
|
||||
return devIssuer(host)
|
||||
}
|
||||
|
||||
// devIssuer is the no-config dev fallback: a host-relative issuer from the trusted
|
||||
// host, or the hanzo.id default when even the host is absent. Reached ONLY when
|
||||
// neither IAM_ISSUER nor IAM_ISSUER_MAP is set; any real deployment pins at least
|
||||
// IAM_ISSUER and never reaches it.
|
||||
func devIssuer(host string) string {
|
||||
if h := normalizeHost(host); h != "" {
|
||||
return "https://" + h
|
||||
}
|
||||
return "https://hanzo.id"
|
||||
}
|
||||
|
||||
// normalizeHost lowercases, trims whitespace, strips a :port, and strips a single
|
||||
// trailing FQDN dot so "LUX.ID", " lux.id ", "lux.id:443" and "lux.id." all resolve
|
||||
// to the one map key "lux.id". Applied to BOTH the config keys and the request
|
||||
// host, so the two are compared apples to apples and neither a port nor a trailing
|
||||
// dot on the request can dodge a configured brand. Any host that still fails to
|
||||
// match a key fails CLOSED to the default — so an imperfect normalization can only
|
||||
// ever cost availability (a brand landing on the default issuer), never safety (an
|
||||
// arbitrary host is never echoed as the issuer).
|
||||
func normalizeHost(host string) string {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 {
|
||||
h = h[:i]
|
||||
}
|
||||
return strings.TrimSuffix(h, ".")
|
||||
}
|
||||
|
||||
// normalizeIssuer trims whitespace and a trailing slash so the issuer is the
|
||||
// canonical no-trailing-slash origin RFC 8414 clients expect — matching the
|
||||
// pre-existing strings.TrimRight(iss, "/") behavior exactly.
|
||||
func normalizeIssuer(iss string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(iss), "/")
|
||||
}
|
||||
|
||||
// activeResolver is the process issuer resolver, installed once at startup by
|
||||
// InitIssuerResolver (and swapped by tests). Atomic so the install is visible to
|
||||
// every request goroutine with no lock on the hot path.
|
||||
var activeResolver atomic.Pointer[issuerResolver]
|
||||
|
||||
// envIssuerResolver builds the resolver from IAM_ISSUER + IAM_ISSUER_MAP exactly
|
||||
// once, lazily. It is the fallback for a request that reaches a handler before
|
||||
// InitIssuerResolver has installed one (only tests that skip the startup path).
|
||||
// A malformed map degrades fail-closed to the default issuer — never a boot an
|
||||
// attacker can steer — while InitIssuerResolver remains the eager, hard-error
|
||||
// path a real deploy hits first, so this degrade branch is a last-resort net.
|
||||
var envIssuerResolver = sync.OnceValue(func() *issuerResolver {
|
||||
r, err := newIssuerResolver(os.Getenv("IAM_ISSUER"), os.Getenv("IAM_ISSUER_MAP"))
|
||||
if err != nil {
|
||||
r, _ = newIssuerResolver(os.Getenv("IAM_ISSUER"), "")
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
// InitIssuerResolver parses IAM_ISSUER + IAM_ISSUER_MAP once at startup and
|
||||
// installs the process resolver. A malformed / fail-open IAM_ISSUER_MAP is a HARD
|
||||
// error so a misconfigured deploy fails to boot rather than silently minting
|
||||
// tokens under the wrong `iss`. Called from serve() before the listener opens.
|
||||
func InitIssuerResolver() error {
|
||||
r, err := newIssuerResolver(os.Getenv("IAM_ISSUER"), os.Getenv("IAM_ISSUER_MAP"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activeResolver.Store(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveIssuer is THE seam every issuer read routes through. host is the TRUSTED
|
||||
// request host (zip.Ctx.Host(), which ignores X-Forwarded-Host), so a
|
||||
// client-supplied header can only ever SELECT an already-configured brand's
|
||||
// issuer, never inject an arbitrary one.
|
||||
func resolveIssuer(host string) string {
|
||||
if r := activeResolver.Load(); r != nil {
|
||||
return r.issuerFor(host)
|
||||
}
|
||||
return envIssuerResolver().issuerFor(host)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// testIssuerMap is the canonical multi-brand map the cutover deploy configures as
|
||||
// IAM_ISSUER_MAP: several ingress hosts (including iam.* aliases) collapse to ONE
|
||||
// pinned issuer per brand, all served by the single iam2 instance.
|
||||
const testIssuerMap = `{
|
||||
"hanzo.id": "https://hanzo.id",
|
||||
"iam.hanzo.ai": "https://hanzo.id",
|
||||
"lux.id": "https://lux.id",
|
||||
"iam.lux.network": "https://lux.id",
|
||||
"id.zoo.network": "https://id.zoo.network",
|
||||
"pars.id": "https://pars.id"
|
||||
}`
|
||||
|
||||
// installIssuerResolver swaps a resolver built from (def, mapJSON) into the
|
||||
// process for the duration of a test, restoring the prior resolver on cleanup —
|
||||
// the SAME activeResolver seam InitIssuerResolver drives at startup, so an e2e
|
||||
// request routes through exactly the production path.
|
||||
func installIssuerResolver(t *testing.T, def, mapJSON string) {
|
||||
t.Helper()
|
||||
r, err := newIssuerResolver(def, mapJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("newIssuerResolver(%q, %q): %v", def, mapJSON, err)
|
||||
}
|
||||
prev := activeResolver.Swap(r)
|
||||
t.Cleanup(func() { activeResolver.Store(prev) })
|
||||
}
|
||||
|
||||
// The resolver maps each configured brand host (and alias) to its pinned issuer,
|
||||
// normalizes case / whitespace / port, and — critically — FAILS CLOSED to the
|
||||
// default for anything not configured, never echoing the input host.
|
||||
func TestIssuerResolver_Resolve(t *testing.T) {
|
||||
r, err := newIssuerResolver("https://hanzo.id", testIssuerMap)
|
||||
if err != nil {
|
||||
t.Fatalf("build resolver: %v", err)
|
||||
}
|
||||
for _, tc := range []struct{ name, host, want string }{
|
||||
{"brand lux", "lux.id", "https://lux.id"},
|
||||
{"brand hanzo", "hanzo.id", "https://hanzo.id"},
|
||||
{"brand zoo", "id.zoo.network", "https://id.zoo.network"},
|
||||
{"brand pars", "pars.id", "https://pars.id"},
|
||||
{"alias to hanzo", "iam.hanzo.ai", "https://hanzo.id"},
|
||||
{"alias to lux", "iam.lux.network", "https://lux.id"},
|
||||
{"uppercase host", "LUX.ID", "https://lux.id"},
|
||||
{"whitespace host", " lux.id ", "https://lux.id"},
|
||||
{"host with port", "lux.id:443", "https://lux.id"},
|
||||
{"trailing fqdn dot", "lux.id.", "https://lux.id"},
|
||||
{"trailing dot with port", "lux.id.:443", "https://lux.id"},
|
||||
{"unknown host → default", "evil.example", "https://hanzo.id"},
|
||||
{"empty host → default", "", "https://hanzo.id"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := r.issuerFor(tc.host); got != tc.want {
|
||||
t.Errorf("issuerFor(%q) = %q, want %q", tc.host, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The core security property: an unknown / attacker-chosen Host can NEVER produce
|
||||
// an issuer derived from that Host. It always fails closed to the pinned default —
|
||||
// including suffix-confusion hosts that merely CONTAIN a real brand.
|
||||
func TestIssuerResolver_UnknownHostNeverEchoed(t *testing.T) {
|
||||
r, err := newIssuerResolver("https://hanzo.id", testIssuerMap)
|
||||
if err != nil {
|
||||
t.Fatalf("build resolver: %v", err)
|
||||
}
|
||||
for _, evil := range []string{
|
||||
"evil.example",
|
||||
"attacker.test",
|
||||
"lux.id.evil.example", // suffix of a brand, but not the brand
|
||||
"hanzo.id.attacker", // prefix of a brand, but not the brand
|
||||
"xn--80ak6aa92e.com", // punycode lookalike
|
||||
} {
|
||||
got := r.issuerFor(evil)
|
||||
if got != "https://hanzo.id" {
|
||||
t.Errorf("issuerFor(%q) = %q, want fail-closed default https://hanzo.id", evil, got)
|
||||
}
|
||||
if got == "https://"+evil {
|
||||
t.Errorf("SECURITY: issuerFor(%q) echoed the attacker host into the issuer", evil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility: with no IAM_ISSUER_MAP the resolver is the pre-existing
|
||||
// single-issuer — every host gets IAM_ISSUER — and with no config at all it is the
|
||||
// pre-existing dev host-relative fallback (now from the trusted host).
|
||||
func TestIssuerResolver_BackwardCompat(t *testing.T) {
|
||||
t.Run("empty map, default set → single issuer for every host", func(t *testing.T) {
|
||||
r, err := newIssuerResolver("https://hanzo.id", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range []string{"hanzo.id", "lux.id", "anything.example", "", "LUX.ID:8443"} {
|
||||
if got := r.issuerFor(h); got != "https://hanzo.id" {
|
||||
t.Errorf("issuerFor(%q) = %q, want https://hanzo.id (single-issuer)", h, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("empty map, trailing slash trimmed", func(t *testing.T) {
|
||||
r, err := newIssuerResolver("https://hanzo.id/", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.issuerFor("lux.id"); got != "https://hanzo.id" {
|
||||
t.Errorf("issuerFor = %q, want https://hanzo.id (trailing slash trimmed)", got)
|
||||
}
|
||||
})
|
||||
t.Run("whitespace-only map is treated as unset", func(t *testing.T) {
|
||||
r, err := newIssuerResolver("https://hanzo.id", " \n\t ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.issuerFor("lux.id"); got != "https://hanzo.id" {
|
||||
t.Errorf("issuerFor = %q, want https://hanzo.id", got)
|
||||
}
|
||||
})
|
||||
t.Run("no config → dev host-relative from trusted host", func(t *testing.T) {
|
||||
r, err := newIssuerResolver("", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.issuerFor("dev.local"); got != "https://dev.local" {
|
||||
t.Errorf("issuerFor(dev.local) = %q, want https://dev.local (dev host-relative)", got)
|
||||
}
|
||||
if got := r.issuerFor(""); got != "https://hanzo.id" {
|
||||
t.Errorf("issuerFor(\"\") = %q, want https://hanzo.id (dev last-resort)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A malformed or fail-open issuer map is a hard error, so a misconfigured deploy
|
||||
// fails LOUD at startup (InitIssuerResolver) rather than silently minting under
|
||||
// the wrong / an attacker-influenced `iss`.
|
||||
func TestNewIssuerResolver_Errors(t *testing.T) {
|
||||
for _, tc := range []struct{ name, def, mapJSON string }{
|
||||
{"malformed json", "https://hanzo.id", `{not json}`},
|
||||
{"map without default is fail-open, refused", "", `{"lux.id":"https://lux.id"}`},
|
||||
{"non-https issuer", "https://hanzo.id", `{"lux.id":"http://lux.id"}`},
|
||||
{"scheme-less issuer", "https://hanzo.id", `{"lux.id":"lux.id"}`},
|
||||
{"empty issuer value", "https://hanzo.id", `{"lux.id":""}`},
|
||||
{"empty host key", "https://hanzo.id", `{"":"https://lux.id"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if r, err := newIssuerResolver(tc.def, tc.mapJSON); err == nil {
|
||||
t.Fatalf("newIssuerResolver(%q, %q) = %+v, nil error; want error", tc.def, tc.mapJSON, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end over the real HTTP surface: for each brand host, the `iss` a minted
|
||||
// token carries EQUALS the discovery document's `issuer`, which EQUALS the base of
|
||||
// its `jwks_uri` — the mutual consistency an RP that discovered via one brand host
|
||||
// relies on. The single instance emits a DIFFERENT correct issuer per brand.
|
||||
func TestIssuerResolver_E2E_PerBrandConsistency(t *testing.T) {
|
||||
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
|
||||
|
||||
for _, tc := range []struct{ host, want string }{
|
||||
{"lux.id", "https://lux.id"},
|
||||
{"hanzo.id", "https://hanzo.id"},
|
||||
{"iam.hanzo.ai", "https://hanzo.id"},
|
||||
{"id.zoo.network", "https://id.zoo.network"},
|
||||
{"pars.id", "https://pars.id"},
|
||||
} {
|
||||
tokenIss := mintClientCredsIssuer(t, app, db, tc.host)
|
||||
discIss, jwksURI := discoveryIssuer(t, app, tc.host)
|
||||
if tokenIss != tc.want {
|
||||
t.Errorf("host %q: token iss = %q, want %q", tc.host, tokenIss, tc.want)
|
||||
}
|
||||
if discIss != tc.want {
|
||||
t.Errorf("host %q: discovery issuer = %q, want %q", tc.host, discIss, tc.want)
|
||||
}
|
||||
if tokenIss != discIss {
|
||||
t.Errorf("host %q: token iss %q != discovery issuer %q (split origin)", tc.host, tokenIss, discIss)
|
||||
}
|
||||
if jwksURI != tc.want+PathJWKS {
|
||||
t.Errorf("host %q: jwks_uri = %q, want %q (JWKS split origin)", tc.host, jwksURI, tc.want+PathJWKS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end over HTTP: an unknown / spoofed Host fails closed to the default
|
||||
// issuer and is never echoed, and a client-supplied X-Forwarded-Host cannot steer
|
||||
// `iss` toward another configured brand — the trusted routed host wins.
|
||||
func TestIssuerResolver_E2E_SpoofFailsClosed(t *testing.T) {
|
||||
installIssuerResolver(t, "https://hanzo.id", testIssuerMap)
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "svc", secret: "svc-secret", redirectURIs: []string{testRedirect}})
|
||||
|
||||
t.Run("unknown host → default, never echoed", func(t *testing.T) {
|
||||
if iss := mintClientCredsIssuer(t, app, db, "evil.example"); iss != "https://hanzo.id" {
|
||||
t.Fatalf("unknown host: iss = %q, want default https://hanzo.id", iss)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("X-Forwarded-Host cannot override the trusted host", func(t *testing.T) {
|
||||
req := formReq("POST", PathToken, url.Values{
|
||||
"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"},
|
||||
})
|
||||
req.Host = "hanzo.id" // the brand the ingress routed to
|
||||
req.Header.Set("X-Forwarded-Host", "lux.id") // attacker-supplied
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("mint status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
access, _ := decode(t, body)["access_token"].(string)
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if claims.Issuer != "https://hanzo.id" {
|
||||
t.Errorf("X-Forwarded-Host steered iss to %q, want https://hanzo.id", claims.Issuer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- e2e helpers ---
|
||||
|
||||
// mintClientCredsIssuer mints a client_credentials token for app "svc" under the
|
||||
// given request host and returns the verified `iss` claim it carries.
|
||||
func mintClientCredsIssuer(t *testing.T, app *zip.App, db orm.DB, host string) string {
|
||||
t.Helper()
|
||||
req := formReq("POST", PathToken, url.Values{
|
||||
"grant_type": {"client_credentials"}, "client_id": {"svc"}, "client_secret": {"svc-secret"},
|
||||
})
|
||||
req.Host = host
|
||||
resp, body := do(t, app, req)
|
||||
access, _ := decode(t, body)["access_token"].(string)
|
||||
if resp.StatusCode != 200 || access == "" {
|
||||
t.Fatalf("mint under host %q: status=%d body=%s", host, resp.StatusCode, body)
|
||||
}
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("verify token under host %q: %v", host, err)
|
||||
}
|
||||
return claims.Issuer
|
||||
}
|
||||
|
||||
// discoveryIssuer fetches the discovery document under the given host and returns
|
||||
// its `issuer` and `jwks_uri`.
|
||||
func discoveryIssuer(t *testing.T, app *zip.App, host string) (issuer, jwksURI string) {
|
||||
t.Helper()
|
||||
req := formReqNoBody("GET", PathDiscovery)
|
||||
req.Host = host
|
||||
resp, body := do(t, app, req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("discovery under host %q: status %d", host, resp.StatusCode)
|
||||
}
|
||||
d := decode(t, body)
|
||||
issuer, _ = d["issuer"].(string)
|
||||
jwksURI, _ = d["jwks_uri"].(string)
|
||||
return issuer, jwksURI
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The confidential-client on-behalf-of primitives. A trusted, allow-listed backend
|
||||
// (the console BFF as `hanzo-console`) authenticates as the confidential CLIENT —
|
||||
// not an end-user bearer — and acts on a `?id=<owner>/<name>` target user: mint a
|
||||
// short-lived user-bound access token (`issue-user-token`), or (re)generate/revoke
|
||||
// the user's durable `hk-` Cloud API key.
|
||||
//
|
||||
// `issue-user-token` is the CANONICAL FORWARD path's transitional twin: the RFC
|
||||
// 8693 Token Exchange grant on /oauth/token is the standard (HIP-0111), and this
|
||||
// verb is the COMPAT SHIM the console still calls (identity.ts `issueUserToken` →
|
||||
// `adminBearer` backs EVERY /v1/* BFF proxy call). It mints over the exact same
|
||||
// authorizeMinter allow-list + reserved-org gate + SignUserToken as token exchange
|
||||
// — same authority, same audit — so the console works unchanged during the cutover,
|
||||
// then migrates to grant_type=token-exchange and this shim is retired. API keys are
|
||||
// a PRODUCT credential (no IETF standard), a first-party primitive.
|
||||
//
|
||||
// They are NOT Bearer-gated (they live in the PUBLIC group, before the Guard); each
|
||||
// does its own tighter authentication through the ONE authorizeMinter seam.
|
||||
const (
|
||||
PathIssueUserToken = "/v1/iam/issue-user-token"
|
||||
PathMintUserKeys = "/v1/iam/mint-user-keys"
|
||||
PathRevokeUserKeys = "/v1/iam/revoke-user-keys"
|
||||
)
|
||||
|
||||
// routeIssueToken registers the confidential-client primitives on the PUBLIC group
|
||||
// r. POST-only: they mint/rotate a credential — never over a cacheable GET (a
|
||||
// client_secret in a query string would reach logs/proxies).
|
||||
func routeIssueToken(r zip.Router, db orm.DB) {
|
||||
r.Post(PathIssueUserToken, issueUserTokenHandler(db))
|
||||
r.Post(PathMintUserKeys, mintUserKeysHandler(db))
|
||||
r.Post(PathRevokeUserKeys, revokeUserKeysHandler(db))
|
||||
}
|
||||
|
||||
// issueUserTokenHandler mints an access token for the `?id=<owner>/<name>` target
|
||||
// user (optional `?aud=` resource, RFC 8707), issued by the authenticated +
|
||||
// allow-listed confidential client. The token's subject + owner are the TARGET
|
||||
// USER's, so a resource server scopes on the validated owner claim to the user's
|
||||
// tenant — indistinguishable from a token the user obtained directly. Response is
|
||||
// the camelCase `{accessToken, expiresIn}` body identity.ts consumes. Equivalent to
|
||||
// the RFC 8693 token-exchange grant, minus the subject_token proof (the console has
|
||||
// the user's id, not a token) — the reason this compat shim exists.
|
||||
func issueUserTokenHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
clientApp, status, msg := authorizeMinter(ctx, db, c)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user, status, msg := mintTarget(ctx, db, c, clientApp)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
|
||||
aud := strings.TrimSpace(c.Query("aud"))
|
||||
if aud == "" {
|
||||
aud = defaultUserAudience(ctx, db, user, clientApp)
|
||||
}
|
||||
signer, err := signerFor(ctx, db, clientApp, tokenIssuer(c))
|
||||
if err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
ttl := appTTL(clientApp)
|
||||
subject := user.Owner + "/" + user.Name
|
||||
display := user.DisplayName
|
||||
if display == "" {
|
||||
display = user.Name
|
||||
}
|
||||
access, err := signer.SignUserToken(subject, user.Owner, aud, clientApp.ClientId, user.Email, display, "", ttl, now)
|
||||
if err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
|
||||
row := &schema.Token{
|
||||
Owner: user.Owner,
|
||||
Application: clientApp.Name,
|
||||
Organization: user.Owner,
|
||||
User: subject,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(ttl.Seconds()),
|
||||
AccessTokenHash: hashToken(access),
|
||||
}
|
||||
row.Name = "iut-" + hashToken(access)[:32]
|
||||
if err := store.PersistToken(ctx, db, row); err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
auditMint(ctx, db, c, "issue-user-token", clientApp.ClientId, subject)
|
||||
return httpx.Ok(c, map[string]any{
|
||||
"accessToken": access,
|
||||
"expiresIn": int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mintUserKeysHandler (re)generates the target user's durable `hk-` Cloud API key
|
||||
// (schema.User.AccessKey) and returns it once, over the shared authorizeMinter +
|
||||
// mintTarget seam.
|
||||
func mintUserKeysHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
clientApp, status, msg := authorizeMinter(ctx, db, c)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user, status, msg := mintTarget(ctx, db, c, clientApp)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
key, err := newAccessKey()
|
||||
if err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
user.AccessKey = key
|
||||
user.UpdatedTime = nowFunc().UTC().Format(time.RFC3339)
|
||||
if err := saveUser(ctx, db, user); err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
auditMint(ctx, db, c, "mint-user-keys", clientApp.ClientId, user.Owner+"/"+user.Name)
|
||||
return httpx.Ok(c, map[string]any{"accessKey": key})
|
||||
}
|
||||
}
|
||||
|
||||
// revokeUserKeysHandler clears the target user's `hk-` key (immediate revoke).
|
||||
func revokeUserKeysHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
clientApp, status, msg := authorizeMinter(ctx, db, c)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user, status, msg := mintTarget(ctx, db, c, clientApp)
|
||||
if status != 0 {
|
||||
return mintErr(c, status, msg)
|
||||
}
|
||||
user.AccessKey = ""
|
||||
user.AccessSecret = ""
|
||||
user.AccessSecretHash = ""
|
||||
user.UpdatedTime = nowFunc().UTC().Format(time.RFC3339)
|
||||
if err := saveUser(ctx, db, user); err != nil {
|
||||
return mintErr(c, 500, "server_error")
|
||||
}
|
||||
auditMint(ctx, db, c, "revoke-user-keys", clientApp.ClientId, user.Owner+"/"+user.Name)
|
||||
return httpx.Ok(c, map[string]any{"affected": true})
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeMinter is the ONE authentication seam for the confidential-client
|
||||
// primitives: it authenticates the client (client_secret_basic or _post,
|
||||
// constant-time) and enforces the mint allow-list. status==0 means authorized and
|
||||
// returns the client app; otherwise (status, msg) is the response to render. It
|
||||
// never reveals WHICH check failed beyond auth-vs-permission (401 vs 403).
|
||||
func authorizeMinter(ctx context.Context, db orm.DB, c *zip.Ctx) (*schema.Application, int, string) {
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
if clientID == "" {
|
||||
return nil, 401, "client authentication required"
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, clientID)
|
||||
if err != nil {
|
||||
return nil, 500, "server_error"
|
||||
}
|
||||
if app == nil || app.ClientSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return nil, 401, "client authentication failed"
|
||||
}
|
||||
// The capability gate: only an ALLOW-LISTED, admin-owned 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"). Keyed on
|
||||
// the resolved app's globally-unique clientId AND pinned to its signing owner (see
|
||||
// mintAllowed), so a colliding-clientId tenant app is refused here.
|
||||
if !mintAllowed(app) {
|
||||
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) {
|
||||
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 app may act on a user's behalf. TWO conditions, both
|
||||
// required: its OWNING org must be a reserved platform signing owner (admin/built-in),
|
||||
// AND its clientId must be on IAM_KEY_MINT_ALLOWED_APPS. The owner-pin is the decisive
|
||||
// gate — clientId and secret are body-supplied at registration, so a tenant could
|
||||
// register an app whose clientId collides with a mint-listed one and, on a backend
|
||||
// whose duplicate-row order is unspecified, have its row resolve and its known secret
|
||||
// authenticate; but its owner is its OWN tenant, never a signing owner, so it mints
|
||||
// nothing. (Resolution is additionally admin-preferring and clientId is unique on
|
||||
// create, so the collision cannot arise nor win — this is the third, innermost gate.)
|
||||
// Every legit minter is admin-owned, so no legitimate grant regresses. Empty/unset
|
||||
// list allows nothing — fail closed.
|
||||
func mintAllowed(app *schema.Application) bool {
|
||||
return store.IsSigningCertOwner(app.Owner) && appInList("IAM_KEY_MINT_ALLOWED_APPS", app.ClientId)
|
||||
}
|
||||
|
||||
// adminMintAllowed reports whether app 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. Same owner-pin as mintAllowed: the app must be admin/built-in owned. The
|
||||
// console, which legitimately drives admin.hanzo.ai, is on both lists. Fail closed.
|
||||
func adminMintAllowed(app *schema.Application) bool {
|
||||
return store.IsSigningCertOwner(app.Owner) && appInList("IAM_ADMIN_MINT_ALLOWED_APPS", app.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/iam/internal/schema"
|
||||
)
|
||||
|
||||
// Red-team helpers + the field-preservation guard for the on-behalf-of primitives.
|
||||
// The name-collision priv-esc PoC that motivated the clientId-only allow-list is
|
||||
// now the regression guard TestTokenExchange_nameCollisionAttacker_403 (the
|
||||
// issue-user-token verb it originally attacked is retired in favor of RFC 8693
|
||||
// Token Exchange). seedAttackerApp stays here — it models exactly what a tenant
|
||||
// org-admin can create through POST /v1/iam/application (every field bound from
|
||||
// the body), the precondition that test relies on.
|
||||
|
||||
// seedAttackerApp creates a tenant-owned application the attacker fully controls
|
||||
// (owner=evil, chosen name/clientId/secret) whose Cert points at an EXISTING
|
||||
// trusted platform cert by name — exactly what internal/applications.create binds
|
||||
// from the request body (Owner/Name/ClientId/ClientSecret/Cert all verbatim).
|
||||
func seedAttackerApp(t *testing.T, db orm.DB, owner, name, clientID, secret, platformCert string) {
|
||||
t.Helper()
|
||||
a := orm.New[schema.Application](db)
|
||||
a.Owner = owner // a NON-reserved tenant org the attacker org-admins
|
||||
a.Name = name
|
||||
a.ClientId = clientID
|
||||
a.ClientSecret = secret
|
||||
a.Organization = owner
|
||||
a.Cert = platformCert // resolved among admin/built-in by GetSigningCert
|
||||
a.ExpireInHours = 1
|
||||
a.SetId(owner + "/" + name)
|
||||
if err := a.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed attacker app: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedTeam_mintKeys_preservesPasswordHashAndIsAdmin proves the mint/revoke
|
||||
// read-modify-write (saveUser) does not blank PasswordHash nor flip privilege
|
||||
// bits. GetUserByName returns the FULL row (no mask), so *existing = *user
|
||||
// preserves every field the handler didn't touch.
|
||||
func TestRedTeam_mintKeys_preservesPasswordHashAndIsAdmin(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name, u.Email = "hanzo", "carol", "carol@hanzo.ai"
|
||||
u.PasswordHash = "$argon2id$v=19$m=65536,t=3,p=4$SALTSALT$HASHHASHHASH"
|
||||
u.PasswordType = "argon2id"
|
||||
u.IsAdmin = true
|
||||
u.SetId("hanzo/carol")
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if resp, body := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/carol")); resp.StatusCode != 200 {
|
||||
t.Fatalf("mint status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
got, err := orm.Get[schema.User](db, "hanzo/carol")
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got.PasswordHash != u.PasswordHash {
|
||||
t.Errorf("PasswordHash mutated by mint: %q", got.PasswordHash)
|
||||
}
|
||||
if got.PasswordType != "argon2id" {
|
||||
t.Errorf("PasswordType mutated by mint: %q", got.PasswordType)
|
||||
}
|
||||
if !got.IsAdmin {
|
||||
t.Errorf("IsAdmin flipped false by mint")
|
||||
}
|
||||
if got.AccessKey == "" {
|
||||
t.Errorf("mint did not set AccessKey")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The `hk-` Cloud API-key primitives (mint/revoke). A confidential, allow-listed
|
||||
// client (client_secret_basic) acts on a ?id=<owner>/<name> target user. These are
|
||||
// a product credential, not an RFC token flow — the on-behalf-of TOKEN minting is
|
||||
// RFC 8693 Token Exchange (token_exchange_test.go).
|
||||
|
||||
// seedForbiddenUser seeds a revoked (forbidden) user — no credential may be minted
|
||||
// or rotated for it.
|
||||
func seedForbiddenUser(t *testing.T, db orm.DB, owner, name string) {
|
||||
t.Helper()
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner, u.Name = owner, name
|
||||
u.IsForbidden = true
|
||||
u.SetId(owner + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed forbidden user %s/%s: %v", owner, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// keyReq builds a POST to a key primitive authenticating clientID/secret via Basic.
|
||||
func keyReq(path, clientID, secret, query string) *http.Request {
|
||||
req := httptest.NewRequest("POST", path+query, nil)
|
||||
req.Host = "hanzo.id"
|
||||
if clientID != "" {
|
||||
req.Header.Set("Authorization", "Basic "+
|
||||
base64.StdEncoding.EncodeToString([]byte(clientID+":"+secret)))
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// dataMap pulls the `data` object out of the v1 envelope.
|
||||
func dataMap(t *testing.T, body []byte) map[string]any {
|
||||
t.Helper()
|
||||
m := decode(t, body)
|
||||
d, _ := m["data"].(map[string]any)
|
||||
return d
|
||||
}
|
||||
|
||||
// issue-user-token is the compat shim the console's adminBearer depends on: an
|
||||
// allow-listed confidential client mints a token bound to the ?id= target user.
|
||||
func TestIssueUserToken_mintsTargetUserToken(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, body := do(t, app, keyReq(PathIssueUserToken, "hanzo-console", "top-secret", "?id=hanzo/alice&aud=hanzo-cloud"))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d; body=%s", resp.StatusCode, body)
|
||||
}
|
||||
access, _ := dataMap(t, body)["accessToken"].(string)
|
||||
if access == "" {
|
||||
t.Fatalf("no accessToken; body=%s", body)
|
||||
}
|
||||
// The minted token verifies under the JWKS and carries the TARGET user's identity.
|
||||
claims, err := verifyToken(context.Background(), db, access)
|
||||
if err != nil {
|
||||
t.Fatalf("minted token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" || claims.Owner != "hanzo" {
|
||||
t.Fatalf("subject/owner = %q/%q, want hanzo/alice / hanzo", claims.Subject, claims.Owner)
|
||||
}
|
||||
if exp, _ := dataMap(t, body)["expiresIn"].(float64); exp <= 0 {
|
||||
t.Fatalf("expiresIn = %v, want > 0", dataMap(t, body)["expiresIn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueUserToken_notAllowlisted_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "other-app")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
resp, _ := do(t, app, keyReq(PathIssueUserToken, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("off-allow-list issue-user-token = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintUserKeys_generatesReadableHkKey(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, body := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d; body=%s", resp.StatusCode, body)
|
||||
}
|
||||
key, _ := dataMap(t, body)["accessKey"].(string)
|
||||
if !strings.HasPrefix(key, "hk-") || len(key) < 8 {
|
||||
t.Fatalf("accessKey = %q, want an hk- key", key)
|
||||
}
|
||||
// The minted key is persisted on the user row (get-user / getUserKey read it).
|
||||
u, err := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if u.AccessKey != key {
|
||||
t.Fatalf("persisted AccessKey = %q, want the minted %q", u.AccessKey, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeUserKeys_clearsTheKey(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
|
||||
resp, body := do(t, app, keyReq(PathRevokeUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 200 || decode(t, body)["status"] != "ok" {
|
||||
t.Fatalf("revoke status = %d; body=%s", resp.StatusCode, body)
|
||||
}
|
||||
u, _ := store.GetUserByName(context.Background(), db, "hanzo", "alice")
|
||||
if u.AccessKey != "" {
|
||||
t.Fatalf("AccessKey after revoke = %q, want empty", u.AccessKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintUserKeys_notAllowlisted_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "other")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw")
|
||||
|
||||
resp, _ := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/alice"))
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("off-allow-list mint status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintUserKeys_forbiddenUser_403(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-console", secret: "top-secret"})
|
||||
seedForbiddenUser(t, db, "hanzo", "banned")
|
||||
|
||||
resp, _ := do(t, app, keyReq(PathMintUserKeys, "hanzo-console", "top-secret", "?id=hanzo/banned"))
|
||||
if resp.StatusCode != 403 {
|
||||
t.Fatalf("forbidden-user mint status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The JSON Web Key Set: the public half of every active signing Cert, so relying
|
||||
// parties verify the tokens iam2 issues. This is the load-bearing interop
|
||||
// surface — the live hanzo.id JWKS publishes one RSA (RS256) key per Cert, keyed
|
||||
// by `kid` = the Cert name, and every existing verifier reads it. Keys are
|
||||
// deduplicated by kid and ordered stably; the response carries a strong ETag and
|
||||
// a 60s cache, matching live.
|
||||
|
||||
// signingAlgs is the set of JOSE algorithms iam2 publishes signing keys for.
|
||||
// A Cert whose CryptoAlgorithm is outside this set (e.g. an ACME/SSL TLS cert)
|
||||
// is not a token-signing key and is excluded from the JWKS.
|
||||
var signingAlgs = map[string]bool{
|
||||
"RS256": true, "RS512": true,
|
||||
"ES256": true, "ES384": true, "ES512": true,
|
||||
"MLDSA65": true,
|
||||
}
|
||||
|
||||
// jwksHandler serves GET /v1/iam/.well-known/jwks.
|
||||
func jwksHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
certs, err := store.ListCerts(c.Context(), db)
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
keys := make([]any, 0, len(certs))
|
||||
seen := make(map[string]bool, len(certs))
|
||||
for _, cert := range certs {
|
||||
if !isSigningCert(cert) || seen[cert.Name] {
|
||||
continue
|
||||
}
|
||||
jwk, err := certToJWK(cert)
|
||||
if err != nil {
|
||||
continue // a cert we cannot encode never fails the whole set
|
||||
}
|
||||
seen[cert.Name] = true
|
||||
keys = append(keys, jwk)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{"keys": keys})
|
||||
if err != nil {
|
||||
return c.JSON(500, map[string]string{"error": "server_error"})
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
c.SetHeader("Cache-Control", "public, max-age=60")
|
||||
c.SetHeader("ETag", etag)
|
||||
if c.Header("If-None-Match") == etag {
|
||||
return c.NoContent(304)
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
return c.Bytes(200, body)
|
||||
}
|
||||
}
|
||||
|
||||
// isSigningCert reports whether a Cert is a token-signing key that belongs in the
|
||||
// JWKS: it must be owned by a reserved platform org (so a tenant cannot publish a
|
||||
// key under a colliding kid), carry key material and a recognized signing
|
||||
// algorithm, and not be a TLS/SSL certificate.
|
||||
func isSigningCert(cert *schema.Cert) bool {
|
||||
if cert == nil || cert.Name == "" {
|
||||
return false
|
||||
}
|
||||
if !store.IsSigningCertOwner(cert.Owner) {
|
||||
return false
|
||||
}
|
||||
if cert.PrivateKey == "" && cert.Certificate == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(cert.Type, "SSL") {
|
||||
return false
|
||||
}
|
||||
return signingAlgs[strings.ToUpper(strings.ReplaceAll(cert.CryptoAlgorithm, "-", ""))]
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// seedMLDSACert creates an ML-DSA-65 signing cert (raw base64 private key).
|
||||
func seedMLDSACert(t *testing.T, db orm.DB, name string) {
|
||||
t.Helper()
|
||||
_, sk, err := mldsa65.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("mldsa keygen: %v", err)
|
||||
}
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "admin"
|
||||
c.Name = name
|
||||
c.CryptoAlgorithm = "MLDSA65"
|
||||
c.PrivateKey = base64.StdEncoding.EncodeToString(sk.Bytes())
|
||||
c.SetId("admin/" + name)
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatalf("seed mldsa cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh server with no signing certs still serves a well-formed, empty key set
|
||||
// — the guard against the earlier bug where JWKS was empty yet discovery
|
||||
// advertised signing algorithms, so verifiers could never resolve a key.
|
||||
func TestJWKS_EmptyButWellFormed(t *testing.T) {
|
||||
app, _ := newServer(t)
|
||||
resp, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
set := decode(t, body)
|
||||
if keys, ok := set["keys"].([]any); !ok || len(keys) != 0 {
|
||||
t.Fatalf("empty JWKS = %v, want an empty keys array", set["keys"])
|
||||
}
|
||||
}
|
||||
|
||||
// The RSA signing key is published with the exact shape RS256 verifiers read —
|
||||
// kty/alg/use/kid/n/e — and never any private material.
|
||||
func TestJWKS_PublishesRSAPublicKey(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo")
|
||||
|
||||
resp, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=60" {
|
||||
t.Errorf("Cache-Control = %q", cc)
|
||||
}
|
||||
if resp.Header.Get("ETag") == "" {
|
||||
t.Error("JWKS must carry a strong ETag")
|
||||
}
|
||||
|
||||
k := jwkByKid(t, body, "cert-hanzo")
|
||||
if k["kty"] != "RSA" || k["alg"] != "RS256" || k["use"] != "sig" {
|
||||
t.Errorf("jwk header wrong: %v", k)
|
||||
}
|
||||
// n encodes the real modulus.
|
||||
nb, err := base64.RawURLEncoding.DecodeString(k["n"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("decode n: %v", err)
|
||||
}
|
||||
if new(big.Int).SetBytes(nb).Cmp(sharedKey(t).N) != 0 {
|
||||
t.Error("jwk modulus does not match the signing key")
|
||||
}
|
||||
// Private material must never appear.
|
||||
for _, secret := range []string{"d", "p", "q", "dp", "dq", "qi"} {
|
||||
if _, bad := k[secret]; bad {
|
||||
t.Fatalf("JWKS leaked private RSA parameter %q", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A conditional GET with the current ETag is answered 304 (parity with live).
|
||||
func TestJWKS_ETag304(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo")
|
||||
|
||||
resp, _ := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
etag := resp.Header.Get("ETag")
|
||||
req := formReqNoBody("GET", PathJWKS)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
resp2, _ := do(t, app, req)
|
||||
if resp2.StatusCode != 304 {
|
||||
t.Fatalf("conditional GET status = %d, want 304", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A post-quantum ML-DSA-65 cert is published as {kty:MLDSA, alg:MLDSA65, x}.
|
||||
func TestJWKS_PublishesMLDSAKey(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedMLDSACert(t, db, "cert-pq")
|
||||
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
k := jwkByKid(t, body, "cert-pq")
|
||||
if k["kty"] != "MLDSA" || k["alg"] != "MLDSA65" || k["use"] != "sig" {
|
||||
t.Errorf("mldsa jwk header wrong: %v", k)
|
||||
}
|
||||
if x, _ := k["x"].(string); x == "" {
|
||||
t.Error("mldsa jwk missing raw public key x")
|
||||
}
|
||||
}
|
||||
|
||||
// A TLS/SSL certificate is not a token-signing key and is excluded.
|
||||
func TestJWKS_ExcludesTLSCert(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo")
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "admin"
|
||||
c.Name = "cert-tls"
|
||||
c.Type = "SSL"
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId("admin/cert-tls")
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if hasKid(t, body, "cert-tls") {
|
||||
t.Fatal("TLS cert must not appear in the JWKS")
|
||||
}
|
||||
if !hasKid(t, body, "cert-hanzo") {
|
||||
t.Fatal("signing cert missing from JWKS")
|
||||
}
|
||||
}
|
||||
|
||||
// A cert owned by a non-platform org is never published, so a tenant cannot
|
||||
// inject a signing key under a chosen kid.
|
||||
func TestJWKS_ExcludesNonPlatformCert(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedRSACert(t, db, "cert-hanzo") // admin-owned, trusted
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = "attacker-org"
|
||||
c.Name = "cert-evil"
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId("attacker-org/cert-evil")
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
if hasKid(t, body, "cert-evil") {
|
||||
t.Fatal("a non-platform cert must not appear in the JWKS")
|
||||
}
|
||||
if !hasKid(t, body, "cert-hanzo") {
|
||||
t.Fatal("platform signing cert missing from JWKS")
|
||||
}
|
||||
}
|
||||
|
||||
// Keys are deduplicated by kid so a name reused across owners publishes once.
|
||||
func TestJWKS_DedupesByKid(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
// Two TRUSTED platform owners hold a cert of the same name; the JWKS must
|
||||
// publish that kid exactly once.
|
||||
for _, owner := range []string{"admin", "built-in"} {
|
||||
c := orm.New[schema.Cert](db)
|
||||
c.Owner = owner
|
||||
c.Name = "cert-shared"
|
||||
c.CryptoAlgorithm = "RS256"
|
||||
c.PrivateKey = rsaKeyToPEM(t, sharedKey(t))
|
||||
c.SetId(owner + "/cert-shared")
|
||||
if err := c.CreateCtx(tctx()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, body := do(t, app, formReqNoBody("GET", PathJWKS))
|
||||
set := decode(t, body)
|
||||
keys, _ := set["keys"].([]any)
|
||||
count := 0
|
||||
for _, k := range keys {
|
||||
if k.(map[string]any)["kid"] == "cert-shared" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("kid cert-shared published %d times, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func jwkByKid(t *testing.T, body []byte, kid string) map[string]any {
|
||||
t.Helper()
|
||||
set := decode(t, body)
|
||||
keys, _ := set["keys"].([]any)
|
||||
for _, k := range keys {
|
||||
m := k.(map[string]any)
|
||||
if m["kid"] == kid {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("kid %q not found in JWKS %s", kid, string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasKid(t *testing.T, body []byte, kid string) bool {
|
||||
t.Helper()
|
||||
set := decode(t, body)
|
||||
keys, _ := set["keys"].([]any)
|
||||
for _, k := range keys {
|
||||
if k.(map[string]any)["kid"] == kid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// JWT token signing. The signing algorithm is a property of the signing Cert's
|
||||
// key, not a global: an RSA cert signs RS256 (the interoperable default that the
|
||||
// live hanzo.id JWKS serves), an EC cert signs ES256/384/512, and a post-quantum
|
||||
// ML-DSA-65 cert signs MLDSA65 (mldsa.go, behind the same jwt.SigningMethod
|
||||
// seam). The classical path is the load-bearing interop path — every existing
|
||||
// verifier reads the RS256 keys published in the JWKS; ML-DSA is additive and
|
||||
// inert until an ML-DSA Cert is configured. Keys come from the Cert entity
|
||||
// (KMS-backed); tests inject an ephemeral in-memory key through the same path.
|
||||
|
||||
// Claims is the iam2 token claim set: the standard registered claims plus the
|
||||
// Hanzo first-class claims the SDK and downstream validators read. owner and
|
||||
// organization are the tenant (both the org slug); scope carries the granted
|
||||
// scopes; nonce is echoed into the id_token; tokenType distinguishes an
|
||||
// access-token from an id-token. A field is emitted only when populated, so one
|
||||
// struct serves both token shapes without leaking empty claims.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Azp string `json:"azp,omitempty"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
}
|
||||
|
||||
// Signer signs tokens with one key under one algorithm. Immutable after
|
||||
// construction; the (method, key, kid, alg) tuple is fixed to the Cert it was
|
||||
// built from so a token can never be signed under a key/alg mismatch.
|
||||
type Signer struct {
|
||||
method jwt.SigningMethod
|
||||
key any // *rsa.PrivateKey | *ecdsa.PrivateKey | *mldsa65.PrivateKey
|
||||
kid string // JWKS key id — the Cert name
|
||||
alg string // JOSE alg — "RS256" | "ES256" | … | "MLDSA65"
|
||||
issuer string
|
||||
}
|
||||
|
||||
// NewSignerFromCert builds a Signer from a Cert, selecting the algorithm from
|
||||
// the cert's key type: RSA → RS256 (or RS512 when the app pins it), EC → ES256/
|
||||
// ES384/ES512 by curve, ML-DSA → MLDSA65. issuer is the canonical OIDC issuer
|
||||
// (https://<host>) that discovery advertises; it is pinned into every token so
|
||||
// id_token `iss` matches the discovery document. app may be nil (the method is
|
||||
// then chosen purely from the key type).
|
||||
func NewSignerFromCert(cert *schema.Cert, app *schema.Application, issuer string) (*Signer, error) {
|
||||
if cert == nil {
|
||||
return nil, errors.New("jwt: nil cert")
|
||||
}
|
||||
// Post-quantum ML-DSA-65 cert: raw key material, own signing method.
|
||||
if isMLDSACert(cert) {
|
||||
key, err := parseMLDSA65PrivateKey(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: SigningMethodMLDSA65, key: key, kid: cert.Name, alg: algMLDSA65, issuer: issuer}, nil
|
||||
}
|
||||
if cert.PrivateKey == "" {
|
||||
return nil, errors.New("jwt: cert has no private key")
|
||||
}
|
||||
key, err := parsePrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method, alg, err := methodForKey(key, pinnedMethod(app))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: method, key: key, kid: cert.Name, alg: alg, issuer: issuer}, nil
|
||||
}
|
||||
|
||||
// NewRSASignerFromCert builds an RS256 Signer from a Cert whose PrivateKey is a
|
||||
// PEM RSA key. Retained as the explicit RSA constructor; NewSignerFromCert is
|
||||
// the general dispatch used by the token endpoint.
|
||||
func NewRSASignerFromCert(cert *schema.Cert, issuer string) (*Signer, error) {
|
||||
if cert == nil || cert.PrivateKey == "" {
|
||||
return nil, errors.New("jwt: cert has no private key")
|
||||
}
|
||||
key, err := parseRSAPrivateKeyPEM(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: cert.Name, alg: "RS256", issuer: issuer}, nil
|
||||
}
|
||||
|
||||
// NewRSASigner builds an RS256 Signer directly from an RSA key (tests and, in
|
||||
// dev, an ephemeral key when no Cert is configured).
|
||||
func NewRSASigner(key *rsa.PrivateKey, kid, issuer string) *Signer {
|
||||
return &Signer{method: jwt.SigningMethodRS256, key: key, kid: kid, alg: "RS256", issuer: issuer}
|
||||
}
|
||||
|
||||
// Sign issues a signed access token for (app, user) with the given scope. now is
|
||||
// injected for testability; ttl is the token lifetime. The audience is the app's
|
||||
// clientId (validators fail closed when aud != clientId).
|
||||
func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: userID,
|
||||
Audience: audienceFor(app, ""),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Organization: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Azp: app.ClientId,
|
||||
TokenType: "access-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// SignUserToken mints an access token a confidential client issues ON BEHALF OF a
|
||||
// target user — the RFC 8693 Token Exchange grant. Unlike Sign (which stamps the
|
||||
// APP's org as the owner claim), every authority claim here is the TARGET USER's:
|
||||
// the subject and owner are the user's, so a resource server that scopes on the
|
||||
// validated `owner` claim (cloud's SanitizeIdentity) scopes to the USER's tenant,
|
||||
// never the minting client's. `aud` is the caller-resolved audience (an explicit
|
||||
// RFC 8707 resource, else the user's own app) and `azp` records the minting
|
||||
// client. Signed under this signer's trusted cert + canonical issuer, so the same
|
||||
// JWKS verifies it — the token is indistinguishable from one the user obtained
|
||||
// directly, which is the point. The Signer stays decoupled from schema.User: the
|
||||
// handler resolves and passes the values it authorized.
|
||||
func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, scope string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: subject,
|
||||
Audience: jwt.ClaimStrings{aud},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: owner,
|
||||
Organization: owner,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Azp: azp,
|
||||
TokenType: "access-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// SignID issues an OIDC id_token for (app, user). It differs from the access
|
||||
// token by carrying the echoed nonce and by declaring tokenType "id-token"; the
|
||||
// audience is the client the token was minted for (the RP), and iss matches the
|
||||
// discovery issuer so a standard OIDC client validates it.
|
||||
func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, nonce string, ttl time.Duration, now time.Time) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("jwt: nil signer")
|
||||
}
|
||||
jti, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: s.issuer,
|
||||
Subject: userID,
|
||||
Audience: jwt.ClaimStrings{app.ClientId},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ID: jti,
|
||||
},
|
||||
Scope: scope,
|
||||
Owner: app.Organization,
|
||||
Organization: app.Organization,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Nonce: nonce,
|
||||
Azp: app.ClientId,
|
||||
TokenType: "id-token",
|
||||
}
|
||||
return s.signClaims(claims)
|
||||
}
|
||||
|
||||
// signClaims is the single choke point that turns a claim set into a signed
|
||||
// compact JWS under this signer's fixed (method, key, kid).
|
||||
func (s *Signer) signClaims(claims Claims) (string, error) {
|
||||
tok := jwt.NewWithClaims(s.method, claims)
|
||||
if s.kid != "" {
|
||||
tok.Header["kid"] = s.kid
|
||||
}
|
||||
return tok.SignedString(s.key)
|
||||
}
|
||||
|
||||
// PublicKey returns the signer's RSA public key, or nil for a non-RSA signer
|
||||
// (JWKS + verification read the public key from the Cert, not the Signer).
|
||||
func (s *Signer) PublicKey() *rsa.PublicKey {
|
||||
if k, ok := s.key.(*rsa.PrivateKey); ok {
|
||||
return &k.PublicKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kid returns the key id (the Cert name).
|
||||
func (s *Signer) Kid() string { return s.kid }
|
||||
|
||||
// Alg returns the JOSE algorithm this signer uses (matches the JWKS `alg`).
|
||||
func (s *Signer) Alg() string { return s.alg }
|
||||
|
||||
// audienceFor computes the token audience per RFC 8707: an explicit resource
|
||||
// indicator wins; a shared application scopes the audience to the org; otherwise
|
||||
// the audience is the client id (the value validators check).
|
||||
func audienceFor(app *schema.Application, resource string) jwt.ClaimStrings {
|
||||
if resource != "" {
|
||||
return jwt.ClaimStrings{resource}
|
||||
}
|
||||
if app.IsShared && app.Organization != "" {
|
||||
return jwt.ClaimStrings{app.ClientId + "-org-" + app.Organization}
|
||||
}
|
||||
return jwt.ClaimStrings{app.ClientId}
|
||||
}
|
||||
|
||||
// pinnedMethod is the app's requested signing method (TokenSigningMethod), or ""
|
||||
// to let the key type decide.
|
||||
func pinnedMethod(app *schema.Application) string {
|
||||
if app == nil {
|
||||
return ""
|
||||
}
|
||||
return app.TokenSigningMethod
|
||||
}
|
||||
|
||||
// methodForKey maps a parsed private key (and an optional app-pinned method
|
||||
// within the same family) to a jwt.SigningMethod and its JOSE alg name.
|
||||
func methodForKey(key any, pinned string) (jwt.SigningMethod, string, error) {
|
||||
switch k := key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
if pinned == "RS512" {
|
||||
return jwt.SigningMethodRS512, "RS512", nil
|
||||
}
|
||||
return jwt.SigningMethodRS256, "RS256", nil
|
||||
case *ecdsa.PrivateKey:
|
||||
switch k.Curve.Params().BitSize {
|
||||
case 256:
|
||||
return jwt.SigningMethodES256, "ES256", nil
|
||||
case 384:
|
||||
return jwt.SigningMethodES384, "ES384", nil
|
||||
case 521:
|
||||
return jwt.SigningMethodES512, "ES512", nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("jwt: unsupported EC curve bit size %d", k.Curve.Params().BitSize)
|
||||
default:
|
||||
return nil, "", errors.New("jwt: unsupported private key type")
|
||||
}
|
||||
}
|
||||
|
||||
// parsePrivateKeyPEM decodes a classical (RSA or EC) PEM private key.
|
||||
func parsePrivateKeyPEM(pemText string) (crypto.Signer, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
if block == nil {
|
||||
return nil, errors.New("jwt: private key is not valid PEM")
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
if k, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt: parse private key: %w", err)
|
||||
}
|
||||
signer, ok := k8.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, errors.New("jwt: PKCS#8 key is not a signing key")
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// parseRSAPrivateKeyPEM decodes a PEM RSA private key (PKCS#1 or PKCS#8).
|
||||
func parseRSAPrivateKeyPEM(pemText string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
if block == nil {
|
||||
return nil, errors.New("jwt: private key is not valid PEM")
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt: parse private key: %w", err)
|
||||
}
|
||||
rk, ok := k8.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("jwt: private key is not RSA")
|
||||
}
|
||||
return rk, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// testKey is a small (fast) RSA key — fine for tests; production uses the Cert.
|
||||
func testKey(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
// A fixed 2048-bit key generated once would be faster, but generating keeps
|
||||
// the test self-contained. 2048 is the JWKS minimum.
|
||||
k, err := rsaGenTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func TestSign_RoundTripAndClaims(t *testing.T) {
|
||||
key := testKey(t)
|
||||
s := NewRSASigner(key, "cert-hanzo", "https://iam.hanzo.ai")
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
app := testApp()
|
||||
|
||||
tokenStr, err := s.Sign(app, "hanzo/alice", "alice@hanzo.ai", "Alice", "openid profile", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify with the public key + assert every claim.
|
||||
var claims Claims
|
||||
parsed, err := jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) {
|
||||
return &key.PublicKey, nil
|
||||
}, jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) }))
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !parsed.Valid {
|
||||
t.Fatal("token not valid")
|
||||
}
|
||||
if kid, _ := parsed.Header["kid"].(string); kid != "cert-hanzo" {
|
||||
t.Fatalf("kid = %q, want cert-hanzo", kid)
|
||||
}
|
||||
if claims.Issuer != "https://iam.hanzo.ai" {
|
||||
t.Fatalf("iss = %q", claims.Issuer)
|
||||
}
|
||||
if claims.Subject != "hanzo/alice" {
|
||||
t.Fatalf("sub = %q", claims.Subject)
|
||||
}
|
||||
if len(claims.Audience) != 1 || claims.Audience[0] != "hanzo-console" {
|
||||
t.Fatalf("aud = %v, want [hanzo-console]", claims.Audience)
|
||||
}
|
||||
if claims.Owner != "hanzo" {
|
||||
t.Fatalf("owner = %q, want hanzo", claims.Owner)
|
||||
}
|
||||
if claims.Scope != "openid profile" || claims.Email != "alice@hanzo.ai" {
|
||||
t.Fatalf("scope/email wrong: %q / %q", claims.Scope, claims.Email)
|
||||
}
|
||||
if claims.ID == "" {
|
||||
t.Fatal("jti empty — every token must be uniquely identifiable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_ExpiredTokenRejected(t *testing.T) {
|
||||
key := testKey(t)
|
||||
s := NewRSASigner(key, "cert-hanzo", "https://iam.hanzo.ai")
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tokenStr, err := s.Sign(testApp(), "u", "", "", "openid", time.Minute, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Validate well after expiry.
|
||||
var claims Claims
|
||||
_, err = jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return &key.PublicKey, nil },
|
||||
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(2 * time.Minute) }))
|
||||
if err == nil {
|
||||
t.Fatal("expired token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign_WrongKeyRejected(t *testing.T) {
|
||||
s := NewRSASigner(testKey(t), "cert-hanzo", "https://iam.hanzo.ai")
|
||||
other := testKey(t)
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
tokenStr, _ := s.Sign(testApp(), "u", "", "", "openid", time.Hour, now)
|
||||
var claims Claims
|
||||
_, err := jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return &other.PublicKey, nil },
|
||||
jwt.WithValidMethods([]string{"RS256"}))
|
||||
if err == nil {
|
||||
t.Fatal("token verified under the wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRSAPrivateKeyPEM_RejectsGarbage(t *testing.T) {
|
||||
if _, err := parseRSAPrivateKeyPEM("not a pem"); err == nil {
|
||||
t.Fatal("garbage PEM accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRSASignerFromCert_PEMRoundTrip(t *testing.T) {
|
||||
key := testKey(t)
|
||||
pemText := rsaKeyToPEM(t, key)
|
||||
cert := &schema.Cert{PrivateKey: pemText}
|
||||
cert.Name = "cert-hanzo"
|
||||
s, err := NewRSASignerFromCert(cert, "https://iam.hanzo.ai")
|
||||
if err != nil {
|
||||
t.Fatalf("load from cert PEM: %v", err)
|
||||
}
|
||||
if s.Kid() != "cert-hanzo" || s.PublicKey() == nil {
|
||||
t.Fatal("signer from cert missing kid/public key")
|
||||
}
|
||||
// Sign+verify to prove the parsed key works.
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
str, err := s.Sign(testApp(), "u", "", "", "openid", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var claims Claims
|
||||
if _, err := jwt.ParseWithClaims(str, &claims, func(*jwt.Token) (any, error) { return s.PublicKey(), nil },
|
||||
jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })); err != nil {
|
||||
t.Fatalf("verify with cert-loaded key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// GET /v1/iam/linked-accounts — the caller's linked social/OAuth identities.
|
||||
//
|
||||
// schema.User has NO single "linkedIdentities" array; a linked identity is stored as
|
||||
// the connector's own column (User.GitHub, User.Google, …) holding the federated
|
||||
// subject — the casdoor data model. So the linked accounts ARE those per-connector
|
||||
// columns that are set; this returns [{provider, subject}] for each non-empty one.
|
||||
// Each item carries only the subject string (the schema stores no per-link display
|
||||
// name / avatar / linkedAt), so a richer per-link shape is not available from iam2.
|
||||
// Self-scoped: resolved from the caller (callerOf), never the request.
|
||||
|
||||
// PathLinkedAccounts is the canonical linked-identities endpoint.
|
||||
const PathLinkedAccounts = "/v1/iam/linked-accounts"
|
||||
|
||||
// connectorTags is the set of User json tags that hold a linked federated-identity
|
||||
// subject — the casdoor per-connector columns (schema/user.go "Linked
|
||||
// federated-identity subjects"). ONE list; linked-accounts reflects the non-empty
|
||||
// ones out, so a new connector column is picked up by adding its tag here only.
|
||||
var connectorTags = fields(
|
||||
"github google qq wechat facebook dingtalk weibo gitee linkedin wecom lark gitlab " +
|
||||
"adfs baidu alipay iam infoflow apple azuread azureadb2c slack steam bilibili okta " +
|
||||
"douyin kwai line amazon auth0 battlenet bitbucket box cloudfoundry dailymotion deezer " +
|
||||
"digitalocean discord dropbox eveonline fitbit gitea heroku influxcloud instagram " +
|
||||
"intercom kakao lastfm mailru meetup microsoftonline naver nextcloud onedrive oura " +
|
||||
"patreon paypal salesforce shopify soundcloud spotify strava stripe telegram tiktok " +
|
||||
"tumblr twitch twitter typetalk uber vk wepay xero yahoo yammer yandex zoom " +
|
||||
"custom custom2 custom3 custom4 custom5 custom6 custom7 custom8 custom9 custom10")
|
||||
|
||||
// linkedAccount is one linked social/OAuth identity.
|
||||
type linkedAccount struct {
|
||||
Provider string `json:"provider"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// linkedAccountsHandler returns the caller's linked identities.
|
||||
func linkedAccountsHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
if user == nil {
|
||||
return httpx.Err(c, "the user does not exist")
|
||||
}
|
||||
return httpx.Ok(c, linkedAccountsOf(user))
|
||||
}
|
||||
}
|
||||
|
||||
// linkedAccountsOf reflects a user's non-empty connector columns into the linked list.
|
||||
// Reflection over the ONE connectorTags set avoids a per-field cascade and stays
|
||||
// faithful to whichever columns hold a subject.
|
||||
func linkedAccountsOf(u *schema.User) []linkedAccount {
|
||||
out := []linkedAccount{}
|
||||
v := reflect.ValueOf(*u)
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
|
||||
if !connectorTags[tag] {
|
||||
continue
|
||||
}
|
||||
if s := v.Field(i).String(); s != "" {
|
||||
out = append(out, linkedAccount{Provider: tag, Subject: s})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fields turns a space-separated tag list into a set.
|
||||
func fields(list string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, f := range strings.Fields(list) {
|
||||
m[f] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/sessions"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// The credential login front door: POST /v1/iam/login. The @hanzo/iam SDK +
|
||||
// hanzo.id portal post here with the app/org + username/password (+ the PKCE
|
||||
// authorize params when type=code). On success with type=code we mint a
|
||||
// PKCE-bound authorization code and return it in the Response envelope; the SDK
|
||||
// then exchanges it at /v1/iam/oauth/token. Login by EMAIL or USERNAME.
|
||||
//
|
||||
// This is the interactive-flow counterpart to the token endpoint: login mints
|
||||
// the code, /token redeems it. Password verification is bcrypt (constant-time),
|
||||
// never plaintext, and the hash never crosses a response.
|
||||
|
||||
// PathLogin is the canonical credential-login endpoint.
|
||||
const PathLogin = "/v1/iam/login"
|
||||
|
||||
// loginForm is the request body the SDK/portal posts.
|
||||
type loginForm struct {
|
||||
Application string `json:"application"`
|
||||
Organization string `json:"organization"`
|
||||
Username string `json:"username"` // email OR username
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"` // "code" (PKCE authorize) | "device" (RFC 8628 approval) | "login" (bare session)
|
||||
|
||||
// UserCode is the RFC 8628 code the device displays, transcribed by the human
|
||||
// approving it (type=device).
|
||||
UserCode string `json:"userCode"`
|
||||
|
||||
// PKCE authorize passthrough (present when type=code).
|
||||
ClientId string `json:"clientId"`
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
State string `json:"state"`
|
||||
Scope string `json:"scope"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
Resource string `json:"resource"`
|
||||
|
||||
// The second factor (present on the finishing request). Challenge names the
|
||||
// outstanding ceremony; a browser returns it in the cookie the gate set and
|
||||
// leaves this empty.
|
||||
MfaType string `json:"mfaType"`
|
||||
Passcode string `json:"passcode"`
|
||||
RecoveryCode string `json:"recoveryCode"`
|
||||
EnableMfaRemember bool `json:"enableMfaRemember"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
// routeLogin registers POST /v1/iam/login.
|
||||
func routeLogin(r zip.Router, db orm.DB) {
|
||||
r.Post(PathLogin, loginHandler(db))
|
||||
}
|
||||
|
||||
func loginHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
var f loginForm
|
||||
if err := c.Bind(&f); err != nil {
|
||||
return httpx.Err(c, "invalid request body")
|
||||
}
|
||||
ctx := c.Context()
|
||||
|
||||
// A post carrying no fresh credential but naming an outstanding challenge is
|
||||
// the SECOND half of a sign-in this endpoint already gated: the second-factor
|
||||
// answer. The principal comes from the challenge, never from the body.
|
||||
if f.Username == "" && f.Password == "" {
|
||||
if id := ReadChallenge(c, f.Challenge); id != "" {
|
||||
return finishMfa(c, db, id, f)
|
||||
}
|
||||
}
|
||||
|
||||
if f.Organization == "" || f.Username == "" || f.Password == "" {
|
||||
return httpx.Err(c, "organization, username and password are required")
|
||||
}
|
||||
|
||||
user, err := resolveLoginUser(ctx, db, f.Organization, f.Username)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// The hash algorithm is a property of the ROW, not a constant: use the
|
||||
// user's PasswordType, falling back to the organization's (v1's
|
||||
// object/check.go contract). Every live v1 row is argon2id — a bcrypt-only
|
||||
// verify would fail every real login at cutover.
|
||||
orgPasswordType := loginOrgPasswordType(ctx, db, f.Organization)
|
||||
// One opaque failure for "no such user" and "wrong password" — no oracle
|
||||
// that reveals whether the account exists.
|
||||
if user == nil || !users.VerifyPassword(user, f.Password, orgPasswordType) {
|
||||
return httpx.Err(c, "the username or password is incorrect")
|
||||
}
|
||||
|
||||
// The password proved ONE factor. The gate holds the sign-in when a second
|
||||
// factor is outstanding — before ANY token or device approval — and answers
|
||||
// the request itself; a false means nothing more is owed. The verificationType
|
||||
// is "" because a password proves none of the offerable factors.
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
gated, err := gate(c, db, user, org, "")
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if gated {
|
||||
return nil
|
||||
}
|
||||
|
||||
return loginGrant(c, db, user, f)
|
||||
}
|
||||
}
|
||||
|
||||
// loginGrant completes a sign-in that has passed the gate: a device approval, a
|
||||
// bare portal session, or a PKCE-bound authorization code. It is the ONE minting
|
||||
// tail every interactive path reaches — the credential post and the second-factor
|
||||
// finish alike — so the checks between "this is the user" and "here is the grant"
|
||||
// are stated once and cannot be true of one path and false of another.
|
||||
func loginGrant(c *zip.Ctx, db orm.DB, user *schema.User, f loginForm) error {
|
||||
ctx := c.Context()
|
||||
|
||||
// type=device: approve a pending RFC 8628 device authorization against the
|
||||
// identity now fully proven (device.go).
|
||||
if f.Type == "device" {
|
||||
return approveDevice(c, db, user, f.UserCode)
|
||||
}
|
||||
|
||||
userID := user.Owner + "/" + user.Name
|
||||
|
||||
// type=login: a bare portal sign-in. Establish the durable session the portal +
|
||||
// the gateway admin-guard read via get-account, then report the user id. The
|
||||
// cookie is best-effort — a session failure never blocks a valid login.
|
||||
if f.Type != "code" {
|
||||
_ = sessions.Set(ctx, c.Fiber(), db, user.Owner, user.Name, f.Application)
|
||||
return httpx.Ok(c, userID)
|
||||
}
|
||||
|
||||
// type=code: mint a PKCE-bound authorization code for the OAuth flow. The org is
|
||||
// the USER's own, from the loaded row, so a second-factor post (which carries no
|
||||
// organization field) is checked exactly like the first.
|
||||
app, err := resolveLoginApp(ctx, db, f)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if app == nil {
|
||||
return httpx.Err(c, "the application does not exist")
|
||||
}
|
||||
if user.Owner != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
|
||||
return httpx.Err(c, "the user is not permitted to sign in to this application")
|
||||
}
|
||||
// Bind the code to an EXACTLY-registered redirect URI (RFC 6749 §3.1.2.3); the
|
||||
// token endpoint re-checks it. A supplied-but-unregistered URI is refused.
|
||||
if f.RedirectUri != "" && !app.IsRedirectUriValid(f.RedirectUri) {
|
||||
return httpx.Err(c, "invalid redirect_uri")
|
||||
}
|
||||
method := normalizeChallengeMethod(f.CodeChallenge, f.CodeChallengeMethod)
|
||||
if f.CodeChallenge != "" && method != "S256" {
|
||||
return httpx.Err(c, "only S256 PKCE is supported")
|
||||
}
|
||||
// A public client (no secret) must use PKCE — no downgrade.
|
||||
if app.ClientSecret == "" && f.CodeChallenge == "" {
|
||||
return httpx.Err(c, "PKCE is required for public clients")
|
||||
}
|
||||
code, err := MintCode(app, userID, f.Scope, f.CodeChallenge, method, f.Resource, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// Bind the redirect_uri and nonce onto the code so the token exchange can
|
||||
// re-verify the redirect and echo the nonce into the id_token.
|
||||
code.RedirectUri = f.RedirectUri
|
||||
code.Nonce = f.Nonce
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
// The SDK reads data as the authorization code to exchange at /token.
|
||||
return httpx.Ok(c, code.Code)
|
||||
}
|
||||
|
||||
// resolveLoginUser looks a user up by email (contains "@") or username, scoped
|
||||
// to the org.
|
||||
func resolveLoginUser(ctx context.Context, db orm.DB, org, identifier string) (*schema.User, error) {
|
||||
if strings.Contains(identifier, "@") {
|
||||
u, err := store.GetUserByEmail(ctx, db, org, identifier)
|
||||
if err != nil || u != nil {
|
||||
return u, err
|
||||
}
|
||||
// Fall through: some accounts set name = email (email is not indexed as
|
||||
// a separate login) — try name too.
|
||||
}
|
||||
return store.GetUserByName(ctx, db, org, identifier)
|
||||
}
|
||||
|
||||
// resolveLoginApp resolves the OAuth app for a type=code login: by clientId when
|
||||
// present, else by (org, application name).
|
||||
func resolveLoginApp(ctx context.Context, db orm.DB, f loginForm) (*schema.Application, error) {
|
||||
if f.ClientId != "" {
|
||||
return store.GetApplicationByClientId(ctx, db, f.ClientId)
|
||||
}
|
||||
if f.Application != "" {
|
||||
return store.GetApplicationByName(ctx, db, "admin", f.Application)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// loginOrgPasswordType returns the organization's PasswordType — the fallback
|
||||
// when a user row carries none. A missing org yields "" (the user's own type
|
||||
// then decides; if neither is set, cred.Verify fails closed rather than guessing
|
||||
// an algorithm).
|
||||
func loginOrgPasswordType(ctx context.Context, db orm.DB, org string) string {
|
||||
o, err := store.GetOrganizationByName(ctx, db, org)
|
||||
if err != nil || o == nil {
|
||||
return ""
|
||||
}
|
||||
return o.PasswordType
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// The MFA gate at login, driven through the REAL mounted router. The contract is
|
||||
// not a status code: every one of these answers is a 200, because the envelope
|
||||
// carries the outcome. What matters is WHICH answer, and — the point of the whole
|
||||
// gate — whether a token row exists afterwards. A test that checked only the
|
||||
// status would pass while every 2FA user signed in with a password alone.
|
||||
|
||||
// newApp mounts the OIDC surface on an EXISTING store, so a test can seed the
|
||||
// same db the router serves (newServer opens its own).
|
||||
func newApp(t *testing.T, db orm.DB) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{AppName: "iam2-test", DisableStartupMessage: true})
|
||||
// The OIDC surface is the pre-auth PUBLIC group; login + the challenge finish
|
||||
// both live here, so a root (empty-prefix) router mounts them at their absolute
|
||||
// paths (main renamed Mount→Route on the zip-group model).
|
||||
Route(app.Group(""), db)
|
||||
return app
|
||||
}
|
||||
|
||||
// enrolled seeds a user with a password AND a live TOTP factor, returning the
|
||||
// TOTP secret.
|
||||
func enrolled(t *testing.T, db orm.DB, name, password string) string {
|
||||
t.Helper()
|
||||
seedUser(t, db, name, name+"@hanzo.ai", password)
|
||||
secret, _, err := factor.Enroll("hanzo/"+name, "Hanzo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.TotpSecret = secret
|
||||
u.PreferredMfaType = factor.App
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// tokens counts persisted token rows — the store-side proof that no credential
|
||||
// was minted. The gate's whole job is that this stays zero until the second
|
||||
// factor lands.
|
||||
func tokens(t *testing.T, db orm.DB) int {
|
||||
t.Helper()
|
||||
n, err := orm.TypedQuery[schema.Token](db).Count(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// passcode computes the code an authenticator would show right now.
|
||||
func passcode(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
code, err := totp.GenerateCode(secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// challengeOf extracts the challenge id the gate set as a cookie.
|
||||
func challengeOf(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == challengeCookie && ck.Value != "" {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("the gate set no challenge cookie")
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestEnrolledUserIsChallengedAndGetsNoToken is THE regression. Before the gate,
|
||||
// login verified the password and minted a code directly: an enrolled user signed
|
||||
// in with one factor and the second was never asked for. Not a missing feature —
|
||||
// a silent downgrade of every 2FA account.
|
||||
func TestEnrolledUserIsChallengedAndGetsNoToken(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "correct horse battery staple")
|
||||
|
||||
resp, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "correct horse battery staple",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("gate answered an error: %v", m["msg"])
|
||||
}
|
||||
// `data` is the literal string the portal compares against. Any other shape
|
||||
// and the client reads it as an authorization code.
|
||||
if m["data"] != NextMfa {
|
||||
t.Fatalf("data = %q, want %q — the client treats anything else as a code, so MFA is bypassed", m["data"], NextMfa)
|
||||
}
|
||||
// data2 carries the factors to choose from.
|
||||
list, ok := m["data2"].([]any)
|
||||
if !ok || len(list) != 1 {
|
||||
t.Fatalf("data2 = %#v, want exactly the one enrolled factor", m["data2"])
|
||||
}
|
||||
got := list[0].(map[string]any)
|
||||
if got["mfaType"] != factor.App || got["enabled"] != true {
|
||||
t.Fatalf("offered factor = %#v, want the enabled app factor", got)
|
||||
}
|
||||
// The masked projection must not carry the shared secret out.
|
||||
if s := string(body); strings.Contains(s, "secret") || strings.Contains(s, "recoveryCodes") {
|
||||
t.Fatalf("the challenge leaked secret material: %s", s)
|
||||
}
|
||||
|
||||
// THE assertion: nothing was minted.
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted at the challenge — the password alone bought a credential", n)
|
||||
}
|
||||
challengeOf(t, resp)
|
||||
}
|
||||
|
||||
// TestChallengeAnsweredWithPasscodeMintsCode — the happy path: the second factor
|
||||
// lands and the code appears.
|
||||
func TestChallengeAnsweredWithPasscodeMintsCode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret),
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("the correct passcode was refused: %v", m["msg"])
|
||||
}
|
||||
code, _ := m["data"].(string)
|
||||
if code == "" || code == NextMfa || code == RequiredMfa {
|
||||
t.Fatalf("data = %q, want an authorization code", m["data"])
|
||||
}
|
||||
tok, err := store2GetTokenByCode(db, code)
|
||||
if err != nil || tok == nil {
|
||||
t.Fatalf("the minted code resolves to no token row: %v", err)
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("code bound to %q, want hanzo/alice", tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongPasscodeMintsNothing — a failed second factor must leave the sign-in
|
||||
// exactly where it was: nowhere.
|
||||
func TestWrongPasscodeMintsNothing(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": factor.App, "passcode": "000000",
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("a wrong passcode was accepted: %#v", m)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted for a wrong passcode", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChallengeIsSingleUse — a challenge is spent by the attempt that takes it,
|
||||
// so a captured id cannot be replayed. The wrong passcode below spends it; the
|
||||
// RIGHT passcode afterwards must still fail, on the challenge and not the code.
|
||||
func TestChallengeIsSingleUse(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
first := map[string]any{"type": "code", "clientId": "hanzo-app", "challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret)}
|
||||
if m := decode(t, mustBody(t, app, first)); m["status"] != "ok" {
|
||||
t.Fatalf("first use failed: %v", m["msg"])
|
||||
}
|
||||
// Same id, same valid passcode, second time.
|
||||
m := decode(t, mustBody(t, app, first))
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("a spent challenge was accepted again: %#v", m)
|
||||
}
|
||||
if m["msg"] != ErrChallenge.Error() {
|
||||
t.Fatalf("msg = %q, want the challenge refusal %q", m["msg"], ErrChallenge.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChallengeBindsItsOwnSubject — invariant 3. A challenge minted for alice
|
||||
// must resolve alice even when the body names mallory. The user comes from the
|
||||
// verified server-side record, never from a request parameter.
|
||||
func TestChallengeBindsItsOwnSubject(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
seedUser(t, db, "mallory", "mallory@hanzo.ai", "pw")
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw",
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret),
|
||||
// The body tries to redirect the ceremony at another account.
|
||||
"username": "", "organization": "hanzo", "name": "mallory",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("the ceremony failed: %v", m["msg"])
|
||||
}
|
||||
tok, err := store2GetTokenByCode(db, m["data"].(string))
|
||||
if err != nil || tok == nil {
|
||||
t.Fatal("no token row for the minted code")
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("code bound to %q — the body redirected the challenge's subject", tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryCodeIsAcceptedOnceAndStoredHashed proves three things at once: a
|
||||
// recovery code answers the challenge, it is CONSUMED (a second use fails), and
|
||||
// what sits in the row is a bcrypt digest — never the code itself.
|
||||
func TestRecoveryCodeIsAcceptedOnceAndStoredHashed(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "pw")
|
||||
|
||||
plain, err := factor.MintRecovery()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := factor.HashRecovery(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := userRow(t, db, "alice")
|
||||
u.RecoveryCodes = []string{hash}
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(hash, plain) {
|
||||
t.Fatal("the stored value contains the plaintext recovery code")
|
||||
}
|
||||
|
||||
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
id := challengeOf(t, resp)
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": id, "recoveryCode": plain,
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("the recovery code was refused: %v", m["msg"])
|
||||
}
|
||||
if code, _ := m["data"].(string); code == "" || code == NextMfa {
|
||||
t.Fatalf("data = %q, want an authorization code", m["data"])
|
||||
}
|
||||
// Spent: the row no longer carries it.
|
||||
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
|
||||
t.Fatalf("recovery codes after use = %v, want none — a one-time code survived", got)
|
||||
}
|
||||
// And a second sign-in cannot reuse it.
|
||||
resp2, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
_, body2 := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp2), "recoveryCode": plain,
|
||||
}))
|
||||
if m2 := decode(t, body2); m2["status"] != "error" {
|
||||
t.Fatalf("a spent recovery code signed in a second time: %#v", m2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyPlaintextRecoveryCodeStillVerifies — every recovery code migrated
|
||||
// from v1 is PLAINTEXT (object/factor.go:81 compares in the clear). The algorithm is
|
||||
// a property of the stored value, so a legacy row must still verify, and the
|
||||
// plaintext must die on first use.
|
||||
func TestLegacyPlaintextRecoveryCodeStillVerifies(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
enrolled(t, db, "alice", "pw")
|
||||
|
||||
const legacy = "0d5a7f0e-3a1e-4a1a-9f6c-2b1d3e4f5a6b" // a v1 uuid.NewString() code
|
||||
u := userRow(t, db, "alice")
|
||||
u.RecoveryCodes = []string{legacy}
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp), "recoveryCode": legacy,
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "ok" {
|
||||
t.Fatalf("a migrated v1 plaintext recovery code was refused: %v — every live 2FA user's way back is gone", m["msg"])
|
||||
}
|
||||
if got := userRow(t, db, "alice").RecoveryCodes; len(got) != 0 {
|
||||
t.Fatalf("the legacy plaintext survived its use: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasscodeRefusedWhenItRepeatsTheUsedFactor — v1 controllers/auth.go:1325.
|
||||
// The factor already used to get here cannot answer for the one still owed.
|
||||
func TestPasscodeRefusedWhenItRepeatsTheUsedFactor(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
u := userRow(t, db, "alice")
|
||||
|
||||
// A challenge whose payload says "the app factor was already used".
|
||||
id, err := MintChallenge(context.Background(), db, KindMfa, "hanzo/"+u.Name, factor.App, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app",
|
||||
"challenge": id, "mfaType": factor.App, "passcode": passcode(t, secret),
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "error" {
|
||||
t.Fatalf("the just-used factor answered its own challenge: %#v", m)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRememberDeadlineRoundTrips — the "don't ask again" window short-circuits
|
||||
// the whole gate, so the value the writer writes must be the value the reader
|
||||
// reads. A format mismatch is silent: a permanent skip, or a permanent challenge.
|
||||
func TestRememberDeadlineRoundTrips(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
// An org with a real remember window (live orgs leave it at zero).
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name, o.MfaRememberInHours = "admin", "hanzo", 24
|
||||
o.SetId("admin/hanzo")
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
|
||||
"mfaType": factor.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
|
||||
}))
|
||||
if m := decode(t, body); m["status"] != "ok" {
|
||||
t.Fatalf("the passcode was refused: %v", m["msg"])
|
||||
}
|
||||
|
||||
// The exact stored string must parse for the exact reader the gate uses.
|
||||
stored := userRow(t, db, "alice").MfaRememberDeadline
|
||||
if stored == "" {
|
||||
t.Fatal("enableMfaRemember wrote no deadline")
|
||||
}
|
||||
if !remembered(userRow(t, db, "alice"), time.Now()) {
|
||||
t.Fatalf("the gate cannot read back the deadline it wrote (%q) — the window is silently dead", stored)
|
||||
}
|
||||
|
||||
// A future deadline SKIPS the challenge: the next password login mints.
|
||||
_, body2 := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
m2 := decode(t, body2)
|
||||
if m2["data"] == NextMfa {
|
||||
t.Fatal("a live remember window still challenged")
|
||||
}
|
||||
if code, _ := m2["data"].(string); code == "" {
|
||||
t.Fatalf("remembered login did not mint: %#v", m2)
|
||||
}
|
||||
|
||||
// A PAST deadline challenges again.
|
||||
u := userRow(t, db, "alice")
|
||||
u.MfaRememberDeadline = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
if err := u.UpdateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body3 := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
if m3 := decode(t, body3); m3["data"] != NextMfa {
|
||||
t.Fatalf("an expired remember window skipped the gate: %#v", m3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroRememberWindowStillChallenges pins the LIVE configuration: every
|
||||
// organization today leaves MfaRememberInHours at zero, which puts the deadline
|
||||
// in the past the instant it is written. "Fixing" a zero into an always-on skip
|
||||
// would turn 2FA off for every tenant at once.
|
||||
func TestZeroRememberWindowStillChallenges(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
secret := enrolled(t, db, "alice", "pw")
|
||||
|
||||
login := map[string]string{"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app"}
|
||||
resp, _ := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
do(t, app, jsonReq("POST", PathLogin, map[string]any{
|
||||
"type": "code", "clientId": "hanzo-app", "challenge": challengeOf(t, resp),
|
||||
"mfaType": factor.App, "passcode": passcode(t, secret), "enableMfaRemember": true,
|
||||
}))
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, login))
|
||||
if m := decode(t, body); m["data"] != NextMfa {
|
||||
t.Fatalf("a zero remember window skipped the gate: %#v — 2FA is off for every live org", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrgRequiredFactorPromptsEnrollment — v1 object/organization.go:770. The org
|
||||
// demands a factor the user has not enrolled, so the answer is "go enroll", not a
|
||||
// challenge it could never answer.
|
||||
func TestOrgRequiredFactorPromptsEnrollment(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "pw") // no factor
|
||||
|
||||
o := orm.New[schema.Organization](db)
|
||||
o.Owner, o.Name = "admin", "hanzo"
|
||||
o.MfaItems = []*schema.MfaItem{{Name: factor.App, Rule: "Required"}}
|
||||
o.SetId("admin/hanzo")
|
||||
if err := o.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "alice", "password": "pw", "type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["data"] != RequiredMfa {
|
||||
t.Fatalf("data = %q, want %q", m["data"], RequiredMfa)
|
||||
}
|
||||
if n := tokens(t, db); n != 0 {
|
||||
t.Fatalf("%d token row(s) persisted while a required factor was missing", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnenrolledUserSignsInUnchanged — the gate must be invisible to everyone
|
||||
// else. A user with no factor still logs in with a password, exactly as before.
|
||||
func TestUnenrolledUserSignsInUnchanged(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
app := newApp(t, db)
|
||||
seedApp(t, db, appOpts{clientID: "hanzo-app", secret: "s3cret"})
|
||||
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
|
||||
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
|
||||
"organization": "hanzo", "username": "bob", "password": "pw", "type": "code", "clientId": "hanzo-app",
|
||||
}))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("an unenrolled user was refused: %v", m["msg"])
|
||||
}
|
||||
if code, _ := m["data"].(string); code == "" || code == NextMfa || code == RequiredMfa {
|
||||
t.Fatalf("data = %q, want an authorization code", m["data"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func mustBody(t *testing.T, app *zip.App, body any) []byte {
|
||||
t.Helper()
|
||||
_, b := do(t, app, jsonReq("POST", PathLogin, body))
|
||||
return b
|
||||
}
|
||||
|
||||
func userRow(t *testing.T, db orm.DB, name string) *schema.User {
|
||||
t.Helper()
|
||||
u, err := orm.TypedQuery[schema.User](db).Filter("Owner=", "hanzo").Filter("Name=", name).First()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func store2GetTokenByCode(db orm.DB, code string) (*schema.Token, error) {
|
||||
t, err := orm.TypedQuery[schema.Token](db).Filter("Code=", code).First()
|
||||
if err == orm.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// seedUserInOrg creates a bcrypt-credentialed user in an arbitrary org.
|
||||
func seedUserInOrg(t *testing.T, db orm.DB, org, name, email, password string) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner = org
|
||||
u.Name = name
|
||||
u.Email = email
|
||||
u.PasswordHash = string(hash)
|
||||
u.PasswordType = "bcrypt"
|
||||
u.SetId(org + "/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user %s/%s: %v", org, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A user authenticated in one tenant cannot obtain an authorization code for a
|
||||
// single-tenant application belonging to a different org — even with fully valid
|
||||
// credentials in their own org.
|
||||
func TestLogin_CrossOrgSignInRejected(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "conf", secret: "s3cret", redirectURIs: []string{testRedirect}}) // org "hanzo"
|
||||
seedUserInOrg(t, db, "lux", "eve", "eve@lux.example", "pw") // valid user in org "lux"
|
||||
|
||||
f := map[string]string{
|
||||
"organization": "lux", "username": "eve", "password": "pw",
|
||||
"clientId": "conf", "redirectUri": testRedirect, "scope": "openid", "type": "code",
|
||||
}
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, f))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "error" {
|
||||
t.Fatalf("cross-org sign-in must be refused; got %v", m)
|
||||
}
|
||||
if code, _ := m["data"].(string); code != "" {
|
||||
t.Fatalf("no code may be minted for a cross-org sign-in; got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A shared application legitimately accepts users from any org.
|
||||
func TestLogin_SharedAppAllowsCrossOrg(t *testing.T) {
|
||||
app, db := newServer(t)
|
||||
seedApp(t, db, appOpts{clientID: "shared", secret: "s3cret", redirectURIs: []string{testRedirect}, shared: true})
|
||||
seedUserInOrg(t, db, "lux", "eve", "eve@lux.example", "pw")
|
||||
|
||||
f := map[string]string{
|
||||
"organization": "lux", "username": "eve", "password": "pw",
|
||||
"clientId": "shared", "redirectUri": testRedirect, "scope": "openid", "type": "code",
|
||||
}
|
||||
_, body := do(t, app, jsonReq("POST", PathLogin, f))
|
||||
m := decode(t, body)
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("shared app must accept a cross-org user; got %v", m)
|
||||
}
|
||||
if code, _ := m["data"].(string); code == "" {
|
||||
t.Fatal("shared app cross-org sign-in should mint a code")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// seedUser creates a user with a bcrypt password in org "hanzo".
|
||||
func seedUser(t *testing.T, db orm.DB, name, email, password string) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) // MinCost = fast tests
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner = "hanzo"
|
||||
u.Name = name
|
||||
u.Email = email
|
||||
u.PasswordHash = string(hash)
|
||||
u.PasswordType = "bcrypt"
|
||||
u.SetId("hanzo/" + name)
|
||||
if err := u.CreateCtx(context.Background()); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginToTokenFlow is the full interactive round-trip: a password login
|
||||
// (verified with bcrypt) mints a PKCE-bound code, which the token endpoint
|
||||
// redeems into a signed JWT. Proves login→code→token end to end.
|
||||
func TestLoginToTokenFlow(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
key := mustGenRSA(t)
|
||||
app := seedAppWithCert(t, db, key)
|
||||
seedUser(t, db, "alice", "alice@hanzo.ai", "correct horse battery staple")
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
|
||||
verifier := "login-verifier-000000000000000000000000000000000"
|
||||
challenge := ComputeS256Challenge(verifier)
|
||||
|
||||
// --- login side: resolve app+user, verify password, mint the code ---
|
||||
user, err := resolveLoginUser(ctx, db, "hanzo", "alice@hanzo.ai") // login by EMAIL
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("resolve user by email: %v (nil=%v)", err, user == nil)
|
||||
}
|
||||
code, err := MintCode(app, user.Owner+"/"+user.Name, "openid profile", challenge, "S256", "", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// --- token side: redeem the code with the verifier ---
|
||||
tok, _ := store.GetTokenByCode(ctx, db, code.Code)
|
||||
if err := RedeemCode(tok, app.Name, verifier, now.Add(time.Second)); err != nil {
|
||||
t.Fatalf("redeem: %v", err)
|
||||
}
|
||||
if tok.User != "hanzo/alice" {
|
||||
t.Fatalf("code bound to wrong user: %q", tok.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoginUser_ByUsernameAndEmail(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedUser(t, db, "bob", "bob@hanzo.ai", "pw")
|
||||
|
||||
byName, _ := resolveLoginUser(ctx, db, "hanzo", "bob")
|
||||
if byName == nil || byName.Name != "bob" {
|
||||
t.Fatal("login by username failed")
|
||||
}
|
||||
byEmail, _ := resolveLoginUser(ctx, db, "hanzo", "bob@hanzo.ai")
|
||||
if byEmail == nil || byEmail.Name != "bob" {
|
||||
t.Fatal("login by email failed")
|
||||
}
|
||||
// Wrong org → not found (tenant isolation).
|
||||
other, _ := resolveLoginUser(ctx, db, "lux", "bob")
|
||||
if other != nil {
|
||||
t.Fatal("user resolved in the wrong org — tenant isolation broken")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The end-session endpoint: GET/POST /v1/iam/oauth/logout. iam2 holds no
|
||||
// server-side browser session to destroy here, so logout's security-relevant
|
||||
// job is the redirect: it bounces to post_logout_redirect_uri ONLY when that URI
|
||||
// is registered by the client named in a signature-verified id_token_hint —
|
||||
// never to an unvalidated absolute URL (open-redirect defense).
|
||||
func logoutHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
redirect := param(c, "post_logout_redirect_uri")
|
||||
if redirect == "" {
|
||||
return c.JSON(200, map[string]string{"status": "ok"})
|
||||
}
|
||||
app := appFromIDTokenHint(c.Context(), db, param(c, "id_token_hint"))
|
||||
if app == nil || !app.IsRedirectUriValid(redirect) {
|
||||
// No proof the caller owns the target — refuse to redirect.
|
||||
return c.JSON(200, map[string]string{"status": "ok"})
|
||||
}
|
||||
if state := param(c, "state"); state != "" {
|
||||
sep := "?"
|
||||
if strings.Contains(redirect, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
redirect += sep + "state=" + url.QueryEscape(state)
|
||||
}
|
||||
return c.Redirect(302, redirect)
|
||||
}
|
||||
}
|
||||
|
||||
// appFromIDTokenHint resolves the application an id_token_hint was issued to, but
|
||||
// only when the hint's signature verifies. A forged or unsigned hint yields nil,
|
||||
// so it can never authorize a redirect.
|
||||
func appFromIDTokenHint(ctx context.Context, db orm.DB, hint string) *schema.Application {
|
||||
if hint == "" {
|
||||
return nil
|
||||
}
|
||||
claims, err := verifyToken(ctx, db, hint)
|
||||
if err != nil || len(claims.Audience) == 0 {
|
||||
return nil
|
||||
}
|
||||
app, err := store.GetApplicationByClientId(ctx, db, claims.Audience[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return app
|
||||
}
|
||||
@@ -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,192 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/mfa/factor"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The login-time second-factor gate. A verified password proves ONE factor;
|
||||
// everything here decides whether a SECOND is owed before any token or device
|
||||
// approval is minted. It is the counterpart to the enrollment surface in
|
||||
// internal/mfa — enrollment decides what factors a user HAS, this decides when the
|
||||
// sign-in must present one.
|
||||
//
|
||||
// The two answers are v1's wire STRINGS (object/factor.go:50-54): the client
|
||||
// string-compares `data` against them, so they are wire format, not internal
|
||||
// names. Any other shape and the client reads the answer as an authorization code
|
||||
// and the factor is skipped.
|
||||
const (
|
||||
// RequiredMfa — the organization requires a factor this user has not enrolled;
|
||||
// the client must divert to enrollment.
|
||||
RequiredMfa = "RequiredMfa"
|
||||
// NextMfa — the user has factors; data2 carries the allowed ones and the client
|
||||
// must post one back. NO code is minted with this answer.
|
||||
NextMfa = "NextMfa"
|
||||
)
|
||||
|
||||
// gate is the second-factor decision — the ONE place a sign-in is held. It answers
|
||||
// the request itself and reports true when it did; a false means this principal has
|
||||
// proven everything it owes and the caller may mint.
|
||||
//
|
||||
// Every path that signs a user in calls this BEFORE minting a token or approving a
|
||||
// device — one function, every call site, because a gate that exists in one branch
|
||||
// is not a gate.
|
||||
//
|
||||
// verificationType names the factor the caller already proved, so the challenge
|
||||
// never offers it back. "" excludes nothing (a password proves none of the
|
||||
// offerable factors).
|
||||
func gate(c *zip.Ctx, db orm.DB, user *schema.User, org *schema.Organization, verificationType string) (bool, error) {
|
||||
ctx := c.Context()
|
||||
|
||||
// The organization REQUIRES a factor this user has not enrolled: the answer is
|
||||
// enrollment, not a challenge it could never answer.
|
||||
if factor.Prompt(org, user) {
|
||||
return true, httpx.Ok(c, RequiredMfa)
|
||||
}
|
||||
if !factor.Enabled(user) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// "Remember this device" — a deadline in the FUTURE skips the factor. Written by
|
||||
// remember() with the same RFC3339 the parse below expects; a value the parser
|
||||
// cannot read is treated as no deadline, so a bad value re-challenges rather than
|
||||
// silently granting a permanent skip.
|
||||
if remembered(user, nowFunc()) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
allow := allowList(user, org, verificationType)
|
||||
if len(allow) == 0 {
|
||||
// Every factor is either the one just used or not actually enrolled: there is
|
||||
// nothing left to ask for.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
id, err := MintChallenge(ctx, db, KindMfa, user.Owner+"/"+user.Name, verificationType, nowFunc())
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
SetChallenge(c, id)
|
||||
// data is the STRING "NextMfa"; data2 carries the factors. No code is minted
|
||||
// here — that is the whole point of the gate.
|
||||
return true, httpx.Ok(c, NextMfa, allow)
|
||||
}
|
||||
|
||||
// allowList is the factors a challenge may be answered with: enrolled, and not the
|
||||
// one the caller just used. Each carries the org's remember window so the client
|
||||
// can offer "don't ask again".
|
||||
func allowList(user *schema.User, org *schema.Organization, verificationType string) []*schema.MfaProps {
|
||||
hours := 0
|
||||
if org != nil {
|
||||
hours = org.MfaRememberInHours
|
||||
}
|
||||
allow := []*schema.MfaProps{}
|
||||
for _, p := range factor.AllProps(user) {
|
||||
if !p.Enabled || p.MfaType == verificationType {
|
||||
continue
|
||||
}
|
||||
p.MfaRememberInHours = hours
|
||||
allow = append(allow, p)
|
||||
}
|
||||
return allow
|
||||
}
|
||||
|
||||
// remembered reports whether the user's "don't ask again" window is still open. An
|
||||
// unparsable or empty deadline is not a skip: this fails CLOSED, to the challenge.
|
||||
func remembered(user *schema.User, now time.Time) bool {
|
||||
if user.MfaRememberDeadline == "" {
|
||||
return false
|
||||
}
|
||||
deadline, err := time.Parse(time.RFC3339, user.MfaRememberDeadline)
|
||||
return err == nil && deadline.After(now)
|
||||
}
|
||||
|
||||
// finishMfa answers an outstanding challenge. The user is loaded from the
|
||||
// CHALLENGE's subject — never from the request — so a body naming another account
|
||||
// cannot redirect the ceremony. Taking the challenge spends it, so a passcode
|
||||
// replayed against the same id loses. On success it completes the ORIGINAL sign-in
|
||||
// through the same loginGrant every other path uses, so a second factor over a
|
||||
// device approval reaches approveDevice, not a token.
|
||||
func finishMfa(c *zip.Ctx, db orm.DB, id string, f loginForm) error {
|
||||
ctx := c.Context()
|
||||
ch, err := TakeChallenge(ctx, db, id, KindMfa, nowFunc())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
ClearChallenge(c)
|
||||
|
||||
owner, name, _ := strings.Cut(ch.Subject, "/")
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if user == nil {
|
||||
return httpx.Err(c, ErrChallenge.Error())
|
||||
}
|
||||
|
||||
switch {
|
||||
case f.Passcode != "":
|
||||
// The challenge's payload is the factor already used to get here. Answering
|
||||
// with that same factor proves nothing new.
|
||||
if f.MfaType == "" || f.MfaType == ch.Payload {
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
if f.MfaType != factor.App {
|
||||
// Only TOTP has a verifier here. Refuse anything else rather than wave it
|
||||
// through: a factor with no verification is not a factor.
|
||||
return httpx.Err(c, "invalid multi-factor authentication type")
|
||||
}
|
||||
if !factor.Verify(user.TotpSecret, f.Passcode) {
|
||||
return httpx.Err(c, "the multi-factor authentication code is incorrect")
|
||||
}
|
||||
case f.RecoveryCode != "":
|
||||
// A recovery code is one-time: the hit is removed and the row written whether
|
||||
// or not the rest of the sign-in succeeds, so a code cannot be spent twice.
|
||||
if !factor.UseRecovery(user, f.RecoveryCode) {
|
||||
return httpx.Err(c, "the recovery code is incorrect")
|
||||
}
|
||||
if err := factor.Save(ctx, db, user); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
default:
|
||||
return httpx.Err(c, "missing passcode or recovery code")
|
||||
}
|
||||
|
||||
if f.EnableMfaRemember {
|
||||
if err := remember(ctx, db, user); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
}
|
||||
return loginGrant(c, db, user, f)
|
||||
}
|
||||
|
||||
// remember opens the "don't ask again" window: now + the ORG's MfaRememberInHours.
|
||||
// A zero window — every live organization today — yields a deadline already in the
|
||||
// past, so the gate keeps challenging. That is the shipped behavior and it is
|
||||
// preserved: turning a zero into "forever" would silently disable the factor for
|
||||
// every tenant.
|
||||
func remember(ctx context.Context, db orm.DB, user *schema.User) error {
|
||||
org, err := store.GetOrganizationByName(ctx, db, user.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hours := 0
|
||||
if org != nil {
|
||||
hours = org.MfaRememberInHours
|
||||
}
|
||||
// Written with the SAME format `remembered` parses — a mismatch here is a
|
||||
// permanent skip or a permanent challenge, silently.
|
||||
user.MfaRememberDeadline = nowFunc().UTC().Add(time.Duration(hours) * time.Hour).Format(time.RFC3339)
|
||||
return factor.Save(ctx, db, user)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// The shared tail of every interactive authentication: given a user who has
|
||||
// ALREADY proven who they are — by password, by wallet signature, by any future
|
||||
// factor — decide what the SDK reads back. One place, so a new front door
|
||||
// inherits the redirect/PKCE/tenant rules instead of restating them.
|
||||
|
||||
// Mint is the authorize passthrough an interactive login carries. Type selects
|
||||
// the shape: "code" mints a PKCE-bound authorization code for the OAuth flow,
|
||||
// anything else is a bare portal sign-in.
|
||||
type Mint struct {
|
||||
Type string
|
||||
RedirectUri string
|
||||
State string
|
||||
Scope string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Resource string
|
||||
}
|
||||
|
||||
// MintFor resolves what a successful authentication returns to the SDK: the
|
||||
// user id for a bare portal sign-in, or a fresh PKCE-bound authorization code
|
||||
// (persisted) for the OAuth flow. userID is "<org>/<name>" — the caller's own
|
||||
// verified identity, never a client-supplied value.
|
||||
//
|
||||
// This is the ONE mint path. Every rule below is a security invariant, so it
|
||||
// lives here rather than in each front door:
|
||||
// - Tenant isolation: the user's org must be permitted for this application —
|
||||
// its own org, a shared app, or an app that lets users choose their org.
|
||||
// Without it a user in one tenant could obtain a token naming another.
|
||||
// - Redirect binding: an exactly-registered redirect_uri (RFC 6749 §3.1.2.3);
|
||||
// the token endpoint re-checks it. A supplied-but-unregistered URI is never
|
||||
// minted against.
|
||||
// - PKCE: S256 only (never "plain"), and a public client must present a
|
||||
// challenge — no downgrade.
|
||||
func MintFor(ctx context.Context, db orm.DB, app *schema.Application, userID string, p Mint) (string, error) {
|
||||
// A bare sign-in needs no application: report the identity and stop.
|
||||
if p.Type != "code" {
|
||||
return userID, nil
|
||||
}
|
||||
if app == nil {
|
||||
return "", errors.New("the application does not exist")
|
||||
}
|
||||
// The user's org is the owner half of its own id, set server-side at
|
||||
// authentication — never read from the request.
|
||||
org, _, _ := strings.Cut(userID, "/")
|
||||
if org != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
|
||||
return "", errors.New("the user is not permitted to sign in to this application")
|
||||
}
|
||||
if p.RedirectUri != "" && !app.IsRedirectUriValid(p.RedirectUri) {
|
||||
return "", errors.New("invalid redirect_uri")
|
||||
}
|
||||
method := normalizeChallengeMethod(p.CodeChallenge, p.CodeChallengeMethod)
|
||||
if p.CodeChallenge != "" && method != "S256" {
|
||||
return "", errors.New("only S256 PKCE is supported")
|
||||
}
|
||||
if app.ClientSecret == "" && p.CodeChallenge == "" {
|
||||
return "", errors.New("PKCE is required for public clients")
|
||||
}
|
||||
code, err := MintCode(app, userID, p.Scope, p.CodeChallenge, method, p.Resource, nowFunc())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Bind the redirect_uri and nonce onto the code so the token exchange can
|
||||
// re-verify the redirect and echo the nonce into the id_token.
|
||||
code.RedirectUri = p.RedirectUri
|
||||
code.Nonce = p.Nonce
|
||||
if err := store.PersistToken(ctx, db, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return code.Code, nil
|
||||
}
|
||||
|
||||
// ResolveApp resolves the OAuth application a front door names: by clientId when
|
||||
// present, else by name under the "admin" registry owner. Returns (nil, nil)
|
||||
// when the request names no application. Shared by every interactive login so
|
||||
// they all resolve the same app from the same fields.
|
||||
func ResolveApp(ctx context.Context, db orm.DB, clientId, name string) (*schema.Application, error) {
|
||||
if clientId != "" {
|
||||
return store.GetApplicationByClientId(ctx, db, clientId)
|
||||
}
|
||||
if name != "" {
|
||||
return store.GetApplicationByName(ctx, db, "admin", name)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// The mint owner-pin, unit-level: mintAllowed / adminMintAllowed require the resolved
|
||||
// minter app's OWNING org to be a reserved signing owner (admin/built-in) AND its
|
||||
// clientId to be allow-listed. A tenant-owned app whose clientId collides with an
|
||||
// allow-listed one is refused on the owner, so the clientId allow-list can never be
|
||||
// satisfied by a body-supplied collision.
|
||||
func TestMintAllowed_OwnerPinned(t *testing.T) {
|
||||
t.Setenv("IAM_KEY_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
t.Setenv("IAM_ADMIN_MINT_ALLOWED_APPS", "hanzo-console")
|
||||
|
||||
admin := &schema.Application{Owner: "admin", ClientId: "hanzo-console"}
|
||||
builtin := &schema.Application{Owner: "built-in", ClientId: "hanzo-console"}
|
||||
tenant := &schema.Application{Owner: "evil", ClientId: "hanzo-console"} // SAME allow-listed clientId
|
||||
|
||||
if !mintAllowed(admin) {
|
||||
t.Fatal("an admin-owned allow-listed app must be permitted to mint")
|
||||
}
|
||||
if !mintAllowed(builtin) {
|
||||
t.Fatal("a built-in-owned allow-listed app must be permitted to mint")
|
||||
}
|
||||
if mintAllowed(tenant) {
|
||||
t.Fatal("HIGH REOPENED: a tenant-owned app with a colliding clientId was permitted to mint")
|
||||
}
|
||||
if adminMintAllowed(tenant) {
|
||||
t.Fatal("HIGH REOPENED: a tenant-owned app reached the admin-mint capability")
|
||||
}
|
||||
if !adminMintAllowed(admin) {
|
||||
t.Fatal("an admin-owned app on the admin list must hold the admin-mint capability")
|
||||
}
|
||||
// Off the list entirely is denied even when admin-owned (the clientId gate still applies).
|
||||
if mintAllowed(&schema.Application{Owner: "admin", ClientId: "not-listed"}) {
|
||||
t.Fatal("an admin-owned app NOT on the allow-list must not mint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchange_clientIdCollisionAttacker_denied is the end-to-end regression
|
||||
// guard for the HIGH: an attacker registers an app with the SAME clientId as the
|
||||
// allow-listed console (not merely the same NAME) and its OWN known secret, then
|
||||
// tries a token exchange. Two independent controls deny it: admin-preferring
|
||||
// resolution returns the REAL console (so the attacker's secret mismatches → 401),
|
||||
// and the owner-pin (mintAllowed) refuses a non-signing owner even if a backend
|
||||
// returned the attacker row. Either way, NO token is minted.
|
||||
func TestTokenExchange_clientIdCollisionAttacker_denied(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"}) // admin-owned console
|
||||
// Attacker: SAME clientId as the console, attacker's OWN secret, tenant-owned.
|
||||
seedAttackerApp(t, db, "evil", "evil-console", "hanzo-console", "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, "hanzo-console", "attacker-knows-this", url.Values{
|
||||
"subject_token": {subject},
|
||||
"resource": {"hanzo-cloud"},
|
||||
})
|
||||
if status == 200 {
|
||||
t.Fatalf("HIGH REOPENED: clientId-collision attacker minted a token (status=200); body=%v", body)
|
||||
}
|
||||
if _, ok := body["access_token"]; ok {
|
||||
t.Fatalf("HIGH REOPENED: a token was minted for a colliding-clientId attacker; body=%v", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/luxfi/crypto/pq/mldsa/mldsa65"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
)
|
||||
|
||||
// ML-DSA-65 (FIPS 204, NIST security level 3) as a first-class JWT signing
|
||||
// method. This is the post-quantum half of the hybrid signing story: RS256
|
||||
// (jwt.go) is the classical interop path every existing verifier already reads
|
||||
// from the JWKS, and MLDSA65 is the forward path, active only for a Cert whose
|
||||
// CryptoAlgorithm is ML-DSA. The two share the same Signer / JWKS seam, so a
|
||||
// deployment migrates one Cert at a time without touching the token core.
|
||||
//
|
||||
// The signature scheme is pure ML-DSA-65 over the JWS signing input (no context,
|
||||
// deterministic), which is exactly what the ML-DSA-65 Verify checks — so a token
|
||||
// this method signs round-trips through the same package's verify path, and a
|
||||
// PQ-aware relying party reads the raw public key published in the JWKS.
|
||||
|
||||
// algMLDSA65 is the JOSE `alg` value for ML-DSA-65 — the identifier carried in
|
||||
// the JWT header and advertised in discovery + JWKS.
|
||||
const algMLDSA65 = "MLDSA65"
|
||||
|
||||
// signingMethodMLDSA65 implements jwt.SigningMethod for ML-DSA-65.
|
||||
type signingMethodMLDSA65 struct{}
|
||||
|
||||
// SigningMethodMLDSA65 is the shared, stateless ML-DSA-65 signing method.
|
||||
var SigningMethodMLDSA65 jwt.SigningMethod = signingMethodMLDSA65{}
|
||||
|
||||
func init() {
|
||||
jwt.RegisterSigningMethod(algMLDSA65, func() jwt.SigningMethod { return SigningMethodMLDSA65 })
|
||||
}
|
||||
|
||||
// Alg returns the JOSE algorithm identifier.
|
||||
func (signingMethodMLDSA65) Alg() string { return algMLDSA65 }
|
||||
|
||||
// Sign produces a deterministic ML-DSA-65 signature over the JWS signing input.
|
||||
func (signingMethodMLDSA65) Sign(signingString string, key any) ([]byte, error) {
|
||||
sk, ok := key.(*mldsa65.PrivateKey)
|
||||
if !ok {
|
||||
return nil, jwt.ErrInvalidKeyType
|
||||
}
|
||||
sig, err := mldsa65.Sign(sk, []byte(signingString), nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// Verify checks an ML-DSA-65 signature; a mismatch is a signature error, never a
|
||||
// key/type panic.
|
||||
func (signingMethodMLDSA65) Verify(signingString string, sig []byte, key any) error {
|
||||
pk, ok := key.(*mldsa65.PublicKey)
|
||||
if !ok {
|
||||
return jwt.ErrInvalidKeyType
|
||||
}
|
||||
if len(sig) != mldsa65.SignatureSize {
|
||||
return jwt.ErrSignatureInvalid
|
||||
}
|
||||
if !mldsa65.Verify(pk, []byte(signingString), nil, sig) {
|
||||
return jwt.ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMLDSACert reports whether a Cert is an ML-DSA-65 signing cert.
|
||||
func isMLDSACert(cert *schema.Cert) bool {
|
||||
if cert == nil {
|
||||
return false
|
||||
}
|
||||
a := strings.ToUpper(strings.ReplaceAll(cert.CryptoAlgorithm, "-", ""))
|
||||
return a == "MLDSA65"
|
||||
}
|
||||
|
||||
// parseMLDSA65PrivateKey decodes an ML-DSA-65 private key from a Cert's stored
|
||||
// material: a PEM envelope ("MLDSA65 PRIVATE KEY") or bare base64 of the packed
|
||||
// key bytes.
|
||||
func parseMLDSA65PrivateKey(material string) (*mldsa65.PrivateKey, error) {
|
||||
raw, err := decodeKeyMaterial(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sk := new(mldsa65.PrivateKey)
|
||||
if err := sk.UnmarshalBinary(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sk, nil
|
||||
}
|
||||
|
||||
// parseMLDSA65PublicKey decodes an ML-DSA-65 public key from stored material.
|
||||
func parseMLDSA65PublicKey(material string) (*mldsa65.PublicKey, error) {
|
||||
raw, err := decodeKeyMaterial(material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pk := new(mldsa65.PublicKey)
|
||||
if err := pk.UnmarshalBinary(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
// mldsa65PublicFromCert returns the ML-DSA-65 public key for a cert, from its
|
||||
// published Certificate material when present, else derived from the private key
|
||||
// (dev certs that store only the key). It never returns private material.
|
||||
func mldsa65PublicFromCert(cert *schema.Cert) (*mldsa65.PublicKey, error) {
|
||||
if cert.Certificate != "" {
|
||||
if pk, err := parseMLDSA65PublicKey(cert.Certificate); err == nil {
|
||||
return pk, nil
|
||||
}
|
||||
}
|
||||
sk, err := parseMLDSA65PrivateKey(cert.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, ok := sk.Public().(*mldsa65.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("mldsa: derived public key has the wrong type")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// decodeKeyMaterial extracts raw key bytes from a PEM envelope or bare base64
|
||||
// (standard or url encoding), the two shapes a Cert row stores raw keys in.
|
||||
func decodeKeyMaterial(material string) ([]byte, error) {
|
||||
material = strings.TrimSpace(material)
|
||||
if material == "" {
|
||||
return nil, errors.New("mldsa: empty key material")
|
||||
}
|
||||
if block, _ := pem.Decode([]byte(material)); block != nil {
|
||||
return block.Bytes, nil
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(material); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(material)
|
||||
if err != nil {
|
||||
return nil, errors.New("mldsa: key material is neither PEM nor base64")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
// Package oidc serves the IAM v2 OpenID Connect / OAuth2 surface on zip. The
|
||||
// handlers are RAW zip handlers (func(c *zip.Ctx) error), not typed generics,
|
||||
// because the auth surface needs query params, form bodies, redirects, and
|
||||
// headers a JSON-in/JSON-out handler can't reach.
|
||||
//
|
||||
// The surface is the canonical hanzo.id contract, unchanged across the v1→v2
|
||||
// backend swap: discovery + JWKS under .well-known, the oauth/{authorize,token,
|
||||
// userinfo,logout} endpoints, and the front-door {get-app-login, auth/methods,
|
||||
// login} the hosted UI calls. Tokens are signed JWTs (RS256 interop, ES/ML-DSA
|
||||
// behind the same JWKS); every value is verified, never trusted.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Canonical OIDC paths — the single source of truth the @hanzo/iam SDK and every
|
||||
// existing relying party hard-code. iam2 serves them directly; the transition
|
||||
// off v1 is a backend swap behind the same paths, never a parallel version.
|
||||
const (
|
||||
PathAuthorize = "/v1/iam/oauth/authorize"
|
||||
PathToken = "/v1/iam/oauth/token"
|
||||
PathUserInfo = "/v1/iam/oauth/userinfo"
|
||||
PathLogout = "/v1/iam/oauth/logout"
|
||||
PathJWKS = "/v1/iam/.well-known/jwks"
|
||||
PathJWKSRoot = "/.well-known/jwks"
|
||||
PathDiscovery = "/.well-known/openid-configuration"
|
||||
PathDiscoveryV1 = "/v1/iam/.well-known/openid-configuration"
|
||||
PathASMetadata = "/.well-known/oauth-authorization-server" // RFC 8414 (root)
|
||||
PathASMetadataV1 = "/v1/iam/.well-known/oauth-authorization-server" // RFC 8414 (v1)
|
||||
PathDevice = "/v1/iam/oauth/device" // RFC 8628 device authorization
|
||||
// PathDeviceVerify is the user-facing device-approval PAGE (a route in the
|
||||
// hosted SPA), not an API path: RFC 8628's verification_uri is somewhere a
|
||||
// human opens and signs in, which the JSON token API can never be.
|
||||
PathDeviceVerify = "/login/oauth/device"
|
||||
)
|
||||
|
||||
// Route registers the entire OIDC/OAuth2 surface on r, backed by db. This is the
|
||||
// one entry point the route table calls — discovery, JWKS, the protocol
|
||||
// endpoints, and the front door are all wired here so the surface lives in one
|
||||
// place. r is the PUBLIC group (registered before the router's authentication
|
||||
// Guard): the whole OIDC/OAuth + front-door surface is pre-authentication by
|
||||
// construction, so membership in this group IS what makes it reachable without a
|
||||
// bearer — there is no separate allow-list to keep in sync.
|
||||
func Route(r zip.Router, db orm.DB) {
|
||||
// Discovery and the JWKS are each served at BOTH the root well-known path
|
||||
// (RFC 8414 §3, where a bare-origin client and the gateway's default look)
|
||||
// and the /v1/iam-prefixed path, matching the live hanzo.id surface. Both
|
||||
// paths are the same handler over the same keys — one key set, two spellings
|
||||
// of where to find it.
|
||||
jwks := jwksHandler(db)
|
||||
r.Get(PathDiscovery, Discovery)
|
||||
r.Get(PathDiscoveryV1, Discovery)
|
||||
// RFC 8414 OAuth Authorization Server Metadata — the same self-consistent
|
||||
// document at the OAuth well-known path (a superset serves it), so an OAuth-only
|
||||
// client that looks for `oauth-authorization-server` finds the AS too.
|
||||
r.Get(PathASMetadata, Discovery)
|
||||
r.Get(PathASMetadataV1, Discovery)
|
||||
r.Get(PathJWKS, jwks)
|
||||
r.Get(PathJWKSRoot, jwks)
|
||||
|
||||
// OAuth2 / OIDC protocol endpoints.
|
||||
r.Get(PathAuthorize, authorizeHandler(db))
|
||||
r.Post(PathAuthorize, authorizeHandler(db))
|
||||
r.Get(PathUserInfo, userinfoHandler(db))
|
||||
r.Post(PathUserInfo, userinfoHandler(db))
|
||||
r.Get(PathLogout, logoutHandler(db))
|
||||
r.Post(PathLogout, logoutHandler(db))
|
||||
|
||||
// The token endpoint, the credential login that mints codes, and the
|
||||
// read-only front door the hosted <Login> self-configures from.
|
||||
routeToken(r, db)
|
||||
routeLogin(r, db)
|
||||
routeFrontDoor(r, db)
|
||||
|
||||
// Identity federation: the external-IdP callback (Google/GitHub, …). The
|
||||
// authorize endpoint kicks a federation off when the request names a
|
||||
// `provider`; this registers the fixed return endpoint the IdP redirects to.
|
||||
routeFederation(r, db)
|
||||
routeFederationMfa(r, db)
|
||||
routeUnlink(r, db)
|
||||
|
||||
// RFC 7662 introspection + RFC 7009 revocation — the standard token-management
|
||||
// endpoints a resource server / confidential client uses (client-authenticated).
|
||||
routeIntrospectRevoke(r, db)
|
||||
|
||||
// RFC 8628 device authorization grant — the browserless CLI sign-in. The
|
||||
// request endpoint is registered here; the poll rides the token endpoint and
|
||||
// the approval rides the login endpoint, both already public above.
|
||||
routeDevice(r, db)
|
||||
|
||||
// The confidential-client "act on behalf of a user" primitive (the console +
|
||||
// keyless-AI proxies mint their forwarded bearer here). Authenticates the
|
||||
// client itself, so it is not Bearer-gated.
|
||||
routeIssueToken(r, db)
|
||||
}
|
||||
|
||||
// Discovery serves the OIDC discovery document, host-relative (issuer derived
|
||||
// from the request host, the same value the tokens carry as `iss`) so a strict
|
||||
// client never splits origin. It advertises only what iam2 implements: the
|
||||
// authorization-code flow, S256 PKCE, the three supported grants, and the
|
||||
// signing algorithms whose public keys the JWKS actually publishes.
|
||||
func Discovery(c *zip.Ctx) error {
|
||||
iss := tokenIssuer(c)
|
||||
return c.JSON(200, map[string]any{
|
||||
"issuer": iss,
|
||||
"authorization_endpoint": iss + PathAuthorize,
|
||||
"token_endpoint": iss + PathToken,
|
||||
"userinfo_endpoint": iss + PathUserInfo,
|
||||
"introspection_endpoint": iss + PathIntrospect,
|
||||
"revocation_endpoint": iss + PathRevoke,
|
||||
"end_session_endpoint": iss + PathLogout,
|
||||
"device_authorization_endpoint": iss + PathDevice,
|
||||
"jwks_uri": iss + PathJWKS,
|
||||
"response_types_supported": []string{"code"},
|
||||
"response_modes_supported": []string{"query", "fragment", "form_post"},
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token", "client_credentials", "password", grantTypeTokenExchange, deviceGrant},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "RS512", "ES256", "ES384", "ES512", "MLDSA65"},
|
||||
"scopes_supported": []string{"openid", "email", "profile", "address", "phone", "offline_access"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"claims_supported": []string{
|
||||
"iss", "sub", "aud", "iat", "exp", "nbf", "jti", "nonce", "azp",
|
||||
"owner", "organization", "scope", "tokenType",
|
||||
"name", "preferred_username", "email", "email_verified",
|
||||
"picture", "address", "phone", "groups", "is_verified",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/organizations"
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// POST /v1/iam/onboard — first-run org onboarding. A signed-in user with no org of
|
||||
// their own creates one (named, or a one-click `<username>` personal org) and is
|
||||
// MOVED into it as its admin, so everyone always has an org. Because an IAM user's
|
||||
// org IS their identity, the move re-keys the caller (their subject becomes
|
||||
// slug/name), so the console re-authenticates after a success.
|
||||
//
|
||||
// The response is the console's own {org}/{error} contract (NOT the casibase
|
||||
// envelope): {"org":"<slug>"} on success, an {"error":"..."} with a 4xx/5xx status
|
||||
// on failure — the OrgOnboarding client reads json.org / json.error directly.
|
||||
//
|
||||
// Self-scoped: the caller is resolved from its session/bearer (callerOf), never from
|
||||
// the body, so onboarding only ever moves the caller — its own identity, its own org.
|
||||
|
||||
// PathOnboard is the canonical first-run onboarding endpoint.
|
||||
const PathOnboard = "/v1/iam/onboard"
|
||||
|
||||
// Org slug bounds mirror the console's onboarding policy (src/lib/server/onboarding.ts):
|
||||
// an IAM org name is varchar(100); keep the slug short + readable.
|
||||
const (
|
||||
minOrgSlug = 2
|
||||
maxOrgSlug = 60
|
||||
)
|
||||
|
||||
// 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) — are the
|
||||
// ONE store.IsReservedOrg set, shared with signup and federated provisioning so the
|
||||
// reserved set never drifts between surfaces. Brand/staff orgs (hanzo/lux/zoo/pars in
|
||||
// the console list) are NOT reserved here (iam2 is white-label): an existing one is
|
||||
// refused by the create-conflict check.
|
||||
|
||||
// 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 store.IsReservedOrg(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/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
"github.com/hanzoai/iam/internal/users"
|
||||
)
|
||||
|
||||
// TestPasswordHashPersists is the regression for the json:"-"-drops-from-storage
|
||||
// bug: orm serializes an entity to its JSON data column, so a credential field
|
||||
// tagged json:"-" was never stored → every retrieved user had an empty hash →
|
||||
// login could never succeed. This proves the hash survives a store round-trip
|
||||
// and verifies, and that the same holds for AccessSecretHash.
|
||||
func TestPasswordHashPersists(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("s3cret-pw"), bcrypt.MinCost)
|
||||
u := orm.New[schema.User](db)
|
||||
u.Owner = "hanzo"
|
||||
u.Name = "persisttest"
|
||||
u.Email = "persist@hanzo.ai"
|
||||
u.PasswordHash = string(hash)
|
||||
u.PasswordType = "bcrypt"
|
||||
u.AccessSecretHash = "access-hash-value"
|
||||
if err := u.Create(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := store.GetUserByEmail(ctx, db, "hanzo", "persist@hanzo.ai")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if got.PasswordHash == "" {
|
||||
t.Fatal("PasswordHash did not persist — the json:\"-\" storage bug is back")
|
||||
}
|
||||
if got.AccessSecretHash == "" {
|
||||
t.Fatal("AccessSecretHash did not persist")
|
||||
}
|
||||
// The retrieved hash actually verifies the password.
|
||||
if !users.VerifyPassword(got, "s3cret-pw", "") {
|
||||
t.Fatal("persisted hash does not verify the password")
|
||||
}
|
||||
if users.VerifyPassword(got, "wrong-pw", "") {
|
||||
t.Fatal("wrong password verified — bcrypt broken")
|
||||
}
|
||||
}
|
||||
@@ -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/iam/internal/httpx"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// POST /v1/iam/update-preferences — the ONE account-backed store for cross-product,
|
||||
// cross-device user customizations (onboarding-completed flag, theme, pinned
|
||||
// favorites, …). The console's PreferencesProvider reads these from the account's
|
||||
// Properties["hanzo.preferences"] JSON blob and write-through-persists partial updates
|
||||
// here. Ported from the v1 me_preferences.go contract.
|
||||
//
|
||||
// SELF-SCOPED (never the body): the target user is ALWAYS the caller resolved from its
|
||||
// session/bearer (callerOf) — never a name/org in the request — so a caller can only
|
||||
// ever write its OWN preferences. MERGE: top-level keys are shallow-merged onto the
|
||||
// stored object, so concurrent products/devices setting DIFFERENT keys don't clobber
|
||||
// each other; the merged object is returned so the caller keeps every other key.
|
||||
|
||||
// PathUpdatePreferences is the canonical self-preferences endpoint.
|
||||
const PathUpdatePreferences = "/v1/iam/update-preferences"
|
||||
|
||||
// preferencesKey is the User.Properties entry holding the cross-product preferences
|
||||
// JSON blob — the backend half of the console contract (PREFS_PROPERTY); keep in
|
||||
// lockstep.
|
||||
const preferencesKey = "hanzo.preferences"
|
||||
|
||||
// preferencesMaxBytes caps the serialized merged blob so a runaway client can't grow
|
||||
// the properties column without bound.
|
||||
const preferencesMaxBytes = 64 * 1024
|
||||
|
||||
// updatePreferencesHandler shallow-merges the posted partial onto the caller's stored
|
||||
// preferences and returns the merged object.
|
||||
func updatePreferencesHandler(db orm.DB) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
ctx := c.Context()
|
||||
owner, name, ok := callerOf(ctx, c, db)
|
||||
if !ok {
|
||||
return httpx.Err(c, "please sign in first")
|
||||
}
|
||||
user, err := store.GetUserByName(ctx, db, owner, name)
|
||||
if err != nil {
|
||||
return httpx.Err(c, "server_error")
|
||||
}
|
||||
if user == nil {
|
||||
return httpx.Err(c, "the user does not exist")
|
||||
}
|
||||
|
||||
mergedJSON, merged, err := mergePreferences(user.Properties[preferencesKey], c.Fiber().Body())
|
||||
if err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
if user.Properties == nil {
|
||||
user.Properties = map[string]string{}
|
||||
}
|
||||
user.Properties[preferencesKey] = mergedJSON
|
||||
user.UpdatedTime = onboardNow()
|
||||
if err := user.UpdateCtx(ctx); err != nil {
|
||||
return httpx.Err(c, err.Error())
|
||||
}
|
||||
return httpx.Ok(c, merged)
|
||||
}
|
||||
}
|
||||
|
||||
// mergePreferences shallow-merges a JSON `patch` object onto the `existing`
|
||||
// preferences JSON string, returning the merged JSON plus the merged map. Pure (no
|
||||
// session, no DB): a patch key overwrites ONLY that top-level key (every other stored
|
||||
// key survives); an absent/blank/corrupt `existing` is an empty object (a first write
|
||||
// still lands and self-heals); a non-object patch is rejected; the blob is size-capped.
|
||||
func mergePreferences(existing string, patch []byte) (string, map[string]json.RawMessage, error) {
|
||||
patchMap := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(patch, &patchMap); err != nil {
|
||||
return "", nil, fmt.Errorf("preferences must be a JSON object: %w", err)
|
||||
}
|
||||
|
||||
merged := map[string]json.RawMessage{}
|
||||
if existing != "" {
|
||||
_ = json.Unmarshal([]byte(existing), &merged) // corrupt stored blob → treated as empty
|
||||
}
|
||||
for k, v := range patchMap {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
out, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(out) > preferencesMaxBytes {
|
||||
return "", nil, fmt.Errorf("preferences exceed maximum size of %d bytes", preferencesMaxBytes)
|
||||
}
|
||||
return string(out), merged, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
||||
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/iam/internal/schema"
|
||||
"github.com/hanzoai/iam/internal/store"
|
||||
)
|
||||
|
||||
// Refresh-token rotation with reuse detection. A refresh token is an opaque,
|
||||
// single-use bearer (stored only as a SHA-256 hash): every exchange consumes the
|
||||
// presented token and mints a successor in the same rotation family. Presenting
|
||||
// an already-consumed refresh is a replay — the whole family is revoked so a
|
||||
// stolen token cannot outlive its legitimate successor (RFC 9700 §4.14). This is
|
||||
// the load-bearing hardening over v1, whose refresh path is rotate-and-delete
|
||||
// with no family cascade.
|
||||
|
||||
// refreshTokenGrant handles grant_type=refresh_token.
|
||||
func refreshTokenGrant(c *zip.Ctx, db orm.DB) error {
|
||||
ctx := c.Context()
|
||||
now := nowFunc()
|
||||
|
||||
presented := param(c, "refresh_token")
|
||||
if presented == "" {
|
||||
return tokenError(c, 400, "invalid_request", "refresh_token is required")
|
||||
}
|
||||
clientID, clientSecret := clientAuth(c)
|
||||
|
||||
tok, err := store.GetTokenByRefreshHash(ctx, db, hashToken(presented))
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if tok == nil {
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token is invalid or revoked")
|
||||
}
|
||||
app, err := resolveTokenApp(ctx, db, tok)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if app == nil {
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token is invalid or revoked")
|
||||
}
|
||||
|
||||
// Client authentication: the presented client must be the grant's client, and
|
||||
// a confidential client must present its secret.
|
||||
if clientID != "" && subtle.ConstantTimeCompare([]byte(clientID), []byte(app.ClientId)) != 1 {
|
||||
return tokenError(c, 400, "invalid_grant", "client mismatch")
|
||||
}
|
||||
if app.ClientSecret != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(app.ClientSecret)) != 1 {
|
||||
return tokenErrorClient(c, "client authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse detection: a consumed token was already rotated. Revoke the whole
|
||||
// family and refuse — a replay means the token leaked.
|
||||
if tok.RefreshConsumed {
|
||||
revokeRefreshFamily(ctx, db, tok.RefreshFamily)
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token replay detected")
|
||||
}
|
||||
if tok.RefreshExpireIn != 0 && now.Unix() > tok.RefreshExpireIn {
|
||||
return tokenError(c, 400, "invalid_grant", "refresh token expired")
|
||||
}
|
||||
|
||||
// Optional scope narrowing — never widening (RFC 6749 §6).
|
||||
scope := tok.Scope
|
||||
if req := param(c, "scope"); req != "" {
|
||||
if !scopeSubset(req, tok.Scope) {
|
||||
return tokenError(c, 400, "invalid_scope", "requested scope exceeds the grant")
|
||||
}
|
||||
scope = req
|
||||
}
|
||||
|
||||
// Rotate: consume the presented token, then mint a successor in the same
|
||||
// family. The successor is a new row so the consumed one remains as a
|
||||
// tripwire for replay until the family is revoked or expires.
|
||||
tok.RefreshConsumed = true
|
||||
if err := store.SaveToken(ctx, db, tok); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
nameSeed, err := newOpaqueToken()
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
nu := &schema.Token{
|
||||
Owner: tok.Owner,
|
||||
Application: tok.Application,
|
||||
Organization: tok.Organization,
|
||||
User: tok.User,
|
||||
Scope: scope,
|
||||
Nonce: tok.Nonce,
|
||||
Resource: tok.Resource,
|
||||
RedirectUri: tok.RedirectUri,
|
||||
}
|
||||
nu.Name = "rt-" + nameSeed[:24]
|
||||
resp, err := issueTokens(ctx, db, c, app, nu, tok.RefreshFamily, now)
|
||||
if err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
if err := store.PersistToken(ctx, db, nu); err != nil {
|
||||
return tokenError(c, 500, "server_error", "")
|
||||
}
|
||||
return c.JSON(200, resp)
|
||||
}
|
||||
|
||||
// revokeRefreshFamily deletes every token row in a rotation family — the
|
||||
// containment response when a rotated refresh token is replayed.
|
||||
func revokeRefreshFamily(ctx context.Context, db orm.DB, family string) {
|
||||
rows, err := store.ListTokensByRefreshFamily(ctx, db, family)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
_ = store.DeleteToken(ctx, db, r)
|
||||
}
|
||||
}
|
||||
|
||||
// scopeSubset reports whether every scope in sub is present in super.
|
||||
func scopeSubset(sub, super string) bool {
|
||||
for _, s := range strings.Fields(sub) {
|
||||
if !hasScope(super, s) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user