Files
projax/web/auth.go
mAi d49a05b1f4 fix(phase 3j auth): allow /static/* through auth middleware for PWA install
The manifest + icons + sw.js need to be reachable pre-auth so the iOS
'Add to Home Screen' flow can fetch the manifest from the /login page
(the browser fetches install metadata BEFORE the user signs in). Static
assets are embedded, non-sensitive, no leakage risk.
2026-05-15 19:34:27 +02:00

281 lines
8.0 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. The MCP
// surface uses its own Bearer-token auth (PROJAX_MCP_TOKEN) — letting
// it through the Supabase-cookie middleware keeps API callers from
// needing a session cookie.
switch r.URL.Path {
case "/healthz", loginPath, logoutPath:
next.ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/mcp/") {
next.ServeHTTP(w, r)
return
}
// /static/* must be reachable pre-auth so the PWA install flow works
// on the login page (browser fetches the manifest + icon BEFORE the
// user signs in, so the "Add to Home Screen" affordance can render).
// These are non-sensitive embedded assets — no leakage risk.
if strings.HasPrefix(r.URL.Path, "/static/") {
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
}