Skip to content

Supporting SCIM feature | Workaround on node-auth0@v3 #921

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 16, 2024
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
228 changes: 228 additions & 0 deletions src/tools/auth0/handlers/scimHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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(`Getting 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(`Getting 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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add debug logs to this and few other newly added methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateScimConfiguration, createScimConfiguration, and deleteScimConfiguration are called within updateOverride, which already has a debug logger in place.

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;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a comment, explaining the general intention of this override function

Copy link
Contributor Author

@nandan-bhat nandan-bhat Jul 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code block contains more details about updateOverride

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
Loading