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:
32
CHANGELOG.md
32
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
|
||||
|
||||
18
authz.go
18
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
5
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
|
||||
)
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
110
httpauth_test.go
110
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user