package auth import ( "bytes" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strings" "time" "github.com/golang-jwt/jwt/v5" ) const ( // SessionCookieName + RefreshCookieName are the canonical cookie names // issued after the patholo → paliad rename (2026-04-16). The legacy // patholo_* fallback was removed in t-paliad-081 (2026-04-30) — any // user who held a legacy cookie has long since been re-authed through // the upgrade path and now carries paliad_* names. SessionCookieName = "paliad_session" RefreshCookieName = "paliad_refresh" CookieMaxAge = 30 * 24 * 60 * 60 // 30 days ) type Client struct { URL string AnonKey string JWTSecret []byte HTTP *http.Client } func NewClient(supabaseURL, anonKey string, jwtSecret []byte) *Client { if len(jwtSecret) == 0 { log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification") } return &Client{ URL: strings.TrimRight(supabaseURL, "/"), AnonKey: anonKey, JWTSecret: jwtSecret, HTTP: &http.Client{Timeout: 10 * time.Second}, } } type TokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` } // SignIn authenticates a user with email and password. func (c *Client) SignIn(email, password string) (*TokenResponse, error) { return c.tokenRequest("password", map[string]string{ "email": email, "password": password, }) } // RefreshSession exchanges a refresh token for a new access token. func (c *Client) RefreshSession(refreshToken string) (*TokenResponse, error) { return c.tokenRequest("refresh_token", map[string]string{ "refresh_token": refreshToken, }) } func (c *Client) tokenRequest(grantType string, body map[string]string) (*TokenResponse, error) { jsonBody, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal: %w", err) } endpoint := fmt.Sprintf("%s/auth/v1/token?grant_type=%s", c.URL, grantType) req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("apikey", c.AnonKey) resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody)) } var result TokenResponse if err := json.Unmarshal(respBody, &result); err != nil { return nil, fmt.Errorf("decode response: %w", err) } if result.AccessToken == "" { return nil, errors.New("empty access_token") } return &result, nil } // SignUp registers a new user. Returns tokens if auto-confirm is enabled, nil otherwise. func (c *Client) SignUp(email, password string) (*TokenResponse, error) { jsonBody, err := json.Marshal(map[string]string{ "email": email, "password": password, }) if err != nil { return nil, fmt.Errorf("marshal: %w", err) } endpoint := fmt.Sprintf("%s/auth/v1/signup", c.URL) req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("apikey", c.AnonKey) resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody)) } var result TokenResponse if err := json.Unmarshal(respBody, &result); err != nil || result.AccessToken == "" { return nil, nil // registered but no auto-login (email confirmation pending) } return &result, nil } // SignOut invalidates the user's session on Supabase. func (c *Client) SignOut(accessToken string) { endpoint := fmt.Sprintf("%s/auth/v1/logout", c.URL) req, err := http.NewRequest("POST", endpoint, nil) if err != nil { return } req.Header.Set("apikey", c.AnonKey) req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := c.HTTP.Do(req) if err != nil { return } resp.Body.Close() } // VerifiedClaims is the subset of Supabase JWT claims the app cares about. // Populated only after signature + expiry verification succeed. type VerifiedClaims struct { Sub string Email string Exp time.Time } // VerifyToken parses and fully validates a Supabase access token. It verifies // the HS256 signature against the shared secret, enforces the standard // exp/nbf/iat checks, and extracts the `sub` claim as the authenticated // user's UUID string. Returns an error if the signature is wrong, the token // is expired, or any required claim is missing. func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) { parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return c.JWTSecret, nil }, jwt.WithValidMethods([]string{"HS256"})) if err != nil { return nil, err } claims, ok := parsed.Claims.(jwt.MapClaims) if !ok || !parsed.Valid { return nil, errors.New("invalid claims") } sub, _ := claims["sub"].(string) if sub == "" { return nil, errors.New("no sub claim") } email, _ := claims["email"].(string) var exp time.Time if expClaim, err := claims.GetExpirationTime(); err == nil && expClaim != nil { exp = expClaim.Time } return &VerifiedClaims{Sub: sub, Email: email, Exp: exp}, nil } // readSessionCookie returns the value of the session cookie, or "" if the // caller did not present one. func readSessionCookie(r *http.Request) string { if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" { return c.Value } return "" } // readRefreshCookie mirrors readSessionCookie for the refresh token. func readRefreshCookie(r *http.Request) string { if c, err := r.Cookie(RefreshCookieName); err == nil && c.Value != "" { return c.Value } return "" } // Middleware requires a valid, signature-verified session for protected routes. // Browser requests get a 302 to /login; API requests get a 401 JSON response. func (c *Client) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionValue := readSessionCookie(r) if sessionValue == "" { rejectUnauthenticated(w, r) return } claims, err := c.VerifyToken(sessionValue) if err == nil { ctx := withVerifiedClaims(r.Context(), claims) next.ServeHTTP(w, r.WithContext(ctx)) return } // Only attempt refresh if the failure was expiry. Any other signature // error means the token is forged or tampered with — reject. if !errors.Is(err, jwt.ErrTokenExpired) { ClearAuthCookies(w) rejectUnauthenticated(w, r) return } refreshValue := readRefreshCookie(r) if refreshValue == "" { ClearAuthCookies(w) rejectUnauthenticated(w, r) return } tokens, err := c.RefreshSession(refreshValue) if err != nil { log.Printf("token refresh failed: %v", err) ClearAuthCookies(w) rejectUnauthenticated(w, r) return } newClaims, err := c.VerifyToken(tokens.AccessToken) if err != nil { log.Printf("refreshed token failed verification: %v", err) ClearAuthCookies(w) rejectUnauthenticated(w, r) return } SetAuthCookies(w, r, tokens) ctx := withVerifiedClaims(r.Context(), newClaims) next.ServeHTTP(w, r.WithContext(ctx)) }) } func rejectUnauthenticated(w http.ResponseWriter, r *http.Request) { if isAPIRequest(r) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthenticated"}`)) return } http.Redirect(w, r, "/login", http.StatusFound) } func isAPIRequest(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, "/api/") } // SetAuthCookies writes session and refresh token cookies. func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) { secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" for _, c := range []*http.Cookie{ {Name: SessionCookieName, Value: tokens.AccessToken}, {Name: RefreshCookieName, Value: tokens.RefreshToken}, } { http.SetCookie(w, &http.Cookie{ Name: c.Name, Value: c.Value, Path: "/", MaxAge: CookieMaxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure, }) } } // ClearAuthCookies removes session and refresh token cookies. func ClearAuthCookies(w http.ResponseWriter) { for _, name := range []string{SessionCookieName, RefreshCookieName} { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) } } func parseErrorMessage(body []byte) string { var resp struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` Message string `json:"message"` Msg string `json:"msg"` } if err := json.Unmarshal(body, &resp); err != nil { return string(body) } for _, msg := range []string{resp.ErrorDescription, resp.Message, resp.Msg, resp.Error} { if msg != "" { return msg } } return string(body) }