Files
certctl/internal/auth/session/middleware_test.go
T
shankar0123 3189f3cd71 auth-bundle-2 Phase 6: session middleware + CSRF token plumbing +
chained-auth combinator + AuthInfo OIDC providers extension + 2 CI
guards (Bundle-1-compat + Bundle-1-to-2-upgrade)

Phase 6 wires the Phase 4 session service + Phase 5 OIDC handlers into
the request path. Three middlewares + one combinator land in
internal/auth/session/middleware.go:

  1. SessionMiddleware reads `certctl_session` cookie, validates via
     SessionService.Validate, populates the legacy UserKey/AdminKey
     + Phase 3 RBAC context keys (ActorIDKey/ActorTypeKey/TenantIDKey)
     so downstream RequirePermission + audit-attribution see a
     consistent caller. Best-effort UpdateLastSeen keeps the idle-
     expiry sliding window fresh. CRITICALLY: never 401s on validate
     failure — defers to the next middleware so the chained-auth
     combinator can fall back to Bearer.

  2. CSRFMiddleware gates state-changing methods (POST/PUT/DELETE/
     PATCH) for session-authenticated requests. API-key actors are
     EXEMPT (no session row in context => CSRF doesn't apply; they're
     not browser-driven). Constant-time-compares SHA-256(X-CSRF-Token
     header) against the session row's stored hash via
     SessionService.ValidateCSRF. Mismatch returns 403.

  3. ChainAuthSessionThenBearer is the load-bearing chained-auth
     combinator: tries the session cookie first; on miss/invalid,
     falls back to the API-key Bearer middleware; if neither
     authenticates, 401. The composition uses bearerSkipIfAuthenticated
     so a request with both a valid session AND a valid Bearer uses
     the session (cookie wins per the Bundle 2 contract).

Middleware chain order in cmd/server/main.go (per Phase 6 spec):

  RequestID → Logging → Recovery → CORS → RateLimit → AUTH (chained:
  session → Bearer) → CSRF (state-changing only; API-key exempt) →
  Audit → Handler

The chained authMiddleware replaces the bare Bundle-1 bearerMiddleware
at the chain entry point; csrfMiddleware lands immediately after so
session-authenticated requests pass through CSRF before audit. Both
new middlewares are pass-throughs when sessionService is nil
(pre-Phase-4 builds).

AuthInfo extension (Category E): GET /api/v1/auth/info now returns the
list of configured OIDC providers (id + display_name + login_url
where login_url = `/auth/oidc/login?provider=<id>`) so the GUI Login
page renders the correct "Sign in with X" buttons. Endpoint stays
auth-exempt; the providers list is public configuration. Wired via
HealthHandler.OIDCProvidersResolver + a new OIDCProvidersListResolver
projection interface; the cmd/server adapter
oidcProvidersListAdapter projects the postgres OIDCProviderRepository
into the public-safe shape. Resolver lookups are best-effort: failures
fall back to the minimal payload rather than 500-ing the GUI's auth
probe. Nil resolver preserves the pre-Phase-6 minimal shape so test
fixtures + no-db deploys keep compiling.

Bypass list preserved (Category E): the existing public-route
allowlist in router.AuthExemptRouterRoutes is preserved by virtue of
those routes registering via direct r.mux.Handle (they bypass the
entire chain). The protocol-endpoint allowlist (ACME/SCEP/EST/OCSP/
CRL) bypasses via cmd/server/main.go::buildFinalHandler URL-prefix
dispatch — those routes never reach the auth middleware at all. Both
preservations are pinned by the Bundle-1 compat CI guard below.

Tests (internal/auth/session/middleware_test.go):

All 7 Phase 6 spec-mandated middleware-chain tests pass:

  1. Session cookie + correct CSRF → 200.
  2. Session cookie + wrong CSRF → 403.
  3. Bearer-only (no session) + no CSRF → 200 (API-key actors are
     CSRF-exempt by design).
  4. No cookie + no Bearer → 401.
  5. Expired cookie + valid Bearer → fall back to Bearer succeeds.
  6. Tampered cookie → 401 (no Bearer to fall back to).
  7. Bypass-list awareness — state-changing method, no auth, no
     session row → uniform 401 (NOT a CSRF 403; the CSRF check is
     gated on session-row presence and never fires for unauth
     requests).

Plus coverage-lift tests covering nil-service pass-through, safe-
methods bypass, SessionFromContext nil + populated, isStateChangingMethod
matrix, clientIPFromRequest variants (RemoteAddr / XFF first-hop /
XFF single / no-port), nil-bearer chain branches.

Coverage on internal/auth/session/middleware.go: 100% per-function
across the 9 entry points (SessionValidator interfaces +
NewSessionMiddleware + NewCSRFMiddleware + ChainAuthSessionThenBearer +
bearerSkipIfAuthenticated + SessionFromContext + isStateChangingMethod
+ clientIPFromRequest + lastIndexByte). Package coverage 94.9%.

Two new CI guards:

  scripts/ci-guards/bundle-1-compat-regression.sh — Bundle-1-only
  compat invariants. Static-source checks that protect the Bundle-1
  path since spinning up docker-compose + running the integration
  test suite is sandbox-infeasible:
    1. SessionMiddleware MUST defer-to-next on missing/invalid cookie.
    2. CSRFMiddleware MUST be pass-through on missing session row.
    3. cmd/server/main.go MUST wire ChainAuthSessionThenBearer.
    4. The 4 public OIDC routes MUST be in AuthExemptRouterRoutes.
    5. AuthInfo MUST guard on OIDCProvidersResolver != nil.

  scripts/ci-guards/bundle-1-to-2-upgrade-regression.sh — Bundle-1 →
  Bundle-2 upgrade invariants:
    1. Migrations 000034..000037 use CREATE TABLE IF NOT EXISTS.
    2. Migrations are wrapped in BEGIN; ... COMMIT;.
    3. NO DROP TABLE / ALTER ... DROP COLUMN against any of the 19
       protected Bundle-1 tables (api_keys, audit_events, certificates,
       certificate_versions, profiles, issuers, targets, agents, jobs,
       owners, teams, agent_groups, notifications, roles, permissions,
       role_permissions, actor_roles, tenants, approvals,
       intermediate_cas, issuance_approval_requests).
    4. 000037 INSERTs use ON CONFLICT DO NOTHING (idempotent re-apply).
    5. ChainAuthSessionThenBearer is wired (Bundle-1 Bearer keys
       continue to authenticate post-upgrade).
    6. Bootstrap handler is registered (fresh-deployment bootstrap
       still works).

Both guards are sandbox-feasible static analysis. When the operator
gets a Linux VM with docker-in-docker, promote both to real `docker
compose up` integration tests against a v2.1.0 baseline DB dump.

Verifications: gofmt clean, go vet ./internal/auth/... ./internal/api/...
./cmd/server/... clean, go test -short -count=1 -race green across
internal/auth/session (94.9% coverage), internal/api/handler,
internal/api/router, no regressions in Bundle 1 packages, both new
ci-guards green.
2026-05-10 06:22:25 +00:00

366 lines
12 KiB
Go

package session
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/certctl-io/certctl/internal/auth"
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
)
// =============================================================================
// In-memory stubs.
// =============================================================================
type stubSessionValidator struct {
sess *sessiondomain.Session
validateErr error
updateLastErr error
validateCalls int
updateCalls int
}
func (s *stubSessionValidator) Validate(_ context.Context, _ ValidateInput) (*sessiondomain.Session, error) {
s.validateCalls++
return s.sess, s.validateErr
}
func (s *stubSessionValidator) UpdateLastSeen(_ context.Context, _ string) error {
s.updateCalls++
return s.updateLastErr
}
func (s *stubSessionValidator) ValidateCSRF(headerValue string, sess *sessiondomain.Session) error {
if sess == nil {
return ErrCSRFMismatch
}
if headerValue == "" {
return ErrCSRFMissing
}
if hashCSRFToken(headerValue) != sess.CSRFTokenHash {
return ErrCSRFMismatch
}
return nil
}
// =============================================================================
// Helpers.
// =============================================================================
// mockBearer returns a Bearer middleware stub that authenticates any
// "Authorization: Bearer XYZ" header by setting the actor context.
// Mimics auth.NewAuthWithKeyStore's success-path behavior for tests
// without spinning up a real KeyStore.
func mockBearer(_ *testing.T) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader != "Bearer test-key" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, auth.UserKey{}, "api-key-actor")
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "api-key-actor")
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// markAuthenticated returns a tiny handler that 200s + writes the
// actor id from context so tests can inspect which auth path won.
func markAuthenticated() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
actorID, _ := r.Context().Value(auth.ActorIDKey{}).(string)
fmt.Fprintf(w, `{"actor_id":%q}`, actorID)
})
}
func newSession(t *testing.T, csrfPlaintext string) *sessiondomain.Session {
t.Helper()
now := time.Now().UTC()
return &sessiondomain.Session{
ID: "ses-test",
ActorID: "u-alice",
ActorType: "User",
SigningKeyID: "sk-test",
CSRFTokenHash: hashCSRFToken(csrfPlaintext),
IdleExpiresAt: now.Add(time.Hour),
AbsoluteExpiresAt: now.Add(8 * time.Hour),
CreatedAt: now,
LastSeenAt: now,
TenantID: "t-default",
}
}
// =============================================================================
// 7 Phase 6 spec-mandated middleware-chain tests.
// =============================================================================
// #1: Session cookie + correct CSRF -> succeeds.
func TestPhase6_SessionPlusCorrectCSRF_Succeeds(t *testing.T) {
csrf := "the-csrf-token-plaintext"
stub := &stubSessionValidator{sess: newSession(t, csrf)}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil)
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-test.sk-test.mac"})
req.Header.Set("X-CSRF-Token", csrf)
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String())
}
if !strContains(w.Body.String(), "u-alice") {
t.Errorf("body missing actor id; got %q", w.Body.String())
}
}
// #2: Session cookie + WRONG CSRF -> 403.
func TestPhase6_SessionPlusWrongCSRF_403(t *testing.T) {
stub := &stubSessionValidator{sess: newSession(t, "real-csrf")}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil)
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-test.sk-test.mac"})
req.Header.Set("X-CSRF-Token", "wrong-csrf")
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d; want 403", w.Code)
}
}
// #3: Bearer-only (no session) + no CSRF -> succeeds (API-key actors are CSRF-exempt).
func TestPhase6_BearerOnly_NoCSRF_Succeeds(t *testing.T) {
stub := &stubSessionValidator{validateErr: errors.New("no cookie")}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil)
req.Header.Set("Authorization", "Bearer test-key")
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String())
}
if !strContains(w.Body.String(), "api-key-actor") {
t.Errorf("body missing api-key actor id; got %q", w.Body.String())
}
}
// #4: No cookie + no Bearer -> 401.
func TestPhase6_NeitherCookieNorBearer_401(t *testing.T) {
stub := &stubSessionValidator{}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil)
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d; want 401; body=%q", w.Code, w.Body.String())
}
}
// #5: Expired cookie + valid Bearer -> falls back to Bearer, succeeds.
func TestPhase6_ExpiredCookieValidBearer_FallsBackToBearer(t *testing.T) {
stub := &stubSessionValidator{validateErr: ErrSessionExpiredAbsolute}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil)
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-expired.sk-x.mac"})
req.Header.Set("Authorization", "Bearer test-key")
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200; body=%q", w.Code, w.Body.String())
}
if !strContains(w.Body.String(), "api-key-actor") {
t.Errorf("expected Bearer fallback to win; body=%q", w.Body.String())
}
}
// #6: Tampered cookie -> 401 (no Bearer to fall back to).
func TestPhase6_TamperedCookie_401(t *testing.T) {
stub := &stubSessionValidator{validateErr: ErrSessionInvalidCookie}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil)
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses-x.sk-x.tampered"})
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d; want 401", w.Code)
}
}
// #7: Bypass-list awareness — the protocol-endpoint allowlist is
// enforced by the dispatch layer (cmd/server/main.go::buildFinalHandler)
// and the public-route allowlist by direct r.mux.Handle in router.go;
// neither reaches the auth chain. Pin the contract by asserting that
// the chained-auth combinator's behavior on a request with no auth +
// a state-changing method is uniformly 401, NOT a CSRF 403 — i.e., the
// CSRF check is gated on session-row presence and never fires for
// unauthenticated requests.
func TestPhase6_StateChangingMethod_Unauthenticated_Returns401NotCSRF403(t *testing.T) {
stub := &stubSessionValidator{}
chain := buildPhase6Chain(stub, stub)
req := httptest.NewRequest(http.MethodPost, "/api/v1/whatever", nil)
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d; want 401 (not 403); body=%q", w.Code, w.Body.String())
}
}
// =============================================================================
// Coverage-lift tests.
// =============================================================================
func TestSessionMiddleware_NilService_PassThrough(t *testing.T) {
mw := NewSessionMiddleware(nil)
handler := mw(markAuthenticated())
req := httptest.NewRequest(http.MethodGet, "/x", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("nil service should pass through; got %d", w.Code)
}
}
func TestCSRFMiddleware_NilService_PassThrough(t *testing.T) {
mw := NewCSRFMiddleware(nil)
handler := mw(markAuthenticated())
req := httptest.NewRequest(http.MethodPost, "/x", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("nil service should pass through; got %d", w.Code)
}
}
func TestCSRFMiddleware_SafeMethodsBypass(t *testing.T) {
stub := &stubSessionValidator{sess: newSession(t, "csrf")}
mw := NewCSRFMiddleware(stub)
handler := mw(markAuthenticated())
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace} {
req := httptest.NewRequest(method, "/x", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("safe method %s blocked by CSRF middleware; status=%d", method, w.Code)
}
}
}
func TestSessionFromContext_NilMissing(t *testing.T) {
if s := SessionFromContext(context.Background()); s != nil {
t.Errorf("expected nil; got %v", s)
}
}
func TestSessionFromContext_PopulatedReturnsSession(t *testing.T) {
sess := newSession(t, "csrf")
ctx := context.WithValue(context.Background(), sessionContextKey{}, sess)
if s := SessionFromContext(ctx); s != sess {
t.Errorf("expected returned session pointer to match; got %v", s)
}
}
func TestIsStateChangingMethod(t *testing.T) {
for _, tc := range []struct {
method string
want bool
}{
{http.MethodGet, false},
{http.MethodHead, false},
{http.MethodOptions, false},
{http.MethodTrace, false},
{http.MethodPost, true},
{http.MethodPut, true},
{http.MethodDelete, true},
{http.MethodPatch, true},
} {
if got := isStateChangingMethod(tc.method); got != tc.want {
t.Errorf("isStateChangingMethod(%s) = %v; want %v", tc.method, got, tc.want)
}
}
}
func TestClientIPFromRequest_Variants(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "1.2.3.4:5555"
if ip := clientIPFromRequest(r); ip != "1.2.3.4" {
t.Errorf("RemoteAddr: got %q; want 1.2.3.4", ip)
}
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
if ip := clientIPFromRequest(r); ip != "10.0.0.1" {
t.Errorf("XFF first hop: got %q; want 10.0.0.1", ip)
}
r.Header.Set("X-Forwarded-For", "10.0.0.99")
if ip := clientIPFromRequest(r); ip != "10.0.0.99" {
t.Errorf("XFF single: got %q; want 10.0.0.99", ip)
}
r2 := httptest.NewRequest(http.MethodGet, "/", nil)
r2.RemoteAddr = "no-port"
if ip := clientIPFromRequest(r2); ip != "no-port" {
t.Errorf("no-port RemoteAddr: got %q; want no-port", ip)
}
}
func TestChainAuthSessionThenBearer_NilBearer_Session401Path(t *testing.T) {
stub := &stubSessionValidator{validateErr: ErrSessionInvalidCookie}
chain := ChainAuthSessionThenBearer(NewSessionMiddleware(stub), nil)(markAuthenticated())
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses.sk.bad"})
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d; want 401", w.Code)
}
}
func TestChainAuthSessionThenBearer_NilBearer_SessionAuthSucceeds(t *testing.T) {
stub := &stubSessionValidator{sess: newSession(t, "csrf")}
chain := ChainAuthSessionThenBearer(NewSessionMiddleware(stub), nil)(markAuthenticated())
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: sessiondomain.PostLoginCookieName, Value: "v1.ses.sk.mac"})
w := httptest.NewRecorder()
chain.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d; want 200", w.Code)
}
}
// =============================================================================
// Helpers.
// =============================================================================
func buildPhase6Chain(svcSession SessionValidator, svcCSRF CSRFValidator) http.Handler {
auth := ChainAuthSessionThenBearer(NewSessionMiddleware(svcSession), mockBearer(nil))
csrf := NewCSRFMiddleware(svcCSRF)
return auth(csrf(markAuthenticated()))
}
func strContains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}