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:
20
README.md
20
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
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