Bundle of small audit findings, all doc-only or dead-code: - F-5: refresh stale escalation-contact comment in models.User — Settings UI dropdown shipped 2026-04-29 (t-paliad-066). - F-10: add "OBSOLETED by migration 018" note to migrations 004/005/006 so readers stop hunting for the live shape in obsolete files. - F-11: document the data-loss semantics of dropping paliad.partner_unit_events on the 027 down — audit rows are append-only telemetry, accepted loss on rollback. - F-15: drop the patholo_session / patholo_refresh cookie fallback added during the 2026-04-16 rebrand. Active users have long since been re-authed through the upgrade path; inactive users hit the normal /login flow. - F-16: refresh stale /api/departments comment in team_pages.go to /api/partner-units (renamed in t-paliad-070). - F-17: move internal/db/migrations/_dev/mock_supabase_auth.sql to internal/db/devtools/ so a future loosening of the //go:embed pattern can't accidentally ship the dev-only fixture. - F-18: update docs/project-status.md "Audit polish-2" entry — the batch shipped via t-paliad-067 / 068 / 073, follow-ups are now tracked under the 2026-04-30 re-audit + t-paliad-074. go build / vet / test clean.
338 lines
9.4 KiB
Go
338 lines
9.4 KiB
Go
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)
|
|
}
|