mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
Lays down the Go skeleton parallel to api/ (FastAPI). Foundation: - config: env-driven Settings - db: pgx pool + idempotent schema migrations + typed row structs - iam: HS256 JWT Verifier + bearer-auth middleware - api: chi router with /healthz + /readyz + versioned /v1 mount Routes ported: - /auth: login + signup + refresh (bcrypt + JWT) - /chains: registry + status - /projects: full CRUD scoped by org - /wallets: list + create scoped by project+org Stubs (route wired, handler returns 501): - /api-keys, /webhooks, /rpc, /tokens, /nfts cmd/bootnode/main.go is the production entrypoint with graceful shutdown (SIGINT/SIGTERM) and slog JSON logging. Dockerfile builds a distroless image with CGO disabled. Remaining surface to port (in order of priority): rpc upstream dispatcher, api-keys CRUD + auth, tokens + nfts read-only, webhooks delivery worker, fleet + node + cluster routes, bundler + gas, billing. The Python service in api/ keeps running until parity ships.
129 lines
3.6 KiB
Go
129 lines
3.6 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.
|
|
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"`
|
|
IssuedAt int64 `json:"iat"`
|
|
ExpiresAt int64 `json:"exp"`
|
|
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
|
|
}
|