diff --git a/README.md b/README.md index f959993..54477f0 100644 --- a/README.md +++ b/README.md @@ -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=`. -- 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 `/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 ` for scripted clients — same surface mgmt exposes. +- Browser arrives without a session → `302 /login?redirectTo=`. +- `/login` posts to `/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 ` 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. diff --git a/cmd/projax/main.go b/cmd/projax/main.go index 446788a..32d8c24 100644 --- a/cmd/projax/main.go +++ b/cmd/projax/main.go @@ -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{ diff --git a/web/auth.go b/web/auth.go index b7a8aac..56149a8 100644 --- a/web/auth.go +++ b/web/auth.go @@ -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=. +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 /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=. +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 } diff --git a/web/auth_test.go b/web/auth_test.go index cb5b16a..b901148 100644 --- a/web/auth_test.go +++ b/web/auth_test.go @@ -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) + } +} diff --git a/web/login.go b/web/login.go new file mode 100644 index 0000000..8320390 --- /dev/null +++ b/web/login.go @@ -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 +} diff --git a/web/server.go b/web/server.go index a1efd34..d72998a 100644 --- a/web/server.go +++ b/web/server.go @@ -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) } } diff --git a/web/static/style.css b/web/static/style.css index 455d62c..0f65916 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; } diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl index 7e26f04..cfc60ff 100644 --- a/web/templates/layout.tmpl +++ b/web/templates/layout.tmpl @@ -11,6 +11,9 @@
diff --git a/web/templates/login.tmpl b/web/templates/login.tmpl new file mode 100644 index 0000000..00d4d28 --- /dev/null +++ b/web/templates/login.tmpl @@ -0,0 +1,83 @@ +{{define "login"}} + + + +Sign in — projax + + + +
+
+

projax

+

Sign in to continue

+
+
+ {{if .RedirectTo}}{{end}} +
+ + +
+
+ + +
+ {{if .Error}}
{{.Error}}
{{end}} + +
+
+ +{{end}}