@@ 0,0 1,537 @@
+package auth_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/lethe/internal/config"
+ "sourcecraft.dev/bigbes/lethe/internal/platform/observability"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// problem mirrors the apierror.Problem extension fields the tests assert on.
+// Decoded into a local type to avoid importing the unexported render plumbing.
+type problem struct {
+ Status int `json:"status"`
+ Code string `json:"code"`
+ Detail string `json:"detail"`
+}
+
+// captureHandler records the Identity it sees on the request context. It
+// always responds 200 so a non-200 in the test means the auth chain rejected
+// the request before reaching here.
+type captureHandler struct {
+ called bool
+ identity auth.Identity
+ hadID bool
+}
+
+func (c *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.called = true
+ c.identity, c.hadID = auth.IdentityFrom(r.Context())
+ w.WriteHeader(http.StatusOK)
+}
+
+// newAuthenticator builds an Authenticator with a working logger and the
+// supplied config + verifier, calls Init, and fails the test on Init error.
+func newAuthenticator(t *testing.T, cfg config.AuthConfig, v *auth.OIDCVerifier) *auth.Authenticator {
+ t.Helper()
+ logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
+ if err := logger.Init(context.Background()); err != nil {
+ t.Fatalf("logger.Init: %v", err)
+ }
+ a := &auth.Authenticator{Cfg: cfg, Log: logger, Verifier: v}
+ if err := a.Init(context.Background()); err != nil {
+ t.Fatalf("Authenticator.Init: %v", err)
+ }
+ return a
+}
+
+// mountAndServe wires the authenticator under a chi router on /api/v1, then
+// dispatches r against it. Returns the recorder and the capture handler so
+// the test can assert on both response and resolved identity.
+func mountAndServe(a *auth.Authenticator, r *http.Request) (*httptest.ResponseRecorder, *captureHandler) {
+ cap := &captureHandler{}
+ router := chi.NewRouter()
+ router.Route("/api/v1", func(r chi.Router) {
+ r.Use(a.Middleware)
+ r.Handle("/*", cap)
+ })
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, r)
+ return rec, cap
+}
+
+// --- Forward-auth path -----------------------------------------------------
+
+func TestForwardAuth_MissingHeader(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ rec, cap := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+ if cap.called {
+ t.Errorf("handler should not have been called")
+ }
+}
+
+func TestForwardAuth_HeaderNotInAllowlist(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Remote-User", "mallory")
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d; want 403", rec.Code)
+ }
+ if cap.called {
+ t.Errorf("handler should not have been called")
+ }
+}
+
+func TestForwardAuth_HeaderAllowed(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Remote-User", "alice")
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", rec.Code)
+ }
+ if !cap.called || !cap.hadID {
+ t.Fatalf("expected handler called with identity; called=%v hadID=%v", cap.called, cap.hadID)
+ }
+ if cap.identity.User != "alice" {
+ t.Errorf("identity.User = %q; want alice", cap.identity.User)
+ }
+}
+
+func TestForwardAuth_AllowlistCaseInsensitive(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"Alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Remote-User", "ALICE")
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", rec.Code)
+ }
+ if cap.identity.User != "alice" {
+ t.Errorf("identity.User = %q; want alice (lowercased)", cap.identity.User)
+ }
+}
+
+func TestForwardAuth_CustomHeaderName(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "X-Forwarded-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("X-Forwarded-User", "alice")
+
+ rec, _ := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", rec.Code)
+ }
+
+ // Default header name should NOT work when a custom one is configured.
+ req2 := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req2.Header.Set("Remote-User", "alice")
+ rec2, _ := mountAndServe(a, req2)
+ if rec2.Code != http.StatusUnauthorized {
+ t.Fatalf("Remote-User leak: status = %d; want 401", rec2.Code)
+ }
+}
+
+// --- OIDC path -------------------------------------------------------------
+
+func TestOIDC_MissingAuthorization(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+}
+
+func TestOIDC_MalformedBearer(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer not-a-jwt")
+
+ rec, _ := mountAndServe(a, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+}
+
+func TestOIDC_ValidAndAllowed(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ tok := o.signToken(t, map[string]any{
+ "aud": "lethe",
+ "sub": "alice-uuid",
+ "preferred_username": "alice",
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if cap.identity.User != "alice" {
+ t.Errorf("identity.User = %q; want alice", cap.identity.User)
+ }
+}
+
+func TestOIDC_ValidNotAllowed(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ tok := o.signToken(t, map[string]any{
+ "aud": "lethe",
+ "sub": "mallory-uuid",
+ "preferred_username": "mallory",
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+
+ rec, _ := mountAndServe(a, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d; want 403", rec.Code)
+ }
+}
+
+func TestOIDC_Expired(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ // Expired well past go-oidc's default skew.
+ tok := o.signToken(t, map[string]any{
+ "aud": "lethe",
+ "sub": "alice",
+ "preferred_username": "alice",
+ "iat": time.Now().Add(-2 * time.Hour).Unix(),
+ "exp": time.Now().Add(-1 * time.Hour).Unix(),
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+
+ rec, _ := mountAndServe(a, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+}
+
+func TestOIDC_WrongAudience(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ tok := o.signToken(t, map[string]any{
+ "aud": "someone-else",
+ "sub": "alice",
+ "preferred_username": "alice",
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+
+ rec, _ := mountAndServe(a, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+}
+
+func TestOIDC_PreferredUsernameClaimUsed(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ tok := o.signToken(t, map[string]any{
+ "aud": "lethe",
+ "sub": "u-9999",
+ "preferred_username": "alice",
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", rec.Code)
+ }
+ if cap.identity.User != "alice" {
+ t.Errorf("identity.User = %q; want alice (from preferred_username)", cap.identity.User)
+ }
+}
+
+func TestOIDC_FallsBackToSubWhenPreferredUsernameAbsent(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"u-9999"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ tok := o.signToken(t, map[string]any{
+ "aud": "lethe",
+ "sub": "u-9999",
+ // no preferred_username
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if cap.identity.User != "u-9999" {
+ t.Errorf("identity.User = %q; want u-9999 (from sub fallback)", cap.identity.User)
+ }
+}
+
+// --- Both enabled ---------------------------------------------------------
+
+func TestBoth_BearerWinsOverHeader(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice", "bob"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ tok := o.signToken(t, map[string]any{
+ "aud": "lethe",
+ "sub": "alice",
+ "preferred_username": "alice",
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+ req.Header.Set("Remote-User", "bob")
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", rec.Code)
+ }
+ if cap.identity.User != "alice" {
+ t.Errorf("identity.User = %q; want alice (from JWT, not header)", cap.identity.User)
+ }
+}
+
+func TestBoth_BearerInvalidWithHeader_FailsClosed(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Authorization", "Bearer not-a-jwt")
+ req.Header.Set("Remote-User", "alice")
+
+ rec, cap := mountAndServe(a, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401 (no fallback to header)", rec.Code)
+ }
+ if cap.called {
+ t.Errorf("handler should not have been called when bearer was invalid")
+ }
+}
+
+func TestBoth_AbsentBearerAndHeader(t *testing.T) {
+ o := newOIDCTestServer(t)
+ v := o.newVerifier(t, "lethe")
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ }, v)
+
+ rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+}
+
+// --- Admin flag -----------------------------------------------------------
+
+func TestAdmin_AdminUserHasIsAdminTrue(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice", "bob"},
+ Admins: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Remote-User", "alice")
+
+ _, cap := mountAndServe(a, req)
+ if !cap.identity.IsAdmin {
+ t.Errorf("IsAdmin = false; want true for alice")
+ }
+}
+
+func TestAdmin_NonAdminHasIsAdminFalse(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice", "bob"},
+ Admins: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Remote-User", "bob")
+
+ _, cap := mountAndServe(a, req)
+ if cap.identity.IsAdmin {
+ t.Errorf("IsAdmin = true; want false for bob")
+ }
+}
+
+// --- Problem JSON shape ---------------------------------------------------
+
+func TestProblemShape_401(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", rec.Code)
+ }
+ if got := rec.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("Content-Type = %q; want application/problem+json", got)
+ }
+ var p problem
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
+ t.Fatalf("decode body: %v; body=%s", err, rec.Body.String())
+ }
+ if p.Status != http.StatusUnauthorized {
+ t.Errorf("body.status = %d; want 401", p.Status)
+ }
+ if p.Code != "UNAUTHORIZED" {
+ t.Errorf("body.code = %q; want UNAUTHORIZED", p.Code)
+ }
+}
+
+func TestProblemShape_403(t *testing.T) {
+ a := newAuthenticator(t, config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
+ req.Header.Set("Remote-User", "mallory")
+
+ rec, _ := mountAndServe(a, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d; want 403", rec.Code)
+ }
+ if got := rec.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("Content-Type = %q; want application/problem+json", got)
+ }
+ var p problem
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
+ t.Fatalf("decode body: %v; body=%s", err, rec.Body.String())
+ }
+ if p.Status != http.StatusForbidden {
+ t.Errorf("body.status = %d; want 403", p.Status)
+ }
+ if p.Code != "FORBIDDEN" {
+ t.Errorf("body.code = %q; want FORBIDDEN", p.Code)
+ }
+}
+
+// --- Init invariants ------------------------------------------------------
+
+func TestInit_OIDCEnabledWithoutVerifier(t *testing.T) {
+ logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
+ if err := logger.Init(context.Background()); err != nil {
+ t.Fatalf("logger.Init: %v", err)
+ }
+ a := &auth.Authenticator{
+ Cfg: config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: "https://example.invalid", Audience: "lethe", UsernameClaim: "preferred_username"},
+ },
+ Log: logger,
+ Verifier: nil,
+ }
+ err := a.Init(context.Background())
+ if err == nil {
+ t.Fatalf("Init: expected error when OIDC enabled without verifier")
+ }
+}
+
+func TestInit_ForwardAuthEnabledWithoutHeader(t *testing.T) {
+ logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
+ if err := logger.Init(context.Background()); err != nil {
+ t.Fatalf("logger.Init: %v", err)
+ }
+ a := &auth.Authenticator{
+ Cfg: config.AuthConfig{
+ AllowedUsers: []string{"alice"},
+ ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: ""},
+ },
+ Log: logger,
+ }
+ err := a.Init(context.Background())
+ if err == nil {
+ t.Fatalf("Init: expected error when forward-auth enabled without user_header")
+ }
+}