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]>
166 lines
5.1 KiB
Go
166 lines
5.1 KiB
Go
package iam
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// newJWKSServer stands up a discovery + JWKS endpoint backed by `key`, with the
|
|
// given issuer and kid. Returns the issuer URL (the test server's base).
|
|
func newJWKSServer(t *testing.T, key *rsa.PrivateKey, kid string) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
srv := httptest.NewServer(mux)
|
|
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"issuer": srv.URL,
|
|
"jwks_uri": srv.URL + "/v1/iam/.well-known/jwks",
|
|
})
|
|
})
|
|
mux.HandleFunc("/v1/iam/.well-known/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
pub := key.Public().(*rsa.PublicKey)
|
|
eb := big.NewInt(int64(pub.E)).Bytes()
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"keys": []map[string]string{{
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"kid": kid,
|
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
|
"e": base64.RawURLEncoding.EncodeToString(eb),
|
|
}},
|
|
})
|
|
})
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func signRS256(t *testing.T, key *rsa.PrivateKey, kid, issuer string, exp time.Time) string {
|
|
t.Helper()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"sub": "user-123",
|
|
"email": "[email protected]",
|
|
"iss": issuer,
|
|
"iat": time.Now().Unix(),
|
|
"exp": exp.Unix(),
|
|
})
|
|
tok.Header["kid"] = kid
|
|
s, err := tok.SignedString(key)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestJWKSVerify_ValidRS256(t *testing.T) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("genkey: %v", err)
|
|
}
|
|
srv := newJWKSServer(t, key, "cert-test")
|
|
v := NewJWKS(srv.URL, "")
|
|
|
|
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(time.Hour))
|
|
claims, err := v.Verify(context.Background(), tokStr)
|
|
if err != nil {
|
|
t.Fatalf("Verify: %v", err)
|
|
}
|
|
if claims.UserID != "user-123" || claims.Email != "[email protected]" {
|
|
t.Fatalf("bad claims: %+v", claims)
|
|
}
|
|
}
|
|
|
|
func TestJWKSVerify_RejectsHS256(t *testing.T) {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
srv := newJWKSServer(t, key, "cert-test")
|
|
v := NewJWKS(srv.URL, "")
|
|
|
|
// HMAC-signed token must be rejected: a JWKS verifier accepts only the
|
|
// asymmetric algs IAM signs with (RS/ES), never a caller-chosen HS256.
|
|
hs := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "x", "iss": srv.URL, "exp": time.Now().Add(time.Hour).Unix()})
|
|
s, _ := hs.SignedString([]byte("attacker-known-secret"))
|
|
if _, err := v.Verify(context.Background(), s); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("expected ErrTokenInvalid for HS256, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJWKSVerify_RejectsWrongIssuer(t *testing.T) {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
srv := newJWKSServer(t, key, "cert-test")
|
|
v := NewJWKS(srv.URL, "")
|
|
|
|
tokStr := signRS256(t, key, "cert-test", "https://evil.example", time.Now().Add(time.Hour))
|
|
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("expected ErrTokenInvalid for wrong issuer, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJWKSVerify_RejectsExpired(t *testing.T) {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
srv := newJWKSServer(t, key, "cert-test")
|
|
v := NewJWKS(srv.URL, "")
|
|
|
|
tokStr := signRS256(t, key, "cert-test", srv.URL, time.Now().Add(-2*time.Minute))
|
|
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenExpired) {
|
|
t.Fatalf("expected ErrTokenExpired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestJWKSVerify_RejectsUnknownKID(t *testing.T) {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
srv := newJWKSServer(t, key, "cert-test")
|
|
v := NewJWKS(srv.URL, "")
|
|
|
|
// Token signed with a kid the JWKS does not publish.
|
|
tokStr := signRS256(t, key, "cert-unknown", srv.URL, time.Now().Add(time.Hour))
|
|
if _, err := v.Verify(context.Background(), tokStr); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("expected ErrTokenInvalid for unknown kid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func signRS256Aud(t *testing.T, key *rsa.PrivateKey, kid, issuer, aud string) string {
|
|
t.Helper()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"sub": "user-123",
|
|
"iss": issuer,
|
|
"aud": aud,
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
tok.Header["kid"] = kid
|
|
s, err := tok.SignedString(key)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestJWKSVerify_Audience(t *testing.T) {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
srv := newJWKSServer(t, key, "cert-test")
|
|
v := NewJWKS(srv.URL, "bootnode-platform")
|
|
|
|
// Matching aud is accepted.
|
|
ok := signRS256Aud(t, key, "cert-test", srv.URL, "bootnode-platform")
|
|
if _, err := v.Verify(context.Background(), ok); err != nil {
|
|
t.Fatalf("expected matching aud to pass, got %v", err)
|
|
}
|
|
|
|
// A token minted for a different client of the same issuer is rejected.
|
|
bad := signRS256Aud(t, key, "cert-test", srv.URL, "some-other-client")
|
|
if _, err := v.Verify(context.Background(), bad); !errors.Is(err, ErrTokenInvalid) {
|
|
t.Fatalf("expected ErrTokenInvalid for wrong aud, got %v", err)
|
|
}
|
|
}
|