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

133 lines
3.9 KiB
Go

// Package iam — JWT verification + claim extraction. Backed by Hanzo
// IAM today; the Verifier interface lets a test stub or a different
// backend (Auth0, Keycloak) drop in.
package iam
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Errors returned by Verify.
var (
ErrTokenMissing = errors.New("authentication required")
ErrTokenInvalid = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
)
// Claims is the JWT body Bootnode trusts. Validated server-side
// against IAM's JWKS in the production verifier; HS256 for local-dev.
//
// Standard registered claims (exp, iat, iss, aud, sub) live in the embedded
// jwt.RegisteredClaims so jwt/v5 enforces them. Only app-specific claims are
// declared here. `sub` is mirrored to UserID for ergonomic access by handlers;
// do NOT re-declare `exp`/`iat` here — duplicate json tags shadow
// RegisteredClaims and silently disable expiry validation.
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
OrgID string `json:"org_id"`
Roles []string `json:"roles"`
Scopes []string `json:"scopes"`
jwt.RegisteredClaims
}
// Verifier validates a bearer token.
type Verifier interface {
Verify(ctx context.Context, token string) (*Claims, error)
}
// HSVerifier validates HS256 tokens using a shared secret. Used in
// local-dev; production uses JWKSVerifier against the IAM service.
type HSVerifier struct {
secret []byte
clockSkew time.Duration
}
// NewHS constructs an HMAC verifier.
func NewHS(secret string) *HSVerifier {
return &HSVerifier{secret: []byte(secret), clockSkew: 5 * time.Second}
}
// Verify implements Verifier.
func (h *HSVerifier) Verify(_ context.Context, token string) (*Claims, error) {
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrTokenInvalid
}
return h.secret, nil
}, jwt.WithLeeway(h.clockSkew))
if err != nil {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
return nil, ErrTokenExpired
default:
return nil, ErrTokenInvalid
}
}
if !parsed.Valid {
return nil, ErrTokenInvalid
}
return claims, nil
}
// ExtractBearer pulls the bearer token out of an Authorization header.
func ExtractBearer(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
if h == "" {
return "", ErrTokenMissing
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", ErrTokenInvalid
}
if parts[1] == "" {
return "", ErrTokenInvalid
}
return parts[1], nil
}
// Middleware verifies the JWT and stores Claims on the request context.
// Requests without a token short-circuit with 401; invalid + expired
// tokens get the same response shape (no information leak about which
// of the two failed).
func Middleware(v Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := ExtractBearer(r)
if err != nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
claims, err := v.Verify(r.Context(), tok)
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
ctx := WithClaims(r.Context(), claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// contextKey is the unexported key type for Claims storage.
type contextKey struct{}
// WithClaims attaches claims to a context.
func WithClaims(ctx context.Context, c *Claims) context.Context {
return context.WithValue(ctx, contextKey{}, c)
}
// FromContext returns the Claims bound by Middleware, or nil if absent.
func FromContext(ctx context.Context) *Claims {
c, _ := ctx.Value(contextKey{}).(*Claims)
return c
}