diff --git a/.changeset/remember-pkce-fallback.md b/.changeset/remember-pkce-fallback.md new file mode 100644 index 000000000..f4edef6a1 --- /dev/null +++ b/.changeset/remember-pkce-fallback.md @@ -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. diff --git a/docs/cli/auth.md b/docs/cli/auth.md index a48c5e2cd..61994c6b7 100644 --- a/docs/cli/auth.md +++ b/docs/cli/auth.md @@ -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 diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index 9698ae89a..96d255042 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -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 diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts index 63a0647ec..9e0c0e886 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-implicit.ts @@ -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; } /** @@ -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, diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts b/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts index aebf159f6..e2e0f193b 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-pkce-fallback.ts @@ -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 @@ -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 { @@ -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; + } + } } /** diff --git a/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts b/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts index 52adf08b3..deffd6a8d 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth-pkce.ts @@ -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)); }); diff --git a/packages/b2c-tooling-sdk/src/auth/session-store.ts b/packages/b2c-tooling-sdk/src/auth/session-store.ts index b1ba7afe9..4a8fff468 100644 --- a/packages/b2c-tooling-sdk/src/auth/session-store.ts +++ b/packages/b2c-tooling-sdk/src/auth/session-store.ts @@ -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; diff --git a/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts index 6d4f20045..1668f0beb 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth-pkce-fallback.test.ts @@ -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 @@ -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 { @@ -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}).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'); diff --git a/packages/b2c-tooling-sdk/test/auth/oauth-pkce.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth-pkce.test.ts index baff07672..d1750320e 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth-pkce.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth-pkce.test.ts @@ -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, @@ -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); }, ); @@ -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(); });