auth-bundle-1 Phase 0: extract internal/auth/ from middleware package

Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home.

Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually.

Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser).

Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames.

Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested.

Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml.
This commit is contained in:
shankar0123
2026-05-09 15:51:31 +00:00
parent 71ebccb8ba
commit 99a012e3be
32 changed files with 397 additions and 283 deletions
+28
View File
@@ -0,0 +1,28 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
)
// NamedAPIKey represents a named API key with optional admin flag.
//
// Name is the canonical actor identity propagated through the request
// context (UserKey) and into the audit trail. Two NamedAPIKey rows
// MAY share a Name during a rotation overlap window per audit L-004
// (CWE-924); both keys validate to the same actor + admin flag so the
// per-user rate-limit bucket stays consistent during rotation.
type NamedAPIKey struct {
Name string
Key string
Admin bool
}
// HashAPIKey computes the SHA-256 hash of an API key for secure storage.
// We use SHA-256 rather than bcrypt because API keys are high-entropy
// random strings (not user-chosen passwords), so rainbow tables and
// brute-force attacks are not a practical concern.
func HashAPIKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}
+50
View File
@@ -0,0 +1,50 @@
// Package auth holds the certctl auth surface: API-key validation, the
// authenticated-actor context keys, and the helpers that consumers across
// the codebase use to read the actor identity (rate limiter, audit
// recorder, handler-level admin gates, GUI affordance hints).
//
// Bundle 1 / Phase 0 split this code out of internal/api/middleware so
// Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles +
// permissions + scoped grants) have a clean home that doesn't bloat the
// generic-middleware package. Phase 0 is a pure refactor; behaviour
// matches the pre-extract NewAuthWithNamedKeys / NewAuth surface
// byte-for-byte.
package auth
import "context"
// UserKey is the context key for storing the authenticated actor's
// canonical name. Populated by Middleware (a.k.a. NewAuthWithNamedKeys)
// from the matched NamedAPIKey.Name. Read by GetUser.
type UserKey struct{}
// AdminKey is the context key for storing the admin flag. Populated by
// Middleware from the matched NamedAPIKey.Admin. Read by IsAdmin.
//
// Bundle 1 keeps the boolean shape for backwards compatibility with the
// pre-RBAC handler gates. Phase 3 introduces RequirePermission and the
// boolean becomes informational only (admin role membership ↔ this flag).
type AdminKey struct{}
// GetUser extracts the authenticated user from context. Returns the name
// of the matched API key, or "" if the request was not authenticated
// (none mode, missing Bearer, or a misconfigured chain).
func GetUser(ctx context.Context) string {
user, ok := ctx.Value(UserKey{}).(string)
if !ok {
return ""
}
return user
}
// IsAdmin extracts the admin flag from context. Returns true only when
// the authenticated actor's NamedAPIKey carried Admin=true.
//
// Bundle 1 maintains the boolean for back-compat. Bundle 1 Phase 3
// introduces auth.RequirePermission as the load-bearing authorization
// gate; legacy IsAdmin callers (5 admin handlers tracked in M-008)
// migrate to RequirePermission in that phase.
func IsAdmin(ctx context.Context) bool {
admin, ok := ctx.Value(AdminKey{}).(bool)
return ok && admin
}
+141
View File
@@ -0,0 +1,141 @@
package auth
import (
"context"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"strings"
)
// AuthConfig holds configuration for the legacy NewAuth shim.
//
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
// removed because no JWT middleware ships with certctl (silent auth
// downgrade pre-G-1). The single source of truth for the allowed set
// lives at internal/config.AuthType / config.ValidAuthTypes(); prefer
// those constants over string literals when comparing.
//
// Bundle 2 will extend ValidAuthTypes() with "oidc"; Bundle 1 leaves
// the surface unchanged.
type AuthConfig struct {
Type string // "api-key" or "none" (see config.AuthType constants)
Secret string // The raw API key or comma-separated list of valid API keys
}
// NewAuthWithNamedKeys creates an authentication middleware that validates
// Bearer tokens against a set of named API keys. Each key carries a name
// (propagated as the actor via context) and an admin flag (consulted by
// authorization gates such as bulk revocation).
//
// When namedKeys is empty the returned middleware is a no-op pass-through,
// which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
// or more keys are provided, requests must include a matching Bearer token
// or they are rejected with 401.
//
// Bundle 1 Phase 3 extends Middleware with the RBAC primitive. This
// function continues to exist as the API-key validator; Phase 3 wraps it
// with the role lookup that populates the future ActorIDKey / RolesKey
// context values.
func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
if len(namedKeys) == 0 {
return func(next http.Handler) http.Handler {
return next
}
}
// Pre-compute hashes of all valid keys for constant-time comparison.
type keyEntry struct {
hash string
name string
admin bool
}
var entries []keyEntry
for _, nk := range namedKeys {
entries = append(entries, keyEntry{
hash: HashAPIKey(nk.Key),
name: nk.Name,
admin: nk.Admin,
})
}
// Warn if only one key is configured in production mode
if len(entries) == 1 {
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`)
http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
return
}
// Extract Bearer token
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized)
return
}
token := authHeader[7:]
tokenHash := HashAPIKey(token)
// Check against all valid keys using constant-time comparison
var matched *keyEntry
for i := range entries {
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 {
matched = &entries[i]
break
}
}
if matched == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return
}
// Store the authenticated identity and admin flag in context
ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// NewAuth is a legacy shim that converts a comma-separated Secret list into
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
// rather than the old hardcoded "api-key-user" so audit events carry
// meaningful identity even on the legacy path.
//
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
if cfg.Type == "none" {
return func(next http.Handler) http.Handler {
return next
}
}
var namedKeys []NamedAPIKey
idx := 0
for _, k := range strings.Split(cfg.Secret, ",") {
k = strings.TrimSpace(k)
if k == "" {
continue
}
namedKeys = append(namedKeys, NamedAPIKey{
Name: fmt.Sprintf("legacy-key-%d", idx),
Key: k,
Admin: false,
})
idx++
}
return NewAuthWithNamedKeys(namedKeys)
}
+189
View File
@@ -0,0 +1,189 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNewAuth_MultiKeyAcceptsBothKeys(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "key-one,key-two",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// First key should work
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req1.Header.Set("Authorization", "Bearer key-one")
rr1 := httptest.NewRecorder()
handler.ServeHTTP(rr1, req1)
if rr1.Code != http.StatusOK {
t.Errorf("expected 200 for first key, got %d", rr1.Code)
}
// Second key should work
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req2.Header.Set("Authorization", "Bearer key-two")
rr2 := httptest.NewRecorder()
handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("expected 200 for second key, got %d", rr2.Code)
}
}
func TestNewAuth_MultiKeyRejectsInvalidKey(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "key-one,key-two",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Invalid key should be rejected
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer wrong-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for invalid key, got %d", rr.Code)
}
}
func TestNewAuth_MultiKeyWithSpaces(t *testing.T) {
// Keys with leading/trailing spaces should be trimmed
cfg := AuthConfig{
Type: "api-key",
Secret: " key-one , key-two ",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer key-one")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for trimmed key, got %d", rr.Code)
}
}
func TestNewAuth_SingleKeyStillWorks(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "my-single-key",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer my-single-key")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for single key, got %d", rr.Code)
}
}
func TestNewAuth_NoneMode(t *testing.T) {
cfg := AuthConfig{
Type: "none",
Secret: "",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// No auth header needed in none mode
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 in none mode, got %d", rr.Code)
}
}
func TestNewAuth_MissingAuthHeader(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "test-key",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for missing auth, got %d", rr.Code)
}
}
func TestNewAuth_InvalidBearerFormat(t *testing.T) {
cfg := AuthConfig{
Type: "api-key",
Secret: "test-key",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for non-Bearer auth, got %d", rr.Code)
}
}
func TestNewAuth_RemovedKeyIsRejected(t *testing.T) {
// Simulate key rotation: only key-two is configured (key-one was removed)
cfg := AuthConfig{
Type: "api-key",
Secret: "key-two",
}
mw := NewAuth(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Old key should be rejected
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req.Header.Set("Authorization", "Bearer key-one")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401 for removed key, got %d", rr.Code)
}
// New key should work
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
req2.Header.Set("Authorization", "Bearer key-two")
rr2 := httptest.NewRecorder()
handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("expected 200 for current key, got %d", rr2.Code)
}
}
+97
View File
@@ -0,0 +1,97 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
)
// Audit L-004 (CWE-924) — auth-middleware side of the dual-key rotation
// contract. ParseNamedAPIKeys allows two entries to share a name during
// the overlap window; NewAuthWithNamedKeys must accept either bearer
// token and produce the same UserKey + Admin context value either way.
func TestL004_AuthMiddleware_BothKeysValidate(t *testing.T) {
mw := NewAuthWithNamedKeys([]NamedAPIKey{
{Name: "alice", Key: "OLDKEY", Admin: true},
{Name: "alice", Key: "NEWKEY", Admin: true},
})
makeReq := func(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/api/v1/anything", nil)
req.Header.Set("Authorization", "Bearer "+token)
return req
}
for _, tok := range []string{"OLDKEY", "NEWKEY"} {
t.Run("token="+tok, func(t *testing.T) {
rec := httptest.NewRecorder()
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := GetUser(r.Context()); got != "alice" {
t.Errorf("UserKey = %q, want alice (rotation must preserve identity across both keys)", got)
}
if !IsAdmin(r.Context()) {
t.Errorf("Admin flag lost — both rotation entries carry admin=true, context must reflect that")
}
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rec, makeReq(tok))
if rec.Code != http.StatusOK {
t.Fatalf("token %s should validate during rotation overlap; got %d", tok, rec.Code)
}
})
}
}
func TestL004_AuthMiddleware_PostRotationOldKeyRejected(t *testing.T) {
// Operator has completed the rotation: old key removed from
// CERTCTL_API_KEYS_NAMED, only new key remains. Old bearer must
// now fail.
mw := NewAuthWithNamedKeys([]NamedAPIKey{
{Name: "alice", Key: "NEWKEY", Admin: true},
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/anything", nil)
req.Header.Set("Authorization", "Bearer OLDKEY")
rec := httptest.NewRecorder()
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("OLDKEY post-rotation should be rejected; got %d", rec.Code)
}
}
func TestL004_AuthMiddleware_DualUserKeyedRateLimit(t *testing.T) {
// Bundle B's rate limiter keys on the UserKey. Both rotation
// entries must produce the SAME UserKey value so the per-user
// bucket stays consistent across the overlap window — otherwise
// a client rotating its key would get a fresh bucket and bypass
// the rate limit. Pin the invariant.
mw := NewAuthWithNamedKeys([]NamedAPIKey{
{Name: "alice", Key: "OLDKEY", Admin: false},
{Name: "alice", Key: "NEWKEY", Admin: false},
})
captured := []string{}
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = append(captured, GetUser(r.Context()))
w.WriteHeader(http.StatusOK)
}))
for _, tok := range []string{"OLDKEY", "NEWKEY"} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+tok)
handler.ServeHTTP(httptest.NewRecorder(), req)
}
if len(captured) != 2 {
t.Fatalf("expected 2 captured UserKey values, got %d", len(captured))
}
if captured[0] != captured[1] {
t.Errorf("UserKey diverged across rotation: OLDKEY=%q NEWKEY=%q — rate-limit bucket would split",
captured[0], captured[1])
}
}
+32
View File
@@ -0,0 +1,32 @@
package auth
import "context"
// WithActor builds a context with UserKey populated, mirroring what
// NewAuthWithNamedKeys produces for a real authenticated request. Used
// by handler / service / middleware tests so they don't construct the
// context manually with internal context-key types.
//
// Phase 0 ships UserKey + AdminKey only; Phase 3 of Bundle 1 introduces
// the RBAC context (ActorIDKey, ActorTypeKey, RolesKey) and this helper
// will be extended to populate those too. Until then, admin should be
// passed via WithAdmin (separate helper below) to mirror the matched-key
// flag.
func WithActor(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, UserKey{}, name)
}
// WithAdmin sets the AdminKey flag on the supplied context. Tests calling
// WithActor + WithAdmin together produce a context indistinguishable from
// what NewAuthWithNamedKeys produces for an admin-flagged NamedAPIKey.
func WithAdmin(ctx context.Context, admin bool) context.Context {
return context.WithValue(ctx, AdminKey{}, admin)
}
// WithActorAdmin is a convenience for the common "admin caller named X"
// pattern across handler tests.
func WithActorAdmin(ctx context.Context, name string, admin bool) context.Context {
ctx = WithActor(ctx, name)
ctx = WithAdmin(ctx, admin)
return ctx
}