Skip to content

Commit

Permalink
Supporting SCIM feature
Browse files Browse the repository at this point in the history
  • Loading branch information
nandan-bhat committed Jul 9, 2024
1 parent 8c8e9ea commit 4f2daef
Show file tree
Hide file tree
Showing 2 changed files with 225 additions and 0 deletions.
32 changes: 32 additions & 0 deletions src/tools/auth0/handlers/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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'],
},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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),
Expand Down
193 changes: 193 additions & 0 deletions src/tools/auth0/handlers/scimHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string, IdMapValue>;
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<string, IdMapValue>();
}

/**
* 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<string, any>[]]): Promise<AxiosResponse> {
// @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<AxiosResponse> {
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<scimBodyParams> {
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<AxiosResponse> {
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<AxiosResponse> {
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;
}
}

0 comments on commit 4f2daef

Please sign in to comment.