feat(auth): audit-log rejections + 503 on Dex outage (gitea-mcp#9, #6)
BearerMiddleware now emits a structured slog line on every rejection with the reason (no_token / static_token_mismatch / jwt_invalid / jwt_dex_unavailable), client IP (X-Forwarded-For aware), presented token type (jwt/opaque), and a truncated SHA-256 fingerprint — never the raw token, so no secret material reaches stdout/log aggregation. Validate now tags a JWKS/Dex fetch failure with the exported ErrUnavailable sentinel (distinct from a present-but-invalid token). BearerMiddleware maps it to HTTP 503 with `WWW-Authenticate: Bearer error="temporarily_unavailable"`, so a transient Dex outage is distinguishable from a bad token instead of a silent generic 401. No signature change — logging goes through slog.Default(); existing consumers are unaffected until they set a default logger. Behavior reaches a consumer only when it bumps the chassis version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,78 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// unavailableValidator returns a validator whose JWKS cache cannot be fetched,
|
||||
// so Validate fails with ErrUnavailable — simulating a Dex/JWKS outage.
|
||||
func unavailableValidator(t *testing.T) *JWTValidator {
|
||||
t.Helper()
|
||||
return &JWTValidator{
|
||||
issuer: "https://dex.example",
|
||||
jwksURI: "http://127.0.0.1:0/jwks", // unregistered in cache → Get fails
|
||||
cache: jwk.NewCache(context.Background()),
|
||||
audience: "",
|
||||
}
|
||||
}
|
||||
|
||||
// #6: a JWKS/Dex outage at validation time is ErrUnavailable, distinct from a
|
||||
// present-but-invalid token.
|
||||
func TestValidate_JWKSUnreachable_IsUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := unavailableValidator(t).Validate(context.Background(), "some.jwt.token")
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrUnavailable)
|
||||
}
|
||||
|
||||
// #6: when JWT validation can't complete because Dex is down, answer 503
|
||||
// temporarily_unavailable rather than a generic 401.
|
||||
func TestBearerMiddleware_DexDown_503(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := BearerMiddleware("static-tok", unavailableValidator(t), "gitea", "",
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("next must NOT be called when Dex is down")
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer aaa.bbb.ccc") // not the static token → JWT path
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.Contains(t, rec.Header().Get("WWW-Authenticate"), `error="temporarily_unavailable"`)
|
||||
}
|
||||
|
||||
// #9: every rejection is audit-logged with reason, remote, token type, and a
|
||||
// non-reversible token fingerprint — never the raw token.
|
||||
func TestBearerMiddleware_AuditLogsRejection(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
h := BearerMiddleware("expected-secret", nil, "gitea", "",
|
||||
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-secret-value")
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
logged := buf.String()
|
||||
assert.Contains(t, logged, "static_token_mismatch")
|
||||
assert.Contains(t, logged, "token_fp")
|
||||
assert.NotContains(t, logged, "wrong-secret-value", "raw token must never be logged")
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_StaticTokenWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user