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