Skip to content

Commit

Permalink
Supporting SCIM feature | Workaround on node-auth0@v3 (#921)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
nandan-bhat and developerkunal authored Jul 16, 2024
1 parent 846c02c commit 0a0567e
Show file tree
Hide file tree
Showing 5 changed files with 711 additions and 2 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
233 changes: 233 additions & 0 deletions src/tools/auth0/handlers/scimHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { Asset } from '../../../types';
import axios, { AxiosResponse } from 'axios';
import log from '../../../logger';
import { sleep } from '../../utils';

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;
private 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();

for (let connection of connections) {
if (!this.isScimStrategy(connection.strategy)) continue;

try {
this.idMap.set(connection.id, { strategy: connection.strategy, hasConfig: false });
await this.getScimConfiguration({ id: connection.id });
this.idMap.set(connection.id, { ...this.idMap.get(connection.id)!, hasConfig: true });

// To avoid rate limiter error, we making API requests with a small delay.
// TODO: However, this logic needs to be re-worked.
await sleep(500);
} catch (err) {
// Skip the connection if it returns 404. This can happen if `SCIM` is not enabled on a `SCIM` connection.
if (err !== 'SCIM_NOT_FOUND') throw err;
}
}
}

/**
* 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[]) {
for (let connection of connections) {
if (!this.isScimStrategy(connection.strategy)) continue;

try {
const { user_id_attribute, mapping } = await this.getScimConfiguration({ id: connection.id });
connection.scim_configuration = { user_id_attribute, mapping }

// To avoid rate limiter error, we making API requests with a small delay.
// TODO: However, this logic needs to be re-worked.
await sleep(500);
} catch (err) {
// Skip the connection if it returns 404. This can happen if `SCIM` is not enabled on a `SCIM` connection.
if (err !== 'SCIM_NOT_FOUND') throw err;

const warningMessage = `SCIM configuration not found on connection \"${connection.id}\".`;
log.warn(warningMessage);
}
}
}

/**
* HTTP request wrapper on axios.
*/
private async scimHttpRequest(method: string, options: [string, ...Record<string, any>[]]): Promise<AxiosResponse> {
return await this.withErrorHandling(async () => {
// @ts-ignore
const accessToken = await this.tokenProvider?.getAccessToken();
const headers = {
'Accept': 'application/json',
'Authorization': `Bearer ${ accessToken }`
}
options = [...options, { headers }];

return await axios[method](...options);
});
}

/**
* Error handler wrapper.
*/
async withErrorHandling(callback) {
try {
return await callback();
} catch (error) {
const errorData = error?.response?.data;
if (errorData?.statusCode === 404) throw "SCIM_NOT_FOUND";

const statusCode = errorData?.statusCode || error?.response?.status;
const errorCode = errorData?.errorCode || errorData?.error || error?.response?.statusText;
const errorMessage = errorData?.message || error?.response?.statusText;
const message = `SCIM request failed with statusCode ${ statusCode } (${ errorCode }). ${ errorMessage }.`;

log.error(message);
throw error;
}
}

/**
* 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> {
log.debug(`Creating SCIM configuration for connection ${ connection_id }`);
const url = this.getScimEndpoint(connection_id);
return (await this.scimHttpRequest('post', [ url, { user_id_attribute, mapping } ])).data;
}

/**
* Retrieves `SCIM` configuration of an enterprise connection.
*/
async getScimConfiguration({ id: connection_id }: scimRequestParams): Promise<scimBodyParams> {
log.debug(`Getting SCIM configuration from connection ${ connection_id }`);
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> {
log.debug(`Updating SCIM configuration on connection ${ connection_id }`);
const url = this.getScimEndpoint(connection_id);
return (await this.scimHttpRequest('patch', [ url, { user_id_attribute, mapping } ])).data;
}

/**
* Deletes an existing `SCIM` configuration.
*/
async deleteScimConfiguration({ id: connection_id }: scimRequestParams): Promise<AxiosResponse> {
log.debug(`Deleting SCIM configuration of connection ${ connection_id }`);
const url = this.getScimEndpoint(connection_id);
return (await this.scimHttpRequest('delete', [ url ])).data;
}

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 {
if (this.config('AUTH0_ALLOW_DELETE')) {
log.warn(`Deleting scim_configuration on connection ${ requestParams.id }.`);
await this.deleteScimConfiguration(requestParams);
} else {
log.warn('Skipping DELETE scim_configuration. Enable deletes by setting AUTH0_ALLOW_DELETE to true in your config.');
}
}
} 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;
}
}
11 changes: 9 additions & 2 deletions test/context/yaml/context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import path from 'path';
import fs from 'fs-extra';
import jsYaml from 'js-yaml';
import { expect } from 'chai';
import sinon from 'sinon';

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

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

it('should preserve keywords when dumping', async () => {
const applyScimConfiguration = (connections) => connections;
sinon.stub(ScimHandler.prototype, 'applyScimConfiguration').returns(applyScimConfiguration);

const dir = path.resolve(testDataDir, 'yaml', 'dump');
cleanThenMkdir(dir);
const tenantFile = path.join(dir, 'tenant.yml');
Expand Down Expand Up @@ -585,10 +590,11 @@ describe('#YAML context validation', () => {
},
},
],
}),
},
})
}
}
);

await context.dump();
const yaml = jsYaml.load(fs.readFileSync(tenantFile));

Expand All @@ -607,5 +613,6 @@ describe('#YAML context validation', () => {
},
],
});
sinon.restore();
});
});
Loading

0 comments on commit 0a0567e

Please sign in to comment.