From fe6db9bef225cd7b3d73c4953e517c4e33276d08 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Mon, 18 May 2026 14:14:04 -0600 Subject: [PATCH] fix(httpauth): wildcard resource fallback, json.Number precision, xerrors JSON error bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaimsPermissionProvider.ResolveMask now falls back to the "*" resource key when the exact resource is absent in the JWT masks claim. Specific resource takes precedence; wildcard is the fallback. Enables ADMIN wildcard masks ({"*": MaxInt64}) to pass every endpoint guard without per-resource entries. Add json.Number handling alongside existing int64/float64 paths. json.Number is produced by jwt.WithJSONNumber() (httpauth-jwt) and preserves math.MaxInt64 exactly — float64 would round to 2^63 and overflow on cast back to int64. Export WriteJSONError(w, status, code, message) — shared JSON error helper for all httpauth-* provider packages. Ensures a consistent {"code":"...","message":"..."} body and Content-Type: application/json across the full middleware stack. AuthzMiddleware and EnrichmentMiddleware now use WriteJSONError with xerrors code constants (UNAUTHENTICATED, PERMISSION_DENIED, INTERNAL) instead of http.Error which writes text/plain. AuthzMiddleware also fixes a wrong code string: UNAUTHORIZED → UNAUTHENTICATED (stable gRPC-aligned name, matching xerrors.ErrUnauthorized). Add xerrors v1.0.1 as a direct dependency for the code constants. --- CHANGELOG.md | 32 +++++++++++++ authz.go | 18 +++++++- claims_provider.go | 22 ++++++--- enrichment.go | 5 ++- go.mod | 5 ++- go.sum | 2 + httpauth_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d530e7..58f4a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this module will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.2] — 2026-05-18 + +### Added + +- `ClaimsPermissionProvider.ResolveMask` now supports a wildcard resource key `"*"` as + a fallback when the requested resource is not present in the JWT masks claim. Specific + resource key takes precedence; wildcard is consulted only when the exact key is absent. + Primary use case: `ADMIN` role carries `{"*": MaxInt64}` — one JWT entry grants access + to every resource without per-resource enumeration. +- `ClaimsPermissionProvider.ResolveMask` now handles `json.Number` mask values in + addition to the existing `int64` and `float64` paths. `json.Number` is produced by + `jwt.WithJSONNumber()` (used in `httpauth-jwt`); it preserves `math.MaxInt64` exactly + where float64 would overflow. +- `WriteJSONError(w http.ResponseWriter, status int, code, message string)` — exported + helper that writes a structured JSON error body (`Content-Type: application/json`, + `{"code":"...","message":"..."}`). Intended for use by all `httpauth-*` provider + packages so the error format is enforced in one place. + +### Fixed + +- `AuthzMiddleware` and `EnrichmentMiddleware` now return JSON error bodies instead of + plain-text `http.Error` responses. Error codes are stable gRPC-aligned names from + `xerrors`: `UNAUTHENTICATED` (401), `PERMISSION_DENIED` (403), `INTERNAL` (500). + Previously `AuthzMiddleware` also used the wrong code `UNAUTHORIZED` for 401 responses + (correct value is `UNAUTHENTICATED`). + +### Dependencies + +- Added `code.nochebuena.dev/go/xerrors v1.0.1` (for code constants in error responses). + +[1.0.2]: https://code.nochebuena.dev/go/httpauth/releases/tag/v1.0.2 + ## [1.0.0] — 2026-05-08 ### Added diff --git a/authz.go b/authz.go index 52289e4..6df59ed 100644 --- a/authz.go +++ b/authz.go @@ -1,9 +1,11 @@ package httpauth import ( + "encoding/json" "net/http" "code.nochebuena.dev/go/rbac" + "code.nochebuena.dev/go/xerrors" ) // AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware) @@ -16,13 +18,13 @@ func AuthzMiddleware(provider rbac.PermissionProvider, resource string, required return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { identity, ok := rbac.FromContext(r.Context()) if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) + WriteJSONError(w, http.StatusUnauthorized, string(xerrors.ErrUnauthorized), "unauthorized") return } mask, err := provider.ResolveMask(r.Context(), identity.UID, resource) if err != nil || !mask.Has(required) { - http.Error(w, "forbidden", http.StatusForbidden) + WriteJSONError(w, http.StatusForbidden, string(xerrors.ErrPermissionDenied), "forbidden") return } @@ -30,3 +32,15 @@ func AuthzMiddleware(provider rbac.PermissionProvider, resource string, required }) } } + +// WriteJSONError writes a structured JSON error body matching the httputil.Error format. +// Used by all httpauth-* provider packages to ensure a consistent error format across +// the full middleware stack. +func WriteJSONError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": code, + "message": message, + }) +} diff --git a/claims_provider.go b/claims_provider.go index 9b478ba..ff146fb 100644 --- a/claims_provider.go +++ b/claims_provider.go @@ -2,6 +2,7 @@ package httpauth import ( "context" + "encoding/json" "code.nochebuena.dev/go/rbac" ) @@ -25,15 +26,24 @@ func (p *claimsPermissionProvider) ResolveMask(ctx context.Context, _, resource if !ok { return 0, nil } - permisos, ok := claims[p.claimsKey].(map[string]any) + masks, ok := claims[p.claimsKey].(map[string]any) if !ok { return 0, nil } - switch v := permisos[resource].(type) { - case int64: - return rbac.PermissionMask(v), nil - case float64: - return rbac.PermissionMask(int64(v)), nil + // Check specific resource first, then wildcard fallback. + for _, key := range []string{resource, "*"} { + switch v := masks[key].(type) { + case int64: + return rbac.PermissionMask(v), nil + case float64: + return rbac.PermissionMask(int64(v)), nil + case json.Number: + n, err := v.Int64() + if err != nil { + return 0, nil + } + return rbac.PermissionMask(n), nil + } } return 0, nil } diff --git a/enrichment.go b/enrichment.go index 63f73ab..27c0240 100644 --- a/enrichment.go +++ b/enrichment.go @@ -5,6 +5,7 @@ import ( "net/http" "code.nochebuena.dev/go/rbac" + "code.nochebuena.dev/go/xerrors" ) // IdentityEnricher builds an rbac.Identity from verified token data. @@ -42,7 +43,7 @@ func EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(htt return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uid, ok := getUID(r.Context()) if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) + WriteJSONError(w, http.StatusUnauthorized, string(xerrors.ErrUnauthorized), "unauthorized") return } @@ -50,7 +51,7 @@ func EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(htt identity, err := enricher.Enrich(r.Context(), uid, claims) if err != nil { - http.Error(w, "internal server error", http.StatusInternalServerError) + WriteJSONError(w, http.StatusInternalServerError, string(xerrors.ErrInternal), "internal server error") return } diff --git a/go.mod b/go.mod index 243be44..c206a5c 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module code.nochebuena.dev/go/httpauth go 1.26 -require code.nochebuena.dev/go/rbac v1.0.0 +require ( + code.nochebuena.dev/go/rbac v1.0.0 + code.nochebuena.dev/go/xerrors v1.0.1 +) diff --git a/go.sum b/go.sum index 10bf86c..c6b7082 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ code.nochebuena.dev/go/rbac v1.0.0 h1:FnsU1HU6vvwchKuZNxDa9RPIFeNwJi0vShWvHKABMws= code.nochebuena.dev/go/rbac v1.0.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= +code.nochebuena.dev/go/xerrors v1.0.1 h1:fgXoabY/ZwxAzaM1sKFf3sbL7ZHWyDxItB/rdbnl0mo= +code.nochebuena.dev/go/xerrors v1.0.1/go.mod h1:03MMVfrhaf4XmTMgMrEUFCmuZPGHUCKDitiQvwCuwvY= diff --git a/httpauth_test.go b/httpauth_test.go index cade31f..d523a7b 100644 --- a/httpauth_test.go +++ b/httpauth_test.go @@ -2,7 +2,9 @@ package httpauth import ( "context" + "encoding/json" "errors" + "math" "net/http" "net/http/httptest" "testing" @@ -348,3 +350,111 @@ func TestChainPermissionProvider_ErrorPropagates(t *testing.T) { t.Error("expected error from first provider, got nil") } } + +// --- ClaimsPermissionProvider wildcard + json.Number --- + +func TestClaimsPermissionProvider_WildcardFallback(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{"*": int64(515)}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "cualquier_recurso") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from wildcard, got %d", mask) + } +} + +func TestClaimsPermissionProvider_SpecificBeatsWildcard(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{ + "system": int64(42), + "*": int64(999), + }, + }) + mask, err := p.ResolveMask(ctx, "uid1", "system") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 42 { + t.Errorf("want 42 from specific key, got %d (wildcard must not override specific)", mask) + } +} + +func TestClaimsPermissionProvider_JSONNumber(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{"system": json.Number("9223372036854775807")}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "system") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if int64(mask) != math.MaxInt64 { + t.Errorf("want MaxInt64 (%d), got %d", int64(math.MaxInt64), int64(mask)) + } +} + +// --- JSON error body assertions --- + +func assertJSONError(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantCode string) { + t.Helper() + if rec.Code != wantStatus { + t.Errorf("want status %d, got %d", wantStatus, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("want Content-Type application/json, got %q", ct) + } + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("response body is not valid JSON: %v", err) + } + if body["code"] != wantCode { + t.Errorf("want code %q, got %q", wantCode, body["code"]) + } +} + +func TestAuthzMiddleware_ForbiddenJSON(t *testing.T) { + mp := &mockProvider{mask: rbac.PermissionMask(0)} + h := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid1", "", "")) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req.WithContext(ctx)) + assertJSONError(t, rec, http.StatusForbidden, "PERMISSION_DENIED") +} + +func TestAuthzMiddleware_UnauthorizedJSON(t *testing.T) { + mp := &mockProvider{} + h := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + assertJSONError(t, rec, http.StatusUnauthorized, "UNAUTHENTICATED") +} + +func TestEnrichmentMiddleware_NoUIDJSON(t *testing.T) { + h := EnrichmentMiddleware(&mockEnricher{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + assertJSONError(t, rec, http.StatusUnauthorized, "UNAUTHENTICATED") +} + +func TestEnrichmentMiddleware_EnricherErrorJSON(t *testing.T) { + me := &mockEnricher{err: errors.New("db error")} + inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + assertJSONError(t, rec, http.StatusInternalServerError, "INTERNAL") +}