Files
projax/web/auth_test.go
mAi 840c1760c9 feat(auth): federate with mgmt.msbls.de via Supabase cookies
projax was deployed publicly through Dokploy/Traefik with a Let's
Encrypt cert; the earlier "Tailscale-only" claim was never true. Gate
every request at the application layer using the same Supabase JWT
cookie pair that mgmt.msbls.de issues, so projax inherits SSO without
running its own login.

Middleware (web/auth.go):
- GET <SUPABASE_URL>/auth/v1/user with the access_token cookie or a
  Bearer header. On 2xx → pass through.
- On expiry, swap the refresh_token via /auth/v1/token?grant_type=
  refresh_token and rotate both cookies (Domain=msbls.de, HttpOnly,
  Secure, SameSite=Lax, Path=/, Max-Age=1y). Cookie attributes match
  mgmt/auth.ts verbatim — refreshed sessions stay drop-in compatible
  with the rest of the .msbls.de fleet.
- Anything still invalid → 302 to <PROJAX_LOGIN_URL>?redirectTo=
  <original-absolute-url>. mgmt's safeRedirect() rejects absolute URLs
  and falls back to /, so after login the user lands on mgmt; manual
  click back to projax then succeeds with the fresh cookie. UX is
  rough but functional; broadening mgmt's safeRedirect is parked for a
  separate PR.
- /healthz remains ungated so Dokploy/Traefik probes don't hit the
  redirect.

main.go: enable the middleware only when SUPABASE_URL is set; require
SUPABASE_ANON_KEY when it is (refuse to start otherwise). New env
overrides: PROJAX_LOGIN_URL (default https://mgmt.msbls.de/login),
PROJAX_COOKIE_DOMAIN (default msbls.de). Local dev with no env stays
fully anonymous.

Tests (7 cases, no DB needed): stub Supabase via httptest covers
healthz-open, anonymous-redirect, bad-cookie-redirect, good-cookie
pass-through, Bearer-pass-through, stale-but-refreshable rotation
(verifies cookie Domain/HttpOnly/Secure/SameSite), final fail
redirect.

DB-backed integration tests now honour PROJAX_SKIP_MIGRATE=1 so they
don't deadlock against the live container's auto-migrate during a
deploy window.

README + dokploy.yaml: kill the Tailscale-only claim, document the
federated-auth trust model and the new SUPABASE_* env contract.
2026-05-15 14:58:43 +02:00

208 lines
6.5 KiB
Go

package web
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newFakeSupabase spins up a tiny stub that mimics /auth/v1/user and
// /auth/v1/token?grant_type=refresh_token. The stub honours simple in-memory
// token validity rules so middleware paths can be exercised without a real DB.
type fakeSupabase struct {
*httptest.Server
ValidAccess string
ValidRefresh string
NewAccess string
NewRefresh string
}
func newFakeSupabase(t *testing.T) *fakeSupabase {
t.Helper()
f := &fakeSupabase{
ValidAccess: "good-access",
ValidRefresh: "good-refresh",
NewAccess: "rotated-access",
NewRefresh: "rotated-refresh",
}
mux := http.NewServeMux()
mux.HandleFunc("/auth/v1/user", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth != "Bearer "+f.ValidAccess {
http.Error(w, `{"msg":"invalid token"}`, http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": "user-1", "email": "m@example"})
})
mux.HandleFunc("/auth/v1/token", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("grant_type") != "refresh_token" {
http.Error(w, "bad grant", http.StatusBadRequest)
return
}
var body struct {
RefreshToken string `json:"refresh_token"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.RefreshToken != f.ValidRefresh {
http.Error(w, `{"msg":"bad refresh"}`, http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": f.NewAccess,
"refresh_token": f.NewRefresh,
"user": map[string]string{"id": "user-1"},
})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Server.Close)
return f
}
func newGatedHandler(t *testing.T, supaURL, anonKey string) http.Handler {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "tree-page")
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "ok")
})
cfg := AuthConfig{
SupabaseURL: supaURL,
AnonKey: anonKey,
LoginURL: "https://mgmt.msbls.de/login",
CookieDomain: "msbls.de",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return authMiddleware(cfg, logger, mux)
}
func TestHealthzAlwaysOpen(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusOK {
t.Fatalf("healthz status %d", w.Result().StatusCode)
}
if strings.TrimSpace(w.Body.String()) != "ok" {
t.Fatalf("healthz body %q", w.Body.String())
}
}
func TestAnonymousRequestRedirectsToMgmt(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "https://projax.msbls.de/i/home", nil)
r.TLS = nil // httptest doesn't set TLS; loginRedirectURL falls back to http
r.Header.Set("X-Forwarded-Proto", "https")
r.Header.Set("X-Forwarded-Host", "projax.msbls.de")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusFound {
t.Fatalf("status %d, want 302", w.Result().StatusCode)
}
loc := w.Header().Get("Location")
if !strings.HasPrefix(loc, "https://mgmt.msbls.de/login?") {
t.Fatalf("Location = %q, want mgmt.msbls.de/login prefix", loc)
}
if !strings.Contains(loc, "redirectTo=") {
t.Fatalf("Location missing redirectTo: %q", loc)
}
if !strings.Contains(loc, "projax.msbls.de") {
t.Fatalf("Location missing host in redirectTo: %q", loc)
}
}
func TestInvalidAccessCookieRedirects(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusFound {
t.Fatalf("status %d, want 302", w.Result().StatusCode)
}
}
func TestValidAccessCookiePassesThrough(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "access_token", Value: supa.ValidAccess})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200", w.Result().StatusCode)
}
if strings.TrimSpace(w.Body.String()) != "tree-page" {
t.Fatalf("body = %q", w.Body.String())
}
}
func TestBearerHeaderPassesThrough(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+supa.ValidAccess)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200 via Bearer", w.Result().StatusCode)
}
}
func TestStaleAccessRefreshesAndPassesThrough(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
r.AddCookie(&http.Cookie{Name: "refresh_token", Value: supa.ValidRefresh})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200 after refresh", w.Result().StatusCode)
}
cookies := w.Result().Cookies()
gotAccess, gotRefresh := "", ""
for _, c := range cookies {
switch c.Name {
case "access_token":
gotAccess = c.Value
if c.Domain != "msbls.de" {
t.Errorf("access cookie Domain = %q, want msbls.de", c.Domain)
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
t.Errorf("access cookie flags wrong: httponly=%v secure=%v samesite=%v", c.HttpOnly, c.Secure, c.SameSite)
}
case "refresh_token":
gotRefresh = c.Value
}
}
if gotAccess != supa.NewAccess {
t.Errorf("rotated access cookie = %q, want %q", gotAccess, supa.NewAccess)
}
if gotRefresh != supa.NewRefresh {
t.Errorf("rotated refresh cookie = %q, want %q", gotRefresh, supa.NewRefresh)
}
}
func TestInvalidRefreshFinallyRedirects(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
r.AddCookie(&http.Cookie{Name: "refresh_token", Value: "stale-too"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusFound {
t.Fatalf("status %d, want 302", w.Result().StatusCode)
}
}