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.
This commit is contained in:
2026-05-07 21:37:25 -06:00
commit 132a1ba1fe
16 changed files with 879 additions and 0 deletions

1
.gitea/CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @go/CoreDevelopers @go/Agents

38
.gitignore vendored Normal file
View 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

29
CHANGELOG.md Normal file
View File

@@ -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

111
CLAUDE.md Normal file
View File

@@ -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.

21
LICENSE Normal file
View 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.

112
README.md Normal file
View File

@@ -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.

25
auth.go Normal file
View File

@@ -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
}

32
authz.go Normal file
View File

@@ -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)
})
}
}

44
cache.go Normal file
View File

@@ -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
}

39
claims_provider.go Normal file
View File

@@ -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
}

33
compliance_test.go Normal file
View File

@@ -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)

19
doc.go Normal file
View File

@@ -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

67
enrichment.go Normal file
View File

@@ -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))
})
}
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module code.nochebuena.dev/go/httpauth
go 1.25
require code.nochebuena.dev/go/rbac v0.9.0

2
go.sum Normal file
View File

@@ -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=

301
httpauth_test.go Normal file
View File

@@ -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")
}
}