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]>
133 lines
3.9 KiB
Go
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
|
|
}
|