Files
projax/web/auth.go
mAi 360060b152 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.
2026-05-15 15:16:55 +02:00

266 lines
7.4 KiB
Go

package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
// 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
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.
type supabaseUser struct {
ID string `json:"id"`
Email string `json:"email"`
}
// 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"`
User struct {
ID string `json:"id"`
} `json:"user"`
}
// 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) {
// Always-open routes: probe and auth endpoints themselves.
switch r.URL.Path {
case "/healthz", loginPath, logoutPath:
next.ServeHTTP(w, r)
return
}
access := tokenFromBearer(r)
if access == "" {
if c, err := r.Cookie(accessTokenCookie); err == nil {
access = c.Value
}
}
ctx := r.Context()
if access != "" {
if _, err := cfg.validateAccessToken(ctx, access); err == nil {
next.ServeHTTP(w, r)
return
}
}
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)
}
http.Redirect(w, r, loginRedirectURL(r), http.StatusFound)
})
}
// tokenFromBearer extracts a Bearer token from the Authorization header.
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.
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.client().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})
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="+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.client().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
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 /auth/v1/token: empty token in response")
}
return &s, nil
}
// 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{
sessionCookie(accessTokenCookie, s.AccessToken),
sessionCookie(refreshTokenCookie, s.RefreshToken),
} {
http.SetCookie(w, c)
}
}
func sessionCookie(name, value string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
}
}
// 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
}