Skip to content

Commit 2aadd49

Browse files
authored
feat(IDE-1701): settings page auth flow — bridge persist and forward apiUrl (#366)
* feat: inject __ideExecuteCommand__ bridge in settings page and bump protocol version to 25 [IDE-1701] Replace __ideLogin__/__ideLogout__ BrowserFunctions with a generic __ideExecuteCommand__ bridge that dispatches any LS command with callback support. Bump REQUIRED_LS_PROTOCOL_VERSION to 25. * refactor: extract ExecuteCommandBridge to shared class for browser reuse [IDE-1701] Move the __ideExecuteCommandBridge__ BrowserFunction and JS wrapper injection into a standalone ExecuteCommandBridge class. HTMLSettingsPreferencePage delegates to ExecuteCommandBridge.install(browser), enabling any future SWT Browser panel (e.g. tree view) to reuse the same bridge. * feat(IDE-1701): save login args from settings page and remove persist flag - Remove persist field from HasAuthenticatedParam — always saves token and apiUrl - Restore original hasAuthenticated flow: always stores endpoint + token, calls configurationUpdater.configurationChanged(), triggers scan when conditions met - Keep notifyAuthTokenChanged(token, apiUrl) with apiUrl param — settings page webview always shows current token after auth - Add bridge persist in ExecuteCommandBridge.registerBridgeFunction: when snyk.login called with 3+ args, save authMethod/endpoint/insecure to Preferences directly (no configurationChanged() → no didChangeConfiguration to LS) - Remove setPersist() calls and persist-related tests from SnykExtendedLanguageClientTest - Add saveLoginArgs() tests to ExecuteCommandBridgeTest covering auth method mapping, endpoint, and insecure saving * feat(IDE-1701): forward apiUrl in notifyAuthTokenChanged for settings page browser Pass apiUrl alongside token when calling window.setAuthToken in the browser so the settings page can update both the token and apiUrl fields after auth. * feat(IDE-1701): apply auth bridge pattern to both settings pages - Add singleton + notifyAuthTokenChanged to native PreferencesPage so the SWT token field is updated live when hasAuthenticated fires, not just the HTML webview - Reorder hasAuthenticated() to update UIs before persisting to storage, avoiding race conditions where a settings-changed event re-reads stale values - Escape token and apiUrl before JS interpolation in notifyAuthTokenChanged to guard against single-quote injection - Fix dispose() identity check (== instead of .equals()) in both settings pages * fix: add updateToken method to TokenFieldEditor and fix Java 21 Mockito compatibility - Add public updateToken() method to TokenFieldEditor to expose setStringValue() from outside the class (setStringValue is protected in StringFieldEditor) - Fix test failures on Java 21 by adding -XX:+EnableDynamicAgentLoading to tycho-surefire-plugin argLine, required for mockito-inline 4.5.1 to do byte-buddy instrumentation * fix: lint * fix: remove stale PMD.CompareObjectsWithEquals suppression * fix: catch specific JsonProcessingException instead of generic Exception in ExecuteCommandBridge * security: restrict webview executeCommand bridge to snyk.* namespace Prevents XSS-to-arbitrary-command escalation by rejecting any command not prefixed with "snyk." before it reaches the Language Server. * refactor: extract JS string escaping into reusable utility method Pull escapeForJsString() into ExecuteCommandBridge and replace inline escaping in both ExecuteCommandBridge and HTMLSettingsPreferencePage. Add explicit test for default (oauth) auth method fallback.
1 parent 348544b commit 2aadd49

File tree

10 files changed

+382
-56
lines changed

10 files changed

+382
-56
lines changed
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package io.snyk.eclipse.plugin;
22

3-
public interface EnvironmentConstants {
4-
String ENV_SNYK_API = "SNYK_API";
5-
String ENV_SNYK_TOKEN = "SNYK_TOKEN";
6-
String ENV_SNYK_ORG = "SNYK_CFG_ORG";
7-
String ENV_DISABLE_ANALYTICS = "SNYK_CFG_DISABLE_ANALYTICS";
8-
String ENV_INTERNAL_SNYK_OAUTH_ENABLED = "INTERNAL_SNYK_OAUTH_ENABLED";
9-
String ENV_INTERNAL_OAUTH_TOKEN_STORAGE = "INTERNAL_OAUTH_TOKEN_STORAGE";
10-
String ENV_OAUTH_ACCESS_TOKEN = "SNYK_OAUTH_TOKEN";
3+
public final class EnvironmentConstants {
4+
private EnvironmentConstants() {}
5+
6+
public static final String ENV_SNYK_API = "SNYK_API";
7+
public static final String ENV_SNYK_TOKEN = "SNYK_TOKEN";
8+
public static final String ENV_SNYK_ORG = "SNYK_CFG_ORG";
9+
public static final String ENV_DISABLE_ANALYTICS = "SNYK_CFG_DISABLE_ANALYTICS";
10+
public static final String ENV_INTERNAL_SNYK_OAUTH_ENABLED = "INTERNAL_SNYK_OAUTH_ENABLED";
11+
public static final String ENV_INTERNAL_OAUTH_TOKEN_STORAGE = "INTERNAL_OAUTH_TOKEN_STORAGE";
12+
public static final String ENV_OAUTH_ACCESS_TOKEN = "SNYK_OAUTH_TOKEN";
1113
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package io.snyk.eclipse.plugin.html;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import io.snyk.eclipse.plugin.preferences.AuthConstants;
6+
import io.snyk.eclipse.plugin.preferences.Preferences;
7+
import io.snyk.eclipse.plugin.utils.SnykLogger;
8+
import io.snyk.languageserver.CommandHandler;
9+
import java.util.Arrays;
10+
import java.util.Collections;
11+
import java.util.List;
12+
import org.eclipse.swt.browser.Browser;
13+
import org.eclipse.swt.browser.BrowserFunction;
14+
import org.eclipse.swt.browser.ProgressAdapter;
15+
import org.eclipse.swt.browser.ProgressEvent;
16+
import org.eclipse.swt.widgets.Display;
17+
18+
/**
19+
* Shared bridge for the window.__ideExecuteCommand__ JS↔IDE contract. Usable by any SWT Browser
20+
* panel (settings preference page, tree view, etc.).
21+
*
22+
* <p>Responsibilities:
23+
*
24+
* <ul>
25+
* <li>Register the native {@code __ideExecuteCommandBridge__} BrowserFunction that receives calls
26+
* from JavaScript.
27+
* <li>Inject the client-side JS wrapper that defines {@code window.__ideExecuteCommand__}.
28+
* <li>Dispatch commands to the Language Server and return results via the JS callback registry.
29+
* </ul>
30+
*/
31+
public class ExecuteCommandBridge {
32+
33+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
34+
35+
/**
36+
* Registers the {@code __ideExecuteCommandBridge__} BrowserFunction and adds a ProgressListener
37+
* that injects the client-side JS wrapper after each page load.
38+
*/
39+
public static void install(Browser browser) {
40+
registerBridgeFunction(browser);
41+
browser.addProgressListener(
42+
new ProgressAdapter() {
43+
@Override
44+
public void completed(ProgressEvent event) {
45+
injectScript(browser);
46+
}
47+
});
48+
}
49+
50+
/**
51+
* Returns the client-side JavaScript that defines {@code window.__ideExecuteCommand__} in the
52+
* browser. Assumes {@code __ideExecuteCommandBridge__} BrowserFunction is registered.
53+
*/
54+
public static String buildClientScript() {
55+
return "(function() {"
56+
+ " if (window.__ideCallbacks__) { return; }"
57+
+ " window.__ideCallbacks__ = {};"
58+
+ " var __cbCounter = 0;"
59+
+ " window.__ideExecuteCommand__ = function(command, args, callback) {"
60+
+ " var callbackId = '';"
61+
+ " if (typeof callback === 'function') {"
62+
+ " callbackId = '__cb_' + (++__cbCounter);"
63+
+ " window.__ideCallbacks__[callbackId] = callback;"
64+
+ " }"
65+
+ " __ideExecuteCommandBridge__(command, JSON.stringify(args || []), callbackId);"
66+
+ " };"
67+
+ "})();";
68+
}
69+
70+
/**
71+
* Injects the client-side JS wrapper into the browser. Safe to call on any page load; the script
72+
* is idempotent (guarded by {@code window.__ideCallbacks__} check).
73+
*/
74+
public static void injectScript(Browser browser) {
75+
if (browser == null || browser.isDisposed()) {
76+
return;
77+
}
78+
browser.execute(buildClientScript());
79+
}
80+
81+
/**
82+
* Escapes a string for safe inclusion inside a single-quoted JavaScript string literal.
83+
* Backslashes and single quotes are escaped.
84+
*/
85+
public static String escapeForJsString(String value) {
86+
if (value == null) {
87+
return "";
88+
}
89+
return value.replace("\\", "\\\\").replace("'", "\\'");
90+
}
91+
92+
/** Returns true if the command is in the snyk.* namespace and may be dispatched from a webview. */
93+
static boolean isAllowedCommand(String command) {
94+
return command != null && command.startsWith("snyk.");
95+
}
96+
97+
static void saveLoginArgs(List<Object> args) {
98+
String authMethodStr = String.valueOf(args.get(0));
99+
String authMethod;
100+
switch (authMethodStr) {
101+
case "pat":
102+
authMethod = AuthConstants.AUTH_PERSONAL_ACCESS_TOKEN;
103+
break;
104+
case "token":
105+
authMethod = AuthConstants.AUTH_API_TOKEN;
106+
break;
107+
default:
108+
authMethod = AuthConstants.AUTH_OAUTH2;
109+
break;
110+
}
111+
String endpoint = args.get(1) != null ? String.valueOf(args.get(1)) : "";
112+
String insecure = String.valueOf(args.get(2));
113+
Preferences prefs = Preferences.getInstance();
114+
prefs.store(Preferences.AUTHENTICATION_METHOD, authMethod);
115+
prefs.store(Preferences.ENDPOINT_KEY, endpoint);
116+
prefs.store(Preferences.INSECURE_KEY, insecure);
117+
}
118+
119+
private static void registerBridgeFunction(Browser browser) {
120+
new BrowserFunction(browser, "__ideExecuteCommandBridge__") {
121+
@Override
122+
public Object function(Object[] arguments) {
123+
if (arguments.length < 1 || !(arguments[0] instanceof String)) {
124+
return null;
125+
}
126+
String command = (String) arguments[0];
127+
if (!isAllowedCommand(command)) {
128+
SnykLogger.logInfo("Webview attempted to execute disallowed command: " + command);
129+
return null;
130+
}
131+
String argsJson =
132+
arguments.length > 1 && arguments[1] instanceof String ? (String) arguments[1] : "[]";
133+
String callbackId =
134+
arguments.length > 2 && arguments[2] instanceof String ? (String) arguments[2] : "";
135+
136+
List<Object> args;
137+
try {
138+
args = Arrays.asList(OBJECT_MAPPER.readValue(argsJson, Object[].class));
139+
} catch (JsonProcessingException e) {
140+
SnykLogger.logError(e);
141+
args = Collections.emptyList();
142+
}
143+
144+
if ("snyk.login".equals(command) && args.size() >= 3) {
145+
saveLoginArgs(args);
146+
}
147+
148+
final List<Object> finalArgs = args;
149+
final String finalCallbackId = callbackId;
150+
CommandHandler.getInstance()
151+
.executeCommand(command, finalArgs)
152+
.thenAccept(
153+
result -> {
154+
if (finalCallbackId == null || finalCallbackId.isEmpty()) {
155+
return;
156+
}
157+
try {
158+
String resultJson =
159+
result == null ? "null" : OBJECT_MAPPER.writeValueAsString(result);
160+
String escaped = escapeForJsString(finalCallbackId);
161+
String script =
162+
"if(window.__ideCallbacks__&&window.__ideCallbacks__['"
163+
+ escaped
164+
+ "']){"
165+
+ "var cb=window.__ideCallbacks__['"
166+
+ escaped
167+
+ "'];"
168+
+ "delete window.__ideCallbacks__['"
169+
+ escaped
170+
+ "'];"
171+
+ "cb("
172+
+ resultJson
173+
+ ");}";
174+
Display.getDefault()
175+
.asyncExec(
176+
() -> {
177+
if (!browser.isDisposed()) {
178+
browser.evaluate(script);
179+
}
180+
});
181+
} catch (JsonProcessingException e) {
182+
SnykLogger.logError(e);
183+
}
184+
});
185+
return null;
186+
}
187+
};
188+
}
189+
}

plugin/src/main/java/io/snyk/eclipse/plugin/preferences/HTMLSettingsPreferencePage.java

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.core.JsonProcessingException;
44
import com.fasterxml.jackson.databind.ObjectMapper;
55
import io.snyk.eclipse.plugin.html.BaseHtmlProvider;
6+
import io.snyk.eclipse.plugin.html.ExecuteCommandBridge;
67
import io.snyk.eclipse.plugin.properties.FolderConfigs;
78
import io.snyk.eclipse.plugin.utils.SnykLogger;
89
import io.snyk.eclipse.plugin.views.snyktoolview.handlers.IHandlerCommands;
@@ -37,7 +38,6 @@ public class HTMLSettingsPreferencePage extends PreferencePage implements IWorkb
3738
private Browser browser;
3839
private final ObjectMapper objectMapper = new ObjectMapper();
3940
private final BaseHtmlProvider htmlProvider = new BaseHtmlProvider();
40-
private final Object authLock = new Object();
4141

4242
@SuppressWarnings("PMD.AssignmentToNonFinalStatic")
4343
public HTMLSettingsPreferencePage() {
@@ -75,43 +75,7 @@ public Object function(Object[] arguments) {
7575
}
7676
};
7777

78-
new BrowserFunction(browser, "__ideLogin__") {
79-
@Override
80-
public Object function(Object[] arguments) {
81-
CompletableFuture.runAsync(
82-
() -> {
83-
synchronized (authLock) {
84-
SnykExtendedLanguageClient lc = SnykExtendedLanguageClient.getInstance();
85-
if (lc == null) {
86-
SnykLogger.logError(
87-
new IllegalStateException("Language client instance is null during login"));
88-
return;
89-
}
90-
lc.triggerAuthentication();
91-
}
92-
});
93-
return null;
94-
}
95-
};
96-
97-
new BrowserFunction(browser, "__ideLogout__") {
98-
@Override
99-
public Object function(Object[] arguments) {
100-
CompletableFuture.runAsync(
101-
() -> {
102-
synchronized (authLock) {
103-
SnykExtendedLanguageClient lc = SnykExtendedLanguageClient.getInstance();
104-
if (lc == null) {
105-
SnykLogger.logError(
106-
new IllegalStateException("Language client instance is null during logout"));
107-
return;
108-
}
109-
lc.logout();
110-
}
111-
});
112-
return null;
113-
}
114-
};
78+
ExecuteCommandBridge.install(browser);
11579
}
11680

11781
private void loadContent() {
@@ -389,23 +353,27 @@ public boolean performOk() {
389353
@Override
390354
@SuppressWarnings("PMD.NullAssignment")
391355
public void dispose() {
392-
if (this.equals(instance)) {
356+
if (instance != null && instance.equals(this)) {
393357
instance = null;
394358
}
395359
super.dispose();
396360
}
397361

398-
public static void notifyAuthTokenChanged(String token) {
362+
public static void notifyAuthTokenChanged(String token, String apiUrl) {
399363
if (instance != null && instance.browser != null && !instance.browser.isDisposed()) {
400364
Display.getDefault()
401365
.asyncExec(
402366
() -> {
403367
if (instance != null
404368
&& instance.browser != null
405369
&& !instance.browser.isDisposed()) {
370+
String safeToken = ExecuteCommandBridge.escapeForJsString(token);
371+
String safeApiUrl = ExecuteCommandBridge.escapeForJsString(apiUrl);
406372
instance.browser.evaluate(
407373
"if (typeof window.setAuthToken === 'function') { window.setAuthToken('"
408-
+ token
374+
+ safeToken
375+
+ "', '"
376+
+ safeApiUrl
409377
+ "'); }");
410378
}
411379
});

plugin/src/main/java/io/snyk/eclipse/plugin/preferences/PreferencesPage.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import io.snyk.languageserver.protocolextension.SnykExtendedLanguageClient;
2727

2828
public class PreferencesPage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage {
29+
private static volatile PreferencesPage instance;
30+
2931
private BooleanFieldEditor snykCodeSecurityCheckbox;
3032
private ComboFieldEditor authenticationEditor;
3133
private StringFieldEditor endpoint;
@@ -34,8 +36,10 @@ public class PreferencesPage extends FieldEditorPreferencePage implements IWorkb
3436

3537
public static final int WIDTH = 60;
3638

39+
@SuppressWarnings("PMD.AssignmentToNonFinalStatic")
3740
public PreferencesPage() {
3841
super(GRID);
42+
instance = this;
3943
}
4044

4145
@Override
@@ -197,6 +201,25 @@ private FieldEditor space() {
197201
return new LabelFieldEditor("", getFieldEditorParent());
198202
}
199203

204+
@Override
205+
@SuppressWarnings("PMD.NullAssignment")
206+
public void dispose() {
207+
if (instance != null && instance.equals(this)) {
208+
instance = null;
209+
}
210+
super.dispose();
211+
}
212+
213+
public static void notifyAuthTokenChanged(String token) {
214+
if (instance != null && instance.tokenField != null) {
215+
Display.getDefault().asyncExec(() -> {
216+
if (instance != null && instance.tokenField != null) {
217+
instance.tokenField.updateToken(token);
218+
}
219+
});
220+
}
221+
}
222+
200223
@Override
201224
public boolean performOk() {
202225
boolean superOK = super.performOk();

plugin/src/main/java/io/snyk/eclipse/plugin/preferences/TokenFieldEditor.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public void emptyTextfield() {
2626
setStringValue("");
2727
}
2828

29+
public void updateToken(String token) {
30+
setStringValue(token);
31+
}
32+
2933
@Override
3034
public void setPreferenceStore(IPreferenceStore store) {
3135
// we don't let the page override the preference store to a non-secure store

plugin/src/main/java/io/snyk/eclipse/plugin/views/snyktoolview/SnykToolView.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ public void refreshTree() {
353353
@Override
354354
public void refreshBrowser(String status) {
355355
Display.getDefault().asyncExec(() -> {
356-
if (null != status && status.equals(SCAN_STATE_IN_PROGRESS)) {
356+
if (SCAN_STATE_IN_PROGRESS.equals(status)) {
357357
this.browserHandler.setScanningBrowserText();
358358
} else {
359359
this.browserHandler.setDefaultBrowserText();

plugin/src/main/java/io/snyk/languageserver/download/LsBinaries.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
public class LsBinaries {
1010
private static final Preferences PREFERENCES = Preferences.getInstance();
11-
public static final String REQUIRED_LS_PROTOCOL_VERSION = "23";
11+
public static final String REQUIRED_LS_PROTOCOL_VERSION = "24";
1212

1313
public static URI getBaseUri() {
1414
return URI.create(PREFERENCES.getPref(CLI_BASE_URL));

0 commit comments

Comments
 (0)