Files
httpauth-jwt/README.md
Rene Nochebuena d8773b0f9f 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.
2026-05-07 22:18:04 -06:00

161 lines
4.3 KiB
Markdown

# 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` |