diff --git a/.changeset/slas-token-command.md b/.changeset/slas-token-command.md new file mode 100644 index 00000000..9f74cef0 --- /dev/null +++ b/.changeset/slas-token-command.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `slas token` command to retrieve SLAS shopper access tokens for API testing. Supports public (PKCE) and private (client_credentials) client flows, guest and registered customer authentication, and auto-discovery of public SLAS clients. diff --git a/docs/cli/slas.md b/docs/cli/slas.md index d5cfeeeb..1b8fe386 100644 --- a/docs/cli/slas.md +++ b/docs/cli/slas.md @@ -49,6 +49,103 @@ For complete setup instructions, see the [Authentication Guide](/guide/authentic --- +## b2c slas token + +Get a SLAS shopper access token for testing APIs. + +### Usage + +```bash +b2c slas token --tenant-id --site-id +``` + +### Flags + +| Flag | Environment Variable | Description | Required | +|------|---------------------|-------------|----------| +| `--tenant-id` | `SFCC_TENANT_ID` | SLAS tenant ID (organization ID) | Yes | +| `--site-id` | `SFCC_SITE_ID` | Site/channel ID | Yes* | +| `--slas-client-id` | `SFCC_SLAS_CLIENT_ID` | SLAS client ID (auto-discovered if omitted) | No | +| `--slas-client-secret` | `SFCC_SLAS_CLIENT_SECRET` | SLAS client secret (omit for public clients) | No | +| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | Yes | +| `--redirect-uri` | | Redirect URI | No | +| `--shopper-login` | | Registered customer login | No | +| `--shopper-password` | | Registered customer password (prompted interactively if omitted) | No | + +\* `--site-id` can be auto-discovered from the SLAS client configuration when using auto-discovery. + +### Flows + +The command automatically selects the appropriate authentication flow: + +| Scenario | Flow | +|----------|------| +| No `--slas-client-secret` | Public client PKCE (authorization_code_pkce) | +| With `--slas-client-secret` | Private client (client_credentials) | +| With `--shopper-login` | Registered customer login | +| No `--slas-client-id` | Auto-discovers first public client via SLAS Admin API | + +### Examples + +```bash +# Guest token with auto-discovery (finds first public SLAS client) +b2c slas token --tenant-id abcd_123 --site-id RefArch + +# Guest token with explicit public client (PKCE flow) +b2c slas token --slas-client-id my-client \ + --tenant-id abcd_123 --short-code kv7kzm78 --site-id RefArch + +# Guest token with private client (client_credentials flow) +b2c slas token --slas-client-id my-client --slas-client-secret sk_xxx \ + --tenant-id abcd_123 --short-code kv7kzm78 --site-id RefArch + +# Registered customer token +b2c slas token --tenant-id abcd_123 --site-id RefArch \ + --shopper-login user@example.com --shopper-password secret + +# JSON output (includes refresh token, expiry, usid, etc.) +b2c slas token --tenant-id abcd_123 --site-id RefArch --json + +# Use token in a subsequent API call +TOKEN=$(b2c slas token --tenant-id abcd_123 --site-id RefArch) +curl -H "Authorization: Bearer $TOKEN" \ + "https://kv7kzm78.api.commercecloud.salesforce.com/..." +``` + +### Output + +- **Normal mode**: prints the raw access token to stdout (pipeable) +- **JSON mode** (`--json`): returns full token details: + +```json +{ + "accessToken": "...", + "refreshToken": "...", + "expiresIn": 1800, + "tokenType": "Bearer", + "usid": "...", + "customerId": "...", + "clientId": "...", + "siteId": "RefArch", + "isGuest": true +} +``` + +### Configuration + +These values can also be set in `dw.json`: + +```json +{ + "tenant-id": "abcd_123", + "short-code": "kv7kzm78", + "slas-client-id": "my-public-client", + "site-id": "RefArch" +} +``` + +--- + ## b2c slas client list List SLAS clients for a tenant. diff --git a/packages/b2c-cli/eslint.config.mjs b/packages/b2c-cli/eslint.config.mjs index 36787267..06475303 100644 --- a/packages/b2c-cli/eslint.config.mjs +++ b/packages/b2c-cli/eslint.config.mjs @@ -77,7 +77,7 @@ export default [ }, }, { - files: ['src/commands/setup/**/*.ts'], + files: ['src/commands/setup/**/*.ts', 'src/commands/slas/**/*.ts'], rules: { // ESLint import resolver doesn't understand conditional exports (development condition) // but Node.js resolves them correctly at runtime diff --git a/packages/b2c-cli/src/commands/setup/inspect.ts b/packages/b2c-cli/src/commands/setup/inspect.ts index d7f85f30..c9815069 100644 --- a/packages/b2c-cli/src/commands/setup/inspect.ts +++ b/packages/b2c-cli/src/commands/setup/inspect.ts @@ -12,7 +12,7 @@ import {withDocs} from '../../i18n/index.js'; /** * Sensitive fields that should be masked by default. */ -const SENSITIVE_FIELDS = new Set(['clientSecret', 'mrtApiKey', 'password']); +const SENSITIVE_FIELDS = new Set(['clientSecret', 'mrtApiKey', 'password', 'slasClientSecret']); /** * JSON output structure for the inspect command. diff --git a/packages/b2c-cli/src/commands/slas/token.ts b/packages/b2c-cli/src/commands/slas/token.ts new file mode 100644 index 00000000..a51fb000 --- /dev/null +++ b/packages/b2c-cli/src/commands/slas/token.ts @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, ux} from '@oclif/core'; +import {password as passwordPrompt} from '@inquirer/prompts'; +import {loadConfig, extractOAuthFlags} from '@salesforce/b2c-tooling-sdk/cli'; +import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {toOrganizationId, decodeJWT} from '@salesforce/b2c-tooling-sdk'; +import {getGuestToken, getRegisteredToken, type SlasTokenConfig} from '@salesforce/b2c-tooling-sdk/slas'; +import {SlasClientCommand, normalizeClientResponse, parseRedirectUris, type Client} from '../../utils/slas/client.js'; +import {t, withDocs} from '../../i18n/index.js'; + +const DEFAULT_REDIRECT_URI = 'http://localhost:3000/callback'; + +/** + * JSON output structure for slas token command. + */ +interface SlasTokenJsonOutput { + response: { + accessToken: string; + refreshToken: string; + expiresIn: number; + tokenType: string; + usid: string; + customerId: string; + }; + decodedJWT?: Record; + clientId: string; + siteId: string; + isGuest: boolean; +} + +export default class SlasToken extends SlasClientCommand { + static description = withDocs( + t('commands.slas.token.description', 'Get a SLAS shopper access token'), + '/cli/slas.html#b2c-slas-token', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --tenant-id abcd_123 --site-id RefArch', + '<%= config.bin %> <%= command.id %> --slas-client-id my-client --tenant-id abcd_123 --short-code kv7kzm78 --site-id RefArch', + '<%= config.bin %> <%= command.id %> --slas-client-id my-client --slas-client-secret sk_xxx --tenant-id abcd_123 --short-code kv7kzm78 --site-id RefArch', + '<%= config.bin %> <%= command.id %> --tenant-id abcd_123 --site-id RefArch --shopper-login user@example.com --shopper-password secret', + '<%= config.bin %> <%= command.id %> --tenant-id abcd_123 --site-id RefArch --json', + ]; + + static flags = { + ...SlasClientCommand.baseFlags, + 'slas-client-id': Flags.string({ + description: 'SLAS client ID (auto-discovered if omitted)', + env: 'SFCC_SLAS_CLIENT_ID', + }), + 'slas-client-secret': Flags.string({ + description: 'SLAS client secret (omit for public clients)', + env: 'SFCC_SLAS_CLIENT_SECRET', + }), + 'site-id': Flags.string({ + description: 'Site/channel ID', + env: 'SFCC_SITE_ID', + }), + 'redirect-uri': Flags.string({ + description: `Redirect URI (default: ${DEFAULT_REDIRECT_URI})`, + }), + 'shopper-login': Flags.string({ + description: 'Registered customer login (triggers registered flow)', + }), + 'shopper-password': Flags.string({ + description: 'Registered customer password (prompted if not provided)', + }), + }; + + protected override loadConfiguration(): ResolvedB2CConfig { + const flags = this.flags as Record; + return loadConfig( + { + ...extractOAuthFlags(flags), + slasClientId: flags['slas-client-id'] as string | undefined, + slasClientSecret: flags['slas-client-secret'] as string | undefined, + siteId: flags['site-id'] as string | undefined, + }, + this.getBaseConfigOptions(), + ); + } + + async run(): Promise { + const config = this.resolvedConfig.values; + const tenantId = this.requireTenantId(); + + let slasClientId = config.slasClientId; + const slasClientSecret = config.slasClientSecret; + let siteId = config.siteId; + let redirectUri = this.flags['redirect-uri'] as string | undefined; + + // Auto-discover SLAS client if not provided + if (!slasClientId) { + slasClientId = await this.autoDiscoverClient(tenantId, { + siteId, + redirectUri, + onDiscovered(discovered) { + if (!siteId && discovered.siteId) siteId = discovered.siteId; + if (!redirectUri && discovered.redirectUri) redirectUri = discovered.redirectUri; + }, + }); + } + + // Validate required fields + if (!siteId) { + this.error( + t( + 'commands.slas.token.siteIdRequired', + 'site-id is required. Provide via --site-id flag, SFCC_SITE_ID env var, or site-id in dw.json.', + ), + ); + } + + const {shortCode} = config; + if (!shortCode) { + this.error( + t( + 'error.shortCodeRequired', + 'SCAPI short code required. Provide --short-code, set SFCC_SHORTCODE, or configure short-code in dw.json.', + ), + ); + } + + redirectUri = redirectUri ?? DEFAULT_REDIRECT_URI; + + const tokenConfig: SlasTokenConfig = { + shortCode, + organizationId: toOrganizationId(tenantId), + slasClientId, + slasClientSecret, + siteId, + redirectUri, + }; + + const shopperLogin = this.flags['shopper-login'] as string | undefined; + const isRegistered = Boolean(shopperLogin); + + if (isRegistered) { + const shopperPassword = await this.getShopperPassword(); + + if (!this.jsonEnabled()) { + this.log(t('commands.slas.token.fetchingRegistered', 'Fetching registered customer token...')); + } + + const tokenResponse = await getRegisteredToken({ + ...tokenConfig, + shopperLogin: shopperLogin!, + shopperPassword, + }); + const decodedJWT = this.decodeAndLogToken(tokenResponse.access_token); + + const output: SlasTokenJsonOutput = { + response: { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresIn: tokenResponse.expires_in, + tokenType: tokenResponse.token_type, + usid: tokenResponse.usid, + customerId: tokenResponse.customer_id, + }, + ...(decodedJWT && {decodedJWT}), + clientId: slasClientId, + siteId, + isGuest: false, + }; + + if (this.jsonEnabled()) return output; + ux.stdout(tokenResponse.access_token); + return output; + } + + // Guest flow + if (!this.jsonEnabled()) { + this.log(t('commands.slas.token.fetchingGuest', 'Fetching guest shopper token...')); + } + + const tokenResponse = await getGuestToken(tokenConfig); + const decodedJWT = this.decodeAndLogToken(tokenResponse.access_token); + + const output: SlasTokenJsonOutput = { + response: { + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresIn: tokenResponse.expires_in, + tokenType: tokenResponse.token_type, + usid: tokenResponse.usid, + customerId: tokenResponse.customer_id, + }, + ...(decodedJWT && {decodedJWT}), + clientId: slasClientId, + siteId, + isGuest: true, + }; + + if (this.jsonEnabled()) return output; + ux.stdout(tokenResponse.access_token); + return output; + } + + /** + * Auto-discover a public SLAS client for the given tenant. + */ + private async autoDiscoverClient( + tenantId: string, + options: { + siteId?: string; + redirectUri?: string; + onDiscovered: (result: {siteId?: string; redirectUri?: string}) => void; + }, + ): Promise { + // Admin API access is needed for auto-discovery + this.requireOAuthCredentials(); + + if (!this.jsonEnabled()) { + this.log( + t('commands.slas.token.discovering', 'Auto-discovering SLAS client for tenant {{tenantId}}...', {tenantId}), + ); + } + + const slasClient = this.getSlasClient(); + + const {data, error, response} = await slasClient.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId}}, + }); + + if (error) { + this.error( + t('commands.slas.token.discoveryError', 'Failed to list SLAS clients for auto-discovery: {{message}}', { + message: `HTTP ${response.status}`, + }), + ); + } + + const clients = ((data as {data?: Client[]})?.data ?? []).map((c) => normalizeClientResponse(c)); + + // Find first public client + const publicClient = clients.find((c) => !c.isPrivateClient); + if (!publicClient) { + this.error( + t( + 'commands.slas.token.noPublicClient', + 'No public SLAS client found for tenant {{tenantId}}. Create one with `b2c slas client create --public` or provide --slas-client-id.', + {tenantId}, + ), + ); + } + + this.logger.debug({clientId: publicClient.clientId, name: publicClient.name}, 'Auto-discovered public SLAS client'); + + if (!this.jsonEnabled()) { + this.log( + t('commands.slas.token.discovered', 'Using SLAS client: {{clientId}} ({{name}})', { + clientId: publicClient.clientId, + name: publicClient.name, + }), + ); + } + + // Populate siteId and redirectUri from client config if not already set + const discovered: {siteId?: string; redirectUri?: string} = {}; + if (!options.siteId && publicClient.channels.length > 0) { + discovered.siteId = publicClient.channels[0]; + } + if (!options.redirectUri && publicClient.redirectUri) { + const uris = parseRedirectUris(publicClient.redirectUri); + if (uris.length > 0) { + discovered.redirectUri = uris[0]; + } + } + options.onDiscovered(discovered); + + return publicClient.clientId; + } + + private decodeAndLogToken(accessToken: string): Record | undefined { + try { + const jwt = decodeJWT(accessToken); + this.logger.debug({jwt: jwt.payload}, '[SLAS] JWT payload'); + return jwt.payload as Record; + } catch { + this.logger.debug('[SLAS] Error decoding JWT (token may not be a JWT)'); + return undefined; + } + } + + /** + * Get shopper password from flag or interactive prompt. + */ + private async getShopperPassword(): Promise { + const flagPassword = this.flags['shopper-password'] as string | undefined; + if (flagPassword) return flagPassword; + + if (!process.stdin.isTTY) { + this.error( + t( + 'commands.slas.token.passwordRequired', + 'Shopper password is required. Provide --shopper-password when stdin is not a TTY.', + ), + ); + } + + return passwordPrompt({ + message: 'Enter shopper password:', + }); + } +} diff --git a/packages/b2c-cli/src/utils/slas/client.ts b/packages/b2c-cli/src/utils/slas/client.ts index ebfe1fe5..69df139d 100644 --- a/packages/b2c-cli/src/utils/slas/client.ts +++ b/packages/b2c-cli/src/utils/slas/client.ts @@ -94,6 +94,25 @@ export function printClientDetails(output: ClientOutput, showSecret = true): voi ux.stdout(ui.toString()); } +/** + * Parse a redirectUri string into individual URIs. + * The SLAS API returns redirect URIs as a pipe-delimited string (e.g. "http://a|http://b"), + * while normalizeClientResponse may produce comma-separated values from an array response. + * This helper handles both formats. + */ +export function parseRedirectUris(redirectUri: string): string[] { + if (redirectUri.includes('|')) { + return redirectUri + .split('|') + .map((s) => s.trim()) + .filter(Boolean); + } + return redirectUri + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + /** * Format API error for display. */ diff --git a/packages/b2c-cli/test/commands/slas/token.test.ts b/packages/b2c-cli/test/commands/slas/token.test.ts new file mode 100644 index 00000000..2b484221 --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/token.test.ts @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import SlasToken from '../../../src/commands/slas/token.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../helpers/stub-parse.js'; + +const SHORT_CODE = 'kv7kzm78'; +const ORG_ID = 'f_ecom_abcd_123'; +const BASE_URL = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/${ORG_ID}`; + +// A valid 3-part JWT for testing decodedJWT output +const JWT_HEADER = Buffer.from(JSON.stringify({alg: 'HS256', typ: 'JWT'})).toString('base64'); +const JWT_PAYLOAD_OBJ = {sub: 'cc-slas::abcd_123:scid:my-client::usid:mock-usid', iss: 'slas', exp: 9_999_999_999}; +const JWT_PAYLOAD = Buffer.from(JSON.stringify(JWT_PAYLOAD_OBJ)).toString('base64'); +const MOCK_JWT = `${JWT_HEADER}.${JWT_PAYLOAD}.fake-signature`; + +const MOCK_TOKEN_RESPONSE = { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 1800, + token_type: 'Bearer', + usid: 'mock-usid', + customer_id: 'mock-customer-id', +}; + +describe('slas token', () => { + let config: Config; + const server = setupServer(); + + async function createCommand(flags: Record, args: Record = {}) { + const command: any = new SlasToken([], config); + stubParse(command, flags, args); + await command.init(); + return command; + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + before(() => { + server.listen({onUnhandledRequest: 'bypass'}); + }); + + afterEach(() => { + server.resetHandlers(); + sinon.restore(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + describe('with explicit slas-client-id (no auto-discovery)', () => { + it('returns guest token in JSON mode via PKCE flow', async () => { + // Mock the SLAS authorize and token endpoints + server.use( + http.get(`${BASE_URL}/oauth2/authorize`, () => { + return new HttpResponse(null, { + status: 303, + headers: {Location: 'http://localhost:3000/callback?code=test-code&usid=test-usid'}, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, () => { + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'site-id': 'RefArch', + 'short-code': SHORT_CODE, + }); + + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.response.accessToken).to.equal('mock-access-token'); + expect(result.response.refreshToken).to.equal('mock-refresh-token'); + expect(result.response.expiresIn).to.equal(1800); + expect(result.clientId).to.equal('my-client'); + expect(result.siteId).to.equal('RefArch'); + expect(result.isGuest).to.equal(true); + }); + + it('includes decodedJWT in JSON output when access_token is a valid JWT', async () => { + server.use( + http.get(`${BASE_URL}/oauth2/authorize`, () => { + return new HttpResponse(null, { + status: 303, + headers: {Location: 'http://localhost:3000/callback?code=test-code&usid=test-usid'}, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, () => { + return HttpResponse.json({...MOCK_TOKEN_RESPONSE, access_token: MOCK_JWT}); + }), + ); + + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'site-id': 'RefArch', + 'short-code': SHORT_CODE, + }); + + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.decodedJWT).to.deep.equal(JWT_PAYLOAD_OBJ); + }); + + it('omits decodedJWT when access_token is not a valid JWT', async () => { + server.use( + http.get(`${BASE_URL}/oauth2/authorize`, () => { + return new HttpResponse(null, { + status: 303, + headers: {Location: 'http://localhost:3000/callback?code=test-code&usid=test-usid'}, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, () => { + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'site-id': 'RefArch', + 'short-code': SHORT_CODE, + }); + + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.decodedJWT).to.be.undefined; + }); + + it('returns guest token via client_credentials when secret provided', async () => { + server.use( + http.post(`${BASE_URL}/oauth2/token`, () => { + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'slas-client-secret': 'my-secret', + 'site-id': 'RefArch', + 'short-code': SHORT_CODE, + }); + + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.response.accessToken).to.equal('mock-access-token'); + expect(result.isGuest).to.equal(true); + }); + }); + + describe('auto-discovery', () => { + it('errors when no public client found', async () => { + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'site-id': 'RefArch', + 'short-code': SHORT_CODE, + }); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + // Mock SLAS admin client — all private clients + const getStub = sinon.stub().resolves({ + data: { + data: [ + { + clientId: 'private-client', + name: 'Private Client', + isPrivateClient: true, + channels: ['RefArch'], + redirectUri: ['http://localhost:3000/callback'], + scopes: '', + }, + ], + }, + error: undefined, + response: {status: 200}, + }); + sinon.stub(command, 'getSlasClient').returns({GET: getStub} as any); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + const errorMessage = errorStub.firstCall.args[0]; + expect(errorMessage).to.include('No public SLAS client found'); + } + }); + }); + + describe('missing required flags', () => { + it('errors when site-id is missing and no auto-discovery', async () => { + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'short-code': SHORT_CODE, + // no site-id + }); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + const errorMessage = errorStub.firstCall.args[0]; + expect(errorMessage).to.include('site-id'); + } + }); + + it('errors when short-code is missing', async () => { + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'site-id': 'RefArch', + // no short-code — isolateConfig() ensures no dw.json or env var provides it + }); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + const errorMessage = errorStub.firstCall.args[0]; + expect(errorMessage).to.include('short code'); + } + }); + }); + + describe('registered customer flow', () => { + it('uses registered token flow when shopper-login is provided', async () => { + // Mock the SLAS login and token endpoints + server.use( + http.post(`${BASE_URL}/oauth2/login`, () => { + return new HttpResponse(null, { + status: 303, + headers: {Location: 'http://localhost:3000/callback?code=reg-code&usid=reg-usid'}, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, () => { + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const command: any = await createCommand({ + 'tenant-id': 'abcd_123', + 'slas-client-id': 'my-client', + 'site-id': 'RefArch', + 'short-code': SHORT_CODE, + 'shopper-login': 'user@example.com', + 'shopper-password': 'secret123', + }); + + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.isGuest).to.equal(false); + expect(result.response.accessToken).to.equal('mock-access-token'); + }); + }); +}); diff --git a/packages/b2c-cli/test/helpers/stub-parse.ts b/packages/b2c-cli/test/helpers/stub-parse.ts index 8e3fcaf8..cb7981b9 100644 --- a/packages/b2c-cli/test/helpers/stub-parse.ts +++ b/packages/b2c-cli/test/helpers/stub-parse.ts @@ -12,8 +12,11 @@ export function stubParse( args: Record = {}, argv: string[] = [], ): SinonStub { - // Include silent log level by default to reduce test output noise - const defaultFlags = {'log-level': 'silent'}; + // Include silent log level by default to reduce test output noise. + // Point config to /dev/null to prevent dw.json discovery from the working + // directory — mirrors what isolateConfig() does via SFCC_CONFIG env var, + // but stubParse bypasses oclif's env-to-flag mapping so we need it here. + const defaultFlags = {'log-level': 'silent', config: '/dev/null'}; return stub(command as {parse: unknown}, 'parse').resolves({ args, flags: {...defaultFlags, ...flags}, diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 6806d9c1..20d74861 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -178,6 +178,17 @@ "default": "./dist/cjs/operations/cip/index.js" } }, + "./slas": { + "development": "./src/slas/index.ts", + "import": { + "types": "./dist/esm/slas/index.d.ts", + "default": "./dist/esm/slas/index.js" + }, + "require": { + "types": "./dist/cjs/slas/index.d.ts", + "default": "./dist/cjs/slas/index.js" + } + }, "./cli": { "development": "./src/cli/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/config/dw-json.ts b/packages/b2c-tooling-sdk/src/config/dw-json.ts index 1f6c256b..e62934b0 100644 --- a/packages/b2c-tooling-sdk/src/config/dw-json.ts +++ b/packages/b2c-tooling-sdk/src/config/dw-json.ts @@ -47,6 +47,12 @@ export interface DwJsonConfig { clientSecret?: string; /** OAuth scopes */ oauthScopes?: string[]; + /** SLAS client ID for shopper authentication */ + slasClientId?: string; + /** SLAS client secret for private shopper clients */ + slasClientSecret?: string; + /** B2C Commerce site/channel ID */ + siteId?: string; /** SCAPI short code */ shortCode?: string; /** Alternate hostname for WebDAV (if different from main hostname) */ diff --git a/packages/b2c-tooling-sdk/src/config/mapping.ts b/packages/b2c-tooling-sdk/src/config/mapping.ts index 23e98c0f..ce76076a 100644 --- a/packages/b2c-tooling-sdk/src/config/mapping.ts +++ b/packages/b2c-tooling-sdk/src/config/mapping.ts @@ -113,6 +113,9 @@ export function mapDwJsonToNormalizedConfig(json: DwJsonConfig): NormalizedConfi clientId: json.clientId, clientSecret: json.clientSecret, scopes: json.oauthScopes, + slasClientId: json.slasClientId, + slasClientSecret: json.slasClientSecret, + siteId: json.siteId, shortCode: json.shortCode, tenantId: json.tenantId, sandboxApiHost: json.sandboxApiHost, @@ -180,6 +183,15 @@ export function mapNormalizedConfigToDwJson(config: Partial, n if (config.scopes !== undefined) { result.oauthScopes = config.scopes; } + if (config.slasClientId !== undefined) { + result.slasClientId = config.slasClientId; + } + if (config.slasClientSecret !== undefined) { + result.slasClientSecret = config.slasClientSecret; + } + if (config.siteId !== undefined) { + result.siteId = config.siteId; + } if (config.shortCode !== undefined) { result.shortCode = config.shortCode; } @@ -309,6 +321,9 @@ export function mergeConfigsWithProtection( clientId: overrides.clientId ?? base.clientId, clientSecret: overrides.clientSecret ?? base.clientSecret, scopes: overrides.scopes ?? base.scopes, + slasClientId: overrides.slasClientId ?? base.slasClientId, + slasClientSecret: overrides.slasClientSecret ?? base.slasClientSecret, + siteId: overrides.siteId ?? base.siteId, authMethods: overrides.authMethods ?? base.authMethods, accountManagerHost: overrides.accountManagerHost ?? base.accountManagerHost, shortCode: overrides.shortCode ?? base.shortCode, diff --git a/packages/b2c-tooling-sdk/src/config/resolver.ts b/packages/b2c-tooling-sdk/src/config/resolver.ts index 927438ca..330c0b73 100644 --- a/packages/b2c-tooling-sdk/src/config/resolver.ts +++ b/packages/b2c-tooling-sdk/src/config/resolver.ts @@ -39,6 +39,7 @@ import {globalConfigSourceRegistry} from './config-source-registry.js'; const CREDENTIAL_GROUPS: (keyof NormalizedConfig)[][] = [ ['clientId', 'clientSecret'], ['username', 'password'], + ['slasClientId', 'slasClientSecret'], ]; /** diff --git a/packages/b2c-tooling-sdk/src/config/types.ts b/packages/b2c-tooling-sdk/src/config/types.ts index 6a711bf5..6aecfacc 100644 --- a/packages/b2c-tooling-sdk/src/config/types.ts +++ b/packages/b2c-tooling-sdk/src/config/types.ts @@ -48,6 +48,14 @@ export interface NormalizedConfig { /** Account Manager hostname for OAuth (default: account.demandware.com) */ accountManagerHost?: string; + // SLAS Shopper + /** SLAS client ID for shopper authentication */ + slasClientId?: string; + /** SLAS client secret for private shopper clients */ + slasClientSecret?: string; + /** B2C Commerce site/channel ID */ + siteId?: string; + // SCAPI /** SCAPI short code */ shortCode?: string; diff --git a/packages/b2c-tooling-sdk/src/slas/index.ts b/packages/b2c-tooling-sdk/src/slas/index.ts new file mode 100644 index 00000000..81790d17 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/slas/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * SLAS Shopper Login token retrieval. + * + * Provides functions to obtain shopper access tokens from the SLAS + * Shopper Login service, supporting public (PKCE) and private + * (client_credentials) client flows for both guest and registered customers. + * + * @module slas + */ +export type {SlasTokenConfig, SlasTokenResponse, SlasRegisteredLoginConfig} from './types.js'; +export {generateCodeChallenge, generateCodeVerifier} from './pkce.js'; +export {getGuestToken, getRegisteredToken} from './token.js'; diff --git a/packages/b2c-tooling-sdk/src/slas/pkce.ts b/packages/b2c-tooling-sdk/src/slas/pkce.ts new file mode 100644 index 00000000..7234e9b3 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/slas/pkce.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * PKCE (Proof Key for Code Exchange) helpers for SLAS authentication. + * + * @module slas/pkce + */ +import {randomBytes, createHash} from 'node:crypto'; + +/** + * Encodes a buffer as a base64url string (RFC 7636). + */ +function base64url(buffer: Buffer): string { + return buffer.toString('base64url'); +} + +/** + * Generates a cryptographically random PKCE code verifier. + * + * @returns A 128-character base64url-encoded random string + */ +export function generateCodeVerifier(): string { + return base64url(randomBytes(96)); +} + +/** + * Generates a PKCE code challenge from a code verifier using S256. + * + * @param verifier - The code verifier to hash + * @returns The base64url-encoded SHA-256 hash of the verifier + */ +export function generateCodeChallenge(verifier: string): string { + return base64url(createHash('sha256').update(verifier).digest()); +} diff --git a/packages/b2c-tooling-sdk/src/slas/token.ts b/packages/b2c-tooling-sdk/src/slas/token.ts new file mode 100644 index 00000000..bbb9def4 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/slas/token.ts @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * SLAS shopper token retrieval. + * + * Supports guest and registered customer flows for both public (PKCE) and + * private (client_credentials) SLAS clients. + * + * @module slas/token + */ +import {getLogger} from '../logging/logger.js'; +import {generateCodeChallenge, generateCodeVerifier} from './pkce.js'; +import type {SlasTokenConfig, SlasTokenResponse, SlasRegisteredLoginConfig} from './types.js'; + +/** + * Builds the SLAS shopper auth base URL. + */ +function buildBaseUrl(shortCode: string, organizationId: string): string { + return `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/${organizationId}`; +} + +/** + * Parses an authorization code and usid from a redirect Location header. + * + * @throws Error if the redirect does not contain the expected code parameter + */ +function parseRedirectCode(locationHeader: string): {code: string; usid: string} { + const url = new URL(locationHeader, 'http://localhost'); + const code = url.searchParams.get('code'); + const usid = url.searchParams.get('usid') ?? ''; + + if (!code) { + throw new Error(`SLAS redirect did not contain authorization code. Location: ${locationHeader}`); + } + + return {code, usid}; +} + +/** + * Checks a SLAS response for errors and throws with details. + */ +async function checkResponse(response: Response, context: string): Promise { + if (response.ok) return; + + let detail = ''; + try { + const body = await response.text(); + detail = body ? ` — ${body}` : ''; + } catch { + // ignore body parse errors + } + + throw new Error(`SLAS ${context} failed (HTTP ${response.status})${detail}`); +} + +/** + * Retrieves a guest shopper access token from SLAS. + * + * - **Private client** (slasClientSecret set): Uses `client_credentials` grant. + * - **Public client** (no secret): Uses PKCE authorization code flow with `hint=guest`. + * + * @param config - SLAS token configuration + * @returns The token response including access_token and refresh_token + */ +export async function getGuestToken(config: SlasTokenConfig): Promise { + const logger = getLogger(); + + if (config.slasClientSecret) { + return getPrivateClientGuestToken(config); + } + + logger.debug('Using public client PKCE guest flow'); + + const baseUrl = buildBaseUrl(config.shortCode, config.organizationId); + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + + // Step 1: Authorize — get authorization code via 303 redirect + const authorizeParams = new URLSearchParams({ + client_id: config.slasClientId, + response_type: 'code', + redirect_uri: config.redirectUri, + hint: 'guest', + code_challenge: challenge, + }); + + const authorizeUrl = `${baseUrl}/oauth2/authorize?${authorizeParams.toString()}`; + logger.debug({url: authorizeUrl}, 'SLAS authorize request'); + + const authorizeResponse = await fetch(authorizeUrl, {redirect: 'manual'}); + + if (authorizeResponse.status !== 303) { + await checkResponse(authorizeResponse, 'authorize'); + throw new Error(`Expected 303 redirect from SLAS authorize, got ${authorizeResponse.status}`); + } + + const location = authorizeResponse.headers.get('location'); + if (!location) { + throw new Error('SLAS authorize response missing Location header'); + } + + const {code, usid} = parseRedirectCode(location); + logger.debug({usid}, 'Got authorization code from SLAS'); + + // Step 2: Exchange code for token + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code_pkce', + client_id: config.slasClientId, + code, + code_verifier: verifier, + redirect_uri: config.redirectUri, + channel_id: config.siteId, + usid, + }); + + const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: tokenBody, + }); + + await checkResponse(tokenResponse, 'token exchange (authorization_code_pkce)'); + return (await tokenResponse.json()) as SlasTokenResponse; +} + +/** + * Private client guest token via client_credentials grant. + */ +async function getPrivateClientGuestToken(config: SlasTokenConfig): Promise { + const logger = getLogger(); + logger.debug('Using private client client_credentials guest flow'); + + const baseUrl = buildBaseUrl(config.shortCode, config.organizationId); + const basicAuth = Buffer.from(`${config.slasClientId}:${config.slasClientSecret}`).toString('base64'); + + const tokenBody = new URLSearchParams({ + grant_type: 'client_credentials', + channel_id: config.siteId, + }); + + const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuth}`, + }, + body: tokenBody, + }); + + await checkResponse(tokenResponse, 'token (client_credentials)'); + return (await tokenResponse.json()) as SlasTokenResponse; +} + +/** + * Retrieves a registered customer access token from SLAS. + * + * Uses the `/oauth2/login` endpoint with shopper credentials, then exchanges + * the authorization code for an access token. + * + * - **Public client**: Uses PKCE for code exchange. + * - **Private client**: Uses client credentials (Basic auth) for code exchange. + * + * @param config - SLAS token configuration including shopper credentials + * @returns The token response including access_token and refresh_token + */ +export async function getRegisteredToken(config: SlasRegisteredLoginConfig): Promise { + const logger = getLogger(); + const baseUrl = buildBaseUrl(config.shortCode, config.organizationId); + const isPrivate = Boolean(config.slasClientSecret); + + logger.debug({isPrivate}, 'Using registered customer login flow'); + + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + + // Step 1: Login with shopper credentials + const shopperAuth = Buffer.from(`${config.shopperLogin}:${config.shopperPassword}`).toString('base64'); + + const loginBody = new URLSearchParams({ + client_id: config.slasClientId, + channel_id: config.siteId, + code_challenge: challenge, + redirect_uri: config.redirectUri, + }); + + const loginResponse = await fetch(`${baseUrl}/oauth2/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${shopperAuth}`, + }, + body: loginBody, + redirect: 'manual', + }); + + if (loginResponse.status !== 303) { + await checkResponse(loginResponse, 'login'); + throw new Error(`Expected 303 redirect from SLAS login, got ${loginResponse.status}`); + } + + const location = loginResponse.headers.get('location'); + if (!location) { + throw new Error('SLAS login response missing Location header'); + } + + const {code, usid} = parseRedirectCode(location); + logger.debug({usid}, 'Got authorization code from SLAS login'); + + // Step 2: Exchange code for token + if (isPrivate) { + const basicAuth = Buffer.from(`${config.slasClientId}:${config.slasClientSecret}`).toString('base64'); + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.redirectUri, + channel_id: config.siteId, + usid, + }); + + const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuth}`, + }, + body: tokenBody, + }); + + await checkResponse(tokenResponse, 'token exchange (authorization_code)'); + return (await tokenResponse.json()) as SlasTokenResponse; + } + + // Public client — use PKCE + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code_pkce', + client_id: config.slasClientId, + code, + code_verifier: verifier, + redirect_uri: config.redirectUri, + channel_id: config.siteId, + usid, + }); + + const tokenResponse = await fetch(`${baseUrl}/oauth2/token`, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: tokenBody, + }); + + await checkResponse(tokenResponse, 'token exchange (authorization_code_pkce)'); + return (await tokenResponse.json()) as SlasTokenResponse; +} diff --git a/packages/b2c-tooling-sdk/src/slas/types.ts b/packages/b2c-tooling-sdk/src/slas/types.ts new file mode 100644 index 00000000..6b3a2eaf --- /dev/null +++ b/packages/b2c-tooling-sdk/src/slas/types.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Types for SLAS Shopper Login token retrieval. + * + * @module slas/types + */ + +/** + * Response from SLAS token endpoints. + */ +export interface SlasTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + usid: string; + customer_id: string; + id_token?: string; +} + +/** + * Configuration for SLAS shopper token retrieval. + */ +export interface SlasTokenConfig { + /** SCAPI short code */ + shortCode: string; + /** Organization ID in f_ecom_xxxx_yyy format */ + organizationId: string; + /** SLAS client ID */ + slasClientId: string; + /** SLAS client secret (undefined = public client) */ + slasClientSecret?: string; + /** B2C Commerce site/channel ID */ + siteId: string; + /** OAuth redirect URI */ + redirectUri: string; +} + +/** + * Configuration for registered customer login. + */ +export interface SlasRegisteredLoginConfig extends SlasTokenConfig { + /** Shopper login/username */ + shopperLogin: string; + /** Shopper password */ + shopperPassword: string; +} diff --git a/packages/b2c-tooling-sdk/test/slas/pkce.test.ts b/packages/b2c-tooling-sdk/test/slas/pkce.test.ts new file mode 100644 index 00000000..46f8501a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/slas/pkce.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {createHash} from 'node:crypto'; +import {generateCodeVerifier, generateCodeChallenge} from '@salesforce/b2c-tooling-sdk/slas'; + +describe('slas/pkce', () => { + describe('generateCodeVerifier', () => { + it('generates a base64url-encoded string', () => { + const verifier = generateCodeVerifier(); + // 96 random bytes → 128 base64url characters + expect(verifier).to.have.lengthOf(128); + // base64url characters only + expect(verifier).to.match(/^[A-Za-z0-9_-]+$/); + }); + + it('generates unique values', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).to.not.equal(b); + }); + }); + + describe('generateCodeChallenge', () => { + it('generates the SHA-256 hash of the verifier', () => { + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + + // Manually compute expected challenge + const expected = createHash('sha256').update(verifier).digest('base64url'); + expect(challenge).to.equal(expected); + }); + + it('generates a base64url-encoded string', () => { + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + // SHA-256 digest is 32 bytes → 43 base64url characters + expect(challenge).to.have.lengthOf(43); + expect(challenge).to.match(/^[A-Za-z0-9_-]+$/); + }); + + it('produces different challenges for different verifiers', () => { + const a = generateCodeChallenge(generateCodeVerifier()); + const b = generateCodeChallenge(generateCodeVerifier()); + expect(a).to.not.equal(b); + }); + + it('produces the same challenge for the same verifier', () => { + const verifier = generateCodeVerifier(); + const a = generateCodeChallenge(verifier); + const b = generateCodeChallenge(verifier); + expect(a).to.equal(b); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/slas/token.test.ts b/packages/b2c-tooling-sdk/test/slas/token.test.ts new file mode 100644 index 00000000..3ffa42e8 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/slas/token.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import { + getGuestToken, + getRegisteredToken, + type SlasTokenConfig, + type SlasRegisteredLoginConfig, +} from '@salesforce/b2c-tooling-sdk/slas'; + +const SHORT_CODE = 'kv7kzm78'; +const ORG_ID = 'f_ecom_abcd_123'; +const BASE_URL = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/${ORG_ID}`; + +const MOCK_TOKEN_RESPONSE = { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 1800, + token_type: 'Bearer', + usid: 'mock-usid', + customer_id: 'mock-customer-id', +}; + +function baseConfig(overrides: Partial = {}): SlasTokenConfig { + return { + shortCode: SHORT_CODE, + organizationId: ORG_ID, + slasClientId: 'test-client-id', + siteId: 'RefArch', + redirectUri: 'http://localhost:3000/callback', + ...overrides, + }; +} + +describe('slas/token', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('getGuestToken - public client (PKCE)', () => { + it('exchanges authorization code for token via PKCE flow', async () => { + server.use( + http.get(`${BASE_URL}/oauth2/authorize`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('client_id')).to.equal('test-client-id'); + expect(url.searchParams.get('response_type')).to.equal('code'); + expect(url.searchParams.get('hint')).to.equal('guest'); + expect(url.searchParams.get('code_challenge')).to.be.a('string'); + expect(url.searchParams.get('redirect_uri')).to.equal('http://localhost:3000/callback'); + + return new HttpResponse(null, { + status: 303, + headers: { + Location: `http://localhost:3000/callback?code=auth-code-123&usid=usid-456`, + }, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, async ({request}) => { + const body = await request.text(); + const params = new URLSearchParams(body); + expect(params.get('grant_type')).to.equal('authorization_code_pkce'); + expect(params.get('client_id')).to.equal('test-client-id'); + expect(params.get('code')).to.equal('auth-code-123'); + expect(params.get('code_verifier')).to.be.a('string'); + expect(params.get('channel_id')).to.equal('RefArch'); + expect(params.get('usid')).to.equal('usid-456'); + + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const result = await getGuestToken(baseConfig()); + + expect(result.access_token).to.equal('mock-access-token'); + expect(result.refresh_token).to.equal('mock-refresh-token'); + expect(result.expires_in).to.equal(1800); + expect(result.usid).to.equal('mock-usid'); + }); + + it('throws when authorize does not return 303', async () => { + server.use( + http.get(`${BASE_URL}/oauth2/authorize`, () => { + return HttpResponse.json({error: 'invalid_client'}, {status: 401}); + }), + ); + + try { + await getGuestToken(baseConfig()); + expect.fail('Expected error'); + } catch (error: unknown) { + expect((error as Error).message).to.include('authorize'); + } + }); + }); + + describe('getGuestToken - private client (client_credentials)', () => { + it('obtains token via client_credentials grant', async () => { + server.use( + http.post(`${BASE_URL}/oauth2/token`, async ({request}) => { + const auth = request.headers.get('Authorization'); + const expected = Buffer.from('test-client-id:test-secret').toString('base64'); + expect(auth).to.equal(`Basic ${expected}`); + + const body = await request.text(); + const params = new URLSearchParams(body); + expect(params.get('grant_type')).to.equal('client_credentials'); + expect(params.get('channel_id')).to.equal('RefArch'); + + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const result = await getGuestToken(baseConfig({slasClientSecret: 'test-secret'})); + + expect(result.access_token).to.equal('mock-access-token'); + }); + + it('throws on token error', async () => { + server.use( + http.post(`${BASE_URL}/oauth2/token`, () => { + return HttpResponse.json({error: 'invalid_client'}, {status: 401}); + }), + ); + + try { + await getGuestToken(baseConfig({slasClientSecret: 'bad-secret'})); + expect.fail('Expected error'); + } catch (error: unknown) { + expect((error as Error).message).to.include('client_credentials'); + expect((error as Error).message).to.include('401'); + } + }); + }); + + describe('getRegisteredToken - public client', () => { + it('logs in with shopper credentials and exchanges code via PKCE', async () => { + server.use( + http.post(`${BASE_URL}/oauth2/login`, async ({request}) => { + const auth = request.headers.get('Authorization'); + const expected = Buffer.from('user@example.com:pass123').toString('base64'); + expect(auth).to.equal(`Basic ${expected}`); + + const body = await request.text(); + const params = new URLSearchParams(body); + expect(params.get('client_id')).to.equal('test-client-id'); + expect(params.get('channel_id')).to.equal('RefArch'); + expect(params.get('code_challenge')).to.be.a('string'); + + return new HttpResponse(null, { + status: 303, + headers: { + Location: `http://localhost:3000/callback?code=reg-code-789&usid=usid-reg`, + }, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, async ({request}) => { + const body = await request.text(); + const params = new URLSearchParams(body); + expect(params.get('grant_type')).to.equal('authorization_code_pkce'); + expect(params.get('code')).to.equal('reg-code-789'); + expect(params.get('code_verifier')).to.be.a('string'); + + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const config: SlasRegisteredLoginConfig = { + ...baseConfig(), + shopperLogin: 'user@example.com', + shopperPassword: 'pass123', + }; + + const result = await getRegisteredToken(config); + + expect(result.access_token).to.equal('mock-access-token'); + }); + }); + + describe('getRegisteredToken - private client', () => { + it('logs in with shopper credentials and exchanges code with Basic auth', async () => { + server.use( + http.post(`${BASE_URL}/oauth2/login`, () => { + return new HttpResponse(null, { + status: 303, + headers: { + Location: `http://localhost:3000/callback?code=priv-code&usid=usid-priv`, + }, + }); + }), + http.post(`${BASE_URL}/oauth2/token`, async ({request}) => { + const auth = request.headers.get('Authorization'); + const expected = Buffer.from('test-client-id:test-secret').toString('base64'); + expect(auth).to.equal(`Basic ${expected}`); + + const body = await request.text(); + const params = new URLSearchParams(body); + expect(params.get('grant_type')).to.equal('authorization_code'); + expect(params.get('code')).to.equal('priv-code'); + + return HttpResponse.json(MOCK_TOKEN_RESPONSE); + }), + ); + + const config: SlasRegisteredLoginConfig = { + ...baseConfig({slasClientSecret: 'test-secret'}), + shopperLogin: 'user@example.com', + shopperPassword: 'pass123', + }; + + const result = await getRegisteredToken(config); + + expect(result.access_token).to.equal('mock-access-token'); + }); + + it('throws when login does not return 303', async () => { + server.use( + http.post(`${BASE_URL}/oauth2/login`, () => { + return HttpResponse.json({error: 'invalid_credentials'}, {status: 401}); + }), + ); + + const config: SlasRegisteredLoginConfig = { + ...baseConfig(), + shopperLogin: 'bad@example.com', + shopperPassword: 'wrong', + }; + + try { + await getRegisteredToken(config); + expect.fail('Expected error'); + } catch (error: unknown) { + expect((error as Error).message).to.include('login'); + } + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-slas/SKILL.md b/skills/b2c-cli/skills/b2c-slas/SKILL.md index 34fe2550..9daf7d3f 100644 --- a/skills/b2c-cli/skills/b2c-slas/SKILL.md +++ b/skills/b2c-cli/skills/b2c-slas/SKILL.md @@ -81,25 +81,33 @@ b2c slas client create \ **Important:** The custom scope in your SLAS client must match the scope defined in your Custom API's `schema.yaml` security section. -### Get a Token for Testing +### Get a Shopper Token -After creating a SLAS client, obtain a token for API testing: +Use `b2c slas token` to obtain a shopper access token for API testing: ```bash -# Set credentials from client creation output -# Find your shortcode in Business Manager: Administration > Site Development > Salesforce Commerce API Settings -SHORTCODE="kv7kzm78" # Example shortcode - yours will be different -ORG="f_ecom_zzpq_013" -CLIENT_ID="your-client-id" -CLIENT_SECRET="your-client-secret" -SITE="RefArch" - -# Get access token -curl -s "https://$SHORTCODE.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/$ORG/oauth2/token" \ - -u "$CLIENT_ID:$CLIENT_SECRET" \ - -d "grant_type=client_credentials&channel_id=$SITE" +# Guest token with auto-discovery (finds first public SLAS client) +b2c slas token --tenant-id abcd_123 --site-id RefArch + +# Guest token with explicit client (public PKCE flow) +b2c slas token --slas-client-id my-client --tenant-id abcd_123 --short-code kv7kzm78 --site-id RefArch + +# Guest token with private client (client_credentials flow) +b2c slas token --slas-client-id my-client --slas-client-secret sk_xxx --tenant-id abcd_123 --short-code kv7kzm78 --site-id RefArch + +# Registered customer token +b2c slas token --tenant-id abcd_123 --site-id RefArch --shopper-login user@example.com --shopper-password secret + +# JSON output (includes refresh token, expiry, usid, etc.) +b2c slas token --tenant-id abcd_123 --site-id RefArch --json + +# Use token in a subsequent API call +TOKEN=$(b2c slas token --tenant-id abcd_123 --site-id RefArch) +curl -H "Authorization: Bearer $TOKEN" "https://kv7kzm78.api.commercecloud.salesforce.com/..." ``` +The `--slas-client-id` and `--slas-client-secret` can also be set via `SFCC_SLAS_CLIENT_ID` and `SFCC_SLAS_CLIENT_SECRET` environment variables, or `slasClientId` and `slasClientSecret` in dw.json. + ### Update SLAS Client ```bash