Files
zeekayandGitHub 36ade30e19 Web3/Bootnode: converge on @hanzo/iam (HIP-0111) — SPA PKCE FE + JWKS backend (#8)
* feat(web3): restore canonical @hanzo/iam SPA PKCE auth (HIP-0111)

The brand-neutral refactor regressed login to a hand-rolled OAuth authorize
URL (no PKCE, wrong path `${iamUrl}/oauth/authorize`) plus a backend
code-exchange POST to /v1/auth/oauth/callback — a route api-go does not
expose. Both break IAM login: the hand-rolled URL hits a path IAM serves
its 200 SPA catch-all for (silent breakage), and the callback 404s.

Restore the one canonical way per HIP-0111:
- lib/iam.ts: @hanzo/iam/browser IAM client, white-labelled by the active
  brand (serverUrl + clientId), PKCE S256, endpoints resolved via OIDC
  discovery. No hand-rolled authorize URL, no genericOAuth, no /api/auth.
- login: getIam().signinRedirect() in production (SDK owns PKCE + state).
- callback: getIam().handleCallback() does the PKCE token exchange
  client-side; the IAM access token becomes the session bearer.
- brand default: bootnode IAM is id.bootno.de / bootnode-platform (was
  hanzo.id / bootnode-web — wrong issuer + unregistered client).
- package.json: add @hanzo/iam ^0.11.0 (used but missing from deps).
- public/config.example.json: in-repo reference for the per-brand catalog
  (registered clientId per host) so the deploy provisions real clients.

Build + typecheck green.

* feat(api): validate IAM tokens via JWKS in production (HIP-0111)

The api-go backend only had an HS256 verifier (shared JWT_SECRET). After the
FE completes IAM PKCE it presents an IAM-issued RS256 access token, which the
HMAC verifier rejects (SigningMethodHMAC check) — IAM login fails at the API.

Converge on the canonical IAM validation path:
- iam/jwks.go: JWKSVerifier validates RS256/ES256 tokens against the issuer's
  JWKS, resolved via OIDC discovery ({issuer}/.well-known/openid-configuration
  -> jwks_uri). Keys cached, refreshed on unknown kid (rate-limited). stdlib
  only — no new dependency. Issuer is checked (WithIssuer).
- main.go: production selects JWKSVerifier(IAM_URL); dev keeps HS256 for the
  built-in email/password flow. One way per environment.
- config: ValidateSecrets() fails fast if production boots with placeholder/
  empty JWT_SECRET or API_KEY_SALT — secrets MUST come from KMS, never the
  in-code default. IsProduction() gates it.
- iam.go: drop redundant exp/iat int64 fields from Claims — duplicate json
  tags shadowed jwt.RegisteredClaims and silently disabled expiry validation
  (latent bug in the HS path too; now exp is enforced).

Tests: JWKS valid/expired/wrong-issuer/unknown-kid/HS-rejected + config
secret guard. go build + go vet + go test all green.

* harden(api): enforce JWT audience when IAM_CLIENT_ID is set

Adversarial review follow-up: without an aud check, a token minted for ANY
client of the same IAM issuer would be accepted. NewJWKS now takes the
expected audience (the deployment's IAM_CLIENT_ID); when set, tokens whose
aud does not match are rejected (token minted for a different client is no
longer accepted). Empty audience preserves issuer+signature-only behavior.

Test: matching aud passes, mismatched aud rejected.

* fix(web): sync lockfile for @hanzo/iam + remove invalid pnpm-workspace.yaml

test-web failed at `pnpm install --frozen-lockfile` for two reasons:

1. web/pnpm-workspace.yaml carried only `allowBuilds: {sharp: true}` — not a
   valid pnpm 9 field, and a workspace file without a `packages:` key makes
   pnpm 9 abort with ERR_PNPM_INVALID_WORKSPACE_CONFIGURATION (pre-existing
   on main). web/ is a single package, not a workspace, so the file is removed
   and sharp's build allowance moved to package.json `pnpm.onlyBuiltDependencies`.

2. This branch added `@hanzo/iam@^0.11.0` to package.json but never regenerated
   pnpm-lock.yaml, so --frozen-lockfile rejected the out-of-date lock.

Regenerated the lockfile (pnpm 9.15.9) — it now pins @hanzo/[email protected].0. Verified
locally: `pnpm install --frozen-lockfile` is clean and `pnpm run build`
(next build) compiles all routes incl. the refactored /login + /auth/callback.

---------

Co-authored-by: zeekay <[email protected]>
2026-06-25 15:13:17 -07:00

166 lines
5.1 KiB
Go

package iam
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// newJWKSServer stands up a discovery + JWKS endpoint backed by `key`, with the
// given issuer and kid. Returns the issuer URL (the test server's base).
func newJWKSServer(t *testing.T, key *rsa.PrivateKey, kid string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": srv.URL,
"jwks_uri": srv.URL + "/v1/iam/.well-known/jwks",
})
})
mux.HandleFunc("/v1/iam/.well-known/jwks", func(w http.ResponseWriter, _ *http.Request) {
pub := key.Public().(*rsa.PublicKey)
eb := big.NewInt(int64(pub.E)).Bytes()
_ = json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(eb),
}},
})
})
t.Cleanup(srv.Close)
return srv
}
func signRS256(t *testing.T, key *rsa.PrivateKey, kid, issuer string, exp time.Time) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": "user-123",
"email": "[email protected]",
"iss": issuer,
"iat": time.Now().Unix(),
"exp": exp.Unix(),
})
tok.Header["kid"] = kid
s, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func TestJWKSVerify_ValidRS256(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
srv := newJWKSServer(t, key, "cert-test")
v := NewJWKS(srv.URL, "")
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(time.Hour))
claims, err := v.Verify(context.Background(), tokStr)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if claims.UserID != "user-123" || claims.Email != "[email protected]" {
t.Fatalf("bad claims: %+v", claims)
}
}
func TestJWKSVerify_RejectsHS256(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
srv := newJWKSServer(t, key, "cert-test")
v := NewJWKS(srv.URL, "")
// HMAC-signed token must be rejected: a JWKS verifier accepts only the
// asymmetric algs IAM signs with (RS/ES), never a caller-chosen HS256.
hs := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "x", "iss": srv.URL, "exp": time.Now().Add(time.Hour).Unix()})
s, _ := hs.SignedString([]byte("attacker-known-secret"))
if _, err := v.Verify(context.Background(), s); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("expected ErrTokenInvalid for HS256, got %v", err)
}
}
func TestJWKSVerify_RejectsWrongIssuer(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
srv := newJWKSServer(t, key, "cert-test")
v := NewJWKS(srv.URL, "")
tokStr := signRS256(t, key, "cert-test", "https://evil.example", time.Now().Add(time.Hour))
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("expected ErrTokenInvalid for wrong issuer, got %v", err)
}
}
func TestJWKSVerify_RejectsExpired(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
srv := newJWKSServer(t, key, "cert-test")
v := NewJWKS(srv.URL, "")
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(-2*time.Minute))
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("expected ErrTokenExpired, got %v", err)
}
}
func TestJWKSVerify_RejectsUnknownKID(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
srv := newJWKSServer(t, key, "cert-test")
v := NewJWKS(srv.URL, "")
// Token signed with a kid the JWKS does not publish.
tokStr := signRS256(t, key, "cert-unknown", srv.URL, time.Now().Add(time.Hour))
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("expected ErrTokenInvalid for unknown kid, got %v", err)
}
}
func signRS256Aud(t *testing.T, key *rsa.PrivateKey, kid, issuer, aud string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"sub": "user-123",
"iss": issuer,
"aud": aud,
"exp": time.Now().Add(time.Hour).Unix(),
})
tok.Header["kid"] = kid
s, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func TestJWKSVerify_Audience(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
srv := newJWKSServer(t, key, "cert-test")
v := NewJWKS(srv.URL, "bootnode-platform")
// Matching aud is accepted.
ok := signRS256Aud(t, key, "cert-test", srv.URL, "bootnode-platform")
if _, err := v.Verify(context.Background(), ok); err != nil {
t.Fatalf("expected matching aud to pass, got %v", err)
}
// A token minted for a different client of the same issuer is rejected.
bad := signRS256Aud(t, key, "cert-test", srv.URL, "some-other-client")
if _, err := v.Verify(context.Background(), bad); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("expected ErrTokenInvalid for wrong aud, got %v", err)
}
}