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.
266 lines
7.4 KiB
Go
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
|
|
}
|