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:
103
CLAUDE.md
Normal file
103
CLAUDE.md
Normal 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`.
|
||||
Reference in New Issue
Block a user