Skip to content

Commit 0a0567e

Browse files
Supporting SCIM feature | Workaround on node-auth0@v3 (#921)
* Supporting SCIM feature * Handling rate limit on getScimConfiguration | Added unit test coverage * Removing un-used variables * Fixing lint issues | Adding dependency axios * Using built-in "sleep" method. | Using 2 space indentation * Removing axios from the dependency list * Adding AUTH0_ALLOW_DELETE condition before deleting the scim_configuration * Updating the debug logs * Updating the debug logs --------- Co-authored-by: KunalOfficial <35455566+developerkunal@users.noreply.github.com>
1 parent 846c02c commit 0a0567e

File tree

5 files changed

+711
-2
lines changed

5 files changed

+711
-2
lines changed

src/tools/auth0/handlers/connections.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import DefaultAPIHandler, { order } from './default';
44
import { filterExcluded, convertClientNameToId, getEnabledClients } from '../../utils';
55
import { CalculatedChanges, Asset, Assets } from '../../../types';
66
import { ConfigFunction } from '../../../configFactory';
7+
import ScimHandler from './scimHandler';
78

89
export const schema = {
910
type: 'array',
@@ -16,6 +17,15 @@ export const schema = {
1617
enabled_clients: { type: 'array', items: { type: 'string' } },
1718
realms: { type: 'array', items: { type: 'string' } },
1819
metadata: { type: 'object' },
20+
scim_configuration: {
21+
type: 'object',
22+
properties: {
23+
connection_name: { type: 'string' },
24+
mapping: { type: 'array', items: { type: 'object', properties: { scim: { type: 'string' }, auth0: { type: 'string' } } } },
25+
user_id_attribute: { type: 'string' }
26+
},
27+
required: ['mapping', 'user_id_attribute'],
28+
}
1929
},
2030
required: ['name', 'strategy'],
2131
},
@@ -79,13 +89,26 @@ export const addExcludedConnectionPropertiesToChanges = ({
7989

8090
export default class ConnectionsHandler extends DefaultAPIHandler {
8191
existing: Asset[] | null;
92+
scimHandler: ScimHandler;
8293

8394
constructor(config: DefaultAPIHandler) {
8495
super({
8596
...config,
8697
type: 'connections',
8798
stripUpdateFields: ['strategy', 'name'],
99+
functions: {
100+
// When `connections` is updated, it can result in `update`,`create` or `delete` action on SCIM.
101+
// Because, `scim_configuration` is inside `connections`.
102+
update: async (requestParams, bodyParams) => await this.scimHandler.updateOverride(requestParams, bodyParams),
103+
104+
// When a new `connection` is created. We can perform only `create` option on SCIM.
105+
// When a connection is `deleted`. `scim_configuration` is also deleted along with it; no action on SCIM is required.
106+
create: async (bodyParams) => await this.scimHandler.createOverride(bodyParams)
107+
},
88108
});
109+
110+
// @ts-ignore
111+
this.scimHandler = new ScimHandler(this.config, this.client.tokenProvider, this.client.connections);
89112
}
90113

91114
objString(connection): string {
@@ -114,9 +137,14 @@ export default class ConnectionsHandler extends DefaultAPIHandler {
114137
paginate: true,
115138
include_totals: true,
116139
});
140+
117141
// Filter out database connections
118142
this.existing = connections.filter((c) => c.strategy !== 'auth0');
119143
if (this.existing === null) return [];
144+
145+
// Apply `scim_configuration` to all the relevant `SCIM` connections. This method mutates `this.existing`.
146+
await this.scimHandler.applyScimConfiguration(this.existing);
147+
120148
return this.existing;
121149
}
122150

@@ -138,6 +166,10 @@ export default class ConnectionsHandler extends DefaultAPIHandler {
138166
paginate: true,
139167
include_totals: true,
140168
});
169+
170+
// Prepare an id map. We'll use this map later to get the `strategy` and SCIM enable status of the connections.
171+
await this.scimHandler.createIdMap(existingConnections);
172+
141173
const formatted = connections.map((connection) => ({
142174
...connection,
143175
...this.getFormattedOptions(connection, clients),
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { Asset } from '../../../types';
2+
import axios, { AxiosResponse } from 'axios';
3+
import log from '../../../logger';
4+
import { sleep } from '../../utils';
5+
6+
interface IdMapValue {
7+
strategy: string;
8+
hasConfig: boolean;
9+
}
10+
11+
interface scimRequestParams {
12+
id: string;
13+
}
14+
15+
interface scimBodyParams {
16+
user_id_attribute: string;
17+
mapping: { scim: string; auth0: string; }[];
18+
}
19+
20+
/**
21+
* The current version of this sdk use `node-auth0` v3. But `SCIM` features are not natively supported by v3.
22+
* This is a workaround to make this SDK support SCIM without `node-auth0` upgrade.
23+
*/
24+
export default class ScimHandler {
25+
private idMap: Map<string, IdMapValue>;
26+
private readonly scimStrategies = ['samlp', 'oidc', 'okta', 'waad'];
27+
private tokenProvider: any;
28+
private config: any;
29+
private connectionsManager: any;
30+
31+
constructor(config, tokenProvider, connectionsManager) {
32+
this.config = config;
33+
this.tokenProvider = tokenProvider;
34+
this.connectionsManager = connectionsManager;
35+
this.idMap = new Map<string, IdMapValue>();
36+
}
37+
38+
/**
39+
* Check if the connection strategy is SCIM supported.
40+
* Only few of the enterprise connections are SCIM supported.
41+
*/
42+
isScimStrategy(strategy: string) {
43+
return this.scimStrategies.includes(strategy.toLowerCase());
44+
}
45+
46+
/**
47+
* Creates connection_id -> { strategy, hasConfig } map.
48+
* Store only the SCIM ids available on the existing / remote config.
49+
* Payload received on `create` and `update` methods has the property `strategy` stripped.
50+
* So, we need this map to perform `create`, `update` or `delete` actions on SCIM.
51+
* @param connections
52+
*/
53+
async createIdMap(connections: Asset[]) {
54+
this.idMap.clear();
55+
56+
for (let connection of connections) {
57+
if (!this.isScimStrategy(connection.strategy)) continue;
58+
59+
try {
60+
this.idMap.set(connection.id, { strategy: connection.strategy, hasConfig: false });
61+
await this.getScimConfiguration({ id: connection.id });
62+
this.idMap.set(connection.id, { ...this.idMap.get(connection.id)!, hasConfig: true });
63+
64+
// To avoid rate limiter error, we making API requests with a small delay.
65+
// TODO: However, this logic needs to be re-worked.
66+
await sleep(500);
67+
} catch (err) {
68+
// Skip the connection if it returns 404. This can happen if `SCIM` is not enabled on a `SCIM` connection.
69+
if (err !== 'SCIM_NOT_FOUND') throw err;
70+
}
71+
}
72+
}
73+
74+
/**
75+
* Iterate through all the connections and add property `scim_configuration` to only `SCIM` connections.
76+
* The following conditions should be met to have `scim_configuration` set to a `connection`.
77+
* 1. Connection `strategy` should be one of `scimStrategies`
78+
* 2. Connection should have `SCIM` enabled.
79+
*
80+
* This method mutates the incoming `connections`.
81+
*/
82+
async applyScimConfiguration(connections: Asset[]) {
83+
for (let connection of connections) {
84+
if (!this.isScimStrategy(connection.strategy)) continue;
85+
86+
try {
87+
const { user_id_attribute, mapping } = await this.getScimConfiguration({ id: connection.id });
88+
connection.scim_configuration = { user_id_attribute, mapping }
89+
90+
// To avoid rate limiter error, we making API requests with a small delay.
91+
// TODO: However, this logic needs to be re-worked.
92+
await sleep(500);
93+
} catch (err) {
94+
// Skip the connection if it returns 404. This can happen if `SCIM` is not enabled on a `SCIM` connection.
95+
if (err !== 'SCIM_NOT_FOUND') throw err;
96+
97+
const warningMessage = `SCIM configuration not found on connection \"${connection.id}\".`;
98+
log.warn(warningMessage);
99+
}
100+
}
101+
}
102+
103+
/**
104+
* HTTP request wrapper on axios.
105+
*/
106+
private async scimHttpRequest(method: string, options: [string, ...Record<string, any>[]]): Promise<AxiosResponse> {
107+
return await this.withErrorHandling(async () => {
108+
// @ts-ignore
109+
const accessToken = await this.tokenProvider?.getAccessToken();
110+
const headers = {
111+
'Accept': 'application/json',
112+
'Authorization': `Bearer ${ accessToken }`
113+
}
114+
options = [...options, { headers }];
115+
116+
return await axios[method](...options);
117+
});
118+
}
119+
120+
/**
121+
* Error handler wrapper.
122+
*/
123+
async withErrorHandling(callback) {
124+
try {
125+
return await callback();
126+
} catch (error) {
127+
const errorData = error?.response?.data;
128+
if (errorData?.statusCode === 404) throw "SCIM_NOT_FOUND";
129+
130+
const statusCode = errorData?.statusCode || error?.response?.status;
131+
const errorCode = errorData?.errorCode || errorData?.error || error?.response?.statusText;
132+
const errorMessage = errorData?.message || error?.response?.statusText;
133+
const message = `SCIM request failed with statusCode ${ statusCode } (${ errorCode }). ${ errorMessage }.`;
134+
135+
log.error(message);
136+
throw error;
137+
}
138+
}
139+
140+
/**
141+
* Returns formatted endpoint url.
142+
*/
143+
private getScimEndpoint(connection_id: string) {
144+
// Call `scim-configuration` endpoint directly to support `SCIM` features.
145+
return `https://${ this.config('AUTH0_DOMAIN') }/api/v2/connections/${ connection_id }/scim-configuration`;
146+
}
147+
148+
/**
149+
* Creates a new `SCIM` configuration.
150+
*/
151+
async createScimConfiguration({ id: connection_id }: scimRequestParams, { user_id_attribute, mapping }: scimBodyParams): Promise<AxiosResponse> {
152+
log.debug(`Creating SCIM configuration for connection ${ connection_id }`);
153+
const url = this.getScimEndpoint(connection_id);
154+
return (await this.scimHttpRequest('post', [ url, { user_id_attribute, mapping } ])).data;
155+
}
156+
157+
/**
158+
* Retrieves `SCIM` configuration of an enterprise connection.
159+
*/
160+
async getScimConfiguration({ id: connection_id }: scimRequestParams): Promise<scimBodyParams> {
161+
log.debug(`Getting SCIM configuration from connection ${ connection_id }`);
162+
const url = this.getScimEndpoint(connection_id);
163+
return (await this.scimHttpRequest('get', [ url ])).data;
164+
}
165+
166+
/**
167+
* Updates an existing `SCIM` configuration.
168+
*/
169+
async updateScimConfiguration({ id: connection_id }: scimRequestParams, { user_id_attribute, mapping }: scimBodyParams): Promise<AxiosResponse> {
170+
log.debug(`Updating SCIM configuration on connection ${ connection_id }`);
171+
const url = this.getScimEndpoint(connection_id);
172+
return (await this.scimHttpRequest('patch', [ url, { user_id_attribute, mapping } ])).data;
173+
}
174+
175+
/**
176+
* Deletes an existing `SCIM` configuration.
177+
*/
178+
async deleteScimConfiguration({ id: connection_id }: scimRequestParams): Promise<AxiosResponse> {
179+
log.debug(`Deleting SCIM configuration of connection ${ connection_id }`);
180+
const url = this.getScimEndpoint(connection_id);
181+
return (await this.scimHttpRequest('delete', [ url ])).data;
182+
}
183+
184+
async updateOverride(requestParams: scimRequestParams, bodyParams: Asset) {
185+
// Extract `scim_configuration` from `bodyParams`.
186+
// Remove `scim_configuration` from `bodyParams`, because `connections.update` doesn't accept it.
187+
const { scim_configuration: scimBodyParams } = bodyParams;
188+
delete bodyParams.scim_configuration;
189+
190+
// First, update `connections`.
191+
const updated = await this.connectionsManager.update(requestParams, bodyParams);
192+
const idMapEntry = this.idMap.get(requestParams.id);
193+
194+
// Now, update `scim_configuration` inside the updated connection.
195+
// If `scim_configuration` exists in both local and remote -> updateScimConfiguration(...)
196+
// If `scim_configuration` exists in remote but local -> deleteScimConfiguration(...)
197+
// If `scim_configuration` exists in local but remote -> createScimConfiguration(...)
198+
if (idMapEntry?.hasConfig) {
199+
if (scimBodyParams) {
200+
await this.updateScimConfiguration(requestParams, scimBodyParams);
201+
} else {
202+
if (this.config('AUTH0_ALLOW_DELETE')) {
203+
log.warn(`Deleting scim_configuration on connection ${ requestParams.id }.`);
204+
await this.deleteScimConfiguration(requestParams);
205+
} else {
206+
log.warn('Skipping DELETE scim_configuration. Enable deletes by setting AUTH0_ALLOW_DELETE to true in your config.');
207+
}
208+
}
209+
} else if (scimBodyParams) {
210+
await this.createScimConfiguration(requestParams, scimBodyParams);
211+
}
212+
213+
// Return response from connections.update(...).
214+
return updated;
215+
}
216+
217+
async createOverride(bodyParams: Asset) {
218+
// Extract `scim_configuration` from `bodyParams`.
219+
// Remove `scim_configuration` from `bodyParams`, because `connections.create` doesn't accept it.
220+
const { scim_configuration: scimBodyParams } = bodyParams;
221+
delete bodyParams.scim_configuration;
222+
223+
// First, create the new `connection`.
224+
const created = await this.connectionsManager.create(bodyParams);
225+
if (scimBodyParams) {
226+
// Now, create the `scim_configuration` for newly created `connection`.
227+
await this.createScimConfiguration({ id: created.id }, scimBodyParams);
228+
}
229+
230+
// Return response from connections.create(...).
231+
return created;
232+
}
233+
}

test/context/yaml/context.test.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import path from 'path';
22
import fs from 'fs-extra';
33
import jsYaml from 'js-yaml';
44
import { expect } from 'chai';
5+
import sinon from 'sinon';
56

67
import Context from '../../../src/context/yaml';
78
import { cleanThenMkdir, testDataDir, mockMgmtClient } from '../../utils';
9+
import ScimHandler from '../../../src/tools/auth0/handlers/scimHandler';
810

911
describe('#YAML context validation', () => {
1012
it('should do nothing on empty yaml', async () => {
@@ -537,6 +539,9 @@ describe('#YAML context validation', () => {
537539
});
538540

539541
it('should preserve keywords when dumping', async () => {
542+
const applyScimConfiguration = (connections) => connections;
543+
sinon.stub(ScimHandler.prototype, 'applyScimConfiguration').returns(applyScimConfiguration);
544+
540545
const dir = path.resolve(testDataDir, 'yaml', 'dump');
541546
cleanThenMkdir(dir);
542547
const tenantFile = path.join(dir, 'tenant.yml');
@@ -585,10 +590,11 @@ describe('#YAML context validation', () => {
585590
},
586591
},
587592
],
588-
}),
589-
},
593+
})
594+
}
590595
}
591596
);
597+
592598
await context.dump();
593599
const yaml = jsYaml.load(fs.readFileSync(tenantFile));
594600

@@ -607,5 +613,6 @@ describe('#YAML context validation', () => {
607613
},
608614
],
609615
});
616+
sinon.restore();
610617
});
611618
});

0 commit comments

Comments
 (0)