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:
1
.gitea/CODEOWNERS
Normal file
1
.gitea/CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @go/CoreDevelopers @go/Agents
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with go test -c
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go build
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directory
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# VCS files
|
||||||
|
COMMIT.md
|
||||||
|
RELEASE.md
|
||||||
56
CHANGELOG.md
Normal file
56
CHANGELOG.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to `code.nochebuena.dev/go/httpauth-jwt` are documented here.
|
||||||
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**`Verifier` interface** — validates JWT strings. Narrowest interface; `AuthMiddleware`
|
||||||
|
accepts this so services that only verify (not issue) tokens pass a public-key verifier.
|
||||||
|
|
||||||
|
**`Signer` interface** — embeds `Verifier` and adds `Sign(jwt.Claims)`. Used by
|
||||||
|
`IssueTokenPair` and `RefreshTokenPair`.
|
||||||
|
|
||||||
|
**`NewHMACSigner(secret []byte) Signer`** — HMAC-SHA256. For single-service or
|
||||||
|
monolith deployments where one process both issues and verifies tokens.
|
||||||
|
|
||||||
|
**`NewRSASigner(privateKey *rsa.PrivateKey) Signer`** — RSA-SHA256 signer + verifier
|
||||||
|
backed by the private key (public key derived automatically).
|
||||||
|
|
||||||
|
**`NewRSASignerFromPEM(pemKey []byte) (Signer, error)`** — loads a PKCS#8 or PKCS#1
|
||||||
|
PEM-encoded RSA private key. Suitable for loading from environment variables or files.
|
||||||
|
|
||||||
|
**`NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier`** — RSA-SHA256
|
||||||
|
verifier backed by a public key only. For microservices that receive tokens from a
|
||||||
|
central issuer but never sign them.
|
||||||
|
|
||||||
|
**`NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error)`** — loads a PKIX
|
||||||
|
or PKCS#1 PEM-encoded RSA public key.
|
||||||
|
|
||||||
|
**`TokenConfig`** — `AccessTTL`, `RefreshTTL`, `Issuer`.
|
||||||
|
|
||||||
|
**`TokenPair`** — `AccessToken`, `RefreshToken`, `ExpiresIn` (seconds).
|
||||||
|
|
||||||
|
**`IssueTokenPair(signer, uid, customClaims, cfg) (TokenPair, error)`** — issues
|
||||||
|
access + refresh tokens. `customClaims` are merged at the top level of the access
|
||||||
|
token (compatible with `httpauth.ClaimsPermissionProvider`). Refresh token carries
|
||||||
|
only `sub`, `iss`, `iat`, `exp`, `jti`, and `fam` (token family for rotation).
|
||||||
|
|
||||||
|
**`Blacklist` interface** — `IsRevoked(ctx, jti)` and `Revoke(ctx, jti, ttl)`.
|
||||||
|
Implementations are typically backed by Valkey or Redis.
|
||||||
|
|
||||||
|
**`ErrTokenRevoked`** — sentinel returned by `RefreshTokenPair` when the JTI is on
|
||||||
|
the blacklist. Callers should respond with 401 and prompt re-authentication.
|
||||||
|
|
||||||
|
**`RefreshTokenPair(ctx, signer, refreshToken, blacklist, cfg, customClaims) (TokenPair, error)`**
|
||||||
|
— validates the refresh token, checks the blacklist, revokes the old JTI with the
|
||||||
|
token's remaining TTL, and issues a new pair. `customClaims` in the new access token
|
||||||
|
allow callers to embed fresh permission masks reflecting any role changes since the
|
||||||
|
previous issue.
|
||||||
|
|
||||||
|
**`AuthMiddleware(verifier, publicPaths) func(http.Handler) http.Handler`** — verifies
|
||||||
|
the Bearer access token and calls `httpauth.SetTokenData(ctx, uid, claims)`. Accepts
|
||||||
|
`Verifier` so services with only the public key can participate. Public paths bypass
|
||||||
|
token verification via `path.Match` glob patterns.
|
||||||
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`.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NOCHEBUENADEV
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
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` |
|
||||||
62
auth.go
Normal file
62
auth.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package jwtauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
httpauthmw "code.nochebuena.dev/go/httpauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware verifies the Bearer access token and injects uid + claims into
|
||||||
|
// the request context via httpauth.SetTokenData. Downstream middleware
|
||||||
|
// (EnrichmentMiddleware, AuthzMiddleware, ClaimsPermissionProvider) from
|
||||||
|
// code.nochebuena.dev/go/httpauth reads them transparently.
|
||||||
|
//
|
||||||
|
// Accepts a Verifier — pass a Signer or a NewRSAPublicKeyVerifier depending on
|
||||||
|
// whether the service issues tokens.
|
||||||
|
//
|
||||||
|
// Requests to publicPaths are skipped without token verification (wildcards
|
||||||
|
// supported via path.Match). Returns 401 on missing, invalid, or expired tokens.
|
||||||
|
func AuthMiddleware(verifier Verifier, publicPaths []string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, pattern := range publicPaths {
|
||||||
|
if matched, _ := path.Match(pattern, r.URL.Path); matched {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
token, err := verifier.Verify(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, _ := claims["sub"].(string)
|
||||||
|
if uid == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := httpauthmw.SetTokenData(r.Context(), uid, map[string]any(claims))
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
30
compliance_test.go
Normal file
30
compliance_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package jwtauth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwtauth "code.nochebuena.dev/go/httpauth-jwt"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockSigner struct{}
|
||||||
|
|
||||||
|
func (m *mockSigner) Sign(_ jwt.Claims) (string, error) { return "", nil }
|
||||||
|
func (m *mockSigner) Verify(_ string) (*jwt.Token, error) { return nil, nil }
|
||||||
|
|
||||||
|
type mockVerifier struct{}
|
||||||
|
|
||||||
|
func (m *mockVerifier) Verify(_ string) (*jwt.Token, error) { return nil, nil }
|
||||||
|
|
||||||
|
type mockBlacklist struct{}
|
||||||
|
|
||||||
|
func (m *mockBlacklist) IsRevoked(_ context.Context, _ string) (bool, error) { return false, nil }
|
||||||
|
func (m *mockBlacklist) Revoke(_ context.Context, _ string, _ time.Duration) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface satisfaction checks.
|
||||||
|
var _ jwtauth.Signer = (*mockSigner)(nil)
|
||||||
|
var _ jwtauth.Verifier = (*mockVerifier)(nil)
|
||||||
|
var _ jwtauth.Blacklist = (*mockBlacklist)(nil)
|
||||||
26
doc.go
Normal file
26
doc.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Package jwtauth provides self-issued JWT authentication middleware and token
|
||||||
|
// management for HTTP services.
|
||||||
|
//
|
||||||
|
// It integrates with code.nochebuena.dev/go/httpauth: AuthMiddleware verifies
|
||||||
|
// Bearer tokens and calls httpauth.SetTokenData, making uid and claims available
|
||||||
|
// to EnrichmentMiddleware, AuthzMiddleware, and ClaimsPermissionProvider.
|
||||||
|
//
|
||||||
|
// Typical flow:
|
||||||
|
//
|
||||||
|
// 1. Issue a token pair on login:
|
||||||
|
//
|
||||||
|
// signer := jwtauth.NewHMACSigner([]byte(os.Getenv("JWT_SECRET")))
|
||||||
|
// pair, err := jwtauth.IssueTokenPair(signer, uid, customClaims, cfg)
|
||||||
|
//
|
||||||
|
// 2. Protect routes:
|
||||||
|
//
|
||||||
|
// r.Use(jwtauth.AuthMiddleware(signer, publicPaths))
|
||||||
|
// r.Use(httpauth.EnrichmentMiddleware(myEnricher))
|
||||||
|
//
|
||||||
|
// 3. Rotate tokens on refresh:
|
||||||
|
//
|
||||||
|
// newPair, err := jwtauth.RefreshTokenPair(ctx, signer, refreshToken, blacklist, cfg, freshClaims)
|
||||||
|
//
|
||||||
|
// For microservices that only verify tokens (not issue them), use NewRSAPublicKeyVerifier
|
||||||
|
// or NewRSAPublicKeyVerifierFromPEM with the public key only.
|
||||||
|
package jwtauth
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module code.nochebuena.dev/go/httpauth-jwt
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
code.nochebuena.dev/go/httpauth v0.1.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require code.nochebuena.dev/go/rbac v0.9.0 // indirect
|
||||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
code.nochebuena.dev/go/httpauth v0.1.0 h1:86xldderCDBvBIvOYgbTg54C00nl1A1OaYVrTHL3BTY=
|
||||||
|
code.nochebuena.dev/go/httpauth v0.1.0/go.mod h1:DzvGBZVo9npBa1llB+sWL9lOcqaltRMmvh/eGXm+3jQ=
|
||||||
|
code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o=
|
||||||
|
code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
385
jwtauth_test.go
Normal file
385
jwtauth_test.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package jwtauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testSecret = []byte("test-secret-key-at-least-32-bytes!")
|
||||||
|
testHMAC = NewHMACSigner(testSecret)
|
||||||
|
testRSAKey = mustGenerateRSA()
|
||||||
|
testRSA = NewRSASigner(testRSAKey)
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustGenerateRSA() *rsa.PrivateKey {
|
||||||
|
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
var testCfg = TokenConfig{
|
||||||
|
AccessTTL: time.Minute,
|
||||||
|
RefreshTTL: 7 * 24 * time.Hour,
|
||||||
|
Issuer: "test-issuer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Signer ---
|
||||||
|
|
||||||
|
func TestHMACSigner_SignAndVerify(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, err := testHMAC.Sign(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := testHMAC.Verify(tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
mc, _ := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if mc["sub"] != "uid1" {
|
||||||
|
t.Errorf("want sub=uid1, got %v", mc["sub"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_TamperedToken(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, _ := testHMAC.Sign(claims)
|
||||||
|
_, err := testHMAC.Verify(tok + "tampered")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for tampered token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_WrongSecret(t *testing.T) {
|
||||||
|
other := NewHMACSigner([]byte("completely-different-secret-key!"))
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, _ := testHMAC.Sign(claims)
|
||||||
|
_, err := other.Verify(tok)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for wrong secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_AlgMismatch(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, _ := testRSA.Sign(claims)
|
||||||
|
_, err := testHMAC.Verify(tok)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for algorithm mismatch (RSA token verified with HMAC)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRSASigner_SignAndVerify(t *testing.T) {
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, err := testRSA.Sign(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := testRSA.Verify(tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
mc, _ := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if mc["sub"] != "uid1" {
|
||||||
|
t.Errorf("want sub=uid1, got %v", mc["sub"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRSAPublicKeyVerifier_VerifiesTokenFromSigner(t *testing.T) {
|
||||||
|
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, _ := testRSA.Sign(claims)
|
||||||
|
parsed, err := verifier.Verify(tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
mc, _ := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if mc["sub"] != "uid1" {
|
||||||
|
t.Errorf("want sub=uid1, got %v", mc["sub"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRSAPublicKeyVerifier_RejectsHMACToken(t *testing.T) {
|
||||||
|
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
|
||||||
|
claims := jwt.MapClaims{"sub": "uid1", "exp": jwt.NewNumericDate(time.Now().Add(time.Minute))}
|
||||||
|
tok, _ := testHMAC.Sign(claims)
|
||||||
|
_, err := verifier.Verify(tok)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error: HMAC token verified with RSA public key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IssueTokenPair ---
|
||||||
|
|
||||||
|
func TestIssueTokenPair_StandardClaims(t *testing.T) {
|
||||||
|
pair, err := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueTokenPair: %v", err)
|
||||||
|
}
|
||||||
|
if pair.AccessToken == "" || pair.RefreshToken == "" {
|
||||||
|
t.Error("expected non-empty tokens")
|
||||||
|
}
|
||||||
|
if pair.ExpiresIn != int64(testCfg.AccessTTL.Seconds()) {
|
||||||
|
t.Errorf("want ExpiresIn=%d, got %d", int64(testCfg.AccessTTL.Seconds()), pair.ExpiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := testHMAC.Verify(pair.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify access token: %v", err)
|
||||||
|
}
|
||||||
|
mc, _ := tok.Claims.(jwt.MapClaims)
|
||||||
|
if mc["sub"] != "uid1" {
|
||||||
|
t.Errorf("want sub=uid1, got %v", mc["sub"])
|
||||||
|
}
|
||||||
|
if mc["iss"] != testCfg.Issuer {
|
||||||
|
t.Errorf("want iss=%s, got %v", testCfg.Issuer, mc["iss"])
|
||||||
|
}
|
||||||
|
if mc["jti"] == "" {
|
||||||
|
t.Error("expected non-empty jti in access token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueTokenPair_CustomClaims(t *testing.T) {
|
||||||
|
custom := map[string]any{
|
||||||
|
"permisos": map[string]any{"usuarios": float64(515)},
|
||||||
|
}
|
||||||
|
pair, err := IssueTokenPair(testHMAC, "uid1", custom, testCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueTokenPair: %v", err)
|
||||||
|
}
|
||||||
|
tok, _ := testHMAC.Verify(pair.AccessToken)
|
||||||
|
mc, _ := tok.Claims.(jwt.MapClaims)
|
||||||
|
permisos, ok := mc["permisos"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("permisos claim missing or wrong type: %T", mc["permisos"])
|
||||||
|
}
|
||||||
|
if permisos["usuarios"] != float64(515) {
|
||||||
|
t.Errorf("want usuarios=515, got %v", permisos["usuarios"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueTokenPair_UniqueJTIs(t *testing.T) {
|
||||||
|
p1, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
p2, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
if p1.AccessToken == p2.AccessToken {
|
||||||
|
t.Error("expected unique access tokens across calls")
|
||||||
|
}
|
||||||
|
if p1.RefreshToken == p2.RefreshToken {
|
||||||
|
t.Error("expected unique refresh tokens across calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueTokenPair_RefreshHasFam(t *testing.T) {
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
tok, _ := testHMAC.Verify(pair.RefreshToken)
|
||||||
|
mc, _ := tok.Claims.(jwt.MapClaims)
|
||||||
|
if mc["fam"] == "" {
|
||||||
|
t.Error("expected fam claim in refresh token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RefreshTokenPair ---
|
||||||
|
|
||||||
|
type mockBlacklist struct {
|
||||||
|
revoked map[string]bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockBlacklist() *mockBlacklist {
|
||||||
|
return &mockBlacklist{revoked: make(map[string]bool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBlacklist) IsRevoked(_ context.Context, jti string) (bool, error) {
|
||||||
|
if m.err != nil {
|
||||||
|
return false, m.err
|
||||||
|
}
|
||||||
|
return m.revoked[jti], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockBlacklist) Revoke(_ context.Context, jti string, _ time.Duration) error {
|
||||||
|
if m.err != nil {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
m.revoked[jti] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenPair_Success(t *testing.T) {
|
||||||
|
bl := newMockBlacklist()
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
|
||||||
|
newPair, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RefreshTokenPair: %v", err)
|
||||||
|
}
|
||||||
|
if newPair.AccessToken == "" || newPair.RefreshToken == "" {
|
||||||
|
t.Error("expected non-empty new token pair")
|
||||||
|
}
|
||||||
|
if newPair.RefreshToken == pair.RefreshToken {
|
||||||
|
t.Error("new refresh token must differ from old")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenPair_OldTokenRevoked(t *testing.T) {
|
||||||
|
bl := newMockBlacklist()
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
|
||||||
|
if _, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil); err != nil {
|
||||||
|
t.Fatalf("first refresh: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil)
|
||||||
|
if !errors.Is(err, ErrTokenRevoked) {
|
||||||
|
t.Errorf("want ErrTokenRevoked, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenPair_InvalidToken(t *testing.T) {
|
||||||
|
bl := newMockBlacklist()
|
||||||
|
_, err := RefreshTokenPair(context.Background(), testHMAC, "not.a.token", bl, testCfg, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid token string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenPair_BlacklistCheckError(t *testing.T) {
|
||||||
|
bl := &mockBlacklist{revoked: make(map[string]bool), err: errors.New("valkey unavailable")}
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
_, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when blacklist is unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenPair_CustomClaimsInNewToken(t *testing.T) {
|
||||||
|
bl := newMockBlacklist()
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
freshClaims := map[string]any{"permisos": map[string]any{"usuarios": float64(7)}}
|
||||||
|
|
||||||
|
newPair, err := RefreshTokenPair(context.Background(), testHMAC, pair.RefreshToken, bl, testCfg, freshClaims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RefreshTokenPair: %v", err)
|
||||||
|
}
|
||||||
|
tok, _ := testHMAC.Verify(newPair.AccessToken)
|
||||||
|
mc, _ := tok.Claims.(jwt.MapClaims)
|
||||||
|
permisos, ok := mc["permisos"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("permisos missing from new access token")
|
||||||
|
}
|
||||||
|
if permisos["usuarios"] != float64(7) {
|
||||||
|
t.Errorf("want 7, got %v", permisos["usuarios"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AuthMiddleware ---
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, testCfg)
|
||||||
|
reached := false
|
||||||
|
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !reached {
|
||||||
|
t.Error("inner handler was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_InvalidToken(t *testing.T) {
|
||||||
|
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer invalid.token.here")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ExpiredToken(t *testing.T) {
|
||||||
|
expiredCfg := TokenConfig{AccessTTL: -time.Minute, RefreshTTL: time.Hour, Issuer: "test"}
|
||||||
|
pair, _ := IssueTokenPair(testHMAC, "uid1", nil, expiredCfg)
|
||||||
|
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_MissingHeader(t *testing.T) {
|
||||||
|
h := AuthMiddleware(testHMAC, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("want 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_PublicPath(t *testing.T) {
|
||||||
|
h := AuthMiddleware(testHMAC, []string{"/health"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_PublicPathWildcard(t *testing.T) {
|
||||||
|
h := AuthMiddleware(testHMAC, []string{"/public/*"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/public/resource", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_RSAPublicKeyVerifier(t *testing.T) {
|
||||||
|
verifier := NewRSAPublicKeyVerifier(&testRSAKey.PublicKey)
|
||||||
|
pair, _ := IssueTokenPair(testRSA, "uid1", nil, testCfg)
|
||||||
|
reached := false
|
||||||
|
h := AuthMiddleware(verifier, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !reached {
|
||||||
|
t.Error("inner handler was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
refresh.go
Normal file
72
refresh.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package jwtauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrTokenRevoked is returned by RefreshTokenPair when the JTI is on the blacklist.
|
||||||
|
// Callers should respond with 401 and prompt re-authentication.
|
||||||
|
var ErrTokenRevoked = errors.New("token revoked")
|
||||||
|
|
||||||
|
// Blacklist records and checks revoked refresh token JTIs.
|
||||||
|
// Implementations are typically backed by Valkey or Redis.
|
||||||
|
// TTL on Revoke should match the token's remaining lifetime so entries expire naturally.
|
||||||
|
type Blacklist interface {
|
||||||
|
IsRevoked(ctx context.Context, jti string) (bool, error)
|
||||||
|
Revoke(ctx context.Context, jti string, ttl time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokenPair validates refreshToken, checks the blacklist, revokes the old
|
||||||
|
// JTI, and issues a new token pair for the same uid.
|
||||||
|
//
|
||||||
|
// customClaims are merged into the new access token — typically the caller
|
||||||
|
// re-fetches fresh permission masks here so the new token reflects any role changes
|
||||||
|
// made since the previous issue.
|
||||||
|
//
|
||||||
|
// Returns ErrTokenRevoked if the JTI is already on the blacklist (replay attack or
|
||||||
|
// re-use after rotation). Any other error indicates an infrastructure or token fault.
|
||||||
|
func RefreshTokenPair(ctx context.Context, signer Signer, refreshToken string, bl Blacklist, cfg TokenConfig, customClaims map[string]any) (TokenPair, error) {
|
||||||
|
token, err := signer.Verify(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, fmt.Errorf("invalid refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return TokenPair{}, fmt.Errorf("unexpected claims type in refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
jti, _ := claims["jti"].(string)
|
||||||
|
uid, _ := claims["sub"].(string)
|
||||||
|
if jti == "" || uid == "" {
|
||||||
|
return TokenPair{}, fmt.Errorf("missing required claims in refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked, err := bl.IsRevoked(ctx, jti)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, fmt.Errorf("blacklist check: %w", err)
|
||||||
|
}
|
||||||
|
if revoked {
|
||||||
|
return TokenPair{}, ErrTokenRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
expTime, err := claims.GetExpirationTime()
|
||||||
|
if err != nil || expTime == nil {
|
||||||
|
return TokenPair{}, fmt.Errorf("invalid expiration in refresh token")
|
||||||
|
}
|
||||||
|
remaining := time.Until(expTime.Time)
|
||||||
|
if remaining < time.Second {
|
||||||
|
remaining = time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bl.Revoke(ctx, jti, remaining); err != nil {
|
||||||
|
return TokenPair{}, fmt.Errorf("revoke old token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return IssueTokenPair(signer, uid, customClaims, cfg)
|
||||||
|
}
|
||||||
133
signer.go
Normal file
133
signer.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package jwtauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verifier validates JWT strings. Use this in services that receive but do not
|
||||||
|
// issue tokens (e.g. microservices given only the RSA public key).
|
||||||
|
type Verifier interface {
|
||||||
|
Verify(tokenString string) (*jwt.Token, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer signs and verifies JWTs.
|
||||||
|
// NewHMACSigner and NewRSASigner return implementations backed by HS256 and RS256.
|
||||||
|
type Signer interface {
|
||||||
|
Verifier
|
||||||
|
Sign(claims jwt.Claims) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HMAC (HS256) ---
|
||||||
|
|
||||||
|
type hmacSigner struct{ secret []byte }
|
||||||
|
|
||||||
|
// NewHMACSigner returns a Signer backed by HMAC-SHA256.
|
||||||
|
// secret should be at least 32 bytes; shorter values are accepted but weakened.
|
||||||
|
func NewHMACSigner(secret []byte) Signer {
|
||||||
|
return &hmacSigner{secret: secret}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hmacSigner) Sign(claims jwt.Claims) (string, error) {
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *hmacSigner) Verify(tokenString string) (*jwt.Token, error) {
|
||||||
|
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return s.secret, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RSA (RS256) ---
|
||||||
|
|
||||||
|
type rsaSigner struct {
|
||||||
|
private *rsa.PrivateKey
|
||||||
|
public *rsa.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRSASigner returns a Signer backed by RSA-SHA256.
|
||||||
|
// The public key is derived from the private key — no separate argument needed.
|
||||||
|
func NewRSASigner(privateKey *rsa.PrivateKey) Signer {
|
||||||
|
return &rsaSigner{private: privateKey, public: &privateKey.PublicKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRSASignerFromPEM parses a PKCS#8 or PKCS#1 PEM-encoded RSA private key.
|
||||||
|
func NewRSASignerFromPEM(pemKey []byte) (Signer, error) {
|
||||||
|
block, _ := pem.Decode(pemKey)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
rsaKey, err2 := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, fmt.Errorf("parse RSA private key: %w", err)
|
||||||
|
}
|
||||||
|
return NewRSASigner(rsaKey), nil
|
||||||
|
}
|
||||||
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("PEM key is not an RSA private key")
|
||||||
|
}
|
||||||
|
return NewRSASigner(rsaKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rsaSigner) Sign(claims jwt.Claims) (string, error) {
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(s.private)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rsaSigner) Verify(tokenString string) (*jwt.Token, error) {
|
||||||
|
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return s.public, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RSA public-key-only verifier ---
|
||||||
|
|
||||||
|
type rsaPublicVerifier struct{ public *rsa.PublicKey }
|
||||||
|
|
||||||
|
// NewRSAPublicKeyVerifier returns a Verifier backed by an RSA public key.
|
||||||
|
// Use this in services that verify tokens but never issue them.
|
||||||
|
func NewRSAPublicKeyVerifier(publicKey *rsa.PublicKey) Verifier {
|
||||||
|
return &rsaPublicVerifier{public: publicKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRSAPublicKeyVerifierFromPEM parses a PKIX or PKCS#1 PEM-encoded RSA public key.
|
||||||
|
func NewRSAPublicKeyVerifierFromPEM(pemKey []byte) (Verifier, error) {
|
||||||
|
block, _ := pem.Decode(pemKey)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
rsaPub, err2 := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, fmt.Errorf("parse RSA public key: %w", err)
|
||||||
|
}
|
||||||
|
return NewRSAPublicKeyVerifier(rsaPub), nil
|
||||||
|
}
|
||||||
|
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("PEM key is not an RSA public key")
|
||||||
|
}
|
||||||
|
return NewRSAPublicKeyVerifier(rsaPub), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *rsaPublicVerifier) Verify(tokenString string) (*jwt.Token, error) {
|
||||||
|
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return v.public, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
84
tokens.go
Normal file
84
tokens.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package jwtauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenConfig configures token lifetimes and the issuer claim.
|
||||||
|
type TokenConfig struct {
|
||||||
|
AccessTTL time.Duration
|
||||||
|
RefreshTTL time.Duration
|
||||||
|
Issuer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPair holds an access token and a refresh token.
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
ExpiresIn int64 // seconds until the access token expires
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTokenPair signs a new access + refresh token pair for uid.
|
||||||
|
//
|
||||||
|
// customClaims are merged into the access token at the top level. Use this to
|
||||||
|
// embed per-resource permission masks so ClaimsPermissionProvider can read them
|
||||||
|
// without a database call:
|
||||||
|
//
|
||||||
|
// customClaims := map[string]any{
|
||||||
|
// "permisos": map[string]any{"usuarios": int64(515), "roles": int64(30)},
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The refresh token contains only sub, iss, iat, exp, jti, and fam (token family).
|
||||||
|
// It carries no permission data — callers re-fetch fresh claims on each rotation.
|
||||||
|
func IssueTokenPair(signer Signer, uid string, customClaims map[string]any, cfg TokenConfig) (TokenPair, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
accessClaims := jwt.MapClaims{
|
||||||
|
"sub": uid,
|
||||||
|
"iss": cfg.Issuer,
|
||||||
|
"iat": jwt.NewNumericDate(now),
|
||||||
|
"exp": jwt.NewNumericDate(now.Add(cfg.AccessTTL)),
|
||||||
|
"jti": newJTI(),
|
||||||
|
}
|
||||||
|
for k, v := range customClaims {
|
||||||
|
accessClaims[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := signer.Sign(accessClaims)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, fmt.Errorf("sign access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshClaims := jwt.MapClaims{
|
||||||
|
"sub": uid,
|
||||||
|
"iss": cfg.Issuer,
|
||||||
|
"iat": jwt.NewNumericDate(now),
|
||||||
|
"exp": jwt.NewNumericDate(now.Add(cfg.RefreshTTL)),
|
||||||
|
"jti": newJTI(),
|
||||||
|
"fam": newJTI(),
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := signer.Sign(refreshClaims)
|
||||||
|
if err != nil {
|
||||||
|
return TokenPair{}, fmt.Errorf("sign refresh token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TokenPair{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(cfg.AccessTTL.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJTI() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80
|
||||||
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||||
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user