// Package authd — middleware.go: HTTP middleware for the JWT gate // (M13a W2). Services like ingestd, routerd, deliverd-* use this // to require a valid Bearer access token on protected routes, and // to get the (user_id, tenant_id, role) claims into the request // context for downstream logging and authorization. // // Threading: safe for concurrent use. The Authd it wraps is // shared across requests. package authd import ( "context" "errors" "net/http" "strings" ) // ctxKey is unexported so external packages can't accidentally // collide on the same context key. type ctxKey int const ( ctxKeyClaims ctxKey = iota + 1 ) // ClaimsFromContext returns the JWT claims that RequireAuth stored // in ctx, or nil if none. Used by handlers downstream of the // middleware to read user_id / tenant_id / role. func ClaimsFromContext(ctx context.Context) *AccessClaims { v, ok := ctx.Value(ctxKeyClaims).(*AccessClaims) if !ok { return nil } return v } // withClaims stores the claims in ctx. Internal — handlers should // use ClaimsFromContext. func withClaims(ctx context.Context, c *AccessClaims) context.Context { return context.WithValue(ctx, ctxKeyClaims, c) } // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- // RequireAuth is the middleware. Wrap any handler that needs // authentication: // // mux.Handle("POST /v1/admin/foo", ad.RequireAuth(myHandler)) // // On success, the handler is called with the claims in context // (ClaimsFromContext). On failure, it writes a 401 with a JSON body // and does NOT call the wrapped handler. func (a *Authd) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tok, err := bearerFromRequest(r) if err != nil { writeUnauthorized(w, "missing_bearer", err.Error()) return } claims, err := a.VerifyAccessToken(tok) if err != nil { writeUnauthorized(w, "invalid_token", "access token invalid or expired") return } next.ServeHTTP(w, r.WithContext(withClaims(r.Context(), claims))) }) } // RequireRole is like RequireAuth but additionally checks that the // JWT carries one of the allowed roles. Returns 403 if the role // doesn't match. // // mux.Handle("POST /v1/users/invite", // ad.RequireRole("super_admin", "tenant_admin")(inviteHandler)) func (a *Authd) RequireRole(allowed ...string) func(http.Handler) http.Handler { allowedSet := make(map[string]struct{}, len(allowed)) for _, r := range allowed { allowedSet[r] = struct{}{} } return func(next http.Handler) http.Handler { return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims := ClaimsFromContext(r.Context()) if claims == nil { // Should not happen — RequireAuth would have 401'd. writeUnauthorized(w, "internal", "claims missing") return } if _, ok := allowedSet[claims.Role]; !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"forbidden","message":"role not allowed"}`)) return } next.ServeHTTP(w, r) })) } } // bearerFromRequest extracts the raw token from the Authorization // header, accepting the case-insensitive "Bearer" scheme. func bearerFromRequest(r *http.Request) (string, error) { h := r.Header.Get("Authorization") if h == "" { return "", errors.New("Authorization header missing") } // "Bearer " — split on the first whitespace. const scheme = "bearer" if len(h) < len(scheme)+1 { return "", errors.New("Authorization header too short") } // case-insensitive scheme match prefix := strings.ToLower(h[:len(scheme)]) if prefix != scheme { return "", errors.New("Authorization must use Bearer scheme") } rest := strings.TrimSpace(h[len(scheme):]) if rest == "" { return "", errors.New("bearer token empty") } // Some clients send "Bearer\t" — trim space above handles. // Also reject "Bearer " (double space) and "Bearer, ...". return rest, nil } // writeUnauthorized writes a 401 with a JSON error body. Duplicated // in cmd/authd/main.go for now; v1.1 should move it to a shared // helper in internal/httpserver. func writeUnauthorized(w http.ResponseWriter, code, msg string) { w.Header().Set("Content-Type", "application/json") w.Header().Set("WWW-Authenticate", `Bearer realm="broad-announce"`) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"` + code + `","message":"` + msg + `"}`)) }