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/remember-pkce-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@salesforce/b2c-tooling-sdk': patch
'@salesforce/b2c-dx-docs': patch
---

Remember when an Account Manager client rejects PKCE so later CLI and VS Code sessions start directly with the compatible implicit flow, and avoid reporting authentication success before the PKCE token exchange completes.
2 changes: 1 addition & 1 deletion docs/cli/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ OAuth 2.1 deprecates the implicit flow for public clients. Configure your Accoun

To use the browser-based `user` flow with your own client, create a **public client** in Account Manager (not a confidential client — public clients have no secret, and selecting that type configures the Authorization Code + PKCE grant automatically). A client's type can't be changed after creation, so an existing implicit-only client must be replaced by a newly-created public client, not converted. Add the CLI's redirect URI to the client's allowed redirect URIs — by default `http://localhost:8080` (override the port with `SFCC_OAUTH_LOCAL_PORT` or the whole URI with `SFCC_REDIRECT_URI`).

If a client is still registered as implicit-only, the `user` flow automatically falls back to the implicit flow and logs a deprecation warning. Create a new public client and use it to silence the warning, or set `SFCC_DISABLE_PKCE_FALLBACK=1` to disable the fallback.
If a client is still registered as implicit-only, the `user` flow automatically falls back to the implicit flow and logs a deprecation warning. After a successful fallback, later sign-ins use the compatible flow directly. Create a new public client and use it to silence the warning, or set `SFCC_DISABLE_PKCE_FALLBACK=1` to force PKCE and surface the failure directly.

## b2c auth logout

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,8 @@ Manager and using it to remove this warning.

The fallback triggers **only** for OAuth errors that indicate the client is not a public/PKCE client — `invalid_client` (Account Manager requires client authentication at the token exchange; the most common case for legacy implicit clients), `unauthorized_client`, `unsupported_response_type`, and `unsupported_grant_type`. Other failures (for example `invalid_scope` from requesting scopes the client can't have, or a cancelled login) surface directly without falling back, because they would fail identically under the implicit flow.

The first fallback can require two browser authorization prompts while the tooling switches to the compatible flow. After a successful fallback, later CLI and VS Code sign-ins use that flow directly and avoid the extra prompt. The first browser callback reports that authorization was received instead of reporting a successful sign-in before the fallback completes.

To resolve the warning, create a new public client as described above and use its client ID. To disable the fallback entirely and surface PKCE failures directly, set `SFCC_DISABLE_PKCE_FALLBACK=1`. The fallback is temporary and will be removed once public clients have migrated.

## Next Steps
Expand Down
7 changes: 7 additions & 0 deletions packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export interface ImplicitOAuthConfig {
* Defaults to `true`.
*/
persistSession?: boolean;
/**
* Mark persisted implicit sessions as originating from the automatic
* PKCE-to-implicit fallback. Used internally by the transitional fallback
* strategy so later activations can skip a known-unsupported PKCE attempt.
*/
pkceUnsupported?: boolean;
}

/**
Expand Down Expand Up @@ -199,6 +205,7 @@ export class ImplicitOAuthStrategy implements AuthStrategy {
const record: AuthSession = {
clientId: this.config.clientId,
flow: 'implicit',
pkceUnsupported: this.config.pkceUnsupported || undefined,
accessToken: tokenResponse.accessToken,
refreshToken: null,
sub,
Expand Down
31 changes: 31 additions & 0 deletions packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
*/
import type {UserAuthStrategy, AccessTokenResponse, DecodedJWT, FetchInit} from './types.js';
import {getLogger} from '../logging/logger.js';
import {findAuthSession} from './session-store.js';
import {PkceOAuthStrategy, PkceGrantUnsupportedError, type PkceOAuthConfig} from './oauth-pkce.js';
import {ImplicitOAuthStrategy, type ImplicitOAuthConfig} from './oauth-implicit.js';
import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js';

/**
* Returns true when the automatic PKCE→implicit fallback is disabled via the
Expand All @@ -64,6 +66,15 @@ export class PkceWithImplicitFallbackStrategy implements UserAuthStrategy {

constructor(private readonly config: PkceOAuthConfig) {
this.pkce = new PkceOAuthStrategy(config);
this.useImplicit = this.hasPersistedUnsupportedMarker();
if (this.useImplicit) {
getLogger().warn(
{clientId: this.config.clientId, accountManagerHost: this.accountManagerHost},
`[Auth] Skipping Authorization Code + PKCE for client ${this.config.clientId} because Account Manager ` +
'previously rejected that grant. Using the deprecated implicit flow. Recommend creating a new public ' +
'(PKCE) client in Account Manager and using it to remove this warning.',
);
}
}

async fetch(url: string, init: FetchInit = {}): Promise<Response> {
Expand Down Expand Up @@ -152,11 +163,31 @@ export class PkceWithImplicitFallbackStrategy implements UserAuthStrategy {
redirectUri: this.config.redirectUri,
openBrowser: this.config.openBrowser,
persistSession: this.config.persistSession,
pkceUnsupported: true,
};
this.implicit = new ImplicitOAuthStrategy(implicitConfig);
}
return this.implicit;
}

private get accountManagerHost(): string {
return (this.config.accountManagerHost || DEFAULT_ACCOUNT_MANAGER_HOST).toLowerCase();
}

private hasPersistedUnsupportedMarker(): boolean {
if (this.config.persistSession === false) return false;
try {
const stored = findAuthSession(this.config.clientId);
return (
stored?.flow === 'implicit' &&
stored.pkceUnsupported === true &&
stored.accountManagerHost?.toLowerCase() === this.accountManagerHost
);
} catch (error) {
getLogger().debug({err: error}, '[Auth] Failed to inspect persisted PKCE fallback marker');
return false;
}
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ export class PkceOAuthStrategy implements AuthStrategy {
}

res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Authentication successful! You may close this browser window and return to your terminal.');
res.end('Authorization received. Completing authentication in the application...');
settleAfterClose(() => resolve(code));
});

Expand Down
7 changes: 7 additions & 0 deletions packages/b2c-tooling-sdk/src/auth/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export interface AuthSession {
clientId: string;
/** Which flow produced this session (informational; controls refresh policy). */
flow: AuthSessionFlow;
/**
* Whether this implicit session was created after Account Manager rejected
* Authorization Code + PKCE for the client. The user-auth fallback uses this
* marker, together with `accountManagerHost`, to avoid repeating a PKCE flow
* that cannot succeed on later process or extension activations.
*/
pkceUnsupported?: boolean;
accessToken: string;
/** Only set for PKCE. Implicit and client-credentials never have a refresh token. */
refreshToken?: string | null;
Expand Down
100 changes: 99 additions & 1 deletion packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import {
PkceWithImplicitFallbackStrategy,
PkceGrantUnsupportedError,
createUserAuthStrategy,
findAuthSession,
InMemoryAuthSessionBackend,
isPkceFallbackDisabled,
resetAuthSessionStoreForTesting,
saveAuthSession,
setAuthSessionBackend,
} from '@salesforce/b2c-tooling-sdk/auth';
import type {UserAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';
import type {AccessTokenResponse, UserAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth';

/**
* Build a fake UserAuthStrategy that records how many times each method is called
Expand Down Expand Up @@ -92,7 +97,12 @@ const GRANT_ERROR = new PkceGrantUnsupportedError('unsupported', 'token', 'unaut
describe('auth/oauth-pkce-fallback', () => {
const originalEnv = process.env.SFCC_DISABLE_PKCE_FALLBACK;

beforeEach(() => {
setAuthSessionBackend(new InMemoryAuthSessionBackend());
});

afterEach(() => {
resetAuthSessionStoreForTesting();
if (originalEnv === undefined) {
delete process.env.SFCC_DISABLE_PKCE_FALLBACK;
} else {
Expand Down Expand Up @@ -172,6 +182,94 @@ describe('auth/oauth-pkce-fallback', () => {
expect(pkceCalls.getAuthorizationHeader).to.equal(0);
});

it('persists a host-scoped unsupported marker after a successful fallback', async () => {
const wrapper = new PkceWithImplicitFallbackStrategy({
clientId: 'persisted-fallback',
accountManagerHost: 'account-pod5.demandware.net',
scopes: ['a'],
});
const internal = wrapper as unknown as {
pkce: UserAuthStrategy;
getImplicit: () => ImplicitOAuthStrategy;
};
internal.pkce.getTokenResponse = async () => {
throw GRANT_ERROR;
};
const implicit = internal.getImplicit();
(implicit as unknown as {implicitFlowLogin: () => Promise<AccessTokenResponse>}).implicitFlowLogin =
async () => ({
accessToken: 'implicit-token',
expires: new Date(Date.now() + 60_000),
scopes: ['a'],
});

await wrapper.getTokenResponse();

const stored = findAuthSession('persisted-fallback');
expect(stored?.flow).to.equal('implicit');
expect(stored?.pkceUnsupported).to.be.true;
expect(stored?.accountManagerHost).to.equal('account-pod5.demandware.net');
});

it('starts with implicit when the persisted marker matches the client and host', async () => {
saveAuthSession({
clientId: 'known-legacy-client',
flow: 'implicit',
pkceUnsupported: true,
accessToken: 'stored-implicit-token',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
scopes: ['a'],
accountManagerHost: 'ACCOUNT-POD5.DEMANDWARE.NET',
});
const wrapper = new PkceWithImplicitFallbackStrategy({
clientId: 'known-legacy-client',
accountManagerHost: 'account-pod5.demandware.net',
scopes: ['a'],
});
const {pkceCalls, implicitCalls} = instrument(wrapper, null);

await wrapper.getAuthorizationHeader();

expect(pkceCalls.getAuthorizationHeader).to.equal(0);
expect(implicitCalls.getAuthorizationHeader).to.equal(1);
});

it('still tries PKCE when the persisted marker belongs to another host', async () => {
saveAuthSession({
clientId: 'host-specific-client',
flow: 'implicit',
pkceUnsupported: true,
accessToken: 'stored-implicit-token',
accountManagerHost: 'account.demandware.com',
});
const wrapper = new PkceWithImplicitFallbackStrategy({
clientId: 'host-specific-client',
accountManagerHost: 'account-pod5.demandware.net',
});
const {pkceCalls, implicitCalls} = instrument(wrapper, null);

await wrapper.getAuthorizationHeader();

expect(pkceCalls.getAuthorizationHeader).to.equal(1);
expect(implicitCalls.getAuthorizationHeader).to.equal(0);
});

it('does not treat an explicitly selected implicit session as a PKCE capability marker', async () => {
saveAuthSession({
clientId: 'explicit-implicit-client',
flow: 'implicit',
accessToken: 'stored-implicit-token',
accountManagerHost: 'account.demandware.com',
});
const wrapper = new PkceWithImplicitFallbackStrategy({clientId: 'explicit-implicit-client'});
const {pkceCalls, implicitCalls} = instrument(wrapper, null);

await wrapper.getAuthorizationHeader();

expect(pkceCalls.getAuthorizationHeader).to.equal(1);
expect(implicitCalls.getAuthorizationHeader).to.equal(0);
});

it('propagates non-grant errors without falling back', async () => {
const wrapper = new PkceWithImplicitFallbackStrategy({clientId: 'c', scopes: ['a']});
const boom = new Error('network down');
Expand Down
8 changes: 7 additions & 1 deletion packages/b2c-tooling-sdk/test/auth/oauth-pkce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe('auth/oauth-pkce', () => {
it('starts the callback listener before invoking the browser opener', async () => {
const clientId = 'pkce-listener-before-browser';
const localPort = await getAvailablePort();
let callbackMessage = '';
const strategy = new PkceOAuthStrategy({
clientId,
localPort,
Expand All @@ -127,7 +128,10 @@ describe('auth/oauth-pkce', () => {
const request = httpGet(
`http://localhost:${localPort}/?code=immediate-code&state=${encodeURIComponent(state ?? '')}`,
(response) => {
response.resume();
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
callbackMessage += chunk;
});
response.once('end', resolve);
},
);
Expand All @@ -143,6 +147,8 @@ describe('auth/oauth-pkce', () => {

const token = await strategy.getTokenResponse();
expect(token.accessToken).to.equal('listener-token');
expect(callbackMessage).to.equal('Authorization received. Completing authentication in the application...');
expect(callbackMessage).not.to.include('Authentication successful');
strategy.invalidateToken();
});

Expand Down
Loading