Skip to content

Commit 5d02344

Browse files
committed
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.
1 parent e4516fa commit 5d02344

11 files changed

Lines changed: 73 additions & 16 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,8 @@ export interface AuthCredentials {
133133
apiKey?: string;
134134
/** Header name for API key (defaults to Authorization with Bearer prefix) */
135135
apiKeyHeaderName?: string;
136+
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
137+
redirectUri?: string;
138+
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
139+
openBrowser?: (url: string) => Promise<void>;
136140
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 4 additions & 0 deletions
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
/**

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', {

packages/b2c-vs-extension/src/config-provider.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
EnvSource,
99
type NormalizedConfig,
1010
type ResolvedB2CConfig,
11+
type CreateOAuthOptions,
1112
} from '@salesforce/b2c-tooling-sdk/config';
1213
import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance';
1314
import * as fs from 'fs';
@@ -234,6 +235,26 @@ export class B2CExtensionConfig implements vscode.Disposable {
234235
return resolveConfig(overrides, {workingDirectory});
235236
}
236237

238+
/**
239+
* Returns CreateOAuthOptions with VS Code-specific overrides for implicit auth:
240+
* - Uses `vscode.env.openExternal` to open the browser on the client (works in Codespaces/remote)
241+
* - Uses `vscode.env.asExternalUri` to resolve the redirect URI for port forwarding
242+
*
243+
* Merge with any additional options before passing to `config.createOAuth()`.
244+
*/
245+
async getImplicitAuthOptions(): Promise<CreateOAuthOptions> {
246+
const localPort = parseInt(process.env.SFCC_OAUTH_LOCAL_PORT || '', 10) || 8080;
247+
const localUri = vscode.Uri.parse(`http://localhost:${localPort}`);
248+
const externalUri = await vscode.env.asExternalUri(localUri);
249+
250+
return {
251+
redirectUri: process.env.SFCC_REDIRECT_URI || externalUri.toString(/* skipEncoding */ true),
252+
openBrowser: async (url: string) => {
253+
await vscode.env.openExternal(vscode.Uri.parse(url));
254+
},
255+
};
256+
}
257+
237258
dispose(): void {
238259
this._onDidReset.dispose();
239260
for (const d of this.disposables) {

packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import type {RealmTreeItem, SandboxTreeDataProvider, SandboxTreeItem} from './sa
1111

1212
const DEFAULT_ODS_HOST = 'admin.dx.commercecloud.salesforce.com';
1313

14-
function getOdsClientFromConfig(configProvider: SandboxConfigProvider) {
14+
async function getOdsClientFromConfig(configProvider: SandboxConfigProvider) {
1515
const config = configProvider.getConfigProvider().getConfig();
1616
if (!config) throw new Error('No B2C Commerce configuration found. Configure dw.json or SFCC_* env vars.');
1717
if (!config.hasOAuthConfig())
1818
throw new Error('OAuth credentials required. Set clientId and clientSecret in dw.json.');
1919
const host = config.values.sandboxApiHost ?? DEFAULT_ODS_HOST;
20-
return createOdsClient({host}, config.createOAuth());
20+
const oauthOptions = await configProvider.getConfigProvider().getImplicitAuthOptions();
21+
return createOdsClient({host}, config.createOAuth(oauthOptions));
2122
}
2223

2324
const SANDBOX_DETAIL_SCHEME = 'b2c-sandbox';
@@ -103,7 +104,7 @@ export function registerSandboxCommands(
103104
{location: vscode.ProgressLocation.Notification, title: `Creating sandbox in realm ${realm}...`},
104105
async () => {
105106
try {
106-
const odsClient = getOdsClientFromConfig(configProvider);
107+
const odsClient = await getOdsClientFromConfig(configProvider);
107108
const result = await odsClient.POST('/sandboxes', {
108109
body: {realm: realm!, ttl, analyticsEnabled: false},
109110
});
@@ -139,7 +140,7 @@ export function registerSandboxCommands(
139140
{location: vscode.ProgressLocation.Notification, title: `Deleting sandbox ${node.sandbox.id}...`},
140141
async () => {
141142
try {
142-
const odsClient = getOdsClientFromConfig(configProvider);
143+
const odsClient = await getOdsClientFromConfig(configProvider);
143144
const result = await odsClient.DELETE('/sandboxes/{sandboxId}', {
144145
params: {path: {sandboxId: node.sandbox.id}},
145146
});
@@ -180,7 +181,7 @@ export function registerSandboxCommands(
180181
},
181182
async () => {
182183
try {
183-
const odsClient = getOdsClientFromConfig(configProvider);
184+
const odsClient = await getOdsClientFromConfig(configProvider);
184185
const result = await odsClient.POST('/sandboxes/{sandboxId}/operations', {
185186
params: {path: {sandboxId: node.sandbox.id}},
186187
body: {operation: operationType},
@@ -264,7 +265,7 @@ export function registerSandboxCommands(
264265
},
265266
async () => {
266267
try {
267-
const odsClient = getOdsClientFromConfig(configProvider);
268+
const odsClient = await getOdsClientFromConfig(configProvider);
268269
const result = await odsClient.PATCH('/sandboxes/{sandboxId}', {
269270
params: {path: {sandboxId: node.sandbox.id}},
270271
body: {ttl},

0 commit comments

Comments
 (0)