Merge branch 'main' into add-audit-logs

# Conflicts:
#	package-lock.json
#	pages/api/v1/connections/index.ts
#	pages/api/v1/saml/config.ts
This commit is contained in:
Deepak Prabhakara 2023-06-28 21:46:15 +01:00
commit ecca8f7df4
69 changed files with 10254 additions and 19204 deletions

View File

@ -1,4 +1,4 @@
ARG NODEJS_IMAGE=node:18.15.0-alpine3.17
ARG NODEJS_IMAGE=node:18.16.1-alpine3.18
FROM --platform=$BUILDPLATFORM $NODEJS_IMAGE AS base
# Install dependencies only when needed

View File

@ -1,6 +1,7 @@
# SAML Jackson: Enterprise SSO made simple
<p>
<a href="https://bestpractices.coreinfrastructure.org/projects/7493"><img src="https://bestpractices.coreinfrastructure.org/projects/7493/badge"></a>
<a href="https://www.npmjs.com/package/@boxyhq/saml-jackson"><img src="https://img.shields.io/npm/dt/@boxyhq/saml-jackson" alt="npm" ></a>
<a href="https://hub.docker.com/r/boxyhq/jackson"><img src="https://img.shields.io/docker/pulls/boxyhq/jackson" alt="Docker pull"></a>
<a href="https://github.com/boxyhq/jackson/stargazers"><img src="https://img.shields.io/github/stars/boxyhq/jackson" alt="Github stargazers"></a>
@ -28,7 +29,13 @@ Jackson implements the SAML login flow as an OAuth 2.0 or OpenID Connect flow, a
Try our hosted demo showcasing the SAML SP login flow [here](https://saml-demo.boxyhq.com), no SAML configuration required thanks to our [Mock SAML](https://mocksaml.com) service.
You can also try our hosted demo showcasing the SAML IdP login flow [here](https://mocksaml.com/saml/login).
## Videos
- SSO/OIDC Tutorial [SAML Jackson Enterprise SSO](https://www.youtube.com/watch?v=nvsD4-GQw4A)
- SAML single sign-on login [demo](https://www.youtube.com/watch?v=VBUznQwoEWU)
## Demo
- SAML IdP login flow showcasing self hosted [Mock SAML](https://mocksaml.com/saml/login)
- SAML [demo flow](https://saml-demo.boxyhq.com/)
## Documentation

View File

@ -103,14 +103,16 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
const [delModalVisible, setDelModalVisible] = useState(false);
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
const deleteConnection = async () => {
const queryParams = new URLSearchParams({
clientID: connection.clientID,
clientSecret: connection.clientSecret,
});
const res = await fetch(
setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : '/api/admin/connections',
setupLinkToken
? `/api/setup/${setupLinkToken}/sso-connection?${queryParams}`
: `/api/admin/connections?${queryParams}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ clientID: connection?.clientID, clientSecret: connection?.clientSecret }),
}
);

View File

@ -1,6 +1,6 @@
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { ApiResponse } from 'types';
import { errorToast, successToast } from '@components/Toaster';
import type { Directory } from '@boxyhq/saml-jackson';
@ -13,28 +13,32 @@ interface CreateDirectoryProps {
defaultWebhookEndpoint: string | undefined;
}
type UnSavedDirectory = Omit<Directory, 'id' | 'log_webhook_events' | 'scim' | 'deactivated' | 'webhook'> & {
webhook_url: string;
webhook_secret: string;
};
const defaultDirectory: UnSavedDirectory = {
name: '',
tenant: '',
product: '',
webhook_url: '',
webhook_secret: '',
type: 'azure-scim-v2',
google_domain: '',
};
const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirectoryProps) => {
const { t } = useTranslation('common');
const router = useRouter();
const { providers } = useDirectoryProviders(setupLinkToken);
const [loading, setLoading] = useState(false);
const [directory, setDirectory] = useState({
name: '',
tenant: '',
product: '',
webhook_url: defaultWebhookEndpoint,
webhook_secret: '',
type: '',
const [showDomain, setShowDomain] = useState(false);
const [directory, setDirectory] = useState<UnSavedDirectory>({
...defaultDirectory,
webhook_url: defaultWebhookEndpoint || '',
});
useEffect(() => {
if (providers && Object.keys(providers).length > 0) {
setDirectory((directory) => {
return { ...directory, type: Object.keys(providers)[0] };
});
}
}, [providers]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
@ -78,6 +82,11 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
...directory,
[target.id]: target.value,
});
// Ask for domain if google is selected
if (target.id === 'type') {
target.value === 'google' ? setShowDomain(true) : setShowDomain(false);
}
};
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
@ -116,6 +125,22 @@ const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirec
})}
</select>
</div>
{showDomain && (
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_domain')}</span>
</label>
<input
type='text'
id='google_domain'
className='input-bordered input w-full'
onChange={onChange}
value={directory.google_domain}
pattern='^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$'
title='Please enter a valid domain (e.g: boxyhq.com)'
/>
</div>
)}
{!setupLinkToken && (
<>
<div className='form-control w-full'>

View File

@ -6,6 +6,7 @@ import React from 'react';
import useDirectory from '@lib/ui/hooks/useDirectory';
import Loading from '@components/Loading';
import { errorToast } from '@components/Toaster';
import { dsyncGoogleAuthURL } from '@lib/env';
const DirectoryInfo = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
const { t } = useTranslation('common');
@ -71,14 +72,24 @@ const DirectoryInfo = ({ directoryId, setupLinkToken }: { directoryId: string; s
)}
</dl>
</div>
<div className='mt-4 space-y-4 rounded border p-6'>
<div className='form-control'>
<InputWithCopyButton text={directory.scim.endpoint as string} label={t('scim_endpoint')} />
{directory.scim.endpoint && directory.scim.secret && (
<div className='mt-4 space-y-4 rounded border p-6'>
<div className='form-control'>
<InputWithCopyButton text={directory.scim.endpoint as string} label={t('scim_endpoint')} />
</div>
<div className='form-control'>
<InputWithCopyButton text={directory.scim.secret} label={t('scim_token')} />
</div>
</div>
<div className='form-control'>
<InputWithCopyButton text={directory.scim.secret} label={t('scim_token')} />
)}
{directory.type === 'google' && (
<div className='form-control mt-10'>
<InputWithCopyButton
text={`${dsyncGoogleAuthURL}?directoryId=${directory.id}`}
label={t('dsync_google_auth_url')}
/>
</div>
</div>
)}
</div>
</>
);

View File

@ -11,7 +11,7 @@ import useDirectory from '@lib/ui/hooks/useDirectory';
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
import { DeleteDirectory } from './DeleteDirectory';
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook'>;
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook' | 'google_domain'>;
const defaultFormState: FormState = {
name: '',
@ -20,6 +20,7 @@ const defaultFormState: FormState = {
endpoint: '',
secret: '',
},
google_domain: '',
};
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
@ -39,6 +40,7 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
endpoint: directory.webhook?.endpoint,
secret: directory.webhook?.secret,
},
google_domain: directory.google_domain,
});
}
}, [directory]);
@ -131,6 +133,20 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
value={directoryUpdated.name}
/>
</div>
{directory.type === 'google' && (
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('directory_domain')}</span>
</label>
<input
type='text'
id='google_domain'
className='input-bordered input w-full'
onChange={onChange}
value={directoryUpdated.google_domain}
/>
</div>
)}
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('webhook_url')}</span>

View File

@ -77,7 +77,7 @@ export const deleteConnection = async (
{ tenant, product }: { tenant: string; product: string }
) => {
const response = await request.delete('/api/v1/connections', {
data: {
params: {
tenant,
product,
},

View File

@ -182,7 +182,7 @@ test.describe('SCIM /api/scim/v2.0/:directoryId/Groups', () => {
startIndex: 1,
totalResults: 2,
itemsPerPage: 2,
Resources: [
Resources: expect.arrayContaining([
{
...groups[1],
id: expect.any(String),
@ -191,7 +191,7 @@ test.describe('SCIM /api/scim/v2.0/:directoryId/Groups', () => {
...groups[0],
id: expect.any(String),
},
],
]),
});
});

View File

@ -80,6 +80,15 @@ const jacksonOptions: JacksonOption = {
endpoint: process.env.WEBHOOK_URL || '',
secret: process.env.WEBHOOK_SECRET || '',
},
dsync: {
providers: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
callbackUrl: process.env.GOOGLE_REDIRECT_URI || '',
},
},
},
};
const adminPortalSSODefaults = {
@ -94,3 +103,5 @@ export { retraced as retracedOptions };
export { terminus as terminusOptions };
export { apiKeys };
export { jacksonOptions };
export const dsyncGoogleAuthURL = externalUrl + '/api/scim/oauth/authorize';

View File

@ -206,5 +206,7 @@
"yes_proceed": "Yes, proceed",
"delete_this_directory": "Delete this directory connection?",
"delete_this_directory_desc": "This action cannot be undone. This will permanently delete the directory connection, users, and groups.",
"directory_connection_deleted_successfully": "Directory connection deleted successfully"
}
"directory_connection_deleted_successfully": "Directory connection deleted successfully",
"directory_domain": "Directory Domain",
"dsync_google_auth_url": "The URL that you will need to authorize the application to access your Google Directory."
}

View File

@ -14,6 +14,8 @@ const unAuthenticatedApiRoutes = [
'/api/logout/**',
'/api/oauth/**',
'/api/scim/v2.0/**',
'/api/scim/oauth/**',
'/api/scim/cron',
'/api/well-known/**',
'/api/setup/**',
'/api/branding',

View File

@ -13,7 +13,7 @@ module.exports = {
config.plugins.push(
new webpack.IgnorePlugin({
resourceRegExp:
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^aws-crt|^aws4$|^pg-native$|^mongodb-client-encryption$|^@sap\/hana-client$|^snappy$|^react-native-sqlite-storage$|^bson-ext$|^cardinal$|^kerberos$|^hdb-pool$|^sql.js$|^sqlite3$|^better-sqlite3$|^ioredis$|^typeorm-aurora-data-api-driver$|^pg-query-stream$|^oracledb$|^mysql$|^snappy\/package\.json$)/,
/(^@google-cloud\/spanner|^@mongodb-js\/zstd|^aws-crt|^aws4$|^pg-native$|^mongodb-client-encryption$|^@sap\/hana-client$|^snappy$|^react-native-sqlite-storage$|^bson-ext$|^cardinal$|^kerberos$|^hdb-pool$|^sql.js$|^sqlite3$|^better-sqlite3$|^ioredis$|^typeorm-aurora-data-api-driver$|^pg-query-stream$|^oracledb$|^mysql$|^snappy\/package\.json$|^cloudflare:sockets$)/,
})
);
}

View File

@ -29,6 +29,20 @@ const map = {
'test/federated-saml/app.test.ts': ['src/ee/federated-saml/app.ts'],
'test/federated-saml/sso.test.ts': ['src/ee/federated-saml/sso.ts'],
'test/event/index.test.ts': ['src/event/*'],
'test/dsync/google_oauth.test.ts': [
'src/directory-sync/non-scim/google/oauth.ts',
'src/directory-sync/non-scim/google/index.ts',
'src/directory-sync/non-scim/utils.ts',
],
'test/dsync/google_api.test.ts': [
'src/directory-sync/non-scim/google/api.ts',
'src/directory-sync/non-scim/google/index.ts',
'src/directory-sync/non-scim/syncUsers.ts',
'src/directory-sync/non-scim/syncGroups.ts',
'src/directory-sync/non-scim/syncGroupsMembers.ts',
'src/directory-sync/non-scim/utils.ts',
'src/directory-sync/non-scim/index.ts',
],
};
module.exports = (testFile) => {

3078
npm/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,23 +40,25 @@
"statements": 70
},
"dependencies": {
"@aws-sdk/client-dynamodb": "3.341.0",
"@aws-sdk/credential-providers": "3.341.0",
"@aws-sdk/util-dynamodb": "3.341.0",
"@aws-sdk/client-dynamodb": "3.360.0",
"@aws-sdk/credential-providers": "3.360.0",
"@aws-sdk/util-dynamodb": "3.360.0",
"@boxyhq/error-code-mnemonic": "0.1.1",
"@boxyhq/metrics": "0.2.2",
"@boxyhq/metrics": "0.2.4",
"@boxyhq/saml20": "1.2.1",
"axios": "1.4.0",
"encoding": "0.1.13",
"googleapis": "118.0.0",
"jose": "4.14.4",
"lodash": "4.17.21",
"mixpanel": "0.17.0",
"mongodb": "5.5.0",
"mongodb": "5.6.0",
"mssql": "9.1.1",
"mysql2": "3.3.3",
"mysql2": "3.4.0",
"node-forge": "1.3.1",
"openid-client": "5.4.2",
"pg": "8.10.0",
"redis": "4.6.6",
"redis": "4.6.7",
"reflect-metadata": "0.1.13",
"ripemd160": "2.0.2",
"typeorm": "0.3.16",
@ -65,15 +67,17 @@
},
"devDependencies": {
"@faker-js/faker": "8.0.2",
"@types/node": "20.2.5",
"@types/lodash": "4.14.195",
"@types/node": "20.3.1",
"@types/sinon": "10.0.15",
"@types/tap": "15.0.8",
"cross-env": "7.0.3",
"sinon": "15.1.0",
"tap": "16.3.4",
"nock": "13.3.1",
"sinon": "15.2.0",
"tap": "16.3.7",
"ts-node": "10.9.1",
"tsconfig-paths": "4.2.0",
"typescript": "5.0.4"
"typescript": "5.1.5"
},
"engines": {
"node": ">=16",

View File

@ -668,27 +668,27 @@ export class ConnectionAPIController implements IConnectionAPIController {
* parameters:
* clientIDDel:
* name: clientID
* in: formData
* in: query
* type: string
* description: Client ID
* clientSecretDel:
* name: clientSecret
* in: formData
* in: query
* type: string
* description: Client Secret
* tenantDel:
* name: tenant
* in: formData
* in: query
* type: string
* description: Tenant
* productDel:
* name: product
* in: formData
* in: query
* type: string
* description: Product
* strategyDel:
* name: strategy
* in: formData
* in: query
* type: string
* description: Strategy which can help to filter connections with tenant/product query
* /api/v1/connections:
@ -702,9 +702,6 @@ export class ConnectionAPIController implements IConnectionAPIController {
* summary: Delete SSO Connections
* operationId: delete-sso-connection
* tags: [Connections]
* consumes:
* - application/x-www-form-urlencoded
* - application/json
* responses:
* '200':
* description: Success
@ -803,4 +800,98 @@ export class ConnectionAPIController implements IConnectionAPIController {
public async deleteConfig(body: DelConnectionsQuery): Promise<void> {
await this.deleteConnections({ ...body, strategy: 'saml' });
}
/**
* @swagger
* parameters:
* productParamGet:
* in: query
* name: product
* type: string
* description: Product
* required: true
* definitions:
* Connection:
* type: object
* properties:
* clientID:
* type: string
* description: Connection clientID
* clientSecret:
* type: string
* description: Connection clientSecret
* name:
* type: string
* description: Connection name
* description:
* type: string
* description: Connection description
* redirectUrl:
* type: string
* description: A list of allowed redirect URLs
* defaultRedirectUrl:
* type: string
* description: The redirect URL to use in the IdP login flow
* tenant:
* type: string
* description: Connection tenant
* product:
* type: string
* description: Connection product
* idpMetadata:
* type: object
* description: SAML IdP metadata
* oidcProvider:
* type: object
* description: OIDC IdP metadata
* responses:
* '200Get':
* description: Success
* schema:
* type: array
* items:
* $ref: '#/definitions/Connection'
* '400Get':
* description: Please provide a `product`.
* '401Get':
* description: Unauthorized
* /api/v1/connections/product:
* get:
* summary: Get SSO Connections by product
* parameters:
* - $ref: '#/parameters/productParamGet'
* operationId: get-connections-by-product
* tags: [Connections]
* responses:
* '200':
* $ref: '#/responses/200Get'
* '400':
* $ref: '#/responses/400Get'
* '401':
* $ref: '#/responses/401Get'
*/
public async getConnectionsByProduct(body: {
product: string;
pageOffset?: number;
pageLimit?: number;
pageToken?: string;
}): Promise<{ data: (SAMLSSORecord | OIDCSSORecord)[]; pageToken?: string }> {
const { product, pageOffset, pageLimit, pageToken } = body;
if (!product) {
throw new JacksonError('Please provide a `product`.', 400);
}
const connections = await this.connectionStore.getByIndex(
{
name: IndexNames.Product,
value: product,
},
pageOffset,
pageLimit,
pageToken
);
return { data: transformConnections(connections.data), pageToken };
}
}

View File

@ -86,11 +86,20 @@ const oidc = {
record.clientSecret = connectionClientSecret;
await connectionStore.put(record.clientID, record, {
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
});
await connectionStore.put(
record.clientID,
record,
{
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
},
{
// secondary index on product
name: IndexNames.Product,
value: product,
}
);
return record as OIDCSSORecord;
},
@ -187,11 +196,20 @@ const oidc = {
record['deactivated'] = body.deactivated;
}
await connectionStore.put(clientInfo?.clientID, record, {
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
});
await connectionStore.put(
clientInfo?.clientID,
record,
{
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
},
{
// secondary index on product
name: IndexNames.Product,
value: _savedConnection.product,
}
);
return record;
},

View File

@ -163,6 +163,11 @@ const saml = {
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
},
{
// secondary index on product
name: IndexNames.Product,
value: product,
}
);
@ -281,6 +286,11 @@ const saml = {
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
},
{
// secondary index on product
name: IndexNames.Product,
value: _savedConnection.product,
}
);

View File

@ -110,7 +110,7 @@ export class SAMLHandler {
...originalParams,
});
return { redirectUrl: `${url.toString()}?${params.toString()}` };
return { redirectUrl: `${url}?${params}` };
}
// IdP initiated flow
@ -119,7 +119,7 @@ export class SAMLHandler {
entityId,
});
const postForm = saml.createPostForm(`${this.opts.idpDiscoveryPath}?${params.toString()}`, [
const postForm = saml.createPostForm(`${this.opts.idpDiscoveryPath}?${params}`, [
{
name: 'SAMLResponse',
value: originalParams.SAMLResponse,

View File

@ -26,6 +26,7 @@ export enum IndexNames {
Service = 'service',
OIDCProviderClientID = 'OIDCProviderClientID',
SSOClientID = 'SSOClientID',
Product = 'product',
}
// The namespace prefix for the database store
@ -36,6 +37,7 @@ export const storeNamespacePrefix = {
users: 'dsync:users',
groups: 'dsync:groups',
members: 'dsync:members',
providers: 'dsync:providers',
},
saml: {
config: 'saml:config',

View File

@ -1,13 +1,15 @@
import type { DatabaseStore, JacksonOption, IEventController } from '../typings';
import { DirectoryConfig } from './DirectoryConfig';
import { DirectoryUsers } from './DirectoryUsers';
import { DirectoryGroups } from './DirectoryGroups';
import { Users } from './Users';
import { Groups } from './Groups';
import { getDirectorySyncProviders } from './utils';
import type { DatabaseStore, JacksonOption, IEventController, EventCallback } from '../typings';
import { DirectoryConfig } from './scim/DirectoryConfig';
import { DirectoryUsers } from './scim/DirectoryUsers';
import { DirectoryGroups } from './scim/DirectoryGroups';
import { Users } from './scim/Users';
import { Groups } from './scim/Groups';
import { getDirectorySyncProviders } from './scim/utils';
import { RequestHandler } from './request';
import { handleEventCallback } from './events';
import { WebhookEventsLogger } from './WebhookEventsLogger';
import { handleEventCallback } from './scim/events';
import { WebhookEventsLogger } from './scim/WebhookEventsLogger';
import { newGoogleProvider } from './non-scim/google';
import { startSync } from './non-scim';
const directorySync = async (params: {
db: DatabaseStore;
@ -23,18 +25,31 @@ const directorySync = async (params: {
const directoryUsers = new DirectoryUsers({ directories, users });
const directoryGroups = new DirectoryGroups({ directories, users, groups });
const requestHandler = new RequestHandler(directoryUsers, directoryGroups);
// Fetch the supported providers
const getProviders = () => {
return getDirectorySyncProviders();
};
const googleProvider = newGoogleProvider({ directories, opts });
return {
users,
groups,
directories,
webhookLogs: logger,
requests: new RequestHandler(directoryUsers, directoryGroups),
requests: requestHandler,
providers: getProviders,
events: {
callback: await handleEventCallback(directories, logger),
},
providers: () => {
return getDirectorySyncProviders();
google: googleProvider.oauth,
sync: async (callback: EventCallback) => {
return await startSync(
{ userController: users, groupController: groups, opts, directories, requestHandler },
callback
);
},
};
};

View File

@ -0,0 +1,179 @@
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import type {
Directory,
IDirectoryConfig,
Group,
GroupMember,
IDirectoryProvider,
JacksonOption,
PaginationParams,
} from '../../../typings';
interface GoogleProviderParams {
opts: JacksonOption;
directories: IDirectoryConfig;
}
export class GoogleProvider implements IDirectoryProvider {
opts: JacksonOption;
directories: IDirectoryConfig;
groupFieldsToExcludeWhenCompare = ['etag'];
userFieldsToExcludeWhenCompare = ['etag', 'lastLoginTime', 'thumbnailPhotoEtag'];
constructor({ directories, opts }: GoogleProviderParams) {
this.opts = opts;
this.directories = directories;
}
createOAuth2Client(directory: Directory) {
const googleProvider = this.opts.dsync?.providers.google;
const authClient = new OAuth2Client(
googleProvider?.clientId,
googleProvider?.clientSecret,
googleProvider?.callbackUrl
);
authClient.setCredentials({
access_token: directory.google_access_token,
refresh_token: directory.google_refresh_token,
});
return authClient;
}
async getDirectories() {
const { data: directories } = await this.directories.filterBy({
provider: 'google',
});
if (!directories || directories.length === 0) {
return [];
}
return directories.filter((directory) => {
return (
directory.google_access_token && directory.google_refresh_token && directory.google_domain !== ''
);
});
}
async getUsers(directory: Directory, options: PaginationParams | null) {
const query = {
maxResults: 200,
domain: directory.google_domain,
};
if (options?.pageToken) {
query['pageToken'] = options.pageToken;
}
const googleAdmin = google.admin({ version: 'directory_v1', auth: this.createOAuth2Client(directory) });
const response = await googleAdmin.users.list(query);
if (!response.data.users) {
return {
data: [],
metadata: null,
};
}
const users = response.data.users.map((user) => {
return {
id: user.id as string,
email: user.primaryEmail as string,
first_name: user.name?.givenName as string,
last_name: user.name?.familyName as string,
active: !user.suspended,
raw: user,
};
});
return {
data: users,
metadata: {
nextPageToken: response.data.nextPageToken,
hasNextPage: !!response.data.nextPageToken,
},
};
}
async getGroups(directory: Directory, options: PaginationParams | null) {
const googleAdmin = google.admin({ version: 'directory_v1', auth: this.createOAuth2Client(directory) });
const query = {
maxResults: 200,
domain: directory.google_domain,
};
if (options?.pageToken) {
query['pageToken'] = options.pageToken;
}
const response = await googleAdmin.groups.list(query);
if (!response.data.groups) {
return {
data: [],
metadata: null,
};
}
const groups = response.data.groups.map((group) => {
return {
id: group.id as string,
name: group.name as string,
raw: group,
};
});
return {
data: groups,
metadata: {
pageToken: response.data.nextPageToken as string,
hasNextPage: !!response.data.nextPageToken,
},
};
}
async getGroupMembers(directory: Directory, group: Group) {
const googleAdmin = google.admin({ version: 'directory_v1', auth: this.createOAuth2Client(directory) });
const allMembers: GroupMember[] = [];
const query = {
maxResults: 200,
groupKey: group.id,
domain: directory.google_domain,
};
let nextPageToken: string | undefined | null = null;
do {
if (nextPageToken) {
query['pageToken'] = nextPageToken;
}
const response = await googleAdmin.members.list(query);
if (!response.data.members) {
break;
}
const members = response.data.members.map((user) => {
return {
id: user.id as string,
raw: user,
};
});
allMembers.push(...members);
nextPageToken = response.data.nextPageToken;
} while (nextPageToken);
return allMembers;
}
}

View File

@ -0,0 +1,17 @@
import { GoogleAuth } from './oauth';
import { GoogleProvider } from './api';
import type { IDirectoryConfig, JacksonOption } from '../../../typings';
interface NewGoogleProviderParams {
directories: IDirectoryConfig;
opts: JacksonOption;
}
export const newGoogleProvider = (params: NewGoogleProviderParams) => {
const { directories, opts } = params;
return {
directory: new GoogleProvider({ opts, directories }),
oauth: new GoogleAuth({ opts, directories }),
};
};

View File

@ -0,0 +1,131 @@
import { OAuth2Client, Credentials } from 'google-auth-library';
import { JacksonError, apiError } from '../../../controller/error';
import type { Directory, IDirectoryConfig, JacksonOption, Response } from '../../../typings';
const scope = [
'https://www.googleapis.com/auth/admin.directory.user.readonly',
'https://www.googleapis.com/auth/admin.directory.group.readonly',
'https://www.googleapis.com/auth/admin.directory.group.member.readonly',
];
interface GoogleAuthParams {
opts: JacksonOption;
directories: IDirectoryConfig;
}
export class GoogleAuth {
private opts: JacksonOption;
private directories: IDirectoryConfig;
constructor({ directories, opts }: GoogleAuthParams) {
this.opts = opts;
this.directories = directories;
}
createOAuth2Client(directory: Directory) {
const googleProvider = this.opts.dsync?.providers.google;
const authClient = new OAuth2Client(
googleProvider?.clientId,
googleProvider?.clientSecret,
googleProvider?.callbackUrl
);
authClient.setCredentials({
access_token: directory.google_access_token,
refresh_token: directory.google_refresh_token,
});
return authClient;
}
// Generate the Google authorization URL
async generateAuthorizationUrl(params: {
directoryId: string;
}): Promise<Response<{ authorizationUrl: string }>> {
const { directoryId } = params;
try {
const { data: directory, error } = await this.directories.get(directoryId);
if (error) {
throw error;
}
if (directory?.type !== 'google') {
throw new JacksonError('Directory is not a Google Directory', 400);
}
const oauth2Client = this.createOAuth2Client(directory);
const authorizationUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope,
state: JSON.stringify({ directoryId }),
});
const data = {
authorizationUrl,
};
return { data, error: null };
} catch (error: any) {
return apiError(error);
}
}
// Get the Google API access token from the authorization code
async getAccessToken(params: { directoryId: string; code: string }): Promise<Response<Credentials>> {
const { directoryId, code } = params;
try {
const { data: directory, error } = await this.directories.get(directoryId);
if (error) {
throw error;
}
const oauth2Client = this.createOAuth2Client(directory);
const { tokens } = await oauth2Client.getToken(code);
return { data: tokens, error: null };
} catch (error: any) {
return apiError(error);
}
}
// Set the Google API access token and refresh token for the directory
async setToken(params: {
directoryId: string;
accessToken: Credentials['access_token'];
refreshToken: Credentials['refresh_token'];
}): Promise<Response<Directory>> {
const { directoryId, accessToken, refreshToken } = params;
try {
if (!accessToken) {
throw new JacksonError(`Access token is required`, 400);
}
if (!refreshToken) {
throw new JacksonError(`Refresh token is required`, 400);
}
const { data } = await this.directories.update(directoryId, {
google_access_token: accessToken,
google_refresh_token: refreshToken,
});
if (!data) {
throw new JacksonError('Failed to update directory', 400);
}
return { data, error: null };
} catch (error: any) {
return apiError(error);
}
}
}

View File

@ -0,0 +1,62 @@
import { newGoogleProvider } from './google';
import type {
IDirectoryConfig,
IUsers,
IGroups,
IRequestHandler,
JacksonOption,
EventCallback,
} from '../../typings';
import { SyncUsers } from './syncUsers';
import { SyncGroups } from './syncGroups';
import { SyncGroupMembers } from './syncGroupMembers';
interface SyncParams {
userController: IUsers;
groupController: IGroups;
opts: JacksonOption;
directories: IDirectoryConfig;
requestHandler: IRequestHandler;
}
// Method to start the directory sync process
// This method will be called by the directory sync cron job
export const startSync = async (params: SyncParams, callback: EventCallback) => {
const { userController, groupController, opts, directories, requestHandler } = params;
const { directory: provider } = newGoogleProvider({ directories, opts });
const startTime = Date.now();
console.info('Starting the sync process');
const allDirectories = await provider.getDirectories();
if (allDirectories.length === 0) {
console.info('No directories found. Skipping the sync process');
return;
}
try {
for (const directory of allDirectories) {
const params = {
directory,
userController,
groupController,
provider,
requestHandler,
callback,
};
await new SyncUsers(params).sync();
await new SyncGroups(params).sync();
await new SyncGroupMembers(params).sync();
}
} catch (e: any) {
console.error(e);
}
const endTime = Date.now();
console.info(`Sync process completed in ${(endTime - startTime) / 1000} seconds`);
};

View File

@ -0,0 +1,138 @@
import _ from 'lodash';
import type {
Directory,
IGroups,
Group,
IRequestHandler,
DirectorySyncRequest,
EventCallback,
IDirectoryProvider,
PaginationParams,
GroupMembership,
} from '../../typings';
import {
compareAndFindDeletedMembers,
compareAndFindNewMembers,
toGroupMembershipSCIMPayload,
} from './utils';
interface SyncGroupMembersParams {
groupController: IGroups;
provider: IDirectoryProvider;
requestHandler: IRequestHandler;
callback: EventCallback;
directory: Directory;
}
type HandleRequestParams = Pick<DirectorySyncRequest, 'method' | 'body' | 'resourceId'>;
export class SyncGroupMembers {
private groupController: IGroups;
private provider: IDirectoryProvider;
private requestHandler: IRequestHandler;
private callback: EventCallback;
private directory: Directory;
constructor({ directory, groupController, requestHandler, provider, callback }: SyncGroupMembersParams) {
this.groupController = groupController;
this.provider = provider;
this.requestHandler = requestHandler;
this.callback = callback;
this.directory = directory;
}
async sync() {
let nextPageOption: PaginationParams | null = null;
do {
const { data: groups, metadata } = await this.provider.getGroups(this.directory, nextPageOption);
if (!groups || groups.length === 0) {
break;
}
for (const group of groups) {
const membersFromDB = await this.getAllExistingMembers(group);
const membersFromProvider = await this.provider.getGroupMembers(this.directory, group);
const idsFromDB = _.map(membersFromDB, 'user_id');
const idsFromProvider = _.map(membersFromProvider, 'id');
const deletedMembers = compareAndFindDeletedMembers(idsFromDB, idsFromProvider);
const newMembers = compareAndFindNewMembers(idsFromDB, idsFromProvider);
if (deletedMembers && deletedMembers.length > 0) {
await this.deleteMembers(group, deletedMembers);
}
if (newMembers && newMembers.length > 0) {
await this.addMembers(group, newMembers);
}
}
nextPageOption = metadata;
} while (nextPageOption && nextPageOption.hasNextPage);
}
// Get all existing members for a group from the Jackson store
async getAllExistingMembers(group: Group) {
const existingMembers: GroupMembership['user_id'][] = [];
const pageLimit = 500;
let pageOffset = 0;
while (true as boolean) {
const { data: members } = await this.groupController
.setTenantAndProduct(this.directory.tenant, this.directory.product)
.getGroupMembers({
groupId: group.id,
pageOffset,
pageLimit,
});
if (!members || members.length === 0) {
break;
}
existingMembers.push(...members);
if (members.length < pageLimit) {
break;
}
pageOffset += pageLimit;
}
return existingMembers;
}
async addMembers(group: Group, memberIds: string[]) {
await this.handleRequest({
method: 'PATCH',
body: toGroupMembershipSCIMPayload(memberIds, 'add'),
resourceId: group.id,
});
}
async deleteMembers(group: Group, memberIds: string[]) {
await this.handleRequest({
method: 'PATCH',
body: toGroupMembershipSCIMPayload(memberIds, 'remove'),
resourceId: group.id,
});
}
async handleRequest(payload: HandleRequestParams) {
const request: DirectorySyncRequest = {
query: {},
body: payload.body,
resourceType: 'groups',
method: payload.method,
directoryId: this.directory.id,
apiSecret: this.directory.scim.secret,
resourceId: payload.resourceId,
};
await this.requestHandler.handle(request, this.callback);
}
}

View File

@ -0,0 +1,147 @@
import _ from 'lodash';
import type {
Directory,
IGroups,
Group,
IRequestHandler,
DirectorySyncRequest,
EventCallback,
IDirectoryProvider,
PaginationParams,
} from '../../typings';
import { compareAndFindDeletedGroups, isGroupUpdated, toGroupSCIMPayload } from './utils';
interface SyncGroupsParams {
groupController: IGroups;
provider: IDirectoryProvider;
requestHandler: IRequestHandler;
callback: EventCallback;
directory: Directory;
}
type HandleRequestParams = Pick<DirectorySyncRequest, 'method' | 'body' | 'resourceId'>;
export class SyncGroups {
private groupController: IGroups;
private provider: IDirectoryProvider;
private requestHandler: IRequestHandler;
private callback: EventCallback;
private directory: Directory;
constructor({ directory, groupController, callback, requestHandler, provider }: SyncGroupsParams) {
this.groupController = groupController;
this.provider = provider;
this.requestHandler = requestHandler;
this.callback = callback;
this.directory = directory;
}
async sync() {
const groupsFromProvider: Group[] = [];
let nextPageOption: PaginationParams | null = null;
do {
const { data: groups, metadata } = await this.provider.getGroups(this.directory, nextPageOption);
if (!groups || groups.length === 0) {
break;
}
// Create or update groups
for (const group of groups) {
const { data: existingGroup } = await this.groupController
.setTenantAndProduct(this.directory.tenant, this.directory.product)
.get(group.id);
if (!existingGroup) {
await this.createGroup(group);
} else if (isGroupUpdated(existingGroup, group, this.provider.groupFieldsToExcludeWhenCompare)) {
await this.updateGroup(group);
}
}
// Store groups to compare and delete later
groupsFromProvider.push(...groups);
nextPageOption = metadata;
} while (nextPageOption && nextPageOption.hasNextPage);
// Delete users that are not in the directory anymore
const existingGroups = await this.getAllExistingGroups();
const groupsToDelete = compareAndFindDeletedGroups(existingGroups, groupsFromProvider);
await this.deleteGroups(groupsToDelete);
}
// Get all the existing groups from the Jackson store
async getAllExistingGroups() {
const existingGroups: Group[] = [];
const pageLimit = 500;
let pageOffset = 0;
while (true as boolean) {
const { data: groups } = await this.groupController
.setTenantAndProduct(this.directory.tenant, this.directory.product)
.getAll({
directoryId: this.directory.id,
pageOffset,
pageLimit,
});
if (!groups || groups.length === 0) {
break;
}
existingGroups.push(...groups);
if (groups.length < pageLimit) {
break;
}
pageOffset += pageLimit;
}
return existingGroups;
}
async createGroup(group: Group) {
await this.handleRequest({
method: 'POST',
body: toGroupSCIMPayload(group),
resourceId: undefined,
});
}
async updateGroup(group: Group) {
await this.handleRequest({
method: 'PUT',
body: toGroupSCIMPayload(group),
resourceId: group.id,
});
}
async deleteGroups(groups: Group[]) {
for (const group of groups) {
await this.handleRequest({
method: 'DELETE',
body: toGroupSCIMPayload(group),
resourceId: group.id,
});
}
}
async handleRequest(payload: HandleRequestParams) {
const request: DirectorySyncRequest = {
query: {},
body: payload.body,
resourceType: 'groups',
method: payload.method,
directoryId: this.directory.id,
apiSecret: this.directory.scim.secret,
resourceId: payload.resourceId,
};
await this.requestHandler.handle(request, this.callback);
}
}

View File

@ -0,0 +1,147 @@
import _ from 'lodash';
import type {
Directory,
User,
IUsers,
IRequestHandler,
DirectorySyncRequest,
EventCallback,
IDirectoryProvider,
PaginationParams,
} from '../../typings';
import { compareAndFindDeletedUsers, isUserUpdated, toUserSCIMPayload } from './utils';
interface SyncUserParams {
directory: Directory;
userController: IUsers;
callback: EventCallback;
provider: IDirectoryProvider;
requestHandler: IRequestHandler;
}
type HandleRequestParams = Pick<DirectorySyncRequest, 'method' | 'body' | 'resourceId'>;
export class SyncUsers {
private directory: Directory;
private userController: IUsers;
private callback: EventCallback;
private provider: IDirectoryProvider;
private requestHandler: IRequestHandler;
constructor({ directory, userController, callback, provider, requestHandler }: SyncUserParams) {
this.callback = callback;
this.provider = provider;
this.directory = directory;
this.requestHandler = requestHandler;
this.userController = userController;
}
async sync() {
const usersFromProvider: User[] = [];
let nextPageOption: PaginationParams | null = null;
do {
const { data: users, metadata } = await this.provider.getUsers(this.directory, nextPageOption);
if (users.length === 0) {
break;
}
// Create or update users
for (const user of users) {
const { data: existingUser } = await this.userController
.setTenantAndProduct(this.directory.tenant, this.directory.product)
.get(user.id);
if (!existingUser) {
await this.createUser(user);
} else if (isUserUpdated(existingUser, user, this.provider.userFieldsToExcludeWhenCompare)) {
await this.updateUser(user);
}
}
// Store users to compare and delete later
usersFromProvider.push(...users);
nextPageOption = metadata;
} while (nextPageOption && nextPageOption.hasNextPage);
// Delete users that are not in the directory anymore
const existingUsers = await this.getAllExistingUsers();
const usersToDelete = compareAndFindDeletedUsers(existingUsers, usersFromProvider);
await this.deleteUsers(usersToDelete);
}
// Get all the existing users from the Jackson store
async getAllExistingUsers() {
const existingUsers: User[] = [];
const pageLimit = 500;
let pageOffset = 0;
while (true as boolean) {
const { data: users } = await this.userController
.setTenantAndProduct(this.directory.tenant, this.directory.product)
.getAll({
directoryId: this.directory.id,
pageOffset,
pageLimit,
});
if (!users || users.length === 0) {
break;
}
existingUsers.push(...users);
if (users.length < pageLimit) {
break;
}
pageOffset += pageLimit;
}
return existingUsers;
}
async createUser(user: User) {
await this.handleRequest({
method: 'POST',
body: toUserSCIMPayload(user),
resourceId: undefined,
});
}
async updateUser(user: User) {
await this.handleRequest({
method: 'PUT',
body: toUserSCIMPayload(user),
resourceId: user.id,
});
}
async deleteUsers(users: User[]) {
for (const user of users) {
await this.handleRequest({
method: 'DELETE',
body: toUserSCIMPayload(user),
resourceId: user.id,
});
}
}
async handleRequest(payload: HandleRequestParams) {
const request: DirectorySyncRequest = {
query: {},
body: payload.body,
resourceType: 'users',
method: payload.method,
directoryId: this.directory.id,
apiSecret: this.directory.scim.secret,
resourceId: payload.resourceId,
};
await this.requestHandler.handle(request, this.callback);
}
}

View File

@ -0,0 +1,138 @@
import _ from 'lodash';
import crypto from 'crypto';
import type { User, Group } from '../../typings';
export const toUserSCIMPayload = (user: User) => {
return {
userName: user.email,
name: {
givenName: user.first_name,
familyName: user.last_name,
},
emails: [
{
primary: true,
value: user.email,
type: 'work',
},
],
userId: user.id,
active: user.active,
rawAttributes: user.raw,
};
};
export const toGroupSCIMPayload = (group: Group) => {
return {
displayName: group.name,
groupId: group.id,
rawAttributes: group.raw,
};
};
export const toGroupMembershipSCIMPayload = (memberIds: string[], operation: 'add' | 'remove') => {
const memberValues = memberIds.map((memberId) => {
return {
value: memberId,
};
});
return {
Operations: [
{
op: operation,
path: 'members',
value: memberValues,
},
],
};
};
export const isUserUpdated = (
existingUser: User,
userFromProvider: User,
ignoreFields: string[] | undefined
) => {
const copyOfExistingUser = _.cloneDeep(existingUser);
const copyOfUserFromProvider = _.cloneDeep(userFromProvider);
if (ignoreFields && ignoreFields.length > 0) {
ignoreFields.forEach((field) => {
_.unset(copyOfExistingUser.raw, field);
_.unset(copyOfUserFromProvider.raw, field);
});
}
return getObjectHash(copyOfExistingUser.raw) !== getObjectHash(copyOfUserFromProvider.raw);
};
export const isGroupUpdated = (
existingGroup: Group,
groupFromProvider: Group,
ignoreFields: string[] | undefined
) => {
const copyOfExistingGroup = _.cloneDeep(existingGroup);
const copyOfGroupFromProvider = _.cloneDeep(groupFromProvider);
if (ignoreFields && ignoreFields.length > 0) {
ignoreFields.forEach((field) => {
_.unset(copyOfExistingGroup.raw, field);
_.unset(copyOfGroupFromProvider.raw, field);
});
}
return getObjectHash(copyOfExistingGroup.raw) !== getObjectHash(copyOfGroupFromProvider.raw);
};
export const compareAndFindDeletedGroups = (existingGroups: Group[] | null, groups: Group[]) => {
if (!existingGroups || existingGroups.length === 0) {
return [];
}
const groupsToDelete = existingGroups.filter((existingGroup) => {
return !groups.some((group) => group.id === existingGroup.id);
});
return groupsToDelete;
};
export const compareAndFindDeletedUsers = (existingUsers: User[] | null, users: User[]) => {
if (!existingUsers || existingUsers.length === 0) {
return [];
}
const usersToDelete = existingUsers.filter((existingUser) => {
return !users.some((user) => user.id === existingUser.id);
});
return usersToDelete;
};
export const compareAndFindDeletedMembers = (idsFromDB: string[], idsFromProvider: string[]) => {
return idsFromDB.filter((userId) => !idsFromProvider.includes(userId));
};
export const compareAndFindNewMembers = (idsFromDB: string[], idsFromProvider: string[]) => {
return idsFromProvider.filter((userId) => !idsFromDB.includes(userId));
};
const normalizeObject = (obj: any) => {
if (_.isArray(obj)) {
return obj.map(normalizeObject);
} else if (_.isObject(obj)) {
const sortedKeys = _.sortBy(Object.keys(obj));
return _.fromPairs(sortedKeys.map((key) => [key, normalizeObject(obj[key])]));
} else {
return obj;
}
};
const getObjectHash = (obj: any) => {
const normalizedObj = normalizeObject(obj);
const hash = crypto.createHash('sha1');
hash.update(JSON.stringify(normalizedObj));
return hash.digest('hex');
};

View File

@ -1,6 +1,5 @@
import type { Storable, DatabaseStore } from '../typings';
import { storeNamespacePrefix } from '../controller/utils';
import { randomUUID } from 'crypto';
import type { Storable, DatabaseStore } from '../../typings';
import { storeNamespacePrefix } from '../../controller/utils';
export class Base {
protected db: DatabaseStore;
@ -21,29 +20,11 @@ export class Base {
return this.db.store(`${storeNamespacePrefix.dsync[type]}:${this.tenant}:${this.product}`);
}
setTenant(tenant: string): this {
// Set the tenant and product
setTenantAndProduct(tenant: string, product: string): this {
this.tenant = tenant;
return this;
}
setProduct(product: string): this {
this.product = product;
return this;
}
// Set the tenant and product
setTenantAndProduct(tenant: string, product: string): this {
return this.setTenant(tenant).setProduct(product);
}
// Set the tenant and product
with(tenant: string, product: string): this {
return this.setTenant(tenant).setProduct(product);
}
createId(): string {
return randomUUID();
}
}

View File

@ -1,32 +1,43 @@
import { randomUUID } from 'crypto';
import type {
Storable,
Directory,
JacksonOption,
DatabaseStore,
DirectoryType,
ApiError,
PaginationParams,
IUsers,
IGroups,
IWebhookEventsLogger,
IEventController,
} from '../typings';
import * as dbutils from '../db/utils';
import { createRandomSecret, isConnectionActive, validateTenantAndProduct } from '../controller/utils';
import { apiError, JacksonError } from '../controller/error';
import { storeNamespacePrefix } from '../controller/utils';
import { randomUUID } from 'crypto';
import { IndexNames } from '../controller/utils';
import { getDirectorySyncProviders } from './utils';
Response,
Index,
} from '../../typings';
import * as dbutils from '../../db/utils';
import {
createRandomSecret,
isConnectionActive,
validateTenantAndProduct,
storeNamespacePrefix,
IndexNames,
} from '../../controller/utils';
import { apiError, JacksonError } from '../../controller/error';
import { getDirectorySyncProviders, isSCIMEnabledProvider } from './utils';
type ConstructorParams = {
interface DirectoryConfigParams {
db: DatabaseStore;
opts: JacksonOption;
users: IUsers;
groups: IGroups;
logger: IWebhookEventsLogger;
eventController: IEventController;
};
}
interface FilterByParams extends PaginationParams {
product?: string;
provider?: DirectoryType;
}
export class DirectoryConfig {
private _store: Storable | null = null;
@ -37,7 +48,7 @@ export class DirectoryConfig {
private logger: IWebhookEventsLogger;
private eventController: IEventController;
constructor({ db, opts, users, groups, logger, eventController }: ConstructorParams) {
constructor({ db, opts, users, groups, logger, eventController }: DirectoryConfigParams) {
this.opts = opts;
this.db = db;
this.users = users;
@ -59,9 +70,22 @@ export class DirectoryConfig {
webhook_url?: string;
webhook_secret?: string;
type?: DirectoryType;
}): Promise<{ data: Directory | null; error: ApiError | null }> {
google_domain?: string;
google_access_token?: string;
google_refresh_token?: string;
}): Promise<Response<Directory>> {
try {
const { name, tenant, product, webhook_url, webhook_secret, type = 'generic-scim-v2' } = params;
const {
name,
tenant,
product,
webhook_url,
webhook_secret,
type = 'generic-scim-v2',
google_domain,
google_access_token,
google_refresh_token,
} = params;
if (!tenant || !product) {
throw new JacksonError('Missing required parameters.', 400);
@ -77,28 +101,59 @@ export class DirectoryConfig {
const directoryName = name || `scim-${tenant}-${product}`;
const id = randomUUID();
const hasWebhook = webhook_url && webhook_secret;
const isSCIMProvider = isSCIMEnabledProvider(type);
const directory: Directory = {
let directory: Directory = {
id,
name: directoryName,
tenant,
product,
type,
log_webhook_events: false,
scim: {
path: `${this.opts.scimPath}/${id}`,
secret: await createRandomSecret(16),
},
webhook: {
endpoint: hasWebhook ? webhook_url : '',
secret: hasWebhook ? webhook_secret : '',
},
scim: isSCIMProvider
? {
path: `${this.opts.scimPath}/${id}`,
secret: await createRandomSecret(16),
}
: {
path: '',
secret: '',
},
};
await this.store().put(id, directory, {
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
});
if (type === 'google') {
directory = {
...directory,
google_domain: google_domain || '',
google_access_token: google_access_token || '',
google_refresh_token: google_refresh_token || '',
};
}
const indexes: Index[] = [
{
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
},
{
name: IndexNames.Product,
value: product,
},
];
// Add secondary index for Non-SCIM providers
if (!isSCIMProvider) {
indexes.push({
name: storeNamespacePrefix.dsync.providers,
value: type,
});
}
await this.store().put(id, directory, ...indexes);
const connection = this.transform(directory);
@ -111,7 +166,7 @@ export class DirectoryConfig {
}
// Get the configuration by id
public async get(id: string): Promise<{ data: Directory | null; error: ApiError | null }> {
public async get(id: string): Promise<Response<Directory>> {
try {
if (!id) {
throw new JacksonError('Missing required parameters.', 400);
@ -132,60 +187,57 @@ export class DirectoryConfig {
// Update the configuration. Partial updates are supported
public async update(
id: string,
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim'>
): Promise<{ data: Directory | null; error: ApiError | null }> {
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim' | 'type'>
): Promise<Response<Directory>> {
try {
if (!id) {
throw new JacksonError('Missing required parameters.', 400);
}
const { name, log_webhook_events, webhook, type } = param;
const {
name,
log_webhook_events,
webhook,
deactivated,
google_domain,
google_access_token,
google_refresh_token,
} = param;
let directory: Directory = await this.store().get(id);
const directory: Directory = await this.store().get(id);
if (name) {
directory.name = name;
}
let updatedDirectory: Directory = {
...directory,
name: name || directory.name,
webhook: webhook || directory.webhook,
deactivated: deactivated !== undefined ? deactivated : directory.deactivated,
log_webhook_events:
log_webhook_events !== undefined ? log_webhook_events : directory.log_webhook_events,
google_domain: google_domain || directory.google_domain,
google_access_token: google_access_token || directory.google_access_token,
google_refresh_token: google_refresh_token || directory.google_refresh_token,
};
if (log_webhook_events !== undefined) {
directory.log_webhook_events = log_webhook_events;
}
await this.store().put(id, updatedDirectory);
if (webhook) {
directory.webhook = webhook;
}
if (type) {
directory.type = type;
}
updatedDirectory = this.transform(updatedDirectory);
if ('deactivated' in param) {
directory['deactivated'] = param.deactivated;
}
await this.store().put(id, { ...directory });
directory = this.transform(directory);
if ('deactivated' in param) {
if (isConnectionActive(directory)) {
await this.eventController.notify('dsync.activated', directory);
if (isConnectionActive(updatedDirectory)) {
await this.eventController.notify('dsync.activated', updatedDirectory);
} else {
await this.eventController.notify('dsync.deactivated', directory);
await this.eventController.notify('dsync.deactivated', updatedDirectory);
}
}
return { data: directory, error: null };
return { data: updatedDirectory, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get the configuration by tenant and product
public async getByTenantAndProduct(
tenant: string,
product: string
): Promise<{ data: Directory[] | null; error: ApiError | null }> {
public async getByTenantAndProduct(tenant: string, product: string): Promise<Response<Directory[]>> {
try {
if (!tenant || !product) {
throw new JacksonError('Missing required parameters.', 400);
@ -204,12 +256,12 @@ export class DirectoryConfig {
}
}
// Get all configurations
public async getAll({ pageOffset, pageLimit, pageToken }: PaginationParams = {}): Promise<{
data: Directory[] | null;
pageToken?: string;
error: ApiError | null;
}> {
// Get directory connections with pagination
public async getAll(
params: PaginationParams = {}
): Promise<Response<Directory[]> & { pageToken?: string }> {
const { pageOffset, pageLimit, pageToken } = params;
try {
const { data: directories, pageToken: nextPageToken } = await this.store().getAll(
pageOffset,
@ -232,7 +284,7 @@ export class DirectoryConfig {
}
// Delete a configuration by id
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
public async delete(id: string): Promise<Response<null>> {
try {
if (!id) {
throw new JacksonError('Missing required parameter.', 400);
@ -267,12 +319,15 @@ export class DirectoryConfig {
}
private transform(directory: Directory): Directory {
// Add the flag to ensure SCIM compliance when using Azure AD
if (directory.type === 'azure-scim-v2') {
directory.scim.path = `${directory.scim.path}/?aadOptscim062020`;
}
if (directory.scim.path) {
// Add the flag to ensure SCIM compliance when using Azure AD
if (directory.type === 'azure-scim-v2') {
directory.scim.path = `${directory.scim.path}/?aadOptscim062020`;
}
directory.scim.endpoint = `${this.opts.externalUrl}${directory.scim.path}`;
// Construct the SCIM endpoint
directory.scim.endpoint = `${this.opts.externalUrl}${directory.scim.path}`;
}
if (!('deactivated' in directory)) {
directory.deactivated = false;
@ -280,4 +335,47 @@ export class DirectoryConfig {
return directory;
}
// Filter connections by product or provider
public async filterBy(
params: FilterByParams = {}
): Promise<Response<Directory[]> & { pageToken?: string }> {
const { product, provider, pageOffset, pageLimit, pageToken } = params;
let index: Index | null = null;
if (product) {
// Filter by product
index = {
name: IndexNames.Product,
value: product,
};
} else if (provider) {
// Filter by provider
index = {
name: storeNamespacePrefix.dsync.providers,
value: provider,
};
}
try {
if (!index) {
throw new JacksonError('Please provider a product or provider.', 400);
}
const { data: directories, pageToken: nextPageToken } = await this.store().getByIndex(
index,
pageOffset,
pageLimit,
pageToken
);
return {
data: directories.map((directory) => this.transform(directory)),
pageToken: nextPageToken,
error: null,
};
} catch (err: any) {
return apiError(err);
}
}
}

View File

@ -10,37 +10,36 @@ import type {
IUsers,
IGroups,
GroupPatchOperation,
} from '../typings';
} from '../../typings';
import { parseGroupOperation } from './utils';
import { sendEvent } from './events';
interface DirectoryGroupsParams {
directories: IDirectoryConfig;
users: IUsers;
groups: IGroups;
}
export class DirectoryGroups {
private directories: IDirectoryConfig;
private users: IUsers;
private groups: IGroups;
private callback: EventCallback | undefined;
constructor({
directories,
users,
groups,
}: {
directories: IDirectoryConfig;
users: IUsers;
groups: IGroups;
}) {
constructor({ directories, users, groups }: DirectoryGroupsParams) {
this.directories = directories;
this.users = users;
this.groups = groups;
}
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
const { displayName } = body as { displayName: string };
const { displayName, groupId } = body as { displayName: string; groupId?: string };
const { data: group } = await this.groups.create({
directoryId: directory.id,
name: displayName,
raw: { ...body, members: [] },
id: groupId,
raw: 'rawAttributes' in body ? body.rawAttributes : { ...body, members: [] },
});
await sendEvent('group.created', { directory, group }, this.callback);
@ -81,7 +80,7 @@ export class DirectoryGroups {
groups = data;
} else {
// Fetch all the existing group
const { data } = await this.groups.getAll({ pageOffset: undefined, pageLimit: undefined });
const { data } = await this.groups.getAll({ directoryId, pageOffset: undefined, pageLimit: undefined });
groups = data;
}
@ -136,12 +135,7 @@ export class DirectoryGroups {
}
public async update(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse> {
const { displayName } = body;
// Update group name
const updatedGroup = await this.updateDisplayName(directory, group, {
displayName,
});
const updatedGroup = await this.updateDisplayName(directory, group, body);
return {
status: 200,
@ -167,14 +161,9 @@ export class DirectoryGroups {
// Update group displayName
public async updateDisplayName(directory: Directory, group: Group, body: any): Promise<Group> {
const { displayName } = body;
const { data: updatedGroup, error } = await this.groups.update(group.id, {
name: displayName,
raw: {
...group.raw,
...body,
},
name: body.displayName,
raw: 'rawAttributes' in body ? body.rawAttributes : { ...group.raw, ...body },
});
if (error || !updatedGroup) {

View File

@ -8,30 +8,36 @@ import type {
IDirectoryConfig,
IUsers,
UserPatchOperation,
} from '../typings';
} from '../../typings';
import { parseUserPatchRequest, extractStandardUserAttributes, updateRawUserAttributes } from './utils';
import { sendEvent } from './events';
interface DirectoryUsersParams {
directories: IDirectoryConfig;
users: IUsers;
}
export class DirectoryUsers {
private directories: IDirectoryConfig;
private users: IUsers;
private callback: EventCallback | undefined;
constructor({ directories, users }: { directories: IDirectoryConfig; users: IUsers }) {
constructor({ directories, users }: DirectoryUsersParams) {
this.directories = directories;
this.users = users;
}
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
const { first_name, last_name, email, active } = extractStandardUserAttributes(body);
const { id, first_name, last_name, email, active } = extractStandardUserAttributes(body);
const { data: user } = await this.users.create({
directoryId: directory.id,
id,
first_name,
last_name,
email,
active,
raw: body,
raw: 'rawAttributes' in body ? body.rawAttributes : body,
});
await sendEvent('user.created', { directory, user }, this.callback);
@ -57,7 +63,7 @@ export class DirectoryUsers {
last_name: name.familyName,
email: emails[0].value,
active,
raw: body,
raw: 'rawAttributes' in body ? body.rawAttributes : body,
});
await sendEvent('user.updated', { directory, user: updatedUser }, this.callback);

View File

@ -1,6 +1,8 @@
import type { Group, DatabaseStore, ApiError, PaginationParams, GroupMembership } from '../typings';
import * as dbutils from '../db/utils';
import { apiError, JacksonError } from '../controller/error';
import { randomUUID } from 'crypto';
import type { Group, DatabaseStore, PaginationParams, Response, GroupMembership } from '../../typings';
import * as dbutils from '../../db/utils';
import { apiError, JacksonError } from '../../controller/error';
import { Base } from './Base';
const indexNames = {
@ -8,32 +10,33 @@ const indexNames = {
directoryId: 'directoryId',
};
interface CreateGroupParams {
directoryId: string;
name: string;
raw: any;
id?: string;
}
export class Groups extends Base {
constructor({ db }: { db: DatabaseStore }) {
super({ db });
}
// Create a new group
public async create({
directoryId,
name,
raw,
}: {
directoryId: string;
name: string;
raw: any;
}): Promise<{ data: Group | null; error: ApiError | null }> {
public async create(params: CreateGroupParams): Promise<Response<Group>> {
const { directoryId, name, raw, id: groupId } = params;
const id = groupId || randomUUID();
raw['id'] = id;
const group: Group = {
id,
name,
raw,
};
try {
const id = this.createId();
raw['id'] = id;
const group: Group = {
id,
name,
raw,
};
await this.store('groups').put(
id,
group,
@ -54,7 +57,7 @@ export class Groups extends Base {
}
// Get a group by id
public async get(id: string): Promise<{ data: Group | null; error: ApiError | null }> {
public async get(id: string): Promise<Response<Group>> {
try {
const group = await this.store('groups').get(id);
@ -75,16 +78,16 @@ export class Groups extends Base {
name: string;
raw: any;
}
): Promise<{ data: Group | null; error: ApiError | null }> {
): Promise<Response<Group>> {
const { name, raw } = param;
const group: Group = {
id,
name,
raw,
};
try {
const { name, raw } = param;
const group: Group = {
id,
name,
raw,
};
await this.store('groups').put(id, group);
return { data: group, error: null };
@ -94,7 +97,7 @@ export class Groups extends Base {
}
// Delete a group by id
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
public async delete(id: string): Promise<Response<null>> {
try {
const { data, error } = await this.get(id);
@ -111,20 +114,6 @@ export class Groups extends Base {
}
}
// Get all users in a group
public async getAllUsers(groupId: string): Promise<{ user_id: string }[]> {
const { data: users } = await this.store('members').getByIndex({
name: 'groupId',
value: groupId,
});
if (users.length === 0) {
return [];
}
return users;
}
// Add a user to a group
public async addUserToGroup(groupId: string, userId: string) {
const id = dbutils.keyDigest(dbutils.keyFromParts(groupId, userId));
@ -158,10 +147,7 @@ export class Groups extends Base {
}
// Search groups by displayName
public async search(
displayName: string,
directoryId: string
): Promise<{ data: Group[] | null; error: ApiError | null }> {
public async search(displayName: string, directoryId: string): Promise<Response<Group[]>> {
try {
const { data: groups } = await this.store('groups').getByIndex({
name: indexNames.directoryIdDisplayname,
@ -179,10 +165,7 @@ export class Groups extends Base {
params: PaginationParams & {
directoryId?: string;
}
): Promise<{
data: Group[] | null;
error: ApiError | null;
}> {
): Promise<Response<Group[]>> {
const { pageOffset, pageLimit, directoryId } = params;
try {
@ -206,6 +189,32 @@ export class Groups extends Base {
}
}
/**
* Get members of a group paginated
* @param groupId
* @returns
*/
public async getGroupMembers(
parmas: { groupId: string } & PaginationParams
): Promise<Response<GroupMembership['user_id'][]>> {
const { groupId, pageOffset, pageLimit } = parmas;
try {
const { data: members } = await this.store('members').getByIndex(
{
name: 'groupId',
value: groupId,
},
pageOffset,
pageLimit
);
return { data: members, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Delete all groups from a directory
async deleteAll(directoryId: string) {
const index = {

View File

@ -1,21 +1,24 @@
import type { User, DatabaseStore, ApiError, PaginationParams } from '../typings';
import { apiError, JacksonError } from '../controller/error';
import { Base } from './Base';
import { keyFromParts } from '../db/utils';
import { randomUUID } from 'crypto';
type CreateUserPayload = {
import type { User, DatabaseStore, PaginationParams, Response } from '../../typings';
import { apiError, JacksonError } from '../../controller/error';
import { Base } from './Base';
import { keyFromParts } from '../../db/utils';
const indexNames = {
directoryIdUsername: 'directoryIdUsername',
directoryId: 'directoryId',
};
interface CreateUserParams {
directoryId: string;
first_name: string;
last_name: string;
email: string;
active: boolean;
raw: any;
};
const indexNames = {
directoryIdUsername: 'directoryIdUsername',
directoryId: 'directoryId',
};
id?: string;
}
export class Users extends Base {
constructor({ db }: { db: DatabaseStore }) {
@ -23,28 +26,23 @@ export class Users extends Base {
}
// Create a new user
public async create({
directoryId,
first_name,
last_name,
email,
active,
raw,
}: CreateUserPayload): Promise<{ data: User | null; error: ApiError | null }> {
public async create(params: CreateUserParams): Promise<Response<User>> {
const { directoryId, first_name, last_name, email, active, raw, id: userId } = params;
const id = userId || randomUUID();
raw['id'] = id;
const user = {
id,
first_name,
last_name,
email,
active,
raw,
};
try {
const id = this.createId();
raw['id'] = id;
const user = {
id,
first_name,
last_name,
email,
active,
raw,
};
await this.store('users').put(
id,
user,
@ -65,7 +63,7 @@ export class Users extends Base {
}
// Get a user by id
public async get(id: string): Promise<{ data: User | null; error: ApiError | null }> {
public async get(id: string): Promise<Response<User>> {
try {
const user = await this.store('users').get(id);
@ -89,21 +87,21 @@ export class Users extends Base {
active: boolean;
raw: object;
}
): Promise<{ data: User | null; error: ApiError | null }> {
): Promise<Response<User>> {
const { first_name, last_name, email, active, raw } = param;
raw['id'] = id;
const user = {
id,
first_name,
last_name,
email,
active,
raw,
};
try {
const { first_name, last_name, email, active, raw } = param;
raw['id'] = id;
const user = {
id,
first_name,
last_name,
email,
active,
raw,
};
await this.store('users').put(id, user);
return { data: user, error: null };
@ -113,7 +111,7 @@ export class Users extends Base {
}
// Delete a user by id
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
public async delete(id: string): Promise<Response<null>> {
try {
const { data, error } = await this.get(id);
@ -130,17 +128,12 @@ export class Users extends Base {
}
// Search users by userName
public async search(
userName: string,
directoryId: string
): Promise<{ data: User[] | null; error: ApiError | null }> {
public async search(userName: string, directoryId: string): Promise<Response<User[]>> {
try {
const users = (
await this.store('users').getByIndex({
name: indexNames.directoryIdUsername,
value: keyFromParts(directoryId, userName),
})
).data;
const { data: users } = await this.store('users').getByIndex({
name: indexNames.directoryIdUsername,
value: keyFromParts(directoryId, userName),
});
return { data: users, error: null };
} catch (err: any) {
@ -155,10 +148,7 @@ export class Users extends Base {
directoryId,
}: PaginationParams & {
directoryId?: string;
} = {}): Promise<{
data: User[] | null;
error: ApiError | null;
}> {
} = {}): Promise<Response<User[]>> {
try {
let users: User[] = [];
@ -185,7 +175,7 @@ export class Users extends Base {
}
// Delete all users from a directory
async deleteAll(directoryId: string): Promise<void> {
async deleteAll(directoryId: string) {
const index = {
name: indexNames.directoryId,
value: directoryId,

View File

@ -1,10 +1,12 @@
import { randomUUID } from 'crypto';
import type {
Directory,
DatabaseStore,
WebhookEventLog,
DirectorySyncEvent,
PaginationParams,
} from '../typings';
} from '../../typings';
import { Base } from './Base';
type GetAllParams = PaginationParams & {
@ -17,7 +19,7 @@ export class WebhookEventsLogger extends Base {
}
public async log(directory: Directory, event: DirectorySyncEvent, status: number) {
const id = this.createId();
const id = randomUUID();
const log: WebhookEventLog = {
...event,
@ -40,6 +42,7 @@ export class WebhookEventsLogger extends Base {
return await this.store('logs').get(id);
}
// Get the event logs for a directory paginated
public async getAll(params: GetAllParams = {}) {
const { pageOffset, pageLimit, directoryId } = params;

View File

@ -7,23 +7,27 @@ import type {
DirectorySyncEvent,
IWebhookEventsLogger,
IDirectoryConfig,
} from '../typings';
import { sendPayloadToWebhook } from '../event/webhook';
} from '../../typings';
import { sendPayloadToWebhook } from '../../event/webhook';
import { transformEventPayload } from './transform';
import { isConnectionActive } from '../controller/utils';
import { isConnectionActive } from '../../controller/utils';
type Payload = { directory: Directory; group?: Group | null; user?: User | null };
export const sendEvent = async (
event: DirectorySyncEventType,
payload: { directory: Directory; group?: Group | null; user?: User | null },
payload: Payload,
callback?: EventCallback
) => {
if (!isConnectionActive(payload.directory)) {
return;
}
const eventTransformed = transformEventPayload(event, payload);
if (!callback) {
return;
}
return callback ? await callback(eventTransformed) : Promise.resolve();
await callback(transformEventPayload(event, payload));
};
export const handleEventCallback = async (

View File

@ -1,4 +1,4 @@
import { Directory, DirectorySyncEvent, DirectorySyncEventType, Group, User } from '../typings';
import { Directory, DirectorySyncEvent, DirectorySyncEventType, Group, User } from '../../typings';
export const transformUser = (user: User): User => {
return {

View File

@ -1,8 +1,9 @@
import type { User } from '../typings';
import { DirectorySyncProviders, UserPatchOperation, GroupPatchOperation } from '../typings';
import lodash from 'lodash';
const parseGroupOperation = (operation: GroupPatchOperation) => {
import { DirectorySyncProviders } from '../../typings';
import type { DirectoryType, User, UserPatchOperation, GroupPatchOperation } from '../../typings';
export const parseGroupOperation = (operation: GroupPatchOperation) => {
const { op, path, value } = operation;
if (path === 'members') {
@ -45,7 +46,7 @@ const parseGroupOperation = (operation: GroupPatchOperation) => {
// List of directory sync providers
// TODO: Fix the return type
const getDirectorySyncProviders = (): { [K: string]: string } => {
export const getDirectorySyncProviders = (): { [K: string]: string } => {
return Object.entries(DirectorySyncProviders).reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
@ -53,7 +54,7 @@ const getDirectorySyncProviders = (): { [K: string]: string } => {
};
// Parse the PATCH request body and return the user attributes (both standard and custom)
const parseUserPatchRequest = (operation: UserPatchOperation) => {
export const parseUserPatchRequest = (operation: UserPatchOperation) => {
const { value, path } = operation;
const attributes: Partial<User> = {};
@ -95,12 +96,13 @@ const parseUserPatchRequest = (operation: UserPatchOperation) => {
};
// Extract standard attributes from the user body
const extractStandardUserAttributes = (body: any) => {
const { name, emails, userName, active } = body as {
export const extractStandardUserAttributes = (body: any) => {
const { name, emails, userName, active, userId } = body as {
name?: { givenName: string; familyName: string };
emails?: { value: string }[];
userName: string;
active: boolean;
userId?: string;
};
return {
@ -108,11 +110,12 @@ const extractStandardUserAttributes = (body: any) => {
last_name: name && 'familyName' in name ? name.familyName : '',
email: emails && emails.length > 0 ? emails[0].value : userName,
active: active || true,
id: userId, // For non-SCIM providers, the id will exist in the body
};
};
// Update raw user attributes
const updateRawUserAttributes = (raw, attributes) => {
export const updateRawUserAttributes = (raw, attributes) => {
const keys = Object.keys(attributes);
if (keys.length === 0) {
@ -126,10 +129,6 @@ const updateRawUserAttributes = (raw, attributes) => {
return raw;
};
export {
parseGroupOperation,
getDirectorySyncProviders,
parseUserPatchRequest,
extractStandardUserAttributes,
updateRawUserAttributes,
export const isSCIMEnabledProvider = (type: DirectoryType) => {
return type !== 'google';
};

View File

@ -1,10 +1,12 @@
import directorySync from '.';
import { DirectoryConfig } from './DirectoryConfig';
import { DirectoryGroups } from './DirectoryGroups';
import { DirectoryUsers } from './DirectoryUsers';
import { Users } from './Users';
import { Groups } from './Groups';
import { WebhookEventsLogger } from './WebhookEventsLogger';
import { DirectoryConfig } from './scim/DirectoryConfig';
import { DirectoryGroups } from './scim/DirectoryGroups';
import { DirectoryUsers } from './scim/DirectoryUsers';
import { Users } from './scim/Users';
import { Groups } from './scim/Groups';
import { WebhookEventsLogger } from './scim/WebhookEventsLogger';
import { ApiError } from '../typings';
import { RequestHandler } from './request';
export type IDirectorySyncController = Awaited<ReturnType<typeof directorySync>>;
export type IDirectoryConfig = InstanceType<typeof DirectoryConfig>;
@ -13,6 +15,7 @@ export type IDirectoryUsers = InstanceType<typeof DirectoryUsers>;
export type IUsers = InstanceType<typeof Users>;
export type IGroups = InstanceType<typeof Groups>;
export type IWebhookEventsLogger = InstanceType<typeof WebhookEventsLogger>;
export type IRequestHandler = InstanceType<typeof RequestHandler>;
export type DirectorySyncEventType =
| 'user.created'
@ -29,7 +32,8 @@ export enum DirectorySyncProviders {
'onelogin-scim-v2' = 'OneLogin SCIM v2.0',
'okta-scim-v2' = 'Okta SCIM v2.0',
'jumpcloud-scim-v2' = 'JumpCloud v2.0',
'generic-scim-v2' = 'SCIM Generic v2.0',
'generic-scim-v2' = 'Generic SCIM v2.0',
'google' = 'Google',
}
export type DirectoryType = keyof typeof DirectorySyncProviders;
@ -51,6 +55,9 @@ export type Directory = {
secret: string;
};
deactivated?: boolean;
google_domain?: string;
google_access_token?: string;
google_refresh_token?: string;
};
export type DirectorySyncGroupMember = { value: string; email?: string };
@ -60,10 +67,6 @@ export type DirectorySyncResponse = {
data?: any;
};
export interface DirectorySyncRequestHandler {
handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse>;
}
export interface Events {
handle(event: DirectorySyncEvent): Promise<void>;
}
@ -92,10 +95,6 @@ export interface DirectorySyncEvent {
product: string;
}
export interface EventCallback {
(event: DirectorySyncEvent): Promise<void>;
}
export interface WebhookEventLog extends DirectorySyncEvent {
id: string;
webhook_endpoint: string;
@ -119,12 +118,18 @@ export type Group = {
raw?: any;
};
export type GroupMember = {
id: string;
raw?: any;
};
export type UserWithGroup = User & { group: Group };
export type PaginationParams = {
pageOffset?: number;
pageLimit?: number;
pageToken?: string;
hasNextPage?: boolean;
};
export type UserPatchOperation = {
@ -164,3 +169,51 @@ export type GroupMembership = {
group_id: string;
user_id: string;
};
export type Response<T> = { data: T; error: null } | { data: null; error: ApiError };
export type EventCallback = (event: DirectorySyncEvent) => Promise<void>;
export interface IDirectoryProvider {
/**
* Fields to exclude from the user payload while comparing the user to find if it is updated
*/
userFieldsToExcludeWhenCompare?: string[];
/**
* Fields to exclude from the group payload while comparing the group to find if it is updated
*/
groupFieldsToExcludeWhenCompare?: string[];
/**
* Get all directories for the provider
*/
getDirectories(): Promise<Directory[]>;
/**
* Get all users for a directory
* @param directory
* @param options
*/
getUsers(
directory: Directory,
options: PaginationParams | null
): Promise<{ data: User[]; metadata: PaginationParams | null }>;
/**
* Get all groups for a directory
* @param directory
* @param options
*/
getGroups(
directory: Directory,
options: PaginationParams | null
): Promise<{ data: Group[]; metadata: PaginationParams | null }>;
/**
* Get all members of a group
* @param directory
* @param group
*/
getGroupMembers(directory: Directory, group: Group): Promise<GroupMember[]>;
}

View File

@ -160,6 +160,12 @@ export interface IConnectionAPIController {
* @deprecated Use `deleteConnections` instead.
*/
deleteConfig(body: DelConfigQuery): Promise<void>;
getConnectionsByProduct(body: {
product: string;
pageOffset?: number;
pageLimit?: number;
pageToken?: string;
}): Promise<{ data: (SAMLSSORecord | OIDCSSORecord)[]; pageToken?: string }>;
}
export interface IOAuthController {
@ -410,6 +416,15 @@ export interface JacksonOption {
adminToken?: string;
};
webhook?: Webhook;
dsync?: {
providers: {
google: {
clientId: string;
clientSecret: string;
callbackUrl: string;
};
};
};
}
export interface SLORequestParams {

View File

@ -187,7 +187,7 @@ tap.test('directories.', async (t) => {
});
t.test('get a directory by tenant and product', async (t) => {
const { data: directoriesFetched } = await directorySync.directories.getByTenantAndProduct(
const { data: directoriesFetched, error } = await directorySync.directories.getByTenantAndProduct(
tenant,
product
);
@ -231,7 +231,6 @@ tap.test('directories.', async (t) => {
secret: 'secret',
},
log_webhook_events: true,
type: 'azure-scim-v2' as DirectoryType,
};
const { data: updatedDirectory } = await directorySync.directories.update(directory.id, {
@ -242,7 +241,6 @@ tap.test('directories.', async (t) => {
t.same(directory.id, updatedDirectory?.id);
t.same(updatedDirectory?.name, toUpdate.name);
t.same(updatedDirectory?.log_webhook_events, toUpdate.log_webhook_events);
t.same(updatedDirectory?.type, toUpdate.type);
t.match(updatedDirectory?.webhook.endpoint, toUpdate.webhook?.endpoint);
t.match(updatedDirectory?.webhook.secret, toUpdate.webhook?.secret);
@ -253,7 +251,6 @@ tap.test('directories.', async (t) => {
t.same(directoryFetched?.id, updatedDirectory?.id);
t.same(directoryFetched?.name, toUpdate.name);
t.same(directoryFetched?.log_webhook_events, toUpdate.log_webhook_events);
t.same(directoryFetched?.type, toUpdate.type);
t.match(directoryFetched?.webhook.endpoint, toUpdate.webhook?.endpoint);
t.match(directoryFetched?.webhook.secret, toUpdate.webhook?.secret);
});
@ -333,4 +330,103 @@ tap.test('directories.', async (t) => {
t.match(directoryFetched2?.deactivated, false);
t.match(isConnectionActive(directoryFetched2!), true);
});
t.test('Fetch the non-scim directories by directory provider', async (t) => {
// Create a google directory
const { data: firstDirectory } = await directorySync.directories.create({
...directoryPayload,
name: 'Google Directory 1',
type: 'google' as DirectoryType,
});
// Create an Okta directory
await directorySync.directories.create({
...directoryPayload,
name: 'Okta Directory 1',
type: 'okta-scim-v2' as DirectoryType,
});
// Create another Google directory
const { data: secondDirectory } = await directorySync.directories.create({
...directoryPayload,
name: 'Google Directory 2',
type: 'google' as DirectoryType,
});
// Fetch all the directories of type google
const { data: googleDirectoryFetched } = await directorySync.directories.filterBy({
provider: 'google',
});
t.ok(googleDirectoryFetched);
t.match(googleDirectoryFetched?.length, 2);
t.match(googleDirectoryFetched?.[1].id, firstDirectory?.id);
t.match(googleDirectoryFetched?.[0].id, secondDirectory?.id);
});
t.test('The SCIM endpoint should be empty for non-scim provider', async (t) => {
const { data: directoryCreated } = await directorySync.directories.create({
...directoryPayload,
tenant: 'acme',
type: 'google' as DirectoryType,
});
t.ok(directoryCreated);
t.match(directoryCreated?.scim.path, '');
t.match(directoryCreated?.scim.secret, '');
});
t.test('Should be able to update the Google credentials', async (t) => {
const { data: directory } = await directorySync.directories.create({
...directoryPayload,
tenant: 'acme',
type: 'google' as DirectoryType,
});
if (!directory) {
t.fail("Couldn't create a directory");
return;
}
const { data: directoryUpdated } = await directorySync.directories.update(directory.id, {
google_access_token: 'access_token',
google_refresh_token: 'refresh_token',
google_domain: 'acme.com',
});
t.ok(directoryUpdated);
t.match(directoryUpdated?.google_access_token, 'access_token');
t.match(directoryUpdated?.google_refresh_token, 'refresh_token');
t.match(directoryUpdated?.google_domain, 'acme.com');
// Check that the directory was updated
const { data: directoryFetched } = await directorySync.directories.get(directory.id);
t.ok(directoryFetched);
t.match(directoryFetched?.google_access_token, 'access_token');
t.match(directoryFetched?.google_refresh_token, 'refresh_token');
t.match(directoryFetched?.google_domain, 'acme.com');
});
t.test('Fetch all connections for a product', async (t) => {
await directorySync.directories.create({
...directoryPayload,
tenant: 'first-tenant',
product: 'a-new-product',
});
const { data: directories, error } = await directorySync.directories.filterBy({
product: 'a-new-product',
});
if (error) {
t.fail(error.message);
return;
}
t.ok(directories);
t.match(directories.length, 1);
t.match(directories[0].tenant, 'first-tenant');
t.match(directories[0].product, 'a-new-product');
});
});

View File

@ -0,0 +1,399 @@
import tap from 'tap';
import nock from 'nock';
import type { DirectorySyncEvent } from '@boxyhq/saml-jackson';
import { jacksonOptions } from '../utils';
import { IDirectorySyncController, DirectoryType, Directory } from '../../src/typings';
let directorySyncController: IDirectorySyncController;
const directoryPayload = {
tenant: 'boxyhq',
product: 'saml-jackson-google',
name: 'Google Directory',
type: 'google' as DirectoryType,
google_domain: 'boxyhq.com',
google_access_token: 'access_token',
google_refresh_token: 'refresh_token',
};
const fakeGoogleDirectory = {
users: [
{
id: 'elizasmith',
primaryEmail: 'eliza@example.com',
name: {
givenName: 'Eliza',
familyName: 'Smith',
},
suspended: false,
password: 'password',
hashFunction: 'SHA-1',
changePasswordAtNextLogin: false,
ipWhitelisted: false,
etag: 'abcd1234',
},
{
id: 'johndoe',
primaryEmail: 'john@example.com',
name: {
givenName: 'John',
familyName: 'Doe',
},
suspended: false,
password: 'password',
hashFunction: 'SHA-1',
changePasswordAtNextLogin: false,
ipWhitelisted: false,
etag: 'efgh5678',
},
],
groups: [
{
id: 'engineering',
email: 'engineering@example.com',
name: 'Engineering Team',
description: 'A group for the engineering department',
adminCreated: true,
directMembersCount: '10',
kind: 'admin#directory#group',
etag: 'abcd1234',
aliases: ['eng-team@example.com', 'engineering@example.com'],
nonEditableAliases: ['group123@example.com'],
},
{
id: 'sales',
email: 'sales@example.com',
name: 'Sales Team',
description: 'A group for the sales department',
adminCreated: true,
directMembersCount: '5',
kind: 'admin#directory#group',
etag: 'efgh5678',
aliases: ['sales@example.com'],
nonEditableAliases: ['sales-group456@example.com'],
},
],
members: {
engineering: [
{
kind: 'directory#member',
id: 'elizasmith',
email: 'eliza@example.com',
role: 'MANAGER',
type: 'USER',
},
{
kind: 'directory#member',
id: 'johndoe',
email: 'johndoe@example.com',
role: 'MANAGER',
type: 'USER',
},
],
sales: [
{
kind: 'directory#member',
id: 'elizasmith',
email: 'eliza@example.com',
role: 'MANAGER',
type: 'USER',
},
],
marketing: [
{
kind: 'directory#member',
id: 'jackson',
email: 'jackson@example.com',
role: 'MANAGER',
type: 'USER',
},
],
},
};
// Mock /admin/directory/v1/users
const mockUsersAPI = (users: any[]) => {
nock('https://admin.googleapis.com')
.get('/admin/directory/v1/users')
.query({
maxResults: 200,
domain: 'boxyhq.com',
})
.reply(200, { users });
};
// Mock /admin/directory/v1/groups
const mockGroupsAPI = (groups: any[]) => {
nock('https://admin.googleapis.com')
.get('/admin/directory/v1/groups')
.query({
maxResults: 200,
domain: 'boxyhq.com',
})
.times(2)
.reply(200, { groups });
};
// Mock /admin/directory/v1/groups/{groupKey}/members
const mockGroupMembersAPI = (groupKey: string, members: any[]) => {
nock('https://admin.googleapis.com')
.get(`/admin/directory/v1/groups/${groupKey}/members`)
.query({
maxResults: 200,
domain: 'boxyhq.com',
})
.reply(200, { members });
};
tap.before(async () => {
directorySyncController = (await (await import('../../src/index')).default(jacksonOptions))
.directorySyncController;
await directorySyncController.directories.create(directoryPayload);
});
tap.teardown(async () => {
process.exit(0);
});
tap.test('Sync 1', async (t) => {
const events: DirectorySyncEvent[] = [];
// Mock necessary API calls
mockUsersAPI(fakeGoogleDirectory.users);
mockGroupsAPI(fakeGoogleDirectory.groups);
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
mockGroupMembersAPI('sales', fakeGoogleDirectory.members.sales);
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
events.push(event);
});
nock.cleanAll();
t.strictSame(events.length, 7);
t.strictSame(events[0].event, 'user.created');
t.strictSame(events[0].data.id, fakeGoogleDirectory.users[0].id);
t.strictSame(events[0].data.raw, fakeGoogleDirectory.users[0]);
t.strictSame(events[1].event, 'user.created');
t.strictSame(events[1].data.id, fakeGoogleDirectory.users[1].id);
t.strictSame(events[1].data.raw, fakeGoogleDirectory.users[1]);
t.strictSame(events[2].event, 'group.created');
t.strictSame(events[2].data.id, fakeGoogleDirectory.groups[0].id);
t.strictSame(events[2].data.raw, fakeGoogleDirectory.groups[0]);
t.strictSame(events[3].event, 'group.created');
t.strictSame(events[3].data.id, fakeGoogleDirectory.groups[1].id);
t.strictSame(events[3].data.raw, fakeGoogleDirectory.groups[1]);
t.strictSame(events[4].event, 'group.user_added');
t.strictSame(events[4].data.id, fakeGoogleDirectory.users[0].id);
t.strictSame(events[4].data.raw, fakeGoogleDirectory.users[0]);
// Check that the user was added to the group
if ('group' in events[4].data) {
t.strictSame(events[4].data.group.id, fakeGoogleDirectory.groups[0].id);
}
t.strictSame(events[5].event, 'group.user_added');
t.strictSame(events[5].data.id, fakeGoogleDirectory.users[1].id);
t.strictSame(events[5].data.raw, fakeGoogleDirectory.users[1]);
// Check that the user was added to the group
if ('group' in events[5].data) {
t.strictSame(events[5].data.group.id, fakeGoogleDirectory.groups[0].id);
}
t.strictSame(events[6].event, 'group.user_added');
t.strictSame(events[6].data.id, fakeGoogleDirectory.users[0].id);
t.strictSame(events[6].data.raw, fakeGoogleDirectory.users[0]);
// Check that the user was added to the group
if ('group' in events[6].data) {
t.strictSame(events[6].data.group.id, fakeGoogleDirectory.groups[1].id);
}
t.end();
});
tap.test('Sync 2', async (t) => {
const events: DirectorySyncEvent[] = [];
// Update user
fakeGoogleDirectory.users[0].name.givenName = 'Eliza Updated';
// Update group
fakeGoogleDirectory.groups[0].name = 'Engineering Updated';
mockUsersAPI(fakeGoogleDirectory.users);
mockGroupsAPI(fakeGoogleDirectory.groups);
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
mockGroupMembersAPI('sales', fakeGoogleDirectory.members.sales);
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
events.push(event);
});
nock.cleanAll();
t.strictSame(events.length, 2);
t.strictSame(events[0].event, 'user.updated');
t.strictSame(events[0].data.id, fakeGoogleDirectory.users[0].id);
t.strictSame(events[0].data.raw, fakeGoogleDirectory.users[0]);
t.strictSame(events[1].event, 'group.updated');
t.strictSame(events[1].data.id, fakeGoogleDirectory.groups[0].id);
t.strictSame(events[1].data.raw, fakeGoogleDirectory.groups[0]);
t.end();
});
tap.test('Sync 3', async (t) => {
const events: DirectorySyncEvent[] = [];
// Delete the last user
const deleteUser = fakeGoogleDirectory.users.pop();
// Delete the last group
const deleteGroup = fakeGoogleDirectory.groups.pop();
// Clear the members
fakeGoogleDirectory.members.sales = [];
mockUsersAPI(fakeGoogleDirectory.users);
mockGroupsAPI(fakeGoogleDirectory.groups);
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
events.push(event);
});
nock.cleanAll();
t.strictSame(events.length, 2);
t.strictSame(events[0].event, 'user.deleted');
t.strictSame(events[0].data.id, deleteUser?.id);
t.strictSame(events[0].data.raw, deleteUser);
t.strictSame(events[1].event, 'group.deleted');
t.strictSame(events[1].data.id, deleteGroup?.id);
t.strictSame(events[1].data.raw, deleteGroup);
t.end();
});
tap.test('Sync 4', async (t) => {
const events: DirectorySyncEvent[] = [];
// Add new user
const newUser = {
...fakeGoogleDirectory.users[0],
id: 'jackson',
primaryEmail: 'jackson@example.com',
name: {
givenName: 'Jackson',
familyName: 'Smith',
},
};
// Add new group
const newGroup = {
...fakeGoogleDirectory.groups[0],
id: 'marketing',
email: 'marketing@example.com',
name: 'Marketing Team',
description: 'A group for the marketing department',
};
fakeGoogleDirectory.users.push(newUser);
fakeGoogleDirectory.groups.push(newGroup);
mockUsersAPI(fakeGoogleDirectory.users);
mockGroupsAPI(fakeGoogleDirectory.groups);
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
mockGroupMembersAPI('marketing', fakeGoogleDirectory.members.marketing);
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
events.push(event);
});
nock.cleanAll();
t.strictSame(events.length, 3);
t.strictSame(events[0].event, 'user.created');
t.strictSame(events[0].data.id, newUser.id);
t.strictSame(events[0].data.raw, newUser);
t.strictSame(events[1].event, 'group.created');
t.strictSame(events[1].data.id, newGroup.id);
t.strictSame(events[1].data.raw, newGroup);
t.strictSame(events[2].event, 'group.user_added');
t.strictSame(events[2].data.id, newUser.id);
t.strictSame(events[2].data.raw, newUser);
// Check that the user was added to the group
if ('group' in events[2].data) {
t.strictSame(events[2].data.group.id, newGroup.id);
}
t.end();
});
tap.test('Sync 5', async (t) => {
const events: DirectorySyncEvent[] = [];
// Remove elizasmith from the engineering group
fakeGoogleDirectory.members.engineering.shift();
// Add elizasmith to the marketing group
fakeGoogleDirectory.members.marketing.push({
kind: 'directory#member',
id: 'elizasmith',
email: 'eliza@example.com',
role: 'MANAGER',
type: 'USER',
});
mockUsersAPI(fakeGoogleDirectory.users);
mockGroupsAPI(fakeGoogleDirectory.groups);
mockGroupMembersAPI('engineering', fakeGoogleDirectory.members.engineering);
mockGroupMembersAPI('marketing', fakeGoogleDirectory.members.marketing);
await directorySyncController.sync(async (event: DirectorySyncEvent) => {
events.push(event);
});
nock.cleanAll();
t.strictSame(events.length, 2);
t.strictSame(events[0].event, 'group.user_removed');
t.strictSame(events[0].data.id, fakeGoogleDirectory.users[0].id);
t.strictSame(events[0].data.raw, fakeGoogleDirectory.users[0]);
// Check that the user was removed from the group
if ('group' in events[0].data) {
t.strictSame(events[0].data.group.id, fakeGoogleDirectory.groups[0].id);
}
t.strictSame(events[1].event, 'group.user_added');
t.strictSame(events[1].data.id, fakeGoogleDirectory.users[0].id);
t.strictSame(events[1].data.raw, fakeGoogleDirectory.users[0]);
// Check that the user was added to the group
if ('group' in events[1].data) {
t.strictSame(events[1].data.group.id, fakeGoogleDirectory.groups[1].id);
}
t.end();
});

View File

@ -0,0 +1,91 @@
import tap from 'tap';
import { jacksonOptions } from '../utils';
import { IDirectorySyncController, DirectoryType, Directory } from '../../src/typings';
let directory: Directory;
let directorySyncController: IDirectorySyncController;
const directoryPayload = {
tenant: 'boxyhq',
product: 'saml-jackson-google',
name: 'Google Directory',
type: 'google' as DirectoryType,
google_domain: 'boxyhq.com',
google_access_token: 'access_token',
google_refresh_token: 'refresh_token',
};
tap.before(async () => {
directorySyncController = (await (await import('../../src/index')).default(jacksonOptions))
.directorySyncController;
const { data, error } = await directorySyncController.directories.create(directoryPayload);
if (error) {
throw error;
}
directory = data;
});
tap.teardown(async () => {
process.exit(0);
});
tap.test('generate the Google API authorization URL', async (t) => {
const result = await directorySyncController.google.generateAuthorizationUrl({
directoryId: directory.id,
});
t.ok(result);
t.strictSame(result.error, null);
const parsedUrl = new URL(result.data?.authorizationUrl || '');
t.strictSame(parsedUrl.origin, 'https://accounts.google.com');
t.strictSame(parsedUrl.pathname, '/o/oauth2/v2/auth');
t.strictSame(
{
access_type: parsedUrl.searchParams.get('access_type'),
prompt: parsedUrl.searchParams.get('prompt'),
response_type: parsedUrl.searchParams.get('response_type'),
client_id: parsedUrl.searchParams.get('client_id'),
redirect_uri: parsedUrl.searchParams.get('redirect_uri'),
scope: parsedUrl.searchParams.get('scope'),
state: parsedUrl.searchParams.get('state'),
},
{
access_type: 'offline',
prompt: 'consent',
response_type: 'code',
client_id: 'GOOGLE_CLIENT_ID',
redirect_uri: `GOOGLE_REDIRECT_URI`,
state: JSON.stringify({ directoryId: directory.id }),
scope:
'https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.group.member.readonly',
}
);
t.end();
});
tap.test('set access token and refresh token', async (t) => {
const { data: updatedDirectory } = await directorySyncController.google.setToken({
directoryId: directory.id,
accessToken: 'ACCESS_TOKEN',
refreshToken: 'REFRESH_TOKEN',
});
t.ok(updatedDirectory);
t.strictSame(updatedDirectory?.google_access_token, 'ACCESS_TOKEN');
t.strictSame(updatedDirectory?.google_refresh_token, 'REFRESH_TOKEN');
const { data: directoryFetched } = await directorySyncController.directories.get(directory.id);
t.ok(directoryFetched);
t.strictSame(directoryFetched?.google_access_token, 'ACCESS_TOKEN');
t.strictSame(directoryFetched?.google_refresh_token, 'REFRESH_TOKEN');
t.end();
});

View File

@ -101,7 +101,7 @@ tap.test('Webhook Events / ', async (t) => {
// Create a user
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
const events = await directorySync.webhookLogs.getAll({});
const events = await directorySync.webhookLogs.getAll();
t.equal(events.length, 0);
@ -115,7 +115,7 @@ tap.test('Webhook Events / ', async (t) => {
// Create a user
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
const logs = await directorySync.webhookLogs.getAll({});
const logs = await directorySync.webhookLogs.getAll();
const log = await directorySync.webhookLogs.get(logs[0].id);
@ -148,7 +148,7 @@ tap.test('Webhook Events / ', async (t) => {
mock.verify();
mock.restore();
const logs = await directorySync.webhookLogs.getAll({});
const logs = await directorySync.webhookLogs.getAll();
t.ok(logs);
t.equal(logs.length, 3);
@ -194,7 +194,7 @@ tap.test('Webhook Events / ', async (t) => {
mock.verify();
mock.restore();
const logs = await directorySync.webhookLogs.getAll({});
const logs = await directorySync.webhookLogs.getAll();
t.ok(logs);
t.equal(logs.length, 3);
@ -249,7 +249,7 @@ tap.test('Webhook Events / ', async (t) => {
mock.verify();
mock.restore();
const logs = await directorySync.webhookLogs.getAll({});
const logs = await directorySync.webhookLogs.getAll();
t.ok(logs);
t.equal(logs.length, 4);

View File

@ -6,6 +6,7 @@ import * as dbutils from '../../src/db/utils';
import controllers from '../../src/index';
import loadConnection from '../../src/loadConnection';
import {
IAdminController,
IConnectionAPIController,
SAMLSSOConnection,
SAMLSSOConnectionWithEncodedMetadata,
@ -657,4 +658,19 @@ tap.test('controller/api', async (t) => {
clientSecret,
});
});
t.test('Should be able to fetch connections by product', async (t) => {
const connectionCreated = await connectionAPIController.createSAMLConnection(
saml_connection as SAMLSSOConnectionWithEncodedMetadata
);
const connections = await connectionAPIController.getConnectionsByProduct({
product: connectionCreated.product,
});
t.ok(connections.data.length === 1);
t.ok(connections.data[0].product, connectionCreated.product);
t.end();
});
});

View File

@ -36,6 +36,15 @@ const jacksonOptions = <JacksonOption>{
},
boxyhqLicenseKey: 'dummy-license',
noAnalytics: true,
dsync: {
providers: {
google: {
clientId: 'GOOGLE_CLIENT_ID',
clientSecret: 'GOOGLE_CLIENT_SECRET',
callbackUrl: 'GOOGLE_REDIRECT_URI',
},
},
},
};
export { addSSOConnections, jacksonOptions };

23363
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "jackson",
"version": "1.9.11",
"version": "1.10.1",
"private": true,
"description": "SAML 2.0 service",
"keywords": [
@ -43,24 +43,24 @@
"prebuild": "mkdirp public/terminus && (cp node_modules/blockly/media/sprites.png public/terminus || copy .\\node_modules\\blockly\\media\\sprites.png .\\public\\terminus)"
},
"dependencies": {
"@boxyhq/metrics": "0.2.2",
"@boxyhq/react-ui": "2.0.0",
"@boxyhq/metrics": "0.2.4",
"@boxyhq/react-ui": "3.0.0",
"@boxyhq/saml-jackson": "file:npm",
"@heroicons/react": "2.0.18",
"@retracedhq/logs-viewer": "2.4.6",
"@retracedhq/retraced": "0.6.3",
"@retracedhq/logs-viewer": "2.5.0",
"@retracedhq/retraced": "0.7.0",
"@tailwindcss/typography": "0.5.9",
"axios": "1.4.0",
"blockly": "9.3.3",
"classnames": "2.3.2",
"cors": "2.8.5",
"daisyui": "2.52.0",
"i18next": "22.5.0",
"i18next": "22.5.1",
"micromatch": "4.0.5",
"next": "13.2.4",
"next": "13.4.7",
"next-auth": "4.22.1",
"next-i18next": "13.2.2",
"nodemailer": "6.9.1",
"next-i18next": "13.3.0",
"nodemailer": "6.9.3",
"raw-body": "2.5.2",
"react": "18.2.0",
"react-daisyui": "3.1.2",
@ -68,25 +68,25 @@
"react-i18next": "12.3.1",
"react-syntax-highlighter": "15.5.0",
"sharp": "0.32.1",
"swr": "2.1.5"
"swr": "2.2.0"
},
"devDependencies": {
"@apidevtools/swagger-cli": "4.0.4",
"@playwright/test": "1.34.3",
"@playwright/test": "1.35.1",
"@types/cors": "2.8.13",
"@types/micromatch": "4.0.2",
"@types/node": "20.1.4",
"@types/react": "18.2.8",
"@types/node": "20.3.1",
"@types/react": "18.2.12",
"@typescript-eslint/eslint-plugin": "5.59.7",
"@typescript-eslint/parser": "5.59.8",
"@typescript-eslint/parser": "5.60.1",
"autoprefixer": "10.4.14",
"cross-env": "7.0.3",
"env-cmd": "10.1.0",
"eslint": "8.41.0",
"eslint-config-next": "13.4.4",
"eslint": "8.43.0",
"eslint-config-next": "13.4.7",
"eslint-config-prettier": "8.8.0",
"mkdirp": "3.0.1",
"postcss": "8.4.23",
"postcss": "8.4.24",
"prettier": "2.8.8",
"prettier-plugin-tailwindcss": "0.3.0",
"swagger-jsdoc": "6.2.8",

View File

@ -41,7 +41,7 @@ const Login = ({
return;
}
const onSSOSubmit = async (ssoIdentifier: string) => {
const onSSOSubmit = async ({ ssoIdentifier }) => {
await signIn('boxyhq-saml', undefined, { client_id: ssoIdentifier });
};

View File

@ -124,7 +124,7 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { connectionAPIController } = await jackson();
const { clientID, clientSecret } = req.body as {
const { clientID, clientSecret } = req.query as {
clientID: string;
clientSecret: string;
};

View File

@ -35,7 +35,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
}
const event = await directorySyncController.webhookLogs
.with(directory.tenant, directory.product)
.setTenantAndProduct(directory.tenant, directory.product)
.get(eventId);
return res.status(200).json({ data: event });

View File

@ -40,11 +40,13 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const pageOffset = parseInt(offset);
const pageLimit = parseInt(limit);
const events = await directorySyncController.webhookLogs.with(directory.tenant, directory.product).getAll({
pageOffset,
pageLimit,
directoryId,
});
const events = await directorySyncController.webhookLogs
.setTenantAndProduct(directory.tenant, directory.product)
.getAll({
pageOffset,
pageLimit,
directoryId,
});
return res.status(200).json({ data: events });
};
@ -65,7 +67,9 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(404).json({ error: { message: 'Directory not found.' } });
}
await directorySyncController.webhookLogs.with(directory.tenant, directory.product).deleteAll(directoryId);
await directorySyncController.webhookLogs
.setTenantAndProduct(directory.tenant, directory.product)
.deleteAll(directoryId);
return res.status(200).json({ data: null });
};

View File

@ -27,7 +27,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
}
const { data: group, error } = await directorySyncController.groups
.with(directory.tenant, directory.product)
.setTenantAndProduct(directory.tenant, directory.product)
.get(groupId);
if (error) {

View File

@ -30,7 +30,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const pageLimit = parseInt(limit);
const { data: groups, error } = await directorySyncController.groups
.with(directory.tenant, directory.product)
.setTenantAndProduct(directory.tenant, directory.product)
.getAll({ pageOffset, pageLimit, directoryId });
if (error) {

View File

@ -27,7 +27,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
}
const { data: user, error } = await directorySyncController.users
.with(directory.tenant, directory.product)
.setTenantAndProduct(directory.tenant, directory.product)
.get(userId);
if (error) {

View File

@ -30,7 +30,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const pageLimit = parseInt(limit);
const { data: users, error } = await directorySyncController.users
.with(directory.tenant, directory.product)
.setTenantAndProduct(directory.tenant, directory.product)
.getAll({ pageOffset, pageLimit, directoryId });
if (error) {

View File

@ -20,7 +20,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { name, tenant, product, type, webhook_url, webhook_secret } = req.body;
const { name, tenant, product, type, webhook_url, webhook_secret, google_domain } = req.body;
const { data, error } = await directorySyncController.directories.create({
name,
@ -29,6 +29,7 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
type: type as DirectoryType,
webhook_url,
webhook_secret,
google_domain,
});
if (data) {

24
pages/api/scim/cron.ts Normal file
View File

@ -0,0 +1,24 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { validateApiKey } from '@lib/auth';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { apiKey } = req.query as { apiKey: string };
try {
if (validateApiKey(apiKey) === false) {
throw new Error('Please provide a valid Jackson API key');
}
const { directorySyncController } = await jackson();
await directorySyncController.sync(directorySyncController.events.callback);
res.status(200).json({ message: 'Sync completed' });
} catch (e: any) {
res.status(500).json({ message: e.message || 'Sync failed' });
}
};
export default handler;

View File

@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
if (method !== 'GET') {
return res
.setHeader('Allow', 'GET')
.status(405)
.json({ error: { message: `Method ${method} Not Allowed` } });
}
const { directoryId } = req.query as { directoryId: string };
try {
const { directorySyncController } = await jackson();
const { data, error } = await directorySyncController.google.generateAuthorizationUrl({
directoryId,
});
if (error) {
throw error;
}
return res.redirect(data.authorizationUrl).end();
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({ error: { message } });
}
};
export default handler;

View File

@ -0,0 +1,53 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
if (method !== 'GET') {
return res
.setHeader('Allow', 'GET')
.status(405)
.json({ error: { message: `Method ${method} Not Allowed` } });
}
const { code, state } = req.query as { code: string; state: string };
try {
const { directoryId } = JSON.parse(state);
if (!directoryId) {
throw new Error('Directory ID not found in state.');
}
const { directorySyncController } = await jackson();
// Fetch the access token and refresh token from the authorization code
const tokenResponse = await directorySyncController.google.getAccessToken({
directoryId,
code,
});
if (tokenResponse.error) {
throw tokenResponse.error;
}
// Set the access token and refresh token for the directory
const response = await directorySyncController.google.setToken({
directoryId,
accessToken: tokenResponse.data.access_token,
refreshToken: tokenResponse.data.refresh_token,
});
if (response.error) {
throw response.error;
}
return res.send('Authorized done successfully. You may close this window.');
} catch (error: any) {
return res.status(500).send({ error });
}
};
export default handler;

View File

@ -68,7 +68,7 @@ const handleDELETE = async (req: NextApiRequest, res: NextApiResponse, setupLink
const { connectionAPIController } = await jackson();
const body = {
...req.body,
...req.query,
tenant: setupLink.tenant,
product: setupLink.product,
};

View File

@ -1,7 +1,7 @@
import jackson from '@lib/jackson';
import { oidcMetadataParse, strategyChecker } from '@lib/utils';
import { NextApiRequest, NextApiResponse } from 'next';
import type { GetConnectionsQuery } from '@boxyhq/saml-jackson';
import type { DelConnectionsQuery, GetConnectionsQuery } from '@boxyhq/saml-jackson';
import { sendAudit } from '@ee/audit-log/lib/retraced';
import { extractAuthToken, redactApiKey } from '@lib/auth';
@ -94,7 +94,7 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { connectionAPIController } = await jackson();
await connectionAPIController.deleteConnections(req.body);
await connectionAPIController.deleteConnections(req.query as DelConnectionsQuery);
sendAudit({
action: 'sso.connection.delete',

View File

@ -0,0 +1,41 @@
import jackson from '@lib/jackson';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
try {
switch (method) {
case 'GET':
return await handleGET(req, res);
default:
res.setHeader('Allow', 'GET');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({ error: { message } });
}
}
// Get the connections filtered by the product
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { connectionAPIController } = await jackson();
const { product, pageOffset, pageLimit, pageToken } = req.query as {
product: string;
pageOffset: string;
pageLimit: string;
pageToken?: string;
};
const connections = await connectionAPIController.getConnectionsByProduct({
product,
pageOffset: parseInt(pageOffset),
pageLimit: parseInt(pageLimit),
pageToken,
});
return res.status(200).json(connections.data);
};

View File

@ -0,0 +1,45 @@
import jackson from '@lib/jackson';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
try {
switch (method) {
case 'GET':
return await handleGET(req, res);
default:
res.setHeader('Allow', 'GET');
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({ error: { message } });
}
}
// Get the connections filtered by the product
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { product, pageOffset, pageLimit, pageToken } = req.query as {
product: string;
pageOffset: string;
pageLimit: string;
pageToken?: string;
};
if (!product) {
throw new Error('Please provide a product');
}
const connections = await directorySyncController.directories.filterBy({
product,
pageOffset: parseInt(pageOffset),
pageLimit: parseInt(pageLimit),
pageToken,
});
return res.status(200).json(connections);
};

View File

@ -2,7 +2,7 @@
import jackson from '@lib/jackson';
import { NextApiRequest, NextApiResponse } from 'next';
import type { GetConfigQuery } from '@boxyhq/saml-jackson';
import type { DelConnectionsQuery, GetConfigQuery } from '@boxyhq/saml-jackson';
import { sendAudit } from '@ee/audit-log/lib/retraced';
import { extractAuthToken, redactApiKey } from '@lib/auth';
@ -38,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(204).end(connection);
} else if (req.method === 'DELETE') {
const connection = await connectionAPIController.deleteConfig(req.body);
const connection = await connectionAPIController.deleteConfig(req.query as DelConnectionsQuery);
sendAudit({
action: 'sso.connection.delete',

View File

@ -85,7 +85,7 @@ const IdpSelector = ({ connections }: { connections: (OIDCSSORecord | SAMLSSORec
onClick={() => {
connectionSelected(connection.clientID);
}}>
<div className='flex items-center gap-2 py-3 px-3'>
<div className='flex items-center gap-2 px-3 py-3'>
<div className='placeholder avatar'>
<div className='w-8 rounded-full bg-primary text-white'>
<span className='text-lg font-bold'>{name.charAt(0).toUpperCase()}</span>
@ -134,7 +134,7 @@ const AppSelector = ({
{connections.map((connection) => {
return (
<li key={connection.clientID} className='rounded bg-gray-100'>
<div className='flex items-center gap-2 py-3 px-3'>
<div className='flex items-center gap-2 px-3 py-3'>
<input
type='radio'
name='idp_hint'
@ -175,14 +175,12 @@ export const getServerSideProps = async ({ query, locale, req }) => {
if (idp_hint) {
const params = new URLSearchParams(paramsToRelay).toString();
const destinations = {
saml: `/api/federated-saml/sso?${params}`,
oauth: `/api/oauth/authorize?${params}`,
};
const destination =
authFlow === 'saml' ? `/api/federated-saml/sso?${params}` : `/api/oauth/authorize?${params}`;
return {
redirect: {
destination: destinations[authFlow],
destination,
permanent: false,
},
};

View File

@ -1,7 +1,7 @@
{
"info": {
"title": "SAML Jackson API",
"version": "1.9.7",
"version": "1.10.1",
"description": "This is the API documentation for SAML Jackson service.",
"termsOfService": "https://boxyhq.com/terms.html",
"contact": {
@ -444,10 +444,6 @@
"tags": [
"Connections"
],
"consumes": [
"application/x-www-form-urlencoded",
"application/json"
],
"responses": {
"200": {
"description": "Success"
@ -461,6 +457,31 @@
}
}
},
"/api/v1/connections/product": {
"get": {
"summary": "Get SSO Connections by product",
"parameters": [
{
"$ref": "#/parameters/productParamGet"
}
],
"operationId": "get-connections-by-product",
"tags": [
"Connections"
],
"responses": {
"200": {
"$ref": "#/responses/200Get"
},
"400": {
"$ref": "#/responses/400Get"
},
"401": {
"$ref": "#/responses/401Get"
}
}
}
},
"/oauth/token": {
"post": {
"summary": "Code exchange",
@ -685,7 +706,7 @@
}
},
"400Get": {
"description": "Please provide `clientID` or `tenant` and `product`."
"description": "Please provide a `product`."
},
"401Get": {
"description": "Unauthorized"
@ -878,7 +899,8 @@
"in": "query",
"name": "product",
"type": "string",
"description": "Product"
"description": "Product",
"required": true
},
"clientIDParamGet": {
"in": "query",
@ -894,31 +916,31 @@
},
"clientIDDel": {
"name": "clientID",
"in": "formData",
"in": "query",
"type": "string",
"description": "Client ID"
},
"clientSecretDel": {
"name": "clientSecret",
"in": "formData",
"in": "query",
"type": "string",
"description": "Client Secret"
},
"tenantDel": {
"name": "tenant",
"in": "formData",
"in": "query",
"type": "string",
"description": "Tenant"
},
"productDel": {
"name": "product",
"in": "formData",
"in": "query",
"type": "string",
"description": "Product"
},
"strategyDel": {
"name": "strategy",
"in": "formData",
"in": "query",
"type": "string",
"description": "Strategy which can help to filter connections with tenant/product query"
}

View File

@ -1,4 +1,4 @@
ARG NODEJS_IMAGE=node:18.15.0-alpine3.17
ARG NODEJS_IMAGE=node:18.16.1-alpine3.18
FROM --platform=$BUILDPLATFORM $NODEJS_IMAGE AS base
# Install dependencies only when needed