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.
281 lines
8.0 KiB
Go
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
|
|
}
|