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=. 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 /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=. 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 }