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 & 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`
|
**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
|
`httpauth-firebase` does not import `logz`, `httpmw`, or `httputil`. It has no
|
||||||
logger parameter — errors are returned as HTTP responses, not logged here.
|
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
|
## Key Design Decisions
|
||||||
|
|
||||||
- **Provider-specific naming** (ADR-001): The module is named `httpauth-firebase`
|
- **Provider-specific naming** (ADR-001): The module is named `httpauth-firebase`
|
||||||
because it imports Firebase directly. Other providers live in sibling modules
|
because it imports Firebase directly. Other providers live in sibling modules
|
||||||
(`httpauth-auth0`, etc.). All converge on `rbac.Identity`. The `TokenVerifier`
|
(`httpauth-jwt`, etc.). All converge on `rbac.Identity` via the shared `httpauth`
|
||||||
interface is retained for unit-test mockability only.
|
contract. The `TokenVerifier` interface is retained for unit-test mockability only.
|
||||||
- **rbac.Identity as output contract** (ADR-002): `EnrichmentMiddleware` stores the
|
- **Integration contract via `httpauth.SetTokenData`**: `AuthMiddleware` stores
|
||||||
final identity with `rbac.SetInContext`; `AuthzMiddleware` reads it with
|
`uid` and `claims` using `httpauth.SetTokenData`, which writes to context keys
|
||||||
`rbac.FromContext`. Downstream handlers only need to import `rbac`. Global ADR-003
|
owned by the `httpauth` package. `EnrichmentMiddleware` (from `httpauth`) reads
|
||||||
applies: `rbac` owns its context helpers.
|
them. This is the explicit integration point between provider-specific and
|
||||||
- **Composable three-middleware stack** (ADR-003): `AuthMiddleware`,
|
provider-agnostic middleware.
|
||||||
`EnrichmentMiddleware`, and `AuthzMiddleware` are separate functions. Each can be
|
- **Single responsibility**: This module only verifies Firebase tokens. Identity
|
||||||
applied independently; each returns 401 if its upstream dependency is missing from
|
enrichment, RBAC, and caching all live in `code.nochebuena.dev/go/httpauth`.
|
||||||
context.
|
|
||||||
|
|
||||||
## Patterns
|
## Patterns
|
||||||
|
|
||||||
**Full stack (most routes):**
|
**Full stack (most routes):**
|
||||||
|
|
||||||
```go
|
```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.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:
|
// 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))
|
Post("/orders", httputil.Handle(v, svc.CreateOrder))
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -62,7 +71,7 @@ identity, ok := rbac.FromContext(r.Context())
|
|||||||
if !ok {
|
if !ok {
|
||||||
// should not happen if EnrichmentMiddleware is in the chain
|
// 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):**
|
**Public paths (bypass token check):**
|
||||||
@@ -73,21 +82,15 @@ publicPaths := []string{"/health", "/ready", "/metrics/*"}
|
|||||||
r.Use(httpauth.AuthMiddleware(firebaseAuthClient, publicPaths))
|
r.Use(httpauth.AuthMiddleware(firebaseAuthClient, publicPaths))
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interfaces the application must implement:**
|
**Interfaces the application must implement (defined in httpauth, not here):**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// IdentityEnricher: called by EnrichmentMiddleware
|
// httpauthmw.IdentityEnricher: called by EnrichmentMiddleware
|
||||||
type MyEnricher struct{ db *sql.DB }
|
type MyEnricher struct{ db *sql.DB }
|
||||||
func (e *MyEnricher) Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) {
|
func (e *MyEnricher) Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) {
|
||||||
user, err := e.db.LookupUser(ctx, uid)
|
user, err := e.db.LookupUser(ctx, uid)
|
||||||
if err != nil { return rbac.Identity{}, err }
|
if err != nil { return rbac.Identity{}, err }
|
||||||
return rbac.NewIdentity(uid, user.DisplayName, user.Email).WithRole(user.Role), nil
|
return rbac.NewIdentity(uid, user.DisplayName, user.Email), 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
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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,
|
`AuthzMiddleware` reads `rbac.Identity` from context; if enrichment has not run,
|
||||||
it will return 401.
|
it will return 401.
|
||||||
- Do not read `firebase.Token` fields directly in business logic. The token UID and
|
- 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.
|
claims are stored in context by `httpauth.SetTokenData` under keys owned by the
|
||||||
Use `rbac.FromContext` to read the enriched identity.
|
`httpauth` package. Use `rbac.FromContext` to read the enriched identity.
|
||||||
- Do not store application-specific data in Firebase custom claims and bypass the
|
- Do not store application-specific data in Firebase custom claims and bypass the
|
||||||
`IdentityEnricher`. Claims are read by `EnrichmentMiddleware` and passed to
|
`IdentityEnricher`. Claims are read by `EnrichmentMiddleware` (from `httpauth`)
|
||||||
`Enrich`, but the enricher is the correct place to resolve application identity —
|
and passed to `Enrich` — the enricher is the correct place to resolve identity.
|
||||||
not raw Firebase claims spread across handlers.
|
|
||||||
- Do not import `httpauth-firebase` from service or domain layers. It is a transport
|
- Do not import `httpauth-firebase` from service or domain layers. It is a transport
|
||||||
package. Dependency should flow inward: handler → service, never service →
|
package. Dependency should flow inward: handler → service, never service →
|
||||||
handler/middleware.
|
handler/middleware.
|
||||||
|
- Do not redefine `IdentityEnricher`, `Cache`, or permission providers here. They
|
||||||
|
live in `code.nochebuena.dev/go/httpauth`.
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
- `compliance_test.go` verifies at compile time that `*mockVerifier` satisfies
|
- `compliance_test.go` verifies at compile time that `*mockVerifier` satisfies
|
||||||
`TokenVerifier`, `*mockEnricher` satisfies `IdentityEnricher`, and `*mockProvider`
|
`TokenVerifier`. Interface checks for `IdentityEnricher`, `Cache`, and permission
|
||||||
satisfies `PermissionProvider`.
|
providers live in `code.nochebuena.dev/go/httpauth`'s own compliance test.
|
||||||
- `AuthMiddleware` can be tested by injecting a `mockVerifier` that returns a
|
- `AuthMiddleware` can be tested by injecting a `mockVerifier` that returns a
|
||||||
controlled `*auth.Token`. No real Firebase project is needed.
|
controlled `*auth.Token`. No real Firebase project is needed.
|
||||||
- `EnrichmentMiddleware` can be tested by pre-populating the request context with
|
- `EnrichmentMiddleware` and `AuthzMiddleware` tests belong in the `httpauth` module,
|
||||||
`uid` via an upstream `AuthMiddleware` using a mock verifier.
|
not here.
|
||||||
- `AuthzMiddleware` can be tested by pre-populating the request context with an
|
|
||||||
`rbac.Identity` via `rbac.SetInContext` — no auth or enrichment middleware needed.
|
|
||||||
- Use `httptest.NewRecorder()` and `httptest.NewRequest()` for all HTTP-level tests.
|
- Use `httptest.NewRecorder()` and `httptest.NewRequest()` for all HTTP-level tests.
|
||||||
|
|||||||
30
auth.go
30
auth.go
@@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"firebase.google.com/go/v4/auth"
|
"firebase.google.com/go/v4/auth"
|
||||||
|
|
||||||
|
httpauthmw "code.nochebuena.dev/go/httpauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenVerifier abstracts Firebase JWT verification.
|
// TokenVerifier abstracts Firebase JWT verification.
|
||||||
@@ -16,30 +18,10 @@ type TokenVerifier interface {
|
|||||||
VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*auth.Token, error)
|
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
|
// AuthMiddleware verifies the Bearer token and injects uid + claims into the
|
||||||
// request context. Requests to publicPaths are skipped without token verification
|
// request context via httpauth.SetTokenData. Requests to publicPaths are skipped
|
||||||
// (wildcards supported via path.Match). Returns 401 on missing or invalid tokens.
|
// 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 {
|
func AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -63,7 +45,7 @@ func AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Hand
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := setTokenData(r.Context(), decoded.UID, decoded.Claims)
|
ctx := httpauthmw.SetTokenData(r.Context(), decoded.UID, decoded.Claims)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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"
|
"firebase.google.com/go/v4/auth"
|
||||||
|
|
||||||
httpauth "code.nochebuena.dev/go/httpauth-firebase"
|
httpauth "code.nochebuena.dev/go/httpauth-firebase"
|
||||||
"code.nochebuena.dev/go/rbac"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockVerifier struct{}
|
type mockVerifier struct{}
|
||||||
@@ -15,19 +14,5 @@ func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockEnricher struct{}
|
// Compile-time interface satisfaction check.
|
||||||
|
|
||||||
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.TokenVerifier = (*mockVerifier)(nil)
|
||||||
var _ httpauth.IdentityEnricher = (*mockEnricher)(nil)
|
|
||||||
var _ httpauth.PermissionProvider = (*mockProvider)(nil)
|
|
||||||
|
|||||||
21
doc.go
21
doc.go
@@ -1,17 +1,18 @@
|
|||||||
// Package httpauth provides Firebase-backed HTTP middleware for authentication,
|
// Package httpauth provides Firebase-backed HTTP authentication middleware.
|
||||||
// identity enrichment, and role-based access control.
|
//
|
||||||
|
// 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:
|
// Typical middleware chain:
|
||||||
//
|
//
|
||||||
|
// import httpauthmw "code.nochebuena.dev/go/httpauth"
|
||||||
|
//
|
||||||
// r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths))
|
// r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths))
|
||||||
// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID")))
|
// r.Use(httpauthmw.EnrichmentMiddleware(userEnricher, httpauthmw.WithTenantHeader("X-Tenant-ID")))
|
||||||
// r.Use(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Read))
|
// r.With(httpauthmw.AuthzMiddleware(permProvider, "orders", rbac.Read)).Post("/orders", handler)
|
||||||
//
|
//
|
||||||
// AuthMiddleware verifies Firebase Bearer tokens and injects uid + claims into
|
// AuthMiddleware accepts a TokenVerifier interface, so it can be tested without
|
||||||
// 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
|
|
||||||
// a live Firebase connection.
|
// a live Firebase connection.
|
||||||
package httpauth
|
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
|
go 1.25
|
||||||
|
|
||||||
require (
|
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
|
firebase.google.com/go/v4 v4.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/compute v1.24.0 // indirect
|
cloud.google.com/go/compute v1.24.0 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 // 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/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.1 // indirect
|
github.com/go-logr/logr v1.4.1 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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 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 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
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 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o=
|
||||||
code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c=
|
code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c=
|
||||||
firebase.google.com/go/v4 v4.15.0 h1:k27M+cHbyN1YpBI2Cf4NSjeHnnYRB9ldXwpqA5KikN0=
|
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"
|
"testing"
|
||||||
|
|
||||||
"firebase.google.com/go/v4/auth"
|
"firebase.google.com/go/v4/auth"
|
||||||
|
|
||||||
"code.nochebuena.dev/go/rbac"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- mocks ---
|
// --- mocks ---
|
||||||
@@ -23,46 +21,17 @@ func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string)
|
|||||||
return m.token, m.err
|
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 {
|
func chain(mw func(http.Handler) http.Handler, h http.HandlerFunc) http.Handler {
|
||||||
return mw(h)
|
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 ---
|
// --- AuthMiddleware ---
|
||||||
|
|
||||||
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||||
mv := &mockVerifier{token: &auth.Token{UID: "uid123", Claims: map[string]any{"name": "Alice"}}}
|
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) {
|
h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) {
|
||||||
capturedUID, _ = getUID(r.Context())
|
reached = true
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
@@ -72,8 +41,8 @@ func TestAuthMiddleware_ValidToken(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("want 200, got %d", rec.Code)
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
if capturedUID != "uid123" {
|
if !reached {
|
||||||
t.Errorf("want uid123, got %q", capturedUID)
|
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)
|
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