mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
* 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]>
185 lines
5.2 KiB
Go
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
|
|
}
|