Merge branch 'mai/knuth/auth-federation'

Federate auth with mgmt.msbls.de via the shared Supabase JWT cookie
pair. /healthz stays anonymous for probes.
This commit is contained in:
mAi
2026-05-15 14:59:21 +02:00
8 changed files with 481 additions and 12 deletions

View File

@@ -14,6 +14,9 @@ go run ./cmd/projax
Defaults:
- `PROJAX_LISTEN_ADDR=:8080`
- `PROJAX_AUTO_MIGRATE=on` (set to `off` to skip on-start migration apply)
- `SUPABASE_URL` + `SUPABASE_ANON_KEY` enable cookie-based auth federated with `mgmt.msbls.de`. Leave unset for local dev — every request is anonymous.
- `PROJAX_LOGIN_URL` overrides the redirect target (default `https://mgmt.msbls.de/login`).
- `PROJAX_COOKIE_DOMAIN` overrides the refresh-cookie Domain attribute (default `msbls.de`, matching mgmt's `auth.ts`).
Visit `http://localhost:8080/`. Routes:
@@ -72,21 +75,24 @@ After this, migration `0005_reown_to_projax_admin.sql` will detect the role on t
`deploy/dokploy.yaml` is a reference manifest. Translate to the Dokploy UI:
1. Create an app `projax` with `Dockerfile` build context = repo root.
2. Set domain `projax.msbls.de` (Tailscale-only — do **not** publish through public reverse proxy).
2. Set domain `projax.msbls.de` (public via Traefik + Let's Encrypt — auth gating is at the application layer, see Trust model).
3. Secret `PROJAX_DB_URL` from step 0.
4. Health check path `/healthz`.
5. Single replica.
4. Env `SUPABASE_URL=https://supa.flexsiebels.de`, secret `SUPABASE_ANON_KEY` (from `.env.age`).
5. Health check path `/healthz`.
6. Single replica.
The image is a distroless static container running as `nonroot`. Total image size is well under 20 MiB because everything (templates, CSS, migrations) is `embed`-bundled.
## Trust model (v1)
Single-user, Tailscale-only. No HTTP-side authentication layer. The deployment relies on:
Single-user. **Public over HTTPS, gated by Supabase JWT cookie federated with `mgmt.msbls.de`.** No anonymous routes except `/healthz` (Dokploy/Traefik probe).
- Dokploy app exposed only to Tailscale (no public DNS / reverse proxy outside Tailscale).
- msupabase reachable only inside the same Tailscale network.
- `PROJAX_DB_URL` is a Dokploy secret, not in the repo.
- Browser arrives without a session → `302 https://mgmt.msbls.de/login?redirectTo=<original-url>`.
- mgmt's login (Supabase email+password) sets `access_token` + `refresh_token` cookies on the parent `msbls.de` domain (no leading dot — matches mgmt/auth.ts verbatim) so every subdomain shares the session.
- Each projax request validates the cookie against `<SUPABASE_URL>/auth/v1/user`. On expiry, projax silently refreshes via `/auth/v1/token?grant_type=refresh_token` and rotates both cookies, preserving SSO across the fleet.
- The middleware also accepts `Authorization: Bearer <token>` for scripted clients — same surface mgmt exposes.
- DB role is `projax_admin` — full rights on `projax.*`, read-only on `mai.projects` via an explicit RLS policy, blocked on every other schema (see deploy step 0).
- `PROJAX_DB_URL` + `SUPABASE_ANON_KEY` live in Dokploy secrets, never the repo.
If projax later needs auth (multi-device, shared with people, etc.), the natural fit is the same Supabase auth used by flexsiebels — defer until projax has actually outgrown the Tailscale fence.

View File

@@ -61,6 +61,31 @@ func main() {
os.Exit(1)
}
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {
anon := os.Getenv("SUPABASE_ANON_KEY")
if anon == "" {
logger.Error("SUPABASE_URL set but SUPABASE_ANON_KEY missing — refusing to start")
os.Exit(1)
}
loginURL := os.Getenv("PROJAX_LOGIN_URL")
if loginURL == "" {
loginURL = "https://mgmt.msbls.de/login"
}
cookieDomain := os.Getenv("PROJAX_COOKIE_DOMAIN")
if cookieDomain == "" {
cookieDomain = "msbls.de"
}
srv.Auth = &web.AuthConfig{
SupabaseURL: supaURL,
AnonKey: anon,
LoginURL: loginURL,
CookieDomain: cookieDomain,
}
logger.Info("auth: federation enabled", "supabase", supaURL, "login", loginURL, "cookie_domain", cookieDomain)
} else {
logger.Warn("auth: federation disabled — SUPABASE_URL not set, every request is anonymous")
}
httpServer := &http.Server{
Addr: listen,
Handler: srv.Routes(),

View File

@@ -12,6 +12,12 @@ import (
"github.com/m/projax/db"
)
// skipMigrate reports whether the test process should skip ApplyMigrations.
// Useful when the live deploy or another process is concurrently migrating
// against the same DB — Postgres serialises CREATE / ALTER OWNER and the
// loser deadlocks. Set PROJAX_SKIP_MIGRATE=1 to opt out.
func skipMigrate() bool { return os.Getenv("PROJAX_SKIP_MIGRATE") == "1" }
// connect returns a pool or skips the test if no DB is configured.
// Honours PROJAX_DB_URL first, then SUPABASE_DATABASE_URL.
func connect(t *testing.T) *pgxpool.Pool {
@@ -36,6 +42,9 @@ func connect(t *testing.T) *pgxpool.Pool {
}
func TestMigrationsAreIdempotent(t *testing.T) {
if skipMigrate() {
t.Skip("PROJAX_SKIP_MIGRATE=1 — schema assumed already applied")
}
pool := connect(t)
defer pool.Close()
ctx := context.Background()

View File

@@ -1,7 +1,9 @@
# Dokploy app: projax
#
# Apply via Dokploy UI on mlake, or as a reference for the manual setup.
# Tailscale-only; no public exposure. Single replica, single tenant (m).
# Public over HTTPS with Let's Encrypt; auth is enforced at the application
# layer via Supabase JWT cookies federated with mgmt.msbls.de.
# Single replica, single tenant (m).
#
# Environment expected (set via Dokploy secrets, NEVER commit):
# PROJAX_DB_URL postgres://projax_admin:<pw>@<msupabase-tailscale-ip>:6789/postgres?sslmode=disable
@@ -35,5 +37,7 @@ restart: unless-stopped
env:
- PROJAX_LISTEN_ADDR=:8080
- PROJAX_AUTO_MIGRATE=on
- SUPABASE_URL=https://supa.flexsiebels.de
secrets:
- PROJAX_DB_URL
- SUPABASE_ANON_KEY

211
web/auth.go Normal file
View 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
View 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)
}
}

View File

@@ -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 ---

View File

@@ -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 {