fix(httpauth): wildcard resource fallback, json.Number precision, xerrors JSON error bodies (#1)

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.

Reviewed-on: #1
Co-authored-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
Co-committed-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
This commit was merged in pull request #1.
This commit is contained in:
2026-05-18 14:15:12 -06:00
committed by NOCHEBUENADEV
parent 9438983f32
commit 601e6a5144
7 changed files with 183 additions and 11 deletions

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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
}

5
go.mod
View File

@@ -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
)

2
go.sum
View File

@@ -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=

View File

@@ -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")
}