fix(httpauth-jwt): WithJSONNumber in Verify, JSON errors in AuthMiddleware, bump httpauth v1.0.2
Pass jwt.WithJSONNumber() to jwt.Parse in hmacSigner.Verify, rsaSigner.Verify, and
rsaPublicVerifier.Verify. JWT numbers (including permission bitmasks) are now decoded
as json.Number instead of float64 — exact parsing for all integers including
math.MaxInt64 (the ADMIN wildcard mask). Float64 would round MaxInt64 to 2^63 and
overflow back to math.MinInt64 on cast to int64.
AuthMiddleware now calls httpauth.WriteJSONError for all 401 responses instead of
http.Error. Standardizes the error format across the full httpauth-* middleware stack:
AuthMiddleware (httpauth-jwt), EnrichmentMiddleware, and AuthzMiddleware (httpauth)
all produce {"code":"UNAUTHENTICATED"|"PERMISSION_DENIED"|"INTERNAL","message":"..."}.
Update two existing tests (TestIssueTokenPair_CustomClaims,
TestRefreshTokenPair_CustomClaimsInNewToken) to assert json.Number instead of float64
— they were correct before WithJSONNumber, now reflect the actual decoded type.
Add TestVerify_JSONNumberPreservesMaxInt64: issues a token with math.MaxInt64 as a
bitmask, verifies it, and asserts the decoded value is exactly math.MaxInt64 via
json.Number.Int64() — proving no float64 round-trip occurs.
Add TestAuthMiddleware_UnauthorizedJSON: asserts the 401 response is
Content-Type: application/json with body {"code":"UNAUTHENTICATED","message":"..."}.
Bump httpauth dependency to v1.0.2.
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -3,6 +3,23 @@
|
|||||||
All notable changes to `code.nochebuena.dev/go/httpauth-jwt` are documented here.
|
All notable changes to `code.nochebuena.dev/go/httpauth-jwt` are documented here.
|
||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [1.0.2] — 2026-05-18
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- All three `Verify` implementations (`hmacSigner`, `rsaSigner`, `rsaPublicVerifier`)
|
||||||
|
now pass `jwt.WithJSONNumber()` to `jwt.Parse`. JWT numeric claims — including
|
||||||
|
permission mask bitmasks — are decoded as `json.Number` instead of `float64`,
|
||||||
|
preserving exact bit patterns for values up to and including `math.MaxInt64`.
|
||||||
|
- `AuthMiddleware` now returns JSON error bodies instead of plain-text `http.Error`
|
||||||
|
for missing/invalid/expired Bearer tokens. Uses `httpauth.WriteJSONError` — the
|
||||||
|
same helper as `EnrichmentMiddleware` and `AuthzMiddleware` — ensuring a consistent
|
||||||
|
`{"code":"UNAUTHENTICATED","message":"unauthorized"}` response across the full
|
||||||
|
middleware stack.
|
||||||
|
- Dependency `code.nochebuena.dev/go/httpauth` bumped to v1.0.2.
|
||||||
|
|
||||||
|
[1.0.2]: https://code.nochebuena.dev/go/httpauth-jwt/releases/tag/v1.0.2
|
||||||
|
|
||||||
## [1.0.0] — 2026-05-08
|
## [1.0.0] — 2026-05-08
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
8
auth.go
8
auth.go
@@ -32,26 +32,26 @@ func AuthMiddleware(verifier Verifier, publicPaths []string) func(http.Handler)
|
|||||||
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
httpauthmw.WriteJSONError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
token, err := verifier.Verify(tokenStr)
|
token, err := verifier.Verify(tokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
httpauthmw.WriteJSONError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
httpauthmw.WriteJSONError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uid, _ := claims["sub"].(string)
|
uid, _ := claims["sub"].(string)
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
httpauthmw.WriteJSONError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -3,8 +3,11 @@ module code.nochebuena.dev/go/httpauth-jwt
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.nochebuena.dev/go/httpauth v1.0.0
|
code.nochebuena.dev/go/httpauth v1.0.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require code.nochebuena.dev/go/rbac v1.0.0 // indirect
|
require (
|
||||||
|
code.nochebuena.dev/go/rbac v1.0.0 // indirect
|
||||||
|
code.nochebuena.dev/go/xerrors v1.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,6 +1,8 @@
|
|||||||
code.nochebuena.dev/go/httpauth v1.0.0 h1:B2ypnlL7yQkePHC3EKo4LPgJbd5lWZ6RuA1o6sx84so=
|
code.nochebuena.dev/go/httpauth v1.0.2 h1:BegLr1nHk/i/RbhEf2Xh9FZZ9psUqjFyQ+hvFJR4Ro4=
|
||||||
code.nochebuena.dev/go/httpauth v1.0.0/go.mod h1:rGaQDInGkavpk8nbiG7azPqcyYsrwo0+E+ZNocRW2MY=
|
code.nochebuena.dev/go/httpauth v1.0.2/go.mod h1:vVyYhdgHBpOJc9uih6bMtrXBPmvPRgTJrI8cfErXojk=
|
||||||
code.nochebuena.dev/go/rbac v1.0.0 h1:FnsU1HU6vvwchKuZNxDa9RPIFeNwJi0vShWvHKABMws=
|
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/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=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -152,7 +154,7 @@ func TestIssueTokenPair_StandardClaims(t *testing.T) {
|
|||||||
|
|
||||||
func TestIssueTokenPair_CustomClaims(t *testing.T) {
|
func TestIssueTokenPair_CustomClaims(t *testing.T) {
|
||||||
custom := map[string]any{
|
custom := map[string]any{
|
||||||
"permisos": map[string]any{"usuarios": float64(515)},
|
"permisos": map[string]any{"usuarios": int64(515)},
|
||||||
}
|
}
|
||||||
pair, err := IssueTokenPair(testHMAC, "uid1", custom, testCfg)
|
pair, err := IssueTokenPair(testHMAC, "uid1", custom, testCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -164,8 +166,13 @@ func TestIssueTokenPair_CustomClaims(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("permisos claim missing or wrong type: %T", mc["permisos"])
|
t.Fatalf("permisos claim missing or wrong type: %T", mc["permisos"])
|
||||||
}
|
}
|
||||||
if permisos["usuarios"] != float64(515) {
|
// Verify uses jwt.WithJSONNumber — numeric claims come back as json.Number.
|
||||||
t.Errorf("want usuarios=515, got %v", permisos["usuarios"])
|
n, ok := permisos["usuarios"].(json.Number)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want json.Number, got %T", permisos["usuarios"])
|
||||||
|
}
|
||||||
|
if v, _ := n.Int64(); v != 515 {
|
||||||
|
t.Errorf("want usuarios=515, got %d", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,8 +284,48 @@ func TestRefreshTokenPair_CustomClaimsInNewToken(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("permisos missing from new access token")
|
t.Fatalf("permisos missing from new access token")
|
||||||
}
|
}
|
||||||
if permisos["usuarios"] != float64(7) {
|
// Verify uses jwt.WithJSONNumber — numeric claims come back as json.Number.
|
||||||
t.Errorf("want 7, got %v", permisos["usuarios"])
|
n, ok := permisos["usuarios"].(json.Number)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want json.Number, got %T", permisos["usuarios"])
|
||||||
|
}
|
||||||
|
if v, _ := n.Int64(); v != 7 {
|
||||||
|
t.Errorf("want 7, got %d", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- json.Number precision ---
|
||||||
|
|
||||||
|
func TestVerify_JSONNumberPreservesMaxInt64(t *testing.T) {
|
||||||
|
// math.MaxInt64 cannot be represented exactly as float64 — it would round to 2^63
|
||||||
|
// and overflow back to math.MinInt64 on cast. WithJSONNumber keeps it as a decimal
|
||||||
|
// string and json.Number.Int64() parses it exactly.
|
||||||
|
custom := map[string]any{
|
||||||
|
"masks": map[string]any{"*": int64(math.MaxInt64)},
|
||||||
|
}
|
||||||
|
pair, err := IssueTokenPair(testHMAC, "uid1", custom, testCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueTokenPair: %v", err)
|
||||||
|
}
|
||||||
|
tok, err := testHMAC.Verify(pair.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
mc, _ := tok.Claims.(jwt.MapClaims)
|
||||||
|
masks, ok := mc["masks"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("masks claim missing or wrong type: %T", mc["masks"])
|
||||||
|
}
|
||||||
|
n, ok := masks["*"].(json.Number)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want json.Number, got %T — WithJSONNumber() may not be set", masks["*"])
|
||||||
|
}
|
||||||
|
got, err := n.Int64()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Int64(): %v", err)
|
||||||
|
}
|
||||||
|
if got != math.MaxInt64 {
|
||||||
|
t.Errorf("want MaxInt64 (%d), got %d — precision lost in float64 round-trip", int64(math.MaxInt64), got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +411,27 @@ func TestAuthMiddleware_PublicPathWildcard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_UnauthorizedJSON(t *testing.T) {
|
||||||
|
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("want 401, got %d", 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"] != "UNAUTHENTICATED" {
|
||||||
|
t.Errorf("want code UNAUTHENTICATED, got %q", body["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthMiddleware_RSAPublicKeyVerifier(t *testing.T) {
|
func TestAuthMiddleware_RSAPublicKeyVerifier(t *testing.T) {
|
||||||
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
|
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
|
||||||
pair, _ := IssueTokenPair(testRSA, "uid1", nil, testCfg)
|
pair, _ := IssueTokenPair(testRSA, "uid1", nil, testCfg)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (s *hmacSigner) Verify(tokenString string) (*jwt.Token, error) {
|
|||||||
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
||||||
}
|
}
|
||||||
return s.secret, nil
|
return s.secret, nil
|
||||||
})
|
}, jwt.WithJSONNumber())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RSA (RS256) ---
|
// --- RSA (RS256) ---
|
||||||
@@ -89,7 +89,7 @@ func (s *rsaSigner) Verify(tokenString string) (*jwt.Token, error) {
|
|||||||
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
||||||
}
|
}
|
||||||
return s.public, nil
|
return s.public, nil
|
||||||
})
|
}, jwt.WithJSONNumber())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RSA public-key-only verifier ---
|
// --- RSA public-key-only verifier ---
|
||||||
@@ -129,5 +129,5 @@ func (v *rsaPublicVerifier) Verify(tokenString string) (*jwt.Token, error) {
|
|||||||
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
||||||
}
|
}
|
||||||
return v.public, nil
|
return v.public, nil
|
||||||
})
|
}, jwt.WithJSONNumber())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user