commit 18e5a16f7eeb1e6e9e99521eb674efe6205da071 Author: Rene Nochebuena Guerrero Date: Thu May 7 21:37:25 2026 -0600 feat(httpauth): initial release — provider-agnostic HTTP auth middleware Provides SetTokenData for upstream AuthMiddleware implementations, EnrichmentMiddleware and AuthzMiddleware compatible with any provider that calls SetTokenData, ClaimsPermissionProvider for JWT-embedded permissions, and CachedPermissionProvider for TTL-backed runtime resolution via any Cache implementation. 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..dd8e58e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-05-08 + +### Added + +- `SetTokenData(ctx, uid, claims) context.Context` — injects verified uid and raw claims into the request context; called by provider-specific AuthMiddleware implementations (e.g. `httpauth-firebase`, `httpauth-jwt`) after token verification; downstream middleware reads these values via unexported helpers in the same package +- `IdentityEnricher` interface — application-implemented; receives `uid string` and `claims map[string]any`, returns `rbac.Identity`; called by `EnrichmentMiddleware` on every authenticated request +- `EnrichOpt` functional option type for configuring `EnrichmentMiddleware` +- `WithTenantHeader(header string) EnrichOpt` — reads a tenant ID from the named request header and attaches it to the identity via `rbac.Identity.WithTenant`; absent header leaves `TenantID` as an empty string with no error +- `EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler` — reads uid and claims stored by any upstream AuthMiddleware via `SetTokenData`, calls `enricher.Enrich`, and stores the resulting `rbac.Identity` in context via `rbac.SetInContext`; returns 401 if no uid is present; returns 500 if the enricher fails +- `AuthzMiddleware(provider rbac.PermissionProvider, resource string, required rbac.Permission) func(http.Handler) http.Handler` — reads `rbac.Identity` from context via `rbac.FromContext`, resolves the permission mask via the provided `rbac.PermissionProvider`, and gates the request against the required permission bit; returns 401 if no identity is in context; returns 403 if the permission check fails or the provider returns an error; uses `rbac.PermissionProvider` directly without redefining it +- `Cache` interface — abstracts the caching backend for permission masks; `Get(ctx, key) (int64, bool, error)` and `Set(ctx, key, value, ttl) error`; implementations are typically backed by Valkey or Redis +- `NewCachedPermissionProvider(inner rbac.PermissionProvider, cache Cache, ttl time.Duration) rbac.PermissionProvider` — wraps any `rbac.PermissionProvider` with a TTL-based cache layer; cache key format is `rbac:{uid}:{resource}`; on cache miss falls through to inner and populates the cache; on cache error falls through silently — never fails due to cache unavailability +- `NewClaimsPermissionProvider(claimsKey string) rbac.PermissionProvider` — reads pre-computed permission masks from JWT claims stored in the request context by `SetTokenData`; expects `claims[claimsKey]` to be a `map[string]any` where each key is a resource name and the value is the bitmask as `int64` or `float64` (JSON decodes numbers as float64); returns 0 without error if the claim is absent + +### Design Notes + +- `AuthzMiddleware` uses `rbac.PermissionProvider` directly rather than redefining a local interface; `rbac` is the single source of truth for this contract +- `EnrichmentMiddleware` and `AuthzMiddleware` are provider-agnostic — they depend only on `SetTokenData` having been called upstream; any `AuthMiddleware` that calls `SetTokenData` (Firebase, JWT, API key, etc.) is compatible without changes to the enrichment or authorization layer +- Two `rbac.PermissionProvider` implementations ship with this module for the two common architectures: `ClaimsPermissionProvider` for simple applications that embed permissions in the JWT (no per-request DB or network call), and `CachedPermissionProvider` for applications where the permission set is too large to embed or needs to be independently revocable +- `CachedPermissionProvider` uses TTL-based expiry exclusively; explicit invalidation is left to callers who can interact with the `Cache` directly using the `rbac:{uid}:{resource}` key format + +[0.1.0]: https://code.nochebuena.dev/go/httpauth/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9b74c07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# httpauth + +Provider-agnostic HTTP middleware for identity enrichment and RBAC authorization. + +## Purpose + +`httpauth` is the shared foundation for all `httpauth-*` provider modules. It provides +`EnrichmentMiddleware`, `AuthzMiddleware`, two `rbac.PermissionProvider` implementations +(`ClaimsPermissionProvider` and `CachedPermissionProvider`), and `SetTokenData` — the +bridge between a provider-specific `AuthMiddleware` and the rest of the auth stack. + +Any `AuthMiddleware` that calls `SetTokenData` after token verification is compatible. +Downstream code reads identity exclusively via `rbac.FromContext` — no provider type +leaks past the middleware boundary. + +## Tier & Dependencies + +**Tier:** 3 (transport auth layer; depends only on Tier 0 `rbac`; no external SDK) +**Module:** `code.nochebuena.dev/go/httpauth` +**Direct imports:** `code.nochebuena.dev/go/rbac` only + +`httpauth` does not import `logz`, `httpmw`, `httputil`, Firebase, or any JWT library. +It has no logger parameter — errors are returned as HTTP responses. + +## Key Design Decisions + +- **`rbac.PermissionProvider` without redefinition:** `AuthzMiddleware` accepts + `rbac.PermissionProvider` directly. `rbac` is the single source of truth for this + interface. Provider-specific modules (e.g. `httpauth-firebase`) previously defined + their own `PermissionProvider` locally — that duplication is removed. +- **`SetTokenData` as the integration contract:** Provider-specific `AuthMiddleware` + implementations call `SetTokenData(ctx, uid, claims)` after verifying the token. + The context keys are unexported typed structs. `EnrichmentMiddleware` reads them + via the unexported helpers `getUID` and `getClaims` in the same package. +- **Two permission strategies:** `ClaimsPermissionProvider` (JWT-embedded, no DB call) + and `CachedPermissionProvider` (TTL-backed runtime resolution) are first-class + implementations. Choose based on token size and revocation requirements. +- **Cache falls through on error:** `CachedPermissionProvider` treats cache errors as + misses — a cache outage degrades gracefully to the inner provider. + +## Patterns + +**Full stack with self-issued JWT:** + +```go +r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +r.Use(httpauth.EnrichmentMiddleware(myEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) + +// Simple app — permissions embedded in JWT: +claimsProvider := httpauth.NewClaimsPermissionProvider("permisos") +r.With(httpauth.AuthzMiddleware(claimsProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) + +// Complex app — runtime resolution with cache: +cachedProvider := httpauth.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute) +r.With(httpauth.AuthzMiddleware(cachedProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) +``` + +**Provider-specific AuthMiddleware calling SetTokenData:** + +```go +// Inside httpauth-jwt or httpauth-firebase AuthMiddleware, after token verification: +ctx := httpauth.SetTokenData(r.Context(), verified.UID, verified.Claims) +next.ServeHTTP(w, r.WithContext(ctx)) +``` + +**Reading identity in a handler:** + +```go +identity, ok := rbac.FromContext(r.Context()) +if !ok { + // should not happen if EnrichmentMiddleware is in the chain +} +``` + +**Implementing Cache (e.g. with Valkey):** + +```go +type valkeyCache struct{ client valkey.Client } + +func (c *valkeyCache) Get(ctx context.Context, key string) (int64, bool, error) { ... } +func (c *valkeyCache) Set(ctx context.Context, key string, val int64, ttl time.Duration) error { ... } +``` + +**Cache key for manual invalidation:** `rbac:{uid}:{resource}` + +## What to Avoid + +- Do not call `SetTokenData` from application or domain layer code. It is the + exclusive responsibility of provider-specific `AuthMiddleware` implementations. +- Do not put `AuthzMiddleware` before `EnrichmentMiddleware` in the chain. + `AuthzMiddleware` reads `rbac.Identity` from context; if enrichment has not run, + it will return 401. +- Do not import `httpauth` from service or domain layers. It is a transport package. +- Do not define a local `PermissionProvider` interface in provider modules that import + this package — use `rbac.PermissionProvider` directly. + +## Testing Notes + +- `compliance_test.go` verifies at compile time that mock types satisfy `IdentityEnricher` + and `Cache`, and that `rbac.PermissionProvider` is satisfied by the two built-in + provider implementations. +- `EnrichmentMiddleware` tests use `injectTokenData(uid, claims, next)` — a helper + that calls `SetTokenData` to bypass a real upstream `AuthMiddleware`. +- `AuthzMiddleware` tests pre-populate context with `rbac.SetInContext` — no enrichment + middleware needed. +- `ClaimsPermissionProvider` tests exercise both `float64` (JSON decode) and `int64` + paths for the mask value. +- `CachedPermissionProvider` tests exercise cache hit, miss, cache error fallthrough, + and inner provider error propagation. 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..1175f77 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# httpauth + +Provider-agnostic HTTP middleware for identity enrichment and RBAC authorization. + +## Overview + +Three composable `func(http.Handler) http.Handler` middleware functions and two `rbac.PermissionProvider` implementations: + +| Component | Responsibility | +|---|---| +| `EnrichmentMiddleware` | Calls app-provided `IdentityEnricher`; stores `rbac.Identity` in context | +| `AuthzMiddleware` | Resolves permission mask via `rbac.PermissionProvider`; gates request | +| `ClaimsPermissionProvider` | Reads pre-computed masks from JWT claims — no DB call | +| `CachedPermissionProvider` | Wraps any provider with a TTL cache; falls through on miss or error | +| `SetTokenData` | Injects uid + claims from any verified token into the request context | + +Any upstream `AuthMiddleware` that calls `SetTokenData` is compatible — Firebase, self-issued JWT, API key, etc. + +## Installation + +``` +require code.nochebuena.dev/go/httpauth v0.1.0 +``` + +## Usage + +### With JWT-embedded permissions (simple apps) + +```go +// Auth middleware (e.g. httpauth-jwt) calls httpauth.SetTokenData after verification. +// JWT claims include: { "permisos": { "usuarios": 515, "roles": 6 } } + +r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +r.Use(httpauth.EnrichmentMiddleware(myEnricher)) + +claimsProvider := httpauth.NewClaimsPermissionProvider("permisos") +r.With(httpauth.AuthzMiddleware(claimsProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) +``` + +### With runtime resolution + cache (complex apps) + +```go +r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +r.Use(httpauth.EnrichmentMiddleware(myEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) + +dbProvider := myapp.NewDBPermissionProvider(db) +cachedProvider := httpauth.NewCachedPermissionProvider(dbProvider, valkeyCache, 5*time.Minute) + +r.With(httpauth.AuthzMiddleware(cachedProvider, "usuarios", rbac.Permission(1))). + Get("/usuarios", handler) +``` + +## Interfaces + +### IdentityEnricher + +```go +type IdentityEnricher interface { + Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) +} +``` + +Implement in your application to load user data and return an `rbac.Identity`. + +### Cache + +```go +type Cache interface { + Get(ctx context.Context, key string) (int64, bool, error) + Set(ctx context.Context, key string, value int64, ttl time.Duration) error +} +``` + +Implement with Valkey, Redis, or any in-memory store. Cache keys follow the format `rbac:{uid}:{resource}`. + +### rbac.PermissionProvider (from the `rbac` package) + +```go +type PermissionProvider interface { + ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) +} +``` + +`AuthzMiddleware` accepts any implementation — `ClaimsPermissionProvider`, `CachedPermissionProvider`, or your own. + +## SetTokenData + +```go +func SetTokenData(ctx context.Context, uid string, claims map[string]any) context.Context +``` + +Called by provider-specific `AuthMiddleware` implementations after token verification. `EnrichmentMiddleware` reads the injected values automatically. + +## Options + +| Option | Description | +|---|---| +| `WithTenantHeader(header)` | Reads `TenantID` from the named request header. If absent, `TenantID` remains `""`. | + +## HTTP status codes + +| Condition | Status | +|---|---| +| No uid in context (EnrichmentMiddleware) | 401 | +| Enricher error | 500 | +| No `rbac.Identity` in context (AuthzMiddleware) | 401 | +| Permission denied or provider error | 403 | + +## Cache key format + +`CachedPermissionProvider` uses `rbac:{uid}:{resource}` as the cache key. To invalidate manually, delete the key directly via your `Cache` implementation. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..e349708 --- /dev/null +++ b/auth.go @@ -0,0 +1,25 @@ +package httpauth + +import "context" + +type ctxUIDKey struct{} +type ctxClaimsKey struct{} + +// SetTokenData injects a verified uid and raw claims into the context. +// Called by provider-specific AuthMiddleware implementations after token verification. +// EnrichmentMiddleware reads these values automatically via unexported helpers. +func SetTokenData(ctx context.Context, uid string, claims map[string]any) context.Context { + ctx = context.WithValue(ctx, ctxUIDKey{}, uid) + ctx = context.WithValue(ctx, ctxClaimsKey{}, claims) + return ctx +} + +func getUID(ctx context.Context) (string, bool) { + uid, ok := ctx.Value(ctxUIDKey{}).(string) + return uid, ok && uid != "" +} + +func getClaims(ctx context.Context) (map[string]any, bool) { + claims, ok := ctx.Value(ctxClaimsKey{}).(map[string]any) + return claims, ok +} diff --git a/authz.go b/authz.go new file mode 100644 index 0000000..52289e4 --- /dev/null +++ b/authz.go @@ -0,0 +1,32 @@ +package httpauth + +import ( + "net/http" + + "code.nochebuena.dev/go/rbac" +) + +// AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware) +// and gates the request against the required permission on resource. +// Uses rbac.PermissionProvider directly — no local redefinition of the interface. +// Returns 401 if no identity is in context. +// Returns 403 if the identity lacks the required permission or if the provider errors. +func AuthzMiddleware(provider rbac.PermissionProvider, resource string, required rbac.Permission) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, ok := rbac.FromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + mask, err := provider.ResolveMask(r.Context(), identity.UID, resource) + if err != nil || !mask.Has(required) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..0916ea1 --- /dev/null +++ b/cache.go @@ -0,0 +1,44 @@ +package httpauth + +import ( + "context" + "fmt" + "time" + + "code.nochebuena.dev/go/rbac" +) + +// Cache abstracts the caching backend for permission masks. +// Implementations are typically backed by Valkey or Redis. +type Cache interface { + Get(ctx context.Context, key string) (int64, bool, error) + Set(ctx context.Context, key string, value int64, ttl time.Duration) error +} + +type cachedPermissionProvider struct { + inner rbac.PermissionProvider + cache Cache + ttl time.Duration +} + +// NewCachedPermissionProvider wraps inner with a TTL-based cache layer. +// Cache key format: "rbac:{uid}:{resource}". +// On cache miss, falls through to inner and populates the cache. +// On cache error, falls through to inner silently — never fails due to cache unavailability. +// For explicit invalidation, delete "rbac:{uid}:{resource}" directly via your Cache. +func NewCachedPermissionProvider(inner rbac.PermissionProvider, cache Cache, ttl time.Duration) rbac.PermissionProvider { + return &cachedPermissionProvider{inner: inner, cache: cache, ttl: ttl} +} + +func (p *cachedPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) { + key := fmt.Sprintf("rbac:%s:%s", uid, resource) + if val, ok, err := p.cache.Get(ctx, key); err == nil && ok { + return rbac.PermissionMask(val), nil + } + mask, err := p.inner.ResolveMask(ctx, uid, resource) + if err != nil { + return 0, err + } + _ = p.cache.Set(ctx, key, int64(mask), p.ttl) + return mask, nil +} diff --git a/claims_provider.go b/claims_provider.go new file mode 100644 index 0000000..9b478ba --- /dev/null +++ b/claims_provider.go @@ -0,0 +1,39 @@ +package httpauth + +import ( + "context" + + "code.nochebuena.dev/go/rbac" +) + +type claimsPermissionProvider struct { + claimsKey string +} + +// NewClaimsPermissionProvider returns an rbac.PermissionProvider that reads +// pre-computed permission masks from JWT claims stored in the request context +// by SetTokenData. Expects claims[claimsKey] to be a map[string]any where each +// key is a resource name and the value is the bitmask as int64 or float64 +// (JSON unmarshaling decodes numbers as float64). +// Returns 0 without error if the claim is absent — callers treat 0 as no access. +func NewClaimsPermissionProvider(claimsKey string) rbac.PermissionProvider { + return &claimsPermissionProvider{claimsKey: claimsKey} +} + +func (p *claimsPermissionProvider) ResolveMask(ctx context.Context, _, resource string) (rbac.PermissionMask, error) { + claims, ok := getClaims(ctx) + if !ok { + return 0, nil + } + permisos, ok := claims[p.claimsKey].(map[string]any) + if !ok { + return 0, nil + } + switch v := permisos[resource].(type) { + case int64: + return rbac.PermissionMask(v), nil + case float64: + return rbac.PermissionMask(int64(v)), nil + } + return 0, nil +} diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..98008c5 --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,33 @@ +package httpauth_test + +import ( + "context" + "time" + + httpauth "code.nochebuena.dev/go/httpauth" + "code.nochebuena.dev/go/rbac" +) + +type mockEnricher struct{} + +func (m *mockEnricher) Enrich(_ context.Context, _ string, _ map[string]any) (rbac.Identity, error) { + return rbac.Identity{}, nil +} + +type mockProvider struct{} + +func (m *mockProvider) ResolveMask(_ context.Context, _, _ string) (rbac.PermissionMask, error) { + return 0, nil +} + +type mockCache struct{} + +func (m *mockCache) Get(_ context.Context, _ string) (int64, bool, error) { return 0, false, nil } +func (m *mockCache) Set(_ context.Context, _ string, _ int64, _ time.Duration) error { + return nil +} + +// Compile-time interface satisfaction checks. +var _ httpauth.IdentityEnricher = (*mockEnricher)(nil) +var _ rbac.PermissionProvider = (*mockProvider)(nil) +var _ httpauth.Cache = (*mockCache)(nil) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..c167bca --- /dev/null +++ b/doc.go @@ -0,0 +1,19 @@ +// Package httpauth provides provider-agnostic HTTP middleware for identity +// enrichment and RBAC authorization. +// +// Any upstream AuthMiddleware that calls [SetTokenData] to inject uid and claims +// into the request context is compatible with this package — Firebase, self-issued +// JWT, API key, etc. +// +// Typical middleware chain: +// +// r.Use(jwtauth.AuthMiddleware(signer, publicPaths, nil)) +// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) +// +// // Choose one PermissionProvider: +// claimsProvider := httpauth.NewClaimsPermissionProvider("permisos") // JWT-embedded +// cachedProvider := httpauth.NewCachedPermissionProvider(db, cache, ttl) // runtime + cache +// +// r.With(httpauth.AuthzMiddleware(provider, "orders", rbac.Permission(1))). +// Post("/orders", handler) +package httpauth diff --git a/enrichment.go b/enrichment.go new file mode 100644 index 0000000..63f73ab --- /dev/null +++ b/enrichment.go @@ -0,0 +1,67 @@ +package httpauth + +import ( + "context" + "net/http" + + "code.nochebuena.dev/go/rbac" +) + +// IdentityEnricher builds an rbac.Identity from verified token data. +// The application provides the implementation — typically reads from a user store. +type IdentityEnricher interface { + Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) +} + +// EnrichOpt configures EnrichmentMiddleware. +type EnrichOpt func(*enrichConfig) + +type enrichConfig struct { + tenantHeader string +} + +// WithTenantHeader configures the request header from which TenantID is read. +// If the header is absent on a request, TenantID remains "" — no error is returned. +func WithTenantHeader(header string) EnrichOpt { + return func(c *enrichConfig) { + c.tenantHeader = header + } +} + +// EnrichmentMiddleware reads uid + claims injected by any upstream AuthMiddleware +// via SetTokenData, calls enricher.Enrich, and stores the resulting rbac.Identity +// in context via rbac.SetInContext. +// Returns 401 if no uid is present (SetTokenData was not called upstream). +// Returns 500 if the enricher fails. +func EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler { + cfg := &enrichConfig{} + for _, o := range opts { + o(cfg) + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid, ok := getUID(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + claims, _ := getClaims(r.Context()) + + identity, err := enricher.Enrich(r.Context(), uid, claims) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if cfg.tenantHeader != "" { + if tenantID := r.Header.Get(cfg.tenantHeader); tenantID != "" { + identity = identity.WithTenant(tenantID) + } + } + + ctx := rbac.SetInContext(r.Context(), identity) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..386bf68 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.nochebuena.dev/go/httpauth + +go 1.25 + +require code.nochebuena.dev/go/rbac v0.9.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..58b17cb --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o= +code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= diff --git a/httpauth_test.go b/httpauth_test.go new file mode 100644 index 0000000..6630f26 --- /dev/null +++ b/httpauth_test.go @@ -0,0 +1,301 @@ +package httpauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.nochebuena.dev/go/rbac" +) + +// --- mocks --- + +type mockEnricher struct { + identity rbac.Identity + err error +} + +func (m *mockEnricher) Enrich(_ context.Context, _ string, _ map[string]any) (rbac.Identity, error) { + return m.identity, m.err +} + +type mockProvider struct { + mask rbac.PermissionMask + err error +} + +func (m *mockProvider) ResolveMask(_ context.Context, _, _ string) (rbac.PermissionMask, error) { + return m.mask, m.err +} + +type mockCache struct { + val int64 + exists bool + getErr error + setErr error +} + +func (m *mockCache) Get(_ context.Context, _ string) (int64, bool, error) { + return m.val, m.exists, m.getErr +} + +func (m *mockCache) Set(_ context.Context, _ string, val int64, _ time.Duration) error { + m.val = val + return m.setErr +} + +// testPerm is permission bit 1, used in authz tests. +const testPerm rbac.Permission = 1 + +// injectTokenData bypasses an upstream AuthMiddleware for testing downstream middleware. +func injectTokenData(uid string, claims map[string]any, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := SetTokenData(r.Context(), uid, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// --- EnrichmentMiddleware --- + +func TestEnrichmentMiddleware_Success(t *testing.T) { + me := &mockEnricher{identity: rbac.NewIdentity("uid1", "Alice", "alice@example.com")} + var got rbac.Identity + inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = rbac.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } + if got.UID != "uid1" { + t.Errorf("want uid1, got %q", got.UID) + } +} + +func TestEnrichmentMiddleware_NoUID(t *testing.T) { + me := &mockEnricher{} + h := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +func TestEnrichmentMiddleware_EnricherError(t *testing.T) { + me := &mockEnricher{err: errors.New("db error")} + inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusInternalServerError { + t.Errorf("want 500, got %d", rec.Code) + } +} + +func TestEnrichmentMiddleware_WithTenantHeader(t *testing.T) { + me := &mockEnricher{identity: rbac.NewIdentity("uid1", "", "")} + var got rbac.Identity + inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = rbac.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Tenant-ID", "tenant-abc") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got.TenantID != "tenant-abc" { + t.Errorf("want tenant-abc, got %q", got.TenantID) + } +} + +func TestEnrichmentMiddleware_TenantHeaderAbsent(t *testing.T) { + me := &mockEnricher{identity: rbac.NewIdentity("uid1", "", "")} + var got rbac.Identity + inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = rbac.FromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + h := injectTokenData("uid1", nil, inner) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if got.TenantID != "" { + t.Errorf("want empty TenantID, got %q", got.TenantID) + } +} + +// --- AuthzMiddleware --- + +func TestAuthzMiddleware_Allowed(t *testing.T) { + mp := &mockProvider{mask: rbac.PermissionMask(0).Grant(testPerm)} + inner := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid1", "", "")) + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, req.WithContext(ctx)) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } +} + +func TestAuthzMiddleware_Denied(t *testing.T) { + mp := &mockProvider{mask: rbac.PermissionMask(0)} + inner := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid1", "", "")) + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, req.WithContext(ctx)) + if rec.Code != http.StatusForbidden { + t.Errorf("want 403, got %d", rec.Code) + } +} + +func TestAuthzMiddleware_NoIdentity(t *testing.T) { + mp := &mockProvider{} + h := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +func TestAuthzMiddleware_ProviderError(t *testing.T) { + mp := &mockProvider{err: errors.New("db error")} + inner := AuthzMiddleware(mp, "resource", testPerm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := rbac.SetInContext(req.Context(), rbac.NewIdentity("uid1", "", "")) + rec := httptest.NewRecorder() + inner.ServeHTTP(rec, req.WithContext(ctx)) + if rec.Code != http.StatusForbidden { + t.Errorf("want 403, got %d", rec.Code) + } +} + +// --- ClaimsPermissionProvider --- + +func TestClaimsPermissionProvider_Float64(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{"usuarios": float64(515)}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515, got %d", mask) + } +} + +func TestClaimsPermissionProvider_Int64(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{"roles": int64(6)}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "roles") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 6 { + t.Errorf("want 6, got %d", mask) + } +} + +func TestClaimsPermissionProvider_NoClaims(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 0 { + t.Errorf("want 0, got %d", mask) + } +} + +func TestClaimsPermissionProvider_ResourceAbsent(t *testing.T) { + p := NewClaimsPermissionProvider("permisos") + ctx := SetTokenData(context.Background(), "uid1", map[string]any{ + "permisos": map[string]any{}, + }) + mask, err := p.ResolveMask(ctx, "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 0 { + t.Errorf("want 0, got %d", mask) + } +} + +// --- CachedPermissionProvider --- + +func TestCachedPermissionProvider_Hit(t *testing.T) { + inner := &mockProvider{mask: 999} + cache := &mockCache{val: 515, exists: true} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from cache, got %d", mask) + } +} + +func TestCachedPermissionProvider_Miss(t *testing.T) { + inner := &mockProvider{mask: 515} + cache := &mockCache{exists: false} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from inner, got %d", mask) + } + if cache.val != 515 { + t.Errorf("expected cache populated with 515, got %d", cache.val) + } +} + +func TestCachedPermissionProvider_CacheErrorFallsThrough(t *testing.T) { + inner := &mockProvider{mask: 515} + cache := &mockCache{getErr: errors.New("valkey unavailable")} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + mask, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mask != 515 { + t.Errorf("want 515 from inner on cache error, got %d", mask) + } +} + +func TestCachedPermissionProvider_InnerError(t *testing.T) { + inner := &mockProvider{err: errors.New("db error")} + cache := &mockCache{exists: false} + p := NewCachedPermissionProvider(inner, cache, time.Minute) + _, err := p.ResolveMask(context.Background(), "uid1", "usuarios") + if err == nil { + t.Error("expected error from inner, got nil") + } +}