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

185 lines
5.2 KiB
Go

// Package config — environment-driven application settings.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
)
// Settings is the canonical application configuration. Loaded once at
// startup via Load() and accessed thereafter via Get().
type Settings struct {
// Application
AppName string
AppEnv string // development | staging | production
Debug bool
APIPrefix string
// Database
DatabaseURL string
DBPoolSize int
DBMaxOverflow int
// Redis
RedisURL string
// Authentication
JWTSecret string
JWTAlgorithm string
JWTExpireMinutes int
APIKeySalt string
// Hanzo IAM
IAMUrl string
IAMClientID string
IAMClientSecret string
EnableMultiTenant bool
AllowedOrgs []string
FrontendURL string
AllowedOrigins []string
// ZAP — Cap'n Proto RPC server
ZAPEnabled bool
ZAPHost string
ZAPPort int
// Observability
OTELEndpoint string
OTELServiceName string
PrometheusPort int
// Listen
HTTPHost string
HTTPPort int
// KMS
KMSURL string
KMSAuthToken string
}
var (
loaded *Settings
loadOnce sync.Once
)
// Load reads settings from environment variables exactly once and
// returns the snapshot. Subsequent calls return the cached value.
func Load() *Settings {
loadOnce.Do(func() {
loaded = &Settings{
AppName: env("APP_NAME", "Bootnode"),
AppEnv: env("APP_ENV", "development"),
Debug: envBool("DEBUG", false),
APIPrefix: env("API_PREFIX", "/v1"),
DatabaseURL: env("DATABASE_URL", "postgres://bootnode:bootnode@localhost:5432/bootnode?sslmode=disable"),
DBPoolSize: envInt("DB_POOL_SIZE", 20),
DBMaxOverflow: envInt("DB_MAX_OVERFLOW", 10),
RedisURL: env("REDIS_URL", "redis://localhost:6379/0"),
JWTSecret: env("JWT_SECRET", "change-me-in-production"),
JWTAlgorithm: env("JWT_ALGORITHM", "HS256"),
JWTExpireMinutes: envInt("JWT_EXPIRE_MINUTES", 60*24),
APIKeySalt: env("API_KEY_SALT", "change-me-in-production"),
IAMUrl: env("IAM_URL", "https://iam.hanzo.ai"),
IAMClientID: env("IAM_CLIENT_ID", ""),
IAMClientSecret: env("IAM_CLIENT_SECRET", ""),
EnableMultiTenant: envBool("ENABLE_MULTI_TENANT", true),
AllowedOrgs: envList("ALLOWED_ORGS", []string{"hanzo", "zoo", "lux", "pars"}),
FrontendURL: env("FRONTEND_URL", "http://localhost:3001"),
AllowedOrigins: envList("ALLOWED_ORIGINS", nil),
ZAPEnabled: envBool("ZAP_ENABLED", true),
ZAPHost: env("ZAP_HOST", "0.0.0.0"),
ZAPPort: envInt("ZAP_PORT", 9999),
OTELEndpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
OTELServiceName: env("OTEL_SERVICE_NAME", "bootnode"),
PrometheusPort: envInt("PROMETHEUS_PORT", 9090),
HTTPHost: env("HTTP_HOST", "0.0.0.0"),
HTTPPort: envInt("HTTP_PORT", 8000),
KMSURL: env("KMS_URL", "http://kms.hanzo.ai:8443"),
KMSAuthToken: env("KMS_AUTH_TOKEN", ""),
}
})
return loaded
}
// Get returns the loaded settings, calling Load() if necessary.
func Get() *Settings { return Load() }
// JWTTTL returns the JWT expiration as a duration.
func (s *Settings) JWTTTL() time.Duration {
return time.Duration(s.JWTExpireMinutes) * time.Minute
}
// IsProduction reports whether the app runs in a non-development environment.
// Drives the auth verifier choice (IAM JWKS vs local HS256).
func (s *Settings) IsProduction() bool {
return s.AppEnv != "" && s.AppEnv != "development" && s.AppEnv != "dev" && s.AppEnv != "local"
}
// placeholderSecret is the in-code default for secrets that MUST be injected
// from KMS in production. Refusing to boot with this value is fail-fast: a
// production pod missing its KMS-synced secret never silently signs/derives
// with a known-public default.
const placeholderSecret = "change-me-in-production"
// ValidateSecrets enforces that production secrets are real (KMS-injected),
// never the in-code placeholder. Returns an error listing every offender.
func (s *Settings) ValidateSecrets() error {
var bad []string
if s.JWTSecret == "" || s.JWTSecret == placeholderSecret {
bad = append(bad, "JWT_SECRET")
}
if s.APIKeySalt == "" || s.APIKeySalt == placeholderSecret {
bad = append(bad, "API_KEY_SALT")
}
if len(bad) > 0 {
return fmt.Errorf("production requires KMS-injected secrets, got placeholder/empty: %s", strings.Join(bad, ", "))
}
return nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func envBool(key string, def bool) bool {
if v, ok := os.LookupEnv(key); ok {
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return def
}
func envInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envList(key string, def []string) []string {
if v, ok := os.LookupEnv(key); ok && v != "" {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
return def
}