diff --git a/src/tools/auth0/handlers/connections.ts b/src/tools/auth0/handlers/connections.ts index bef7832e1..10b2587f7 100644 --- a/src/tools/auth0/handlers/connections.ts +++ b/src/tools/auth0/handlers/connections.ts @@ -4,6 +4,7 @@ import DefaultAPIHandler, { order } from './default'; import { filterExcluded, convertClientNameToId, getEnabledClients } from '../../utils'; import { CalculatedChanges, Asset, Assets } from '../../../types'; import { ConfigFunction } from '../../../configFactory'; +import ScimHandler from './scimHandler'; export const schema = { type: 'array', @@ -16,6 +17,15 @@ export const schema = { enabled_clients: { type: 'array', items: { type: 'string' } }, realms: { type: 'array', items: { type: 'string' } }, metadata: { type: 'object' }, + scim_configuration: { + type: 'object', + properties: { + connection_name: { type: 'string' }, + mapping: { type: 'array', items: { type: 'object', properties: { scim: { type: 'string' }, auth0: { type: 'string' } } } }, + user_id_attribute: { type: 'string' } + }, + required: ['mapping', 'user_id_attribute'], + } }, required: ['name', 'strategy'], }, @@ -79,13 +89,26 @@ export const addExcludedConnectionPropertiesToChanges = ({ export default class ConnectionsHandler extends DefaultAPIHandler { existing: Asset[] | null; + scimHandler: ScimHandler; constructor(config: DefaultAPIHandler) { super({ ...config, type: 'connections', stripUpdateFields: ['strategy', 'name'], + functions: { + // When `connections` is updated, it can result in `update`,`create` or `delete` action on SCIM. + // Because, `scim_configuration` is inside `connections`. + update: async (requestParams, bodyParams) => await this.scimHandler.updateOverride(requestParams, bodyParams), + + // When a new `connection` is created. We can perform only `create` option on SCIM. + // When a connection is `deleted`. `scim_configuration` is also deleted along with it; no action on SCIM is required. + create: async (bodyParams) => await this.scimHandler.createOverride(bodyParams) + }, }); + + // @ts-ignore + this.scimHandler = new ScimHandler(this.config, this.client.tokenProvider, this.client.connections); } objString(connection): string { @@ -114,9 +137,14 @@ export default class ConnectionsHandler extends DefaultAPIHandler { paginate: true, include_totals: true, }); + // Filter out database connections this.existing = connections.filter((c) => c.strategy !== 'auth0'); if (this.existing === null) return []; + + // Apply `scim_configuration` to all the relevant `SCIM` connections. This method mutates `this.existing`. + await this.scimHandler.applyScimConfiguration(this.existing); + return this.existing; } @@ -138,6 +166,10 @@ export default class ConnectionsHandler extends DefaultAPIHandler { paginate: true, include_totals: true, }); + + // Prepare an id map. We'll use this map later to get the `strategy` and SCIM enable status of the connections. + await this.scimHandler.createIdMap(existingConnections); + const formatted = connections.map((connection) => ({ ...connection, ...this.getFormattedOptions(connection, clients), diff --git a/src/tools/auth0/handlers/scimHandler.ts b/src/tools/auth0/handlers/scimHandler.ts new file mode 100644 index 000000000..ef8e36385 --- /dev/null +++ b/src/tools/auth0/handlers/scimHandler.ts @@ -0,0 +1,193 @@ +import { Asset } from '../../../types'; +import axios, { AxiosResponse } from 'axios'; + +interface IdMapValue { + strategy: string; + hasConfig: boolean; +} + +interface scimRequestParams { + id: string; +} + +interface scimBodyParams { + user_id_attribute: string; + mapping: { scim: string; auth0: string; }[]; +} + +/** + * The current version of this sdk use `node-auth0` v3. But `SCIM` features are not natively supported by v3. + * This is a workaround to make this SDK support SCIM without `node-auth0` upgrade. + */ +export default class ScimHandler { + private idMap: Map; + private readonly scimStrategies = ['samlp', 'oidc', 'okta', 'waad']; + private tokenProvider: any; + private config: any; + connectionsManager: any; + + constructor(config, tokenProvider, connectionsManager) { + this.config = config; + this.tokenProvider = tokenProvider; + this.connectionsManager = connectionsManager; + this.idMap = new Map(); + } + + /** + * Check if the connection strategy is SCIM supported. + * Only few of the enterprise connections are SCIM supported. + */ + isScimStrategy(strategy: string) { + return this.scimStrategies.includes(strategy.toLowerCase()); + } + + /** + * Creates connection_id -> { strategy, hasConfig } map. + * Store only the SCIM ids available on the existing / remote config. + * Payload received on `create` and `update` methods has the property `strategy` stripped. + * So, we need this map to perform `create`, `update` or `delete` actions on SCIM. + * @param connections + */ + async createIdMap(connections: Asset[]) { + this.idMap.clear(); + + await Promise.all( + connections.map(async (connection) => { + if (this.isScimStrategy(connection.strategy)) { + this.idMap.set(connection.id, { strategy: connection.strategy, hasConfig: false }); + try { + await this.getScimConfiguration({ id: connection.id }); + this.idMap.set(connection.id, { ...this.idMap.get(connection.id)!, hasConfig: true }); + } catch (err) { + if (!err.response || +err.response.status !== 404) throw err; + } + } + + return connection; + }) + ) + } + + /** + * Iterate through all the connections and add property `scim_configuration` to only `SCIM` connections. + * The following conditions should be met to have `scim_configuration` set to a `connection`. + * 1. Connection `strategy` should be one of `scimStrategies` + * 2. Connection should have `SCIM` enabled. + * + * This method mutates the incoming `connections`. + */ + async applyScimConfiguration(connections: Asset[]) { + await Promise.all(connections.map(async (connection) => { + if (this.isScimStrategy(connection.strategy)) { + try { + const { user_id_attribute, mapping } = await this.getScimConfiguration({ id: connection.id }); + connection.scim_configuration = { user_id_attribute, mapping } + } catch (err) { + // Skip the connection if it returns 404. This can happen if `SCIM` is not enabled on a `SCIM` connection. + if (!err.response || +err.response.status !== 404) throw err; + } + } + + return connection; + })); + } + + /** + * HTTP request wrapper on axios. + */ + private async scimHttpRequest(method: string, options: [string, ...Record[]]): Promise { + // @ts-ignore + const accessToken = await this.tokenProvider.getAccessToken(); + const headers = { + 'Accept': 'application/json', + 'Authorization': `Bearer ${ accessToken }` + } + options = [...options, { headers }]; + return await axios[method](...options); + } + + /** + * Returns formatted endpoint url. + */ + private getScimEndpoint(connection_id: string) { + // Call `scim-configuration` endpoint directly to support `SCIM` features. + return `https://${ this.config('AUTH0_DOMAIN') }/api/v2/connections/${ connection_id }/scim-configuration`; + } + + /** + * Creates a new `SCIM` configuration. + */ + async createScimConfiguration({ id: connection_id }: scimRequestParams, { user_id_attribute, mapping }: scimBodyParams): Promise { + const url = this.getScimEndpoint(connection_id); + return await this.scimHttpRequest('post', [ url, { user_id_attribute, mapping } ]); + } + + /** + * Retrieves `SCIM` configuration of an enterprise connection. + */ + async getScimConfiguration({ id: connection_id }: scimRequestParams): Promise { + const url = this.getScimEndpoint(connection_id); + return (await this.scimHttpRequest('get', [ url ])).data; + } + + /** + * Updates an existing `SCIM` configuration. + */ + async updateScimConfiguration({ id: connection_id }: scimRequestParams, { user_id_attribute, mapping }: scimBodyParams): Promise { + const url = this.getScimEndpoint(connection_id); + return await this.scimHttpRequest('patch', [ url, { user_id_attribute, mapping } ]); + } + + /** + * Deletes an existing `SCIM` configuration. + */ + async deleteScimConfiguration({ id: connection_id }: scimRequestParams): Promise { + const url = this.getScimEndpoint(connection_id); + return await this.scimHttpRequest('delete', [ url ]); + } + + async updateOverride(requestParams: scimRequestParams, bodyParams: Asset) { + // Extract `scim_configuration` from `bodyParams`. + // Remove `scim_configuration` from `bodyParams`, because `connections.update` doesn't accept it. + const { scim_configuration: scimBodyParams } = bodyParams; + delete bodyParams.scim_configuration; + + // First, update `connections`. + const updated = await this.connectionsManager.update(requestParams, bodyParams); + const idMapEntry = this.idMap.get(requestParams.id); + + // Now, update `scim_configuration` inside the updated connection. + // If `scim_configuration` exists in both local and remote -> updateScimConfiguration(...) + // If `scim_configuration` exists in remote but local -> deleteScimConfiguration(...) + // If `scim_configuration` exists in local but remote -> createScimConfiguration(...) + if (idMapEntry?.hasConfig) { + if (scimBodyParams) { + await this.updateScimConfiguration(requestParams, scimBodyParams); + } else { + await this.deleteScimConfiguration(requestParams); + } + } else if (scimBodyParams) { + await this.createScimConfiguration(requestParams, scimBodyParams); + } + + // Return response from connections.update(...). + return updated; + } + + async createOverride(bodyParams: Asset) { + // Extract `scim_configuration` from `bodyParams`. + // Remove `scim_configuration` from `bodyParams`, because `connections.create` doesn't accept it. + const { scim_configuration: scimBodyParams } = bodyParams; + delete bodyParams.scim_configuration; + + // First, create the new `connection`. + const created = await this.connectionsManager.create(bodyParams); + if (scimBodyParams) { + // Now, create the `scim_configuration` for newly created `connection`. + await this.createScimConfiguration({ id: created.id }, scimBodyParams); + } + + // Return response from connections.create(...). + return created; + } +} \ No newline at end of file