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.
This commit is contained in:
211
web/auth.go
Normal file
211
web/auth.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthConfig federates projax with mgmt.msbls.de. Cookies set on the parent
|
||||
// `msbls.de` domain (no leading dot — matches mgmt/auth.ts default verbatim)
|
||||
// roundtrip across every msbls.de subdomain.
|
||||
type AuthConfig struct {
|
||||
SupabaseURL string // e.g. https://supa.flexsiebels.de
|
||||
AnonKey string
|
||||
LoginURL string // e.g. https://mgmt.msbls.de/login
|
||||
CookieDomain string // e.g. msbls.de (no leading dot; mgmt parity)
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
const (
|
||||
accessTokenCookie = "access_token"
|
||||
refreshTokenCookie = "refresh_token"
|
||||
cookieMaxAge = 365 * 24 * 60 * 60
|
||||
)
|
||||
|
||||
// supabaseUser is the minimum slice of GET /auth/v1/user we read.
|
||||
type supabaseUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// supabaseSession is what /auth/v1/token?grant_type=refresh_token returns.
|
||||
type supabaseSession struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
// authMiddleware gates every request except /healthz. The /healthz exemption
|
||||
// is required because Dokploy/Traefik probes must not be redirected to login.
|
||||
func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler {
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
access := tokenFromBearer(r)
|
||||
if access == "" {
|
||||
if c, err := r.Cookie(accessTokenCookie); err == nil {
|
||||
access = c.Value
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
// Try the access token first.
|
||||
if access != "" {
|
||||
if _, err := cfg.validateAccessToken(ctx, access); err == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to refresh-on-expiry.
|
||||
if c, err := r.Cookie(refreshTokenCookie); err == nil && c.Value != "" {
|
||||
sess, err := cfg.refreshSession(ctx, c.Value)
|
||||
if err == nil {
|
||||
cfg.setSessionCookies(w, sess)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
logger.Debug("auth: refresh failed", "err", err)
|
||||
}
|
||||
|
||||
// No valid session — redirect to mgmt login with the original URL.
|
||||
http.Redirect(w, r, cfg.loginRedirectURL(r), http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
// tokenFromBearer extracts a Bearer token from the Authorization header.
|
||||
// Empty string means no Bearer credential present.
|
||||
func tokenFromBearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
|
||||
// validateAccessToken calls GET <SUPABASE_URL>/auth/v1/user with the bearer.
|
||||
// Returns the user on success or an error on any non-2xx response.
|
||||
func (cfg AuthConfig) validateAccessToken(ctx context.Context, token string) (*supabaseUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.SupabaseURL+"/auth/v1/user", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("apikey", cfg.AnonKey)
|
||||
resp, err := cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("supabase /auth/v1/user: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var u supabaseUser
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.ID == "" {
|
||||
return nil, errors.New("supabase /auth/v1/user: empty user id")
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// refreshSession swaps a refresh token for a fresh access/refresh pair.
|
||||
func (cfg AuthConfig) refreshSession(ctx context.Context, refresh string) (*supabaseSession, error) {
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": refresh})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
cfg.SupabaseURL+"/auth/v1/token?grant_type=refresh_token",
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("apikey", cfg.AnonKey)
|
||||
resp, err := cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("supabase refresh: %d %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var s supabaseSession
|
||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.AccessToken == "" || s.RefreshToken == "" {
|
||||
return nil, errors.New("supabase refresh: empty token in response")
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// setSessionCookies writes refreshed access/refresh cookies. Domain, Secure,
|
||||
// HttpOnly and SameSite match mgmt.msbls.de exactly so the same browser session
|
||||
// continues to work after the round-trip.
|
||||
func (cfg AuthConfig) setSessionCookies(w http.ResponseWriter, s *supabaseSession) {
|
||||
for _, c := range []*http.Cookie{
|
||||
{
|
||||
Name: accessTokenCookie,
|
||||
Value: s.AccessToken,
|
||||
Domain: cfg.CookieDomain,
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
},
|
||||
{
|
||||
Name: refreshTokenCookie,
|
||||
Value: s.RefreshToken,
|
||||
Domain: cfg.CookieDomain,
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
},
|
||||
} {
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
}
|
||||
|
||||
// loginRedirectURL builds the mgmt.msbls.de/login?redirectTo=... target.
|
||||
// redirectTo carries the original public URL so the user lands back where they
|
||||
// started after login.
|
||||
func (cfg AuthConfig) loginRedirectURL(r *http.Request) string {
|
||||
scheme := "https"
|
||||
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
|
||||
// Behind Traefik we always run on https in production; this branch is
|
||||
// reached only in local dev. Preserve original scheme so the round-trip
|
||||
// works there too.
|
||||
scheme = "http"
|
||||
}
|
||||
host := r.Host
|
||||
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
|
||||
host = fwd
|
||||
}
|
||||
original := scheme + "://" + host + r.URL.RequestURI()
|
||||
q := url.Values{}
|
||||
q.Set("redirectTo", original)
|
||||
return cfg.LoginURL + "?" + q.Encode()
|
||||
}
|
||||
207
web/auth_test.go
Normal file
207
web/auth_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Server struct {
|
||||
Store *store.Store
|
||||
pages map[string]*template.Template
|
||||
Logger *slog.Logger
|
||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||
}
|
||||
|
||||
// New builds a Server. Each page is parsed alongside the layout into its own
|
||||
@@ -77,7 +78,11 @@ func (s *Server) Routes() http.Handler {
|
||||
static, _ := fs.Sub(staticFS, "static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
||||
|
||||
return logging(s.Logger, mux)
|
||||
var h http.Handler = mux
|
||||
if s.Auth != nil {
|
||||
h = authMiddleware(*s.Auth, s.Logger, h)
|
||||
}
|
||||
return logging(s.Logger, h)
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
@@ -43,9 +43,11 @@ func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
t.Skipf("DB unreachable: %v", err)
|
||||
}
|
||||
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
|
||||
if migrateErr != nil {
|
||||
t.Fatalf("migrate: %v", migrateErr)
|
||||
if os.Getenv("PROJAX_SKIP_MIGRATE") != "1" {
|
||||
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
|
||||
if migrateErr != nil {
|
||||
t.Fatalf("migrate: %v", migrateErr)
|
||||
}
|
||||
}
|
||||
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user