Skip to content

Commit e301330

Browse files
committed
Add credential grouping to prevent mixing creds from different sources
OAuth (clientId/clientSecret) and Basic auth (username/password) credentials are now treated as atomic groups. If any field in a group is set by a higher-priority source, all fields in that group from lower-priority sources are skipped. This prevents accidentally mixing credentials that don't belong together.
1 parent 6963618 commit e301330

4 files changed

Lines changed: 178 additions & 2 deletions

File tree

docs/guide/configuration.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ Configuration is resolved with the following precedence (highest to lowest):
161161
Plugins can add custom configuration sources like secret managers or environment-specific files. See [Extending the CLI](./extending) for details.
162162
:::
163163

164+
### Credential Grouping
165+
166+
To prevent mixing credentials from different sources, certain fields are treated as atomic groups:
167+
168+
- **OAuth**: `clientId` and `clientSecret`
169+
- **Basic Auth**: `username` and `password`
170+
171+
If any field in a group is set by a higher-priority source, all fields in that group from lower-priority sources are ignored. This ensures credential pairs always come from the same source.
172+
173+
**Example:**
174+
- dw.json provides `clientId` only
175+
- A plugin provides `clientSecret`
176+
- Result: Only `clientId` is used; the plugin's `clientSecret` is ignored to prevent mismatched credentials
177+
164178
::: warning Hostname Mismatch Protection
165179
When you explicitly specify a hostname that differs from the `dw.json` hostname, the CLI ignores all other values from `dw.json` and only uses your explicit overrides. This prevents accidentally using credentials from one instance with a different server.
166180
:::

docs/guide/extending.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ Configuration is resolved with the following precedence:
5656

5757
Each source fills in missing values - it doesn't override values from higher-priority sources.
5858

59+
::: warning Credential Grouping
60+
OAuth credentials (`clientId`/`clientSecret`) and Basic auth credentials (`username`/`password`) are treated as atomic groups. If any field in a group is already set by a higher-priority source, all fields in that group from your source will be ignored. Ensure your source provides complete credential pairs, or that higher-priority sources don't partially define the same credentials.
61+
:::
62+
5963
### Example: Custom Config Source Plugin
6064

6165
The SDK includes an example plugin that loads configuration from `.env.b2c` files:

packages/b2c-tooling-sdk/src/config/resolver.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,50 @@ import type {
2525
} from './types.js';
2626
import {ResolvedConfigImpl} from './resolved-config.js';
2727

28+
/**
29+
* Credential groups that must come from the same source.
30+
*
31+
* When merging configuration, if any field in a group is already set by a
32+
* higher-priority source, all fields in that group from lower-priority
33+
* sources are skipped. This prevents mixing credentials that don't belong together.
34+
*/
35+
const CREDENTIAL_GROUPS: (keyof NormalizedConfig)[][] = [
36+
['clientId', 'clientSecret'],
37+
['username', 'password'],
38+
];
39+
40+
/**
41+
* Get the set of credential groups that are already claimed in the config.
42+
*
43+
* A group is "claimed" if any of its fields are set.
44+
*
45+
* @param config - The current configuration
46+
* @returns Set of group indices that are claimed
47+
*/
48+
function getClaimedCredentialGroups(config: NormalizedConfig): Set<number> {
49+
const claimed = new Set<number>();
50+
for (let i = 0; i < CREDENTIAL_GROUPS.length; i++) {
51+
const group = CREDENTIAL_GROUPS[i];
52+
if (group.some((f) => config[f] !== undefined)) {
53+
claimed.add(i);
54+
}
55+
}
56+
return claimed;
57+
}
58+
59+
/**
60+
* Check if a field belongs to a credential group in the claimed set.
61+
*
62+
* @param field - The field name to check
63+
* @param claimedGroups - Set of group indices that are already claimed
64+
* @returns true if the field's credential group is claimed
65+
*/
66+
function isFieldInClaimedGroup(field: string, claimedGroups: Set<number>): boolean {
67+
const groupIndex = CREDENTIAL_GROUPS.findIndex((g) => g.includes(field as keyof NormalizedConfig));
68+
if (groupIndex === -1) return false;
69+
return claimedGroups.has(groupIndex);
70+
}
71+
2872
/**
2973
* Resolves configuration from multiple sources with consistent behavior.
3074
*
@@ -123,11 +167,22 @@ export class ConfigResolver {
123167
fieldsContributed,
124168
});
125169

170+
// Capture which credential groups are already claimed BEFORE processing this source
171+
// This allows a single source to provide complete credential pairs
172+
const claimedGroups = getClaimedCredentialGroups(baseConfig);
173+
126174
// Merge: source values fill in gaps (don't override existing values)
127175
for (const [key, value] of Object.entries(sourceConfig)) {
128-
if (value !== undefined && baseConfig[key as keyof NormalizedConfig] === undefined) {
129-
(baseConfig as Record<string, unknown>)[key] = value;
176+
if (value === undefined) continue;
177+
if (baseConfig[key as keyof NormalizedConfig] !== undefined) continue;
178+
179+
// Skip if this field's credential group was already claimed by a higher-priority source
180+
// This prevents mixing credentials from different sources
181+
if (isFieldInClaimedGroup(key, claimedGroups)) {
182+
continue;
130183
}
184+
185+
(baseConfig as Record<string, unknown>)[key] = value;
131186
}
132187
}
133188
}

packages/b2c-tooling-sdk/test/config/resolver.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,109 @@ describe('config/resolver', () => {
171171
});
172172
});
173173

174+
describe('credential grouping', () => {
175+
it('does not mix clientId and clientSecret from different sources', () => {
176+
const source1 = new MockSource('first', {clientId: 'first-client'});
177+
const source2 = new MockSource('second', {clientSecret: 'second-secret'});
178+
const resolver = new ConfigResolver([source1, source2]);
179+
180+
const {config} = resolver.resolve();
181+
182+
expect(config.clientId).to.equal('first-client');
183+
expect(config.clientSecret).to.be.undefined; // Not mixed from source2
184+
});
185+
186+
it('does not mix username and password from different sources', () => {
187+
const source1 = new MockSource('first', {username: 'user1'});
188+
const source2 = new MockSource('second', {password: 'pass2'});
189+
const resolver = new ConfigResolver([source1, source2]);
190+
191+
const {config} = resolver.resolve();
192+
193+
expect(config.username).to.equal('user1');
194+
expect(config.password).to.be.undefined; // Not mixed from source2
195+
});
196+
197+
it('allows complete credential pairs from same source', () => {
198+
const source1 = new MockSource('first', {hostname: 'example.com'});
199+
const source2 = new MockSource('second', {
200+
clientId: 'client',
201+
clientSecret: 'secret',
202+
});
203+
const resolver = new ConfigResolver([source1, source2]);
204+
205+
const {config} = resolver.resolve();
206+
207+
expect(config.hostname).to.equal('example.com');
208+
expect(config.clientId).to.equal('client');
209+
expect(config.clientSecret).to.equal('secret');
210+
});
211+
212+
it('allows non-grouped fields to merge normally', () => {
213+
const source1 = new MockSource('first', {clientId: 'client'});
214+
const source2 = new MockSource('second', {
215+
hostname: 'example.com',
216+
codeVersion: 'v1',
217+
});
218+
const resolver = new ConfigResolver([source1, source2]);
219+
220+
const {config} = resolver.resolve();
221+
222+
expect(config.clientId).to.equal('client');
223+
expect(config.hostname).to.equal('example.com');
224+
expect(config.codeVersion).to.equal('v1');
225+
});
226+
227+
it('blocks both oauth fields when clientId is claimed', () => {
228+
const source1 = new MockSource('first', {clientId: 'first-client'});
229+
const source2 = new MockSource('second', {
230+
clientId: 'second-client',
231+
clientSecret: 'second-secret',
232+
});
233+
const resolver = new ConfigResolver([source1, source2]);
234+
235+
const {config} = resolver.resolve();
236+
237+
expect(config.clientId).to.equal('first-client');
238+
expect(config.clientSecret).to.be.undefined; // Blocked due to group claim
239+
});
240+
241+
it('blocks both basic auth fields when username is claimed', () => {
242+
const source1 = new MockSource('first', {username: 'first-user'});
243+
const source2 = new MockSource('second', {
244+
username: 'second-user',
245+
password: 'second-pass',
246+
});
247+
const resolver = new ConfigResolver([source1, source2]);
248+
249+
const {config} = resolver.resolve();
250+
251+
expect(config.username).to.equal('first-user');
252+
expect(config.password).to.be.undefined; // Blocked due to group claim
253+
});
254+
255+
it('allows independent credential groups to come from different sources', () => {
256+
const source1 = new MockSource('first', {
257+
clientId: 'oauth-client',
258+
clientSecret: 'oauth-secret',
259+
});
260+
const source2 = new MockSource('second', {
261+
username: 'basic-user',
262+
password: 'basic-pass',
263+
});
264+
const resolver = new ConfigResolver([source1, source2]);
265+
266+
const {config} = resolver.resolve();
267+
268+
// OAuth from source1
269+
expect(config.clientId).to.equal('oauth-client');
270+
expect(config.clientSecret).to.equal('oauth-secret');
271+
// Basic from source2
272+
expect(config.username).to.equal('basic-user');
273+
expect(config.password).to.equal('basic-pass');
274+
});
275+
});
276+
174277
describe('createAuthCredentials', () => {
175278
it('creates auth credentials from resolved config', () => {
176279
const source = new MockSource('test', {

0 commit comments

Comments
 (0)