refactor(httpauth-firebase)!: delegate enrichment and authz to httpauth v0.1.0
EnrichmentMiddleware, AuthzMiddleware, IdentityEnricher, PermissionProvider, and related types are removed from this module. They now live in code.nochebuena.dev/go/httpauth, the provider-agnostic middleware layer. AuthMiddleware is updated to call httpauth.SetTokenData, fulfilling the integration contract between provider-specific auth and generic middleware. This module now has a single responsibility: Firebase JWT verification. BREAKING CHANGE: IdentityEnricher, PermissionProvider, EnrichmentMiddleware, AuthzMiddleware, and WithTenantHeader are no longer exported from this package. Import code.nochebuena.dev/go/httpauth for those identifiers.
This commit is contained in:
1
.gitea/CODEOWNERS
Normal file
1
.gitea/CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @go/CoreDevelopers @go/Agents
|
||||
72
CLAUDE.md
72
CLAUDE.md
@@ -13,38 +13,47 @@ logic are decoupled from Firebase types entirely.
|
||||
|
||||
## Tier & Dependencies
|
||||
|
||||
**Tier:** 4 (transport auth layer; depends on Tier 0 `rbac` and external Firebase SDK)
|
||||
**Tier:** 4 (transport auth layer; depends on Tier 3 `httpauth` and external Firebase SDK)
|
||||
**Module:** `code.nochebuena.dev/go/httpauth-firebase`
|
||||
**Direct imports:** `code.nochebuena.dev/go/rbac`, `firebase.google.com/go/v4/auth`
|
||||
**Direct imports:** `code.nochebuena.dev/go/httpauth`, `firebase.google.com/go/v4/auth`
|
||||
**Transitive:** `code.nochebuena.dev/go/rbac` (indirect, pulled in by `httpauth`)
|
||||
|
||||
`httpauth-firebase` does not import `logz`, `httpmw`, or `httputil`. It has no
|
||||
logger parameter — errors are returned as HTTP responses, not logged here.
|
||||
|
||||
`EnrichmentMiddleware`, `AuthzMiddleware`, `IdentityEnricher`, `WithTenantHeader`,
|
||||
`Cache`, and permission providers all live in `code.nochebuena.dev/go/httpauth`.
|
||||
This module only provides `AuthMiddleware` and the `TokenVerifier` interface.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Provider-specific naming** (ADR-001): The module is named `httpauth-firebase`
|
||||
because it imports Firebase directly. Other providers live in sibling modules
|
||||
(`httpauth-auth0`, etc.). All converge on `rbac.Identity`. The `TokenVerifier`
|
||||
interface is retained for unit-test mockability only.
|
||||
- **rbac.Identity as output contract** (ADR-002): `EnrichmentMiddleware` stores the
|
||||
final identity with `rbac.SetInContext`; `AuthzMiddleware` reads it with
|
||||
`rbac.FromContext`. Downstream handlers only need to import `rbac`. Global ADR-003
|
||||
applies: `rbac` owns its context helpers.
|
||||
- **Composable three-middleware stack** (ADR-003): `AuthMiddleware`,
|
||||
`EnrichmentMiddleware`, and `AuthzMiddleware` are separate functions. Each can be
|
||||
applied independently; each returns 401 if its upstream dependency is missing from
|
||||
context.
|
||||
(`httpauth-jwt`, etc.). All converge on `rbac.Identity` via the shared `httpauth`
|
||||
contract. The `TokenVerifier` interface is retained for unit-test mockability only.
|
||||
- **Integration contract via `httpauth.SetTokenData`**: `AuthMiddleware` stores
|
||||
`uid` and `claims` using `httpauth.SetTokenData`, which writes to context keys
|
||||
owned by the `httpauth` package. `EnrichmentMiddleware` (from `httpauth`) reads
|
||||
them. This is the explicit integration point between provider-specific and
|
||||
provider-agnostic middleware.
|
||||
- **Single responsibility**: This module only verifies Firebase tokens. Identity
|
||||
enrichment, RBAC, and caching all live in `code.nochebuena.dev/go/httpauth`.
|
||||
|
||||
## Patterns
|
||||
|
||||
**Full stack (most routes):**
|
||||
|
||||
```go
|
||||
import (
|
||||
httpauth "code.nochebuena.dev/go/httpauth-firebase"
|
||||
httpauthmw "code.nochebuena.dev/go/httpauth"
|
||||
)
|
||||
|
||||
r.Use(httpauth.AuthMiddleware(firebaseAuthClient, []string{"/health", "/metrics/*"}))
|
||||
r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID")))
|
||||
r.Use(httpauthmw.EnrichmentMiddleware(userEnricher, httpauthmw.WithTenantHeader("X-Tenant-ID")))
|
||||
|
||||
// Per-route RBAC:
|
||||
r.With(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Write)).
|
||||
r.With(httpauthmw.AuthzMiddleware(permProvider, "orders", rbac.Write)).
|
||||
Post("/orders", httputil.Handle(v, svc.CreateOrder))
|
||||
```
|
||||
|
||||
@@ -62,7 +71,7 @@ identity, ok := rbac.FromContext(r.Context())
|
||||
if !ok {
|
||||
// should not happen if EnrichmentMiddleware is in the chain
|
||||
}
|
||||
fmt.Println(identity.UID, identity.Role, identity.TenantID)
|
||||
fmt.Println(identity.UID, identity.DisplayName, identity.TenantID)
|
||||
```
|
||||
|
||||
**Public paths (bypass token check):**
|
||||
@@ -73,21 +82,15 @@ publicPaths := []string{"/health", "/ready", "/metrics/*"}
|
||||
r.Use(httpauth.AuthMiddleware(firebaseAuthClient, publicPaths))
|
||||
```
|
||||
|
||||
**Interfaces the application must implement:**
|
||||
**Interfaces the application must implement (defined in httpauth, not here):**
|
||||
|
||||
```go
|
||||
// IdentityEnricher: called by EnrichmentMiddleware
|
||||
// httpauthmw.IdentityEnricher: called by EnrichmentMiddleware
|
||||
type MyEnricher struct{ db *sql.DB }
|
||||
func (e *MyEnricher) Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) {
|
||||
user, err := e.db.LookupUser(ctx, uid)
|
||||
if err != nil { return rbac.Identity{}, err }
|
||||
return rbac.NewIdentity(uid, user.DisplayName, user.Email).WithRole(user.Role), nil
|
||||
}
|
||||
|
||||
// PermissionProvider: called by AuthzMiddleware
|
||||
type MyPermProvider struct{}
|
||||
func (p *MyPermProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) {
|
||||
return rbac.ReadWrite, nil
|
||||
return rbac.NewIdentity(uid, user.DisplayName, user.Email), nil
|
||||
}
|
||||
```
|
||||
|
||||
@@ -100,25 +103,24 @@ func (p *MyPermProvider) ResolveMask(ctx context.Context, uid, resource string)
|
||||
`AuthzMiddleware` reads `rbac.Identity` from context; if enrichment has not run,
|
||||
it will return 401.
|
||||
- Do not read `firebase.Token` fields directly in business logic. The token UID and
|
||||
claims are stored under unexported context keys and are not part of the public API.
|
||||
Use `rbac.FromContext` to read the enriched identity.
|
||||
claims are stored in context by `httpauth.SetTokenData` under keys owned by the
|
||||
`httpauth` package. Use `rbac.FromContext` to read the enriched identity.
|
||||
- Do not store application-specific data in Firebase custom claims and bypass the
|
||||
`IdentityEnricher`. Claims are read by `EnrichmentMiddleware` and passed to
|
||||
`Enrich`, but the enricher is the correct place to resolve application identity —
|
||||
not raw Firebase claims spread across handlers.
|
||||
`IdentityEnricher`. Claims are read by `EnrichmentMiddleware` (from `httpauth`)
|
||||
and passed to `Enrich` — the enricher is the correct place to resolve identity.
|
||||
- Do not import `httpauth-firebase` from service or domain layers. It is a transport
|
||||
package. Dependency should flow inward: handler → service, never service →
|
||||
handler/middleware.
|
||||
- Do not redefine `IdentityEnricher`, `Cache`, or permission providers here. They
|
||||
live in `code.nochebuena.dev/go/httpauth`.
|
||||
|
||||
## Testing Notes
|
||||
|
||||
- `compliance_test.go` verifies at compile time that `*mockVerifier` satisfies
|
||||
`TokenVerifier`, `*mockEnricher` satisfies `IdentityEnricher`, and `*mockProvider`
|
||||
satisfies `PermissionProvider`.
|
||||
`TokenVerifier`. Interface checks for `IdentityEnricher`, `Cache`, and permission
|
||||
providers live in `code.nochebuena.dev/go/httpauth`'s own compliance test.
|
||||
- `AuthMiddleware` can be tested by injecting a `mockVerifier` that returns a
|
||||
controlled `*auth.Token`. No real Firebase project is needed.
|
||||
- `EnrichmentMiddleware` can be tested by pre-populating the request context with
|
||||
`uid` via an upstream `AuthMiddleware` using a mock verifier.
|
||||
- `AuthzMiddleware` can be tested by pre-populating the request context with an
|
||||
`rbac.Identity` via `rbac.SetInContext` — no auth or enrichment middleware needed.
|
||||
- `EnrichmentMiddleware` and `AuthzMiddleware` tests belong in the `httpauth` module,
|
||||
not here.
|
||||
- Use `httptest.NewRecorder()` and `httptest.NewRequest()` for all HTTP-level tests.
|
||||
|
||||
30
auth.go
30
auth.go
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"firebase.google.com/go/v4/auth"
|
||||
|
||||
httpauthmw "code.nochebuena.dev/go/httpauth"
|
||||
)
|
||||
|
||||
// TokenVerifier abstracts Firebase JWT verification.
|
||||
@@ -16,30 +18,10 @@ type TokenVerifier interface {
|
||||
VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*auth.Token, error)
|
||||
}
|
||||
|
||||
// ctxUIDKey and ctxClaimsKey are unexported typed context keys.
|
||||
// Using distinct types prevents collisions with keys from other packages.
|
||||
type ctxUIDKey struct{}
|
||||
type ctxClaimsKey struct{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// AuthMiddleware verifies the Bearer token and injects uid + claims into the
|
||||
// request context. Requests to publicPaths are skipped without token verification
|
||||
// (wildcards supported via path.Match). Returns 401 on missing or invalid tokens.
|
||||
// request context via httpauth.SetTokenData. Requests to publicPaths are skipped
|
||||
// without token verification (wildcards supported via path.Match).
|
||||
// Returns 401 on missing or invalid tokens.
|
||||
func AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -63,7 +45,7 @@ func AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Hand
|
||||
return
|
||||
}
|
||||
|
||||
ctx := setTokenData(r.Context(), decoded.UID, decoded.Claims)
|
||||
ctx := httpauthmw.SetTokenData(r.Context(), decoded.UID, decoded.Claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
37
authz.go
37
authz.go
@@ -1,37 +0,0 @@
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.nochebuena.dev/go/rbac"
|
||||
)
|
||||
|
||||
// PermissionProvider resolves the permission mask for a given uid on a resource.
|
||||
type PermissionProvider interface {
|
||||
ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error)
|
||||
}
|
||||
|
||||
// AuthzMiddleware reads the rbac.Identity from context (set by EnrichmentMiddleware)
|
||||
// and gates the request against the required permission on resource.
|
||||
// Returns 401 if no identity is in the context.
|
||||
// Returns 403 if the identity lacks the required permission or if the provider errors.
|
||||
func AuthzMiddleware(provider 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"firebase.google.com/go/v4/auth"
|
||||
|
||||
httpauth "code.nochebuena.dev/go/httpauth-firebase"
|
||||
"code.nochebuena.dev/go/rbac"
|
||||
)
|
||||
|
||||
type mockVerifier struct{}
|
||||
@@ -15,19 +14,5 @@ func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Compile-time interface satisfaction checks.
|
||||
var _ httpauth.TokenVerifier = (*mockVerifier)(nil)
|
||||
var _ httpauth.IdentityEnricher = (*mockEnricher)(nil)
|
||||
var _ httpauth.PermissionProvider = (*mockProvider)(nil)
|
||||
// Compile-time interface satisfaction check.
|
||||
var _ httpauth.TokenVerifier = (*mockVerifier)(nil)
|
||||
|
||||
21
doc.go
21
doc.go
@@ -1,17 +1,18 @@
|
||||
// Package httpauth provides Firebase-backed HTTP middleware for authentication,
|
||||
// identity enrichment, and role-based access control.
|
||||
// Package httpauth provides Firebase-backed HTTP authentication middleware.
|
||||
//
|
||||
// AuthMiddleware verifies Firebase Bearer tokens and injects uid + claims into
|
||||
// the request context via httpauth.SetTokenData (code.nochebuena.dev/go/httpauth).
|
||||
// Downstream middleware (EnrichmentMiddleware, AuthzMiddleware) comes from that
|
||||
// package and is provider-agnostic.
|
||||
//
|
||||
// Typical middleware chain:
|
||||
//
|
||||
// import httpauthmw "code.nochebuena.dev/go/httpauth"
|
||||
//
|
||||
// r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths))
|
||||
// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID")))
|
||||
// r.Use(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Read))
|
||||
// r.Use(httpauthmw.EnrichmentMiddleware(userEnricher, httpauthmw.WithTenantHeader("X-Tenant-ID")))
|
||||
// r.With(httpauthmw.AuthzMiddleware(permProvider, "orders", rbac.Read)).Post("/orders", handler)
|
||||
//
|
||||
// AuthMiddleware verifies Firebase Bearer tokens and injects uid + claims into
|
||||
// the request context. EnrichmentMiddleware reads those values, calls the
|
||||
// app-provided IdentityEnricher, and stores the full rbac.Identity. AuthzMiddleware
|
||||
// resolves the permission mask and gates the request.
|
||||
//
|
||||
// All three middleware functions accept interfaces, so they can be tested without
|
||||
// AuthMiddleware accepts a TokenVerifier interface, so it can be tested without
|
||||
// a live Firebase connection.
|
||||
package httpauth
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.nochebuena.dev/go/rbac"
|
||||
)
|
||||
|
||||
// IdentityEnricher builds an rbac.Identity from verified token claims.
|
||||
// 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 AuthMiddleware, calls
|
||||
// enricher.Enrich to build a full rbac.Identity, and stores it in the context.
|
||||
// Returns 401 if no uid is present (AuthMiddleware was not in the chain).
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
3
go.mod
3
go.mod
@@ -3,13 +3,14 @@ module code.nochebuena.dev/go/httpauth-firebase
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
code.nochebuena.dev/go/rbac v0.9.0
|
||||
code.nochebuena.dev/go/httpauth v0.1.0
|
||||
firebase.google.com/go/v4 v4.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute v1.24.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
code.nochebuena.dev/go/rbac v0.9.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -3,6 +3,8 @@ cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1Yl
|
||||
cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
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=
|
||||
firebase.google.com/go/v4 v4.15.0 h1:k27M+cHbyN1YpBI2Cf4NSjeHnnYRB9ldXwpqA5KikN0=
|
||||
|
||||
172
httpauth_test.go
172
httpauth_test.go
@@ -8,8 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"firebase.google.com/go/v4/auth"
|
||||
|
||||
"code.nochebuena.dev/go/rbac"
|
||||
)
|
||||
|
||||
// --- mocks ---
|
||||
@@ -23,46 +21,17 @@ func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string)
|
||||
return m.token, m.err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// testRead is permission bit 0, used in authz tests.
|
||||
const testRead rbac.Permission = 0
|
||||
|
||||
func chain(mw func(http.Handler) http.Handler, h http.HandlerFunc) http.Handler {
|
||||
return mw(h)
|
||||
}
|
||||
|
||||
// injectUID bypasses AuthMiddleware for EnrichmentMiddleware tests.
|
||||
func injectUID(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))
|
||||
})
|
||||
}
|
||||
|
||||
// --- AuthMiddleware ---
|
||||
|
||||
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||
mv := &mockVerifier{token: &auth.Token{UID: "uid123", Claims: map[string]any{"name": "Alice"}}}
|
||||
var capturedUID string
|
||||
reached := false
|
||||
h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedUID, _ = getUID(r.Context())
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||
@@ -72,8 +41,8 @@ func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("want 200, got %d", rec.Code)
|
||||
}
|
||||
if capturedUID != "uid123" {
|
||||
t.Errorf("want uid123, got %q", capturedUID)
|
||||
if !reached {
|
||||
t.Error("inner handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,136 +95,3 @@ func TestAuthMiddleware_PublicPathWildcard(t *testing.T) {
|
||||
t.Errorf("want 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- EnrichmentMiddleware ---
|
||||
|
||||
func TestEnrichmentMiddleware_Success(t *testing.T) {
|
||||
me := &mockEnricher{identity: rbac.NewIdentity("uid123", "Alice", "alice@example.com")}
|
||||
var capturedIdentity rbac.Identity
|
||||
inner := EnrichmentMiddleware(me)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedIdentity, _ = rbac.FromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h := injectUID("uid123", 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 capturedIdentity.UID != "uid123" {
|
||||
t.Errorf("want uid123, got %q", capturedIdentity.UID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichmentMiddleware_NoUID(t *testing.T) {
|
||||
me := &mockEnricher{}
|
||||
h := chain(EnrichmentMiddleware(me), 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 := injectUID("uid123", 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_WithTenant(t *testing.T) {
|
||||
me := &mockEnricher{identity: rbac.NewIdentity("uid123", "", "")}
|
||||
var capturedIdentity rbac.Identity
|
||||
inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedIdentity, _ = rbac.FromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h := injectUID("uid123", nil, inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Tenant-ID", "tenant-abc")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if capturedIdentity.TenantID != "tenant-abc" {
|
||||
t.Errorf("want tenant-abc, got %q", capturedIdentity.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichmentMiddleware_NoTenantHeader(t *testing.T) {
|
||||
me := &mockEnricher{identity: rbac.NewIdentity("uid123", "", "")}
|
||||
var capturedIdentity rbac.Identity
|
||||
inner := EnrichmentMiddleware(me, WithTenantHeader("X-Tenant-ID"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedIdentity, _ = rbac.FromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h := injectUID("uid123", nil, inner)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if capturedIdentity.TenantID != "" {
|
||||
t.Errorf("want empty TenantID, got %q", capturedIdentity.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- AuthzMiddleware ---
|
||||
|
||||
func TestAuthzMiddleware_Allowed(t *testing.T) {
|
||||
mp := &mockProvider{mask: rbac.PermissionMask(0).Grant(testRead)}
|
||||
inner := AuthzMiddleware(mp, "orders", testRead)(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("uid123", "", ""))
|
||||
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)} // no permissions granted
|
||||
inner := AuthzMiddleware(mp, "orders", testRead)(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("uid123", "", ""))
|
||||
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 := chain(AuthzMiddleware(mp, "orders", testRead), 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, "orders", testRead)(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("uid123", "", ""))
|
||||
rec := httptest.NewRecorder()
|
||||
inner.ServeHTTP(rec, req.WithContext(ctx))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user