Directory Sync (#202)

* SCIM Config API - / POST

* SCIM wip

* Add SCIM Webhook

* Send webhoo event, and add signature

* SCIM Group wip

* wip

* SCIM wip

* User store wip

* wip

* wip

* SCIM - Groups management

* Add the params validation

* Cleanup

* Create user API, return the created user

* Replace the nanoid with crypto
    .randomBytes

* Improve the transform methods

* Fix the events APIs

* Fix

* Wip - Testing with OneLogin SCIM

* wip

* Make changes to SCIM APIs

* wip

* Add the method createRandomSecret

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* refactor wip

* refactor wip

* wip

* Users finished

* Group finished

* Group fix

* Fix the types

* Fix the types

* wip webhook events

* Fix the config API

* wip

* wip

* wip

* wip

* Improve the methods

* wip

* wip

* wip webhook

* Refactor the code

* Add some comments

* Fix the API

* wip SCIM

* Fix the pk

* Return the all the groups

* Fix

* Improve the code

* Final changes

* wip APIs

* Rename variables

* Rename the classes

* Fix the APIs

* wip

* Admin UI - wip

* Add SCIM config screen

* Admin UI wip

* Admin UI wip

* Admin UI wip

* Fix the Admin UI

* Add tabs

* Add tabs

* Add user screens

* Add EmptyState

* Add users, groups info screen

* Add JSON syntax highlighter

* Fix the config details screen

* Add authentication to the APIs

* wip

* Add types

* Add webhook event logs

* Add type to directory

* Display the event log details

* Fix the missing arg

* Ability to configure the logging enable/disable

* Display alert if webhook logging is disabled

* Fix the SCIM

* Applied prettier

* Search users by userName

* Fix the section width

* Add pagination for /users /groups in admin UI

* Add pagination for directory listing

* Fix the issues with list()

* Add APIs

* Add Next.js middleware for authentication

* Fix the TS issue

* Add pagination for SCIM /users

* Add pagination for SCIM /users

* Moved the tests into sub folders

* Add unit tests for directories, users

* wip

* wip - unit tests

* wip - unit tests

* Some improvments

* wip

* Finished the SCIM unit tests

* Some fixes

* Fixes

* Rename methods

* Fix the TS

* Many fixes

* Fixes

* Fixes

* SCIM Fixes

* SCIM updates

* Fix the unit tests

* Fix the unit tests

* Fix the unit tests

* Improve the unit tests

* A fix

* File renamed as per JS standard

* Fix

* Updates

* Fix the SCIM APIs

* Fix the tests

* Added the Base class

* Some fixes

* Some fixes

* Some fixes

* Fix the events

* Renamed to directorySyncController for consistency

* Moved the createId to Base class

* Moved the createId to Base class

* Remove the Next.js middleware and add authentication to each routes

* Change the text

* Merged

* Revert the changes

* Improved the response of the SDK and APIs

* Fix the return value

* Azure related changes

* Add the middleware back

* Infer the types from getServerSideProps

* givenName and familyName can be empty depends on the mapping

* Fix the issue with update

* API changes

* Fixes

* Fix the types

* Revert the change

* Improving the Webhooks and Callback

* Added the event callback and changed the implementation for Webhook

* Fix the SCIM API

* Fix the events.ts file

* wip

* Cleanup and improve the request handler

* Revert the package.json changes

* Make the directory name optional.

* Add a generic scim provider to the type

* wip

* Remove supabase UI

* Update package-lock.json

* Update the UI with DaisyUI

* UI fixes

* Final changes to the UI

* Standardize the Input theme

Co-authored-by: Kiran <kiran@Kirans-MacBook-Pro.local>
Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2022-09-08 20:06:18 +05:30 committed by GitHub
parent 29f532bd3a
commit 461a820b6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 11338 additions and 3319 deletions

14
components/Badge.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from 'react';
interface Props {
vairant?: 'info' | 'success' | 'warning' | 'error';
children: React.ReactNode;
}
const Badge = (props: Props) => {
const { vairant = 'info', children } = props;
return <div className={`badge gap-2 badge-${vairant}`}>{children}</div>;
};
export default Badge;

View File

@ -1,9 +1,10 @@
import Link from 'next/link';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
const EmptyState = ({ title, href }: { title: string; href?: string }) => {
const EmptyState = ({ title, href, className }: { title: string; href?: string; className?: string }) => {
return (
<div className='flex flex-col items-center justify-center space-y-3 rounded border py-32'>
<div
className={`my-3 flex flex-col items-center justify-center space-y-3 rounded border py-32 ${className}`}>
<InformationCircleIcon className='h-10 w-10' />
<h4 className='text-center'>{title}</h4>
{href && (

60
components/Paginate.tsx Normal file
View File

@ -0,0 +1,60 @@
import Link from 'next/link';
const Paginate = ({
pageOffset,
pageLimit,
itemsCount,
path,
}: {
pageOffset: number;
pageLimit: number;
itemsCount: number;
path: string;
}) => {
if ((itemsCount === 0 && pageOffset === 0) || (itemsCount < pageLimit && pageOffset === 0)) {
return null;
}
const nextPageUrl = itemsCount === pageLimit ? `${path}offset=${pageOffset + pageLimit}` : '#';
const previousPageUrl = pageOffset > 0 ? `${path}offset=${pageOffset - pageLimit}` : '#';
return (
<div className='flex justify-center py-3 px-3'>
<Link href={previousPageUrl}>
<a className='mr-3 inline-flex items-center rounded-lg border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'>
<svg
className='mr-2 h-5 w-5'
fill='currentColor'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'>
<path
fillRule='evenodd'
d='M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z'
clipRule='evenodd'
/>
</svg>
Previous
</a>
</Link>
<Link href={nextPageUrl}>
<a className='inline-flex items-center rounded-lg border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'>
Next
<svg
className='ml-2 h-5 w-5'
fill='currentColor'
viewBox='0 0 20 20'
xmlns='http://www.w3.org/2000/svg'>
<path
fillRule='evenodd'
d='M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z'
clipRule='evenodd'
/>
</svg>
</a>
</Link>
</div>
);
};
export default Paginate;

View File

@ -1,22 +1,31 @@
import { ShieldCheckIcon } from '@heroicons/react/20/solid';
import { ShieldCheckIcon, UsersIcon } from '@heroicons/react/20/solid';
import Image from 'next/image';
import Link from 'next/link';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import Logo from '../public/logo.png';
const menus = [
{
href: '/admin/saml/config',
text: 'SAML Connections',
icon: ShieldCheckIcon,
current: false,
},
];
export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
const { isOpen, setIsOpen } = props;
const { asPath } = useRouter();
const menus = [
{
href: '/admin/saml/config',
text: 'SAML Connections',
icon: ShieldCheckIcon,
active: asPath.includes('/admin/saml'),
},
{
href: '/admin/directory-sync',
text: 'Directory Sync',
icon: UsersIcon,
active: asPath.includes('/admin/directory-sync'),
},
];
return (
<>
<div
@ -94,7 +103,10 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
<a
key={menu.text}
href={menu.href}
className='group flex items-center rounded-md bg-gray-100 px-2 py-2 text-sm font-medium text-gray-900'>
className={classNames(
'group flex items-center rounded-md px-2 py-2 text-sm text-gray-900',
menu.active ? 'bg-gray-100 font-bold' : 'font-medium'
)}>
<menu.icon className='mr-4 h-6 w-6 flex-shrink-0' aria-hidden='true' />
<div>{menu.text}</div>
</a>

View File

@ -0,0 +1,52 @@
import Link from 'next/link';
import type { Directory } from '@lib/jackson';
import classNames from 'classnames';
const DirectoryTab = (props: { directory: Directory; activeTab: string }) => {
const { directory, activeTab } = props;
const menus = [
{
name: 'Directory',
href: `/admin/directory-sync/${directory.id}`,
active: activeTab === 'directory',
},
{
name: 'Users',
href: `/admin/directory-sync/${directory.id}/users`,
active: activeTab === 'users',
},
{
name: 'Groups',
href: `/admin/directory-sync/${directory.id}/groups`,
active: activeTab === 'groups',
},
{
name: 'Webhook Events',
href: `/admin/directory-sync/${directory.id}/events`,
active: activeTab === 'events',
},
];
return (
<nav className='-mb-px flex space-x-5 border-b' aria-label='Tabs'>
{menus.map((menu) => {
return (
<Link href={menu.href} key={menu.href}>
<a
className={classNames(
'inline-flex items-center border-b-2 py-4 text-sm font-medium',
menu.active
? 'border-gray-700 text-gray-700'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
)}>
{menu.name}
</a>
</Link>
);
})}
</nav>
);
};
export default DirectoryTab;

View File

@ -195,7 +195,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
{isEditView ? 'Edit Connection' : 'Create Connection'}
</h2>
<form onSubmit={saveSAMLConfiguration}>
<div className='min-w-[28rem] rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
{fieldCatalog
.filter(({ attributes: { showOnlyInEditView } }) => (isEditView ? true : !showOnlyInEditView))
.map(
@ -242,7 +242,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
readOnly={readOnly}
maxLength={maxLength}
onChange={handleChange}
className={`block w-full rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500 ${
className={`textarea textarea-bordered h-24 w-full ${
isArray ? 'whitespace-pre' : ''
}`}
rows={rows}
@ -257,7 +257,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
readOnly={readOnly}
maxLength={maxLength}
onChange={handleChange}
className='block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500'
className='input input-bordered w-full'
/>
)}
</div>

23
lib/auth.ts Normal file
View File

@ -0,0 +1,23 @@
import env from '@lib/env';
export const validateApiKey = (token: string | null) => {
if (!token) {
return false;
}
return env.apiKeys.includes(token);
};
export const extractAuthToken = (req): string | null => {
let authHeader = '';
if (typeof req.headers.get === 'function') {
authHeader = req.headers.get('authorization') || '';
} else {
authHeader = req.headers.authorization || '';
}
const parts = authHeader.split(' ');
return parts.length > 1 ? parts[1] : null;
};

7
lib/inferSSRProps.ts Normal file
View File

@ -0,0 +1,7 @@
type GetSSRResult<TProps> = { props: TProps } | { redirect: any } | { notFound: boolean };
type GetSSRFn<TProps> = (args: any) => Promise<GetSSRResult<TProps>>;
export type inferSSRProps<TFn extends GetSSRFn<any>> = TFn extends GetSSRFn<infer TProps>
? NonNullable<TProps>
: never;

View File

@ -1,13 +1,23 @@
import jackson, {
import type {
IAdminController,
IAPIController,
IdPConfig,
ILogoutController,
IOAuthController,
IHealthCheckController,
DirectorySync,
DirectoryType,
Directory,
User,
Group,
DirectorySyncEvent,
HTTPMethod,
DirectorySyncRequest,
IOidcDiscoveryController,
ISPSAMLConfig,
} from '@boxyhq/saml-jackson';
import jackson from '@boxyhq/saml-jackson';
import env from '@lib/env';
import '@lib/metrics';
@ -16,6 +26,7 @@ let oauthController: IOAuthController;
let adminController: IAdminController;
let logoutController: ILogoutController;
let healthCheckController: IHealthCheckController;
let directorySyncController: DirectorySync;
let oidcDiscoveryController: IOidcDiscoveryController;
let spConfig: ISPSAMLConfig;
@ -28,6 +39,7 @@ export default async function init() {
!g.adminController ||
!g.healthCheckController ||
!g.logoutController ||
!g.directorySync ||
!g.oidcDiscoveryController ||
!g.spConfig
) {
@ -37,6 +49,7 @@ export default async function init() {
adminController = ret.adminController;
logoutController = ret.logoutController;
healthCheckController = ret.healthCheckController;
directorySyncController = ret.directorySync;
oidcDiscoveryController = ret.oidcDiscoveryController;
spConfig = ret.spConfig;
@ -45,6 +58,7 @@ export default async function init() {
g.adminController = adminController;
g.logoutController = logoutController;
g.healthCheckController = healthCheckController;
g.directorySync = directorySyncController;
g.oidcDiscoveryController = oidcDiscoveryController;
g.spConfig = spConfig;
g.isJacksonReady = true;
@ -54,6 +68,7 @@ export default async function init() {
adminController = g.adminController;
logoutController = g.logoutController;
healthCheckController = g.healthCheckController;
directorySyncController = g.directorySync;
oidcDiscoveryController = g.oidcDiscoveryController;
spConfig = g.spConfig;
}
@ -65,8 +80,18 @@ export default async function init() {
adminController,
logoutController,
healthCheckController,
directorySyncController,
oidcDiscoveryController,
};
}
export type { IdPConfig };
export type {
IdPConfig,
DirectoryType,
Directory,
User,
Group,
DirectorySyncEvent,
HTTPMethod,
DirectorySyncRequest,
};

View File

@ -1,21 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import env from '@lib/env';
import type { NextApiRequest, NextApiResponse } from 'next';
import micromatch from 'micromatch';
export const validateApiKey = (token) => {
return env.apiKeys.includes(token);
};
export const extractAuthToken = (req: NextApiRequest) => {
const authHeader = req.headers['authorization'];
const parts = (authHeader || '').split(' ');
if (parts.length > 1) {
return parts[1];
}
return null;
};
export const validateEmailWithACL = (email) => {
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
const acl = NEXTAUTH_ACL?.split(',');
@ -39,3 +24,17 @@ export const setErrorCookie = (res: NextApiResponse, value: unknown, options: {
}
res.setHeader('Set-Cookie', cookieContents);
};
const IsJsonString = (body: any): boolean => {
try {
const json = JSON.parse(body);
return typeof json === 'object';
} catch (e) {
return false;
}
};
export const bodyParser = (req: NextApiRequest): any => {
return IsJsonString(req.body) ? JSON.parse(req.body) : req.body;
};

17
middleware.ts Normal file
View File

@ -0,0 +1,17 @@
// eslint-disable-next-line
import type { NextRequest } from 'next/server';
// eslint-disable-next-line
import { NextResponse } from 'next/server';
import { validateApiKey, extractAuthToken } from '@lib/auth';
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (pathname.startsWith('/api/v1')) {
if (!validateApiKey(extractAuthToken(req))) {
return NextResponse.rewrite(new URL('/api/v1/unauthenticated', req.nextUrl));
}
}
return NextResponse.next();
}

View File

@ -1,8 +1,21 @@
const map = {
'test/api.test.ts': ['src/controller/api.ts'],
'test/oauth.test.ts': ['src/controller/oauth.ts', 'src/controller/oauth/*', 'src/controller/utils.ts'],
'test/logout.test.ts': ['src/controller/logout.ts', 'src/controller/utils.ts'],
'test/db.test.ts': ['src/db/*'],
'test/saml/api.test.ts': ['src/controller/api.ts'],
'test/saml/oauth.test.ts': ['src/controller/oauth.ts', 'src/controller/oauth/*', 'src/controller/utils.ts'],
'test/saml/logout.test.ts': ['src/controller/logout.ts', 'src/controller/utils.ts'],
'test/db/db.test.ts': ['src/db/*'],
'test/dsync/directories.test.ts': ['src/directory-sync/DirectoryConfig.ts'],
'test/dsync/users.test.ts': [
'src/directory-sync/DirectoryUsers.ts',
'src/directory-sync/Users.ts',
'src/directory-sync/request.ts',
],
'test/dsync/groups.test.ts': [
'src/directory-sync/DirectoryGroups.ts',
'src/directory-sync/Groups.ts',
'src/directory-sync/request.ts',
],
'test/dsync/events.test.ts': ['src/directory-sync/events.ts'],
};
module.exports = (testFile) => {

2528
npm/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,7 @@
"@opentelemetry/api": "1.0.4",
"@opentelemetry/api-metrics": "0.27.0",
"@peculiar/webcrypto": "1.4.0",
"axios": "^0.27.2",
"@peculiar/x509": "1.8.3",
"jose": "4.9.2",
"marked": "4.1.0",
@ -56,6 +57,7 @@
"xmlbuilder": "15.1.1"
},
"devDependencies": {
"@faker-js/faker": "7.2.0",
"@types/node": "18.7.16",
"@types/sinon": "10.0.13",
"@types/tap": "15.0.7",

View File

@ -1,3 +1,5 @@
import { ApiError } from '../typings';
export class JacksonError extends Error {
public name: string;
public statusCode: number;
@ -11,3 +13,9 @@ export class JacksonError extends Error {
Error.captureStackTrace(this, this.constructor);
}
}
export const apiError = (err: any) => {
const { message, statusCode = 500 } = err;
return { data: null, error: { message, code: statusCode } as ApiError };
};

View File

@ -1,6 +1,7 @@
import type { OAuthErrorHandlerParams } from '../typings';
import { JacksonError } from './error';
import * as redirect from './oauth/redirect';
import crypto from 'crypto';
import * as jose from 'jose';
export enum IndexNames {
@ -8,6 +9,20 @@ export enum IndexNames {
TenantProduct = 'tenantProduct',
}
// The namespace prefix for the database store
export const storeNamespacePrefix = {
dsync: {
config: 'dsync:config',
logs: 'dsync:logs',
users: 'dsync:users',
groups: 'dsync:groups',
members: 'dsync:members',
},
saml: {
config: 'saml:config',
},
};
export const relayStatePrefix = 'boxyhq_jackson_';
export const validateAbsoluteUrl = (url, message) => {
@ -35,6 +50,15 @@ export function getErrorMessage(error: unknown) {
return String(error);
}
export const createRandomSecret = async (length: number) => {
return crypto
.randomBytes(length)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};
export async function loadJWSPrivateKey(key: string, alg: string): Promise<jose.KeyLike> {
const pkcs8 = Buffer.from(key, 'base64').toString('ascii');
const privateKey = await jose.importPKCS8(pkcs8, alg);

View File

@ -53,26 +53,37 @@ class Mem implements DatabaseDriver {
async getAll(namespace: string, pageOffset: number, pageLimit: number): Promise<unknown[]> {
const offsetAndLimitValueCheck = !dbutils.isNumeric(pageOffset) && !dbutils.isNumeric(pageLimit);
let take = Number(offsetAndLimitValueCheck ? this.options.pageLimit : pageLimit);
const skip = Number(offsetAndLimitValueCheck ? 0 : pageOffset);
let count = 0;
take += skip;
const returnValue: string[] = [];
const skip = Number(offsetAndLimitValueCheck ? 0 : pageOffset);
let take = Number(offsetAndLimitValueCheck ? this.options.pageLimit : pageLimit);
let count = 0;
take += skip;
if (namespace) {
const val: string[] = Array.from(
this.indexes[dbutils.keyFromParts(dbutils.createdAtPrefix, namespace)]
);
const index = dbutils.keyFromParts(dbutils.createdAtPrefix, namespace);
if (this.indexes[index] === undefined) {
return [];
}
const val: string[] = Array.from(this.indexes[index]);
const iterator: IterableIterator<string> = val.reverse().values();
for (const value of iterator) {
if (count >= take) {
break;
}
if (count >= skip) {
returnValue.push(this.store[dbutils.keyFromParts(namespace, value)]);
}
count++;
}
}
return returnValue || [];
}

View File

@ -0,0 +1,48 @@
import type { Storable, DatabaseStore } from '../typings';
import { storeNamespacePrefix } from '../controller/utils';
import { v4 as uuidv4 } from 'uuid';
export class Base {
protected db: DatabaseStore;
protected tenant: null | string = null;
protected product: null | string = null;
constructor({ db }: { db: DatabaseStore }) {
this.db = db;
}
// Return the database store
store(type: 'groups' | 'members' | 'users' | 'logs'): Storable {
if (!this.tenant || !this.product) {
throw new Error('Set tenant and product before using store.');
}
return this.db.store(`${storeNamespacePrefix.dsync[type]}:${this.tenant}:${this.product}`);
}
setTenant(tenant: 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 uuidv4();
}
}

View File

@ -0,0 +1,197 @@
import type { Storable, Directory, JacksonOption, DatabaseStore, DirectoryType, ApiError } from '../typings';
import * as dbutils from '../db/utils';
import { createRandomSecret } from '../controller/utils';
import { apiError, JacksonError } from '../controller/error';
import { storeNamespacePrefix } from '../controller/utils';
export class DirectoryConfig {
private _store: Storable | null = null;
private opts: JacksonOption;
private db: DatabaseStore;
constructor({ db, opts }: { db: DatabaseStore; opts: JacksonOption }) {
this.opts = opts;
this.db = db;
}
// Return the database store
private store(): Storable {
return this._store || (this._store = this.db.store(storeNamespacePrefix.dsync.config));
}
// Create the configuration
public async create({
name,
tenant,
product,
webhook_url,
webhook_secret,
type = 'generic-scim-v2',
}: {
name?: string;
tenant: string;
product: string;
webhook_url?: string;
webhook_secret?: string;
type?: DirectoryType;
}): Promise<{ data: Directory | null; error: ApiError | null }> {
try {
if (!tenant || !product) {
throw new JacksonError('Missing required parameters.', 400);
}
if (!name) {
name = `scim-${tenant}-${product}`;
}
const id = dbutils.keyDigest(dbutils.keyFromParts(tenant, product));
const hasWebhook = webhook_url && webhook_secret;
const directory: Directory = {
id,
name,
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 : '',
},
};
await this.store().put(id, directory);
return { data: this.transform(directory), error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get the configuration by id
public async get(id: string): Promise<{ data: Directory | null; error: ApiError | null }> {
try {
if (!id) {
throw new JacksonError('Missing required parameters.', 400);
}
const directory: Directory = await this.store().get(id);
if (!directory) {
throw new JacksonError('Directory configuration not found.', 404);
}
return { data: this.transform(directory), error: null };
} catch (err: any) {
return apiError(err);
}
}
// 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 }> {
try {
if (!id) {
throw new JacksonError('Missing required parameters.', 400);
}
const { name, log_webhook_events, webhook, type } = param;
const directory = await this.store().get(id);
if (name) {
directory.name = name;
}
if (log_webhook_events !== undefined) {
directory.log_webhook_events = log_webhook_events;
}
if (webhook) {
directory.webhook = webhook;
}
if (type) {
directory.type = type;
}
await this.store().put(id, { ...directory });
return { data: this.transform(directory), 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 }> {
try {
if (!tenant || !product) {
throw new JacksonError('Missing required parameters.', 400);
}
return await this.get(dbutils.keyDigest(dbutils.keyFromParts(tenant, product)));
} catch (err: any) {
return apiError(err);
}
}
// Get all configurations
public async list({
pageOffset,
pageLimit,
}: {
pageOffset: number;
pageLimit: number;
}): Promise<{ data: Directory[] | null; error: ApiError | null }> {
try {
const directories = (await this.store().getAll(pageOffset, pageLimit)) as Directory[];
const transformedDirectories = directories
? directories.map((directory) => this.transform(directory))
: [];
return {
data: transformedDirectories,
error: null,
};
} catch (err: any) {
return apiError(err);
}
}
// Delete a configuration by id
// Note: This feature is not yet implemented
public async delete(id: string): Promise<void> {
if (!id) {
throw new JacksonError('Missing required parameter.', 400);
}
// TODO: Delete the users and groups associated with the configuration
await this.store().delete(id);
return;
}
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`;
}
directory.scim.endpoint = `${this.opts.externalUrl}${directory.scim.path}`;
return directory;
}
}

View File

@ -0,0 +1,321 @@
import type {
Group,
DirectoryConfig,
DirectorySyncResponse,
Directory,
DirectorySyncGroupMember,
DirectorySyncRequest,
Users,
Groups,
ApiError,
IDirectoryGroups,
EventCallback,
HTTPMethod,
} from '../typings';
import { parseGroupOperations, toGroupMembers } from './utils';
import { sendEvent } from './events';
export class DirectoryGroups implements IDirectoryGroups {
private directories: DirectoryConfig;
private users: Users;
private groups: Groups;
private callback: EventCallback | undefined;
constructor({
directories,
users,
groups,
}: {
directories: DirectoryConfig;
users: Users;
groups: Groups;
}) {
this.directories = directories;
this.users = users;
this.groups = groups;
}
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
const { displayName, members } = body;
const { data: group } = await this.groups.create({
name: displayName,
raw: body,
});
await sendEvent('group.created', { directory, group }, this.callback);
return {
status: 201,
data: {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
id: group?.id,
displayName: group?.name,
members: members ?? [],
},
};
}
public async get(group: Group): Promise<DirectorySyncResponse> {
return {
status: 200,
data: {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
id: group.id,
displayName: group.name,
members: toGroupMembers(await this.groups.getAllUsers(group.id)),
},
};
}
public async delete(directory: Directory, group: Group): Promise<DirectorySyncResponse> {
await this.groups.removeAllUsers(group.id);
await this.groups.delete(group.id);
await sendEvent('group.deleted', { directory, group }, this.callback);
return {
status: 200,
data: {},
};
}
public async getAll(queryParams: { filter?: string }): Promise<DirectorySyncResponse> {
const { filter } = queryParams;
let groups: Group[] | null = [];
if (filter) {
// Filter by group displayName
// filter: displayName eq "Developer"
const { data } = await this.groups.search(filter.split('eq ')[1].replace(/['"]+/g, ''));
groups = data;
} else {
// Fetch all the existing group
const { data } = await this.groups.list({ pageOffset: undefined, pageLimit: undefined });
groups = data;
}
return {
status: 200,
data: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
totalResults: groups ? groups.length : 0,
itemsPerPage: groups ? groups.length : 0,
startIndex: 1,
Resources: groups ? groups.map((group) => group.raw) : [],
},
};
}
// 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,
},
});
if (error || !updatedGroup) {
throw error;
}
await sendEvent('group.updated', { directory, group: updatedGroup }, this.callback);
return updatedGroup;
}
public async patch(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse> {
const { Operations } = body;
const operation = parseGroupOperations(Operations);
// Add group members
if (operation.action === 'addGroupMember') {
await this.addGroupMembers(directory, group, operation.members);
}
// Remove group members
if (operation.action === 'removeGroupMember') {
await this.removeGroupMembers(directory, group, operation.members);
}
// Update group name
if (operation.action === 'updateGroupName') {
await this.updateDisplayName(directory, group, {
displayName: operation.displayName,
});
}
const { data: updatedGroup } = await this.groups.get(group.id);
return {
status: 200,
data: {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
id: updatedGroup?.id,
displayName: updatedGroup?.name,
members: toGroupMembers(await this.groups.getAllUsers(group.id)),
},
};
}
public async update(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse> {
const { displayName, members } = body;
// Update group name
const updatedGroup = await this.updateDisplayName(directory, group, {
displayName,
});
// Update group members
if (members) {
await this.addOrRemoveGroupMembers(directory, group, members);
}
return {
status: 200,
data: {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
id: group.id,
displayName: updatedGroup.name,
members: toGroupMembers(await this.groups.getAllUsers(group.id)),
},
};
}
public async addGroupMembers(
directory: Directory,
group: Group,
members: DirectorySyncGroupMember[] | undefined,
sendWebhookEvent = true
) {
if (members === undefined || (members && members.length === 0)) {
return;
}
for (const member of members) {
if (await this.groups.isUserInGroup(group.id, member.value)) {
continue;
}
await this.groups.addUserToGroup(group.id, member.value);
const { data: user } = await this.users.get(member.value);
if (sendWebhookEvent && user) {
await sendEvent('group.user_added', { directory, group, user }, this.callback);
}
}
}
public async removeGroupMembers(
directory: Directory,
group: Group,
members: DirectorySyncGroupMember[],
sendWebhookEvent = true
) {
if (members.length === 0) {
return;
}
for (const member of members) {
await this.groups.removeUserFromGroup(group.id, member.value);
const { data: user } = await this.users.get(member.value);
// User may not exist in the directory, so we need to check if the user exists
if (sendWebhookEvent && user) {
await sendEvent('group.user_removed', { directory, group, user }, this.callback);
}
}
}
// Add or remove users from a group
public async addOrRemoveGroupMembers(
directory: Directory,
group: Group,
members: DirectorySyncGroupMember[]
) {
const users = toGroupMembers(await this.groups.getAllUsers(group.id));
const usersToAdd = members.filter((member) => !users.some((user) => user.value === member.value));
const usersToRemove = users
.filter((user) => !members.some((member) => member.value === user.value))
.map((user) => ({ value: user.value }));
await this.addGroupMembers(directory, group, usersToAdd, false);
await this.removeGroupMembers(directory, group, usersToRemove, false);
}
private respondWithError(error: ApiError | null) {
return {
status: error ? error.code : 500,
data: null,
};
}
// Handle the request from the Identity Provider and route it to the appropriate method
public async handleRequest(
request: DirectorySyncRequest,
callback?: EventCallback
): Promise<DirectorySyncResponse> {
const { body, query, resourceId: groupId, directoryId, apiSecret } = request;
const method = request.method.toUpperCase() as HTTPMethod;
// Get the directory
const { data: directory, error } = await this.directories.get(directoryId);
if (error || !directory) {
return this.respondWithError(error);
}
// Validate the request
if (directory.scim.secret != apiSecret) {
return this.respondWithError({ code: 401, message: 'Unauthorized' });
}
this.callback = callback;
this.users.setTenantAndProduct(directory.tenant, directory.product);
this.groups.setTenantAndProduct(directory.tenant, directory.product);
// Get the group
const { data: group } = groupId ? await this.groups.get(groupId) : { data: null };
if (group) {
switch (method) {
case 'GET':
return await this.get(group);
case 'PUT':
return await this.update(directory, group, body);
case 'PATCH':
return await this.patch(directory, group, body);
case 'DELETE':
return await this.delete(directory, group);
}
}
switch (method) {
case 'POST':
return await this.create(directory, body);
case 'GET':
return await this.getAll({
filter: query.filter,
});
}
return {
status: 404,
data: {},
};
}
}

View File

@ -0,0 +1,210 @@
import type {
DirectoryConfig,
Directory,
DirectorySyncResponse,
DirectorySyncRequest,
User,
Users,
ApiError,
IDirectoryUsers,
EventCallback,
HTTPMethod,
} from '../typings';
import { parseUserOperations } from './utils';
import { sendEvent } from './events';
export class DirectoryUsers implements IDirectoryUsers {
private directories: DirectoryConfig;
private users: Users;
private callback: EventCallback | undefined;
constructor({ directories, users }: { directories: DirectoryConfig; users: Users }) {
this.directories = directories;
this.users = users;
}
public async create(directory: Directory, body: any): Promise<DirectorySyncResponse> {
const { name, emails } = body;
const { data: user } = await this.users.create({
first_name: name && 'givenName' in name ? name.givenName : '',
last_name: name && 'familyName' in name ? name.familyName : '',
email: emails[0].value,
active: true,
raw: body,
});
await sendEvent('user.created', { directory, user }, this.callback);
return {
status: 201,
data: user?.raw,
};
}
public async get(user: User): Promise<DirectorySyncResponse> {
return {
status: 200,
data: user.raw,
};
}
public async update(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse> {
const { name, emails, active } = body;
const { data: updatedUser } = await this.users.update(user.id, {
first_name: name.givenName,
last_name: name.familyName,
email: emails[0].value,
active,
raw: body,
});
await sendEvent('user.updated', { directory, user: updatedUser }, this.callback);
return {
status: 200,
data: updatedUser?.raw,
};
}
public async patch(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse> {
const { Operations } = body;
const operation = parseUserOperations(Operations);
if (operation.action === 'updateUser') {
const { data: updatedUser } = await this.users.update(user.id, {
...user,
...operation.attributes,
raw: { ...user.raw, ...operation.raw },
});
await sendEvent('user.updated', { directory, user: updatedUser }, this.callback);
return {
status: 200,
data: updatedUser?.raw,
};
}
return {
status: 200,
data: null,
};
}
public async delete(directory: Directory, user: User): Promise<DirectorySyncResponse> {
await this.users.delete(user.id);
await sendEvent('user.deleted', { directory, user }, this.callback);
return {
status: 200,
data: user.raw,
};
}
public async getAll(queryParams: {
count: number;
startIndex: number;
filter?: string;
}): Promise<DirectorySyncResponse> {
const { startIndex, filter, count } = queryParams;
let users: User[] | null = [];
let totalResults = 0;
if (filter) {
// Search users by userName
// filter: userName eq "john@example.com"
const { data } = await this.users.search(filter.split('eq ')[1].replace(/['"]+/g, ''));
users = data;
totalResults = users ? users.length : 0;
} else {
// Fetch all the existing Users (Paginated)
// At this moment, we don't have method to count the database records.
const { data: allUsers } = await this.users.list({});
const { data } = await this.users.list({ pageOffset: startIndex - 1, pageLimit: count });
users = data;
totalResults = allUsers ? allUsers.length : 0;
}
return {
status: 200,
data: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
startIndex: startIndex ? startIndex : 1,
totalResults: totalResults ? totalResults : 0,
itemsPerPage: count ? count : 0,
Resources: users ? users.map((user) => user.raw) : [],
},
};
}
private respondWithError(error: ApiError | null) {
return {
status: error ? error.code : 500,
data: null,
};
}
// Handle the request from the Identity Provider and route it to the appropriate method
public async handleRequest(
request: DirectorySyncRequest,
callback?: EventCallback
): Promise<DirectorySyncResponse> {
const { body, query, resourceId: userId, directoryId, apiSecret } = request;
const method = request.method.toUpperCase() as HTTPMethod;
// Get the directory
const { data: directory, error } = await this.directories.get(directoryId);
if (error || !directory) {
return this.respondWithError(error);
}
// Validate the request
if (directory.scim.secret != apiSecret) {
return this.respondWithError({ code: 401, message: 'Unauthorized' });
}
this.callback = callback;
this.users.setTenantAndProduct(directory.tenant, directory.product);
// Get the user
const { data: user } = userId ? await this.users.get(userId) : { data: null };
if (user) {
switch (method) {
case 'GET':
return await this.get(user);
case 'PATCH':
return await this.patch(directory, user, body);
case 'PUT':
return await this.update(directory, user, body);
case 'DELETE':
return await this.delete(directory, user);
}
}
switch (method) {
case 'POST':
return await this.create(directory, body);
case 'GET':
return await this.getAll({
count: query.count as number,
startIndex: query.startIndex as number,
filter: query.filter,
});
}
return {
status: 404,
data: {},
};
}
}

View File

@ -0,0 +1,185 @@
import type { Group, DatabaseStore, ApiError } from '../typings';
import * as dbutils from '../db/utils';
import { apiError, JacksonError } from '../controller/error';
import { Base } from './Base';
export class Groups extends Base {
constructor({ db }: { db: DatabaseStore }) {
super({ db });
}
// Create a new group
public async create(param: {
name: string;
raw: any;
}): Promise<{ data: Group | null; error: ApiError | null }> {
try {
const { name, raw } = param;
const id = this.createId();
raw['id'] = id;
const group: Group = {
id,
name,
raw,
};
await this.store('groups').put(id, group, {
name: 'displayName',
value: name,
});
return { data: group, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get a group by id
public async get(id: string): Promise<{ data: Group | null; error: ApiError | null }> {
try {
const group = await this.store('groups').get(id);
if (!group) {
throw new JacksonError(`Group with id ${id} not found.`, 404);
}
return { data: group, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Update the group data
public async update(
id: string,
param: {
name: string;
raw: any;
}
): Promise<{ data: Group | null; error: ApiError | null }> {
try {
const { name, raw } = param;
const group: Group = {
id,
name,
raw,
};
await this.store('groups').put(id, group);
return { data: group, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Delete a group by id
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
try {
const { data, error } = await this.get(id);
if (error || !data) {
throw error;
}
await this.store('groups').delete(id);
return { data: null, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get all users in a group
public async getAllUsers(groupId: string): Promise<{ user_id: string }[]> {
const users: { user_id: string }[] = 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));
await this.store('members').put(
id,
{
group_id: groupId,
user_id: userId,
},
{
name: 'groupId',
value: groupId,
}
);
}
// Remove a user from a group
public async removeUserFromGroup(groupId: string, userId: string) {
const id = dbutils.keyDigest(dbutils.keyFromParts(groupId, userId));
await this.store('members').delete(id);
}
// Remove all users from a group
public async removeAllUsers(groupId: string) {
const users = await this.getAllUsers(groupId);
if (users.length === 0) {
return;
}
for (const user of users) {
await this.removeUserFromGroup(groupId, user.user_id);
}
}
// Check if a user is a member of a group
public async isUserInGroup(groupId: string, userId: string): Promise<boolean> {
const id = dbutils.keyDigest(dbutils.keyFromParts(groupId, userId));
return !!(await this.store('members').get(id));
}
// Search groups by displayName
public async search(displayName: string): Promise<{ data: Group[] | null; error: ApiError | null }> {
try {
const groups = (await this.store('groups').getByIndex({
name: 'displayName',
value: displayName,
})) as Group[];
return { data: groups, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get all groups in a directory
public async list({
pageOffset,
pageLimit,
}: {
pageOffset?: number;
pageLimit?: number;
}): Promise<{ data: Group[] | null; error: ApiError | null }> {
try {
const groups = (await this.store('groups').getAll(pageOffset, pageLimit)) as Group[];
return { data: groups, error: null };
} catch (err: any) {
return apiError(err);
}
}
}

View File

@ -0,0 +1,152 @@
import type { User, DatabaseStore, ApiError } from '../typings';
import { apiError, JacksonError } from '../controller/error';
import { Base } from './Base';
export class Users extends Base {
constructor({ db }: { db: DatabaseStore }) {
super({ db });
}
// Create a new user
public async create(param: {
first_name: string;
last_name: string;
email: string;
active: boolean;
raw: any;
}): Promise<{ data: User | null; error: ApiError | null }> {
try {
const { first_name, last_name, email, active, raw } = param;
const id = this.createId();
raw['id'] = id;
const user = {
id,
first_name,
last_name,
email,
active,
raw,
};
await this.store('users').put(id, user, {
name: 'userName',
value: email,
});
return { data: user, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get a user by id
public async get(id: string): Promise<{ data: User | null; error: ApiError | null }> {
try {
const user = await this.store('users').get(id);
if (user === null) {
throw new JacksonError('User not found', 404);
}
return { data: user, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Update the user data
public async update(
id: string,
param: {
first_name: string;
last_name: string;
email: string;
active: boolean;
raw: object;
}
): Promise<{ data: User | null; error: ApiError | null }> {
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 };
} catch (err: any) {
return apiError(err);
}
}
// Delete a user by id
public async delete(id: string): Promise<{ data: null; error: ApiError | null }> {
try {
const { data, error } = await this.get(id);
if (error || !data) {
throw error;
}
await this.store('users').delete(id);
return { data: null, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Get all users in a directory
public async list({
pageOffset,
pageLimit,
}: {
pageOffset?: number;
pageLimit?: number;
}): Promise<{ data: User[] | null; error: ApiError | null }> {
try {
const users = (await this.store('users').getAll(pageOffset, pageLimit)) as User[];
return { data: users, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Search users by userName
public async search(userName: string): Promise<{ data: User[] | null; error: ApiError | null }> {
try {
const users = (await this.store('users').getByIndex({ name: 'userName', value: userName })) as User[];
return { data: users, error: null };
} catch (err: any) {
return apiError(err);
}
}
// Clear all the users
public async clear() {
const { data: users, error } = await this.list({});
if (!users || error) {
return;
}
await Promise.all(
users.map(async (user) => {
return this.delete(user.id);
})
);
}
}

View File

@ -0,0 +1,63 @@
import type {
Directory,
DatabaseStore,
WebhookEventLog,
DirectorySyncEvent,
IWebhookEventsLogger,
} from '../typings';
import { Base } from './Base';
export class WebhookEventsLogger extends Base implements IWebhookEventsLogger {
constructor({ db }: { db: DatabaseStore }) {
super({ db });
}
public async log(directory: Directory, event: DirectorySyncEvent): Promise<WebhookEventLog> {
const id = this.createId();
const log: WebhookEventLog = {
...event,
id,
webhook_endpoint: directory.webhook.endpoint,
created_at: new Date(),
};
await this.store('logs').put(id, log);
return log;
}
public async get(id: string): Promise<WebhookEventLog> {
return await this.store('logs').get(id);
}
public async getAll(): Promise<WebhookEventLog[]> {
return (await this.store('logs').getAll()) as WebhookEventLog[];
}
public async delete(id: string) {
await this.store('logs').delete(id);
}
public async clear() {
const events = await this.getAll();
await Promise.all(
events.map(async (event) => {
await this.delete(event.id);
})
);
}
public async updateStatus(log: WebhookEventLog, statusCode: number): Promise<WebhookEventLog> {
const updatedLog = {
...log,
status_code: statusCode,
delivered: statusCode === 200,
};
await this.store('logs').put(log.id, updatedLog);
return updatedLog;
}
}

View File

@ -0,0 +1,65 @@
import type {
DirectorySyncEventType,
Directory,
User,
Group,
EventCallback,
DirectorySyncEvent,
DirectoryConfig,
IWebhookEventsLogger,
} from '../typings';
import { createHeader, transformEventPayload } from './utils';
import axios from 'axios';
export const sendEvent = async (
event: DirectorySyncEventType,
payload: { directory: Directory; group?: Group | null; user?: User | null },
callback?: EventCallback
) => {
const eventTransformed = transformEventPayload(event, payload);
return callback ? await callback(eventTransformed) : Promise.resolve();
};
export const handleEventCallback = async (
directories: DirectoryConfig,
webhookEventsLogger: IWebhookEventsLogger
) => {
return async (event: DirectorySyncEvent) => {
const { tenant, product, directory_id: directoryId } = event;
const { data: directory } = await directories.get(directoryId);
if (!directory) {
return;
}
const { webhook } = directory;
// If there is no webhook, then we don't need to send an event
if (webhook.endpoint === '') {
return;
}
webhookEventsLogger.setTenantAndProduct(tenant, product);
const headers = await createHeader(webhook.secret, event);
// Log the events only if `log_webhook_events` is enabled
const log = directory.log_webhook_events ? await webhookEventsLogger.log(directory, event) : undefined;
let status = 200;
try {
await axios.post(webhook.endpoint, event, {
headers,
});
} catch (err: any) {
status = err.response ? err.response.status : 500;
}
if (log) {
await webhookEventsLogger.updateStatus(log, status);
}
};
};

View File

@ -0,0 +1,44 @@
import type { DatabaseStore, DirectorySync, JacksonOption } 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 { DirectorySyncRequestHandler } from './request';
import { handleEventCallback } from './events';
import { WebhookEventsLogger } from './WebhookEventsLogger';
const directorySync = async ({
db,
opts,
}: {
db: DatabaseStore;
opts: JacksonOption;
}): Promise<DirectorySync> => {
const directories = new DirectoryConfig({ db, opts });
const users = new Users({ db });
const groups = new Groups({ db });
const directoryUsers = new DirectoryUsers({ directories, users });
const directoryGroups = new DirectoryGroups({ directories, users, groups });
const webhookEventsLogger = new WebhookEventsLogger({ db });
return {
users,
groups,
directories,
webhookLogs: webhookEventsLogger,
requests: new DirectorySyncRequestHandler(directoryUsers, directoryGroups),
events: {
callback: await handleEventCallback(directories, webhookEventsLogger),
},
providers: () => {
return getDirectorySyncProviders();
},
};
};
export default directorySync;

View File

@ -0,0 +1,21 @@
import type {
DirectorySyncResponse,
IDirectoryGroups,
IDirectoryUsers,
EventCallback,
DirectorySyncRequest,
} from '../typings';
export class DirectorySyncRequestHandler {
constructor(private directoryUsers: IDirectoryUsers, private directoryGroups: IDirectoryGroups) {}
async handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse> {
if (request.resourceType === 'users') {
return await this.directoryUsers.handleRequest(request, callback);
} else if (request.resourceType === 'groups') {
return await this.directoryGroups.handleRequest(request, callback);
}
return { status: 404, data: {} };
}
}

View File

@ -0,0 +1,29 @@
import { Group, User } from '../typings';
const transformUser = (user: User): User => {
return {
id: user.id,
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
active: user.active,
raw: user.raw,
};
};
const transformGroup = (group: Group): Group => {
return {
id: group.id,
name: group.name,
raw: group.raw,
};
};
const transformUserGroup = (user: User, group: Group): User & { group: Group } => {
return {
...transformUser(user),
group: transformGroup(group),
};
};
export { transformUser, transformGroup, transformUserGroup };

View File

@ -0,0 +1,188 @@
import type {
Directory,
DirectorySyncEvent,
DirectorySyncEventType,
DirectorySyncGroupMember,
Group,
User,
} from '../typings';
import { DirectorySyncProviders } from '../typings';
import { transformUser, transformGroup, transformUserGroup } from './transform';
import crypto from 'crypto';
const parseGroupOperations = (
operations: {
op: 'add' | 'remove' | 'replace';
path: string;
value: any;
}[]
):
| {
action: 'addGroupMember' | 'removeGroupMember';
members: DirectorySyncGroupMember[];
}
| {
action: 'updateGroupName';
displayName: string;
}
| {
action: 'unknown';
} => {
const { op, path, value } = operations[0];
// Add group members
if (op === 'add' && path === 'members') {
return {
action: 'addGroupMember',
members: value,
};
}
// Remove group members
if (op === 'remove' && path === 'members') {
return {
action: 'removeGroupMember',
members: value,
};
}
// Remove group members
if (op === 'remove' && path.startsWith('members[value eq')) {
return {
action: 'removeGroupMember',
members: [{ value: path.split('"')[1] }],
};
}
// Update group name
if (op === 'replace') {
return {
action: 'updateGroupName',
displayName: value.displayName,
};
}
return {
action: 'unknown',
};
};
const toGroupMembers = (users: { user_id: string }[]): DirectorySyncGroupMember[] => {
return users.map((user) => ({
value: user.user_id,
}));
};
export const parseUserOperations = (operations: {
op: 'replace';
value: any;
}): {
action: 'updateUser' | 'unknown';
raw: any;
attributes: Partial<User>;
} => {
const { op, value } = operations[0];
const attributes: Partial<User> = {};
// Update the user
if (op === 'replace') {
if ('active' in value) {
attributes['active'] = value.active;
}
if ('name.givenName' in value) {
attributes['first_name'] = value['name.givenName'];
}
if ('name.familyName' in value) {
attributes['last_name'] = value['name.familyName'];
}
return {
action: 'updateUser',
raw: value,
attributes,
};
}
return {
action: 'unknown',
raw: value,
attributes,
};
};
// List of directory sync providers
// TODO: Fix the return type
const getDirectorySyncProviders = (): { [K: string]: string } => {
return Object.entries(DirectorySyncProviders).reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {});
};
const transformEventPayload = (
event: DirectorySyncEventType,
payload: { directory: Directory; group?: Group | null; user?: User | null }
): DirectorySyncEvent => {
const { directory, group, user } = payload;
const { tenant, product, id: directory_id } = directory;
const eventPayload = {
event,
tenant,
product,
directory_id,
} as DirectorySyncEvent;
// User events
if (['user.created', 'user.updated', 'user.deleted'].includes(event) && user) {
eventPayload['data'] = transformUser(user);
}
// Group events
if (['group.created', 'group.updated', 'group.deleted'].includes(event) && group) {
eventPayload['data'] = transformGroup(group);
}
// Group membership events
if (['group.user_added', 'group.user_removed'].includes(event) && user && group) {
eventPayload['data'] = transformUserGroup(user, group);
}
return eventPayload;
};
// Create request headers
const createHeader = async (secret: string, event: DirectorySyncEvent) => {
return {
'Content-Type': 'application/json',
'BoxyHQ-Signature': await createSignatureString(secret, event),
};
};
// Create a signature string
const createSignatureString = async (secret: string, event: DirectorySyncEvent) => {
if (!secret) {
return '';
}
const timestamp = new Date().getTime();
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${JSON.stringify(event)}`)
.digest('hex');
return `t=${timestamp},s=${signature}`;
};
export {
parseGroupOperations,
toGroupMembers,
getDirectorySyncProviders,
transformEventPayload,
createHeader,
createSignatureString,
};

View File

@ -1,16 +1,18 @@
import type { DirectorySync, JacksonOption } from './typings';
import DB from './db/db';
import defaultDb from './db/defaultDb';
import readConfig from './read-config';
import { AdminController } from './controller/admin';
import { APIController } from './controller/api';
import { OAuthController } from './controller/oauth';
import { HealthCheckController } from './controller/health-check';
import { LogoutController } from './controller/logout';
import initDirectorySync from './directory-sync';
import { OidcDiscoveryController } from './controller/oidc-discovery';
import { SPSAMLConfig } from './controller/sp-config';
import DB from './db/db';
import defaultDb from './db/defaultDb';
import readConfig from './read-config';
import { JacksonOption } from './typings';
const defaultOpts = (opts: JacksonOption): JacksonOption => {
const newOpts = {
...opts,
@ -24,6 +26,8 @@ const defaultOpts = (opts: JacksonOption): JacksonOption => {
throw new Error('samlPath is required');
}
newOpts.scimPath = newOpts.scimPath || '/api/scim/v2.0';
newOpts.samlAudience = newOpts.samlAudience || 'https://saml.boxyhq.com';
newOpts.preLoadedConfig = newOpts.preLoadedConfig || ''; // path to folder containing static SAML config that will be preloaded. This is useful for self-hosted deployments that only have to support a single tenant (or small number of known tenants).
newOpts.idpEnabled = newOpts.idpEnabled === true;
@ -46,6 +50,7 @@ export const controllers = async (
adminController: AdminController;
logoutController: LogoutController;
healthCheckController: HealthCheckController;
directorySync: DirectorySync;
oidcDiscoveryController: OidcDiscoveryController;
spConfig: SPSAMLConfig;
}> => {
@ -63,6 +68,7 @@ export const controllers = async (
const adminController = new AdminController({ configStore });
const healthCheckController = new HealthCheckController({ healthCheckStore });
await healthCheckController.init();
const oauthController = new OAuthController({
configStore,
sessionStore,
@ -77,6 +83,8 @@ export const controllers = async (
opts,
});
const directorySync = await initDirectorySync({ db, opts });
const oidcDiscoveryController = new OidcDiscoveryController({ opts });
const spConfig = new SPSAMLConfig(opts);
@ -103,6 +111,7 @@ export const controllers = async (
adminController,
logoutController,
healthCheckController,
directorySync,
oidcDiscoveryController,
};
};

View File

@ -33,6 +33,7 @@ export interface IOAuthController {
export interface IAdminController {
getAllConfig(pageOffset?: number, pageLimit?: number);
}
export interface IHealthCheckController {
status(): Promise<{
status: number;
@ -40,6 +41,11 @@ export interface IHealthCheckController {
init(): Promise<void>;
}
export interface ILogoutController {
createRequest(body: SLORequestParams): Promise<{ logoutUrl: string | null; logoutForm: string | null }>;
handleResponse(body: SAMLResponsePayload): Promise<any>;
}
export interface IOidcDiscoveryController {
openidConfig(): {
issuer: string;
@ -128,6 +134,10 @@ export interface Storable {
getByIndex(idx: Index): Promise<any>;
}
export interface DatabaseStore {
store(namespace: string): Storable;
}
export interface Encrypted {
iv?: string;
tag?: string;
@ -160,6 +170,7 @@ export interface JacksonOption {
db: DatabaseOption;
clientSecretVerifier?: string;
idpDiscoveryPath?: string;
scimPath?: string;
openid: {
jwsAlg?: string;
jwtSigningKeys?: {
@ -200,13 +211,8 @@ export interface SAMLConfig {
defaultRedirectUrl: string;
}
export interface ILogoutController {
createRequest(body: SLORequestParams): Promise<{ logoutUrl: string | null; logoutForm: string | null }>;
handleResponse(body: SAMLResponsePayload): Promise<any>;
}
// See Error Response section in https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/
export interface OAuthErrorHandlerParams {
// See Error Response section in https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/
error:
| 'invalid_request'
| 'access_denied'
@ -232,3 +238,272 @@ export interface ISPSAMLConfig {
toMarkdown(): string;
toHTML(): string;
}
export type DirectorySyncEventType =
| 'user.created'
| 'user.updated'
| 'user.deleted'
| 'group.created'
| 'group.updated'
| 'group.deleted'
| 'group.user_added'
| 'group.user_removed';
export interface Base {
store(type: 'groups' | 'members' | 'users'): Storable;
setTenant(tenant: string): this;
setProduct(product: string): this;
setTenantAndProduct(tenant: string, product: string): this;
with(tenant: string, product: string): this;
createId(): string;
}
export interface Users extends Base {
list({
pageOffset,
pageLimit,
}: {
pageOffset?: number;
pageLimit?: number;
}): Promise<{ data: User[] | null; error: ApiError | null }>;
get(id: string): Promise<{ data: User | null; error: ApiError | null }>;
search(userName: string): Promise<{ data: User[] | null; error: ApiError | null }>;
delete(id: string): Promise<{ data: null; error: ApiError | null }>;
clear(): Promise<void>;
create(param: {
first_name: string;
last_name: string;
email: string;
active: boolean;
raw: any;
}): Promise<{ data: User | null; error: ApiError | null }>;
update(
id: string,
param: {
first_name: string;
last_name: string;
email: string;
active: boolean;
raw: object;
}
): Promise<{ data: User | null; error: ApiError | null }>;
}
export interface Groups extends Base {
create(param: { name: string; raw: any }): Promise<{ data: Group | null; error: ApiError | null }>;
removeAllUsers(groupId: string): Promise<void>;
list({
pageOffset,
pageLimit,
}: {
pageOffset?: number;
pageLimit?: number;
}): Promise<{ data: Group[] | null; error: ApiError | null }>;
get(id: string): Promise<{ data: Group | null; error: ApiError | null }>;
getAllUsers(groupId: string): Promise<{ user_id: string }[]>;
delete(id: string): Promise<{ data: null; error: ApiError | null }>;
addUserToGroup(groupId: string, userId: string): Promise<void>;
isUserInGroup(groupId: string, userId: string): Promise<boolean>;
removeUserFromGroup(groupId: string, userId: string): Promise<void>;
search(displayName: string): Promise<{ data: Group[] | null; error: ApiError | null }>;
update(
id: string,
param: {
name: string;
raw: any;
}
): Promise<{ data: Group | null; error: ApiError | null }>;
}
export type User = {
id: string;
email: string;
first_name: string;
last_name: string;
active: boolean;
raw?: any;
};
export type Group = {
id: string;
name: string;
raw?: any;
};
export enum DirectorySyncProviders {
'azure-scim-v2' = 'Azure SCIM v2.0',
'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',
}
export type DirectoryType = keyof typeof DirectorySyncProviders;
export type HTTPMethod = 'POST' | 'PUT' | 'DELETE' | 'GET' | 'PATCH';
export type Directory = {
id: string;
name: string;
tenant: string;
product: string;
type: DirectoryType;
log_webhook_events: boolean;
scim: {
path: string;
endpoint?: string;
secret: string;
};
webhook: {
endpoint: string;
secret: string;
};
};
export type DirectorySyncGroupMember = { value: string; email?: string };
export interface DirectoryConfig {
create({
name,
tenant,
product,
webhook_url,
webhook_secret,
type,
}: {
name?: string;
tenant: string;
product: string;
webhook_url?: string;
webhook_secret?: string;
type?: DirectoryType;
}): Promise<{ data: Directory | null; error: ApiError | null }>;
update(
id: string,
param: Omit<Partial<Directory>, 'id' | 'tenant' | 'prodct' | 'scim'>
): Promise<{ data: Directory | null; error: ApiError | null }>;
get(id: string): Promise<{ data: Directory | null; error: ApiError | null }>;
getByTenantAndProduct(
tenant: string,
product: string
): Promise<{ data: Directory | null; error: ApiError | null }>;
list({
pageOffset,
pageLimit,
}: {
pageOffset?: number;
pageLimit?: number;
}): Promise<{ data: Directory[] | null; error: ApiError | null }>;
delete(id: string): Promise<void>;
}
export interface IDirectoryUsers {
create(directory: Directory, body: any): Promise<DirectorySyncResponse>;
get(user: User): Promise<DirectorySyncResponse>;
update(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse>;
patch(directory: Directory, user: User, body: any): Promise<DirectorySyncResponse>;
delete(directory: Directory, user: User, active: boolean): Promise<DirectorySyncResponse>;
getAll(queryParams: { count: number; startIndex: number; filter?: string }): Promise<DirectorySyncResponse>;
handleRequest(request: DirectorySyncRequest, eventCallback?: EventCallback): Promise<DirectorySyncResponse>;
}
export interface IDirectoryGroups {
create(directory: Directory, body: any): Promise<DirectorySyncResponse>;
get(group: Group): Promise<DirectorySyncResponse>;
updateDisplayName(directory: Directory, group: Group, body: any): Promise<Group>;
delete(directory: Directory, group: Group): Promise<DirectorySyncResponse>;
getAll(queryParams: { filter?: string }): Promise<DirectorySyncResponse>;
addGroupMembers(
directory: Directory,
group: Group,
members: DirectorySyncGroupMember[] | undefined,
sendWebhookEvent: boolean
): Promise<void>;
removeGroupMembers(
directory: Directory,
group: Group,
members: DirectorySyncGroupMember[],
sendWebhookEvent: boolean
): Promise<void>;
addOrRemoveGroupMembers(
directory: Directory,
group: Group,
members: DirectorySyncGroupMember[]
): Promise<void>;
update(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse>;
patch(directory: Directory, group: Group, body: any): Promise<DirectorySyncResponse>;
handleRequest(request: DirectorySyncRequest, eventCallback?: EventCallback): Promise<DirectorySyncResponse>;
}
export interface IWebhookEventsLogger extends Base {
log(directory: Directory, event: DirectorySyncEvent): Promise<WebhookEventLog>;
getAll(): Promise<WebhookEventLog[]>;
get(id: string): Promise<WebhookEventLog>;
clear(): Promise<void>;
delete(id: string): Promise<void>;
updateStatus(log: WebhookEventLog, statusCode: number): Promise<WebhookEventLog>;
}
export type DirectorySyncResponse = {
status: number;
data?: any;
};
export interface DirectorySyncRequestHandler {
handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse>;
}
export interface Events {
handle(event: DirectorySyncEvent): Promise<void>;
}
export interface DirectorySyncRequest {
method: HTTPMethod;
body: any | undefined;
directoryId: Directory['id'];
resourceType: 'users' | 'groups';
resourceId: string | undefined;
apiSecret: string | null;
query: {
count?: number;
startIndex?: number;
filter?: string;
};
}
export type DirectorySync = {
requests: DirectorySyncRequestHandler;
directories: DirectoryConfig;
groups: Groups;
users: Users;
events: { callback: EventCallback };
webhookLogs: IWebhookEventsLogger;
providers: () => {
[K in string]: string;
};
};
export interface ApiError {
message: string;
code: number;
}
export interface DirectorySyncEvent {
directory_id: Directory['id'];
event: DirectorySyncEventType;
data: User | Group | (User & { group: Group });
tenant: string;
product: string;
}
export interface EventCallback {
(event: DirectorySyncEvent): Promise<void>;
}
export interface WebhookEventLog extends DirectorySyncEvent {
id: string;
webhook_endpoint: string;
created_at: Date;
status_code?: number;
delivered?: boolean;
}

View File

@ -1,6 +1,6 @@
import { DatabaseEngine, DatabaseOption, EncryptionKey, Storable } from '../src/typings';
import { DatabaseEngine, DatabaseOption, EncryptionKey, Storable } from '../../src/typings';
import tap from 'tap';
import DB from '../src/db/db';
import DB from '../../src/db/db';
const encryptionKey: EncryptionKey = 'I+mnyTixBoNGu0OtpG0KXJSunoPTiWMb';

View File

@ -0,0 +1,12 @@
import { Directory, DirectoryType } from '../../../src/typings';
import { faker } from '@faker-js/faker';
export const getFakeDirectory = () => {
return {
name: faker.company.companyName(),
tenant: faker.internet.domainName(),
product: faker.commerce.productName(),
type: 'okta-scim-v2' as DirectoryType,
log_webhook_events: false,
} as Directory;
};

View File

@ -0,0 +1,168 @@
import type { DirectorySyncRequest, Directory } from '../../../src/typings';
const requests = {
// Create a group
// POST /api/scim/v2.0/{directoryId: directory.id}/Groups
create: (directory: Directory, group: any): DirectorySyncRequest => {
return {
method: 'POST',
body: {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
displayName: group.displayName,
members: [],
},
directoryId: directory.id,
resourceType: 'groups',
resourceId: undefined,
apiSecret: directory.scim.secret,
query: {},
};
},
// Get a group by id
// GET /api/scim/v2.0/{directoryId: directory.id}/Groups/{groupId}
getById: (directory: Directory, groupId: string): DirectorySyncRequest => {
return {
method: 'GET',
body: undefined,
directoryId: directory.id,
resourceType: 'groups',
resourceId: groupId,
apiSecret: directory.scim.secret,
query: {},
};
},
// Filter by displayName
// GET /api/scim/v2.0/{directoryId: directory.id}/Groups?filter=displayName eq "{displayName}"
filterByDisplayName: (directory: Directory, displayName: string): DirectorySyncRequest => {
return {
method: 'GET',
directoryId: directory.id,
body: undefined,
resourceType: 'groups',
resourceId: undefined,
apiSecret: directory.scim.secret,
query: {
filter: `displayName eq "${displayName}"`,
},
};
},
// Update a group by id
// PUT /api/scim/v2.0/{directoryId: directory.id}/Groups/{groupId}
updateById: (directory: Directory, groupId: string, group: any): DirectorySyncRequest => {
return {
method: 'PUT',
body: group,
directoryId: directory.id,
resourceType: 'groups',
resourceId: groupId,
apiSecret: directory.scim.secret,
query: {},
};
},
// Delete a group by id
// DELETE /api/scim/v2.0/{directoryId: directory.id}/Groups/{groupId}
deleteById: (directory: Directory, groupId: string): DirectorySyncRequest => {
return {
method: 'DELETE',
body: undefined,
directoryId: directory.id,
resourceType: 'groups',
resourceId: groupId,
apiSecret: directory.scim.secret,
query: {},
};
},
// Get all groups
// GET /api/scim/v2.0/{directoryId: directory.id}/Groups
getAll: (directory: Directory): DirectorySyncRequest => {
return {
method: 'GET',
body: undefined,
directoryId: directory.id,
resourceType: 'groups',
resourceId: undefined,
apiSecret: directory.scim.secret,
query: {
count: 1,
startIndex: 1,
},
};
},
addMembers: (directory: Directory, groupId: string, members: any): DirectorySyncRequest => {
return {
method: 'PATCH',
directoryId: directory.id,
body: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
Operations: [
{
op: 'add',
path: 'members',
value: members,
},
],
},
resourceType: 'groups',
resourceId: groupId,
apiSecret: directory.scim.secret,
query: {},
};
},
removeMembers: (
directory: Directory,
groupId: string,
members: any,
path: string
): DirectorySyncRequest => {
return {
method: 'PATCH',
directoryId: directory.id,
body: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
Operations: [
{
op: 'remove',
value: members,
path,
},
],
},
resourceType: 'groups',
resourceId: groupId,
apiSecret: directory.scim.secret,
query: {},
};
},
updateName: (directory: Directory, groupId: string, group: any): DirectorySyncRequest => {
return {
method: 'PATCH',
directoryId: directory.id,
body: {
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
Operations: [
{
op: 'replace',
value: {
id: groupId,
displayName: group.displayName,
},
},
],
},
resourceType: 'groups',
resourceId: groupId,
apiSecret: directory.scim.secret,
query: {},
};
},
};
export default requests;

View File

@ -0,0 +1,9 @@
const groups = [
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
displayName: 'Developers',
members: [],
},
];
export default groups;

View File

@ -0,0 +1,111 @@
import type { Directory, DirectorySyncRequest } from '../../../src/typings';
const requests = {
create: (directory: Directory, user: any): DirectorySyncRequest => {
return {
method: 'POST',
body: user,
directoryId: directory.id,
resourceType: 'users',
resourceId: undefined,
apiSecret: directory.scim.secret,
query: {},
};
},
// GET /Users?filter=userName eq "userName"
filterByUsername: (directory: Directory, userName: string): DirectorySyncRequest => {
return {
method: 'GET',
body: undefined,
directoryId: directory.id,
resourceType: 'users',
resourceId: undefined,
apiSecret: directory.scim.secret,
query: {
filter: `userName eq "${userName}"`,
count: 1,
startIndex: 1,
},
};
},
// GET /Users/{userId}
getById: (directory: Directory, userId: string): DirectorySyncRequest => {
return {
method: 'GET',
body: undefined,
directoryId: directory.id,
resourceType: 'users',
resourceId: userId,
apiSecret: directory.scim.secret,
query: {},
};
},
// PUT /Users/{userId}
updateById: (directory: Directory, userId: string, user: any): DirectorySyncRequest => {
return {
method: 'PUT',
body: user,
directoryId: directory.id,
resourceType: 'users',
resourceId: userId,
apiSecret: directory.scim.secret,
query: {},
};
},
// PATCH /Users/{userId}
updateOperationById: (directory: Directory, userId: string): DirectorySyncRequest => {
return {
method: 'PATCH',
body: {
Operations: [
{
op: 'replace',
value: {
active: false,
},
},
],
},
directoryId: directory.id,
resourceType: 'users',
resourceId: userId,
apiSecret: directory.scim.secret,
query: {},
};
},
// GET /Users/
getAll: (directory: Directory): DirectorySyncRequest => {
return {
method: 'GET',
body: undefined,
directoryId: directory.id,
resourceType: 'users',
resourceId: undefined,
apiSecret: directory.scim.secret,
query: {
count: 1,
startIndex: 1,
},
};
},
// DELETE /Users/{userId}
deleteById: (directory: Directory, userId: string): DirectorySyncRequest => {
return {
method: 'DELETE',
body: undefined,
directoryId: directory.id,
resourceType: 'users',
resourceId: userId,
apiSecret: directory.scim.secret,
query: {},
};
},
};
export default requests;

View File

@ -0,0 +1,44 @@
const users = [
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
userName: 'jackson@boxyhq.com',
name: {
givenName: 'Jackson',
familyName: 'M',
},
emails: [
{
primary: true,
value: 'jackson@boxyhq.com',
type: 'work',
},
],
displayName: 'Jackson M',
locale: 'en-US',
externalId: '00u5b1hpjh9tGaknX5d7',
groups: [],
active: true,
},
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
userName: 'kiran@boxyhq.com',
name: {
givenName: 'Kiran',
familyName: 'K',
},
emails: [
{
primary: true,
value: 'kiran@boxyhq.com',
type: 'work',
},
],
displayName: 'Kiran K',
locale: 'en-US',
externalId: '00u1b1hpjh91GaknX5d7',
groups: [],
active: true,
},
];
export default users;

View File

@ -0,0 +1,189 @@
import { DirectorySync, Directory, DirectoryType } from '../../src/typings';
import tap from 'tap';
import { getFakeDirectory } from './data/directories';
import { getDatabaseOption } from '../utils';
let directorySync: DirectorySync;
tap.before(async () => {
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
directorySync = jackson.directorySync;
});
tap.teardown(async () => {
process.exit(0);
});
tap.test('Directories / ', async (t) => {
let directory: Directory;
let fakeDirectory: Directory;
t.beforeEach(async () => {
// Create a directory before each test
// t.afterEach() is not working for some reason, so we need to manually delete the directory after each test
fakeDirectory = getFakeDirectory();
const { data, error } = await directorySync.directories.create(fakeDirectory);
if (error || !data) {
t.fail("Couldn't create a directory");
return;
}
directory = data;
});
t.test('should be able to create a directory', async (t) => {
t.ok(directory);
t.hasStrict(directory, fakeDirectory);
t.type(directory.scim, 'object');
t.type(directory.webhook, 'object');
await directorySync.directories.delete(directory.id);
t.end();
});
t.test('should not be able to create a directory without required params', async (t) => {
const { data, error } = await directorySync.directories.create({
name: '',
tenant: '',
product: '',
type: 'azure-scim-v2',
});
t.ok(error);
t.notOk(data);
t.equal(error?.code, 400);
t.equal(error?.message, 'Missing required parameters.');
t.end();
});
t.test('should be able to get a directory', async (t) => {
const { data: directoryFetched } = await directorySync.directories.get(directory.id);
t.ok(directoryFetched);
t.hasStrict(directoryFetched, fakeDirectory);
t.match(directoryFetched?.id, directory.id);
await directorySync.directories.delete(directory.id);
t.end();
});
t.test("should not be able to get a directory that doesn't exist", async (t) => {
const { data, error } = await directorySync.directories.get('fake-id');
t.ok(error);
t.notOk(data);
t.equal(error?.code, 404);
t.equal(error?.message, 'Directory configuration not found.');
});
t.test('should not be able to get a directory without an id', async (t) => {
const { data, error } = await directorySync.directories.get('');
t.ok(error);
t.notOk(data);
t.equal(error?.code, 400);
t.equal(error?.message, 'Missing required parameters.');
});
t.test('should not be able to update a directory without an id', async (t) => {
const { data, error } = await directorySync.directories.update('', {
log_webhook_events: false,
});
t.ok(error);
t.notOk(data);
t.equal(error?.code, 400);
t.equal(error?.message, 'Missing required parameters.');
});
t.test('should be able to update a directory', async (t) => {
const toUpdate = {
name: 'BoxyHQ 1',
webhook: {
endpoint: 'https://my-cool-app.com/webhook',
secret: 'secret',
},
log_webhook_events: true,
type: 'jumpcloud-scim-v2' as DirectoryType,
};
const { data: updatedDirectory } = await directorySync.directories.update(directory.id, toUpdate);
t.ok(updatedDirectory);
t.match(directory.id, updatedDirectory?.id);
t.match(updatedDirectory?.name, toUpdate.name);
t.match(updatedDirectory?.webhook.endpoint, toUpdate.webhook.endpoint);
t.match(updatedDirectory?.webhook.secret, toUpdate.webhook.secret);
t.match(updatedDirectory?.log_webhook_events, toUpdate.log_webhook_events);
// Partial update
const { data: anotherDirectory } = await directorySync.directories.update(directory.id, {
name: 'BoxyHQ 2',
log_webhook_events: false,
});
t.ok(anotherDirectory);
t.match(anotherDirectory?.name, 'BoxyHQ 2');
t.match(anotherDirectory?.log_webhook_events, false);
await directorySync.directories.delete(directory.id);
t.end();
});
t.test('should be able to get a directory by tenant and product', async (t) => {
const { data: directoryFetched } = await directorySync.directories.getByTenantAndProduct(
directory.tenant,
directory.product
);
t.ok(directoryFetched);
t.hasStrict(directoryFetched, fakeDirectory);
t.match(directoryFetched, directory);
await directorySync.directories.delete(directory.id);
t.end();
});
t.test('should be able to delete a directory', async (t) => {
await directorySync.directories.delete(directory.id);
const { data } = await directorySync.directories.get(directory.id);
t.notOk(data);
t.end();
});
t.test('should be able to get all directories', async (t) => {
const directoriesList = await directorySync.directories.list({});
t.ok(directoriesList);
await directorySync.directories.delete(directory.id);
t.end();
});
t.test(
'should not be able to get a directory by tenant and product without tenant and product',
async (t) => {
const { data, error } = await directorySync.directories.getByTenantAndProduct('', '');
t.ok(error);
t.notOk(data);
t.equal(error?.code, 400);
t.equal(error?.message, 'Missing required parameters.');
}
);
t.end();
});

View File

@ -0,0 +1,313 @@
import { DirectorySync, Directory } from '../../src/typings';
import tap from 'tap';
import groups from './data/groups';
import users from './data/users';
import { default as usersRequest } from './data/user-requests';
import { default as groupsRequest } from './data/group-requests';
import { getFakeDirectory } from './data/directories';
import { getDatabaseOption } from '../utils';
let directorySync: DirectorySync;
let directory: Directory;
const fakeDirectory = getFakeDirectory();
tap.before(async () => {
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
directorySync = jackson.directorySync;
const { data, error } = await directorySync.directories.create(fakeDirectory);
if (error || !data) {
tap.fail("Couldn't create a directory");
return;
}
directory = data;
});
tap.teardown(async () => {
// Delete the directory after test
await directorySync.directories.delete(directory.id);
process.exit(0);
});
tap.test('Directory groups / ', async (t) => {
let createdGroup: any;
tap.beforeEach(async () => {
// Create a group before each test
const { data } = await directorySync.requests.handle(groupsRequest.create(directory, groups[0]));
createdGroup = data;
});
tap.afterEach(async () => {
// Delete the group after each test
await directorySync.groups.delete(createdGroup.id);
});
t.test('Should be able to create a new group', async (t) => {
t.ok(createdGroup);
t.hasStrict(createdGroup, groups[0]);
t.ok('id' in createdGroup);
t.end();
});
t.test('Should be able to get the group by id', async (t) => {
const { status, data } = await directorySync.requests.handle(
groupsRequest.getById(directory, createdGroup.id)
);
t.ok(data);
t.equal(status, 200);
t.hasStrict(data, createdGroup);
t.hasStrict(data, groups[0]);
t.end();
});
t.test('Should be able to get the group by displayName', async (t) => {
const { status, data } = await directorySync.requests.handle(
groupsRequest.filterByDisplayName(directory, createdGroup.displayName)
);
t.ok(data);
t.equal(status, 200);
t.hasStrict(data.Resources[0], createdGroup);
t.hasStrict(data.Resources[0], groups[0]);
t.equal(data.Resources.length, 1);
t.end();
});
t.test('Should be able to get all groups', async (t) => {
const { status, data } = await directorySync.requests.handle(groupsRequest.getAll(directory));
t.ok(data);
t.equal(status, 200);
t.hasStrict(data.Resources[0], createdGroup);
t.hasStrict(data.Resources[0], groups[0]);
t.equal(data.totalResults, 1);
t.equal(data.Resources[0].members.length, 0);
t.end();
});
t.test('Should be able to update the group name - POST request', async (t) => {
const { status, data } = await directorySync.requests.handle(
groupsRequest.updateById(directory, createdGroup.id, {
displayName: 'Developers Updated',
})
);
t.ok(data);
t.equal(status, 200);
t.equal(data.displayName, 'Developers Updated');
t.end();
});
t.test('Should be able to update the group name - PATCH request', async (t) => {
const { status, data } = await directorySync.requests.handle(
groupsRequest.updateName(directory, createdGroup.id, {
...createdGroup,
displayName: 'Developers Updated',
})
);
t.ok(data);
t.equal(status, 200);
t.equal(data.displayName, 'Developers Updated');
t.end();
});
t.test('Should be able to add or remove the group members - PUT request', async (t) => {
const { data: user1 } = await directorySync.requests.handle(usersRequest.create(directory, users[0]));
const { data: user2 } = await directorySync.requests.handle(usersRequest.create(directory, users[1]));
const { status, data } = await directorySync.requests.handle(
groupsRequest.updateById(directory, createdGroup.id, {
...createdGroup,
members: [
{
value: user1.id,
},
{
value: user2.id,
},
],
})
);
let members = toMemberArray(data.members);
t.ok(data);
t.equal(status, 200);
t.equal(data.members.length, 2);
t.ok(members.includes(user1.id));
t.ok(members.includes(user2.id));
// Removing the user1 from the group (Body has the user2 id only)
const { data: data1 } = await directorySync.requests.handle(
groupsRequest.updateById(directory, createdGroup.id, {
...createdGroup,
members: [
{
value: user2.id,
},
],
})
);
members = toMemberArray(data1.members);
t.ok(data1);
t.equal(data1.members.length, 1);
t.ok(members.includes(user2.id));
t.end();
});
t.test('Should be able add a member to an existing group - PATCH request', async (t) => {
const { data: user1 } = await directorySync.requests.handle(usersRequest.create(directory, users[0]));
const response1 = await directorySync.requests.handle(
groupsRequest.addMembers(directory, createdGroup.id, [
{
value: user1.id,
},
])
);
let members = toMemberArray(response1.data.members);
t.ok(response1.data);
t.equal(response1.status, 200);
t.equal(response1.data.members.length, 1);
t.ok(members.includes(user1.id));
// Add another member
const { data: user2 } = await directorySync.requests.handle(usersRequest.create(directory, users[1]));
// Fetch the group again
const group = await directorySync.requests.handle(groupsRequest.getById(directory, createdGroup.id));
// Add the second member
group.data.members.push({ value: user2.id });
const response2 = await directorySync.requests.handle(
groupsRequest.addMembers(directory, createdGroup.id, group.data.members)
);
members = toMemberArray(response2.data.members);
t.ok(response2.data);
t.equal(response2.status, 200);
t.equal(response2.data.members.length, 2);
t.ok(members.includes(user1.id));
t.ok(members.includes(user2.id));
// Clean up
await directorySync.users.delete(user1.id);
await directorySync.users.delete(user2.id);
t.end();
});
t.test('Should be able remove a member from an existing group - PATCH request', async (t) => {
const { data: user1 } = await directorySync.requests.handle(usersRequest.create(directory, users[0]));
const { data: user2 } = await directorySync.requests.handle(usersRequest.create(directory, users[1]));
// Add 2 members
const response1 = await directorySync.requests.handle(
groupsRequest.addMembers(directory, createdGroup.id, [
{
value: user1.id,
},
{
value: user2.id,
},
])
);
t.ok(response1.data);
t.equal(response1.status, 200);
// Remove the first member
const response2 = await directorySync.requests.handle(
groupsRequest.removeMembers(
directory,
createdGroup.id,
[
{
value: user1.id,
},
],
'members'
)
);
const members = toMemberArray(response2.data.members);
t.ok(response2.data);
t.equal(response2.status, 200);
t.equal(response2.data.members.length, 1);
t.ok(members.includes(user2.id));
// Remove the second member
const response3 = await directorySync.requests.handle(
groupsRequest.removeMembers(
directory,
createdGroup.id,
[
{
value: user2.id,
},
],
`members[value eq "${user2.id}"]`
)
);
t.ok(response3.data);
t.equal(response3.status, 200);
t.equal(response3.data.members.length, 0);
// Clean up
await directorySync.users.delete(user1.id);
await directorySync.users.delete(user2.id);
t.end();
});
t.test('Should be able to delete a group', async (t) => {
const { status } = await directorySync.requests.handle(
groupsRequest.deleteById(directory, createdGroup.id)
);
t.equal(status, 200);
// Try to get the group
try {
await directorySync.requests.handle(groupsRequest.getById(directory, createdGroup.id));
} catch (e: any) {
t.equal(e.statusCode, 404);
t.equal(e.message, `Group with id ${createdGroup.id} not found.`);
}
t.end();
});
t.end();
});
const toMemberArray = (members) => {
return members.map((member) => {
return member.value;
});
};

View File

@ -0,0 +1,165 @@
import { DirectorySync, Directory } from '../../src/typings';
import tap from 'tap';
import users from './data/users';
import requests from './data/user-requests';
import { getFakeDirectory } from './data/directories';
import { getDatabaseOption } from '../utils';
let directorySync: DirectorySync;
let directory: Directory;
const fakeDirectory = getFakeDirectory();
tap.before(async () => {
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
directorySync = jackson.directorySync;
const { data, error } = await directorySync.directories.create(fakeDirectory);
if (error || !data) {
tap.fail("Couldn't create a directory");
return;
}
directory = data;
});
tap.teardown(async () => {
// Delete the directory after test
await directorySync.directories.delete(directory.id);
process.exit(0);
});
tap.test('Directory users / ', async (t) => {
let createdUser: any;
tap.beforeEach(async () => {
// Create a user before each test
const { data } = await directorySync.requests.handle(requests.create(directory, users[0]));
createdUser = data;
});
tap.afterEach(async () => {
// Delete the user after each test
await directorySync.users.delete(createdUser.id);
});
t.test('Should be able to get the user by userName', async (t) => {
const { status, data } = await directorySync.requests.handle(
requests.filterByUsername(directory, createdUser.userName)
);
t.ok(data);
t.equal(status, 200);
t.hasStrict(data.Resources[0], createdUser);
t.hasStrict(data.Resources[0], users[0]);
t.end();
});
t.test('Should be able to get the user by id', async (t) => {
const { status, data } = await directorySync.requests.handle(requests.getById(directory, createdUser.id));
t.ok(data);
t.equal(status, 200);
t.hasStrict(data, users[0]);
t.end();
});
t.test('Should be able to update the user using PUT request', async (t) => {
const toUpdate = {
...users[0],
name: {
givenName: 'Jackson Updated',
familyName: 'M',
},
city: 'New York',
};
const { status, data: updatedUser } = await directorySync.requests.handle(
requests.updateById(directory, createdUser.id, toUpdate)
);
t.ok(updatedUser);
t.equal(status, 200);
t.hasStrict(updatedUser, toUpdate);
t.match(updatedUser.city, toUpdate.city);
// Make sure the user was updated
const { data: user } = await directorySync.requests.handle(requests.getById(directory, createdUser.id));
t.ok(user);
t.hasStrict(user, toUpdate);
t.match(user.city, toUpdate.city);
t.end();
});
t.test('Should be able to delete the user using PATCH request', async (t) => {
const toUpdate = {
...users[0],
active: false,
};
const { status, data } = await directorySync.requests.handle(
requests.updateOperationById(directory, createdUser.id)
);
t.ok(data);
t.equal(status, 200);
t.hasStrict(data, toUpdate);
t.end();
});
t.test('Should be able to fetch all users', async (t) => {
const { status, data } = await directorySync.requests.handle(requests.getAll(directory));
t.ok(data);
t.equal(status, 200);
t.ok(data.Resources);
t.equal(data.Resources.length, 1);
t.hasStrict(data.Resources[0], users[0]);
t.equal(data.totalResults, 1);
t.end();
});
t.test('Should be able to delete the user', async (t) => {
const { status, data } = await directorySync.requests.handle(
requests.deleteById(directory, createdUser.id)
);
t.equal(status, 200);
t.ok(data);
t.strictSame(data, createdUser);
// Make sure the user was deleted
const { data: user } = await directorySync.requests.handle(
requests.filterByUsername(directory, createdUser.userName)
);
t.hasStrict(user.Resources, []);
t.hasStrict(user.totalResults, 0);
t.end();
});
t.test('Should be able to delete all users using clear() method', async (t) => {
directorySync.users.setTenantAndProduct(directory.tenant, directory.product);
await directorySync.users.clear();
// Make sure all the user was deleted
const { data: users } = await directorySync.users.list({});
t.equal(users?.length, 0);
t.end();
});
t.end();
});

View File

@ -0,0 +1,313 @@
import { DirectorySync, Directory, DirectorySyncEvent, EventCallback } from '../../src/typings';
import tap from 'tap';
import groups from './data/groups';
import users from './data/users';
import { default as usersRequest } from './data/user-requests';
import { default as groupRequest } from './data/group-requests';
import { getFakeDirectory } from './data/directories';
import { getDatabaseOption } from '../utils';
import sinon from 'sinon';
import axios from 'axios';
import { createSignatureString } from '../../src/directory-sync/utils';
let directorySync: DirectorySync;
let directory: Directory;
let eventCallback: EventCallback;
const fakeDirectory = getFakeDirectory();
const webhook: Directory['webhook'] = {
endpoint: 'http://localhost',
secret: 'secret',
};
tap.before(async () => {
const jackson = await (await import('../../src/index')).default(getDatabaseOption());
directorySync = jackson.directorySync;
// Create a directory before starting the test
const { data, error } = await directorySync.directories.create({
...fakeDirectory,
webhook_url: webhook.endpoint,
webhook_secret: webhook.secret,
});
if (error || !data) {
tap.fail("Couldn't create a directory");
return;
}
directory = data;
// Turn on webhook event logging for the directory
await directorySync.directories.update(directory.id, {
log_webhook_events: true,
});
directorySync.webhookLogs.setTenantAndProduct(directory.tenant, directory.product);
directorySync.users.setTenantAndProduct(directory.tenant, directory.product);
eventCallback = directorySync.events.callback;
});
tap.teardown(async () => {
// Delete the directory after the test
await directorySync.directories.delete(directory.id);
process.exit(0);
});
tap.test('Webhook Events / ', async (t) => {
tap.afterEach(async () => {
await directorySync.webhookLogs.clear();
});
t.test("Should be able to get the directory's webhook", async (t) => {
t.match(directory.webhook.endpoint, webhook.endpoint);
t.match(directory.webhook.secret, webhook.secret);
t.end();
});
t.test('Should not log events if the directory has no webhook', async (t) => {
await directorySync.directories.update(directory.id, {
webhook: {
endpoint: '',
secret: '',
},
});
// Create a user
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
const events = await directorySync.webhookLogs.getAll();
t.equal(events.length, 0);
// Restore the directory's webhook
await directorySync.directories.update(directory.id, {
webhook: {
endpoint: webhook.endpoint,
secret: webhook.secret,
},
});
});
t.test('Should not log webhook events if the logging is turned off', async (t) => {
// Turn off webhook event logging for the directory
await directorySync.directories.update(directory.id, {
log_webhook_events: false,
});
// Create a user
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
const events = await directorySync.webhookLogs.getAll();
t.equal(events.length, 0);
// Turn on webhook event logging for the directory
await directorySync.directories.update(directory.id, {
log_webhook_events: true,
});
t.end();
});
t.test('Should be able to get an event by id', async (t) => {
// Create a user
await directorySync.requests.handle(usersRequest.create(directory, users[0]), eventCallback);
const logs = await directorySync.webhookLogs.getAll();
const log = await directorySync.webhookLogs.get(logs[0].id);
t.equal(log.id, logs[0].id);
t.end();
});
t.test('Should send user related events', async (t) => {
const mock = sinon.mock(axios);
mock.expects('post').thrice().withArgs(webhook.endpoint).throws();
// Create the user
const { data: createdUser } = await directorySync.requests.handle(
usersRequest.create(directory, users[0]),
eventCallback
);
// Update the user
const { data: updatedUser } = await directorySync.requests.handle(
usersRequest.updateById(directory, createdUser.id, users[0]),
eventCallback
);
// Delete the user
const { data: deletedUser } = await directorySync.requests.handle(
usersRequest.deleteById(directory, createdUser.id),
eventCallback
);
mock.verify();
mock.restore();
const logs = await directorySync.webhookLogs.getAll();
t.ok(logs);
t.equal(logs.length, 3);
t.match(logs[0].event, 'user.deleted');
t.match(logs[0].directory_id, directory.id);
t.hasStrict(logs[0].data.raw, deletedUser);
t.match(logs[1].event, 'user.updated');
t.match(logs[1].directory_id, directory.id);
t.hasStrict(logs[1].data.raw, updatedUser);
t.match(logs[2].event, 'user.created');
t.match(logs[2].directory_id, directory.id);
t.hasStrict(logs[2].data.raw, createdUser);
await directorySync.users.clear();
t.end();
});
t.test('Should send group related events', async (t) => {
const mock = sinon.mock(axios);
mock.expects('post').thrice().withArgs(webhook.endpoint).throws();
// Create the group
const { data: createdGroup } = await directorySync.requests.handle(
groupRequest.create(directory, groups[0]),
eventCallback
);
// Update the group
const { data: updatedGroup } = await directorySync.requests.handle(
groupRequest.updateById(directory, createdGroup.id, groups[0]),
eventCallback
);
// Delete the group
const { data: deletedGroup } = await directorySync.requests.handle(
groupRequest.deleteById(directory, createdGroup.id),
eventCallback
);
mock.verify();
mock.restore();
const logs = await directorySync.webhookLogs.getAll();
t.ok(logs);
t.equal(logs.length, 3);
t.match(logs[0].event, 'group.deleted');
t.match(logs[0].directory_id, directory.id);
t.hasStrict(logs[0].data.raw, deletedGroup);
t.match(logs[1].event, 'group.updated');
t.match(logs[1].directory_id, directory.id);
t.hasStrict(logs[1].data.raw, updatedGroup);
t.match(logs[2].event, 'group.created');
t.match(logs[2].directory_id, directory.id);
t.hasStrict(logs[2].data.raw, createdGroup);
t.end();
});
t.test('Should send group membership related events', async (t) => {
const mock = sinon.mock(axios);
mock.expects('post').exactly(4).withArgs(webhook.endpoint).throws();
// Create the user
const { data: createdUser } = await directorySync.requests.handle(
usersRequest.create(directory, users[0]),
eventCallback
);
// Create the group
const { data: createdGroup } = await directorySync.requests.handle(
groupRequest.create(directory, groups[0]),
eventCallback
);
// Add the user to the group
await directorySync.requests.handle(
groupRequest.addMembers(directory, createdGroup.id, [{ value: createdUser.id }]),
eventCallback
);
// Remove the user from the group
await directorySync.requests.handle(
groupRequest.removeMembers(
directory,
createdGroup.id,
[{ value: createdUser.id }],
`members[value eq "${createdUser.id}"]`
),
eventCallback
);
mock.verify();
mock.restore();
const logs = await directorySync.webhookLogs.getAll();
t.ok(logs);
t.equal(logs.length, 4);
t.match(logs[0].event, 'group.user_removed');
t.match(logs[0].directory_id, directory.id);
t.hasStrict(logs[0].data.raw, createdUser);
t.match(logs[1].event, 'group.user_added');
t.match(logs[1].directory_id, directory.id);
t.hasStrict(logs[1].data.raw, createdUser);
await directorySync.users.delete(createdUser.id);
await directorySync.groups.delete(createdGroup.id);
t.end();
});
t.test('createSignatureString()', async (t) => {
const event: DirectorySyncEvent = {
event: 'user.created',
directory_id: directory.id,
tenant: directory.tenant,
product: directory.product,
data: {
raw: [],
id: 'user-id',
first_name: 'Kiran',
last_name: 'Krishnan',
email: 'kiran@boxyhq.com',
active: true,
},
};
const signatureString = await createSignatureString(directory.webhook.secret, event);
const parts = signatureString.split(',');
t.ok(signatureString);
t.ok(parts[0].match(/^t=[0-9a-f]/));
t.ok(parts[1].match(/^s=[0-9a-f]/));
// Empty secret should create an empty signature
const emptySignatureString = await createSignatureString('', event);
t.match(emptySignatureString, '');
t.end();
});
t.end();
});

View File

@ -1,11 +1,12 @@
import * as path from 'path';
import sinon from 'sinon';
import tap from 'tap';
import * as dbutils from '../src/db/utils';
import controllers from '../src/index';
import readConfig from '../src/read-config';
import { IdPConfig, JacksonOption } from '../src/typings';
import * as dbutils from '../../src/db/utils';
import controllers from '../../src/index';
import readConfig from '../../src/read-config';
import { IdPConfig, JacksonOption } from '../../src/typings';
import { saml_config } from './fixture';
import { getDatabaseOption } from '../utils';
let apiController;
@ -25,7 +26,7 @@ const OPTIONS = <JacksonOption>{
};
tap.before(async () => {
const controller = await controllers(OPTIONS);
const controller = await controllers(getDatabaseOption());
apiController = controller.apiController;
});

View File

@ -1,4 +1,4 @@
import { OAuthReqBody, OAuthTokenReq } from '../src';
import { OAuthReqBody, OAuthTokenReq } from '../../src';
import boxyhq from './data/metadata/boxyhq';
import boxyhqNobinding from './data/metadata/boxyhq-nobinding';

View File

@ -3,10 +3,11 @@ import { promises as fs } from 'fs';
import path from 'path';
import sinon from 'sinon';
import tap from 'tap';
import readConfig from '../src/read-config';
import { IAPIController, ILogoutController, JacksonOption } from '../src/typings';
import { relayStatePrefix } from '../src/controller/utils';
import readConfig from '../../src/read-config';
import { IAPIController, ILogoutController, JacksonOption } from '../../src/typings';
import { relayStatePrefix } from '../../src/controller/utils';
import { saml_config } from './fixture';
import { getDatabaseOption } from '../utils';
let apiController: IAPIController;
let logoutController: ILogoutController;
@ -36,7 +37,7 @@ const addMetadata = async (metadataPath) => {
};
tap.before(async () => {
const controller = await (await import('../src/index')).default(options);
const controller = await (await import('../../src/index')).default(getDatabaseOption());
apiController = controller.apiController;
logoutController = controller.logoutController;

View File

@ -1,6 +1,6 @@
import crypto from 'crypto';
import { promises as fs } from 'fs';
import * as utils from '../src/controller/utils';
import * as utils from '../../src/controller/utils';
import path from 'path';
import {
IOAuthController,
@ -9,11 +9,11 @@ import {
OAuthReqBody,
OAuthTokenReq,
SAMLResponsePayload,
} from '../src/typings';
} from '../../src/typings';
import sinon from 'sinon';
import tap from 'tap';
import { JacksonError } from '../src/controller/error';
import readConfig from '../src/read-config';
import { JacksonError } from '../../src/controller/error';
import readConfig from '../../src/read-config';
import saml from '@boxyhq/saml20';
import * as jose from 'jose';
import {
@ -38,6 +38,7 @@ import {
token_req_idp_initiated_saml_login,
token_req_unencoded_client_id_gen,
} from './fixture';
import { getDatabaseOption } from '../utils';
let apiController: IAPIController;
let oauthController: IOAuthController;
@ -78,9 +79,9 @@ const addMetadata = async (metadataPath) => {
tap.before(async () => {
keyPair = await jose.generateKeyPair('RS256', { modulusLength: 3072 });
const controller = await (await import('../src/index')).default(options);
const controller = await (await import('../../src/index')).default(options);
const idpFlowEnabledController = await (
await import('../src/index')
await import('../../src/index')
).default({ ...options, idpEnabled: true });
apiController = controller.apiController;

12
npm/test/utils.ts Normal file
View File

@ -0,0 +1,12 @@
import { JacksonOption } from '../src/typings';
export const getDatabaseOption = () => {
return {
externalUrl: 'https://my-cool-app.com',
samlAudience: 'https://saml.boxyhq.com',
samlPath: '/sso/oauth/saml',
db: {
engine: 'mem',
},
} as JacksonOption;
};

7174
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,8 @@
"next-mdx-remote": "4.1.0",
"nodemailer": "6.7.8",
"raw-body": "2.5.1",
"react-hot-toast": "2.2.0",
"react-syntax-highlighter": "15.5.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"sharp": "0.31.0",

View File

@ -1,6 +1,7 @@
import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';
import { useRouter } from 'next/router';
import { Toaster } from 'react-hot-toast';
import { AccountLayout } from '@components/layouts';
@ -17,6 +18,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
<SessionProvider session={session}>
<AccountLayout>
<Component {...pageProps} />
<Toaster />
</AccountLayout>
</SessionProvider>
);

View File

@ -0,0 +1,157 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { useRouter } from 'next/router';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/outline';
import jackson from '@lib/jackson';
import { inferSSRProps } from '@lib/inferSSRProps';
import classNames from 'classnames';
const Edit: NextPage<inferSSRProps<typeof getServerSideProps>> = ({
directory: { id, name, log_webhook_events, webhook },
}) => {
const router = useRouter();
const [directory, setDirectory] = React.useState({
name,
log_webhook_events,
webhook_url: webhook.endpoint,
webhook_secret: webhook.secret,
});
const [loading, setLoading] = React.useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch(`/api/admin/directory-sync/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(directory),
});
setLoading(false);
const { data, error } = await rawResponse.json();
if (error) {
toast.error(error.message);
return;
}
if (data) {
toast.success('Directory updated successfully');
router.replace('/admin/directory-sync');
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target as HTMLInputElement;
const value = target.type === 'checkbox' ? target.checked : target.value;
setDirectory({
...directory,
[target.id]: value,
});
};
return (
<div>
<Link href='/admin/directory-sync'>
<a className='btn btn-outline items-center space-x-2'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span>Back</span>
</a>
</Link>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>Update Directory</h2>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Directory name</span>
</label>
<input
type='text'
id='name'
className='input input-bordered w-full'
required
onChange={onChange}
value={directory.name}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Webhook URL</span>
</label>
<input
type='text'
id='webhook_url'
className='input input-bordered w-full'
onChange={onChange}
value={directory.webhook_url}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Webhook secret</span>
</label>
<input
type='text'
id='webhook_secret'
className='input input-bordered w-full'
onChange={onChange}
value={directory.webhook_secret}
/>
</div>
<div className='form-control w-full py-2'>
<div className='flex items-center'>
<input
id='log_webhook_events'
type='checkbox'
checked={directory.log_webhook_events}
onChange={onChange}
className='h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600'
/>
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
Enable Webhook events logging
</label>
</div>
</div>
<div>
<button className={classNames('btn btn-primary', loading ? 'loading' : '')}>
Save Changes
</button>
</div>
</div>
</form>
</div>
</div>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId } = context.query;
const { directorySyncController } = await jackson();
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
return {
props: {
directory,
},
};
};
export default Edit;

View File

@ -0,0 +1,70 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { coy } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import jackson from '@lib/jackson';
import DirectoryTab from '@components/dsync/DirectoryTab';
import { inferSSRProps } from '@lib/inferSSRProps';
const EventInfo: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, event }) => {
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='events' />
<div className='my-3 rounded border text-sm'>
<SyntaxHighlighter language='json' style={coy}>
{JSON.stringify(event, null, 3)}
</SyntaxHighlighter>
</div>
</div>
</>
);
return (
<div>
<div className='mb-4 flex items-center justify-between'>
<h2 className='font-bold text-primary dark:text-white md:text-2xl'>{directory.name}</h2>
</div>
<DirectoryTab directory={directory} activeTab='events' />
<div className='w-3/4 rounded border text-sm'>
<SyntaxHighlighter language='json' style={coy}>
{JSON.stringify(event, null, 3)}
</SyntaxHighlighter>
</div>
</div>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId, eventId } = context.query;
const { directorySyncController } = await jackson();
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
const event = await directorySyncController.webhookLogs
.with(directory.tenant, directory.product)
.get(eventId as string);
if (!event) {
return {
notFound: true,
};
}
return {
props: {
directory,
event,
},
};
};
export default EventInfo;

View File

@ -0,0 +1,119 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { EyeIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import { useRouter } from 'next/router';
import jackson from '@lib/jackson';
import EmptyState from '@components/EmptyState';
import DirectoryTab from '@components/dsync/DirectoryTab';
import { inferSSRProps } from '@lib/inferSSRProps';
import Badge from '@components/Badge';
import classNames from 'classnames';
const Events: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, events }) => {
const [loading, setLoading] = React.useState(false);
const router = useRouter();
const clearEvents = async () => {
setLoading(true);
await fetch(`/api/admin/directory-sync/${directory.id}/events`, {
method: 'DELETE',
});
setLoading(false);
router.reload();
};
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='events' />
{events.length === 0 ? (
<EmptyState title='No webhook events found' />
) : (
<>
<div className='my-3 flex justify-end'>
<button
onClick={clearEvents}
className={classNames('btn btn-error btn-sm', loading ? 'loading' : '')}>
Clear Events
</button>
</div>
<div className='rounded border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr>
<th scope='col' className='px-6 py-3'>
Event Type
</th>
<th scope='col' className='px-6 py-3'>
Sent At
</th>
<th scope='col' className='px-6 py-3'>
Status Code
</th>
<th scope='col' className='px-6 py-3'></th>
</tr>
</thead>
<tbody>
{events.map((event) => {
return (
<tr
key={event.id}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
<td className='px-6 py-3 font-semibold'>{event.event}</td>
<td className='px-6 py-3'>{event.created_at.toString()}</td>
<td className='px-6 py-3'>
{event.status_code === 200 ? (
<Badge vairant='success'>200</Badge>
) : (
<Badge vairant='error'>{`${event.status_code}`}</Badge>
)}
</td>
<td className='px-6 py-3'>
<Link href={`/admin/directory-sync/${directory.id}/events/${event.id}`}>
<a>
<EyeIcon className='h-5 w-5' />
</a>
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId } = context.query;
const { directorySyncController } = await jackson();
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
const events = await directorySyncController.webhookLogs.with(directory.tenant, directory.product).getAll();
return {
props: {
directory,
events,
},
};
};
export default Events;

View File

@ -0,0 +1,56 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { coy } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import jackson from '@lib/jackson';
import DirectoryTab from '@components/dsync/DirectoryTab';
import { inferSSRProps } from '@lib/inferSSRProps';
const GroupInfo: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, group }) => {
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='groups' />
<div className='my-3 rounded border text-sm'>
<SyntaxHighlighter language='json' style={coy}>
{JSON.stringify(group, null, 3)}
</SyntaxHighlighter>
</div>
</div>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId, groupId } = context.query;
const { directorySyncController } = await jackson();
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
const { data: group } = await directorySyncController.groups
.with(directory.tenant, directory.product)
.get(groupId as string);
if (!group) {
return {
notFound: true,
};
}
return {
props: {
directory,
group,
},
};
};
export default GroupInfo;

View File

@ -0,0 +1,100 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import Link from 'next/link';
import { EyeIcon } from '@heroicons/react/outline';
import jackson from '@lib/jackson';
import EmptyState from '@components/EmptyState';
import Paginate from '@components/Paginate';
import DirectoryTab from '@components/dsync/DirectoryTab';
import { inferSSRProps } from '@lib/inferSSRProps';
const GroupsList: NextPage<inferSSRProps<typeof getServerSideProps>> = ({
directory,
groups,
pageOffset,
pageLimit,
}) => {
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='groups' />
{groups?.length === 0 && pageOffset === 0 ? (
<EmptyState title='No groups found' />
) : (
<div className='my-3 rounded border'>
<table className='w-full table-fixed text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr>
<th scope='col' className='w-5/6 px-6 py-3'>
Name
</th>
<th scope='col' className='w-1/6 px-6 py-3'>
Actions
</th>
</tr>
</thead>
<tbody>
{groups &&
groups.map((group) => {
return (
<tr
key={group.id}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
<td className='px-6 py-3'>{group.name}</td>
<td className='px-6 py-3'>
<Link href={`/admin/directory-sync/${directory.id}/groups/${group.id}`}>
<a>
<EyeIcon className='h-5 w-5' />
</a>
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
<Paginate
pageOffset={pageOffset}
pageLimit={pageLimit}
itemsCount={groups ? groups.length : 0}
path={`/admin/directory-sync/${directory.id}/groups?`}
/>
</div>
)}
</div>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId, offset = 0 } = context.query;
const { directorySyncController } = await jackson();
const pageOffset = parseInt(offset as string);
const pageLimit = 25;
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
const { data: groups } = await directorySyncController.groups
.setTenantAndProduct(directory.tenant, directory.product)
.list({ pageOffset, pageLimit });
return {
props: {
directory,
groups,
pageOffset,
pageLimit,
},
};
};
export default GroupsList;

View File

@ -0,0 +1,77 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import jackson from '@lib/jackson';
import DirectoryTab from '@components/dsync/DirectoryTab';
import { inferSSRProps } from '@lib/inferSSRProps';
const Info: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory }) => {
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='directory' />
<div className='my-3 rounded border'>
<dl>
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Directory ID</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.id}</dd>
</div>
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Tenant</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.tenant}</dd>
</div>
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Product</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.product}</dd>
</div>
<div className='border-b bg-gray-100 px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='pt-2 text-sm font-medium text-gray-500'>SCIM Endpoint</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.scim.endpoint}</dd>
</div>
<div className='border-b bg-gray-100 px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='pt-2 text-sm font-medium text-gray-500'>SCIM Token</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.scim.secret}</dd>
</div>
{directory.webhook.endpoint && directory.webhook.secret && (
<>
<div className='border-b px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='text-sm font-medium text-gray-500'>Webhook Endpoint</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.endpoint}
</dd>
</div>
<div className='px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
<dt className='pt-2 text-sm font-medium text-gray-500'>Webhook Secret</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.secret}
</dd>
</div>
</>
)}
</dl>
</div>
</div>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId } = context.query;
const { directorySyncController } = await jackson();
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
return {
props: {
directory,
},
};
};
export default Info;

View File

@ -0,0 +1,56 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { coy } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import jackson from '@lib/jackson';
import DirectoryTab from '@components/dsync/DirectoryTab';
import { inferSSRProps } from '@lib/inferSSRProps';
const UserInfo: NextPage<inferSSRProps<typeof getServerSideProps>> = ({ directory, user }) => {
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='users' />
<div className='my-3 rounded border text-sm'>
<SyntaxHighlighter language='json' style={coy}>
{JSON.stringify(user, null, 3)}
</SyntaxHighlighter>
</div>
</div>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId, userId } = context.query;
const { directorySyncController } = await jackson();
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
const { data: user } = await directorySyncController.users
.with(directory.tenant, directory.product)
.get(userId as string);
if (!user) {
return {
notFound: true,
};
}
return {
props: {
directory,
user,
},
};
};
export default UserInfo;

View File

@ -0,0 +1,119 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { EyeIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import { inferSSRProps } from '@lib/inferSSRProps';
import jackson from '@lib/jackson';
import EmptyState from '@components/EmptyState';
import Paginate from '@components/Paginate';
import DirectoryTab from '@components/dsync/DirectoryTab';
import Badge from '@components/Badge';
const UsersList: NextPage<inferSSRProps<typeof getServerSideProps>> = ({
directory,
users,
pageOffset,
pageLimit,
}) => {
return (
<>
<h2 className='font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='users' />
{users?.length === 0 && pageOffset === 0 ? (
<EmptyState title='No users found' />
) : (
<div className='my-3 rounded border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr>
<th scope='col' className='px-6 py-3'>
First Name
</th>
<th scope='col' className='px-6 py-3'>
Last Name
</th>
<th scope='col' className='px-6 py-3'>
Email
</th>
<th scope='col' className='px-6 py-3'>
Status
</th>
<th scope='col' className='px-6 py-3'>
Actions
</th>
</tr>
</thead>
<tbody>
{users &&
users.map((user) => {
return (
<tr
key={user.id}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
<td className='px-6 py-3'>{user.first_name}</td>
<td className='px-6 py-3'>{user.last_name}</td>
<td className='px-6 py-3'>{user.email}</td>
<td className='px-6 py-3'>
{user.active ? (
<Badge vairant='success'>Active</Badge>
) : (
<Badge vairant='warning'>Suspended</Badge>
)}
</td>
<td className='px-6 py-3'>
<Link href={`/admin/directory-sync/${directory.id}/users/${user.id}`}>
<a>
<EyeIcon className='h-5 w-5' />
</a>
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
<Paginate
pageOffset={pageOffset}
pageLimit={pageLimit}
itemsCount={users ? users.length : 0}
path={`/admin/directory-sync/${directory.id}/users?`}
/>
</div>
)}
</div>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { directoryId, offset = 0 } = context.query;
const { directorySyncController } = await jackson();
const pageOffset = parseInt(offset as string);
const pageLimit = 25;
const { data: directory } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return {
notFound: true,
};
}
const { data: users } = await directorySyncController.users
.setTenantAndProduct(directory.tenant, directory.product)
.list({ pageOffset, pageLimit });
return {
props: {
directory,
users,
pageOffset,
pageLimit,
},
};
};
export default UsersList;

View File

@ -0,0 +1,109 @@
import type { InferGetServerSidePropsType, GetServerSidePropsContext } from 'next';
import Link from 'next/link';
import { PencilAltIcon, DatabaseIcon } from '@heroicons/react/outline';
import jackson from '@lib/jackson';
import EmptyState from '@components/EmptyState';
import Paginate from '@components/Paginate';
const Index = ({
directories,
pageOffset,
pageLimit,
providers,
}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
return (
<>
<div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>Directory Sync</h2>
<Link href={'/admin/directory-sync/new'}>
<a className='btn btn-primary'>+ New Directory</a>
</Link>
</div>
{directories?.length === 0 && pageOffset === 0 ? (
<EmptyState title='No directories found' href='/admin/directory-sync/new' />
) : (
<div className='rounder border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr>
<th scope='col' className='px-6 py-3'>
Name
</th>
<th scope='col' className='px-6 py-3'>
Tenant
</th>
<th scope='col' className='px-6 py-3'>
Product
</th>
<th scope='col' className='px-6 py-3'>
Type
</th>
<th scope='col' className='px-6 py-3'>
Actions
</th>
</tr>
</thead>
<tbody>
{directories &&
directories.map((directory) => {
return (
<tr
key={directory.id}
className='border-b bg-white last:border-b-0 dark:border-gray-700 dark:bg-gray-800'>
<td className='whitespace-nowrap px-6 py-3 text-sm text-gray-500 dark:text-gray-400'>
{directory.name}
</td>
<td className='px-6'>{directory.tenant}</td>
<td className='px-6'>{directory.product}</td>
<td className='px-6'>{providers[directory.type]}</td>
<td className='px-6'>
<div className='flex flex-row'>
<Link href={`/admin/directory-sync/${directory.id}`}>
<a className='link-primary'>
<DatabaseIcon className='h-5 w-5 text-secondary' />
</a>
</Link>
<Link href={`/admin/directory-sync/${directory.id}/edit`}>
<a className='link-primary'>
<PencilAltIcon className='h-5 w-5 text-secondary' />
</a>
</Link>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<Paginate
pageOffset={pageOffset}
pageLimit={pageLimit}
itemsCount={directories ? directories.length : 0}
path={`/admin/directory-sync?`}
/>
</div>
)}
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { offset = 0 } = context.query;
const { directorySyncController } = await jackson();
const pageOffset = parseInt(offset as string);
const pageLimit = 25;
const { data: directories } = await directorySyncController.directories.list({ pageOffset, pageLimit });
return {
props: {
providers: directorySyncController.providers(),
directories,
pageOffset,
pageLimit,
},
};
};
export default Index;

View File

@ -0,0 +1,166 @@
import type { NextPage, GetServerSideProps } from 'next';
import React from 'react';
import { useRouter } from 'next/router';
import toast from 'react-hot-toast';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/outline';
import classNames from 'classnames';
import jackson from '@lib/jackson';
const New: NextPage<{ providers: any }> = ({ providers }) => {
const router = useRouter();
const [loading, setLoading] = React.useState(false);
const [directory, setDirectory] = React.useState({
name: '',
tenant: '',
product: '',
webhook_url: '',
webhook_secret: '',
type: '',
});
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch('/api/admin/directory-sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(directory),
});
setLoading(false);
const { data, error } = await rawResponse.json();
if (error) {
toast.error(error.message);
return;
}
if (data) {
toast.success('Directory created successfully');
router.replace(`/admin/directory-sync/${data.id}`);
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setDirectory({
...directory,
[target.id]: target.value,
});
};
return (
<div>
<Link href='/admin/directory-sync'>
<a className='btn btn-outline items-center space-x-2'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span>Back</span>
</a>
</Link>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>New Directory</h2>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Directory name</span>
</label>
<input
type='text'
id='name'
className='input input-bordered w-full'
required
onChange={onChange}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Directory provider</span>
</label>
<select className='select select-bordered w-full' id='type' onChange={onChange} required>
{Object.keys(providers).map((key) => {
return (
<option key={key} value={key}>
{providers[key]}
</option>
);
})}
</select>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Tenant</span>
</label>
<input
type='text'
id='tenant'
className='input input-bordered w-full'
required
onChange={onChange}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Product</span>
</label>
<input
type='text'
id='product'
className='input input-bordered w-full'
required
onChange={onChange}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Webhook URL</span>
</label>
<input
type='text'
id='webhook_url'
className='input input-bordered w-full'
onChange={onChange}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>Webhook secret</span>
</label>
<input
type='text'
id='webhook_secret'
className='input input-bordered w-full'
onChange={onChange}
/>
</div>
<div>
<button className={classNames('btn btn-primary', loading ? 'loading' : '')}>
Create Directory
</button>
</div>
</div>
</form>
</div>
</div>
);
};
export const getServerSideProps: GetServerSideProps = async () => {
const { directorySyncController } = await jackson();
return {
props: {
providers: directorySyncController.providers(),
},
};
};
export default New;

View File

@ -60,7 +60,7 @@ const SAMLConfigurations: NextPage = () => {
{samlConfigs.map((samlConfig) => (
<tr
key={samlConfig.clientID}
className='border-b bg-white dark:border-gray-700 dark:bg-gray-800'>
className='border-b bg-white last:border-b-0 dark:border-gray-700 dark:bg-gray-800'>
<td className='whitespace-nowrap px-6 py-3 text-sm font-medium text-gray-900 dark:text-white'>
{samlConfig.tenant}
</td>

View File

@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { checkSession } from '@lib/middleware';
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
switch (method) {
case 'DELETE':
return handleDELETE(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
};
// Delete all events
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { directoryId } = req.query;
const { directorySyncController } = await jackson();
const { data: directory, error } = await directorySyncController.directories.get(directoryId as string);
if (!directory) {
return res.status(404).json({ data: null, error });
}
await directorySyncController.webhookLogs.setTenantAndProduct(directory.tenant, directory.product).clear();
return res.status(201).json({ data: null, error: null });
};
export default checkSession(handler);

View File

@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { checkSession } from '@lib/middleware';
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
switch (method) {
case 'PUT':
return handlePUT(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
};
// Update a directory configuration
const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
const { directoryId } = req.query;
const { directorySyncController } = await jackson();
const { name, webhook_url, webhook_secret, log_webhook_events } = req.body;
const { data, error } = await directorySyncController.directories.update(directoryId as string, {
name,
log_webhook_events,
webhook: {
endpoint: webhook_url,
secret: webhook_secret,
},
});
return res.status(error ? error.code : 201).json({ data, error });
};
export default checkSession(handler);

View File

@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { DirectoryType } from '@lib/jackson';
import jackson from '@lib/jackson';
import { checkSession } from '@lib/middleware';
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { method } = req;
switch (method) {
case 'POST':
return handlePOST(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
};
// Create a new configuration
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { name, tenant, product, type, webhook_url, webhook_secret } = req.body;
const { data, error } = await directorySyncController.directories.create({
name,
tenant,
product,
type: type as DirectoryType,
webhook_url,
webhook_secret,
});
return res.status(error ? error.code : 201).json({ data, error });
};
export default checkSession(handler);

View File

@ -1,7 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { extractAuthToken } from '@lib/utils';
import { extractAuthToken } from '@lib/auth';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {

View File

@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { HTTPMethod, DirectorySyncRequest } from '@lib/jackson';
import jackson from '@lib/jackson';
import { extractAuthToken } from '@lib/auth';
import { bodyParser } from '@lib/utils';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { directorySyncController } = await jackson();
const { method, query } = req;
const params = query.directory as string[];
const [directoryId, path, resourceId] = params;
// Handle the SCIM API requests
const request: DirectorySyncRequest = {
method: method as HTTPMethod,
body: bodyParser(req),
directoryId,
resourceId,
resourceType: path === 'Users' ? 'users' : 'groups',
apiSecret: extractAuthToken(req),
query: {
count: req.query.count ? parseInt(req.query.count as string) : undefined,
startIndex: req.query.startIndex ? parseInt(req.query.startIndex as string) : undefined,
filter: req.query.filter as string,
},
};
const { status, data } = await directorySyncController.requests.handle(
request,
directorySyncController.events.callback
);
return res.status(status).json(data);
}

View File

@ -0,0 +1,25 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
}
// Get directory by id
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { directoryId } = req.query;
const { data, error } = await directorySyncController.directories.get(directoryId as string);
return res.status(error ? error.code : 200).json({ data, error });
};

View File

@ -0,0 +1,32 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
}
// Get a group by id
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { tenant, product, groupId } = req.query;
directorySyncController.groups.setTenantAndProduct(<string>tenant, <string>product);
const { data: group, error } = await directorySyncController.groups.get(<string>groupId);
// Get the members of the group if it exists
if (group) {
group['members'] = await directorySyncController.groups.getAllUsers(<string>groupId);
}
return res.status(error ? error.code : 200).json({ data: group, error });
};

View File

@ -0,0 +1,27 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
}
// Get the groups
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { tenant, product } = req.query;
const { data, error } = await directorySyncController.groups
.setTenantAndProduct(<string>tenant, <string>product)
.list({});
return res.status(error ? error.code : 200).json({ data, error });
};

View File

@ -0,0 +1,56 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
case 'POST':
return handlePOST(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
}
// Get the configurations
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { tenant, product } = req.query;
// If tenant and product are specified, get the configuration by tenant and product
if (tenant && product) {
const { data, error } = await directorySyncController.directories.getByTenantAndProduct(
tenant as string,
product as string
);
return res.status(error ? error.code : 200).json({ data, error });
}
// otherwise, get all configurations
const { data, error } = await directorySyncController.directories.list({});
return res.status(error ? error.code : 200).json({ data, error });
};
// Create a new configuration
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { name, tenant, type, product, webhook_url, webhook_secret } = req.body;
const { data, error } = await directorySyncController.directories.create({
name,
tenant,
product,
type,
webhook_url,
webhook_secret,
});
return res.status(error ? error.code : 201).json({ data, error });
};

View File

@ -0,0 +1,27 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
}
// Get a user by id
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { tenant, product, userId } = req.query;
const { data, error } = await directorySyncController.users
.setTenantAndProduct(<string>tenant, <string>product)
.get(<string>userId);
return res.status(error ? error.code : 200).json({ data, error });
};

View File

@ -0,0 +1,27 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
}
// Get the users
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { directorySyncController } = await jackson();
const { tenant, product } = req.query;
const { data, error } = await directorySyncController.users
.setTenantAndProduct(<string>tenant, <string>product)
.list({});
return res.status(error ? error.code : 200).json({ data, error });
};

View File

@ -1,15 +1,8 @@
import jackson from '@lib/jackson';
import { extractAuthToken, validateApiKey } from '@lib/utils';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const apiKey = extractAuthToken(req);
if (!validateApiKey(apiKey)) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const { apiController } = await jackson();
if (req.method === 'POST') {
res.json(await apiController.config(req.body));

View File

@ -1,15 +1,8 @@
import jackson from '@lib/jackson';
import { extractAuthToken, validateApiKey } from '@lib/utils';
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const apiKey = extractAuthToken(req);
if (!validateApiKey(apiKey)) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const { apiController } = await jackson();
if (req.method === 'GET') {
const rsp = await apiController.getConfig(req.query as any);

View File

@ -0,0 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ data: null, error: { message: 'Unauthorized' } });
}