commit d1de096c72272ddb35387e1210d31b7570a84a3e Author: Rene Nochebuena Date: Thu Mar 19 13:44:45 2026 +0000 docs(httpauth-firebase): fix rbac tier reference from 1 to 0 rbac is a Tier 0 module (no micro-lib dependencies). The dependency line incorrectly cited it as Tier 1. The module's own tier (4) is unchanged — it remains the auth layer above the transport infrastructure. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "features": { + "ghcr.io/devcontainers-extra/features/claude-code:1": {} + }, + "forwardPorts": [], + "postCreateCommand": "go version", + "customizations": { + "vscode": { + "settings": { + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + "explorer.compactFolders": false, + "explorer.showEmptyFolders": true + }, + "extensions": [ + "golang.go", + "eamodio.golang-postfix-completion", + "quicktype.quicktype", + "usernamehw.errorlens" + ] + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e3b09d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# 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.9.0] - 2026-03-18 + +### Added + +- `TokenVerifier` interface — abstracts `*auth.Client` for unit-test mockability; `*auth.Client` satisfies it directly in production via its `VerifyIDTokenAndCheckRevoked` method +- `IdentityEnricher` interface — application-implemented; receives `uid string` and `claims map[string]any`, returns `rbac.Identity`; called by `EnrichmentMiddleware` on every request +- `PermissionProvider` interface — application-implemented; receives `uid` and `resource` string, returns `rbac.PermissionMask`; called by `AuthzMiddleware` on every 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 +- `AuthMiddleware(verifier TokenVerifier, publicPaths []string) func(http.Handler) http.Handler` — verifies `Authorization: Bearer ` via Firebase JWT verification and injects the verified `uid` and raw claims into the request context under unexported typed keys; paths matching any pattern in `publicPaths` bypass token verification (glob patterns via `path.Match`, `*` wildcard supported); returns 401 on missing or invalid tokens +- `EnrichmentMiddleware(enricher IdentityEnricher, opts ...EnrichOpt) func(http.Handler) http.Handler` — reads the uid and claims stored by `AuthMiddleware`, calls `enricher.Enrich`, and stores the resulting `rbac.Identity` in context via `rbac.SetInContext`; returns 401 if `AuthMiddleware` has not run upstream; returns 500 if the enricher fails +- `AuthzMiddleware(provider PermissionProvider, resource string, required rbac.Permission) func(http.Handler) http.Handler` — reads `rbac.Identity` from context via `rbac.FromContext`, resolves the permission mask for the identity's UID on `resource`, 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 + +### Design Notes + +- The three middleware functions are intentionally separate so they can be applied at different scopes: `AuthMiddleware` at the root router, `EnrichmentMiddleware` on authenticated route groups, and `AuthzMiddleware` per-route or per-group with different resource and permission arguments +- The module is named `httpauth-firebase` rather than `httpauth` because it imports the Firebase SDK directly; other providers (`httpauth-auth0`, `httpauth-jwt`, etc.) are separate sibling modules that all converge on the same `rbac.Identity` output contract, which means downstream handlers and business logic never depend on a specific auth provider +- No logger parameter is accepted; errors are returned as plain-text HTTP responses, keeping the dependency surface to `rbac` and `firebase.google.com/go/v4` only + +[0.9.0]: https://code.nochebuena.dev/go/httpauth-firebase/releases/tag/v0.9.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..294c252 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# httpauth-firebase + +Firebase-backed HTTP middleware for authentication, identity enrichment, and +role-based access control. + +## Purpose + +`httpauth-firebase` provides three composable `net/http` middleware functions that +implement the full auth stack: JWT verification via Firebase, app-specific identity +enrichment via a caller-supplied `IdentityEnricher`, and permission enforcement via +`rbac`. The output contract is always `rbac.Identity` — downstream code and business +logic are decoupled from Firebase types entirely. + +## Tier & Dependencies + +**Tier:** 4 (transport auth layer; depends on Tier 0 `rbac` and external Firebase SDK) +**Module:** `code.nochebuena.dev/go/httpauth-firebase` +**Direct imports:** `code.nochebuena.dev/go/rbac`, `firebase.google.com/go/v4/auth` + +`httpauth-firebase` does not import `logz`, `httpmw`, or `httputil`. It has no +logger parameter — errors are returned as HTTP responses, not logged here. + +## 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. + +## Patterns + +**Full stack (most routes):** + +```go +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, []string{"/health", "/metrics/*"})) +r.Use(httpauth.EnrichmentMiddleware(userEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) + +// Per-route RBAC: +r.With(httpauth.AuthzMiddleware(permProvider, "orders", rbac.Write)). + Post("/orders", httputil.Handle(v, svc.CreateOrder)) +``` + +**Token-only (webhook verification):** + +```go +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, nil)) +// No enrichment, no authz — just verify the token is valid +``` + +**Reading identity in a handler:** + +```go +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) +``` + +**Public paths (bypass token check):** + +```go +// path.Match patterns — supports * wildcard +publicPaths := []string{"/health", "/ready", "/metrics/*"} +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, publicPaths)) +``` + +**Interfaces the application must implement:** + +```go +// 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 +} +``` + +## What to Avoid + +- Do not use `AuthMiddleware` alone and assume the request is fully authorised. + Token verification only confirms the token is valid; it does not enforce + application-level roles or permissions. +- 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 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. +- 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. +- 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. + +## Testing Notes + +- `compliance_test.go` verifies at compile time that `*mockVerifier` satisfies + `TokenVerifier`, `*mockEnricher` satisfies `IdentityEnricher`, and `*mockProvider` + satisfies `PermissionProvider`. +- `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. +- Use `httptest.NewRecorder()` and `httptest.NewRequest()` for all HTTP-level tests. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..562b7b8 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# httpauth-firebase + +Firebase-backed HTTP middleware for authentication, identity enrichment, and RBAC authorization. + +## Overview + +Three composable `func(http.Handler) http.Handler` middleware functions: + +| Middleware | Responsibility | +|---|---| +| `AuthMiddleware` | Verifies Firebase Bearer token; injects uid + claims into context | +| `EnrichmentMiddleware` | Calls app-provided `IdentityEnricher`; stores `rbac.Identity` in context | +| `AuthzMiddleware` | Resolves permission mask; gates request | + +All functions accept interfaces — testable without a live Firebase connection. + +## Installation + +``` +require code.nochebuena.dev/go/httpauth-firebase v0.1.0 +``` + +## Usage + +```go +r.Use(httpauth.AuthMiddleware(firebaseAuthClient, []string{"/health", "/public/*"})) +r.Use(httpauth.EnrichmentMiddleware(myUserEnricher, httpauth.WithTenantHeader("X-Tenant-ID"))) +r.Use(httpauth.AuthzMiddleware(myPermProvider, "orders", rbac.Read)) +``` + +## Interfaces + +### TokenVerifier + +```go +type TokenVerifier interface { + VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken string) (*auth.Token, error) +} +``` + +`*firebase/auth.Client` satisfies this directly. Swap in a mock for tests. + +### IdentityEnricher + +```go +type IdentityEnricher interface { + Enrich(ctx context.Context, uid string, claims map[string]any) (rbac.Identity, error) +} +``` + +Implement this in your application to load user data from your store and return an `rbac.Identity`. + +### PermissionProvider + +```go +type PermissionProvider interface { + ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) +} +``` + +Returns the permission bitmask for the user on a given resource. + +## Options + +| Option | Description | +|---|---| +| `WithTenantHeader(header)` | Reads `TenantID` from the named request header. If absent, `TenantID` remains `""`. | + +## Public paths + +`AuthMiddleware` skips token verification for requests matching any pattern in `publicPaths`. Patterns use `path.Match` semantics (e.g. `"/public/*"`). + +## HTTP status codes + +| Condition | Status | +|---|---| +| Missing or malformed `Authorization` header | 401 | +| Token verification failure | 401 | +| No `rbac.Identity` in context (AuthzMiddleware) | 401 | +| Missing uid in context (EnrichmentMiddleware) | 401 | +| Enricher error | 500 | +| Permission denied or provider error | 403 | diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..d8ce5ac --- /dev/null +++ b/auth.go @@ -0,0 +1,70 @@ +package httpauth + +import ( + "context" + "net/http" + "path" + "strings" + + "firebase.google.com/go/v4/auth" +) + +// TokenVerifier abstracts Firebase JWT verification. +// *auth.Client satisfies this interface directly — no adapter needed in production. +// Retained solely for unit-test mockability. +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. +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) { + for _, pattern := range publicPaths { + if matched, _ := path.Match(pattern, r.URL.Path); matched { + next.ServeHTTP(w, r) + return + } + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + token := strings.TrimPrefix(authHeader, "Bearer ") + + decoded, err := verifier.VerifyIDTokenAndCheckRevoked(r.Context(), token) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ctx := setTokenData(r.Context(), decoded.UID, decoded.Claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/authz.go b/authz.go new file mode 100644 index 0000000..5ffb788 --- /dev/null +++ b/authz.go @@ -0,0 +1,37 @@ +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) + }) + } +} diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..86fc6eb --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,33 @@ +package httpauth_test + +import ( + "context" + + "firebase.google.com/go/v4/auth" + + httpauth "code.nochebuena.dev/go/httpauth-firebase" + "code.nochebuena.dev/go/rbac" +) + +type mockVerifier struct{} + +func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string) (*auth.Token, error) { + 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) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..61e02f9 --- /dev/null +++ b/doc.go @@ -0,0 +1,17 @@ +// Package httpauth provides Firebase-backed HTTP middleware for authentication, +// identity enrichment, and role-based access control. +// +// Typical middleware chain: +// +// r.Use(httpauth.AuthMiddleware(firebaseClient, publicPaths)) +// 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 +// 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. +package httpauth diff --git a/docs/adr/ADR-001-provider-specific-naming.md b/docs/adr/ADR-001-provider-specific-naming.md new file mode 100644 index 0000000..935787a --- /dev/null +++ b/docs/adr/ADR-001-provider-specific-naming.md @@ -0,0 +1,49 @@ +# ADR-001: Provider-Specific Module Naming (httpauth-firebase) + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +An auth middleware module originally named `httpauth` was designed to be +provider-agnostic: it defined a `TokenVerifier` interface and duck-typed +`firebase.Client` so the module never imported Firebase directly. The intent was +that any JWT provider could be supported by implementing `TokenVerifier`. + +Under global ADR-001, framework modules import their named dependencies directly +rather than duck-typing them away. An auth module for Firebase should import +`firebase.google.com/go/v4/auth` directly, since that is its explicit purpose. +This raises the naming question: if the module imports Firebase, should it still be +called `httpauth`? + +## Decision + +The module is named **`httpauth-firebase`**: + +- Go module path: `code.nochebuena.dev/go/httpauth-firebase` +- Directory: `micro-lib/httpauth-firebase/` +- Package name: `httpauth` (short import alias; the module path carries the provider name) + +A different auth provider would live in `httpauth-auth0`, `httpauth-jwks`, etc. +All `httpauth-*` modules converge on the same output contract: `rbac.Identity` stored +in context via `rbac.SetInContext`. + +The `TokenVerifier` interface is retained **solely for unit-test mockability** — +not for provider abstraction. In production, `*auth.Client` from +`firebase.google.com/go/v4/auth` satisfies `TokenVerifier` directly without any +adapter. + +## Consequences + +- The module name is honest: scanning the module list, a developer immediately knows + this is Firebase-specific and not a generic auth abstraction. +- Other auth providers are accommodated by creating sibling modules + (`httpauth-auth0`, etc.) with identical output contracts, not by implementing an + interface in this module. +- The `TokenVerifier` interface remains exported so test code outside the package can + implement it (`mockVerifier` in `compliance_test.go`). Its presence does not imply + that swapping providers at runtime is a supported pattern. +- Applications that switch from Firebase to another provider replace the module + import; the rest of the auth stack (`EnrichmentMiddleware`, `AuthzMiddleware`) and + all business logic remain unchanged because they depend on `rbac.Identity`, not on + Firebase types. diff --git a/docs/adr/ADR-002-rbac-identity-output-contract.md b/docs/adr/ADR-002-rbac-identity-output-contract.md new file mode 100644 index 0000000..b3837b1 --- /dev/null +++ b/docs/adr/ADR-002-rbac-identity-output-contract.md @@ -0,0 +1,55 @@ +# ADR-002: rbac.Identity as the Output Contract + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +`AuthMiddleware` verifies a Firebase JWT and extracts a UID and claims map from the +decoded token. Downstream code needs a richer identity: application-specific role, +tenant, display name, email. Several design options exist: + +1. Expose Firebase token fields (`auth.Token`) directly in context — ties all + downstream code to Firebase types. +2. Define a custom identity struct in `httpauth-firebase` — decouples from Firebase + but creates a module-specific contract that other `httpauth-*` modules cannot share. +3. Use `rbac.Identity` as the shared identity type — all `httpauth-*` modules + produce the same type; downstream code and `AuthzMiddleware` depend on `rbac` + only, not on any Firebase types. + +## Decision + +`EnrichmentMiddleware` calls `rbac.SetInContext(ctx, identity)` to store the +enriched identity. `AuthzMiddleware` reads it with `rbac.FromContext(ctx)`. +Downstream business logic and service handlers use `rbac.FromContext` directly — +they never import `httpauth-firebase`. + +The flow is: + +1. `AuthMiddleware` (this module): verifies Firebase JWT → stores `uid` + `claims` + in context under unexported keys local to this package. +2. `EnrichmentMiddleware` (this module): reads `uid` + `claims` → calls + `IdentityEnricher.Enrich` → stores `rbac.Identity` via `rbac.SetInContext`. +3. `AuthzMiddleware` (this module): reads `rbac.Identity` via `rbac.FromContext` → + calls `PermissionProvider.ResolveMask` → allows or rejects. + +The intermediate `uid` + `claims` context values are stored under unexported typed +keys (`ctxUIDKey{}`, `ctxClaimsKey{}`). They are internal to `httpauth-firebase` +and not part of the public API. + +## Consequences + +- Business logic and service layers only need to import `rbac` to read the caller's + identity. They have no knowledge of Firebase, JWTs, or token claims. +- Switching from Firebase to another provider (e.g. Auth0) requires replacing the + `AuthMiddleware` module. `EnrichmentMiddleware`, `AuthzMiddleware`, and all + downstream code remain unchanged because they operate on `rbac.Identity`. +- `IdentityEnricher` is the application's extension point: it receives the Firebase + UID and raw claims and returns a fully populated `rbac.Identity` with role and + tenant. This is the only place where app-specific user store queries should occur. +- `rbac.Identity.WithTenant(tenantID)` is called in `EnrichmentMiddleware` if a + tenant header is configured. The base identity from `Enrich` is immutable; a new + value is returned. +- Global ADR-003 (context helpers live with data owners) is applied here: `rbac` + owns `SetInContext` and `FromContext` because `rbac.Identity` is a RBAC concern, + not an auth transport concern. diff --git a/docs/adr/ADR-003-composable-middleware-stack.md b/docs/adr/ADR-003-composable-middleware-stack.md new file mode 100644 index 0000000..1a161f0 --- /dev/null +++ b/docs/adr/ADR-003-composable-middleware-stack.md @@ -0,0 +1,60 @@ +# ADR-003: Composable Three-Middleware Auth Stack + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +HTTP authentication and authorisation involve at least three distinct concerns: + +1. **Token verification**: Is this JWT valid and non-revoked? Who issued it? +2. **Identity enrichment**: Given the verified UID, what role and tenant does this + user have in the application? +3. **Permission enforcement**: Does this identity have the required permission on + this resource? + +A single "auth middleware" that handles all three concerns is a common pattern, but +it creates problems: + +- Enrichment requires a user-store lookup. Not every route needs enrichment (e.g. + public endpoints that only verify the token is structurally valid). +- Permission enforcement is per-resource and per-route. A single monolithic + middleware cannot vary by route without routing logic inside it. +- Testing each concern in isolation becomes difficult when they are combined. + +## Decision + +Three separate middleware functions are provided: + +- **`AuthMiddleware(verifier, publicPaths)`**: Verifies the Bearer token using + Firebase. Stores `uid` and `claims` in context. Returns 401 on missing or invalid + token. Routes matching `publicPaths` (glob patterns) are bypassed entirely. +- **`EnrichmentMiddleware(enricher, opts...)`**: Reads `uid` + `claims` from context. + Calls `IdentityEnricher.Enrich` (app-provided). Stores `rbac.Identity` in context. + Returns 401 if no UID is present (i.e. `AuthMiddleware` was not in the chain). + Optionally reads a tenant ID from a configurable request header. +- **`AuthzMiddleware(provider, resource, required)`**: Reads `rbac.Identity` from + context. Calls `PermissionProvider.ResolveMask` to get the permission mask for the + identity's UID on the named resource. Returns 401 if no identity is present, 403 + if the mask does not include the required permission. + +Each function returns a `func(http.Handler) http.Handler`, composable with any +standard middleware stack. + +## Consequences + +- Each middleware can be tested in isolation. `AuthMiddleware` tests do not need a + real user store; `EnrichmentMiddleware` tests do not need a Firebase client; + `AuthzMiddleware` tests do not need either. +- Routes can apply only the subset they need. A webhook endpoint that only verifies + token validity can use `AuthMiddleware` alone. A read-only endpoint can use + `AuthMiddleware` + `EnrichmentMiddleware` without `AuthzMiddleware`. +- The order constraint is enforced by dependency: `EnrichmentMiddleware` depends on + values written by `AuthMiddleware`; `AuthzMiddleware` depends on values written by + `EnrichmentMiddleware`. Incorrect ordering produces 401 (missing UID or identity) + rather than a silent bug. +- `publicPaths` in `AuthMiddleware` uses `path.Match` for glob matching. Patterns + like `/health`, `/metrics/*` can be listed to skip token verification entirely. +- `IdentityEnricher` and `PermissionProvider` are interfaces injected at + construction time, making the middleware fully testable without a real Firebase + connection or database. diff --git a/enrichment.go b/enrichment.go new file mode 100644 index 0000000..4349d88 --- /dev/null +++ b/enrichment.go @@ -0,0 +1,66 @@ +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)) + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eb79b1e --- /dev/null +++ b/go.mod @@ -0,0 +1,39 @@ +module code.nochebuena.dev/go/httpauth-firebase + +go 1.25 + +require ( + code.nochebuena.dev/go/rbac v0.9.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 + 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 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.18.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/api v0.170.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/appengine/v2 v2.0.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a90996 --- /dev/null +++ b/go.sum @@ -0,0 +1,183 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +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/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= +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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48= +google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk= +google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 h1:9IZDv+/GcI6u+a4jRFRLxQs0RUCfavGfoOgEW6jpkI0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/httpauth_test.go b/httpauth_test.go new file mode 100644 index 0000000..efde99d --- /dev/null +++ b/httpauth_test.go @@ -0,0 +1,261 @@ +package httpauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "firebase.google.com/go/v4/auth" + + "code.nochebuena.dev/go/rbac" +) + +// --- mocks --- + +type mockVerifier struct { + token *auth.Token + err error +} + +func (m *mockVerifier) VerifyIDTokenAndCheckRevoked(_ context.Context, _ string) (*auth.Token, error) { + 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 + h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { + capturedUID, _ = getUID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/api", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } + if capturedUID != "uid123" { + t.Errorf("want uid123, got %q", capturedUID) + } +} + +func TestAuthMiddleware_InvalidToken(t *testing.T) { + mv := &mockVerifier{err: errors.New("token invalid")} + h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/api", nil) + req.Header.Set("Authorization", "Bearer bad-token") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +func TestAuthMiddleware_MissingHeader(t *testing.T) { + mv := &mockVerifier{} + h := chain(AuthMiddleware(mv, nil), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("want 401, got %d", rec.Code) + } +} + +func TestAuthMiddleware_PublicPath(t *testing.T) { + mv := &mockVerifier{err: errors.New("should not be called")} + h := chain(AuthMiddleware(mv, []string{"/health"}), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Errorf("want 200, got %d", rec.Code) + } +} + +func TestAuthMiddleware_PublicPathWildcard(t *testing.T) { + mv := &mockVerifier{err: errors.New("should not be called")} + h := chain(AuthMiddleware(mv, []string{"/public/*"}), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/public/resource", nil)) + if rec.Code != http.StatusOK { + 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) + } +}