Skip to content

Commit 59b218d

Browse files
@W-20893800: Adding support for stateful auth [sfcc-ci compatibility]
1 parent 38ab575 commit 59b218d

File tree

8 files changed

+117
-81
lines changed

8 files changed

+117
-81
lines changed

docs/cli/auth.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Commands for authentication and token management.
1010

1111
The CLI supports **stateful auth** (session stored on disk) in addition to **stateless auth** (client credentials or one-off implicit flow):
1212

13-
- **Stateful (browser)**: After you run `b2c auth login`, your token is stored in the same location as [sfcc-ci](https://github.com/SalesforceCommerceCloud/sfcc-ci). Subsequent commands (e.g. `b2c auth token`, `b2c am orgs list`) use this token when it is present and valid. If the token is missing or expired, the CLI falls back to stateless auth.
13+
- **Stateful (browser)**: After you run `b2c auth login`, your token is stored on disk in the CLI data directory. Subsequent commands (e.g. `b2c auth token`, `b2c am orgs list`) use this token when it is present and valid. If the token is missing or expired, the CLI falls back to stateless auth.
1414
- **Stateful (client credentials)**: Use `b2c auth client` to authenticate with client ID and secret (or user/password) for non-interactive/automation use. Supports auto-renewal with `--renew`.
1515
- **Stateless**: You provide `--client-id` (and optionally `--client-secret`) per run or via environment/config; no session is persisted.
1616

@@ -20,7 +20,7 @@ Use **auth:logout** to clear the stored session and return to stateless-only beh
2020

2121
## b2c auth login
2222

23-
Log in via browser (implicit OAuth) and save the session for stateful auth. Uses the same storage as sfcc-ci.
23+
Log in via browser (implicit OAuth) and save the session for stateful auth.
2424

2525
### Usage
2626

@@ -41,7 +41,7 @@ b2c auth logout
4141

4242
## b2c auth client
4343

44-
Authenticate an API client using client credentials or resource owner password credentials and save the session for stateful auth. Mirrors the [sfcc-ci `client:auth`](https://github.com/SalesforceCommerceCloud/sfcc-ci) command.
44+
Authenticate an API client using client credentials or resource owner password credentials and save the session for stateful auth. Compatible with the [sfcc-ci `client:auth`](https://github.com/SalesforceCommerceCloud/sfcc-ci) workflow.
4545

4646
This is the non-interactive alternative to `auth login` — ideal for CI/CD pipelines and automation.
4747

@@ -120,7 +120,7 @@ b2c auth client renew
120120

121121
## b2c auth client token
122122

123-
Return the current stored authentication token. Mirrors [sfcc-ci `client:auth:token`](https://github.com/SalesforceCommerceCloud/sfcc-ci).
123+
Return the current stored authentication token. Compatible with the [sfcc-ci `client:auth:token`](https://github.com/SalesforceCommerceCloud/sfcc-ci) workflow.
124124

125125
### Usage
126126

docs/guide/authentication.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ The CLI supports four authentication methods:
4949

5050
**Client Credentials** uses the API client's secret for non-interactive authentication. This is ideal for CI/CD pipelines and automation.
5151

52-
**Stateful User Auth** uses `b2c auth login` to open a browser for interactive login once, then stores the session on disk. Subsequent commands automatically use the stored token when it is present and valid, without re-opening the browser. Uses the same storage as [sfcc-ci](https://github.com/SalesforceCommerceCloud/sfcc-ci). Clear the session with `b2c auth logout`. See [Auth Commands](/cli/auth#b2c-auth-login) for details.
52+
**Stateful User Auth** uses `b2c auth login` to open a browser for interactive login once, then stores the session on disk. Subsequent commands automatically use the stored token when it is present and valid, without re-opening the browser. Clear the session with `b2c auth logout`. See [Auth Commands](/cli/auth#b2c-auth-login) for details.
5353

54-
**Stateful Client Auth** uses `b2c auth client` to authenticate once with client credentials (or user/password), store the session, and reuse it across subsequent commands without passing credentials each time. Use `--renew` to enable automatic token renewal via `b2c auth client renew`. See [Auth Commands](/cli/auth#b2c-auth-client) for details.
54+
**Stateful Client Auth** uses `b2c auth client` to authenticate once with client credentials (or user/password), store the session, and reuse it across subsequent commands without passing credentials each time. Mirrors the [sfcc-ci](https://github.com/SalesforceCommerceCloud/sfcc-ci) `client:auth` workflow. Use `--renew` to enable automatic token renewal via `b2c auth client renew`. See [Auth Commands](/cli/auth#b2c-auth-client) for details.
5555

5656
::: warning Stateful vs Stateless Precedence
5757
The stored session is used only when the token is valid **and** no explicit auth flags are provided. The CLI falls back to stateless auth when:

packages/b2c-tooling-sdk/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,6 @@
359359
"node": ">=22.16.0"
360360
},
361361
"dependencies": {
362-
"conf": "^13.0.0",
363362
"@salesforce/telemetry": "6.4.6",
364363
"archiver": "7.0.1",
365364
"chokidar": "5.0.0",

packages/b2c-tooling-sdk/src/auth/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ export {ApiKeyStrategy} from './api-key.js';
8484
export {StatefulOAuthStrategy} from './stateful-oauth-strategy.js';
8585
export type {StatefulOAuthStrategyOptions} from './stateful-oauth-strategy.js';
8686

87-
// Stateful auth store (sfcc-ci compatible)
87+
// Stateful auth store
8888
export {
89+
initializeStatefulStore,
8990
getStoredSession,
9091
setStoredSession,
9192
clearStoredSession,

packages/b2c-tooling-sdk/src/auth/stateful-store.ts

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,110 +4,131 @@
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
66
/**
7-
* Stateful auth store using the same storage mechanism and keys as sfcc-ci.
8-
* Uses the `conf` package with projectName 'sfcc-ci' so tokens persist across
9-
* sfcc-ci and b2c-cli when present and valid.
7+
* Stateful auth store backed by a JSON file in the oclif data directory
8+
* (e.g. ~/Library/Application Support/@salesforce/b2c-cli/auth-session.json on macOS).
9+
*
10+
* Initialize via initializeStatefulStore(dataDir) from BaseCommand.init() so the
11+
* session file is co-located with other CLI data. Falls back to an OS-appropriate
12+
* default path when used standalone (outside a CLI command).
13+
*
14+
* The stateful auth workflow (b2c auth client / b2c auth login / b2c auth logout)
15+
* is compatible with sfcc-ci command patterns. Session data is stored internally
16+
* in the CLI data directory, not in the sfcc-ci config store.
1017
*
1118
* @module auth/stateful-store
1219
*/
13-
import Conf from 'conf';
20+
import {existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync} from 'node:fs';
21+
import {join} from 'node:path';
22+
import {homedir, platform} from 'node:os';
1423
import {decodeJWT} from './oauth.js';
1524
import {getLogger} from '../logging/logger.js';
1625

17-
/** Config keys matching sfcc-ci lib/config usage */
18-
const SFCC_CLIENT_ID = 'SFCC_CLIENT_ID';
19-
const SFCC_CLIENT_TOKEN = 'SFCC_CLIENT_TOKEN';
20-
const SFCC_REFRESH_TOKEN = 'SFCC_REFRESH_TOKEN';
21-
const SFCC_CLIENT_RENEW_BASE = 'SFCC_CLIENT_RENEW_BASE';
22-
const SFCC_USER = 'SFCC_USER';
26+
const STATEFUL_AUTH_SESSION_STORE = 'auth-session.json';
2327

2428
/** Default buffer (seconds) before token exp to consider it expired */
2529
const EXPIRY_BUFFER_SEC = 60;
2630

27-
let storeInstance: Conf | null = null;
31+
let storePath: string | null = null;
2832

2933
/**
30-
* Returns the conf store instance (projectName 'sfcc-ci' for compatibility with sfcc-ci).
31-
* Uses 'sfcc-ci-test' when NODE_ENV === 'test' so tests do not touch user config.
34+
* Computes the oclif-compatible data directory for @salesforce/b2c-cli.
35+
* Used as a fallback when initializeStatefulStore() has not been called.
3236
*/
33-
function getStore(): Conf {
34-
if (!storeInstance) {
35-
const projectName = process.env.NODE_ENV === 'test' ? 'sfcc-ci-test' : 'sfcc-ci';
36-
storeInstance = new Conf({projectName});
37+
function getDefaultDataDir(): string {
38+
const home = homedir();
39+
const name = '@salesforce/b2c-cli';
40+
switch (platform()) {
41+
case 'darwin':
42+
return join(home, 'Library', 'Application Support', name);
43+
case 'win32':
44+
return join(process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), name);
45+
default:
46+
return join(process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'), name);
3747
}
38-
return storeInstance;
48+
}
49+
50+
function getSessionFilePath(): string {
51+
return join(storePath ?? getDefaultDataDir(), STATEFUL_AUTH_SESSION_STORE);
52+
}
53+
54+
/**
55+
* Initialize the stateful store with the oclif data directory.
56+
* Call this from BaseCommand.init() with this.config.dataDir so the session
57+
* file is stored alongside other CLI data (e.g. ~/Library/Application Support/@salesforce/b2c-cli).
58+
*/
59+
export function initializeStatefulStore(dataDir: string): void {
60+
storePath = dataDir;
3961
}
4062

4163
/**
42-
* Stored session read from stateful store.
43-
* Matches sfcc-ci persisted keys.
64+
* Stored session persisted by stateful auth commands.
4465
*/
4566
export interface StatefulSession {
4667
clientId: string;
4768
accessToken: string;
4869
refreshToken?: string | null;
49-
/** Base64-encoded "clientId:clientSecret" for token renewal (client_credentials or refresh_token). */
70+
/** Base64-encoded "clientId:clientSecret" for token renewal. */
5071
renewBase?: string | null;
5172
user?: string | null;
5273
}
5374

5475
/**
55-
* Reads the current stateful session from store if present.
56-
* Returns null if no access token is stored.
76+
* Reads the current stateful session from the JSON file.
77+
* Returns null if no session file exists or the file is invalid.
5778
*/
5879
export function getStoredSession(): StatefulSession | null {
59-
const store = getStore();
60-
const accessToken = store.get(SFCC_CLIENT_TOKEN) as string | undefined;
61-
if (!accessToken) {
80+
const filePath = getSessionFilePath();
81+
if (!existsSync(filePath)) {
6282
return null;
6383
}
64-
const clientId = store.get(SFCC_CLIENT_ID) as string | undefined;
65-
if (!clientId) {
84+
try {
85+
const data = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<StatefulSession>;
86+
if (!data.clientId || !data.accessToken) {
87+
return null;
88+
}
89+
return {
90+
clientId: data.clientId,
91+
accessToken: data.accessToken,
92+
refreshToken: data.refreshToken ?? null,
93+
renewBase: data.renewBase ?? null,
94+
user: data.user ?? null,
95+
};
96+
} catch {
6697
return null;
6798
}
68-
return {
69-
clientId,
70-
accessToken,
71-
refreshToken: (store.get(SFCC_REFRESH_TOKEN) as string | undefined) ?? null,
72-
renewBase: (store.get(SFCC_CLIENT_RENEW_BASE) as string | undefined) ?? null,
73-
user: (store.get(SFCC_USER) as string | undefined) ?? null,
74-
};
7599
}
76100

77101
/**
78-
* Writes a session to the stateful store (same keys as sfcc-ci).
102+
* Writes a session to the JSON file, creating the directory if needed.
79103
*/
80104
export function setStoredSession(session: StatefulSession): void {
81-
const store = getStore();
82-
store.set(SFCC_CLIENT_ID, session.clientId);
83-
store.set(SFCC_CLIENT_TOKEN, session.accessToken);
84-
if (session.refreshToken != null) {
85-
store.set(SFCC_REFRESH_TOKEN, session.refreshToken);
86-
} else {
87-
store.delete(SFCC_REFRESH_TOKEN);
88-
}
89-
if (session.renewBase != null) {
90-
store.set(SFCC_CLIENT_RENEW_BASE, session.renewBase);
91-
} else {
92-
store.delete(SFCC_CLIENT_RENEW_BASE);
93-
}
94-
if (session.user != null) {
95-
store.set(SFCC_USER, session.user);
96-
} else {
97-
store.delete(SFCC_USER);
105+
const filePath = getSessionFilePath();
106+
const dir = join(filePath, '..');
107+
if (!existsSync(dir)) {
108+
mkdirSync(dir, {recursive: true});
98109
}
110+
const data: StatefulSession = {
111+
clientId: session.clientId,
112+
accessToken: session.accessToken,
113+
refreshToken: session.refreshToken ?? null,
114+
renewBase: session.renewBase ?? null,
115+
user: session.user ?? null,
116+
};
117+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
99118
}
100119

101120
/**
102-
* Clears all stateful auth data (same keys as sfcc-ci clear()).
121+
* Clears the stored session by removing the session file.
103122
*/
104123
export function clearStoredSession(): void {
105-
const store = getStore();
106-
store.delete(SFCC_CLIENT_ID);
107-
store.delete(SFCC_CLIENT_TOKEN);
108-
store.delete(SFCC_REFRESH_TOKEN);
109-
store.delete(SFCC_CLIENT_RENEW_BASE);
110-
store.delete(SFCC_USER);
124+
const filePath = getSessionFilePath();
125+
if (existsSync(filePath)) {
126+
try {
127+
unlinkSync(filePath);
128+
} catch {
129+
// ignore — file may have already been removed
130+
}
131+
}
111132
}
112133

113134
/**
@@ -142,7 +163,7 @@ export function isStatefulTokenValid(
142163
}
143164
const nowSec = Math.floor(Date.now() / 1000);
144165
if (nowSec >= exp - expiryBufferSec) {
145-
logger.debug('[StatefulAuth] Token expired or within buffer');
166+
logger.debug('[StatefulAuth] Token missing or expired');
146167
return false;
147168
}
148169
if (requiredScopes.length > 0) {
@@ -162,9 +183,10 @@ export function isStatefulTokenValid(
162183
}
163184

164185
/**
165-
* Resets the store instance (for tests).
186+
* Resets the store path (for tests). After calling this, the next operation
187+
* will use the default data directory unless initializeStatefulStore() is called again.
166188
* @internal
167189
*/
168190
export function resetStatefulStoreForTesting(): void {
169-
storeInstance = null;
191+
storePath = null;
170192
}

packages/b2c-tooling-sdk/src/cli/base-command.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {createExtraParamsMiddleware, type ExtraParamsConfig} from '../clients/mi
2222
import type {ConfigSource} from '../config/types.js';
2323
import {globalMiddlewareRegistry} from '../clients/middleware-registry.js';
2424
import {globalAuthMiddlewareRegistry} from '../auth/middleware.js';
25+
import {initializeStatefulStore} from '../auth/stateful-store.js';
2526
import {setUserAgent} from '../clients/user-agent.js';
2627
import {createTelemetry, Telemetry, type TelemetryAttributes} from '../telemetry/index.js';
2728

@@ -155,6 +156,10 @@ export abstract class BaseCommand<T extends typeof Command> extends Command {
155156

156157
this.configureLogging();
157158

159+
// Initialize stateful auth store with oclif's data directory so session
160+
// files are stored alongside other CLI data (e.g. ~/Library/Application Support/@salesforce/b2c-cli)
161+
initializeStatefulStore(this.config.dataDir);
162+
158163
// Set CLI User-Agent (CLI name/version only, without @salesforce/ prefix)
159164
// This must happen before any API clients are created
160165
setUserAgent(`${this.config.name.replace(/^@salesforce\//, '')}/${this.config.version}`);

packages/b2c-tooling-sdk/test/auth/stateful-store.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
* SPDX-License-Identifier: Apache-2
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
6+
import {mkdtempSync, rmSync} from 'node:fs';
7+
import {join} from 'node:path';
8+
import {tmpdir} from 'node:os';
69
import {expect} from 'chai';
710
import {
11+
initializeStatefulStore,
812
getStoredSession,
913
setStoredSession,
1014
clearStoredSession,
@@ -21,20 +25,20 @@ function makeJWT(payload: {exp?: number; scope?: string | string[]}): string {
2125
}
2226

2327
describe('auth/stateful-store', () => {
24-
const originalEnv = process.env.NODE_ENV;
28+
let testDir: string;
2529

2630
before(() => {
27-
process.env.NODE_ENV = 'test';
28-
resetStatefulStoreForTesting();
31+
testDir = mkdtempSync(join(tmpdir(), 'b2c-stateful-test-'));
32+
initializeStatefulStore(testDir);
2933
});
3034

3135
after(() => {
32-
process.env.NODE_ENV = originalEnv;
36+
resetStatefulStoreForTesting();
37+
rmSync(testDir, {recursive: true, force: true});
3338
});
3439

3540
afterEach(() => {
3641
clearStoredSession();
37-
resetStatefulStoreForTesting();
3842
});
3943

4044
describe('getStoredSession', () => {

packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
* SPDX-License-Identifier: Apache-2
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
6+
import {mkdtempSync, rmSync} from 'node:fs';
7+
import {join} from 'node:path';
8+
import {tmpdir} from 'node:os';
69
import {expect} from 'chai';
710
import sinon from 'sinon';
811
import {Config} from '@oclif/core';
912
import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli';
1013
import {
1114
ImplicitOAuthStrategy,
1215
StatefulOAuthStrategy,
16+
initializeStatefulStore,
1317
setStoredSession,
1418
clearStoredSession,
1519
resetStatefulStoreForTesting,
@@ -88,18 +92,20 @@ class TestOAuthCommandWithDefault extends OAuthCommand<typeof TestOAuthCommandWi
8892
describe('cli/oauth-command', () => {
8993
let config: Config;
9094
let command: TestOAuthCommand;
91-
const originalEnv = process.env.NODE_ENV;
95+
let testDir: string;
9296

9397
before(() => {
94-
process.env.NODE_ENV = 'test';
98+
testDir = mkdtempSync(join(tmpdir(), 'b2c-oauth-cmd-test-'));
99+
initializeStatefulStore(testDir);
95100
});
101+
96102
after(() => {
97-
process.env.NODE_ENV = originalEnv;
103+
resetStatefulStoreForTesting();
104+
rmSync(testDir, {recursive: true, force: true});
98105
});
99106

100107
beforeEach(async () => {
101108
clearStoredSession();
102-
resetStatefulStoreForTesting();
103109
isolateConfig();
104110
config = await Config.load();
105111
command = new TestOAuthCommand([], config);
@@ -109,7 +115,6 @@ describe('cli/oauth-command', () => {
109115
sinon.restore();
110116
restoreConfig();
111117
clearStoredSession();
112-
resetStatefulStoreForTesting();
113118
});
114119

115120
describe('requireOAuthCredentials', () => {

0 commit comments

Comments
 (0)