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