mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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]>
This commit is contained in:
@@ -41,7 +41,21 @@ func main() {
|
||||
}
|
||||
slog.Info("db ready")
|
||||
|
||||
verifier := iam.NewHS(cfg.JWTSecret)
|
||||
// HIP-0111: production verifies IAM-issued access tokens against the
|
||||
// issuer's JWKS (RS256/ES256), resolved via OIDC discovery. Local-dev keeps
|
||||
// the HS256 shared-secret path for the built-in email/password flow.
|
||||
var verifier iam.Verifier
|
||||
if cfg.IsProduction() {
|
||||
if err := cfg.ValidateSecrets(); err != nil {
|
||||
slog.Error("config invalid", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
verifier = iam.NewJWKS(cfg.IAMUrl, cfg.IAMClientID)
|
||||
slog.Info("auth: IAM JWKS verifier", "issuer", cfg.IAMUrl, "audience", cfg.IAMClientID)
|
||||
} else {
|
||||
verifier = iam.NewHS(cfg.JWTSecret)
|
||||
slog.Info("auth: HS256 dev verifier")
|
||||
}
|
||||
|
||||
handler := api.New(api.Deps{
|
||||
Pool: pool,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -33,9 +34,9 @@ type Settings struct {
|
||||
APIKeySalt string
|
||||
|
||||
// Hanzo IAM
|
||||
IAMUrl string
|
||||
IAMClientID string
|
||||
IAMClientSecret string
|
||||
IAMUrl string
|
||||
IAMClientID string
|
||||
IAMClientSecret string
|
||||
EnableMultiTenant bool
|
||||
AllowedOrgs []string
|
||||
FrontendURL string
|
||||
@@ -47,9 +48,9 @@ type Settings struct {
|
||||
ZAPPort int
|
||||
|
||||
// Observability
|
||||
OTELEndpoint string
|
||||
OTELEndpoint string
|
||||
OTELServiceName string
|
||||
PrometheusPort int
|
||||
PrometheusPort int
|
||||
|
||||
// Listen
|
||||
HTTPHost string
|
||||
@@ -112,6 +113,34 @@ 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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"production": true,
|
||||
"staging": true,
|
||||
"development": false,
|
||||
"dev": false,
|
||||
"local": false,
|
||||
"": false,
|
||||
}
|
||||
for env, want := range cases {
|
||||
s := &Settings{AppEnv: env}
|
||||
if got := s.IsProduction(); got != want {
|
||||
t.Errorf("IsProduction(%q) = %v, want %v", env, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSecrets(t *testing.T) {
|
||||
// Placeholder / empty secrets must be rejected in production.
|
||||
bad := &Settings{JWTSecret: placeholderSecret, APIKeySalt: ""}
|
||||
if err := bad.ValidateSecrets(); err == nil {
|
||||
t.Fatal("expected error for placeholder/empty secrets, got nil")
|
||||
}
|
||||
|
||||
// Real KMS-injected secrets pass.
|
||||
good := &Settings{JWTSecret: "a-real-kms-secret", APIKeySalt: "a-real-kms-salt"}
|
||||
if err := good.ValidateSecrets(); err != nil {
|
||||
t.Fatalf("expected no error for real secrets, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,19 @@ var (
|
||||
|
||||
// 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"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Package iam — production JWT verification against Hanzo IAM's JWKS.
|
||||
//
|
||||
// HIP-0111: IAM-issued access tokens are validated against the issuer's
|
||||
// published JWKS (RS256/ES256), resolved via OIDC discovery. No shared HMAC
|
||||
// secret, no introspection round-trip per request — the JWKS public keys are
|
||||
// fetched once, cached, and refreshed on an unknown `kid`.
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ecCurve maps a JWK `crv` value to its elliptic.Curve.
|
||||
func ecCurve(crv string) (elliptic.Curve, error) {
|
||||
switch crv {
|
||||
case "P-256":
|
||||
return elliptic.P256(), nil
|
||||
case "P-384":
|
||||
return elliptic.P384(), nil
|
||||
case "P-521":
|
||||
return elliptic.P521(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported EC curve %q", crv)
|
||||
}
|
||||
}
|
||||
|
||||
// JWKSVerifier validates bearer tokens against an IAM deployment's JWKS.
|
||||
//
|
||||
// The issuer is the canonical OIDC issuer for the brand this deployment serves
|
||||
// (iam.hanzo.ai / lux.id / zoo.id / pars.id / id.bootno.de). The jwks_uri is
|
||||
// read from `{issuer}/.well-known/openid-configuration`; keys are cached and
|
||||
// re-fetched at most once per minTTL when a token presents an unknown `kid`.
|
||||
type JWKSVerifier struct {
|
||||
issuer string
|
||||
audience string // expected `aud` (clientId); "" skips the audience check
|
||||
clockSkew time.Duration
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
keys map[string]any // kid -> *rsa.PublicKey | *ecdsa.PublicKey
|
||||
fetched time.Time
|
||||
minRefresh time.Duration
|
||||
}
|
||||
|
||||
// NewJWKS constructs a JWKS verifier for the given OIDC issuer. When audience is
|
||||
// non-empty (the deployment's IAM client ID), tokens whose `aud` does not match
|
||||
// are rejected — so a token minted for a different client of the same issuer is
|
||||
// not accepted. Empty audience skips the check (issuer + signature only).
|
||||
func NewJWKS(issuer, audience string) *JWKSVerifier {
|
||||
return &JWKSVerifier{
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
clockSkew: 60 * time.Second,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
keys: map[string]any{},
|
||||
minRefresh: 1 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify implements Verifier. It parses the token, resolves the signing key by
|
||||
// `kid` against the cached JWKS (refreshing on a miss), validates the signature
|
||||
// and standard claims, and checks the issuer.
|
||||
func (j *JWKSVerifier) Verify(ctx context.Context, token string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
switch t.Method.(type) {
|
||||
case *jwt.SigningMethodRSA, *jwt.SigningMethodECDSA:
|
||||
default:
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
key, err := j.keyByID(ctx, kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
},
|
||||
opts(j)...,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
// opts builds the jwt parser options: leeway + issuer always, audience only
|
||||
// when configured.
|
||||
func opts(j *JWKSVerifier) []jwt.ParserOption {
|
||||
o := []jwt.ParserOption{
|
||||
jwt.WithLeeway(j.clockSkew),
|
||||
jwt.WithIssuer(j.issuer),
|
||||
}
|
||||
if j.audience != "" {
|
||||
o = append(o, jwt.WithAudience(j.audience))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// keyByID returns the public key for kid, refreshing the JWKS once (subject to
|
||||
// minRefresh) if the kid is not cached.
|
||||
func (j *JWKSVerifier) keyByID(ctx context.Context, kid string) (any, error) {
|
||||
j.mu.RLock()
|
||||
key, ok := j.keys[kid]
|
||||
j.mu.RUnlock()
|
||||
if ok {
|
||||
return key, nil
|
||||
}
|
||||
if err := j.refresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j.mu.RLock()
|
||||
key, ok = j.keys[kid]
|
||||
j.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// refresh fetches the JWKS via discovery and replaces the key cache. Rate
|
||||
// limited to one fetch per minRefresh so a flood of bad-kid tokens cannot
|
||||
// hammer the IAM server.
|
||||
func (j *JWKSVerifier) refresh(ctx context.Context) error {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
if time.Since(j.fetched) < j.minRefresh && len(j.keys) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
jwksURI, err := j.discoverJWKSURI(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := j.fetchKeys(ctx, jwksURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j.keys = keys
|
||||
j.fetched = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
type discoveryDoc struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
|
||||
func (j *JWKSVerifier) discoverJWKSURI(ctx context.Context) (string, error) {
|
||||
url := j.issuer + "/.well-known/openid-configuration"
|
||||
var doc discoveryDoc
|
||||
if err := j.getJSON(ctx, url, &doc); err != nil {
|
||||
return "", fmt.Errorf("iam discovery: %w", err)
|
||||
}
|
||||
if doc.JWKSURI == "" {
|
||||
return "", errors.New("iam discovery: empty jwks_uri")
|
||||
}
|
||||
if doc.Issuer != "" && doc.Issuer != j.issuer {
|
||||
return "", fmt.Errorf("iam discovery: issuer mismatch %q != %q", doc.Issuer, j.issuer)
|
||||
}
|
||||
return doc.JWKSURI, nil
|
||||
}
|
||||
|
||||
// jwk is one key from a JWKS document (RSA or EC public key, RFC 7517/7518).
|
||||
type jwk struct {
|
||||
Kty string `json:"kty"`
|
||||
Kid string `json:"kid"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
// RSA
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
// EC
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
func (j *JWKSVerifier) fetchKeys(ctx context.Context, jwksURI string) (map[string]any, error) {
|
||||
var doc struct {
|
||||
Keys []jwk `json:"keys"`
|
||||
}
|
||||
if err := j.getJSON(ctx, jwksURI, &doc); err != nil {
|
||||
return nil, fmt.Errorf("iam jwks: %w", err)
|
||||
}
|
||||
out := make(map[string]any, len(doc.Keys))
|
||||
for _, k := range doc.Keys {
|
||||
if k.Use != "" && k.Use != "sig" {
|
||||
continue
|
||||
}
|
||||
pub, err := k.publicKey()
|
||||
if err != nil {
|
||||
continue // skip keys we can't parse (e.g. MLDSA65) — RS/ES suffice
|
||||
}
|
||||
if k.Kid != "" {
|
||||
out[k.Kid] = pub
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("iam jwks: no usable signing keys")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k jwk) publicKey() (any, error) {
|
||||
switch k.Kty {
|
||||
case "RSA":
|
||||
nb, err := base64.RawURLEncoding.DecodeString(k.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eb, err := base64.RawURLEncoding.DecodeString(k.E)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e := 0
|
||||
for _, b := range eb {
|
||||
e = e<<8 | int(b)
|
||||
}
|
||||
if e == 0 {
|
||||
e = int(binary.BigEndian.Uint32(leftPad(eb, 4)))
|
||||
}
|
||||
return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: e}, nil
|
||||
case "EC":
|
||||
crv, err := ecCurve(k.Crv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xb, err := base64.RawURLEncoding.DecodeString(k.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yb, err := base64.RawURLEncoding.DecodeString(k.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ecdsa.PublicKey{
|
||||
Curve: crv,
|
||||
X: new(big.Int).SetBytes(xb),
|
||||
Y: new(big.Int).SetBytes(yb),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported kty %q", k.Kty)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWKSVerifier) getJSON(ctx context.Context, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := j.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: status %d", url, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func leftPad(b []byte, size int) []byte {
|
||||
if len(b) >= size {
|
||||
return b
|
||||
}
|
||||
out := make([]byte, size)
|
||||
copy(out[size-len(b):], b)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,14 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/lib/auth"
|
||||
import { getBrand } from "@/lib/brand"
|
||||
import { getIam, takeReturnUrl } from "@/lib/iam"
|
||||
|
||||
function CallbackContent() {
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { loginWithToken } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,53 +18,17 @@ function CallbackContent() {
|
||||
|
||||
async function handleCallback() {
|
||||
try {
|
||||
const code = searchParams.get("code")
|
||||
const stateParam = searchParams.get("state")
|
||||
const errorParam = searchParams.get("error")
|
||||
|
||||
if (errorParam) {
|
||||
throw new Error(`OAuth error: ${errorParam}`)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error("No authorization code received")
|
||||
}
|
||||
|
||||
// Parse state to get return URL
|
||||
let returnUrl = "/dashboard"
|
||||
try {
|
||||
const state = JSON.parse(decodeURIComponent(stateParam || "{}"))
|
||||
returnUrl = state.returnUrl || "/dashboard"
|
||||
} catch {
|
||||
// State might be just the org name
|
||||
}
|
||||
|
||||
// Exchange code for token via our API
|
||||
const brand = getBrand()
|
||||
const apiUrl = (typeof window !== "undefined" && window.location.hostname !== "localhost")
|
||||
? brand.apiUrl
|
||||
: (process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000")
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
const response = await fetch(`${apiUrl}/v1/auth/oauth/callback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code, state: stateParam, redirect_uri: redirectUri }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.detail || `Authentication failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { access_token } = await response.json()
|
||||
|
||||
await loginWithToken(access_token)
|
||||
// Canonical PKCE exchange (HIP-0111): the IAM SDK validates state,
|
||||
// exchanges the authorization code at the token endpoint with the stored
|
||||
// PKCE verifier, and returns tokens. The resulting IAM access token is
|
||||
// the session bearer the bootnode API verifies against IAM JWKS.
|
||||
const tokens = await getIam().handleCallback()
|
||||
await loginWithToken(tokens.accessToken)
|
||||
|
||||
setStatus("success")
|
||||
|
||||
// Redirect to dashboard after successful login
|
||||
// Redirect to the stashed destination after successful login.
|
||||
const returnUrl = takeReturnUrl()
|
||||
setTimeout(() => {
|
||||
router.push(returnUrl)
|
||||
}, 1500)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useAuth, HanzoLoginButton } from "@/lib/auth"
|
||||
import { getIam, setReturnUrl } from "@/lib/iam"
|
||||
import { BrandLogo, useBrand } from "@/components/brand-logo"
|
||||
|
||||
function LoginForm() {
|
||||
@@ -32,17 +33,15 @@ function LoginForm() {
|
||||
}
|
||||
}, [user, authLoading, router, searchParams])
|
||||
|
||||
// Production mode: Redirect to brand-specific IAM
|
||||
// Production mode: canonical IAM PKCE redirect (HIP-0111) via @hanzo/iam.
|
||||
// The SDK generates the PKCE S256 challenge + CSRF state, resolves the
|
||||
// authorize endpoint from the active brand's OIDC discovery, and redirects.
|
||||
useEffect(() => {
|
||||
if (isProduction && !authLoading && !user) {
|
||||
const returnUrl = searchParams.get("returnUrl") || "/dashboard"
|
||||
const callbackUrl = `${window.location.origin}/auth/callback`
|
||||
const iamUrl = brand.iam.url
|
||||
const clientId = brand.iam.clientId
|
||||
const authRedirect = `${iamUrl}/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=openid+profile+email&state=${encodeURIComponent(returnUrl)}`
|
||||
window.location.href = authRedirect
|
||||
setReturnUrl(searchParams.get("returnUrl") || "/dashboard")
|
||||
void getIam().signinRedirect()
|
||||
}
|
||||
}, [isProduction, authLoading, user, searchParams, brand.iam.url, brand.iam.clientId])
|
||||
}, [isProduction, authLoading, user, searchParams])
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
+7
-3
@@ -99,9 +99,13 @@ const BOOTNODE_DEFAULT: BrandConfig = {
|
||||
},
|
||||
social: {},
|
||||
iam: {
|
||||
url: "https://hanzo.id",
|
||||
clientId: "bootnode-web",
|
||||
domain: "hanzo.id",
|
||||
// HIP-0111: bootnode's canonical OIDC issuer is id.bootno.de; the
|
||||
// registered Casdoor client for bootno.de/auth/callback is bootnode-platform.
|
||||
// (Per-brand catalogs for lux/zoo/pars/hanzo are injected at deploy time —
|
||||
// see public/config.example.json for the registered clientId per brand.)
|
||||
url: "https://id.bootno.de",
|
||||
clientId: "bootnode-platform",
|
||||
domain: "id.bootno.de",
|
||||
},
|
||||
footerLinks: [],
|
||||
ecosystemApps: [],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Canonical IAM auth (HIP-0111) via @hanzo/iam SPA PKCE.
|
||||
//
|
||||
// One source of truth for the browser IAM client, white-labelled per brand:
|
||||
// serverUrl + clientId come from the active brand catalog (lib/brand.ts), which
|
||||
// is injected per-deploy. Env overrides win for one-off local testing.
|
||||
//
|
||||
// HIP-0111: authenticate ONLY through @hanzo/iam against the canonical OIDC
|
||||
// endpoints. The SDK resolves /v1/iam/oauth/* via live discovery (with a
|
||||
// canonical-path fallback), drives PKCE S256, and owns `state` for CSRF. No
|
||||
// hand-rolled authorize URLs, no genericOAuth({discoveryUrl}), no /api/auth.
|
||||
"use client"
|
||||
|
||||
import { IAM } from "@hanzo/iam/browser"
|
||||
import { getBrand } from "@/lib/brand"
|
||||
|
||||
// Stash the post-login destination across the IAM round-trip. The SDK owns
|
||||
// `state` for CSRF; the app owns where to land after sign-in.
|
||||
const RETURN_URL_KEY = "bootnode_return_url"
|
||||
|
||||
export function getIam(): IAM {
|
||||
const brand = getBrand()
|
||||
const serverUrl =
|
||||
process.env.NEXT_PUBLIC_IAM_URL || brand.iam.url
|
||||
const clientId =
|
||||
process.env.NEXT_PUBLIC_IAM_CLIENT_ID || brand.iam.clientId
|
||||
return new IAM({
|
||||
serverUrl,
|
||||
clientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
scope: "openid profile email",
|
||||
})
|
||||
}
|
||||
|
||||
export function setReturnUrl(url: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(RETURN_URL_KEY, url)
|
||||
} catch {
|
||||
/* sessionStorage unavailable — fall back to default destination */
|
||||
}
|
||||
}
|
||||
|
||||
export function takeReturnUrl(): string {
|
||||
try {
|
||||
const url = sessionStorage.getItem(RETURN_URL_KEY)
|
||||
sessionStorage.removeItem(RETURN_URL_KEY)
|
||||
return url || "/dashboard"
|
||||
} catch {
|
||||
return "/dashboard"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@luxfi/logo": "^1.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
@@ -60,5 +61,8 @@
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["sharp"]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+71
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0([email protected])
|
||||
'@luxfi/logo':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0([email protected])
|
||||
@@ -179,6 +182,15 @@ packages:
|
||||
'@floating-ui/[email protected]':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-AGTYSl+En0Ci3UTBJs/kJ1MjxIAKOW9pMwLD6QCFLsBZIZCp1JljESepTEs4aBLifK0L3h/j3ya53ZyU3dSXjA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1167,6 +1179,10 @@ packages:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
|
||||
hasBin: true
|
||||
@@ -1378,9 +1394,15 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -1519,10 +1541,21 @@ packages:
|
||||
react-router-dom:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1600,6 +1633,7 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -1674,6 +1708,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -1702,6 +1739,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
@@ -1736,6 +1777,14 @@ snapshots:
|
||||
|
||||
'@floating-ui/[email protected]': {}
|
||||
|
||||
'@hanzo/[email protected]([email protected])':
|
||||
dependencies:
|
||||
jose: 6.2.3
|
||||
libphonenumber-js: 1.13.7
|
||||
passport-oauth2: 1.8.0
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@img/[email protected]': {}
|
||||
|
||||
'@img/[email protected]':
|
||||
@@ -2682,6 +2731,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2904,8 +2955,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -3007,8 +3062,20 @@ snapshots:
|
||||
optionalDependencies:
|
||||
next: 16.1.6([email protected]([email protected]))([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
base64url: 3.0.1
|
||||
oauth: 0.10.2
|
||||
passport-strategy: 1.0.0
|
||||
uid2: 0.0.4
|
||||
utils-merge: 1.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -3170,6 +3237,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
@@ -3191,6 +3260,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
allowBuilds:
|
||||
sharp: true
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_comment": "REFERENCE multi-brand catalog (HIP-0111). The deploy (universe / CF-Pages) provisions config.json (and/or window.__BRAND_CATALOG__) from a catalog of this shape, host-keyed. iam.url is the canonical OIDC issuer (serverUrl); iam.clientId is the REGISTERED Casdoor app for that host (<org>-web3 / bootnode-platform / pars-app) whose redirectUris include https://<host>/auth/callback. These are PUBLIC values (no secrets) — the @hanzo/iam SPA PKCE flow is a public client. Do NOT invent client IDs: an unregistered clientId returns 'Invalid client_id' and IAM serves its 200 SPA catch-all, which is silent breakage. Brand identity (logos/colors/links) is elided here; only the iam + host wiring is shown as the source of truth.",
|
||||
"defaultKey": "bootnode",
|
||||
"hosts": [
|
||||
{ "match": "web3.hanzo.ai", "key": "hanzo" },
|
||||
{ "match": "cloud.lux.network", "key": "lux" },
|
||||
{ "match": "web3.lux.network", "key": "lux" },
|
||||
{ "match": "web3.zoo.ngo", "key": "zoo" },
|
||||
{ "match": "cloud.pars.network", "key": "pars" },
|
||||
{ "match": "bootno.de", "key": "bootnode" }
|
||||
],
|
||||
"brands": {
|
||||
"hanzo": {
|
||||
"iam": { "url": "https://iam.hanzo.ai", "clientId": "hanzo-web3", "domain": "iam.hanzo.ai" }
|
||||
},
|
||||
"lux": {
|
||||
"iam": { "url": "https://lux.id", "clientId": "lux-web3", "domain": "lux.id" }
|
||||
},
|
||||
"zoo": {
|
||||
"iam": { "url": "https://zoo.id", "clientId": "zoo-web3", "domain": "zoo.id" }
|
||||
},
|
||||
"pars": {
|
||||
"iam": { "url": "https://pars.id", "clientId": "pars-app", "domain": "pars.id" }
|
||||
},
|
||||
"bootnode": {
|
||||
"iam": { "url": "https://id.bootno.de", "clientId": "bootnode-platform", "domain": "id.bootno.de" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user