Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/implicit-auth-vscode-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@salesforce/b2c-tooling-sdk': minor
'b2c-vs-extension': patch
---

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.
68 changes: 0 additions & 68 deletions docs/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,11 @@
--vp-c-brand-2: #0090c8;
--vp-c-brand-3: #007eb0;
--vp-c-brand-soft: rgba(0, 161, 224, 0.14);
--vp-banner-height: 38px;
/* Hero name gradient (Salesforce blue) */
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(135deg, #00A1E0 0%, #0070D2 100%);
}

/* Developer Preview Banner */
.preview-banner {
background: linear-gradient(90deg, #0070d2 0%, #00a1e0 100%);
color: white;
text-align: center;
padding: 8px 16px;
font-size: 14px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 60;
min-height: var(--vp-banner-height);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}

.preview-banner a {
color: white;
text-decoration: underline;
margin-left: 8px;
}

.preview-banner a:hover {
opacity: 0.9;
}

/* Adjust VitePress layout for banner */
.VPNav {
top: var(--vp-banner-height) !important;
}

.VPSidebar {
top: calc(var(--vp-nav-height) + var(--vp-banner-height)) !important;
height: calc(100vh - var(--vp-nav-height) - var(--vp-banner-height)) !important;
}

.VPContent {
padding-top: calc(var(--vp-nav-height) + var(--vp-banner-height)) !important;
}

.VPLocalNav {
top: calc(var(--vp-nav-height) + var(--vp-banner-height)) !important;
}

/* Mobile/tablet: banner scrolls with page when sidebar is hidden */
@media (max-width: 959px) {
.preview-banner {
position: relative;
}

.VPNav {
top: 0 !important;
}

.VPContent {
padding-top: 0 !important;
}

.VPLocalNav {
top: 0 !important;
}
}

.dark {
--vp-c-brand-1: #00A1E0;
--vp-c-brand-2: #33b4e6;
Expand Down
10 changes: 0 additions & 10 deletions docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@ export default {
extends: DefaultTheme,
Layout() {
return h(DefaultTheme.Layout, null, {
'layout-top': () =>
h('div', {class: 'preview-banner'}, [
h('strong', 'Developer Preview'),
' — This project is in active development. APIs may change. ',
h(
'a',
{href: 'https://github.com/SalesforceCommerceCloud/b2c-developer-tooling/issues', target: '_blank'},
'Provide feedback',
),
]),
'home-features-before': () => h(HomeQuickInstall),
});
},
Expand Down
6 changes: 0 additions & 6 deletions packages/b2c-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io)

> [!NOTE]
> This project is currently in **Developer Preview**. Not all features are implemented, and the API may change in future releases. Please provide feedback via GitHub issues.

A command-line interface for Salesforce Agentforce Commerce (formerly Commerce Cloud) B2C instances and platform services.

## Installation
Expand Down Expand Up @@ -286,6 +283,3 @@ Full documentation is available at: https://salesforcecommercecloud.github.io/b2

This project is licensed under the Apache License 2.0. See [LICENSE.txt](../../LICENSE.txt) for full details.

## Disclaimer

This project is currently in **Developer Preview** and is provided "as-is" without warranty of any kind. It is not yet generally available (GA) and should not be used in production environments. Features, APIs, and functionality may change without notice in future releases.
16 changes: 13 additions & 3 deletions packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export interface ImplicitOAuthConfig {
* The local server still listens on localPort regardless of this setting.
*/
redirectUri?: string;
/**
* Custom browser opener. Receives the authorization URL and should open it
* in the user's browser. Useful in environments where the default `open` package
* doesn't work (e.g., VS Code remote/Codespaces where `vscode.env.openExternal` is needed).
*/
openBrowser?: (url: string) => Promise<void>;
}

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

// Attempt to open the browser
// Attempt to open the browser (prefer injected opener, fall back to `open` package)
logger.debug('[Auth] Attempting to open browser');
await openBrowser(authorizeUrl);
if (this.config.openBrowser) {
await this.config.openBrowser(authorizeUrl);
} else {
await openBrowserDefault(authorizeUrl);
}

return new Promise<AccessTokenResponse>((resolve, reject) => {
const sockets: Set<Socket> = new Set();
Expand Down
2 changes: 2 additions & 0 deletions packages/b2c-tooling-sdk/src/auth/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export function resolveAuthStrategy(
clientId: credentials.clientId,
scopes: credentials.scopes,
accountManagerHost: credentials.accountManagerHost,
redirectUri: credentials.redirectUri,
openBrowser: credentials.openBrowser,
});
}
break;
Expand Down
8 changes: 8 additions & 0 deletions packages/b2c-tooling-sdk/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export interface OAuthAuthConfig {
clientSecret?: string;
scopes?: string[];
accountManagerHost?: string;
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
redirectUri?: string;
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
openBrowser?: (url: string) => Promise<void>;
}

/**
Expand Down Expand Up @@ -133,4 +137,8 @@ export interface AuthCredentials {
apiKey?: string;
/** Header name for API key (defaults to Authorization with Bearer prefix) */
apiKeyHeaderName?: string;
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
redirectUri?: string;
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
openBrowser?: (url: string) => Promise<void>;
}
14 changes: 13 additions & 1 deletion packages/b2c-tooling-sdk/src/config/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,10 @@ export function buildAuthConfigFromNormalized(config: NormalizedConfig): AuthCon
* await instance.webdav.mkcol('Cartridges/v1');
* ```
*/
export function createInstanceFromConfig(config: NormalizedConfig): B2CInstance {
export function createInstanceFromConfig(
config: NormalizedConfig,
options?: {redirectUri?: string; openBrowser?: (url: string) => Promise<void>},
): B2CInstance {
if (!config.hostname) {
throw new Error('Hostname is required. Set in dw.json or provide via overrides.');
}
Expand All @@ -482,5 +485,14 @@ export function createInstanceFromConfig(config: NormalizedConfig): B2CInstance

const authConfig = buildAuthConfigFromNormalized(config);

// Inject implicit auth options into OAuth config when present
if (authConfig.oauth && (options?.redirectUri || options?.openBrowser)) {
authConfig.oauth = {
...authConfig.oauth,
redirectUri: options.redirectUri,
openBrowser: options.openBrowser,
};
}

return new B2CInstance(instanceConfig, authConfig);
}
6 changes: 4 additions & 2 deletions packages/b2c-tooling-sdk/src/config/resolved-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ export class ResolvedConfigImpl implements ResolvedB2CConfig {

// Factory methods

createB2CInstance(): B2CInstance {
createB2CInstance(options?: Pick<CreateOAuthOptions, 'redirectUri' | 'openBrowser'>): B2CInstance {
if (!this.hasB2CInstanceConfig()) {
throw new Error('B2C instance requires hostname');
}
return createInstanceFromConfig(this.values);
return createInstanceFromConfig(this.values, options);
}

createBasicAuth(): AuthStrategy {
Expand All @@ -82,6 +82,8 @@ export class ResolvedConfigImpl implements ResolvedB2CConfig {
clientSecret: this.values.clientSecret,
scopes: mergedScopes.length > 0 ? mergedScopes : undefined,
accountManagerHost: this.values.accountManagerHost,
redirectUri: options?.redirectUri,
openBrowser: options?.openBrowser,
};
return resolveAuthStrategy(credentials, {allowedMethods: options?.allowedMethods});
}
Expand Down
7 changes: 6 additions & 1 deletion packages/b2c-tooling-sdk/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,10 @@ export interface CreateOAuthOptions {
allowedMethods?: AuthMethod[];
/** Additional OAuth scopes to request beyond those in config */
scopes?: string[];
/** Override redirect URI for implicit OAuth flow (e.g., for port forwarding in remote environments) */
redirectUri?: string;
/** Custom browser opener for implicit OAuth flow. Receives the authorization URL. */
openBrowser?: (url: string) => Promise<void>;
}

/**
Expand Down Expand Up @@ -422,9 +426,10 @@ export interface ResolvedB2CConfig {

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

/**
* Creates a Basic auth strategy.
Expand Down
2 changes: 2 additions & 0 deletions packages/b2c-tooling-sdk/src/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ export class B2CInstance {
clientSecret: this.auth.oauth.clientSecret,
scopes: this.auth.oauth.scopes,
accountManagerHost: this.auth.oauth.accountManagerHost,
redirectUri: this.auth.oauth.redirectUri,
openBrowser: this.auth.oauth.openBrowser,
};

// Filter to only OAuth methods (client-credentials, implicit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ export class ApiBrowserTreeDataProvider implements vscode.TreeDataProvider<ApiBr
const schemas = await vscode.window.withProgress(
{location: {viewId: 'b2cApiBrowser'}, title: 'Loading SCAPI schemas...'},
async () => {
const oauthStrategy = config.createOAuth();
const oauthOptions = await this.configProvider.getImplicitAuthOptions();
const oauthStrategy = config.createOAuth(oauthOptions);
const schemasClient = createScapiSchemasClient({shortCode, tenantId}, oauthStrategy);
const orgId = toOrganizationId(tenantId);
const {data, error, response} = await schemasClient.GET('/organizations/{organizationId}/schemas', {
Expand Down
12 changes: 8 additions & 4 deletions packages/b2c-vs-extension/src/api-browser/swagger-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ export class SwaggerWebviewManager implements vscode.Disposable {

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

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

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

const {data, error} = await slasClient.GET('/tenants/{tenantId}/clients', {
Expand Down
24 changes: 23 additions & 1 deletion packages/b2c-vs-extension/src/config-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
EnvSource,
type NormalizedConfig,
type ResolvedB2CConfig,
type CreateOAuthOptions,
} from '@salesforce/b2c-tooling-sdk/config';
import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance';
import * as fs from 'fs';
Expand Down Expand Up @@ -234,6 +235,26 @@ export class B2CExtensionConfig implements vscode.Disposable {
return resolveConfig(overrides, {workingDirectory});
}

/**
* Returns CreateOAuthOptions with VS Code-specific overrides for implicit auth:
* - Uses `vscode.env.openExternal` to open the browser on the client (works in Codespaces/remote)
* - Uses `vscode.env.asExternalUri` to resolve the redirect URI for port forwarding
*
* Merge with any additional options before passing to `config.createOAuth()`.
*/
async getImplicitAuthOptions(): Promise<CreateOAuthOptions> {
const localPort = parseInt(process.env.SFCC_OAUTH_LOCAL_PORT || '', 10) || 8080;
const localUri = vscode.Uri.parse(`http://localhost:${localPort}`);
const externalUri = await vscode.env.asExternalUri(localUri);

return {
redirectUri: process.env.SFCC_REDIRECT_URI || externalUri.toString(/* skipEncoding */ true),
openBrowser: async (url: string) => {
await vscode.env.openExternal(vscode.Uri.parse(url));
},
};
}

dispose(): void {
this._onDidReset.dispose();
for (const d of this.disposables) {
Expand Down Expand Up @@ -290,7 +311,8 @@ export class B2CExtensionConfig implements vscode.Disposable {
return;
}

this.instance = config.createB2CInstance();
const implicitAuthOpts = await this.getImplicitAuthOptions();
this.instance = config.createB2CInstance(implicitAuthOpts);
this.configError = null;
this.log.appendLine(`[Config] Resolved instance: ${this.instance.config.hostname}`);
} catch (err) {
Expand Down
Loading
Loading