// Package middleware holds the chi middleware Bootnode mounts. // // All identity decisions go through deps.IAM — Bootnode never decodes // JWTs itself. Per the Hanzo header convention (LLM.md), incoming // X-Org-Id / X-User-Id / X-User-Email are TRUSTED only when minted by // the gateway from a validated JWT. When Bootnode runs standalone it // derives them from the Authorization bearer via deps.IAM. package middleware import ( "context" "net/http" "strings" ) // ctxKey is unexported so external packages can't accidentally clobber // our context values. type ctxKey int const ( ctxOrgID ctxKey = iota ctxUserID ctxEmail ) // IAMVerifier matches api.Deps.IAM but lets this package avoid an // import cycle. type IAMVerifier interface { VerifyJWT(ctx context.Context, bearer string) (orgID, userID, email string, err error) } // AuthOptional wires identity into request context when the // Authorization header parses; missing/invalid tokens fall through // (handlers that require auth call MustAuth on the context). func AuthOptional(iam IAMVerifier) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 1) Gateway-trusted headers (per HTTP Header Convention // 2026-03-27): only honored when the upstream gateway // has already validated the JWT. We trust them iff IAM // is nil (split-deploy behind gateway) or the bearer // matches. if orgID := r.Header.Get("X-Org-Id"); orgID != "" && iam == nil { ctx = context.WithValue(ctx, ctxOrgID, orgID) ctx = context.WithValue(ctx, ctxUserID, r.Header.Get("X-User-Id")) ctx = context.WithValue(ctx, ctxEmail, r.Header.Get("X-User-Email")) next.ServeHTTP(w, r.WithContext(ctx)) return } // 2) Authorization: Bearer ... → IAM verify. if iam != nil { if bearer := bearerToken(r); bearer != "" { orgID, userID, email, err := iam.VerifyJWT(ctx, bearer) if err == nil { ctx = context.WithValue(ctx, ctxOrgID, orgID) ctx = context.WithValue(ctx, ctxUserID, userID) ctx = context.WithValue(ctx, ctxEmail, email) } } } next.ServeHTTP(w, r.WithContext(ctx)) }) } } // OrgID returns the org slug attached by AuthOptional, or "". func OrgID(ctx context.Context) string { if v, ok := ctx.Value(ctxOrgID).(string); ok { return v } return "" } // UserID returns the user ID attached by AuthOptional, or "". func UserID(ctx context.Context) string { if v, ok := ctx.Value(ctxUserID).(string); ok { return v } return "" } // Email returns the user email attached by AuthOptional, or "". func Email(ctx context.Context) string { if v, ok := ctx.Value(ctxEmail).(string); ok { return v } return "" } // RequireAuth wraps a handler so it 401s when no org is attached. func RequireAuth(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if OrgID(r.Context()) == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } h(w, r) } } func bearerToken(r *http.Request) string { h := r.Header.Get("Authorization") const p = "Bearer " if !strings.HasPrefix(h, p) { return "" } return strings.TrimSpace(h[len(p):]) }