Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/vsp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,11 @@ func resolveConfig(cmd *cobra.Command) {
}
}

// Session type: SAP_SESSION_TYPE env (stateful | stateless | keep)
if v := viper.GetString("SESSION_TYPE"); v != "" {
cfg.SessionType = v
}

// Keep-alive interval: flag > SAP_KEEPALIVE env
if !cmd.Flags().Changed("keepalive") {
if v := viper.GetString("KEEPALIVE"); v != "" {
Expand Down
13 changes: 13 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package mcp
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -87,6 +88,9 @@ type Config struct {
// Graph / co-change configuration
TransportAttribute string // E070A attribute name for CR-level co-change aggregation

// Session type: "stateful" keeps SAP session across requests (required for lock/write flows)
SessionType string

// Debugger configuration
TerminalID string // SAP GUI terminal ID for cross-tool breakpoint sharing

Expand Down Expand Up @@ -126,6 +130,15 @@ func NewServer(cfg *Config) *Server {
if cfg.Verbose {
opts = append(opts, adt.WithVerbose())
}
if cfg.SessionType != "" {
st := adt.SessionType(cfg.SessionType)
switch st {
case adt.SessionStateful, adt.SessionStateless, adt.SessionKeep:
opts = append(opts, adt.WithSessionType(st))
default:
fmt.Fprintf(os.Stderr, "[vsp] warning: unknown SAP_SESSION_TYPE %q, using default (stateless)\n", cfg.SessionType)
}
}
if cfg.ReauthFunc != nil {
opts = append(opts, adt.WithReauthFunc(cfg.ReauthFunc))
}
Expand Down
26 changes: 25 additions & 1 deletion pkg/adt/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
)

Expand Down Expand Up @@ -227,9 +228,32 @@ func WithTerminalID(terminalID string) Option {
}
}

// httpCookieJar wraps cookiejar.Jar and strips the Secure flag when storing cookies
// received over plain HTTP. This is required for SAP systems accessed via HTTP reverse
// proxies that still set Secure cookies (e.g. nginx in front of SAP ICM). Go's standard
// jar won't send Secure cookies on HTTP requests, causing CSRF tokens to appear expired
// because the session cookie never reaches the server on subsequent requests.
type httpCookieJar struct {
*cookiejar.Jar
}

func (j *httpCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
if u.Scheme == "http" {
stripped := make([]*http.Cookie, len(cookies))
for i, c := range cookies {
copy := *c
copy.Secure = false
stripped[i] = &copy
}
cookies = stripped
}
j.Jar.SetCookies(u, cookies)
}

// NewHTTPClient creates an http.Client configured for the given Config.
func (c *Config) NewHTTPClient() *http.Client {
jar, _ := cookiejar.New(nil)
base, _ := cookiejar.New(nil)
jar := &httpCookieJar{base}

transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, // Honor HTTP_PROXY/HTTPS_PROXY env vars
Expand Down
45 changes: 39 additions & 6 deletions pkg/adt/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,17 +308,50 @@ func (t *Transport) fetchCSRFToken(ctx context.Context) error {
req.Header.Set("X-sap-adt-sessiontype", "stateful")
}

resp, err := t.httpClient.Do(req)
headResp, err := t.httpClient.Do(req)
if err != nil {
return fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()

// Drain body to allow connection reuse
_, _ = io.Copy(io.Discard, resp.Body)
_, _ = io.Copy(io.Discard, headResp.Body)
headResp.Body.Close()

// Use the HEAD response by default; fall back to GET when HEAD returns no usable token.
// Some SAP systems (on-premise, S/4HANA cloud) reject HEAD or return no token —
// CL_ADT_WB_RES_APP may not implement HEAD. GET is what Eclipse ADT uses.
//
// But skip the GET fallback on 401/403: there the token is absent because the
// credentials/authorizations are invalid, not because HEAD is unsupported — a GET
// retry can't help and only wastes a round-trip (and would swallow a mock slot in
// the re-auth-failure path).
resp := headResp
headToken := headResp.Header.Get("X-CSRF-Token")
headIsAuthFailure := headResp.StatusCode == http.StatusUnauthorized ||
headResp.StatusCode == http.StatusForbidden
if (headToken == "" || headToken == "Required") && !headIsAuthFailure {
reqGet, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("creating GET fallback request: %w", err)
}
if t.config.HasBasicAuth() {
reqGet.SetBasicAuth(t.config.Username, t.config.Password)
}
t.addCookies(reqGet)
reqGet.Header.Set("X-CSRF-Token", "fetch")
reqGet.Header.Set("Accept", "*/*")
if t.config.SessionType == SessionStateful {
reqGet.Header.Set("X-sap-adt-sessiontype", "stateful")
}
getResp, err := t.httpClient.Do(reqGet)
if err != nil {
return fmt.Errorf("executing GET fallback: %w", err)
}
_, _ = io.Copy(io.Discard, getResp.Body)
getResp.Body.Close()
resp = getResp
}

// Note: HEAD may return 400 but still provides CSRF token in headers
// But 401/403 indicates auth failure and won't have a valid token
// But 401 indicates auth failure and won't have a valid token

token := resp.Header.Get("X-CSRF-Token")
if token == "" || token == "Required" {
Expand Down