Skip to content

Commit a40acc3

Browse files
committed
fix(slas): drive PKCE for registered login on private clients (@W-23235332)
`b2c slas token --shopper-login/--shopper-password` failed against a PRIVATE SLAS client with `HTTP 400 code_verifier is required`. The registered flow's `/oauth2/login` step always presents a `code_challenge`, but the private-client token exchange downgraded to `grant_type=authorization_code` and omitted `code_verifier`, so SLAS rejected it. Unify the registered token exchange: always use `authorization_code_pkce` with the matching `code_verifier` for both public and private clients; a private client additionally authenticates with its secret via HTTP Basic. This matches commerce-sdk-isomorphic's `loginRegisteredUserB2C`. Guest and `client_credentials` flows unchanged. - Rewrite SDK `getRegisteredToken` token exchange (single PKCE path). - Fix SDK test that had codified the buggy `authorization_code` contract; it now asserts PKCE + `code_verifier` + Basic auth. - Update slas docs flow table + b2c-slas skill. - Changeset (patch, @salesforce/b2c-tooling-sdk).
1 parent 78eaea0 commit a40acc3

5 files changed

Lines changed: 61 additions & 39 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': patch
3+
---
4+
5+
Fix `b2c slas token` registered-customer login failing against a private SLAS client with `HTTP 400 code_verifier is required`. The registered login flow is always PKCE-protected, so the token exchange now always sends the `code_verifier` with the `authorization_code_pkce` grant — and, for private clients, additionally authenticates with the client secret via HTTP Basic. Registered login now works on both public and private clients; guest and `client_credentials` flows are unchanged.

docs/cli/slas.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,20 @@ b2c slas token --tenant-id <TENANT_ID> --site-id <SITE_ID>
7676

7777
### Flows
7878

79-
The command automatically selects the appropriate authentication flow:
79+
The command automatically selects the appropriate authentication flow based on whether `--shopper-login` and `--slas-client-secret` are provided:
8080

8181
| Scenario | Flow |
8282
|----------|------|
83-
| No `--slas-client-secret` | Public client PKCE (authorization_code_pkce) |
84-
| With `--slas-client-secret` | Private client (client_credentials) |
85-
| With `--shopper-login` | Registered customer login |
83+
| Guest, no `--slas-client-secret` | Public client guest PKCE (`authorization_code_pkce`) |
84+
| Guest, with `--slas-client-secret` | Private client `client_credentials` |
85+
| Registered (`--shopper-login`), no secret | Registered login + PKCE (`authorization_code_pkce`) |
86+
| Registered (`--shopper-login`), with secret | Registered login + PKCE, plus client-secret Basic auth |
8687
| No `--slas-client-id` | Auto-discovers first public client via SLAS Admin API |
8788

89+
::: tip Registered login uses PKCE on both public and private clients
90+
The registered-customer flow is always PKCE-protected: the `/oauth2/login` step presents a `code_challenge`, so the token exchange always sends the matching `code_verifier`. A private SLAS client additionally authenticates with its secret (HTTP Basic). This is why the registered flow works against both public and private clients.
91+
:::
92+
8893
### Examples
8994

9095
```bash

packages/b2c-tooling-sdk/src/slas/token.ts

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,15 @@ async function getPrivateClientGuestToken(config: SlasTokenConfig): Promise<Slas
213213
* Uses the `/oauth2/login` endpoint with shopper credentials, then exchanges
214214
* the authorization code for an access token.
215215
*
216-
* - **Public client**: Uses PKCE for code exchange.
217-
* - **Private client**: Uses client credentials (Basic auth) for code exchange.
216+
* The registered-customer flow is PKCE-protected for **both** public and
217+
* private clients: a `code_challenge` is always presented at the
218+
* `/oauth2/login` step, so the matching `code_verifier` must always be sent at
219+
* the token exchange with the `authorization_code_pkce` grant.
220+
*
221+
* - **Public client**: PKCE token exchange (no client secret).
222+
* - **Private client**: PKCE token exchange, plus HTTP Basic authentication
223+
* using the client secret. The client must NOT drop PKCE, or SLAS rejects the
224+
* exchange with `400 code_verifier is required`.
218225
*
219226
* @param config - SLAS token configuration including shopper credentials
220227
* @returns The token response including access_token and refresh_token
@@ -265,37 +272,16 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
265272
const {code, usid} = parseRedirectCode(location);
266273
logger.debug({usid}, '[SLAS] Got authorization code from login');
267274

268-
// Step 2: Exchange code for token
275+
// Step 2: Exchange code for token.
276+
//
277+
// The login step always presents a `code_challenge`, so the token exchange
278+
// must always send the matching `code_verifier` with the
279+
// `authorization_code_pkce` grant — for both public and private clients.
280+
// A private client additionally authenticates with HTTP Basic using its
281+
// secret; it must NOT downgrade to the plain `authorization_code` grant or
282+
// SLAS rejects the exchange with `400 code_verifier is required`.
269283
const tokenUrl = `${baseUrl}/oauth2/token`;
270284

271-
if (isPrivate) {
272-
const basicAuth = Buffer.from(`${config.slasClientId}:${config.slasClientSecret}`).toString('base64');
273-
const tokenBody = new URLSearchParams({
274-
grant_type: 'authorization_code',
275-
code,
276-
redirect_uri: config.redirectUri,
277-
channel_id: config.siteId,
278-
usid,
279-
});
280-
281-
const {response: tokenResponse} = await loggedFetch(tokenUrl, {
282-
method: 'POST',
283-
headers: {
284-
'Content-Type': 'application/x-www-form-urlencoded',
285-
Authorization: `Basic ${basicAuth}`,
286-
},
287-
body: tokenBody,
288-
});
289-
290-
await checkResponse(tokenResponse, 'token exchange (authorization_code)');
291-
const data = (await tokenResponse.json()) as SlasTokenResponse;
292-
const respHeaders = serializeHeaders(tokenResponse);
293-
logger.trace({headers: respHeaders, body: data}, `[SLAS RESP BODY] POST ${tokenUrl}`);
294-
295-
return data;
296-
}
297-
298-
// Public client — use PKCE
299285
const tokenBody = new URLSearchParams({
300286
grant_type: 'authorization_code_pkce',
301287
client_id: config.slasClientId,
@@ -306,9 +292,15 @@ export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Pro
306292
usid,
307293
});
308294

295+
const tokenHeaders: Record<string, string> = {'Content-Type': 'application/x-www-form-urlencoded'};
296+
if (isPrivate) {
297+
const basicAuth = Buffer.from(`${config.slasClientId}:${config.slasClientSecret}`).toString('base64');
298+
tokenHeaders.Authorization = `Basic ${basicAuth}`;
299+
}
300+
309301
const {response: tokenResponse} = await loggedFetch(tokenUrl, {
310302
method: 'POST',
311-
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
303+
headers: tokenHeaders,
312304
body: tokenBody,
313305
});
314306

packages/b2c-tooling-sdk/test/slas/token.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,19 @@ describe('slas/token', () => {
193193
});
194194

195195
describe('getRegisteredToken - private client', () => {
196-
it('logs in with shopper credentials and exchanges code with Basic auth', async () => {
196+
it('exchanges code via PKCE with Basic auth (sends code_verifier)', async () => {
197+
// Regression test for W-23235332: the login step always presents a
198+
// code_challenge, so the private-client token exchange must send the
199+
// matching code_verifier with the authorization_code_pkce grant. The
200+
// previous behavior used grant_type=authorization_code WITHOUT a
201+
// code_verifier, which SLAS rejects with "400 code_verifier is required".
202+
let loginChallenge: string | null = null;
197203
server.use(
198-
http.post(`${BASE_URL}/oauth2/login`, () => {
204+
http.post(`${BASE_URL}/oauth2/login`, async ({request}) => {
205+
const params = new URLSearchParams(await request.text());
206+
loginChallenge = params.get('code_challenge');
207+
expect(loginChallenge).to.be.a('string');
208+
199209
return new HttpResponse(null, {
200210
status: 303,
201211
headers: {
@@ -210,8 +220,13 @@ describe('slas/token', () => {
210220

211221
const body = await request.text();
212222
const params = new URLSearchParams(body);
213-
expect(params.get('grant_type')).to.equal('authorization_code');
223+
expect(params.get('grant_type')).to.equal('authorization_code_pkce');
224+
expect(params.get('client_id')).to.equal('test-client-id');
214225
expect(params.get('code')).to.equal('priv-code');
226+
// The verifier must be present so SLAS can validate the challenge.
227+
expect(params.get('code_verifier')).to.be.a('string').and.not.empty;
228+
expect(params.get('channel_id')).to.equal('RefArch');
229+
expect(params.get('usid')).to.equal('usid-priv');
215230

216231
return HttpResponse.json(MOCK_TOKEN_RESPONSE);
217232
}),

skills/b2c-cli/skills/b2c-slas/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ curl -H "Authorization: Bearer $TOKEN" "https://$SHORTCODE.api.commercecloud.sal
116116

117117
# Override the SLAS client explicitly (e.g., targeting a private client for client_credentials flow)
118118
b2c slas token --site-id RefArch --slas-client-id my-client --slas-client-secret sk_xxx
119+
120+
# Registered customer token against a private client (registered login is always PKCE-protected;
121+
# the client secret is sent as Basic auth in addition to the PKCE code_verifier)
122+
b2c slas token --site-id RefArch --slas-client-id my-client --slas-client-secret sk_xxx \
123+
--shopper-login user@example.com --shopper-password secret
119124
```
120125

121126
### Update SLAS Client

0 commit comments

Comments
 (0)