feat(httpauth-jwt): initial release — self-issued JWT auth middleware v1.0.0

Provides AuthMiddleware (calls httpauth.SetTokenData, accepts Verifier or Signer),
IssueTokenPair (access + refresh tokens as jwt.MapClaims, custom claims at top
level for ClaimsPermissionProvider compatibility), RefreshTokenPair (blacklist
check + rotation + re-issue), and Signer/Verifier implementations for HMAC-SHA256
and RSA-SHA256 including PEM loaders and a public-key-only Verifier for read-only
microservices.
This commit is contained in:
2026-05-07 22:18:04 -06:00
commit d8773b0f9f
15 changed files with 1187 additions and 0 deletions

1
.gitea/CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @go/CoreDevelopers @go/Agents

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

56
CHANGELOG.md Normal file
View File

@@ -0,0 +1,56 @@
# Changelog
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/).
## [1.0.0] — 2026-05-08
### Added
**`Verifier` interface** — validates JWT strings. Narrowest interface; `AuthMiddleware`
accepts this so services that only verify (not issue) tokens pass a public-key verifier.
**`Signer` interface** — embeds `Verifier` and adds `Sign(jwt.Claims)`. Used by
`IssueTokenPair` and `RefreshTokenPair`.
**`NewHMACSigner(secret []byte) Signer`** — HMAC-SHA256. For single-service or
monolith deployments where one process both issues and verifies tokens.
**`NewRSASigner(privateKey *rsa.PrivateKey) Signer`** — RSA-SHA256 signer + verifier
backed by the private key (public key derived automatically).
**`NewRSASignerFromPEM(pemKey []byte) (Signer, error)`** — loads a PKCS#8 or PKCS#1
PEM-encoded RSA private key. Suitable for loading from environment variables or files.
**`NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier`** — RSA-SHA256
verifier backed by a public key only. For microservices that receive tokens from a
central issuer but never sign them.
**`NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)`** — loads a PKIX
or PKCS#1 PEM-encoded RSA public key.
**`TokenConfig`** — `AccessTTL`, `RefreshTTL`, `Issuer`.
**`TokenPair`** — `AccessToken`, `RefreshToken`, `ExpiresIn` (seconds).
**`IssueTokenPair(signer, uid, customClaims, cfg) (TokenPair, error)`** — issues
access + refresh tokens. `customClaims` are merged at the top level of the access
token (compatible with `httpauth.ClaimsPermissionProvider`). Refresh token carries
only `sub`, `iss`, `iat`, `exp`, `jti`, and `fam` (token family for rotation).
**`Blacklist` interface** — `IsRevoked(ctx, jti)` and `Revoke(ctx, jti, ttl)`.
Implementations are typically backed by Valkey or Redis.
**`ErrTokenRevoked`** — sentinel returned by `RefreshTokenPair` when the JTI is on
the blacklist. Callers should respond with 401 and prompt re-authentication.
**`RefreshTokenPair(ctx, signer, refreshToken, blacklist, cfg, customClaims) (TokenPair, error)`**
— validates the refresh token, checks the blacklist, revokes the old JTI with the
token's remaining TTL, and issues a new pair. `customClaims` in the new access token
allow callers to embed fresh permission masks reflecting any role changes since the
previous issue.
**`AuthMiddleware(verifier, publicPaths) func(http.Handler) http.Handler`** — verifies
the Bearer access token and calls `httpauth.SetTokenData(ctx, uid, claims)`. Accepts
`Verifier` so services with only the public key can participate. Public paths bypass
token verification via `path.Match` glob patterns.

103
CLAUDE.md Normal file
View File

@@ -0,0 +1,103 @@
# httpauth-jwt
Self-issued JWT authentication middleware and token management.
## Purpose
`httpauth-jwt` covers the full JWT lifecycle for HTTP services that issue their own
tokens (as opposed to delegating to Firebase or another IdP). It provides:
- `AuthMiddleware` — verifies Bearer JWTs and calls `httpauth.SetTokenData`
- `IssueTokenPair` — signs access + refresh token pairs
- `RefreshTokenPair` — validates, revokes old JTI, and re-issues
- `Signer` / `Verifier` — HMAC-SHA256 and RSA-SHA256 implementations + PEM loaders
The JWT counterpart to `httpauth-firebase`. Both call `httpauth.SetTokenData` after
verification — all downstream middleware in `httpauth` is provider-agnostic.
## Tier & Dependencies
**Tier:** 4 (transport auth layer; depends on Tier 3 `httpauth` and `jwt/v5`)
**Module:** `code.nochebuena.dev/go/httpauth-jwt`
**Package name:** `jwtauth`
**Direct imports:** `code.nochebuena.dev/go/httpauth`, `github.com/golang-jwt/jwt/v5`
**Transitive:** `code.nochebuena.dev/go/rbac` (indirect, via `httpauth`)
## Key Design Decisions
- **`Verifier``Signer`**: `AuthMiddleware` accepts `Verifier` — services that
only verify use `NewRSAPublicKeyVerifier` without ever touching the private key.
- **`jwt.MapClaims` at the top level**: Custom claims are merged at the top level of
the access token (not under a wrapper key). This makes them directly compatible
with `httpauth.ClaimsPermissionProvider` and standard JWT tools.
- **Refresh tokens carry no permissions**: Re-fetching on each rotation ensures role
changes take effect without revoking access tokens.
- **Blacklist TTL = remaining token lifetime**: Entries expire naturally; no cleanup
job needed. Implementations backed by Valkey/Redis set the TTL on `Revoke`.
- **`ErrTokenRevoked` is a sentinel**: `errors.Is(err, jwtauth.ErrTokenRevoked)`
distinguishes replay attacks from infrastructure errors.
## Patterns
**Issuer service (HMAC or RSA private key):**
```go
signer := jwtauth.NewHMACSigner([]byte(os.Getenv("JWT_SECRET")))
// or:
signer, err := jwtauth.NewRSASignerFromPEM([]byte(os.Getenv("RSA_PRIVATE_KEY_PEM")))
cfg := jwtauth.TokenConfig{AccessTTL: 15 * time.Minute, RefreshTTL: 7 * 24 * time.Hour, Issuer: "svc"}
pair, err := jwtauth.IssueTokenPair(signer, uid, customClaims, cfg)
```
**Verifier-only microservice (RSA public key):**
```go
verifier, err := jwtauth.NewRSAPublicKeyVerifierFromPEM([]byte(os.Getenv("RSA_PUBLIC_KEY_PEM")))
r.Use(jwtauth.AuthMiddleware(verifier, publicPaths))
```
**Full middleware stack:**
```go
r.Use(jwtauth.AuthMiddleware(signer, publicPaths))
r.Use(httpauthmw.EnrichmentMiddleware(myEnricher))
claimsProvider := httpauthmw.NewClaimsPermissionProvider("permisos")
r.With(httpauthmw.AuthzMiddleware(claimsProvider, "usuarios", rbac.Permission(1))).
Get("/usuarios", handler)
```
**Refresh endpoint:**
```go
newPair, err := jwtauth.RefreshTokenPair(ctx, signer, body.RefreshToken, bl, cfg, freshClaims)
if errors.Is(err, jwtauth.ErrTokenRevoked) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
```
## What to Avoid
- Do not use `AuthMiddleware` with a `Signer` when a `Verifier` (public key only)
is sufficient. Pass the narrowest interface.
- Do not embed permission data in refresh tokens. `RefreshTokenPair` re-embeds fresh
claims from `customClaims` — that is the correct place to update permissions.
- Do not implement `Blacklist` without TTL support. Unbounded blacklists grow
indefinitely; TTL = remaining token lifetime keeps the store bounded.
- Do not import `httpauth-jwt` from service or domain layers. It is a transport
package. Dependency should flow inward: handler → service, never the reverse.
- Do not define a local `Verifier` or `Signer` interface in application code.
Use these interfaces directly from `jwtauth`.
## Testing Notes
- `compliance_test.go` checks at compile time that `mockSigner` satisfies `Signer`,
`mockVerifier` satisfies `Verifier`, and `mockBlacklist` satisfies `Blacklist`.
- RSA tests use a package-level `*rsa.PrivateKey` generated once via `mustGenerateRSA`
to avoid the cost of key generation on every test.
- `TestAuthMiddleware_ExpiredToken` uses `AccessTTL: -time.Minute` to produce an
already-expired token without sleeping.
- `TestRefreshTokenPair_OldTokenRevoked` calls `RefreshTokenPair` twice with the
same token — the second call must return `ErrTokenRevoked`.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

160
README.md Normal file
View File

@@ -0,0 +1,160 @@
# httpauth-jwt
Self-issued JWT authentication middleware and token management for Go HTTP services.
Part of the `code.nochebuena.dev/go` ecosystem. Integrates with
[`httpauth`](https://code.nochebuena.dev/go/httpauth) for identity enrichment and
RBAC authorization.
## Installation
```
go get code.nochebuena.dev/go/httpauth-jwt@v1.0.0
go get code.nochebuena.dev/go/httpauth@v0.1.0
```
## Usage
### Issue a token pair on login
```go
signer := jwtauth.NewHMACSigner([]byte(os.Getenv("JWT_SECRET")))
cfg := jwtauth.TokenConfig{
AccessTTL: 15 * time.Minute,
RefreshTTL: 7 * 24 * time.Hour,
Issuer: "my-service",
}
// Embed per-resource permission masks — readable by ClaimsPermissionProvider
// without a database call on every request.
customClaims := map[string]any{
"permisos": map[string]any{
"usuarios": int64(515),
"roles": int64(30),
},
}
pair, err := jwtauth.IssueTokenPair(signer, uid, customClaims, cfg)
// pair.AccessToken, pair.RefreshToken, pair.ExpiresIn
```
### Protect routes
```go
import (
jwtauth "code.nochebuena.dev/go/httpauth-jwt"
httpauthmw "code.nochebuena.dev/go/httpauth"
)
r.Use(jwtauth.AuthMiddleware(signer, []string{"/health", "/login", "/refresh"}))
r.Use(httpauthmw.EnrichmentMiddleware(myEnricher))
// Claims-based authz (no DB call — reads from JWT):
claimsProvider := httpauthmw.NewClaimsPermissionProvider("permisos")
r.With(httpauthmw.AuthzMiddleware(claimsProvider, "usuarios", rbac.Permission(1))).
Get("/usuarios", handler)
```
### Rotate tokens on refresh
```go
// blacklist is your Blacklist implementation (e.g. backed by Valkey)
freshClaims := fetchFreshPermissions(ctx, uid)
newPair, err := jwtauth.RefreshTokenPair(ctx, signer, refreshToken, blacklist, cfg, freshClaims)
if errors.Is(err, jwtauth.ErrTokenRevoked) {
// Token was already used — possible replay attack, force re-login
}
```
### RSA — split signing and verification
Services that issue tokens hold the private key; services that only verify hold the
public key.
```go
// Issuer service
signer, err := jwtauth.NewRSASignerFromPEM([]byte(os.Getenv("RSA_PRIVATE_KEY_PEM")))
// Verifier-only microservice
verifier, err := jwtauth.NewRSAPublicKeyVerifierFromPEM([]byte(os.Getenv("RSA_PUBLIC_KEY_PEM")))
r.Use(jwtauth.AuthMiddleware(verifier, publicPaths))
```
## Interfaces
### `Signer`
```go
type Signer interface {
Verifier
Sign(claims jwt.Claims) (string, error)
}
```
Implementations: `NewHMACSigner`, `NewRSASigner`, `NewRSASignerFromPEM`.
### `Verifier`
```go
type Verifier interface {
Verify(tokenString string) (*jwt.Token, error)
}
```
Every `Signer` also satisfies `Verifier`. Standalone: `NewRSAPublicKeyVerifier`,
`NewRSAPublicKeyVerifierFromPEM`.
### `Blacklist`
```go
type Blacklist interface {
IsRevoked(ctx context.Context, jti string) (bool, error)
Revoke(ctx context.Context, jti string, ttl time.Duration) error
}
```
Implement this against Valkey, Redis, or any key-value store that supports TTL.
The TTL on `Revoke` should match the token's remaining lifetime — entries expire
naturally and the blacklist stays small.
## Token structure
**Access token claims** (standard + custom):
| Claim | Type | Description |
|---|---|---|
| `sub` | string | User ID |
| `iss` | string | `TokenConfig.Issuer` |
| `iat` | NumericDate | Issued at |
| `exp` | NumericDate | Expires at |
| `jti` | string | Unique token ID (UUID v4) |
| _(custom)_ | any | Merged from `customClaims` at top level |
**Refresh token claims**:
| Claim | Description |
|---|---|
| `sub`, `iss`, `iat`, `exp`, `jti` | Same as access token |
| `fam` | Token family — UUID v4 for rotation lineage tracking |
Custom claims are intentionally absent from the refresh token. Re-fetch fresh
permission data when issuing the new pair via `RefreshTokenPair`.
## HTTP status codes from `AuthMiddleware`
| Condition | Status |
|---|---|
| Missing or malformed `Authorization` header | 401 |
| Invalid or expired token | 401 |
| Missing `sub` claim | 401 |
| Public path match | Pass-through (no check) |
## Dependencies
| Module | Role |
|---|---|
| `code.nochebuena.dev/go/httpauth` | `SetTokenData` — integration contract with `EnrichmentMiddleware` |
| `github.com/golang-jwt/jwt/v5` | JWT signing and parsing |
| `code.nochebuena.dev/go/rbac` | Transitive via `httpauth` |

62
auth.go Normal file
View File

@@ -0,0 +1,62 @@
package jwtauth
import (
"net/http"
"path"
"strings"
"github.com/golang-jwt/jwt/v5"
httpauthmw "code.nochebuena.dev/go/httpauth"
)
// AuthMiddleware verifies the Bearer access token and injects uid + claims into
// the request context via httpauth.SetTokenData. Downstream middleware
// (EnrichmentMiddleware, AuthzMiddleware, ClaimsPermissionProvider) from
// code.nochebuena.dev/go/httpauth reads them transparently.
//
// Accepts a Verifier — pass a Signer or a NewRSAPublicKeyVerifier depending on
// whether the service issues tokens.
//
// Requests to publicPaths are skipped without token verification (wildcards
// supported via path.Match). Returns 401 on missing, invalid, or expired tokens.
func AuthMiddleware(verifier Verifier, publicPaths []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, pattern := range publicPaths {
if matched, _ := path.Match(pattern, r.URL.Path); matched {
next.ServeHTTP(w, r)
return
}
}
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
token, err := verifier.Verify(tokenStr)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
uid, _ := claims["sub"].(string)
if uid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := httpauthmw.SetTokenData(r.Context(), uid, map[string]any(claims))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

30
compliance_test.go Normal file
View File

@@ -0,0 +1,30 @@
package jwtauth_test
import (
"context"
"time"
jwtauth "code.nochebuena.dev/go/httpauth-jwt"
"github.com/golang-jwt/jwt/v5"
)
type mockSigner struct{}
func (m *mockSigner) Sign(_ jwt.Claims) (string, error) { return "", nil }
func (m *mockSigner) Verify(_ string) (*jwt.Token, error) { return nil, nil }
type mockVerifier struct{}
func (m *mockVerifier) Verify(_ string) (*jwt.Token, error) { return nil, nil }
type mockBlacklist struct{}
func (m *mockBlacklist) IsRevoked(_ context.Context, _ string) (bool, error) { return false, nil }
func (m *mockBlacklist) Revoke(_ context.Context, _ string, _ time.Duration) error {
return nil
}
// Compile-time interface satisfaction checks.
var _ jwtauth.Signer = (*mockSigner)(nil)
var _ jwtauth.Verifier = (*mockVerifier)(nil)
var _ jwtauth.Blacklist = (*mockBlacklist)(nil)

26
doc.go Normal file
View File

@@ -0,0 +1,26 @@
// Package jwtauth provides self-issued JWT authentication middleware and token
// management for HTTP services.
//
// It integrates with code.nochebuena.dev/go/httpauth: AuthMiddleware verifies
// Bearer tokens and calls httpauth.SetTokenData, making uid and claims available
// to EnrichmentMiddleware, AuthzMiddleware, and ClaimsPermissionProvider.
//
// Typical flow:
//
// 1. Issue a token pair on login:
//
// signer := jwtauth.NewHMACSigner([]byte(os.Getenv("JWT_SECRET")))
// pair, err := jwtauth.IssueTokenPair(signer, uid, customClaims, cfg)
//
// 2. Protect routes:
//
// r.Use(jwtauth.AuthMiddleware(signer, publicPaths))
// r.Use(httpauth.EnrichmentMiddleware(myEnricher))
//
// 3. Rotate tokens on refresh:
//
// newPair, err := jwtauth.RefreshTokenPair(ctx, signer, refreshToken, blacklist, cfg, freshClaims)
//
// For microservices that only verify tokens (not issue them), use NewRSAPublicKeyVerifier
// or NewRSAPublicKeyVerifierFromPEM with the public key only.
package jwtauth

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module code.nochebuena.dev/go/httpauth-jwt
go 1.25
require (
code.nochebuena.dev/go/httpauth v0.1.0
github.com/golang-jwt/jwt/v5 v5.2.1
)
require code.nochebuena.dev/go/rbac v0.9.0 // indirect

6
go.sum Normal file
View File

@@ -0,0 +1,6 @@
code.nochebuena.dev/go/httpauth v0.1.0 h1:86xldderCDBvBIvOYgbTg54C00nl1A1OaYVrTHL3BTY=
code.nochebuena.dev/go/httpauth v0.1.0/go.mod h1:DzvGBZVo9npBa1llB+sWL9lOcqaltRMmvh/eGXm+3jQ=
code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o=
code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=

385
jwtauth_test.go Normal file
View File

@@ -0,0 +1,385 @@
package jwtauth
import (
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
testSecret = []byte("test-secret-key-at-least-32-bytes!")
testHMAC = NewHMACSigner(testSecret)
testRSAKey = mustGenerateRSA()
testRSA = NewRSASigner(testRSAKey)
)
func mustGenerateRSA() *rsa.PrivateKey {
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return k
}
var testCfg = TokenConfig{
AccessTTL: time.Minute,
RefreshTTL: 7 * 24 * time.Hour,
Issuer: "test-issuer",
}
// --- Signer ---
func TestHMACSigner_SignAndVerify(t *testing.T) {
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, err := testHMAC.Sign(claims)
if err != nil {
t.Fatalf("Sign: %v", err)
}
parsed, err := testHMAC.Verify(tok)
if err != nil {
t.Fatalf("Verify: %v", err)
}
mc, _ := parsed.Claims.(jwt.MapClaims)
if mc["sub"] != "uid1" {
t.Errorf("want sub=uid1, got %v", mc["sub"])
}
}
func TestHMACSigner_TamperedToken(t *testing.T) {
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, _ := testHMAC.Sign(claims)
_, err := testHMAC.Verify(tok + "tampered")
if err == nil {
t.Error("expected error for tampered token")
}
}
func TestHMACSigner_WrongSecret(t *testing.T) {
other := NewHMACSigner([]byte("completely-different-secret-key!"))
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, _ := testHMAC.Sign(claims)
_, err := other.Verify(tok)
if err == nil {
t.Error("expected error for wrong secret")
}
}
func TestHMACSigner_AlgMismatch(t *testing.T) {
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, _ := testRSA.Sign(claims)
_, err := testHMAC.Verify(tok)
if err == nil {
t.Error("expected error for algorithm mismatch (RSA token verified with HMAC)")
}
}
func TestRSASigner_SignAndVerify(t *testing.T) {
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, err := testRSA.Sign(claims)
if err != nil {
t.Fatalf("Sign: %v", err)
}
parsed, err := testRSA.Verify(tok)
if err != nil {
t.Fatalf("Verify: %v", err)
}
mc, _ := parsed.Claims.(jwt.MapClaims)
if mc["sub"] != "uid1" {
t.Errorf("want sub=uid1, got %v", mc["sub"])
}
}
func TestRSAPublicKeyVerifier_VerifiesTokenFromSigner(t *testing.T) {
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, _ := testRSA.Sign(claims)
parsed, err := verifier.Verify(tok)
if err != nil {
t.Fatalf("Verify: %v", err)
}
mc, _ := parsed.Claims.(jwt.MapClaims)
if mc["sub"] != "uid1" {
t.Errorf("want sub=uid1, got %v", mc["sub"])
}
}
func TestRSAPublicKeyVerifier_RejectsHMACToken(t *testing.T) {
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
tok, _ := testHMAC.Sign(claims)
_, err := verifier.Verify(tok)
if err == nil {
t.Error("expected error: HMAC token verified with RSA public key")
}
}
// --- IssueTokenPair ---
func TestIssueTokenPair_StandardClaims(t *testing.T) {
pair, err := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
if err != nil {
t.Fatalf("IssueTokenPair: %v", err)
}
if pair.AccessToken == "" || pair.RefreshToken == "" {
t.Error("expected non-empty tokens")
}
if pair.ExpiresIn != int64(testCfg.AccessTTL.Seconds()) {
t.Errorf("want ExpiresIn=%d, got %d", int64(testCfg.AccessTTL.Seconds()), pair.ExpiresIn)
}
tok, err := testHMAC.Verify(pair.AccessToken)
if err != nil {
t.Fatalf("verify access token: %v", err)
}
mc, _ := tok.Claims.(jwt.MapClaims)
if mc["sub"] != "uid1" {
t.Errorf("want sub=uid1, got %v", mc["sub"])
}
if mc["iss"] != testCfg.Issuer {
t.Errorf("want iss=%s, got %v", testCfg.Issuer, mc["iss"])
}
if mc["jti"] == "" {
t.Error("expected non-empty jti in access token")
}
}
func TestIssueTokenPair_CustomClaims(t *testing.T) {
custom := map[string]any{
"permisos": map[string]any{"usuarios": float64(515)},
}
pair, err := IssueTokenPair(testHMAC, "uid1", custom, testCfg)
if err != nil {
t.Fatalf("IssueTokenPair: %v", err)
}
tok, _ := testHMAC.Verify(pair.AccessToken)
mc, _ := tok.Claims.(jwt.MapClaims)
permisos, ok := mc["permisos"].(map[string]any)
if !ok {
t.Fatalf("permisos claim missing or wrong type: %T", mc["permisos"])
}
if permisos["usuarios"] != float64(515) {
t.Errorf("want usuarios=515, got %v", permisos["usuarios"])
}
}
func TestIssueTokenPair_UniqueJTIs(t *testing.T) {
p1, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
p2, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
if p1.AccessToken == p2.AccessToken {
t.Error("expected unique access tokens across calls")
}
if p1.RefreshToken == p2.RefreshToken {
t.Error("expected unique refresh tokens across calls")
}
}
func TestIssueTokenPair_RefreshHasFam(t *testing.T) {
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
tok, _ := testHMAC.Verify(pair.RefreshToken)
mc, _ := tok.Claims.(jwt.MapClaims)
if mc["fam"] == "" {
t.Error("expected fam claim in refresh token")
}
}
// --- RefreshTokenPair ---
type mockBlacklist struct {
revoked map[string]bool
err error
}
func newMockBlacklist() *mockBlacklist {
return &mockBlacklist{revoked: make(map[string]bool)}
}
func (m *mockBlacklist) IsRevoked(_ context.Context, jti string) (bool, error) {
if m.err != nil {
return false, m.err
}
return m.revoked[jti], nil
}
func (m *mockBlacklist) Revoke(_ context.Context, jti string, _ time.Duration) error {
if m.err != nil {
return m.err
}
m.revoked[jti] = true
return nil
}
func TestRefreshTokenPair_Success(t *testing.T) {
bl := newMockBlacklist()
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
newPair, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil)
if err != nil {
t.Fatalf("RefreshTokenPair: %v", err)
}
if newPair.AccessToken == "" || newPair.RefreshToken == "" {
t.Error("expected non-empty new token pair")
}
if newPair.RefreshToken == pair.RefreshToken {
t.Error("new refresh token must differ from old")
}
}
func TestRefreshTokenPair_OldTokenRevoked(t *testing.T) {
bl := newMockBlacklist()
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
if _, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil); err != nil {
t.Fatalf("first refresh: %v", err)
}
_, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil)
if !errors.Is(err, ErrTokenRevoked) {
t.Errorf("want ErrTokenRevoked, got %v", err)
}
}
func TestRefreshTokenPair_InvalidToken(t *testing.T) {
bl := newMockBlacklist()
_, err := RefreshTokenPair(context.Background(), testHMAC, "not.a.token", bl, testCfg, nil)
if err == nil {
t.Error("expected error for invalid token string")
}
}
func TestRefreshTokenPair_BlacklistCheckError(t *testing.T) {
bl := &mockBlacklist{revoked: make(map[string]bool), err: errors.New("valkey unavailable")}
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
_, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil)
if err == nil {
t.Error("expected error when blacklist is unavailable")
}
}
func TestRefreshTokenPair_CustomClaimsInNewToken(t *testing.T) {
bl := newMockBlacklist()
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
freshClaims := map[string]any{"permisos": map[string]any{"usuarios": float64(7)}}
newPair, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, freshClaims)
if err != nil {
t.Fatalf("RefreshTokenPair: %v", err)
}
tok, _ := testHMAC.Verify(newPair.AccessToken)
mc, _ := tok.Claims.(jwt.MapClaims)
permisos, ok := mc["permisos"].(map[string]any)
if !ok {
t.Fatalf("permisos missing from new access token")
}
if permisos["usuarios"] != float64(7) {
t.Errorf("want 7, got %v", permisos["usuarios"])
}
}
// --- AuthMiddleware ---
func TestAuthMiddleware_ValidToken(t *testing.T) {
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
reached := false
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api", nil)
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("want 200, got %d", rec.Code)
}
if !reached {
t.Error("inner handler was not called")
}
}
func TestAuthMiddleware_InvalidToken(t *testing.T) {
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api", nil)
req.Header.Set("Authorization", "Bearer invalid.token.here")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d", rec.Code)
}
}
func TestAuthMiddleware_ExpiredToken(t *testing.T) {
expiredCfg := TokenConfig{AccessTTL: -time.Minute, RefreshTTL: time.Hour, Issuer: "test"}
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, expiredCfg)
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api", nil)
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d", rec.Code)
}
}
func TestAuthMiddleware_MissingHeader(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)
}
}
func TestAuthMiddleware_PublicPath(t *testing.T) {
h := AuthMiddleware(testHMAC, []string{"/health"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
if rec.Code != http.StatusOK {
t.Errorf("want 200, got %d", rec.Code)
}
}
func TestAuthMiddleware_PublicPathWildcard(t *testing.T) {
h := AuthMiddleware(testHMAC, []string{"/public/*"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/public/resource", nil))
if rec.Code != http.StatusOK {
t.Errorf("want 200, got %d", rec.Code)
}
}
func TestAuthMiddleware_RSAPublicKeyVerifier(t *testing.T) {
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
pair, _ := IssueTokenPair(testRSA, "uid1", nil, testCfg)
reached := false
h := AuthMiddleware(verifier, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api", nil)
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("want 200, got %d", rec.Code)
}
if !reached {
t.Error("inner handler was not called")
}
}

72
refresh.go Normal file
View File

@@ -0,0 +1,72 @@
package jwtauth
import (
"context"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// ErrTokenRevoked is returned by RefreshTokenPair when the JTI is on the blacklist.
// Callers should respond with 401 and prompt re-authentication.
var ErrTokenRevoked = errors.New("token revoked")
// Blacklist records and checks revoked refresh token JTIs.
// Implementations are typically backed by Valkey or Redis.
// TTL on Revoke should match the token's remaining lifetime so entries expire naturally.
type Blacklist interface {
IsRevoked(ctx context.Context, jti string) (bool, error)
Revoke(ctx context.Context, jti string, ttl time.Duration) error
}
// RefreshTokenPair validates refreshToken, checks the blacklist, revokes the old
// JTI, and issues a new token pair for the same uid.
//
// customClaims are merged into the new access token — typically the caller
// re-fetches fresh permission masks here so the new token reflects any role changes
// made since the previous issue.
//
// Returns ErrTokenRevoked if the JTI is already on the blacklist (replay attack or
// re-use after rotation). Any other error indicates an infrastructure or token fault.
func RefreshTokenPair(ctx context.Context, signer Signer, refreshToken string, bl Blacklist, cfg TokenConfig, customClaims map[string]any) (TokenPair, error) {
token, err := signer.Verify(refreshToken)
if err != nil {
return TokenPair{}, fmt.Errorf("invalid refresh token: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return TokenPair{}, fmt.Errorf("unexpected claims type in refresh token")
}
jti, _ := claims["jti"].(string)
uid, _ := claims["sub"].(string)
if jti == "" || uid == "" {
return TokenPair{}, fmt.Errorf("missing required claims in refresh token")
}
revoked, err := bl.IsRevoked(ctx, jti)
if err != nil {
return TokenPair{}, fmt.Errorf("blacklist check: %w", err)
}
if revoked {
return TokenPair{}, ErrTokenRevoked
}
expTime, err := claims.GetExpirationTime()
if err != nil || expTime == nil {
return TokenPair{}, fmt.Errorf("invalid expiration in refresh token")
}
remaining := time.Until(expTime.Time)
if remaining < time.Second {
remaining = time.Second
}
if err := bl.Revoke(ctx, jti, remaining); err != nil {
return TokenPair{}, fmt.Errorf("revoke old token: %w", err)
}
return IssueTokenPair(signer, uid, customClaims, cfg)
}

133
signer.go Normal file
View File

@@ -0,0 +1,133 @@
package jwtauth
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
// Verifier validates JWT strings. Use this in services that receive but do not
// issue tokens (e.g. microservices given only the RSA public key).
type Verifier interface {
Verify(tokenString string) (*jwt.Token, error)
}
// Signer signs and verifies JWTs.
// NewHMACSigner and NewRSASigner return implementations backed by HS256 and RS256.
type Signer interface {
Verifier
Sign(claims jwt.Claims) (string, error)
}
// --- HMAC (HS256) ---
type hmacSigner struct{ secret []byte }
// NewHMACSigner returns a Signer backed by HMAC-SHA256.
// secret should be at least 32 bytes; shorter values are accepted but weakened.
func NewHMACSigner(secret []byte) Signer {
return &hmacSigner{secret: secret}
}
func (s *hmacSigner) Sign(claims jwt.Claims) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
}
func (s *hmacSigner) Verify(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
}
return s.secret, nil
})
}
// --- RSA (RS256) ---
type rsaSigner struct {
private *rsa.PrivateKey
public *rsa.PublicKey
}
// NewRSASigner returns a Signer backed by RSA-SHA256.
// The public key is derived from the private key — no separate argument needed.
func NewRSASigner(privateKey *rsa.PrivateKey) Signer {
return &rsaSigner{private: privateKey, public: &privateKey.PublicKey}
}
// NewRSASignerFromPEM parses a PKCS#8 or PKCS#1 PEM-encoded RSA private key.
func NewRSASignerFromPEM(pemKey []byte) (Signer, error) {
block, _ := pem.Decode(pemKey)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
rsaKey, err2 := x509.ParsePKCS1PrivateKey(block.Bytes)
if err2 != nil {
return nil, fmt.Errorf("parse RSA private key: %w", err)
}
return NewRSASigner(rsaKey), nil
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("PEM key is not an RSA private key")
}
return NewRSASigner(rsaKey), nil
}
func (s *rsaSigner) Sign(claims jwt.Claims) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(s.private)
}
func (s *rsaSigner) Verify(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
}
return s.public, nil
})
}
// --- RSA public-key-only verifier ---
type rsaPublicVerifier struct{ public *rsa.PublicKey }
// NewRSAPublicKeyVerifier returns a Verifier backed by an RSA public key.
// Use this in services that verify tokens but never issue them.
func NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier {
return &rsaPublicVerifier{public: publicKey}
}
// NewRSAPublicKeyVerifierFromPEM parses a PKIX or PKCS#1 PEM-encoded RSA public key.
func NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error) {
block, _ := pem.Decode(pemKey)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
rsaPub, err2 := x509.ParsePKCS1PublicKey(block.Bytes)
if err2 != nil {
return nil, fmt.Errorf("parse RSA public key: %w", err)
}
return NewRSAPublicKeyVerifier(rsaPub), nil
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("PEM key is not an RSA public key")
}
return NewRSAPublicKeyVerifier(rsaPub), nil
}
func (v *rsaPublicVerifier) Verify(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
}
return v.public, nil
})
}

84
tokens.go Normal file
View File

@@ -0,0 +1,84 @@
package jwtauth
import (
"crypto/rand"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// TokenConfig configures token lifetimes and the issuer claim.
type TokenConfig struct {
AccessTTL time.Duration
RefreshTTL time.Duration
Issuer string
}
// TokenPair holds an access token and a refresh token.
type TokenPair struct {
AccessToken string
RefreshToken string
ExpiresIn int64 // seconds until the access token expires
}
// IssueTokenPair signs a new access + refresh token pair for uid.
//
// customClaims are merged into the access token at the top level. Use this to
// embed per-resource permission masks so ClaimsPermissionProvider can read them
// without a database call:
//
// customClaims := map[string]any{
// "permisos": map[string]any{"usuarios": int64(515), "roles": int64(30)},
// }
//
// The refresh token contains only sub, iss, iat, exp, jti, and fam (token family).
// It carries no permission data — callers re-fetch fresh claims on each rotation.
func IssueTokenPair(signer Signer, uid string, customClaims map[string]any, cfg TokenConfig) (TokenPair, error) {
now := time.Now()
accessClaims := jwt.MapClaims{
"sub": uid,
"iss": cfg.Issuer,
"iat": jwt.NewNumericDate(now),
"exp": jwt.NewNumericDate(now.Add(cfg.AccessTTL)),
"jti": newJTI(),
}
for k, v := range customClaims {
accessClaims[k] = v
}
accessToken, err := signer.Sign(accessClaims)
if err != nil {
return TokenPair{}, fmt.Errorf("sign access token: %w", err)
}
refreshClaims := jwt.MapClaims{
"sub": uid,
"iss": cfg.Issuer,
"iat": jwt.NewNumericDate(now),
"exp": jwt.NewNumericDate(now.Add(cfg.RefreshTTL)),
"jti": newJTI(),
"fam": newJTI(),
}
refreshToken, err := signer.Sign(refreshClaims)
if err != nil {
return TokenPair{}, fmt.Errorf("sign refresh token: %w", err)
}
return TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(cfg.AccessTTL.Seconds()),
}, nil
}
func newJTI() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}