feat(auth): rip federation, give projax its own /login

mgmt.msbls.de is being retired; depending on it for auth was the wrong
direction. Match the mBrian / flexsiebels pattern instead — same
Supabase backend, but every tool runs its own login page and scopes
cookies to its own host.

Routes
- GET  /login   render a sign-in form (mBrian dark visual). If the
                request already has a valid session, jump to a safe
                redirectTo (or /).
- POST /login   exchange email+password at /auth/v1/token?grant_type=
                password, set cookies, 302 → redirectTo or /. On
                Supabase 4xx, re-render the form with the error.
- POST /logout  clear both cookies (Max-Age=-1) + 302 → /login.

Cookies
- access_token + refresh_token only. No Domain attribute → scope is
  projax.msbls.de exclusively. HttpOnly, Secure, SameSite=Lax, Path=/,
  Max-Age=1y. Matches mBrian + flexsiebels per-host pattern.

Middleware
- /healthz, /login, /logout always pass through (otherwise infinite
  redirect on the probe / login page).
- On invalid/expired session → 302 /login?redirectTo=<safe-path>,
  RELATIVE to projax. No more cross-host bounce.
- Cookie refresh on expiry still rotates both cookies in place.
- Bearer header path kept for scripted clients.

safeRedirect
- Path-only. Rejects "", "//*", "https://*", "\*", control-char
  injection. Cross-host or scheme bounces fall back to "/". Tested
  against the obvious bypasses.

Cleanup
- Drop PROJAX_LOGIN_URL + PROJAX_COOKIE_DOMAIN env vars (unused now).
- main.go: log "auth: own-login enabled" with the supabase URL on
  startup; warn loudly when SUPABASE_URL is unset.
- README trust-model section rewritten: own login, per-host cookies,
  same backend.
- layout.tmpl gains a "sign out" form-button in the nav so the tree /
  detail / classify pages can log out without curl.

Tests (14, no DB needed): stub Supabase via httptest covers
healthz/login/logout exemption, anonymous→/login redirect, valid
cookie + Bearer pass-through, stale-refresh rotation with NO Domain
attribute, hard-fail redirect, GET form render with redirectTo carry,
already-signed-in short-circuit, POST success with correct cookies,
POST bad-creds error surface, redirectTo safety (path-only, no //,
no absolute URLs), logout cookie clearance.

Full suite (incl. DB-backed): 27/27 green with PROJAX_SKIP_MIGRATE=1.
This commit is contained in:
mAi
2026-05-15 15:16:55 +02:00
parent 65f73cb3ef
commit 360060b152
9 changed files with 644 additions and 197 deletions

View File

@@ -14,9 +14,7 @@ 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`).
- `SUPABASE_URL` + `SUPABASE_ANON_KEY` enable projax's own `/login`. Same Supabase backend as the rest of the m/* fleet, but every tool runs its own login page and scopes cookies per-host. Leave both unset for local dev — every request is anonymous.
Visit `http://localhost:8080/`. Routes:
@@ -29,7 +27,10 @@ Visit `http://localhost:8080/`. Routes:
| `GET /new?parent={path}` | Create a new item (area at root, project under parent) |
| `POST /new` | Submit |
| `GET /admin/classify` | Orphan list with inline HTMX promote |
| `GET /healthz` | DB ping |
| `GET /login` | Sign-in form (open) |
| `POST /login` | Sign-in submit (open) |
| `POST /logout` | Clear cookies, redirect to `/login` |
| `GET /healthz` | DB ping (open) |
| `GET /static/style.css` | Embedded CSS |
## Test
@@ -85,12 +86,14 @@ The image is a distroless static container running as `nonroot`. Total image siz
## Trust model (v1)
Single-user. **Public over HTTPS, gated by Supabase JWT cookie federated with `mgmt.msbls.de`.** No anonymous routes except `/healthz` (Dokploy/Traefik probe).
Single-user. **Public over HTTPS, gated by projax's own Supabase login.** No anonymous routes except `/healthz` (Dokploy/Traefik probe), `/login` and `/logout`.
- 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.
- Browser arrives without a session → `302 /login?redirectTo=<safe-path>`.
- `/login` posts to `<SUPABASE_URL>/auth/v1/token?grant_type=password` with the m/* user account. On success projax sets `access_token` and `refresh_token` cookies (HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=1y, **no Domain attribute** so they are scoped to `projax.msbls.de` only).
- Every request after that validates the cookie against `/auth/v1/user`. On expiry, projax silently refreshes via `/auth/v1/token?grant_type=refresh_token` and rotates both cookies. The middleware also accepts `Authorization: Bearer <token>` for scripted clients.
- `/logout` clears both cookies and bounces to `/login`.
- `redirectTo` is path-only (`/`-prefixed, no `//`, no escape sequences). Cross-host bounces are rejected and fall back to `/`.
- Same Supabase backend as the rest of the m/* fleet (mBrian, flexsiebels, …); each tool keeps its own login + cookie scope.
- 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.

View File

@@ -67,23 +67,13 @@ func main() {
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,
SupabaseURL: supaURL,
AnonKey: anon,
}
logger.Info("auth: federation enabled", "supabase", supaURL, "login", loginURL, "cookie_domain", cookieDomain)
logger.Info("auth: own-login enabled", "supabase", supaURL)
} else {
logger.Warn("auth: federation disabled — SUPABASE_URL not set, every request is anonymous")
logger.Warn("auth: disabled — SUPABASE_URL not set, every request is anonymous")
}
httpServer := &http.Server{

View File

@@ -14,21 +14,31 @@ import (
"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.
// AuthConfig backs projax's own /login. Same Supabase backend the other m/*
// tools use, but cookies are per-host (no Domain attribute) so projax does not
// share its session with any other subdomain. Matches the mBrian / flexsiebels
// pattern.
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
SupabaseURL string // e.g. https://supa.flexsiebels.de
AnonKey string
HTTPClient *http.Client
}
// client returns a usable *http.Client even when HTTPClient is unset, so
// helpers can be called directly (e.g. from /login GET) without explicit init.
func (cfg AuthConfig) client() *http.Client {
if cfg.HTTPClient != nil {
return cfg.HTTPClient
}
return &http.Client{Timeout: 5 * time.Second}
}
const (
accessTokenCookie = "access_token"
refreshTokenCookie = "refresh_token"
cookieMaxAge = 365 * 24 * 60 * 60
loginPath = "/login"
logoutPath = "/logout"
)
// supabaseUser is the minimum slice of GET /auth/v1/user we read.
@@ -37,7 +47,8 @@ type supabaseUser struct {
Email string `json:"email"`
}
// supabaseSession is what /auth/v1/token?grant_type=refresh_token returns.
// supabaseSession is what /auth/v1/token returns for either grant type
// (password or refresh_token).
type supabaseSession struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
@@ -46,14 +57,34 @@ type supabaseSession struct {
} `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}
// supabaseAuthError is the {error, error_description, msg} shape Supabase
// returns on a 4xx from /auth/v1/*.
type supabaseAuthError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
}
func (e supabaseAuthError) Message() string {
switch {
case e.ErrorDescription != "":
return e.ErrorDescription
case e.Msg != "":
return e.Msg
case e.Error != "":
return e.Error
default:
return "Login failed"
}
}
// authMiddleware gates every request except /healthz, /login and /logout.
// On invalid session it 302s to /login?redirectTo=<safe-path>.
func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
// Always-open routes: probe and auth endpoints themselves.
switch r.URL.Path {
case "/healthz", loginPath, logoutPath:
next.ServeHTTP(w, r)
return
}
@@ -66,7 +97,6 @@ func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http
}
ctx := r.Context()
// Try the access token first.
if access != "" {
if _, err := cfg.validateAccessToken(ctx, access); err == nil {
next.ServeHTTP(w, r)
@@ -74,7 +104,6 @@ func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http
}
}
// 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 {
@@ -85,13 +114,11 @@ func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http
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)
http.Redirect(w, r, 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 "
@@ -102,7 +129,6 @@ func tokenFromBearer(r *http.Request) string {
}
// 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 {
@@ -110,7 +136,7 @@ func (cfg AuthConfig) validateAccessToken(ctx context.Context, token string) (*s
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("apikey", cfg.AnonKey)
resp, err := cfg.HTTPClient.Do(req)
resp, err := cfg.client().Do(req)
if err != nil {
return nil, err
}
@@ -132,80 +158,108 @@ func (cfg AuthConfig) validateAccessToken(ctx context.Context, token string) (*s
// 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})
return cfg.tokenRequest(ctx, "refresh_token", body)
}
// passwordSignIn exchanges email+password for a session via /auth/v1/token.
func (cfg AuthConfig) passwordSignIn(ctx context.Context, email, password string) (*supabaseSession, error) {
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
return cfg.tokenRequest(ctx, "password", body)
}
func (cfg AuthConfig) tokenRequest(ctx context.Context, grant string, body []byte) (*supabaseSession, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
cfg.SupabaseURL+"/auth/v1/token?grant_type=refresh_token",
cfg.SupabaseURL+"/auth/v1/token?grant_type="+grant,
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)
resp, err := cfg.client().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)))
raw, _ := io.ReadAll(resp.Body)
var ae supabaseAuthError
_ = json.Unmarshal(raw, &ae)
return nil, fmt.Errorf("supabase /auth/v1/token (%s): %s", grant, ae.Message())
}
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 nil, errors.New("supabase /auth/v1/token: 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.
// setSessionCookies writes per-host access/refresh cookies. No Domain attribute
// — scope is projax.msbls.de only, matching mbrian + flexsiebels.
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,
},
sessionCookie(accessTokenCookie, s.AccessToken),
sessionCookie(refreshTokenCookie, s.RefreshToken),
} {
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"
func sessionCookie(name, value string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
}
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()
}
// clearCookie returns a Set-Cookie that erases `name` on the browser (Max-Age=0).
func clearCookie(name string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
}
}
// loginRedirectURL builds /login?redirectTo=<safe-path>.
func loginRedirectURL(r *http.Request) string {
target := safeRedirect(r.URL.RequestURI())
if target == "" || target == loginPath {
return loginPath
}
q := url.Values{}
q.Set("redirectTo", target)
return loginPath + "?" + q.Encode()
}
// safeRedirect rejects anything that is not a same-origin path. Mirrors the
// mgmt safeRedirect: must start with "/", must not start with "//", must not
// contain "\" (Windows-style URL trick).
func safeRedirect(value string) string {
v := strings.TrimSpace(value)
if v == "" {
return ""
}
if !strings.HasPrefix(v, "/") {
return ""
}
if strings.HasPrefix(v, "//") {
return ""
}
if strings.ContainsAny(v, "\\\r\n\t") {
return ""
}
return v
}

View File

@@ -6,19 +6,22 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"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.
// fakeSupabase stubs the three /auth/v1 endpoints we touch.
type fakeSupabase struct {
*httptest.Server
ValidAccess string
ValidRefresh string
NewAccess string
NewRefresh string
ValidEmail string
ValidPass string
IssuedAccess string
IssuedRefr string
}
func newFakeSupabase(t *testing.T) *fakeSupabase {
@@ -28,41 +31,57 @@ func newFakeSupabase(t *testing.T) *fakeSupabase {
ValidRefresh: "good-refresh",
NewAccess: "rotated-access",
NewRefresh: "rotated-refresh",
ValidEmail: "m@example",
ValidPass: "correct-horse-battery-staple",
IssuedAccess: "issued-access",
IssuedRefr: "issued-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 {
if r.Header.Get("Authorization") != "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"})
_ = json.NewEncoder(w).Encode(map[string]string{"id": "u-1", "email": f.ValidEmail})
})
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"`
}
grant := r.URL.Query().Get("grant_type")
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body.RefreshToken != f.ValidRefresh {
http.Error(w, `{"msg":"bad refresh"}`, http.StatusBadRequest)
return
switch grant {
case "password":
if body["email"] != f.ValidEmail || body["password"] != f.ValidPass {
http.Error(w, `{"error_description":"Invalid login credentials"}`, http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": f.IssuedAccess,
"refresh_token": f.IssuedRefr,
"user": map[string]string{"id": "u-1"},
})
case "refresh_token":
if body["refresh_token"] != 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": "u-1"},
})
default:
http.Error(w, "bad grant", http.StatusBadRequest)
}
_ = 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 {
// gatedMux wires a tiny app behind authMiddleware. It exposes /, /healthz,
// /login (always-open), /logout (always-open) so the middleware tests can
// exercise the gate without spinning up the real Server.
func gatedMux(t *testing.T, supaURL, anon string) http.Handler {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -71,137 +90,305 @@ func newGatedHandler(t *testing.T, supaURL, anonKey string) http.Handler {
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",
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "login-form")
})
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, clearCookie(accessTokenCookie))
http.SetCookie(w, clearCookie(refreshTokenCookie))
http.Redirect(w, r, "/login", http.StatusFound)
})
cfg := AuthConfig{SupabaseURL: supaURL, AnonKey: anon}
return authMiddleware(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), mux)
}
func TestSafeRedirect(t *testing.T) {
cases := map[string]string{
"/i/dev": "/i/dev",
"/": "/",
"": "",
"//evil.com": "",
"https://evil.com": "",
"javascript:alert": "",
`/path\nset-cookie`: "",
`\evil`: "",
}
for in, want := range cases {
if got := safeRedirect(in); got != want {
t.Errorf("safeRedirect(%q) = %q, want %q", in, got, want)
}
}
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")
f := newFakeSupabase(t)
h := gatedMux(t, f.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())
if w.Result().StatusCode != 200 {
t.Fatalf("healthz: %d", w.Result().StatusCode)
}
}
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")
func TestLoginPathBypassesAuth(t *testing.T) {
f := newFakeSupabase(t)
h := gatedMux(t, f.URL, "anon")
for _, path := range []string{"/login", "/logout"} {
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
// /login → 200 (the test mux serves a form). /logout → 302 (clears cookies).
if w.Result().StatusCode == http.StatusFound && path == "/logout" {
continue
}
if w.Result().StatusCode != 200 {
t.Fatalf("%s: status %d", path, w.Result().StatusCode)
}
}
}
func TestUnauthedRedirectsToLocalLogin(t *testing.T) {
f := newFakeSupabase(t)
h := gatedMux(t, f.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/i/dev", nil)
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.HasPrefix(loc, "/login?") {
t.Fatalf("Location = %q, want /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)
// Must NOT bounce to another host.
if strings.Contains(loc, "msbls.de") || strings.HasPrefix(loc, "http") {
t.Fatalf("Location should be relative to projax: %q", loc)
}
}
func TestInvalidAccessCookieRedirects(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
func TestValidCookieAuthorizes(t *testing.T) {
f := newFakeSupabase(t)
h := gatedMux(t, f.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "access_token", Value: "stale"})
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: f.ValidAccess})
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 w.Result().StatusCode != 200 {
t.Fatalf("status %d", w.Result().StatusCode)
}
if strings.TrimSpace(w.Body.String()) != "tree-page" {
t.Fatalf("body = %q", w.Body.String())
t.Fatalf("body %q", w.Body.String())
}
}
func TestBearerHeaderPassesThrough(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
func TestBearerHeaderAuthorizes(t *testing.T) {
f := newFakeSupabase(t)
h := gatedMux(t, f.URL, "anon")
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+supa.ValidAccess)
r.Header.Set("Authorization", "Bearer "+f.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)
if w.Result().StatusCode != 200 {
t.Fatalf("status %d", w.Result().StatusCode)
}
}
func TestStaleAccessRefreshesAndPassesThrough(t *testing.T) {
supa := newFakeSupabase(t)
h := newGatedHandler(t, supa.URL, "anon")
func TestStaleAccessRefreshesAndIssuesCookies(t *testing.T) {
f := newFakeSupabase(t)
h := gatedMux(t, f.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})
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: "stale"})
r.AddCookie(&http.Cookie{Name: refreshTokenCookie, Value: f.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)
if w.Result().StatusCode != 200 {
t.Fatalf("status %d", 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
got := map[string]*http.Cookie{}
for _, c := range w.Result().Cookies() {
got[c.Name] = c
}
if c := got[accessTokenCookie]; c == nil || c.Value != f.NewAccess {
t.Fatalf("access cookie missing or wrong value")
}
if c := got[refreshTokenCookie]; c == nil || c.Value != f.NewRefresh {
t.Fatalf("refresh cookie missing or wrong value")
}
// Per-host scope: NO Domain attribute.
if got[accessTokenCookie].Domain != "" {
t.Fatalf("access cookie has Domain=%q, want empty (per-host)", got[accessTokenCookie].Domain)
}
if got[refreshTokenCookie].Domain != "" {
t.Fatalf("refresh cookie has Domain=%q, want empty", got[refreshTokenCookie].Domain)
}
for _, c := range got {
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
t.Errorf("flags wrong for %q: httponly=%v secure=%v samesite=%v", c.Name, c.HttpOnly, c.Secure, c.SameSite)
}
}
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")
func TestBadRefreshFinallyRedirects(t *testing.T) {
f := newFakeSupabase(t)
h := gatedMux(t, f.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"})
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: "stale"})
r.AddCookie(&http.Cookie{Name: refreshTokenCookie, Value: "no-good"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Result().StatusCode != http.StatusFound {
t.Fatalf("status %d, want 302", w.Result().StatusCode)
}
}
// --- /login + /logout handler tests (use the real Server) ---
func makeServerWithStub(t *testing.T, f *fakeSupabase) *Server {
t.Helper()
srv, err := New(nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("server: %v", err)
}
srv.Auth = &AuthConfig{SupabaseURL: f.URL, AnonKey: "anon"}
return srv
}
func TestLoginGETRendersForm(t *testing.T) {
f := newFakeSupabase(t)
srv := makeServerWithStub(t, f)
r := httptest.NewRequest(http.MethodGet, "/login?redirectTo=/i/dev", nil)
w := httptest.NewRecorder()
srv.handleLoginForm(w, r)
if w.Result().StatusCode != 200 {
t.Fatalf("status %d", w.Result().StatusCode)
}
body := w.Body.String()
if !strings.Contains(body, `name="email"`) || !strings.Contains(body, `name="password"`) {
t.Errorf("body missing email/password fields")
}
if !strings.Contains(body, `name="redirectTo" value="/i/dev"`) {
t.Errorf("body missing redirectTo hidden input")
}
}
func TestLoginGETShortCircuitsWhenAlreadySignedIn(t *testing.T) {
f := newFakeSupabase(t)
srv := makeServerWithStub(t, f)
r := httptest.NewRequest(http.MethodGet, "/login?redirectTo=/i/dev", nil)
r.AddCookie(&http.Cookie{Name: accessTokenCookie, Value: f.ValidAccess})
w := httptest.NewRecorder()
srv.handleLoginForm(w, r)
if w.Result().StatusCode != http.StatusFound {
t.Fatalf("status %d, want 302", w.Result().StatusCode)
}
if loc := w.Header().Get("Location"); loc != "/i/dev" {
t.Errorf("Location = %q, want /i/dev", loc)
}
}
func TestLoginPOSTSuccessSetsCookiesAndRedirects(t *testing.T) {
f := newFakeSupabase(t)
srv := makeServerWithStub(t, f)
form := url.Values{}
form.Set("email", f.ValidEmail)
form.Set("password", f.ValidPass)
form.Set("redirectTo", "/i/dev")
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.handleLoginSubmit(w, r)
if w.Result().StatusCode != http.StatusFound {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("status %d body=%s", w.Result().StatusCode, body)
}
if loc := w.Header().Get("Location"); loc != "/i/dev" {
t.Errorf("Location = %q, want /i/dev", loc)
}
var sawAccess, sawRefresh bool
for _, c := range w.Result().Cookies() {
if c.Name == accessTokenCookie {
sawAccess = true
if c.Domain != "" {
t.Errorf("access cookie has Domain=%q, want empty", c.Domain)
}
if c.Value != f.IssuedAccess {
t.Errorf("access cookie value %q, want %q", c.Value, f.IssuedAccess)
}
}
if c.Name == refreshTokenCookie {
sawRefresh = true
}
}
if !sawAccess || !sawRefresh {
t.Errorf("missing session cookies after login")
}
}
func TestLoginPOSTBadCredsRerendersWithError(t *testing.T) {
f := newFakeSupabase(t)
srv := makeServerWithStub(t, f)
form := url.Values{}
form.Set("email", f.ValidEmail)
form.Set("password", "wrong")
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.handleLoginSubmit(w, r)
if w.Result().StatusCode != http.StatusUnauthorized {
t.Fatalf("status %d, want 401", w.Result().StatusCode)
}
if !strings.Contains(w.Body.String(), "Invalid login credentials") {
t.Errorf("form did not surface error message: %q", w.Body.String())
}
}
func TestLoginRedirectToRejectedWhenUnsafe(t *testing.T) {
f := newFakeSupabase(t)
srv := makeServerWithStub(t, f)
for _, hostile := range []string{"//evil.com", "https://evil.com", `\evil`} {
form := url.Values{}
form.Set("email", f.ValidEmail)
form.Set("password", f.ValidPass)
form.Set("redirectTo", hostile)
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.handleLoginSubmit(w, r)
if loc := w.Header().Get("Location"); loc != "/" {
t.Errorf("hostile redirectTo %q -> Location %q, want /", hostile, loc)
}
}
}
func TestLogoutClearsCookies(t *testing.T) {
f := newFakeSupabase(t)
srv := makeServerWithStub(t, f)
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
w := httptest.NewRecorder()
srv.handleLogout(w, r)
if w.Result().StatusCode != http.StatusFound {
t.Fatalf("status %d, want 302", w.Result().StatusCode)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Errorf("Location = %q, want /login", loc)
}
cleared := 0
for _, c := range w.Result().Cookies() {
if c.Name == accessTokenCookie || c.Name == refreshTokenCookie {
if c.MaxAge >= 0 || c.Value != "" {
t.Errorf("cookie %q not cleared: maxAge=%d value=%q", c.Name, c.MaxAge, c.Value)
}
cleared++
}
}
if cleared != 2 {
t.Errorf("cleared %d cookies, want 2", cleared)
}
}

109
web/login.go Normal file
View File

@@ -0,0 +1,109 @@
package web
import (
"net/http"
"strings"
)
// handleLoginForm renders the login page. If the request already carries a
// valid session, jump straight to the safe redirectTo.
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
if s.Auth != nil && s.hasValidSession(r) {
http.Redirect(w, r, postLoginTarget(r.URL.Query().Get("redirectTo")), http.StatusFound)
return
}
redirectTo := safeRedirect(r.URL.Query().Get("redirectTo"))
s.render(w, "login", map[string]any{
"Title": "sign in",
"RedirectTo": redirectTo,
"Error": "",
})
}
// handleLoginSubmit exchanges email+password for a Supabase session and writes
// the session cookies. On failure we re-render the login form with the
// Supabase error message.
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
redirectTo := safeRedirect(r.FormValue("redirectTo"))
if email == "" || password == "" {
w.WriteHeader(http.StatusBadRequest)
s.render(w, "login", map[string]any{
"Title": "sign in",
"RedirectTo": redirectTo,
"Error": "Email and password are required.",
})
return
}
sess, err := s.Auth.passwordSignIn(r.Context(), email, password)
if err != nil {
s.Logger.Info("login: signin failed", "email", email, "err", err)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, "login", map[string]any{
"Title": "sign in",
"RedirectTo": redirectTo,
"Error": humanLoginError(err),
})
return
}
s.Auth.setSessionCookies(w, sess)
http.Redirect(w, r, postLoginTarget(redirectTo), http.StatusFound)
}
// handleLogout clears both session cookies and redirects to /login.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, clearCookie(accessTokenCookie))
http.SetCookie(w, clearCookie(refreshTokenCookie))
http.Redirect(w, r, loginPath, http.StatusFound)
}
// hasValidSession checks current cookies/Bearer against Supabase. Used only by
// the login page's "already signed in" short-circuit; the main gate lives in
// authMiddleware.
func (s *Server) hasValidSession(r *http.Request) bool {
if s.Auth == nil {
return false
}
access := tokenFromBearer(r)
if access == "" {
if c, err := r.Cookie(accessTokenCookie); err == nil {
access = c.Value
}
}
if access == "" {
return false
}
_, err := s.Auth.validateAccessToken(r.Context(), access)
return err == nil
}
func postLoginTarget(redirectTo string) string {
if t := safeRedirect(redirectTo); t != "" {
return t
}
return "/"
}
// humanLoginError trims Supabase's noisy prefix down to a sentence the form
// can show without leaking internals.
func humanLoginError(err error) string {
msg := err.Error()
if i := strings.Index(msg, "supabase /auth/v1/token (password): "); i >= 0 {
msg = msg[i+len("supabase /auth/v1/token (password): "):]
}
if msg == "" {
return "Login failed."
}
return msg
}

View File

@@ -30,7 +30,9 @@ type Server struct {
}
// New builds a Server. Each page is parsed alongside the layout into its own
// Template so per-page `define "content"` blocks don't shadow each other.
// Template so per-page `define "content"` blocks don't shadow each other. The
// login page is intentionally NOT wrapped in the regular layout (chrome would
// imply you're already inside the app).
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
if logger == nil {
logger = slog.Default()
@@ -54,6 +56,11 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages[name] = t
}
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
}
pages["login"] = loginTmpl
return &Server{Store: s, pages: pages, Logger: logger}, nil
}
@@ -67,6 +74,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /new", s.handleNewForm)
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.Store.Pool.Ping(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
@@ -341,8 +351,13 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
entry := "layout"
if name == "login" {
// Login page is intentionally standalone — no nav chrome.
entry = "login"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
if err := t.ExecuteTemplate(w, entry, data); err != nil {
s.Logger.Error("render", "page", name, "err", err)
}
}

View File

@@ -15,6 +15,9 @@ html { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color:
body { margin: 0; }
header { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 8px 16px; }
header nav { display: flex; gap: 16px; align-items: center; }
header .logout-form { margin: 0 0 0 auto; }
header .logout-btn { background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px 6px; font: inherit; }
header .logout-btn:hover { color: var(--bad); text-decoration: underline; }
header .brand { font-weight: 600; font-size: 1.1em; color: var(--fg); text-decoration: none; }
header a { color: var(--accent); text-decoration: none; }
header a:hover { text-decoration: underline; }

View File

@@ -11,6 +11,9 @@
<nav>
<a href="/" class="brand">projax</a>
<a href="/admin/classify">classify orphans</a>
<form method="post" action="/logout" class="logout-form">
<button type="submit" class="logout-btn">sign out</button>
</form>
</nav>
</header>
<main>

83
web/templates/login.tmpl Normal file
View File

@@ -0,0 +1,83 @@
{{define "login"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign in — projax</title>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; margin: 0; }
body {
background: #0a0a0a;
color: #e0e0e0;
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card {
width: 100%;
max-width: 24rem;
background: #111;
border: 1px solid #222;
border-radius: 0.75rem;
padding: 2rem;
}
.header { text-align: center; margin-bottom: 1.5rem; }
.title { font-size: 1.5rem; font-weight: 700; margin: 0 0 0.25rem; color: #e0e0e0; }
.subtitle { color: #666; margin: 0; font-size: 0.9rem; }
form { display: flex; flex-direction: column; gap: 1.1rem; }
.field { display: flex; flex-direction: column; gap: 0.35rem; }
label { font-weight: 500; font-size: 0.85rem; color: #aaa; }
input {
padding: 0.6rem 0.75rem;
border: 1px solid #333;
border-radius: 0.4rem;
background: #1a1a1a;
color: #e0e0e0;
font: inherit;
}
input:focus { outline: none; border-color: #555; }
.err {
padding: 0.6rem;
border-radius: 0.4rem;
text-align: center;
font-size: 0.85rem;
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
button {
padding: 0.65rem;
background: #e0e0e0;
color: #0a0a0a;
border: none;
border-radius: 0.4rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:hover { opacity: 0.85; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1 class="title">projax</h1>
<p class="subtitle">Sign in to continue</p>
</div>
<form method="POST" action="/login">
{{if .RedirectTo}}<input type="hidden" name="redirectTo" value="{{.RedirectTo}}">{{end}}
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="username" autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" required autocomplete="current-password">
</div>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>{{end}}