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