From cf4ce3c4767cecb04a509f1502b58904eae00d83 Mon Sep 17 00:00:00 2001 From: Divya Jain Date: Tue, 17 Feb 2026 09:55:35 +0530 Subject: [PATCH] add support for globalprotect vpn using saml auth flow (#1689) * add support for globalprotect vpn using saml auth flow * go fmt --------- Co-authored-by: bbedward --- .../server/network/agent_networkmanager.go | 120 +++++- .../network/agent_networkmanager_test.go | 355 ++++++++++++++++++ .../server/network/backend_networkmanager.go | 22 +- .../network/backend_networkmanager_gp_saml.go | 203 ++++++++++ .../backend_networkmanager_gp_saml_test.go | 169 +++++++++ .../network/backend_networkmanager_vpn.go | 70 +++- 6 files changed, 925 insertions(+), 14 deletions(-) create mode 100644 core/internal/server/network/agent_networkmanager_test.go create mode 100644 core/internal/server/network/backend_networkmanager_gp_saml.go create mode 100644 core/internal/server/network/backend_networkmanager_gp_saml_test.go diff --git a/core/internal/server/network/agent_networkmanager.go b/core/internal/server/network/agent_networkmanager.go index b438731b..088b7fac 100644 --- a/core/internal/server/network/agent_networkmanager.go +++ b/core/internal/server/network/agent_networkmanager.go @@ -32,8 +32,10 @@ type SecretAgent struct { backend *NetworkManagerBackend } -type nmVariantMap map[string]dbus.Variant -type nmSettingMap map[string]nmVariantMap +type ( + nmVariantMap map[string]dbus.Variant + nmSettingMap map[string]nmVariantMap +) const introspectXML = ` @@ -308,6 +310,63 @@ func (a *SecretAgent) GetSecrets( return out, nil } a.backend.cachedVPNCredsMu.Unlock() + + a.backend.cachedGPSamlMu.Lock() + cachedGPSaml := a.backend.cachedGPSamlCookie + if cachedGPSaml != nil && cachedGPSaml.ConnectionUUID == connUuid { + a.backend.cachedGPSamlMu.Unlock() + + log.Infof("[SecretAgent] Using cached GlobalProtect SAML cookie for %s", connUuid) + + return buildGPSamlSecretsResponse(settingName, cachedGPSaml.Cookie, cachedGPSaml.Host, cachedGPSaml.Fingerprint), nil + } + a.backend.cachedGPSamlMu.Unlock() + + if len(fields) == 1 && fields[0] == "gp-saml" { + gateway := "" + protocol := "" + if vpnSettings, ok := conn["vpn"]; ok { + if dataVariant, ok := vpnSettings["data"]; ok { + if dataMap, ok := dataVariant.Value().(map[string]string); ok { + if gw, ok := dataMap["gateway"]; ok { + gateway = gw + } + if proto, ok := dataMap["protocol"]; ok && proto != "" { + protocol = proto + } + } + } + } + + if protocol != "gp" { + return nil, dbus.MakeFailedError(fmt.Errorf("gp-saml auth only supported for GlobalProtect (protocol=gp), got: %s", protocol)) + } + + log.Infof("[SecretAgent] Starting GlobalProtect SAML authentication for gateway=%s", gateway) + + samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer samlCancel() + + authResult, err := a.backend.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol) + if err != nil { + log.Warnf("[SecretAgent] GlobalProtect SAML authentication failed: %v", err) + return nil, dbus.MakeFailedError(fmt.Errorf("GlobalProtect SAML authentication failed: %w", err)) + } + + log.Infof("[SecretAgent] GlobalProtect SAML authentication successful, returning cookie to NetworkManager") + + a.backend.cachedGPSamlMu.Lock() + a.backend.cachedGPSamlCookie = &cachedGPSamlCookie{ + ConnectionUUID: connUuid, + Cookie: authResult.Cookie, + Host: authResult.Host, + User: authResult.User, + Fingerprint: authResult.Fingerprint, + } + a.backend.cachedGPSamlMu.Unlock() + + return buildGPSamlSecretsResponse(settingName, authResult.Cookie, authResult.Host, authResult.Fingerprint), nil + } } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) @@ -659,12 +718,25 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string { switch { case strings.Contains(vpnService, "openconnect"): + protocol := dataMap["protocol"] authType := dataMap["authtype"] - userCert := dataMap["usercert"] - if authType == "cert" && strings.HasPrefix(userCert, "pkcs11:") { + username := dataMap["username"] + + if authType == "cert" && strings.HasPrefix(dataMap["usercert"], "pkcs11:") { return []string{"key_pass"} } - if dataMap["username"] == "" { + + if needsExternalBrowserAuth(protocol, authType, username, dataMap) { + switch protocol { + case "gp": + log.Infof("[SecretAgent] GlobalProtect SAML auth detected") + return []string{"gp-saml"} + default: + log.Infof("[SecretAgent] External browser auth detected for protocol '%s' but only GlobalProtect (gp) SAML is currently supported, falling back to credentials", protocol) + } + } + + if username == "" { fields = []string{"username", "password"} } case strings.Contains(vpnService, "openvpn"): @@ -683,8 +755,31 @@ func inferVPNFields(conn map[string]nmVariantMap, vpnService string) []string { return fields } +func needsExternalBrowserAuth(protocol, authType, username string, data map[string]string) bool { + if method, ok := data["saml-auth-method"]; ok { + if method == "REDIRECT" || method == "POST" { + return true + } + } + + if authType != "" && authType != "password" && authType != "cert" { + return true + } + + switch protocol { + case "gp": + if authType == "" && username == "" { + return true + } + } + + return false +} + func vpnFieldMeta(field, vpnService string) (label string, isSecret bool) { switch field { + case "gp-saml": + return "GlobalProtect SAML/SSO", false case "key_pass": return "PIN", true case "password": @@ -785,3 +880,18 @@ func reasonFromFlags(flags uint32) string { } return "required" } + +func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap { + out := nmSettingMap{} + vpnSec := nmVariantMap{} + + secrets := map[string]string{ + "cookie": cookie, + "gateway": host, + "gwcert": fingerprint, + } + vpnSec["secrets"] = dbus.MakeVariant(secrets) + + out[settingName] = vpnSec + return out +} diff --git a/core/internal/server/network/agent_networkmanager_test.go b/core/internal/server/network/agent_networkmanager_test.go new file mode 100644 index 00000000..20409865 --- /dev/null +++ b/core/internal/server/network/agent_networkmanager_test.go @@ -0,0 +1,355 @@ +package network + +import ( + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +func TestNeedsExternalBrowserAuth(t *testing.T) { + tests := []struct { + name string + protocol string + authType string + username string + data map[string]string + expected bool + }{ + { + name: "GP with saml-auth-method REDIRECT", + protocol: "gp", + authType: "password", + username: "user", + data: map[string]string{"saml-auth-method": "REDIRECT"}, + expected: true, + }, + { + name: "GP with saml-auth-method POST", + protocol: "gp", + authType: "password", + username: "user", + data: map[string]string{"saml-auth-method": "POST"}, + expected: true, + }, + { + name: "GP with no authtype and no username", + protocol: "gp", + authType: "", + username: "", + data: map[string]string{}, + expected: true, + }, + { + name: "GP with username and password authtype", + protocol: "gp", + authType: "password", + username: "john", + data: map[string]string{}, + expected: false, + }, + { + name: "GP with username but no authtype", + protocol: "gp", + authType: "", + username: "john", + data: map[string]string{}, + expected: false, + }, + { + name: "GP with authtype but no username - should detect SAML", + protocol: "gp", + authType: "", + username: "", + data: map[string]string{}, + expected: true, + }, + { + name: "pulse with SAML", + protocol: "pulse", + authType: "", + username: "", + data: map[string]string{"saml-auth-method": "REDIRECT"}, + expected: true, + }, + { + name: "fortinet with non-password authtype", + protocol: "fortinet", + authType: "saml", + username: "", + data: map[string]string{}, + expected: true, + }, + { + name: "anyconnect with cert", + protocol: "anyconnect", + authType: "cert", + username: "", + data: map[string]string{}, + expected: false, + }, + { + name: "anyconnect with password", + protocol: "anyconnect", + authType: "password", + username: "user", + data: map[string]string{}, + expected: false, + }, + { + name: "empty protocol", + protocol: "", + authType: "", + username: "", + data: map[string]string{}, + expected: false, + }, + { + name: "GP with cert authtype", + protocol: "gp", + authType: "cert", + username: "", + data: map[string]string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := needsExternalBrowserAuth(tt.protocol, tt.authType, tt.username, tt.data) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildGPSamlSecretsResponse(t *testing.T) { + tests := []struct { + name string + settingName string + cookie string + host string + fingerprint string + }{ + { + name: "all fields populated", + settingName: "vpn", + cookie: "authcookie=abc123&portal=GATE", + host: "vpn.example.com", + fingerprint: "pin-sha256:ABCD1234", + }, + { + name: "empty fingerprint", + settingName: "vpn", + cookie: "authcookie=xyz", + host: "10.0.0.1", + fingerprint: "", + }, + { + name: "complex cookie with special chars", + settingName: "vpn", + cookie: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100", + host: "connect.seclore.com", + fingerprint: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildGPSamlSecretsResponse(tt.settingName, tt.cookie, tt.host, tt.fingerprint) + + assert.NotNil(t, result) + assert.Contains(t, result, tt.settingName) + + vpnSec := result[tt.settingName] + assert.NotNil(t, vpnSec) + + secretsVariant, ok := vpnSec["secrets"] + assert.True(t, ok, "secrets key should exist") + + secrets, ok := secretsVariant.Value().(map[string]string) + assert.True(t, ok, "secrets should be map[string]string") + + assert.Equal(t, tt.cookie, secrets["cookie"]) + assert.Equal(t, tt.host, secrets["gateway"]) + assert.Equal(t, tt.fingerprint, secrets["gwcert"]) + }) + } +} + +func TestVpnFieldMeta_GPSaml(t *testing.T) { + label, isSecret := vpnFieldMeta("gp-saml", "org.freedesktop.NetworkManager.openconnect") + + assert.Equal(t, "GlobalProtect SAML/SSO", label) + assert.False(t, isSecret, "gp-saml should not be marked as secret") +} + +func TestVpnFieldMeta_StandardFields(t *testing.T) { + tests := []struct { + field string + vpnService string + expectedLabel string + expectedSecret bool + }{ + { + field: "username", + vpnService: "org.freedesktop.NetworkManager.openconnect", + expectedLabel: "Username", + expectedSecret: false, + }, + { + field: "password", + vpnService: "org.freedesktop.NetworkManager.openconnect", + expectedLabel: "Password", + expectedSecret: true, + }, + { + field: "key_pass", + vpnService: "org.freedesktop.NetworkManager.openconnect", + expectedLabel: "PIN", + expectedSecret: true, + }, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + label, isSecret := vpnFieldMeta(tt.field, tt.vpnService) + assert.Equal(t, tt.expectedLabel, label) + assert.Equal(t, tt.expectedSecret, isSecret) + }) + } +} + +func TestInferVPNFields_GPSaml(t *testing.T) { + tests := []struct { + name string + vpnService string + dataMap map[string]string + expectedLen int + shouldHave []string + }{ + { + name: "GP with no authtype and no username - should require SAML", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + }, + expectedLen: 1, + shouldHave: []string{"gp-saml"}, + }, + { + name: "GP with saml-auth-method REDIRECT", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "saml-auth-method": "REDIRECT", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"gp-saml"}, + }, + { + name: "GP with saml-auth-method POST", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "saml-auth-method": "POST", + }, + expectedLen: 1, + shouldHave: []string{"gp-saml"}, + }, + { + name: "GP with username and password authtype - should use credentials", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "authtype": "password", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"password"}, + }, + { + name: "GP with username but no authtype - password only", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"password"}, + }, + { + name: "GP with PKCS11 cert", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "gp", + "gateway": "vpn.example.com", + "authtype": "cert", + "usercert": "pkcs11:model=PKCS%2315%20emulated;manufacturer=piv_II", + }, + expectedLen: 1, + shouldHave: []string{"key_pass"}, + }, + { + name: "non-GP protocol (anyconnect)", + vpnService: "org.freedesktop.NetworkManager.openconnect", + dataMap: map[string]string{ + "protocol": "anyconnect", + "gateway": "vpn.example.com", + }, + expectedLen: 2, + shouldHave: []string{"username", "password"}, + }, + { + name: "OpenVPN with username", + vpnService: "org.freedesktop.NetworkManager.openvpn", + dataMap: map[string]string{ + "connection-type": "password", + "username": "john", + }, + expectedLen: 1, + shouldHave: []string{"password"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Convert dataMap to nmVariantMap + vpnSettings := make(nmVariantMap) + vpnSettings["data"] = dbus.MakeVariant(tt.dataMap) + vpnSettings["service-type"] = dbus.MakeVariant(tt.vpnService) + + conn := make(map[string]nmVariantMap) + conn["vpn"] = vpnSettings + + fields := inferVPNFields(conn, tt.vpnService) + + assert.Len(t, fields, tt.expectedLen, "unexpected number of fields") + if len(tt.shouldHave) > 0 { + for _, expected := range tt.shouldHave { + assert.Contains(t, fields, expected, "should contain field: %s", expected) + } + } + }) + } +} + +func TestNmVariantMap(t *testing.T) { + // Test that nmVariantMap and nmSettingMap work correctly + settingMap := make(nmSettingMap) + variantMap := make(nmVariantMap) + + variantMap["test-key"] = dbus.MakeVariant("test-value") + settingMap["test-setting"] = variantMap + + assert.Contains(t, settingMap, "test-setting") + assert.Contains(t, settingMap["test-setting"], "test-key") + + value := settingMap["test-setting"]["test-key"].Value() + assert.Equal(t, "test-value", value) +} diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index 9c48f562..2233975a 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -69,12 +69,14 @@ type NetworkManagerBackend struct { lastFailedTime int64 failedMutex sync.RWMutex - pendingVPNSave *pendingVPNCredentials - pendingVPNSaveMu sync.Mutex - cachedVPNCreds *cachedVPNCredentials - cachedVPNCredsMu sync.Mutex - cachedPKCS11PIN *cachedPKCS11PIN - cachedPKCS11Mu sync.Mutex + pendingVPNSave *pendingVPNCredentials + pendingVPNSaveMu sync.Mutex + cachedVPNCreds *cachedVPNCredentials + cachedVPNCredsMu sync.Mutex + cachedPKCS11PIN *cachedPKCS11PIN + cachedPKCS11Mu sync.Mutex + cachedGPSamlCookie *cachedGPSamlCookie + cachedGPSamlMu sync.Mutex onStateChange func() } @@ -97,6 +99,14 @@ type cachedPKCS11PIN struct { PIN string } +type cachedGPSamlCookie struct { + ConnectionUUID string + Cookie string + Host string + User string + Fingerprint string +} + func NewNetworkManagerBackend(nmConn ...gonetworkmanager.NetworkManager) (*NetworkManagerBackend, error) { var nm gonetworkmanager.NetworkManager var err error diff --git a/core/internal/server/network/backend_networkmanager_gp_saml.go b/core/internal/server/network/backend_networkmanager_gp_saml.go new file mode 100644 index 00000000..b1fa7fb8 --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_gp_saml.go @@ -0,0 +1,203 @@ +package network + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type gpSamlAuthResult struct { + Cookie string + Host string + User string + Fingerprint string +} + +// runGlobalProtectSAMLAuth handles GlobalProtect SAML/SSO authentication using gp-saml-gui. +// Only supports protocol=gp. Other protocols need their own implementations. +func (b *NetworkManagerBackend) runGlobalProtectSAMLAuth(ctx context.Context, gateway, protocol string) (*gpSamlAuthResult, error) { + if gateway == "" { + return nil, fmt.Errorf("GP SAML auth: gateway is empty") + } + if protocol != "gp" { + return nil, fmt.Errorf("only GlobalProtect (protocol=gp) SAML is supported, got: %s", protocol) + } + + log.Infof("[GP-SAML] Starting GlobalProtect SAML authentication with gp-saml-gui for gateway=%s", gateway) + + gpSamlPath, err := exec.LookPath("gp-saml-gui") + if err != nil { + return nil, fmt.Errorf("GlobalProtect SAML requires gp-saml-gui (install: pip install gp-saml-gui): %w", err) + } + + args := []string{ + "--gateway", + "--allow-insecure-crypto", + gateway, + } + + cmd := exec.CommandContext(ctx, gpSamlPath, args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to create stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to start gp-saml-gui: %w", err) + } + + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + log.Debugf("[GP-SAML] gp-saml-gui: %s", scanner.Text()) + } + }() + + result := &gpSamlAuthResult{Host: gateway} + var allOutput []string + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + allOutput = append(allOutput, line) + log.Infof("[GP-SAML] stdout: %s", line) + + switch { + case strings.HasPrefix(line, "COOKIE="): + result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE=")) + case strings.HasPrefix(line, "HOST="): + result.Host = unshellQuote(strings.TrimPrefix(line, "HOST=")) + case strings.HasPrefix(line, "USER="): + result.User = unshellQuote(strings.TrimPrefix(line, "USER=")) + case strings.HasPrefix(line, "FINGERPRINT="): + result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT=")) + default: + parseGPSamlFromCommandLine(line, result) + } + } + + if err := cmd.Wait(); err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("GP SAML auth timed out or was cancelled: %w", ctx.Err()) + } + if result.Cookie == "" { + return nil, fmt.Errorf("GP SAML auth failed: %w (output: %s)", err, strings.Join(allOutput, "\n")) + } + log.Warnf("[GP-SAML] gp-saml-gui exited with error but cookie was captured: %v", err) + } + + if result.Cookie == "" { + return nil, fmt.Errorf("GP SAML auth: no cookie in gp-saml-gui output") + } + + log.Infof("[GP-SAML] Got prelogin-cookie from gp-saml-gui, converting to openconnect cookie via --authenticate") + + // Convert prelogin-cookie to full openconnect cookie format + ocResult, err := convertGPPreloginCookie(ctx, gateway, result.Cookie, result.User) + if err != nil { + return nil, fmt.Errorf("GP SAML auth: failed to convert prelogin-cookie: %w", err) + } + + result.Cookie = ocResult.Cookie + result.Host = ocResult.Host + result.Fingerprint = ocResult.Fingerprint + + log.Infof("[GP-SAML] Authentication successful: user=%s, host=%s, cookie_len=%d, has_fingerprint=%v", + result.User, result.Host, len(result.Cookie), result.Fingerprint != "") + return result, nil +} + +func convertGPPreloginCookie(ctx context.Context, gateway, preloginCookie, user string) (*gpSamlAuthResult, error) { + ocPath, err := exec.LookPath("openconnect") + if err != nil { + return nil, fmt.Errorf("openconnect not found: %w", err) + } + + args := []string{ + "--protocol=gp", + "--usergroup=gateway:prelogin-cookie", + "--user=" + user, + "--passwd-on-stdin", + "--allow-insecure-crypto", + "--authenticate", + gateway, + } + + cmd := exec.CommandContext(ctx, ocPath, args...) + cmd.Stdin = strings.NewReader(preloginCookie) + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("openconnect --authenticate failed: %w\noutput: %s", err, string(output)) + } + + result := &gpSamlAuthResult{} + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "COOKIE="): + result.Cookie = unshellQuote(strings.TrimPrefix(line, "COOKIE=")) + case strings.HasPrefix(line, "HOST="): + result.Host = unshellQuote(strings.TrimPrefix(line, "HOST=")) + case strings.HasPrefix(line, "FINGERPRINT="): + result.Fingerprint = unshellQuote(strings.TrimPrefix(line, "FINGERPRINT=")) + case strings.HasPrefix(line, "CONNECT_URL="): + connectURL := unshellQuote(strings.TrimPrefix(line, "CONNECT_URL=")) + if connectURL != "" && result.Host == "" { + result.Host = connectURL + } + } + } + + if result.Cookie == "" { + return nil, fmt.Errorf("no COOKIE in openconnect --authenticate output: %s", string(output)) + } + + log.Infof("[GP-SAML] openconnect --authenticate: cookie_len=%d, host=%s, fingerprint=%s", + len(result.Cookie), result.Host, result.Fingerprint) + + return result, nil +} + +func unshellQuote(s string) string { + if len(s) >= 2 { + if (s[0] == '\'' && s[len(s)-1] == '\'') || + (s[0] == '"' && s[len(s)-1] == '"') { + return s[1 : len(s)-1] + } + } + return s +} + +func parseGPSamlFromCommandLine(line string, result *gpSamlAuthResult) { + if !strings.Contains(line, "openconnect") { + return + } + + for _, part := range strings.Fields(line) { + switch { + case strings.HasPrefix(part, "--cookie="): + if result.Cookie == "" { + result.Cookie = strings.TrimPrefix(part, "--cookie=") + } + case strings.HasPrefix(part, "--servercert="): + if result.Fingerprint == "" { + result.Fingerprint = strings.TrimPrefix(part, "--servercert=") + } + case strings.HasPrefix(part, "--user="): + if result.User == "" { + result.User = strings.TrimPrefix(part, "--user=") + } + } + } +} diff --git a/core/internal/server/network/backend_networkmanager_gp_saml_test.go b/core/internal/server/network/backend_networkmanager_gp_saml_test.go new file mode 100644 index 00000000..56558159 --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_gp_saml_test.go @@ -0,0 +1,169 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnshellQuote(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "single quoted", + input: "'hello world'", + expected: "hello world", + }, + { + name: "double quoted", + input: `"hello world"`, + expected: "hello world", + }, + { + name: "unquoted", + input: "hello", + expected: "hello", + }, + { + name: "empty single quotes", + input: "''", + expected: "", + }, + { + name: "empty double quotes", + input: `""`, + expected: "", + }, + { + name: "single quote only", + input: "'", + expected: "'", + }, + { + name: "mismatched quotes", + input: "'hello\"", + expected: "'hello\"", + }, + { + name: "with special chars", + input: "'cookie=abc123&user=john'", + expected: "cookie=abc123&user=john", + }, + { + name: "complex cookie", + input: `'authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100'`, + expected: "authcookie=077058d3bc81&portal=PANGP_GW_01-N&user=john.doe@example.com&domain=Default&preferred-ip=192.168.1.100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := unshellQuote(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseGPSamlFromCommandLine(t *testing.T) { + tests := []struct { + name string + line string + initialResult *gpSamlAuthResult + expectedCookie string + expectedUser string + expectedFP string + }{ + { + name: "full openconnect command", + line: "openconnect --protocol=gp --cookie=AUTH123 --servercert=pin-sha256:ABC --user=john", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "AUTH123", + expectedUser: "john", + expectedFP: "pin-sha256:ABC", + }, + { + name: "with equals signs in cookie", + line: "openconnect --cookie=authcookie=xyz123&portal=GATE --user=jane", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "authcookie=xyz123&portal=GATE", + expectedUser: "jane", + expectedFP: "", + }, + { + name: "non-openconnect line", + line: "some other output", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "", + expectedUser: "", + expectedFP: "", + }, + { + name: "preserves existing values", + line: "openconnect --user=newuser", + initialResult: &gpSamlAuthResult{Cookie: "existing", Fingerprint: "existing-fp"}, + expectedCookie: "existing", + expectedUser: "newuser", + expectedFP: "existing-fp", + }, + { + name: "only updates empty fields", + line: "openconnect --cookie=NEW --user=NEW", + initialResult: &gpSamlAuthResult{Cookie: "OLD"}, + expectedCookie: "OLD", + expectedUser: "NEW", + expectedFP: "", + }, + { + name: "real gp-saml-gui output", + line: "openconnect --protocol=gp --user=john.doe@example.com --os=linux-64 --usergroup=gateway:prelogin-cookie --passwd-on-stdin", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "", + expectedUser: "john.doe@example.com", + expectedFP: "", + }, + { + name: "with server cert flag", + line: "openconnect --servercert=pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE= vpn.example.com", + initialResult: &gpSamlAuthResult{}, + expectedCookie: "", + expectedUser: "", + expectedFP: "pin-sha256:xp3scfzy3rOgQEXnfPiYKrUk7D66a8b8O+gEXaMPleE=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.initialResult + parseGPSamlFromCommandLine(tt.line, result) + + assert.Equal(t, tt.expectedCookie, result.Cookie, "cookie mismatch") + assert.Equal(t, tt.expectedUser, result.User, "user mismatch") + assert.Equal(t, tt.expectedFP, result.Fingerprint, "fingerprint mismatch") + }) + } +} + +func TestParseGPSamlFromCommandLine_MultipleLines(t *testing.T) { + // Simulate gp-saml-gui output with command line suggestion + lines := []string{ + "", + "SAML REDIRECT", + "Got SAML Login URL", + "POST to ACS endpoint...", + "Got 'prelogin-cookie': 'FAKE_cookie_12345'", + "openconnect --protocol=gp --user=john.doe@example.com --usergroup=gateway:prelogin-cookie --passwd-on-stdin vpn.example.com", + "", + } + + result := &gpSamlAuthResult{} + for _, line := range lines { + parseGPSamlFromCommandLine(line, result) + } + + assert.Equal(t, "john.doe@example.com", result.User) + assert.Empty(t, result.Cookie, "cookie should not be parsed from command line") + assert.Empty(t, result.Fingerprint) +} diff --git a/core/internal/server/network/backend_networkmanager_vpn.go b/core/internal/server/network/backend_networkmanager_vpn.go index c8916301..261fcefa 100644 --- a/core/internal/server/network/backend_networkmanager_vpn.go +++ b/core/internal/server/network/backend_networkmanager_vpn.go @@ -304,6 +304,51 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil { return err } + case "gp_saml": + gateway := vpnData["gateway"] + protocol := vpnData["protocol"] + if protocol != "gp" { + return fmt.Errorf("GlobalProtect SAML authentication only supported for protocol=gp, got: %s", protocol) + } + + log.Infof("[ConnectVPN] GlobalProtect SAML/SSO authentication required for %s (gateway=%s)", connName, gateway) + + samlCtx, samlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + authResult, err := b.runGlobalProtectSAMLAuth(samlCtx, gateway, protocol) + samlCancel() + if err != nil { + errMsg := err.Error() + switch { + case strings.Contains(errMsg, "not installed"): + return fmt.Errorf("gp-saml-gui is not installed (required for GlobalProtect SAML/SSO VPN)") + case strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "cancelled"): + return fmt.Errorf("GlobalProtect SAML authentication timed out — please try again") + case strings.Contains(errMsg, "no cookie"): + return fmt.Errorf("GlobalProtect SAML login did not complete — browser was closed before authentication finished") + case strings.Contains(errMsg, "convert prelogin-cookie"): + return fmt.Errorf("GlobalProtect VPN authentication succeeded but cookie exchange failed: %w", err) + default: + return fmt.Errorf("GlobalProtect SAML authentication failed: %w", err) + } + } + + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = &cachedGPSamlCookie{ + ConnectionUUID: targetUUID, + Cookie: authResult.Cookie, + Host: authResult.Host, + User: authResult.User, + Fingerprint: authResult.Fingerprint, + } + b.cachedGPSamlMu.Unlock() + + if err := targetConn.ClearSecrets(); err != nil { + log.Warnf("[ConnectVPN] ClearSecrets failed (non-fatal): %v", err) + } else { + log.Infof("[ConnectVPN] Cleared stale stored secrets for %s", connName) + } + + log.Infof("[ConnectVPN] GlobalProtect SAML cookie cached for %s, proceeding with activation", connName) } b.stateMutex.Lock() @@ -339,6 +384,16 @@ func detectVPNAuthAction(serviceType string, data map[string]string) string { } switch { + case strings.Contains(serviceType, "openconnect"): + protocol := data["protocol"] + if needsExternalBrowserAuth(protocol, data["authtype"], data["username"], data) { + switch protocol { + case "gp": + return "gp_saml" + default: + log.Infof("[VPN] External browser auth detected for protocol '%s' but only GlobalProtect (gp) is currently supported", protocol) + } + } case strings.Contains(serviceType, "openvpn"): connType := data["connection-type"] username := data["username"] @@ -670,10 +725,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() { b.state.LastError = "" b.stateMutex.Unlock() - // Clear cached PKCS11 PIN on success + // Clear cached PKCS11 PIN and SAML cookie on success b.cachedPKCS11Mu.Lock() b.cachedPKCS11PIN = nil b.cachedPKCS11Mu.Unlock() + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = nil + b.cachedGPSamlMu.Unlock() b.pendingVPNSaveMu.Lock() pending := b.pendingVPNSave @@ -692,10 +750,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() { b.state.LastError = "VPN connection failed" b.stateMutex.Unlock() - // Clear cached PKCS11 PIN on failure + // Clear cached PKCS11 PIN and SAML cookie on failure b.cachedPKCS11Mu.Lock() b.cachedPKCS11PIN = nil b.cachedPKCS11Mu.Unlock() + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = nil + b.cachedGPSamlMu.Unlock() return } } @@ -709,10 +770,13 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() { b.state.LastError = "VPN connection failed" b.stateMutex.Unlock() - // Clear cached PKCS11 PIN + // Clear cached PKCS11 PIN and SAML cookie b.cachedPKCS11Mu.Lock() b.cachedPKCS11PIN = nil b.cachedPKCS11Mu.Unlock() + b.cachedGPSamlMu.Lock() + b.cachedGPSamlCookie = nil + b.cachedGPSamlMu.Unlock() } }