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.
208 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|