From d8773b0f9fd5975f393ea4d4184be60122e3dfa6 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Thu, 7 May 2026 22:18:04 -0600 Subject: [PATCH] =?UTF-8?q?feat(httpauth-jwt):=20initial=20release=20?= =?UTF-8?q?=E2=80=94=20self-issued=20JWT=20auth=20middleware=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/CODEOWNERS | 1 + .gitignore | 38 +++++ CHANGELOG.md | 56 +++++++ CLAUDE.md | 103 ++++++++++++ LICENSE | 21 +++ README.md | 160 +++++++++++++++++++ auth.go | 62 ++++++++ compliance_test.go | 30 ++++ doc.go | 26 +++ go.mod | 10 ++ go.sum | 6 + jwtauth_test.go | 385 +++++++++++++++++++++++++++++++++++++++++++++ refresh.go | 72 +++++++++ signer.go | 133 ++++++++++++++++ tokens.go | 84 ++++++++++ 15 files changed, 1187 insertions(+) create mode 100644 .gitea/CODEOWNERS create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 auth.go create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 jwtauth_test.go create mode 100644 refresh.go create mode 100644 signer.go create mode 100644 tokens.go diff --git a/.gitea/CODEOWNERS b/.gitea/CODEOWNERS new file mode 100644 index 0000000..ae1f67b --- /dev/null +++ b/.gitea/CODEOWNERS @@ -0,0 +1 @@ +* @go/CoreDevelopers @go/Agents diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d57a1f8 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..632d6e1 --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aab79c --- /dev/null +++ b/README.md @@ -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` | diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..afc3920 --- /dev/null +++ b/auth.go @@ -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)) + }) + } +} diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..878e911 --- /dev/null +++ b/compliance_test.go @@ -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) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..9b187e4 --- /dev/null +++ b/doc.go @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6bf8ae3 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a1c4db2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/jwtauth_test.go b/jwtauth_test.go new file mode 100644 index 0000000..6a3bbf8 --- /dev/null +++ b/jwtauth_test.go @@ -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") + } +} diff --git a/refresh.go b/refresh.go new file mode 100644 index 0000000..c352082 --- /dev/null +++ b/refresh.go @@ -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) +} diff --git a/signer.go b/signer.go new file mode 100644 index 0000000..8c5a164 --- /dev/null +++ b/signer.go @@ -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 + }) +} diff --git a/tokens.go b/tokens.go new file mode 100644 index 0000000..5dd039b --- /dev/null +++ b/tokens.go @@ -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:]) +}