2 Commits

Author SHA1 Message Date
34c5fa7ded fix(httpauth-firebase)!: rename package httpauthfirebase, bump httpauth and rbac to v1.0.0
Rename package from httpauth to httpauthfirebase to follow ecosystem convention
(repo name = package name, hyphens removed). Bump httpauth dependency from
v0.1.0 to v1.0.0 and rbac indirect dependency from v0.9.0 to v1.0.0.

BREAKING CHANGE: import path unchanged (code.nochebuena.dev/go/httpauth-firebase)
but package identifier changes from httpauth to httpauthfirebase — remove any
import alias previously used to disambiguate from code.nochebuena.dev/go/httpauth.
2026-05-07 23:46:59 -06:00
2c90fe22bf 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.
2026-05-07 21:57:01 -06:00
11 changed files with 90 additions and 365 deletions

1
.gitea/CODEOWNERS Normal file
View File

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

View File

@@ -5,6 +5,24 @@ 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/), 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). and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-05-08
### Changed
- Package renamed from `httpauth` to `httpauthfirebase` — follows ecosystem convention
(`repo name = package name`, hyphens removed); import path is unchanged
(`code.nochebuena.dev/go/httpauth-firebase`); remove any alias previously used to
disambiguate from `code.nochebuena.dev/go/httpauth`
- `EnrichmentMiddleware`, `AuthzMiddleware`, `IdentityEnricher`, `PermissionProvider`,
`WithTenantHeader`, and `EnrichOpt` removed; they now live in
`code.nochebuena.dev/go/httpauth` (provider-agnostic middleware layer)
- `AuthMiddleware` updated to call `httpauth.SetTokenData` — fulfills the integration
contract between provider-specific auth and generic middleware
- Dependency `code.nochebuena.dev/go/httpauth` bumped to v1.0.0
- Dependency `code.nochebuena.dev/go/rbac` bumped to v1.0.0 (indirect)
[1.0.0]: https://code.nochebuena.dev/go/httpauth-firebase/releases/tag/v1.0.0
## [0.9.0] - 2026-03-18 ## [0.9.0] - 2026-03-18
### Added ### Added

View File

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

32
auth.go
View File

@@ -1,4 +1,4 @@
package httpauth package httpauthfirebase
import ( import (
"context" "context"
@@ -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))
}) })
} }

View File

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

View File

@@ -1,12 +1,11 @@
package httpauth_test package httpauthfirebase_test
import ( import (
"context" "context"
"firebase.google.com/go/v4/auth" "firebase.google.com/go/v4/auth"
httpauth "code.nochebuena.dev/go/httpauth-firebase" httpauthfirebase "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.
var _ httpauthfirebase.TokenVerifier = (*mockVerifier)(nil)
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)

23
doc.go
View File

@@ -1,17 +1,18 @@
// Package httpauth provides Firebase-backed HTTP middleware for authentication, // Package httpauthfirebase 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:
// //
// r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths)) // import httpauthmw "code.nochebuena.dev/go/httpauth"
// r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID")))
// r.Use(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Read))
// //
// AuthMiddleware verifies Firebase Bearer tokens and injects uid + claims into // r.Use(httpauthfirebase.AuthMiddleware(firebaseClient, publicPaths))
// the request context. EnrichmentMiddleware reads those values, calls the // r.Use(httpauthmw.EnrichmentMiddleware(userEnricher, httpauthmw.WithTenantHeader("X-Tenant-ID")))
// app-provided IdentityEnricher, and stores the full rbac.Identity. AuthzMiddleware // r.With(httpauthmw.AuthzMiddleware(permProvider, "orders", rbac.Read)).Post("/orders", handler)
// 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. // a live Firebase connection.
package httpauth package httpauthfirebase

View File

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

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

6
go.sum
View File

@@ -3,8 +3,10 @@ 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/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o= code.nochebuena.dev/go/httpauth v1.0.0 h1:B2ypnlL7yQkePHC3EKo4LPgJbd5lWZ6RuA1o6sx84so=
code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= code.nochebuena.dev/go/httpauth v1.0.0/go.mod h1:rGaQDInGkavpk8nbiG7azPqcyYsrwo0+E+ZNocRW2MY=
code.nochebuena.dev/go/rbac v1.0.0 h1:FnsU1HU6vvwchKuZNxDa9RPIFeNwJi0vShWvHKABMws=
code.nochebuena.dev/go/rbac v1.0.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=
firebase.google.com/go/v4 v4.15.0/go.mod h1:S/4MJqVZn1robtXkHhpRUbwOC4gdYtgsiMMJQ4x+xmQ= firebase.google.com/go/v4 v4.15.0/go.mod h1:S/4MJqVZn1robtXkHhpRUbwOC4gdYtgsiMMJQ4x+xmQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

View File

@@ -1,4 +1,4 @@
package httpauth package httpauthfirebase
import ( import (
"context" "context"
@@ -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)
}
}