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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user