package browser import ( "context" "net/http" "net/url" "path" "strings" "time" "git.sendico.io/tech/sxchnge/shared/errors" ) const ( TransportHeader = "X-Auth-Transport" TransportCookie = "cookie" CSRFHeader = "X-CSRF-Protection" CSRFValue = "1" RefreshCookie = "sxchnge_refresh" ) func UsesCookie(r *http.Request) bool { return r != nil && strings.EqualFold(strings.TrimSpace(r.Header.Get(TransportHeader)), TransportCookie) } func RefreshCredential(r *http.Request, fallback string) (string, error) { if !UsesCookie(r) { return strings.TrimSpace(fallback), nil } cookie, err := r.Cookie(RefreshCookie) if err != nil || strings.TrimSpace(cookie.Value) == "" { return "", merrors.Unauthorized("browser session is missing") } return cookie.Value, nil } func SetRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) { if !UsesCookie(r) { return } maxAge := int(time.Until(expiresAt).Seconds()) if maxAge < 1 { maxAge = 1 } http.SetCookie(w, &http.Cookie{ Name: RefreshCookie, Value: token, Path: cookiePath(r), Expires: expiresAt.UTC(), MaxAge: maxAge, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func ClearRefreshCookie(w http.ResponseWriter, r *http.Request) { if !UsesCookie(r) { return } http.SetCookie(w, &http.Cookie{ Name: RefreshCookie, Value: "", Path: cookiePath(r), Expires: time.Unix(1, 0).UTC(), MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func ValidateCSRF(r *http.Request, allowedOrigins []string) error { if !UsesCookie(r) || isSafeMethod(r.Method) { return nil } if r.Header.Get(CSRFHeader) != CSRFValue { return merrors.PermissionDenied("browser CSRF protection header is missing") } origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" || !originAllowed(origin, allowedOrigins) { return merrors.PermissionDenied("browser request origin is not allowed") } return nil } func cookiePath(r *http.Request) string { const accountsSegment = "/accounts" if r != nil { if mount, ok := r.Context().Value(scopeKey{}).(string); ok { return path.Join(mount, accountsSegment) } } if r != nil && r.URL != nil { if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 { return r.URL.Path[:index+len(accountsSegment)] } } return accountsSegment } func isSafeMethod(method string) bool { switch method { case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: return true default: return false } } func originAllowed(origin string, allowedOrigins []string) bool { parsed, err := url.Parse(origin) if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" { return false } normalized := strings.TrimSuffix(strings.ToLower(origin), "/") for _, allowed := range allowedOrigins { if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(allowed)), "/") == normalized { return true } } return false } type scopeKey struct{} // Scope binds cookie creation and deletion to a validated installed API mount. func Scope(mount string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, mount))) }) } }