Skip to content

Commit a851e07

Browse files
authored
feat: allow updating Auth Provider Consumer Key and Secret
and enabling Authentication service for Auth Provider
2 parents 5a7d525 + 58a2230 commit a851e07

14 files changed

Lines changed: 568 additions & 21 deletions

File tree

CONTRIBUTING.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,16 @@ The execution will apply as few changes as necessary and so you will be able to
165165
Both the result of the `retrieve` function and the argument of the `apply` function are objects in the format defined in your `schema.json`.
166166
In this example, you would return `{enabled: boolean}` as part of `retrieve`, and expect `{enabled: boolean}` as argument in `apply`.
167167

168+
### Formatting
169+
170+
After plugin development is completed, run the following command to ensure your code adheres to the project's style guidelines:
171+
172+
```shell
173+
npm run format
174+
```
175+
176+
This will automatically format your TypeScript and configuration files (e.g., indentation, line breaks, trailing commas) to maintain consistency across the codebase.
177+
168178
## Testing
169179

170180
To run **unit tests**:

src/commands/browserforce/apply.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
1+
import { readFileSync } from 'fs';
12
import { BrowserforceCommand } from '../../browserforce-command.js';
3+
import { maskSensitiveValues } from '../../plugins/utils.js';
4+
5+
// Convert camelCase to kebab-case (e.g., "authProviders" -> "auth-providers")
6+
function camelToKebab(str: string): string {
7+
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
8+
}
9+
10+
// Load schema for a plugin
11+
function loadPluginSchema(pluginName: string): unknown | undefined {
12+
try {
13+
// Resolve schema path relative to the plugins directory
14+
// Since we're in src/commands/browserforce/, we need to go up to src/plugins/
15+
const schemaPath = new URL(`../../plugins/${camelToKebab(pluginName)}/schema.json`, import.meta.url);
16+
const schemaContent = readFileSync(schemaPath, 'utf8');
17+
return JSON.parse(schemaContent);
18+
} catch (error) {
19+
// Schema file not found or invalid - return undefined to fall back to pattern matching
20+
return undefined;
21+
}
22+
}
223

324
type BrowserforceApplyResponse = {
425
success: boolean;
@@ -35,10 +56,14 @@ export class BrowserforceApply extends BrowserforceCommand<BrowserforceApplyResp
3556
const diff = instance.diff(state, setting.value);
3657
const action = flags['dry-run'] ? 'would change' : 'changing';
3758
if (diff !== undefined) {
59+
// Load schema for this plugin to check for password fields
60+
const schema = loadPluginSchema(setting.key);
61+
// Mask sensitive values before logging (using schema if available)
62+
const maskedDiff = maskSensitiveValues(diff, '', schema) as typeof diff;
3863
this.spinner.start(
39-
`[${driver.name}] ${Object.keys(diff)
64+
`[${driver.name}] ${Object.keys(maskedDiff)
4065
.map((key) => {
41-
return `${action} '${key}' to '${JSON.stringify(diff[key])}'`;
66+
return `${action} '${key}' to '${JSON.stringify(maskedDiff[key])}'`;
4267
})
4368
.join('\n')}`,
4469
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "../schema.json",
3+
"settings": {
4+
"authProviders": {
5+
"TestAuthProvider": {
6+
"consumerSecret": "test-secret-12345",
7+
"consumerKey": "test-key-67890"
8+
}
9+
}
10+
}
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "../schema.json",
3+
"settings": {
4+
"authProviders": {
5+
"TestAuthProvider": {
6+
"consumerSecret": "test-secret-12345",
7+
"consumerKey": "test-key-67890"
8+
}
9+
}
10+
}
11+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import assert from 'assert';
2+
import * as child from 'child_process';
3+
import { fileURLToPath } from 'node:url';
4+
import * as path from 'path';
5+
import { type Config, AuthProviders } from './index.js';
6+
7+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
8+
9+
describe(AuthProviders.name, function () {
10+
this.timeout('10m');
11+
let plugin: AuthProviders;
12+
13+
before(() => {
14+
plugin = new AuthProviders(global.browserforce);
15+
});
16+
17+
const configWithSecretAndKey: Config = {
18+
TestAuthProvider: {
19+
consumerSecret: 'test-secret-12345',
20+
consumerKey: 'test-key-67890',
21+
},
22+
};
23+
24+
const configWithSecretOnly: Config = {
25+
TestAuthProvider: {
26+
consumerSecret: 'updated-secret-abcde',
27+
},
28+
};
29+
30+
const configWithKeyOnly: Config = {
31+
TestAuthProvider: {
32+
consumerKey: 'updated-key-fghij',
33+
},
34+
};
35+
36+
const configEmpty: Config = {
37+
TestAuthProvider: {},
38+
};
39+
40+
it('should deploy an AuthProvider for testing', () => {
41+
const sourceDeployCmd = child.spawnSync('sf', [
42+
'project',
43+
'deploy',
44+
'start',
45+
'-d',
46+
path.join(__dirname, 'sfdx-source'),
47+
'--json',
48+
]);
49+
assert.deepStrictEqual(sourceDeployCmd.status, 0, sourceDeployCmd.output.toString());
50+
});
51+
52+
it('should update consumerSecret and consumerKey', async () => {
53+
await plugin.apply(configWithSecretAndKey);
54+
// Note: retrieve() returns empty config, so we can only verify apply completes without errors
55+
});
56+
57+
it('should update consumerSecret only', async () => {
58+
await plugin.apply(configWithSecretOnly);
59+
});
60+
61+
it('should update consumerKey only', async () => {
62+
await plugin.apply(configWithKeyOnly);
63+
});
64+
65+
it('should handle empty config without errors', async () => {
66+
await plugin.apply(configEmpty);
67+
});
68+
69+
it('should throw an error when AuthProvider does not exist', async () => {
70+
const configInvalid: Config = {
71+
NonExistentAuthProvider: {
72+
consumerSecret: 'test-secret',
73+
consumerKey: 'test-key',
74+
},
75+
};
76+
let err;
77+
try {
78+
await plugin.apply(configInvalid);
79+
} catch (e) {
80+
err = e;
81+
}
82+
assert.throws(() => {
83+
throw err;
84+
}, /No AuthProviders found with DeveloperNames/);
85+
});
86+
87+
it('should remove the testing AuthProvider', async () => {
88+
await global.browserforce.connection.metadata.delete('AuthProvider', ['TestAuthProvider']);
89+
});
90+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { type SalesforceUrlPath, waitForPageErrors } from '../../browserforce.js';
2+
import { BrowserforcePlugin } from '../../plugin.js';
3+
4+
const CONSUMER_SECRET_SELECTOR = '#ConsumerSecret';
5+
const CONSUMER_KEY_SELECTOR = '#ConsumerKey';
6+
const SAVE_BUTTON_SELECTOR = 'input[id$=":saveBtn"], #topButtonRow > input[name="save"], button[title="Save"]';
7+
8+
const getUrl = (orgId: string): SalesforceUrlPath => `/${orgId}/e?retURL=/${orgId}` as SalesforceUrlPath;
9+
10+
type AuthProviderConfig = {
11+
consumerSecret?: string;
12+
consumerKey?: string;
13+
};
14+
15+
export type Config = {
16+
[developerName: string]: AuthProviderConfig;
17+
};
18+
19+
type AuthProviderRecord = {
20+
Id: string;
21+
DeveloperName: string;
22+
};
23+
24+
export class AuthProviders extends BrowserforcePlugin {
25+
public async retrieve(): Promise<Config> {
26+
// Skip retrieve as requested - return empty config
27+
return {};
28+
}
29+
30+
public async apply(config: Config): Promise<void> {
31+
if (!config || Object.keys(config).length === 0) {
32+
return;
33+
}
34+
35+
const developerNames = Object.keys(config);
36+
const developerNamesList = developerNames.map((name) => `'${name}'`).join(',');
37+
38+
// Query AuthProviders using standard REST API (not Tooling API)
39+
const authProviders = await this.browserforce.connection.query<AuthProviderRecord>(
40+
`SELECT Id, DeveloperName FROM AuthProvider WHERE DeveloperName IN (${developerNamesList})`,
41+
);
42+
43+
if (authProviders.records.length === 0) {
44+
throw new Error(`No AuthProviders found with DeveloperNames: ${developerNames.join(', ')}`);
45+
}
46+
47+
// Create a map for quick lookup
48+
const authProviderMap = new Map<string, string>();
49+
for (const authProvider of authProviders.records) {
50+
authProviderMap.set(authProvider.DeveloperName, authProvider.Id);
51+
}
52+
53+
// Verify we found all required AuthProviders
54+
const missingProviders = developerNames.filter((name) => !authProviderMap.has(name));
55+
if (missingProviders.length > 0) {
56+
throw new Error(
57+
`AuthProvider with DeveloperName(s) not found: ${missingProviders.join(', ')}. ` +
58+
`Please verify the DeveloperNames are correct and the AuthProviders exist in your org.`,
59+
);
60+
}
61+
62+
// Process each auth provider configuration
63+
for (const [developerName, authProviderConfig] of Object.entries(config)) {
64+
const authProviderId = authProviderMap.get(developerName);
65+
if (!authProviderId) {
66+
throw new Error(`AuthProvider with DeveloperName '${developerName}' not found`);
67+
}
68+
69+
try {
70+
// Check if there are updates to consumerSecret or consumerKey
71+
const hasConsumerUpdates =
72+
authProviderConfig.consumerSecret !== undefined || authProviderConfig.consumerKey !== undefined;
73+
74+
if (hasConsumerUpdates) {
75+
// Navigate to the edit page
76+
const editPageUrl = getUrl(authProviderId);
77+
78+
this.browserforce.logger?.log(`Navigating to edit page for ${developerName}: ${editPageUrl}`);
79+
await using page = await this.browserforce.openPage(editPageUrl);
80+
81+
// Update ConsumerSecret if provided
82+
if (authProviderConfig.consumerSecret !== undefined) {
83+
await page.locator(CONSUMER_SECRET_SELECTOR).fill(authProviderConfig.consumerSecret);
84+
}
85+
86+
// Update ConsumerKey if provided
87+
if (authProviderConfig.consumerKey !== undefined) {
88+
await page.locator(CONSUMER_KEY_SELECTOR).fill(authProviderConfig.consumerKey);
89+
}
90+
91+
// Save the changes
92+
const saveButtonLocator = page.locator(SAVE_BUTTON_SELECTOR);
93+
await saveButtonLocator.first().click();
94+
await Promise.race([
95+
page.waitForURL((url) => url.pathname === `/${authProviderId}`),
96+
waitForPageErrors(page),
97+
]);
98+
}
99+
} catch (error) {
100+
throw new Error(`Failed to update AuthProvider '${developerName}': ${error.message}`);
101+
}
102+
}
103+
}
104+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "./auth-providers/schema.json",
4+
"title": "Auth Providers",
5+
"description": "Configuration for updating Auth Provider Consumer Key and Consumer Secret",
6+
"type": "object",
7+
"patternProperties": {
8+
"^[a-zA-Z0-9_]+$": {
9+
"type": "object",
10+
"properties": {
11+
"consumerSecret": {
12+
"title": "Consumer Secret",
13+
"description": "The Consumer Secret value for the Auth Provider",
14+
"type": "string",
15+
"x-password": true
16+
},
17+
"consumerKey": {
18+
"title": "Consumer Key",
19+
"description": "The Consumer Key value for the Auth Provider",
20+
"type": "string"
21+
}
22+
}
23+
}
24+
}
25+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<AuthProvider xmlns="http://soap.sforce.com/2006/04/metadata">
3+
<friendlyName>TestAuthProvider</friendlyName>
4+
<includeOrgIdInIdentifier>true</includeOrgIdInIdentifier>
5+
<isPkceEnabled>true</isPkceEnabled>
6+
<providerType>OpenIdConnect</providerType>
7+
<requireMfa>false</requireMfa>
8+
<sendClientCredentialsInHeader>false</sendClientCredentialsInHeader>
9+
<sendSecretInApis>true</sendSecretInApis>
10+
<authorizeUrl>https://login.openid.com/services/oauth2/authorize</authorizeUrl>
11+
<consumerKey>3MVG9k9cKU2O1H29X8gT08Ks2jVjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92WjH6Kd92W</consumerKey>
12+
<consumerSecret>Placeholder_Value</consumerSecret>
13+
<defaultScopes>openid profile</defaultScopes>
14+
<idTokenIssuer>https://login.openid.com</idTokenIssuer>
15+
<sendAccessTokenInHeader>true</sendAccessTokenInHeader>
16+
<tokenUrl>https://login.openid.com/services/oauth2/token</tokenUrl>
17+
<userInfoUrl>https://login.openid.com/services/oauth2/userinfo</userInfoUrl>
18+
</AuthProvider>

src/plugins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ActivitySettings as activitySettings } from './activity-settings/index.js';
2+
import { AuthProviders as authProviders } from './auth-providers/index.js';
23
import { CompanyInformation as companyInformation } from './company-information/index.js';
34
import { CustomerPortal as customerPortal } from './customer-portal/index.js';
45
import { DensitySettings as densitySettings } from './density-settings/index.js';
@@ -24,6 +25,7 @@ import { UserAccessPolicies as userAccessPolicies } from './user-access-policies
2425

2526
export {
2627
activitySettings,
28+
authProviders,
2729
companyInformation,
2830
customerPortal,
2931
densitySettings,

src/plugins/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"properties": {
88
"settings": {
99
"properties": {
10+
"authProviders": {
11+
"$ref": "./auth-providers/schema.json"
12+
},
1013
"userAccessPolicies": {
1114
"$ref": "./user-access-policies/schema.json"
1215
},

0 commit comments

Comments
 (0)