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.
104 lines
4.3 KiB
Markdown
104 lines
4.3 KiB
Markdown
# 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`.
|