Skip to content

Commit cb74ce4

Browse files
authored
feat: support implicit OAuth in VS Code remote environments (#285)
* docs: remove developer preview banner and disclaimer Remove the "Developer Preview" note and disclaimer from the CLI README, the preview banner from the docs site theme, and all associated CSS. * feat: support implicit OAuth in VS Code remote environments (Codespaces) Add openBrowser and redirectUri options to ImplicitOAuthConfig, AuthCredentials, and CreateOAuthOptions so callers can customize the browser opener and redirect URI for implicit auth flows. The VS Code extension now uses vscode.env.openExternal (opens browser on the client) and vscode.env.asExternalUri (resolves localhost to the Codespaces forwarded port URL) so implicit OAuth works in remote environments where the `open` package cannot reach the user's browser. * fix: thread openBrowser/redirectUri through B2CInstance path The content tree, webdav tree, and log tailing features use configProvider.getInstance() which creates a B2CInstance with its own internal auth resolution. Thread redirectUri and openBrowser through OAuthAuthConfig, AuthConfig, createInstanceFromConfig, and createB2CInstance so these features also use vscode.env.openExternal in remote environments.
1 parent e131e19 commit cb74ce4

13 files changed

Lines changed: 98 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': minor
3+
'b2c-vs-extension': patch
4+
---
5+
6+
Add `openBrowser` and `redirectUri` options to OAuth strategy creation, allowing callers to customize how the browser is opened and which redirect URI is used during implicit auth. The VS Code extension now uses `vscode.env.openExternal` and `vscode.env.asExternalUri` so implicit OAuth works in Codespaces and other remote environments.

packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export interface ImplicitOAuthConfig {
4141
* The local server still listens on localPort regardless of this setting.
4242
*/
4343
redirectUri?: string;
44+
/**
45+
* Custom browser opener. Receives the authorization URL and should open it
46+
* in the user's browser. Useful in environments where the default `open` package
47+
* doesn't work (e.g., VS Code remote/Codespaces where `vscode.env.openExternal` is needed).
48+
*/
49+
openBrowser?: (url: string) => Promise<void>;
4450
}
4551

4652
/**
@@ -70,7 +76,7 @@ function getOauth2RedirectHTML(redirectUri: string): string {
7076
* Opens the system default browser to the specified URL.
7177
* Dynamically imports 'open' package to handle the browser opening.
7278
*/
73-
async function openBrowser(url: string): Promise<void> {
79+
async function openBrowserDefault(url: string): Promise<void> {
7480
try {
7581
// Dynamic import of 'open' package
7682
const open = await import('open');
@@ -325,9 +331,13 @@ export class ImplicitOAuthStrategy implements AuthStrategy {
325331
logger.info({url: authorizeUrl}, `Login URL: ${authorizeUrl}`);
326332
logger.info('If the URL does not open automatically, copy/paste it into a browser on this machine.');
327333

328-
// Attempt to open the browser
334+
// Attempt to open the browser (prefer injected opener, fall back to `open` package)
329335
logger.debug('[Auth] Attempting to open browser');
330-
await openBrowser(authorizeUrl);
336+
if (this.config.openBrowser) {
337+
await this.config.openBrowser(authorizeUrl);
338+
} else {
339+
await openBrowserDefault(authorizeUrl);
340+
}
331341

332342
return new Promise<AccessTokenResponse>((resolve, reject) => {
333343
const sockets: Set<Socket> = new Set();

packages/b2c-tooling-sdk/src/auth/resolve.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ export function resolveAuthStrategy(
182182
clientId: credentials.clientId,
183183
scopes: credentials.scopes,
184184
accountManagerHost: credentials.accountManagerHost,
185+
redirectUri: credentials.redirectUri,
186+
openBrowser: credentials.openBrowser,
185187
});
186188
}
187189
break;

packages/b2c-tooling-sdk/src/auth/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export interface OAuthAuthConfig {
5151
clientSecret?: string;
5252
scopes?: string[];
5353
accountManagerHost?: string;
54+
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
55+
redirectUri?: string;
56+
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
57+
openBrowser?: (url: string) => Promise<void>;
5458
}
5559

5660
/**
@@ -133,4 +137,8 @@ export interface AuthCredentials {
133137
apiKey?: string;
134138
/** Header name for API key (defaults to Authorization with Bearer prefix) */
135139
apiKeyHeaderName?: string;
140+
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
141+
redirectUri?: string;
142+
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
143+
openBrowser?: (url: string) => Promise<void>;
136144
}

packages/b2c-tooling-sdk/src/config/mapping.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,10 @@ export function buildAuthConfigFromNormalized(config: NormalizedConfig): AuthCon
478478
* await instance.webdav.mkcol('Cartridges/v1');
479479
* ```
480480
*/
481-
export function createInstanceFromConfig(config: NormalizedConfig): B2CInstance {
481+
export function createInstanceFromConfig(
482+
config: NormalizedConfig,
483+
options?: {redirectUri?: string; openBrowser?: (url: string) => Promise<void>},
484+
): B2CInstance {
482485
if (!config.hostname) {
483486
throw new Error('Hostname is required. Set in dw.json or provide via overrides.');
484487
}
@@ -500,5 +503,14 @@ export function createInstanceFromConfig(config: NormalizedConfig): B2CInstance
500503

501504
const authConfig = buildAuthConfigFromNormalized(config);
502505

506+
// Inject implicit auth options into OAuth config when present
507+
if (authConfig.oauth && (options?.redirectUri || options?.openBrowser)) {
508+
authConfig.oauth = {
509+
...authConfig.oauth,
510+
redirectUri: options.redirectUri,
511+
openBrowser: options.openBrowser,
512+
};
513+
}
514+
503515
return new B2CInstance(instanceConfig, authConfig);
504516
}

packages/b2c-tooling-sdk/src/config/resolved-config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ export class ResolvedConfigImpl implements ResolvedB2CConfig {
5555

5656
// Factory methods
5757

58-
createB2CInstance(): B2CInstance {
58+
createB2CInstance(options?: Pick<CreateOAuthOptions, 'redirectUri' | 'openBrowser'>): B2CInstance {
5959
if (!this.hasB2CInstanceConfig()) {
6060
throw new Error('B2C instance requires hostname');
6161
}
62-
return createInstanceFromConfig(this.values);
62+
return createInstanceFromConfig(this.values, options);
6363
}
6464

6565
createBasicAuth(): AuthStrategy {
@@ -82,6 +82,8 @@ export class ResolvedConfigImpl implements ResolvedB2CConfig {
8282
clientSecret: this.values.clientSecret,
8383
scopes: mergedScopes.length > 0 ? mergedScopes : undefined,
8484
accountManagerHost: this.values.accountManagerHost,
85+
redirectUri: options?.redirectUri,
86+
openBrowser: options?.openBrowser,
8587
};
8688
return resolveAuthStrategy(credentials, {allowedMethods: options?.allowedMethods});
8789
}

packages/b2c-tooling-sdk/src/config/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,10 @@ export interface CreateOAuthOptions {
326326
allowedMethods?: AuthMethod[];
327327
/** Additional OAuth scopes to request beyond those in config */
328328
scopes?: string[];
329+
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
330+
redirectUri?: string;
331+
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
332+
openBrowser?: (url: string) => Promise<void>;
329333
}
330334

331335
/**
@@ -422,9 +426,10 @@ export interface ResolvedB2CConfig {
422426

423427
/**
424428
* Creates a B2CInstance from the resolved configuration.
429+
* @param options - Options for implicit OAuth (redirectUri, openBrowser)
425430
* @throws Error if hostname is not configured
426431
*/
427-
createB2CInstance(): B2CInstance;
432+
createB2CInstance(options?: Pick<CreateOAuthOptions, 'redirectUri' | 'openBrowser'>): B2CInstance;
428433

429434
/**
430435
* Creates a Basic auth strategy.

packages/b2c-tooling-sdk/src/instance/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ export class B2CInstance {
184184
clientSecret: this.auth.oauth.clientSecret,
185185
scopes: this.auth.oauth.scopes,
186186
accountManagerHost: this.auth.oauth.accountManagerHost,
187+
redirectUri: this.auth.oauth.redirectUri,
188+
openBrowser: this.auth.oauth.openBrowser,
187189
};
188190

189191
// Filter to only OAuth methods (client-credentials, implicit)

packages/b2c-vs-extension/src/api-browser/api-browser-tree-provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ export class ApiBrowserTreeDataProvider implements vscode.TreeDataProvider<ApiBr
127127
const schemas = await vscode.window.withProgress(
128128
{location: {viewId: 'b2cApiBrowser'}, title: 'Loading SCAPI schemas...'},
129129
async () => {
130-
const oauthStrategy = config.createOAuth();
130+
const oauthOptions = await this.configProvider.getImplicitAuthOptions();
131+
const oauthStrategy = config.createOAuth(oauthOptions);
131132
const schemasClient = createScapiSchemasClient({shortCode, tenantId}, oauthStrategy);
132133
const orgId = toOrganizationId(tenantId);
133134
const {data, error, response} = await schemasClient.GET('/organizations/{organizationId}/schemas', {

packages/b2c-vs-extension/src/api-browser/swagger-webview.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ export class SwaggerWebviewManager implements vscode.Disposable {
218218

219219
// Resolve external $refs server-side so the webview doesn't have to fetch them
220220
if (config.hasOAuthConfig()) {
221-
const oauthStrategy = config.createOAuth();
221+
const oauthOptions = await this.configProvider.getImplicitAuthOptions();
222+
const oauthStrategy = config.createOAuth(oauthOptions);
222223
const authHeader = await oauthStrategy.getAuthorizationHeader?.();
223224
await resolveExternalRefs(spec, authHeader, this.log);
224225
}
@@ -308,7 +309,8 @@ export class SwaggerWebviewManager implements vscode.Disposable {
308309
const tenantId = deriveTenantId(config.values.hostname);
309310
if (!tenantId) throw new Error('Could not derive tenant ID from hostname.');
310311

311-
const oauthStrategy = config.createOAuth();
312+
const oauthOptions = await this.configProvider.getImplicitAuthOptions();
313+
const oauthStrategy = config.createOAuth(oauthOptions);
312314
const schemasClient = createScapiSchemasClient({shortCode, tenantId}, oauthStrategy);
313315
const orgId = toOrganizationId(tenantId);
314316
const {data, error, response} = await schemasClient.GET(
@@ -452,7 +454,8 @@ export class SwaggerWebviewManager implements vscode.Disposable {
452454
if (!config.hasOAuthConfig()) return null;
453455
// Request the specific scopes required by this API spec so the token
454456
// includes them (the cache will re-authenticate if scopes are missing)
455-
const oauthStrategy = config.createOAuth({scopes});
457+
const oauthOptions = await this.configProvider.getImplicitAuthOptions();
458+
const oauthStrategy = config.createOAuth({...oauthOptions, scopes});
456459
const header = await oauthStrategy.getAuthorizationHeader?.();
457460
if (!header) return null;
458461
// Header is "Bearer <token>" — extract the token
@@ -510,7 +513,8 @@ export class SwaggerWebviewManager implements vscode.Disposable {
510513

511514
try {
512515
this.log.appendLine(`[API Browser] Auto-discovering SLAS client for tenant ${tenantId}...`);
513-
const oauthStrategy = config.createOAuth();
516+
const oauthOptions = await this.configProvider.getImplicitAuthOptions();
517+
const oauthStrategy = config.createOAuth(oauthOptions);
514518
const slasClient = createSlasClient({shortCode}, oauthStrategy);
515519

516520
const {data, error} = await slasClient.GET('/tenants/{tenantId}/clients', {

0 commit comments

Comments
 (0)