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")
}
}