Federated SAML (#685)

* Add alert component

* Add a loading state component

* Now Emptystate accept an optional prop description

* SAML federation create app controller

* Add the UI to create and list SAML federation apps

* Create SAML federation app and metadata

* wip

* wip

* wip

* Cleanup

* Fix the return values

* Delete the session after the SAML response is sent to the user

* wip

* Revert the changes to the ConnectionAPIController

* wip - IdP selection, session fixes

* Fix the flow

* Refactor

* Refactor

* wip

* Refactor the idp selection page - wip

* Refactor

* Refactor the resolve connection

* Refactor the idp selection

* Refactor the idp/app selection and other fixes

* wip

* Refactor

* Refactor the SAML response handling to merge the logic

* Rename the methods

* Move the saml federation to /ee folder

* Fix the imported types

* wip

* wip /ee

* Move the federated SAML UI to /ee

* Move to /ee folder

* wip admin portal

* Delete the SAML federation app

* Rename the controllers

* Add the translation

* Add the proper license check

* Add the unit tests

* tweaks to test

* tweaks to test

* Changes to the controller and other cleanup

* Fix API routes headers

* Use new toast

* Add button to download cert

* Tweaks

* log cleanup

* saml federation is part of enterprise sso

* entityID now contains the unique hash needed for each tenant + product combination

* cleanup

* cleanup

* we don't need a unique entityID

* text tweaks

Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2022-12-16 21:08:59 +05:30 committed by GitHub
parent 2cf9675794
commit 7287a6bb37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 2659 additions and 484 deletions

View File

@ -55,4 +55,7 @@ OPENID_RSA_PUBLIC_KEY=
PUBLIC_KEY=
# Base64 encoded value of private key `cat key.pem | base64`
PRIVATE_KEY=
PRIVATE_KEY=
# To enable enterprise-only features, fill your license key in here.
BOXYHQ_LICENSE_KEY=

31
components/Alert.tsx Normal file
View File

@ -0,0 +1,31 @@
const Alert = ({ type, message }: { type?: 'error' | 'success' | 'warning'; message: string }) => {
const alertType = {
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning',
};
const variant = type ? alertType[type] : '';
return (
<div className={`alert mb-5 rounded ${variant}`}>
<div>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-6 w-6 flex-shrink-0 stroke-current'
fill='none'
viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z'
/>
</svg>
<span>{message}</span>
</div>
</div>
);
};
export default Alert;

View File

@ -2,13 +2,25 @@ import Link from 'next/link';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import { useTranslation } from 'next-i18next';
const EmptyState = ({ title, href, className }: { title: string; href?: string; className?: string }) => {
const EmptyState = ({
title,
href,
className,
description,
}: {
title: string;
href?: string;
className?: string;
description?: string;
}) => {
const { t } = useTranslation('common');
return (
<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>
{description && <p className='text-center text-gray-500'>{description}</p>}
{href && (
<Link href={href} className='btn-primary btn'>
+ {t('create_new')}

View File

@ -0,0 +1,36 @@
import useSWR from 'swr';
import { fetcher } from '@lib/ui/utils';
import EmptyState from './EmptyState';
import Loading from './Loading';
type Props = {
children: React.ReactNode;
};
const LicenseRequired = (props: Props) => {
const { children } = props;
const { data, error } = useSWR<{ data: { status: boolean } }>('/api/admin/license', fetcher);
if (!data && !error) {
return <Loading />;
}
const hasValidLicense = data?.data.status;
return (
<>
{hasValidLicense ? (
children
) : (
<EmptyState
title='This is an Enterprise feature.'
description="Please add a valid license to use this feature. If you don't have a license, please contact BoxyHQ Support."
/>
)}
</>
);
};
export default LicenseRequired;

9
components/Loading.tsx Normal file
View File

@ -0,0 +1,9 @@
const Loading = () => {
return (
<div className='flex items-center justify-center'>
<progress className='progress progress-primary w-56'></progress>
</div>
);
};
export default Loading;

View File

@ -1,4 +1,11 @@
import { ShieldCheckIcon, UsersIcon, HomeIcon, LinkIcon, ListBulletIcon } from '@heroicons/react/20/solid';
import {
SquaresPlusIcon,
ShieldCheckIcon,
UsersIcon,
HomeIcon,
LinkIcon,
ListBulletIcon,
} from '@heroicons/react/20/solid';
import Image from 'next/image';
import Link from 'next/link';
import classNames from 'classnames';
@ -40,6 +47,12 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any; hideMenus?: bo
icon: LinkIcon,
active: asPath.includes('/admin/sso-connection/setup-link'),
},
{
href: '/admin/federated-saml',
text: t('saml_federation'),
icon: SquaresPlusIcon,
active: asPath.includes('/admin/federated-saml'),
},
],
},
{

View File

@ -1,5 +1,5 @@
import Link from 'next/link';
import type { Directory } from '@lib/jackson';
import type { Directory } from '@boxyhq/saml-jackson';
import classNames from 'classnames';
const DirectoryTab = (props: { directory: Directory; activeTab: string; token?: any }) => {

1
ee/LICENSE Normal file
View File

@ -0,0 +1 @@
The BoxyHQ Enterprise Edition (EE) license (the “EE License”)

View File

@ -0,0 +1,101 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { checkSession } from '@lib/middleware';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { checkLicense } = await jackson();
if (!(await checkLicense())) {
return res.status(404).json({
error: { message: 'License not found. Please add a valid license to use this feature.' },
});
}
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
case 'PUT':
return handlePUT(req, res);
case 'DELETE':
return handleDELETE(req, res);
default:
res.setHeader('Allow', ['GET, PUT, DELETE']);
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } });
}
};
// Get SAML Federation app by id
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { id } = req.query as { id: string };
try {
const app = await samlFederatedController.app.get(id);
const metadata = await samlFederatedController.app.getMetadata(id);
return res.status(200).json({
data: {
...app,
metadata,
},
});
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({
error: { message },
});
}
};
// Update SAML Federation app
const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { id } = req.query as { id: string };
const { name, acsUrl, entityId } = req.body as Pick<SAMLFederationApp, 'acsUrl' | 'entityId' | 'name'>;
try {
const updatedApp = await samlFederatedController.app.update(id, {
name,
acsUrl,
entityId,
});
res.status(200).json({
data: updatedApp,
});
} catch (error: any) {
const { message, statusCode = 500 } = error;
res.status(statusCode).json({
error: { message },
});
}
};
// Delete the SAML Federation app
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { id } = req.query as { id: string };
try {
await samlFederatedController.app.delete(id);
return res.status(200).json({ data: {} });
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({
error: { message },
});
}
};
export default checkSession(handler);

View File

@ -0,0 +1,71 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { checkSession } from '@lib/middleware';
import jackson from '@lib/jackson';
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { checkLicense } = await jackson();
if (!(await checkLicense())) {
return res.status(404).json({
error: { message: 'License not found. Please add a valid license to use this feature.' },
});
}
const { method } = req;
switch (method) {
case 'POST':
return handlePOST(req, res);
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET, POST']);
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
};
// Create new SAML Federation app
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { name, tenant, product, acsUrl, entityId } = req.body;
try {
const app = await samlFederatedController.app.create({
name,
tenant,
product,
acsUrl,
entityId,
});
return res.status(201).json({ data: app });
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({
error: { message },
});
}
};
// Get SAML Federation apps
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
try {
const apps = await samlFederatedController.app.getAll();
res.status(200).json({ data: apps });
} catch (error: any) {
const { message, statusCode = 500 } = error;
res.status(statusCode).json({
error: { message },
});
}
};
export default checkSession(handler);

View File

@ -0,0 +1,55 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import stream from 'stream';
import { promisify } from 'util';
const pipeline = promisify(stream.pipeline);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { checkLicense } = await jackson();
if (!(await checkLicense())) {
return res.status(404).json({
error: { message: 'License not found. Please add a valid license to use this feature.' },
});
}
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
}
// Display the metadata for the SAML federation
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { appId, download } = req.query as { appId: string; download: any };
try {
const metadata = await samlFederatedController.app.getMetadata(appId);
res.setHeader('Content-type', 'text/xml');
if (download || download === '') {
res.setHeader('Content-Disposition', `attachment; filename=saml-metadata-${appId}.xml`);
await pipeline(metadata.xml, res);
return;
}
res.status(200).send(metadata.xml);
} catch (error: any) {
const { message, statusCode = 500 } = error;
return res.status(statusCode).json({
error: { message },
});
}
};

View File

@ -0,0 +1,43 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { checkLicense } = await jackson();
if (!(await checkLicense())) {
return res.status(404).json({
error: { message: 'License not found. Please add a valid license to use this feature.' },
});
}
const { method } = req;
switch (method) {
case 'GET':
return handleGET(req, res);
default:
res.setHeader('Allow', ['GET']);
res.status(405).json({ error: { message: `Method ${method} Not Allowed` } });
}
}
// Handle the SAML Request from Service Provider
// This endpoint act as Single Sign On (SSO) URL
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const { SAMLRequest, RelayState, idp_hint } = req.query as {
SAMLRequest: string;
RelayState: string;
idp_hint: string;
};
const { redirectUrl } = await samlFederatedController.sso.getAuthorizeUrl({
request: SAMLRequest,
relayState: RelayState,
idp_hint,
});
return res.redirect(redirectUrl);
};

View File

@ -0,0 +1,218 @@
import type { NextPage } from 'next';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import useSWR from 'swr';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { fetcher } from '@lib/ui/utils';
import Loading from '@components/Loading';
import LicenseRequired from '@components/LicenseRequired';
import { errorToast, successToast } from '@components/Toast';
import ConfirmationModal from '@components/ConfirmationModal';
import type { ApiError, ApiResponse, ApiSuccess } from 'types';
const UpdateApp: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const [loading, setLoading] = useState(false);
const [app, setApp] = useState<SAMLFederationApp>({
id: '',
name: '',
tenant: '',
product: '',
acsUrl: '',
entityId: '',
});
const { id } = router.query as { id: string };
const { data, error } = useSWR<ApiSuccess<SAMLFederationApp>, ApiError>(
`/api/admin/federated-saml/${id}`,
fetcher,
{
revalidateOnFocus: false,
}
);
useEffect(() => {
if (data) {
setApp(data.data);
}
}, [data]);
if (error) {
errorToast(error.message);
return null;
}
if (!data) {
return <Loading />;
}
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch(`/api/admin/federated-saml/${app.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(app),
});
setLoading(false);
const response: ApiResponse<SAMLFederationApp> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
successToast(t('saml_federation_update_success'));
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setApp({
...app,
[target.id]: target.value,
});
};
return (
<LicenseRequired>
<Link href='/admin/federated-saml' className='btn-outline btn items-center space-x-2'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span>{t('back')}</span>
</Link>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('saml_federation_update_app')}</h2>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('tenant')}</span>
</label>
<input type='text' className='input-bordered input' defaultValue={app.tenant} disabled />
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('product')}</span>
</label>
<input type='text' className='input-bordered input' defaultValue={app.product} disabled />
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('name')}</span>
</label>
<input
type='text'
id='name'
className='input-bordered input'
required
onChange={onChange}
value={app.name}
/>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('acs_url')}</span>
</label>
<input
type='url'
id='acsUrl'
className='input-bordered input'
required
onChange={onChange}
value={app.acsUrl}
/>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>
<input
type='url'
id='entityId'
className='input-bordered input'
required
onChange={onChange}
value={app.entityId}
/>
</div>
<div>
<button className={classNames('btn-primary btn', loading ? 'loading' : '')}>
{t('save_changes')}
</button>
</div>
</div>
</form>
</div>
<DeleteApp app={app} />
</LicenseRequired>
);
};
export const DeleteApp = ({ app }: { app: SAMLFederationApp }) => {
const { t } = useTranslation('common');
const [delModalVisible, setDelModalVisible] = useState(false);
const deleteApp = async () => {
const rawResponse = await fetch(`/api/admin/federated-saml/${app.id}`, {
method: 'DELETE',
});
const response: ApiResponse<unknown> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
successToast(t('saml_federation_delete_success'));
window.location.href = '/admin/federated-saml';
}
};
return (
<>
<section className='mt-5 flex items-center rounded bg-red-100 p-6 text-red-900'>
<div className='flex-1'>
<h6 className='mb-1 font-medium'>{t('delete_this_saml_federation_app')}</h6>
<p className='font-light'>{t('all_your_apps_using_this_connection_will_stop_working')}</p>
</div>
<button
type='button'
className='btn-error btn'
data-modal-toggle='popup-modal'
onClick={() => {
setDelModalVisible(true);
}}>
Delete
</button>
</section>
<ConfirmationModal
title={t('delete_the_saml_federation_app')}
description={t('confirmation_modal_description')}
visible={delModalVisible}
onConfirm={deleteApp}
onCancel={() => {
setDelModalVisible(false);
}}
/>
</>
);
};
export default UpdateApp;

View File

@ -0,0 +1,99 @@
import type { NextPage } from 'next';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
import useSWR from 'swr';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import Loading from '@components/Loading';
import EmptyState from '@components/EmptyState';
import LicenseRequired from '@components/LicenseRequired';
import { errorToast } from '@components/Toast';
const AppsList: NextPage = () => {
const { t } = useTranslation('common');
const { data, error } = useSWR<ApiSuccess<SAMLFederationApp[]>, ApiError>(
'/api/admin/federated-saml',
fetcher
);
if (error) {
errorToast(error.message);
return null;
}
if (!data) {
return <Loading />;
}
const apps = data.data;
const noApps = apps && apps.length === 0;
return (
<LicenseRequired>
<div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{t('saml_federation_apps')}</h2>
<Link href={'/admin/federated-saml/new'} className='btn-primary btn'>
+ {t('new_saml_federation_app')}
</Link>
</div>
{noApps ? (
<>
<EmptyState title={t('no_saml_federation_apps')} href='/admin/federated-saml/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'>
{t('name')}
</th>
<th scope='col' className='px-6 py-3'>
{t('tenant')}
</th>
<th scope='col' className='px-6 py-3'>
{t('product')}
</th>
<th scope='col' className='px-6 py-3'>
{t('metadata')}
</th>
</tr>
</thead>
<tbody>
{apps &&
apps.map((app) => {
return (
<tr
key={app.id}
className='border-b bg-white last:border-b-0 dark:border-gray-700 dark:bg-gray-800'>
<td className='px-6 py-3'>
<Link
href={`/admin/federated-saml/${app.id}/edit`}
className='link-primary link underline-offset-4'>
{app.name}
</Link>
</td>
<td className='px-6 py-3'>{app.tenant}</td>
<td className='px-6'>{app.product}</td>
<td className='px-6'>
<Link
href={`/admin/federated-saml/${app.id}/metadata`}
className='link-secondary link underline-offset-4'>
{t('view')}
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</LicenseRequired>
);
};
export default AppsList;

View File

@ -0,0 +1,121 @@
import useSWR from 'swr';
import Link from 'next/link';
import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationAppWithMetadata } from '@boxyhq/saml-jackson';
import { fetcher } from '@lib/ui/utils';
import Loading from '@components/Loading';
import { errorToast } from '@components/Toast';
import type { ApiError, ApiSuccess } from 'types';
import LicenseRequired from '@components/LicenseRequired';
const Metadata: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const { id } = router.query as { id: string };
const { data, error } = useSWR<ApiSuccess<SAMLFederationAppWithMetadata>, ApiError>(
`/api/admin/federated-saml/${id}`,
fetcher
);
if (error) {
errorToast(error.message);
return null;
}
if (!data) {
return <Loading />;
}
const app = data.data;
return (
<LicenseRequired>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('saml_federation_app_info')}</h2>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<div className='flex flex-col'>
<div className='space-y-3'>
<p className='text-sm leading-6 text-gray-800'>{t('saml_federation_app_info_details')}</p>
<div className='flex flex-row gap-5'>
<Link
href={`/api/federated-saml/${id}/metadata?download=true`}
className='btn-outline btn-secondary btn'>
<svg
className='mr-1 inline-block h-6 w-6'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
aria-hidden
strokeWidth='2'>
<path
strokeLinecap='round'
strokeLinejoin='round'
d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'
/>
</svg>
{t('download_metadata')}
</Link>
<Link
href={`/api/federated-saml/${id}/metadata`}
className='btn-outline btn-secondary btn'
target='_blank'>
{t('metadata_url')}
</Link>
</div>
</div>
<div className='divider'>OR</div>
<div className='space-y-6'>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text font-bold'>{t('sso_url')}</span>
</label>
<input
type='text'
className='input-bordered input w-full'
defaultValue={app.metadata.ssoUrl}
readOnly
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text font-bold'>{t('entity_id')}</span>
</label>
<input
type='text'
className='input-bordered input w-full'
defaultValue={app.metadata.entityId}
readOnly
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<div className='flex w-full items-center justify-between'>
<span className='label-text font-bold'>{t('x509_certificate')}</span>
<span>
<a
href='/.well-known/saml.cer'
target='_blank'
className='label-text font-bold text-gray-500'>
{t('download')}
</a>
</span>
</div>
</label>
<textarea
className='textarea-bordered textarea w-full'
rows={10}
defaultValue={app.metadata.x509cert.trim()}
readOnly></textarea>
</div>
</div>
</div>
</div>
</LicenseRequired>
);
};
export default Metadata;

View File

@ -0,0 +1,151 @@
import type { NextPage } from 'next';
import { useState } from 'react';
import Link from 'next/link';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
import type { ApiResponse } from 'types';
import LicenseRequired from '@components/LicenseRequired';
import { errorToast, successToast } from '@components/Toast';
const NewApp: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const [loading, setLoading] = useState(false);
const [newApp, setApp] = useState({
name: '',
tenant: '',
product: '',
acsUrl: '',
entityId: '',
});
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch('/api/admin/federated-saml', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newApp),
});
setLoading(false);
const response: ApiResponse<SAMLFederationApp> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
successToast(t('saml_federation_new_success'));
router.replace(`/admin/federated-saml/${response.data.id}/metadata`);
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setApp({
...newApp,
[target.id]: target.value,
});
};
return (
<LicenseRequired>
<Link href='/admin/federated-saml' className='btn-outline btn items-center space-x-2'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span>{t('back')}</span>
</Link>
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('saml_federation_add_new_app')}</h2>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='flex flex-col space-y-3'>
<p className='text-sm leading-6 text-gray-800'>{t('saml_federation_add_new_app_description')}</p>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('name')}</span>
</label>
<input
type='text'
id='name'
className='input-bordered input'
required
onChange={onChange}
placeholder='Your app'
/>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('tenant')}</span>
</label>
<input
type='text'
id='tenant'
className='input-bordered input'
required
onChange={onChange}
placeholder='boxyhq'
/>
</div>
<div className='form-control w-full md:w-1/2'>
<label className='label'>
<span className='label-text'>{t('product')}</span>
</label>
<input
type='text'
id='product'
className='input-bordered input'
required
onChange={onChange}
placeholder='saml-jackson'
/>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('acs_url')}</span>
</label>
<input
type='url'
id='acsUrl'
className='input-bordered input'
required
onChange={onChange}
placeholder='https://your-idp.com/saml/acs'
/>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>
<input
type='url'
id='entityId'
className='input-bordered input'
required
onChange={onChange}
placeholder='https://your-idp.com/saml/entityId'
/>
</div>
<div>
<button className={classNames('btn-primary btn', loading ? 'loading' : '')}>
{t('create_app')}
</button>
</div>
</div>
</form>
</div>
</LicenseRequired>
);
};
export default NewApp;

View File

@ -1,18 +1,14 @@
import type { DatabaseEngine, DatabaseType, JacksonOption } from '@boxyhq/saml-jackson';
import type { DatabaseEngine, DatabaseOption, DatabaseType, JacksonOption } from '@boxyhq/saml-jackson';
const hostUrl = process.env.HOST_URL || 'localhost';
const hostPort = Number(process.env.PORT || '5225');
const externalUrl = process.env.EXTERNAL_URL || 'http://' + hostUrl + ':' + hostPort;
const samlPath = '/api/oauth/saml';
const oidcPath = '/api/oauth/oidc';
const idpDiscoveryPath = '/idp/select';
const hostUrl = process.env.HOST_URL || 'localhost';
const hostPort = Number(process.env.PORT || '5225');
const externalUrl = process.env.EXTERNAL_URL || 'http://' + hostUrl + ':' + hostPort;
const apiKeys = (process.env.JACKSON_API_KEYS || '').split(',');
const samlAudience = process.env.SAML_AUDIENCE;
const preLoadedConnection = process.env.PRE_LOADED_CONNECTION || process.env.PRE_LOADED_CONFIG;
const idpEnabled = process.env.IDP_ENABLED === 'true';
let ssl;
if (process.env.DB_SSL === 'true') {
ssl = {
@ -20,7 +16,7 @@ if (process.env.DB_SSL === 'true') {
};
}
const db = {
const db: DatabaseOption = {
engine: process.env.DB_ENGINE ? <DatabaseEngine>process.env.DB_ENGINE : undefined,
url: process.env.DB_URL || process.env.DATABASE_URL,
type: process.env.DB_TYPE ? <DatabaseType>process.env.DB_TYPE : undefined,
@ -31,30 +27,28 @@ const db = {
ssl,
};
const clientSecretVerifier = process.env.CLIENT_SECRET_VERIFIER;
const jwsAlg = process.env.OPENID_JWS_ALG;
const jwtSigningKeys = {
private: process.env.OPENID_RSA_PRIVATE_KEY || '',
public: process.env.OPENID_RSA_PUBLIC_KEY || '',
};
const openid = { jwsAlg, jwtSigningKeys };
const jacksonOptions: JacksonOption = {
externalUrl,
samlPath,
oidcPath,
idpDiscoveryPath,
samlAudience,
preLoadedConnection,
idpEnabled,
samlAudience: process.env.SAML_AUDIENCE,
preLoadedConnection: process.env.PRE_LOADED_CONNECTION || process.env.PRE_LOADED_CONFIG,
idpEnabled: process.env.IDP_ENABLED === 'true',
db,
clientSecretVerifier,
openid,
clientSecretVerifier: process.env.CLIENT_SECRET_VERIFIER,
openid: {
jwsAlg: process.env.OPENID_JWS_ALG,
jwtSigningKeys: {
private: process.env.OPENID_RSA_PRIVATE_KEY || '',
public: process.env.OPENID_RSA_PUBLIC_KEY || '',
},
},
certs: {
publicKey: process.env.PUBLIC_KEY || '',
privateKey: process.env.PRIVATE_KEY || '',
},
boxyhqLicenseKey: process.env.BOXYHQ_LICENSE_KEY,
};
export { apiKeys };

View File

@ -18,6 +18,7 @@ import type {
DirectorySyncRequest,
IOidcDiscoveryController,
ISPSAMLConfig,
ISAMLFederationController,
GetConnectionsQuery,
GetIDPEntityIDBody,
GetConfigQuery,
@ -36,6 +37,8 @@ let setupLinkController: ISetupLinkController;
let directorySyncController: IDirectorySyncController;
let oidcDiscoveryController: IOidcDiscoveryController;
let spConfig: ISPSAMLConfig;
let samlFederatedController: ISAMLFederationController;
let checkLicense: () => Promise<boolean>;
const g = global as any;
@ -49,7 +52,8 @@ export default async function init() {
!g.directorySync ||
!g.setupLinkController ||
!g.oidcDiscoveryController ||
!g.spConfig
!g.spConfig ||
!g.samlFederatedController
) {
const ret = await jackson(jacksonOptions);
connectionAPIController = ret.connectionAPIController;
@ -61,6 +65,8 @@ export default async function init() {
directorySyncController = ret.directorySyncController;
oidcDiscoveryController = ret.oidcDiscoveryController;
spConfig = ret.spConfig;
samlFederatedController = ret.samlFederatedController;
checkLicense = ret.checkLicense;
g.connectionAPIController = connectionAPIController;
g.oauthController = oauthController;
@ -72,6 +78,8 @@ export default async function init() {
g.oidcDiscoveryController = oidcDiscoveryController;
g.spConfig = spConfig;
g.isJacksonReady = true;
g.samlFederatedController = samlFederatedController;
g.checkLicense = checkLicense;
} else {
connectionAPIController = g.connectionAPIController;
oauthController = g.oauthController;
@ -82,6 +90,8 @@ export default async function init() {
oidcDiscoveryController = g.oidcDiscoveryController;
setupLinkController = g.setupLinkController;
spConfig = g.spConfig;
samlFederatedController = g.samlFederatedController;
checkLicense = g.checkLicense;
}
return {
@ -94,6 +104,8 @@ export default async function init() {
directorySyncController,
oidcDiscoveryController,
setupLinkController,
samlFederatedController,
checkLicense,
};
}

View File

@ -11,11 +11,6 @@ export function copyToClipboard(text) {
navigator.clipboard.writeText(text);
}
export interface APIError extends Error {
info?: string;
status: number;
}
export const fetcher = async (url: string, queryParams = '') => {
const res = await fetch(`${url}${queryParams}`);

View File

@ -62,7 +62,7 @@
"scim_endpoint": "SCIM Endpoint",
"scim_token": "SCIM Token",
"select_type": "Select Type",
"select_an_app": "Select an App",
"select_an_app": "Select an App to continue",
"selection_list_empty": "Selection list empty",
"send_magic_link": "Send Magic Link",
"sent_at": "Sent At",
@ -76,11 +76,35 @@
"webhook_endpoint": "Webhook Endpoint",
"webhook_secret": "Webhook secret",
"webhook_url": "Webhook URL",
"saml_federation_apps": "SAML Federation Apps",
"metadata": "Metadata",
"no_saml_federation_apps": "No SAML Federation Apps found.",
"download": "Download",
"saml_federation_new_success": "SAML Federation app created successfully.",
"saml_federation_add_new_app": "Add SAML Federation App",
"saml_federation_add_new_app_description": "To configure SAML Federation app, add service provider details such as ACS URL and Entity ID.",
"acs_url": "ACS URL",
"entity_id": "Entity ID / Audience URI / Audience Restriction",
"create_app": "Create App",
"saml_federation_update_success": "SAML Federation app updated successfully.",
"saml_federation_update_app": "Update SAML Federation App",
"saml_federation_delete_success": "SAML federation app deleted successfully",
"delete_this_saml_federation_app": "Delete this SAML Federation app",
"delete_the_saml_federation_app": "Delete the SAML Federation app?",
"saml_federation_app_info": "SAML Federation App Information",
"saml_federation_app_info_details": "Choose from the following options to configure your SAML Federation on the service provider side",
"download_metadata": "Download Metadata",
"metadata_url": "Metadata URL",
"sso_url": "SSO URL",
"x509_certificate": "X.509 Certificate",
"directory_created_successfully": "Directory created successfully",
"directory_updated_successfully": "Directory updated successfully",
"dashboard": "Dashboard",
"create_setup_link": "Create Setup Link ({{service}})",
"setup_link_info": "Setup Link Info",
"setup_link_sso_description": "Create a unique Setup Link to share with your customer so they can setup SSO Connection for your app.",
"setup_link_dsync_description": "Create a unique Setup Link to share with your customer so they can setup Directory Sync for your app."
"setup_link_dsync_description": "Create a unique Setup Link to share with your customer so they can setup Directory Sync for your app.",
"saml_federation": "SAML Federation",
"new_saml_federation_app": "New App",
"view": "View"
}

View File

@ -26,6 +26,8 @@ const map = {
'src/directory-sync/request.ts',
],
'test/dsync/events.test.ts': ['src/directory-sync/events.ts'],
'test/federated-saml/app.test.ts': ['src/ee/federated-saml/app.ts'],
'test/federated-saml/sso.test.ts': ['src/ee/federated-saml/sso.ts'],
};
module.exports = (testFile) => {

View File

@ -15,7 +15,7 @@ import {
GetIDPEntityIDBody,
} from '../typings';
import { JacksonError } from './error';
import { IndexNames } from './utils';
import { IndexNames, appID } from './utils';
import oidcConnection from './connection/oidc';
import samlConnection from './connection/saml';
@ -385,7 +385,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
if (!tenant || !product) {
throw new JacksonError('Please provide `tenant` and `product`.', 400);
} else {
return `${this.opts.samlAudience}/${dbutils.keyDigest(dbutils.keyFromParts(tenant, product))}`;
return `${this.opts.samlAudience}/${appID(tenant, product)}`;
}
}
@ -480,9 +480,23 @@ export class ConnectionAPIController implements IConnectionAPIController {
const tenant = 'tenant' in body ? body.tenant : undefined;
const product = 'product' in body ? body.product : undefined;
const strategy = 'strategy' in body ? body.strategy : undefined;
const entityId = 'entityId' in body ? body.entityId : undefined;
metrics.increment('getConnections');
if (entityId) {
const connections = await this.connectionStore.getByIndex({
name: IndexNames.EntityID,
value: entityId,
});
if (!connections || typeof connections !== 'object') {
return [];
}
return connections;
}
if (clientID) {
const connection = await this.connectionStore.get(clientID);

View File

@ -1,13 +1,10 @@
import crypto from 'crypto';
import * as jose from 'jose';
import { promisify } from 'util';
import { deflateRaw } from 'zlib';
import { Client, errors, generators, Issuer, TokenSet } from 'openid-client';
import * as jose from 'jose';
import * as dbutils from '../db/utils';
import * as metrics from '../opentelemetry/metrics';
import saml from '@boxyhq/saml20';
import claims from '../saml/claims';
import { errors, generators, Issuer } from 'openid-client';
import { SAMLProfile } from '@boxyhq/saml20/dist/typings';
import type {
OIDCAuthzResponsePayload,
@ -19,11 +16,9 @@ import type {
Profile,
SAMLResponsePayload,
Storable,
SAMLSSORecord,
OIDCSSORecord,
} from '../typings';
import { JacksonError } from './error';
import * as allowed from './oauth/allowed';
import * as codeVerifier from './oauth/code-verifier';
import * as redirect from './oauth/redirect';
import {
relayStatePrefix,
IndexNames,
@ -31,56 +26,29 @@ import {
getErrorMessage,
loadJWSPrivateKey,
isJWSKeyPairLoaded,
extractOIDCUserProfile,
getScopeValues,
getEncodedTenantProduct,
} from './utils';
import * as metrics from '../opentelemetry/metrics';
import { JacksonError } from './error';
import * as allowed from './oauth/allowed';
import * as codeVerifier from './oauth/code-verifier';
import * as redirect from './oauth/redirect';
import { getDefaultCertificate } from '../saml/x509';
import { SAMLHandler } from './saml-handler';
import { extractSAMLResponseAttributes } from '../saml/lib';
const deflateRawAsync = promisify(deflateRaw);
const validateSAMLResponse = async (rawResponse: string, validateOpts) => {
const profile = await saml.validate(rawResponse, validateOpts);
if (profile && profile.claims) {
// we map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
profile.claims = claims.map(profile.claims);
// some providers don't return the id in the assertion, we set it to a sha256 hash of the email
if (!profile.claims.id && profile.claims.email) {
profile.claims.id = crypto.createHash('sha256').update(profile.claims.email).digest('hex');
}
// we'll send a ripemd160 hash of the id, this can be used in the case of email missing it can be used as the local part
profile.claims.idHash = dbutils.keyDigest(profile.claims.id);
}
return profile;
};
function getEncodedTenantProduct(param: string): { tenant: string | null; product: string | null } | null {
try {
const sp = new URLSearchParams(param);
const tenant = sp.get('tenant');
const product = sp.get('product');
if (tenant && product) {
return {
tenant: sp.get('tenant'),
product: sp.get('product'),
};
}
return null;
} catch (err) {
return null;
}
}
function getScopeValues(scope?: string): string[] {
return typeof scope === 'string' ? scope.split(' ').filter((s) => s.length > 0) : [];
}
export class OAuthController implements IOAuthController {
private connectionStore: Storable;
private sessionStore: Storable;
private codeStore: Storable;
private tokenStore: Storable;
private opts: JacksonOption;
private samlHandler: SAMLHandler;
constructor({ connectionStore, sessionStore, codeStore, tokenStore, opts }) {
this.connectionStore = connectionStore;
@ -88,59 +56,12 @@ export class OAuthController implements IOAuthController {
this.codeStore = codeStore;
this.tokenStore = tokenStore;
this.opts = opts;
}
private resolveMultipleConnectionMatches(
connections,
idp_hint,
originalParams,
isIdpFlow = false
): { resolvedConnection?: unknown; redirect_url?: string; app_select_form?: string } {
if (connections.length > 1) {
if (idp_hint) {
return { resolvedConnection: connections.find(({ clientID }) => clientID === idp_hint) };
} else if (this.opts.idpDiscoveryPath) {
if (!isIdpFlow) {
// redirect to IdP selection page
const idpList = connections.map(({ idpMetadata, oidcProvider, clientID, name }) =>
JSON.stringify({
provider: idpMetadata?.provider ?? oidcProvider?.provider,
clientID,
name,
connectionIsSAML: idpMetadata && typeof idpMetadata === 'object',
connectionIsOIDC: oidcProvider && typeof oidcProvider === 'object',
})
);
return {
redirect_url: redirect.success(this.opts.externalUrl + this.opts.idpDiscoveryPath, {
...originalParams,
idp: idpList,
}),
};
} else {
// Relevant to IdP initiated SAML flow
const appList = connections.map(({ product, name, description, clientID }) => ({
product,
name,
description,
clientID,
}));
return {
app_select_form: saml.createPostForm(this.opts.idpDiscoveryPath, [
{
name: 'SAMLResponse',
value: originalParams.SAMLResponse,
},
{
name: 'app',
value: encodeURIComponent(JSON.stringify(appList)),
},
]),
};
}
}
}
return {};
this.samlHandler = new SAMLHandler({
connection: connectionStore,
session: sessionStore,
opts,
});
}
public async authorize(body: OAuthReq): Promise<{ redirect_url?: string; authorize_form?: string }> {
@ -171,48 +92,27 @@ export class OAuthController implements IOAuthController {
throw new JacksonError('Please specify a redirect URL.', 400);
}
let connection;
let connection: SAMLSSORecord | OIDCSSORecord | undefined;
const requestedScopes = getScopeValues(scope);
const requestedOIDCFlow = requestedScopes.includes('openid');
if (tenant && product) {
const connections = await this.connectionStore.getByIndex({
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
const response = await this.samlHandler.resolveConnection({
tenant,
product,
idp_hint,
authFlow: 'oauth',
originalParams: { ...body },
});
if (!connections || connections.length === 0) {
throw new JacksonError('IdP connection not found.', 403);
if ('redirectUrl' in response) {
return {
redirect_url: response.redirectUrl,
};
}
connection = connections[0];
// Support multiple matches
const { resolvedConnection, redirect_url } = this.resolveMultipleConnectionMatches(
connections,
idp_hint,
{
response_type,
client_id,
redirect_uri,
state,
tenant,
product,
access_type,
resource,
scope,
nonce,
code_challenge,
code_challenge_method,
}
);
if (redirect_url) {
return { redirect_url };
}
if (resolvedConnection) {
connection = resolvedConnection;
if ('connection' in response) {
connection = response.connection;
}
} else if (client_id && client_id !== '' && client_id !== 'undefined' && client_id !== 'null') {
// if tenant and product are encoded in the client_id then we parse it and check for the relevant connection(s)
@ -231,45 +131,27 @@ export class OAuthController implements IOAuthController {
}
}
if (sp && sp.tenant && sp.product) {
requestedTenant = sp.tenant;
requestedProduct = sp.product;
const { tenant, product } = sp;
const connections = await this.connectionStore.getByIndex({
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(sp.tenant, sp.product),
requestedTenant = tenant;
requestedProduct = product;
const response = await this.samlHandler.resolveConnection({
tenant,
product,
idp_hint,
authFlow: 'oauth',
originalParams: { ...body },
});
if (!connections || connections.length === 0) {
throw new JacksonError('IdP connection not found.', 403);
if ('redirectUrl' in response) {
return {
redirect_url: response.redirectUrl,
};
}
connection = connections[0];
// Support multiple matches
const { resolvedConnection, redirect_url } = this.resolveMultipleConnectionMatches(
connections,
idp_hint,
{
response_type,
client_id,
redirect_uri,
state,
tenant,
product,
access_type,
resource,
scope,
nonce,
code_challenge,
code_challenge_method,
}
);
if (redirect_url) {
return { redirect_url };
}
if (resolvedConnection) {
connection = resolvedConnection;
if ('connection' in response) {
connection = response.connection;
}
} else {
connection = await this.connectionStore.get(client_id);
@ -286,7 +168,7 @@ export class OAuthController implements IOAuthController {
throw new JacksonError('IdP connection not found.', 403);
}
if (!allowed.redirect(redirect_uri, connection.redirectUrl)) {
if (!allowed.redirect(redirect_uri, connection.redirectUrl as string[])) {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
@ -328,15 +210,15 @@ export class OAuthController implements IOAuthController {
// Connection retrieved: Handover to IdP starts here
let ssoUrl;
let post = false;
const connectionIsSAML = connection.idpMetadata && typeof connection.idpMetadata === 'object';
const connectionIsOIDC = connection.oidcProvider && typeof connection.oidcProvider === 'object';
const connectionIsSAML = 'idpMetadata' in connection && connection.idpMetadata !== undefined;
const connectionIsOIDC = 'oidcProvider' in connection && connection.oidcProvider !== undefined;
// Init sessionId
const sessionId = crypto.randomBytes(16).toString('hex');
const relayState = relayStatePrefix + sessionId;
// SAML connection: SAML request will be constructed here
let samlReq;
if (connectionIsSAML) {
if ('idpMetadata' in connection) {
const { sso } = connection.idpMetadata;
if ('redirectUrl' in sso) {
@ -387,7 +269,7 @@ export class OAuthController implements IOAuthController {
// OIDC Connection: Issuer discovery, openid-client init and extraction of authorization endpoint happens here
let oidcCodeVerifier: string | undefined;
if (connectionIsOIDC) {
if (connectionIsOIDC && 'oidcProvider' in connection) {
if (!this.opts.oidcPath) {
return {
redirect_url: OAuthErrorResponse({
@ -400,9 +282,9 @@ export class OAuthController implements IOAuthController {
}
const { discoveryUrl, clientId, clientSecret } = connection.oidcProvider;
try {
const oidcIssuer = await Issuer.discover(discoveryUrl);
const oidcIssuer = await Issuer.discover(discoveryUrl as string);
const oidcClient = new oidcIssuer.Client({
client_id: clientId,
client_id: clientId as string,
client_secret: clientSecret,
redirect_uris: [this.opts.externalUrl + this.opts.oidcPath],
response_types: ['code'],
@ -523,179 +405,150 @@ export class OAuthController implements IOAuthController {
public async samlResponse(
body: SAMLResponsePayload
): Promise<{ redirect_url?: string; app_select_form?: string }> {
const { SAMLResponse, idp_hint } = body;
let RelayState = body.RelayState || ''; // RelayState will contain the sessionId from earlier quasi-oauth flow
): Promise<{ redirect_url?: string; app_select_form?: string; responseForm?: string }> {
const { SAMLResponse, idp_hint, RelayState = '' } = body;
const isIdPFlow = !RelayState.startsWith(relayStatePrefix);
// IdP is disabled so block the request
if (!this.opts.idpEnabled && isIdPFlow) {
// IdP login is disabled so block the request
throw new JacksonError(
'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.',
403
);
}
RelayState = RelayState.replace(relayStatePrefix, '');
const sessionId = RelayState.replace(relayStatePrefix, '');
const rawResponse = Buffer.from(SAMLResponse, 'base64').toString();
const issuer = saml.parseIssuer(rawResponse);
if (!issuer) {
throw new JacksonError('Issuer not found.', 403);
}
const samlConnections = await this.connectionStore.getByIndex({
const connections: SAMLSSORecord[] = await this.connectionStore.getByIndex({
name: IndexNames.EntityID,
value: issuer,
});
if (!samlConnections || samlConnections.length === 0) {
if (!connections || connections.length === 0) {
throw new JacksonError('SAML connection not found.', 403);
}
let samlConnection = samlConnections[0];
const session = sessionId ? await this.sessionStore.get(sessionId) : null;
if (!isIdPFlow && !session) {
throw new JacksonError('Unable to validate state from the origin request.', 403);
}
const isSAMLFederated = session && 'samlFederated' in session;
const isSPFflow = !isIdPFlow && !isSAMLFederated;
let connection: SAMLSSORecord | undefined;
// IdP initiated SSO flow
if (isIdPFlow) {
RelayState = '';
const { resolvedConnection, app_select_form } = this.resolveMultipleConnectionMatches(
samlConnections,
const response = await this.samlHandler.resolveConnection({
idp_hint,
{ SAMLResponse },
true
);
if (app_select_form) {
return { app_select_form };
authFlow: 'idp-initiated',
entityId: issuer,
originalParams: {
SAMLResponse,
},
});
// Redirect to the product selection page
if ('postForm' in response) {
return {
app_select_form: response.postForm,
};
}
if (resolvedConnection) {
samlConnection = resolvedConnection;
// Found a connection
if ('connection' in response) {
connection = response.connection as SAMLSSORecord;
}
}
let session;
if (RelayState !== '') {
session = await this.sessionStore.get(RelayState);
if (!session) {
throw new JacksonError('Unable to validate state from the origin request.', 403);
}
}
if (!isIdPFlow) {
// Resolve if there are multiple matches for SP login. TODO: Support multiple matches for IdP login
samlConnection =
samlConnections.length === 1
? samlConnections[0]
: samlConnections.filter((c) => {
return (
c.clientID === session?.requested?.client_id ||
(c.tenant === session?.requested?.tenant && c.product === session?.requested?.product)
);
})[0];
// SP initiated SSO flow
// Resolve if there are multiple matches for SP login
if (isSPFflow) {
connection = connections.filter((c) => {
return (
c.clientID === session.requested.client_id ||
(c.tenant === session.requested.tenant && c.product === session.requested.product)
);
})[0];
}
if (!samlConnection) {
if (!connection) {
connection = connections[0];
}
if (!connection) {
throw new JacksonError('SAML connection not found.', 403);
}
const { privateKey } = await getDefaultCertificate();
const validateOpts: Record<string, string> = {
thumbprint: samlConnection.idpMetadata.thumbprint,
audience: this.opts.samlAudience!,
privateKey,
};
if (
session &&
session.redirect_uri &&
!allowed.redirect(session.redirect_uri, samlConnection.redirectUrl)
!allowed.redirect(session.redirect_uri, connection.redirectUrl as string[])
) {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
const { privateKey } = await getDefaultCertificate();
const validateOpts = {
thumbprint: `${connection.idpMetadata.thumbprint}`,
audience: `${this.opts.samlAudience}`,
privateKey,
};
if (session && session.id) {
validateOpts.inResponseTo = session.id;
validateOpts['inResponseTo'] = session.id;
}
let profile;
const redirect_uri = (session && session.redirect_uri) || samlConnection.defaultRedirectUrl;
const redirect_uri = (session && session.redirect_uri) || connection.defaultRedirectUrl;
let profile: SAMLProfile | null = null;
try {
profile = await validateSAMLResponse(rawResponse, validateOpts);
profile = await extractSAMLResponseAttributes(rawResponse, validateOpts);
} catch (err: unknown) {
// return error to redirect_uri
return {
redirect_url: OAuthErrorResponse({
error: 'access_denied',
error_description: getErrorMessage(err),
redirect_uri,
state: session?.requested?.state,
}),
};
}
// store details against a code
const code = crypto.randomBytes(20).toString('hex');
const codeVal: Record<string, unknown> = {
profile,
clientID: samlConnection.clientID,
clientSecret: samlConnection.clientSecret,
requested: session?.requested,
isIdPFlow,
};
if (session) {
codeVal.session = session;
}
try {
await this.codeStore.put(code, codeVal);
} catch (err: unknown) {
// return error to redirect_uri
return {
redirect_url: OAuthErrorResponse({
error: 'server_error',
error_description: getErrorMessage(err),
redirect_uri,
state: session?.requested?.state,
state: session.requested?.state,
}),
};
}
const params: Record<string, string> = {
// This is a federated SAML flow, let's create a new SAMLResponse and POST it to the SP
if (isSAMLFederated) {
const { responseForm } = await this.samlHandler.createSAMLResponse({ profile, session });
await this.sessionStore.delete(sessionId);
return { responseForm };
}
const code = await this._buildAuthorizationCode(connection, profile, session, isIdPFlow);
const params = {
code,
};
if (session && session.state) {
params.state = session.state;
params['state'] = session.state;
}
const redirectUrl = redirect.success(redirect_uri, params);
await this.sessionStore.delete(sessionId);
// delete the session
try {
await this.sessionStore.delete(RelayState);
} catch (_err) {
// ignore error
}
return { redirect_url: redirectUrl };
}
private async extractOIDCUserProfile(tokenSet: TokenSet, oidcClient: Client) {
const profile: { claims: Partial<Profile & { raw: Record<string, unknown> }> } = { claims: {} };
const idTokenClaims = tokenSet.claims();
const userinfo = await oidcClient.userinfo(tokenSet);
profile.claims.id = idTokenClaims.sub;
profile.claims.idHash = dbutils.keyDigest(idTokenClaims.sub);
profile.claims.email = idTokenClaims.email ?? userinfo.email;
profile.claims.firstName = idTokenClaims.given_name ?? userinfo.given_name;
profile.claims.lastName = idTokenClaims.family_name ?? userinfo.family_name;
profile.claims.roles = idTokenClaims.roles ?? (userinfo.roles as any);
profile.claims.groups = idTokenClaims.groups ?? (userinfo.groups as any);
profile.claims.raw = userinfo;
return profile;
return { redirect_url: redirect.success(redirect_uri, params) };
}
public async oidcAuthzResponse(body: OIDCAuthzResponsePayload): Promise<{ redirect_url?: string }> {
@ -759,7 +612,7 @@ export class OAuthController implements IOAuthController {
},
{ code_verifier: session.oidcCodeVerifier }
);
profile = await this.extractOIDCUserProfile(tokenSet, oidcClient);
profile = await extractOIDCUserProfile(tokenSet, oidcClient);
} catch (err: unknown) {
if (err) {
return {
@ -772,53 +625,51 @@ export class OAuthController implements IOAuthController {
};
}
}
// store details against a code
const code = crypto.randomBytes(20).toString('hex');
const codeVal: Record<string, unknown> = {
profile,
clientID: oidcConnection.clientID,
clientSecret: oidcConnection.clientSecret,
requested: session?.requested,
};
const code = await this._buildAuthorizationCode(oidcConnection, profile, session, false);
if (session) {
codeVal.session = session;
}
try {
await this.codeStore.put(code, codeVal);
} catch (err: unknown) {
// return error to redirect_uri
return {
redirect_url: OAuthErrorResponse({
error: 'server_error',
error_description: getErrorMessage(err),
redirect_uri,
state: session.state,
}),
};
}
const params: Record<string, string> = {
const params = {
code,
};
if (session && session.state) {
params.state = session.state;
params['state'] = session.state;
}
const redirectUrl = redirect.success(redirect_uri, params);
// delete the session
try {
await this.sessionStore.delete(RelayState);
} catch (_err) {
// ignore error
}
await this.sessionStore.delete(RelayState);
return { redirect_url: redirectUrl };
}
// Build the authorization code for the session
private async _buildAuthorizationCode(
connection: SAMLSSORecord | OIDCSSORecord,
profile: any,
session: any,
isIdPFlow: boolean
) {
// Store details against a code
const code = crypto.randomBytes(20).toString('hex');
const codeVal = {
profile,
clientID: connection.clientID,
clientSecret: connection.clientSecret,
requested: session ? session.requested : null,
isIdPFlow,
};
if (session) {
codeVal['session'] = session;
}
await this.codeStore.put(code, codeVal);
return code;
}
/**
* @swagger
*

View File

@ -0,0 +1,201 @@
import saml from '@boxyhq/saml20';
import crypto from 'crypto';
import { promisify } from 'util';
import { deflateRaw } from 'zlib';
import type { SAMLProfile } from '@boxyhq/saml20/dist/typings';
import type { JacksonOption, Storable, SAMLSSORecord, OIDCSSORecord } from '../typings';
import { getDefaultCertificate } from '../saml/x509';
import * as dbutils from '../db/utils';
import { JacksonError } from './error';
import { IndexNames } from './utils';
import { relayStatePrefix } from './utils';
import { createSAMLResponse } from '../saml/lib';
const deflateRawAsync = promisify(deflateRaw);
export class SAMLHandler {
private connection: Storable;
private session: Storable;
private opts: JacksonOption;
constructor({
connection,
session,
opts,
}: {
connection: Storable;
session: Storable;
opts: JacksonOption;
}) {
this.connection = connection;
this.session = session;
this.opts = opts;
}
// If there are multiple connections for the given tenant and product, return the url to the IdP selection page
// If idp_hint is provided, return the connection with the matching clientID
// If there is only one connection, return the connection
async resolveConnection(params: {
authFlow: 'oauth' | 'saml' | 'idp-initiated';
originalParams: Record<string, string>;
tenant?: string;
product?: string;
entityId?: string;
idp_hint?: string;
}): Promise<
| {
connection: SAMLSSORecord | OIDCSSORecord;
}
| {
redirectUrl: string;
}
| {
postForm: string;
}
> {
const { authFlow, originalParams, tenant, product, idp_hint, entityId } = params;
let connections: (SAMLSSORecord | OIDCSSORecord)[] | null = null;
// Find SAML connections for the app
if (tenant && product) {
connections = await this.connection.getByIndex({
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
});
}
if (entityId) {
connections = await this.connection.getByIndex({
name: IndexNames.EntityID,
value: entityId,
});
}
if (!connections || connections.length === 0) {
throw new JacksonError('No SAML connection found.', 404);
}
// If an IdP is specified, find the connection for that IdP
if (idp_hint) {
const connection = connections.find((c) => c.clientID === idp_hint);
if (!connection) {
throw new JacksonError('No SAML connection found.', 404);
}
return { connection };
}
// If more than one, redirect to the connection selection page
if (connections.length > 1) {
const url = new URL(`${this.opts.externalUrl}${this.opts.idpDiscoveryPath}`);
// SP initiated flow
if (['oauth', 'saml'].includes(authFlow) && tenant && product) {
const params = new URLSearchParams({
tenant,
product,
authFlow,
...originalParams,
});
return { redirectUrl: `${url.toString()}?${params.toString()}` };
}
// IdP initiated flow
if (authFlow === 'idp-initiated' && entityId) {
const params = new URLSearchParams({
entityId,
});
const postForm = saml.createPostForm(`${this.opts.idpDiscoveryPath}?${params.toString()}`, [
{
name: 'SAMLResponse',
value: originalParams.SAMLResponse,
},
]);
return { postForm };
}
}
// If only one, use that connection
return { connection: connections[0] };
}
async createSAMLRequest(params: {
connection: SAMLSSORecord;
requestParams: Record<string, any>;
}): Promise<{ redirectUrl: string }> {
const { connection, requestParams } = params;
// We have a connection now, so we can create the SAML request
const certificate = await getDefaultCertificate();
const samlRequest = saml.request({
ssoUrl: connection.idpMetadata.sso.redirectUrl,
entityID: `${this.opts.samlAudience}`,
callbackUrl: `${this.opts.externalUrl}/api/oauth/saml`,
signingKey: certificate.privateKey,
publicKey: certificate.publicKey,
});
// Create a new session to store SP request information
const sessionId = crypto.randomBytes(16).toString('hex');
await this.session.put(sessionId, {
id: samlRequest.id,
request: {
...requestParams,
},
samlFederated: true,
});
// Create URL to redirect to the Identity Provider
const url = new URL(`${connection.idpMetadata.sso.redirectUrl}`);
url.searchParams.set('RelayState', `${relayStatePrefix}${sessionId}`);
url.searchParams.set(
'SAMLRequest',
Buffer.from(await deflateRawAsync(samlRequest.request)).toString('base64')
);
return {
redirectUrl: url.toString(),
};
}
createSAMLResponse = async (params: { profile: SAMLProfile; session: any }) => {
const { profile, session } = params;
const certificate = await getDefaultCertificate();
try {
const responseSigned = await createSAMLResponse({
audience: session.request.entityId,
acsUrl: session.request.acsUrl,
requestId: session.request.id,
issuer: `${this.opts.samlAudience}`,
profile,
...certificate,
});
const responseForm = saml.createPostForm(session.request.acsUrl, [
{
name: 'RelayState',
value: session.request.relayState,
},
{
name: 'SAMLResponse',
value: Buffer.from(responseSigned).toString('base64'),
},
]);
return { responseForm };
} catch (err) {
throw new JacksonError('Unable to validate SAML Response.', 403);
}
};
}

View File

@ -1,14 +1,18 @@
import crypto from 'crypto';
import * as jose from 'jose';
import { Client, TokenSet } from 'openid-client';
import * as dbutils from '../db/utils';
import type {
ConnectionType,
OAuthErrorHandlerParams,
OIDCSSOConnection,
SAMLSSOConnectionWithEncodedMetadata,
SAMLSSOConnectionWithRawMetadata,
Profile,
} from '../typings';
import { JacksonError } from './error';
import * as redirect from './oauth/redirect';
import crypto from 'crypto';
import * as jose from 'jose';
export enum IndexNames {
EntityID = 'entityID',
@ -198,6 +202,47 @@ export const extractHostName = (url: string): string | null => {
}
};
export const extractOIDCUserProfile = async (tokenSet: TokenSet, oidcClient: Client) => {
const idTokenClaims = tokenSet.claims();
const userinfo = await oidcClient.userinfo(tokenSet);
const profile: { claims: Partial<Profile & { raw: Record<string, unknown> }> } = { claims: {} };
profile.claims.id = idTokenClaims.sub;
profile.claims.email = idTokenClaims.email ?? userinfo.email;
profile.claims.firstName = idTokenClaims.given_name ?? userinfo.given_name;
profile.claims.lastName = idTokenClaims.family_name ?? userinfo.family_name;
profile.claims.roles = idTokenClaims.roles ?? (userinfo.roles as any);
profile.claims.groups = idTokenClaims.groups ?? (userinfo.groups as any);
profile.claims.raw = userinfo;
return profile;
};
export const getScopeValues = (scope?: string): string[] => {
return typeof scope === 'string' ? scope.split(' ').filter((s) => s.length > 0) : [];
};
export const getEncodedTenantProduct = (
param: string
): { tenant: string | null; product: string | null } | null => {
try {
const sp = new URLSearchParams(param);
const tenant = sp.get('tenant');
const product = sp.get('product');
if (tenant && product) {
return {
tenant: sp.get('tenant'),
product: sp.get('product'),
};
}
return null;
} catch (err) {
return null;
}
};
export const validateTenantAndProduct = (tenant: string, product: string) => {
if (tenant.indexOf(':') !== -1) {
throw new JacksonError('tenant cannot contain the character :', 400);
@ -207,3 +252,7 @@ export const validateTenantAndProduct = (tenant: string, product: string) => {
throw new JacksonError('product cannot contain the character :', 400);
}
};
export const appID = (tenant: string, product: string) => {
return dbutils.keyDigest(dbutils.keyFromParts(tenant, product));
};

View File

@ -14,8 +14,6 @@ export const keyDigest = (k: string): string => {
};
export const keyFromParts = (...parts: string[]): string => {
// TODO: pick a better strategy, keys can collide now
return parts.join(':');
};

1
npm/src/ee/LICENSE Normal file
View File

@ -0,0 +1 @@
The BoxyHQ Enterprise Edition (EE) license (the “EE License”)

View File

@ -0,0 +1,9 @@
const checkLicense = async (license: string | undefined): Promise<boolean> => {
if (!license) {
return false;
}
return license === 'dummy-license';
};
export default checkLicense;

View File

@ -0,0 +1,158 @@
import type {
Storable,
JacksonOption,
SAMLFederationAppWithMetadata,
SAMLFederationApp,
} from '../../typings';
import { appID } from '../../controller/utils';
import { createMetadataXML } from '../../saml/lib';
import { JacksonError } from '../../controller/error';
import { getDefaultCertificate } from '../../saml/x509';
import { IndexNames, validateTenantAndProduct } from '../../controller/utils';
export class App {
protected store: Storable;
private opts: JacksonOption;
constructor({ store, opts }: { store: Storable; opts: JacksonOption }) {
this.store = store;
this.opts = opts;
}
// Create a new SAML Federation app for the tenant and product
public async create({
name,
tenant,
product,
acsUrl,
entityId,
}: Omit<SAMLFederationApp, 'id'>): Promise<SAMLFederationApp> {
if (!tenant || !product || !acsUrl || !entityId || !name) {
throw new JacksonError(
'Missing required parameters. Required parameters are: name, tenant, product, acsUrl, entityId',
400
);
}
validateTenantAndProduct(tenant, product);
const id = appID(tenant, product);
const app = {
id,
name,
tenant,
product,
acsUrl,
entityId,
};
await this.store.put(id, app, {
name: IndexNames.EntityID,
value: entityId,
});
return { ...app };
}
// Get an app by tenant and product
public async get(id: string): Promise<SAMLFederationApp> {
if (!id) {
throw new JacksonError('Missing required parameters. Required parameters are: id', 400);
}
const app: SAMLFederationApp = await this.store.get(id);
if (!app) {
throw new JacksonError('SAML Federation app not found', 404);
}
return { ...app };
}
// Get the app by SP EntityId
public async getByEntityId(entityId: string): Promise<SAMLFederationApp> {
if (!entityId) {
throw new JacksonError('Missing required parameters. Required parameters are: entityId', 400);
}
const apps: SAMLFederationApp[] = await this.store.getByIndex({
name: IndexNames.EntityID,
value: entityId,
});
if (!apps || apps.length === 0) {
throw new JacksonError('SAML Federation app not found', 404);
}
return { ...apps[0] };
}
// Update the app
public async update(
id: string,
{ acsUrl, entityId, name }: Partial<Omit<SAMLFederationApp, 'id'>>
): Promise<SAMLFederationApp> {
if (!id && (!acsUrl || !entityId || !name)) {
throw new JacksonError(
"Missing required parameters. Required parameters are: id, acsUrl, entityId, name'",
400
);
}
const app = await this.get(id);
const updatedApp = {
...app,
name: name || app.name,
acsUrl: acsUrl || app.acsUrl,
entityId: entityId || app.entityId,
};
await this.store.put(id, updatedApp);
return { ...updatedApp };
}
// Get all apps
public async getAll(): Promise<SAMLFederationApp[]> {
const apps = (await this.store.getAll()) as SAMLFederationApp[];
return apps.map((app) => ({ ...app }));
}
// Delete the app
public async delete(id: string): Promise<void> {
if (!id) {
throw new JacksonError('Missing required parameters. Required parameters are: id', 400);
}
await this.get(id);
await this.store.delete(id);
return;
}
// Get the metadata for the app
public async getMetadata(id: string): Promise<Pick<SAMLFederationAppWithMetadata, 'metadata'>['metadata']> {
await this.get(id);
const { publicKey } = await getDefaultCertificate();
const ssoUrl = `${this.opts.externalUrl}/api/federated-saml/sso`;
const entityId = `${this.opts.samlAudience}`;
const xml = await createMetadataXML({
entityId,
ssoUrl,
x509cert: publicKey,
});
return {
xml,
entityId,
ssoUrl,
x509cert: publicKey,
};
}
}

View File

@ -0,0 +1,39 @@
import { SSO } from './sso';
import { App } from './app';
import type { JacksonOption } from '../../typings';
import { SAMLHandler } from '../../controller/saml-handler';
// This is the main entry point for the SAML Federation module
const SAMLFederation = async ({ db, opts }: { db; opts: JacksonOption }) => {
const appStore = db.store('samlfed:apps');
const sessionStore = db.store('oauth:session', opts.db.ttl);
const connectionStore = db.store('saml:config');
const samlHandler = new SAMLHandler({
connection: connectionStore,
session: sessionStore,
opts,
});
const app = new App({ store: appStore, opts });
const sso = new SSO({ app, samlHandler });
const response = {
app,
sso,
};
return response;
};
export default SAMLFederation;
export * from './types';
// SAML Federation flow:
// SP (Eg: Twilio Flex) --> SAML Jackson --> IdP (Eg: Okta) --> SAML Jackson --> SP (Eg: Twilio Flex)
// 1. SP send SAML Request to Jackson's SSO endpoint
// 2. Jackson process SAML Request and create a new session to store SP request information
// 3. Jackson create a new SAML Request and send it to chosen IdP
// 4. After successful authentication, IdP send (POST) SAML Response to Jackson's ACS endpoint
// 5. Jackson process SAML Response from the IdP and create a new SAML Response to send (POST) back to the SP's ACS endpoint

View File

@ -0,0 +1,86 @@
import saml from '@boxyhq/saml20';
import { App } from './app';
import { JacksonError } from '../../controller/error';
import { SAMLHandler } from '../../controller/saml-handler';
import type { SAMLSSORecord } from '../../typings';
import { extractSAMLRequestAttributes } from '../../saml/lib';
export class SSO {
private app: App;
private samlHandler: SAMLHandler;
constructor({ app, samlHandler }: { app: App; samlHandler: SAMLHandler }) {
this.app = app;
this.samlHandler = samlHandler;
}
// Accept the SAML Request from Service Provider, and create a new SAML Request to be sent to Identity Provider
public getAuthorizeUrl = async ({
request,
relayState,
idp_hint,
}: {
request: string;
relayState: string;
idp_hint?: string;
}) => {
const { id, acsUrl, entityId, publicKey, providerName } = await extractSAMLRequestAttributes(request);
// Verify the request if it is signed
if (publicKey && !saml.hasValidSignature(request, publicKey, null)) {
throw new JacksonError('Invalid SAML Request signature.', 400);
}
const app = await this.app.getByEntityId(entityId);
if (app.acsUrl !== acsUrl) {
throw new JacksonError("Assertion Consumer Service URL doesn't match.", 400);
}
const response = await this.samlHandler.resolveConnection({
tenant: app.tenant,
product: app.product,
idp_hint,
authFlow: 'saml',
originalParams: {
RelayState: relayState,
SAMLRequest: request,
},
});
// If there is a redirect URL, then we need to redirect to that URL
if ('redirectUrl' in response) {
return {
redirectUrl: response.redirectUrl,
};
}
let connection: SAMLSSORecord | undefined;
// If there is a connection, use that connection
if ('connection' in response && 'idpMetadata' in response.connection) {
connection = response.connection;
}
if (!connection) {
throw new JacksonError('No SAML connection found.', 404);
}
const { redirectUrl } = await this.samlHandler.createSAMLRequest({
connection,
requestParams: {
id,
acsUrl,
entityId,
publicKey,
providerName,
relayState,
},
});
return {
redirectUrl,
};
};
}

View File

@ -0,0 +1,21 @@
import SAMLFederation from '.';
export type ISAMLFederationController = Awaited<ReturnType<typeof SAMLFederation>>;
export type SAMLFederationApp = {
id: string;
name: string;
tenant: string;
product: string;
acsUrl: string;
entityId: string;
};
export type SAMLFederationAppWithMetadata = SAMLFederationApp & {
metadata: {
entityId: string;
ssoUrl: string;
x509cert: string;
xml: string;
};
};

View File

@ -1,11 +1,8 @@
import type { IDirectorySyncController, JacksonOption } from './typings';
import DB from './db/db';
import defaultDb from './db/defaultDb';
import loadConnection from './loadConnection';
import { init as metricsInit } from './opentelemetry/metrics';
import { AdminController } from './controller/admin';
import { ConnectionAPIController } from './controller/api';
import { OAuthController } from './controller/oauth';
@ -16,6 +13,8 @@ import { OidcDiscoveryController } from './controller/oidc-discovery';
import { SPSAMLConfig } from './controller/sp-config';
import { SetupLinkController } from './controller/setup-link';
import * as x509 from './saml/x509';
import initFederatedSAML, { type ISAMLFederationController } from './ee/federated-saml';
import checkLicense from './ee/common/checkLicense';
const defaultOpts = (opts: JacksonOption): JacksonOption => {
const newOpts = {
@ -46,6 +45,8 @@ const defaultOpts = (opts: JacksonOption): JacksonOption => {
newOpts.openid = newOpts.openid || {};
newOpts.openid.jwsAlg = newOpts.openid.jwsAlg || 'RS256';
newOpts.boxyhqLicenseKey = newOpts.boxyhqLicenseKey || undefined;
return newOpts;
};
@ -62,6 +63,8 @@ export const controllers = async (
directorySyncController: IDirectorySyncController;
oidcDiscoveryController: OidcDiscoveryController;
spConfig: SPSAMLConfig;
samlFederatedController: ISAMLFederationController;
checkLicense: () => Promise<boolean>;
}> => {
opts = defaultOpts(opts);
@ -100,11 +103,10 @@ export const controllers = async (
opts,
});
const directorySyncController = await initDirectorySync({ db, opts });
const oidcDiscoveryController = new OidcDiscoveryController({ opts });
const spConfig = new SPSAMLConfig(opts);
const directorySyncController = await initDirectorySync({ db, opts });
const samlFederatedController = await initFederatedSAML({ db, opts });
// write pre-loaded connections if present
const preLoadedConnection = opts.preLoadedConnection || opts.preLoadedConfig;
@ -137,9 +139,15 @@ export const controllers = async (
setupLinkController,
directorySyncController,
oidcDiscoveryController,
samlFederatedController,
checkLicense: () => {
return checkLicense(opts.boxyhqLicenseKey);
},
};
};
export default controllers;
export * from './typings';
export * from './ee/federated-saml/types';
export type SAMLJackson = Awaited<ReturnType<typeof controllers>>;

262
npm/src/saml/lib.ts Normal file
View File

@ -0,0 +1,262 @@
import crypto from 'crypto';
import xml2js from 'xml2js';
import { inflateRaw } from 'zlib';
import { promisify } from 'util';
import saml from '@boxyhq/saml20';
import xmlbuilder from 'xmlbuilder';
import type { SAMLProfile } from '@boxyhq/saml20/dist/typings';
import claims from '../saml/claims';
// Validate the SAMLResponse and extract the user profile
export const extractSAMLResponseAttributes = async (
decodedResponse: string,
validateOpts: ValidateOption
) => {
const attributes = await saml.validate(decodedResponse, validateOpts);
if (attributes && attributes.claims) {
// We map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
attributes.claims = claims.map(attributes.claims);
// Some providers don't return the id in the assertion, we set it to a sha256 hash of the email
if (!attributes.claims.id && attributes.claims.email) {
attributes.claims.id = crypto.createHash('sha256').update(attributes.claims.email).digest('hex');
}
}
return attributes;
};
export const extractSAMLRequestAttributes = async (samlRequest: string) => {
const decodeRequest = await decodeBase64(samlRequest, true);
const result = await parseXML(decodeRequest);
const publicKey: string = result['samlp:AuthnRequest']['Signature']
? result['samlp:AuthnRequest']['Signature'][0]['KeyInfo'][0]['X509Data'][0]['X509Certificate'][0]
: null;
const attributes = result['samlp:AuthnRequest']['$'];
const id: string = attributes.ID;
const providerName: string = attributes.ProviderName;
const acsUrl: string = attributes.AssertionConsumerServiceURL;
const entityId: string = result['samlp:AuthnRequest']['saml:Issuer'][0];
if (!entityId) {
throw new Error("Missing 'Entity ID' in SAML Request.");
}
if (!acsUrl) {
throw new Error("Missing 'ACS URL' in SAML Request.");
}
return {
id,
acsUrl,
entityId,
publicKey,
providerName,
};
};
// Create Metadata XML
export const createMetadataXML = async ({
ssoUrl,
entityId,
x509cert,
}: {
ssoUrl: string;
entityId: string;
x509cert: string;
}): Promise<string> => {
x509cert = saml.stripCertHeaderAndFooter(x509cert);
const today = new Date();
const nodes = {
'md:EntityDescriptor': {
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
'@entityID': entityId,
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
'md:IDPSSODescriptor': {
'@WantAuthnRequestsSigned': false,
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
'md:KeyDescriptor': {
'@use': 'signing',
'ds:KeyInfo': {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': {
'ds:X509Certificate': {
'#text': x509cert,
},
},
},
},
'md:NameIDFormat': {
'#text': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
},
'md:SingleSignOnService': [
{
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
'@Location': ssoUrl,
},
{
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
'@Location': ssoUrl,
},
],
},
},
};
return xmlbuilder.create(nodes, { encoding: 'UTF-8', standalone: false }).end({ pretty: true });
};
// Decode the base64 string
export const decodeBase64 = async (string: string, isDeflated: boolean) => {
const inflateRawAsync = promisify(inflateRaw);
return isDeflated
? (await inflateRawAsync(Buffer.from(string, 'base64'))).toString()
: Buffer.from(string, 'base64').toString();
};
// Parse XML
const parseXML = async (xml: string): Promise<Record<string, string>> => {
return new Promise((resolve, reject) => {
xml2js.parseString(xml, (err: Error | null, result: any) => {
if (err) {
reject(err);
}
resolve(result);
});
});
};
const randomId = () => {
return '_' + crypto.randomBytes(10).toString('hex');
};
// Create SAML Response and sign it
export const createSAMLResponse = async ({
audience,
issuer,
acsUrl,
profile,
requestId,
privateKey,
publicKey,
}: {
audience: string;
issuer: string;
acsUrl: string;
profile: SAMLProfile;
requestId: string;
privateKey: string;
publicKey: string;
}): Promise<string> => {
const authDate = new Date();
const authTimestamp = authDate.toISOString();
authDate.setMinutes(authDate.getMinutes() - 5);
const notBefore = authDate.toISOString();
authDate.setMinutes(authDate.getMinutes() + 10);
const notAfter = authDate.toISOString();
const nodes = {
'samlp:Response': {
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
'@Version': '2.0',
'@ID': randomId(),
'@Destination': acsUrl,
'@InResponseTo': requestId,
'@IssueInstant': authTimestamp,
'saml:Issuer': {
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
'@Format': 'urn:oasis:names:tc:SAML:2.0:assertion',
'#text': issuer,
},
'samlp:Status': {
'samlp:StatusCode': {
'@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success',
},
},
'saml:Assertion': {
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
'@Version': '2.0',
'@ID': randomId(),
'@IssueInstant': authTimestamp,
'saml:Issuer': {
'#text': issuer,
},
'saml:Subject': {
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
'saml:NameID': {
'@Format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
'#text': profile.claims.email,
},
'saml:SubjectConfirmation': {
'@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer',
'saml:SubjectConfirmationData': {
'@Recipient': acsUrl,
'@NotOnOrAfter': notAfter,
'@InResponseTo': requestId,
},
},
},
'saml:Conditions': {
'@NotBefore': notBefore,
'@NotOnOrAfter': notAfter,
'saml:AudienceRestriction': {
'saml:Audience': {
'#text': audience,
},
},
},
'saml:AuthnStatement': {
'@AuthnInstant': authTimestamp,
'@SessionIndex': '_YIlFoNFzLMDYxdwf-T_BuimfkGa5qhKg',
'saml:AuthnContext': {
'saml:AuthnContextClassRef': {
'#text': 'urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified',
},
},
},
'saml:AttributeStatement': {
'@xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'saml:Attribute': Object.keys(profile.claims.raw).map((attributeName) => {
return {
'@Name': attributeName,
'@NameFormat': 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'@xsi:type': 'xs:string',
'#text': profile.claims.raw[attributeName],
},
};
}),
},
},
},
};
const xml = xmlbuilder.create(nodes, { encoding: 'UTF-8' }).end();
return await saml.sign(
xml,
privateKey,
publicKey,
'/*[local-name(.)="Response" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]'
);
};
type ValidateOption = {
thumbprint: string;
audience: string;
privateKey: string;
inResponseTo?: string;
};

View File

@ -1,5 +1,7 @@
import { type JWK } from 'jose';
export * from '../src/ee/federated-saml/types';
interface SSOConnection {
defaultRedirectUrl: string;
redirectUrl: string[] | string;
@ -80,7 +82,7 @@ type TenantProduct = {
product: string;
};
export type GetConnectionsQuery = ClientIDQuery | TenantQuery;
export type GetConnectionsQuery = ClientIDQuery | TenantQuery | { entityId: string };
export type GetIDPEntityIDBody = TenantProduct;
export type DelConnectionsQuery = (ClientIDQuery & { clientSecret: string }) | TenantQuery;
@ -122,7 +124,9 @@ export interface IConnectionAPIController {
export interface IOAuthController {
authorize(body: OAuthReq): Promise<{ redirect_url?: string; authorize_form?: string }>;
samlResponse(body: SAMLResponsePayload): Promise<{ redirect_url?: string; app_select_form?: string }>;
samlResponse(
body: SAMLResponsePayload
): Promise<{ redirect_url?: string; app_select_form?: string; responseForm?: string }>;
oidcAuthzResponse(body: OIDCAuthzResponsePayload): Promise<{ redirect_url?: string }>;
token(body: OAuthTokenReq): Promise<OAuthTokenRes>;
userInfo(token: string): Promise<Profile>;
@ -334,6 +338,7 @@ export interface JacksonOption {
publicKey: string;
privateKey: string;
};
boxyhqLicenseKey?: string;
}
export interface SLORequestParams {

View File

@ -0,0 +1,96 @@
import tap from 'tap';
import { ISAMLFederationController } from '../../src';
import { databaseOptions } from '../utils';
import { tenant, product, serviceProvider, appId } from './constants';
let samlFederatedController: ISAMLFederationController;
tap.before(async () => {
const jackson = await (await import('../../src/index')).default(databaseOptions);
samlFederatedController = jackson.samlFederatedController;
});
tap.test('Federated SAML App', async (t) => {
const app = await samlFederatedController.app.create({
name: 'Test App',
tenant,
product,
entityId: serviceProvider.entityId,
acsUrl: serviceProvider.acsUrl,
});
tap.test('Should be able to create a new SAML Federation app', async (t) => {
t.ok(app);
t.match(app.id, appId);
t.match(app.tenant, tenant);
t.match(app.product, product);
t.match(app.entityId, serviceProvider.entityId);
t.match(app.acsUrl, serviceProvider.acsUrl);
t.end();
});
tap.test('Should be able to get the SAML Federation app by id', async (t) => {
const response = await samlFederatedController.app.get(app.id);
t.ok(response);
t.match(response.id, app.id);
t.end();
});
tap.test('Should be able to get the SAML Federation app by entity id', async (t) => {
const response = await samlFederatedController.app.getByEntityId(serviceProvider.entityId);
t.ok(response);
t.match(response.entityId, serviceProvider.entityId);
t.end();
});
tap.test('Should be able to update the SAML Federation app', async (t) => {
const response = await samlFederatedController.app.update(app.id, {
name: 'Updated App Name',
acsUrl: 'https://twilio.com/saml/acsUrl/updated',
});
t.ok(response);
t.match(response.name, 'Updated App Name');
t.match(response.acsUrl, 'https://twilio.com/saml/acsUrl/updated');
const updatedApp = await samlFederatedController.app.get(app.id);
t.ok(updatedApp);
t.match(updatedApp.name, 'Updated App Name');
t.match(updatedApp.acsUrl, 'https://twilio.com/saml/acsUrl/updated');
t.end();
});
tap.test('Should be able to get all SAML Federation apps', async (t) => {
const response = await samlFederatedController.app.getAll();
t.ok(response);
t.ok(response.length === 1);
t.end();
});
tap.test('Should be able to delete the SAML Federation app', async (t) => {
await samlFederatedController.app.delete(app.id);
const allApps = await samlFederatedController.app.getAll();
t.ok(allApps.length === 0);
t.end();
});
t.end();
});
tap.teardown(async () => {
process.exit(0);
});

View File

@ -0,0 +1,13 @@
import * as dbutils from '../../src/db/utils';
const tenant = 'boxyhq';
const product = 'flex';
const serviceProvider = {
acsUrl: 'https://twilio.com/saml2/acs',
entityId: 'https://twilio.com/saml2/entityId',
};
const appId = dbutils.keyDigest(dbutils.keyFromParts(tenant, product));
export { tenant, product, serviceProvider, appId };

View File

@ -0,0 +1,39 @@
<md:EntityDescriptor
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://saml.example.com/entityid"
validUntil="2032-11-29T06:16:13.750Z">
<md:IDPSSODescriptor
WantAuthnRequestsSigned="false"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate
>MIIC4jCCAcoCCQC33wnybT5QZDANBgkqhkiG9w0BAQsFADAyMQswCQYDVQQGEwJV
SzEPMA0GA1UECgwGQm94eUhRMRIwEAYDVQQDDAlNb2NrIFNBTUwwIBcNMjIwMjI4
MjE0NjM4WhgPMzAyMTA3MDEyMTQ2MzhaMDIxCzAJBgNVBAYTAlVLMQ8wDQYDVQQK
DAZCb3h5SFExEjAQBgNVBAMMCU1vY2sgU0FNTDCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBALGfYettMsct1T6tVUwTudNJH5Pnb9GGnkXi9Zw/e6x45DD0
RuRONbFlJ2T4RjAE/uG+AjXxXQ8o2SZfb9+GgmCHuTJFNgHoZ1nFVXCmb/Hg8Hpd
4vOAGXndixaReOiq3EH5XvpMjMkJ3+8+9VYMzMZOjkgQtAqO36eAFFfNKX7dTj3V
pwLkvz6/KFCq8OAwY+AUi4eZm5J57D31GzjHwfjH9WTeX0MyndmnNB1qV75qQR3b
2/W5sGHRv+9AarggJkF+ptUkXoLtVA51wcfYm6hILptpde5FQC8RWY1YrswBWAEZ
NfyrR4JeSweElNHg4NVOs4TwGjOPwWGqzTfgTlECAwEAATANBgkqhkiG9w0BAQsF
AAOCAQEAAYRlYflSXAWoZpFfwNiCQVE5d9zZ0DPzNdWhAybXcTyMf0z5mDf6FWBW
5Gyoi9u3EMEDnzLcJNkwJAAc39Apa4I2/tml+Jy29dk8bTyX6m93ngmCgdLh5Za4
khuU3AM3L63g7VexCuO7kwkjh/+LqdcIXsVGO6XDfu2QOs1Xpe9zIzLpwm/RNYeX
UjbSj5ce/jekpAw7qyVVL4xOyh8AtUW1ek3wIw1MJvEgEPt0d16oshWJpoS1OT8L
r/22SvYEo3EmSGdTVGgk3x3s+A0qWAqTcyjr7Q4s/GKYRFfomGwz0TZ4Iw1ZN99M m0eo2USlSRTVl7QHRTuiuSThHpLKQQ==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://mocksaml.com/api/saml/sso" />
<md:SingleSignOnService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://mocksaml.com/api/saml/sso" />
</md:IDPSSODescriptor>
</md:EntityDescriptor>

View File

@ -0,0 +1 @@
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="ONELOGIN_082f89d9-a32a-441d-ae49-ab6fc13fe73b" Version="2.0" IssueInstant="2022-11-29T09:04:48Z" ProviderName="Twilio" Destination="http://localhost:5225/api/federated-saml/sso" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="https://twilio.com/saml2/acs"><saml:Issuer>https://twilio.com/saml2/entityId</saml:Issuer><samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true" /><samlp:RequestedAuthnContext Comparison="exact"><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></samlp:RequestedAuthnContext></samlp:AuthnRequest>

View File

@ -0,0 +1,17 @@
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" Version="2.0" ID="_df0a63e0b825ed6552c6" Destination="http://localhost:5225/api/oauth/saml" InResponseTo="_6881987ee66b1556c5a4" IssueInstant="2022-11-29T09:11:07.356Z"><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:assertion">https://saml.example.com/entityid</saml:Issuer><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><Reference URI="#_df0a63e0b825ed6552c6"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><DigestValue>yxHOELG4n8U7f4xPfeS5ZyPFbAftoWPnogaacO9EpVg=</DigestValue></Reference></SignedInfo><SignatureValue>H/VdKbKQgyGto0YCix9zIPAkfwjnwnMDOr6/MMC3aKVM+TeY9fBzIMnhqLP79aWRXlXpG8SG7/i6ZsoEne50sptA4e50Ta7KcVKzoqHM+cEJ/gjqC0gr8SYYY0GPFkTnI5IvXW0VdycP002FWvV+i94CZFfn2f8/FAn5O7XpbeMiW2c4jIFnWU+gKZC8XlVCAa5tsiGTyZKJb/R1k8393BQdlxlsVBOBvM1GtWObwGovEb/pPYXeM3f9YkFY9L74zmMsCw4WteNpyHh6fkMEJzfoaoSt/PmM4vtjsdgK/b6HCG8pBQj84YAtBj8EDvPNMZVuJJvNfE7UdnoDBlIgbg==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIC4jCCAcoCCQC33wnybT5QZDANBgkqhkiG9w0BAQsFADAyMQswCQYDVQQGEwJV
SzEPMA0GA1UECgwGQm94eUhRMRIwEAYDVQQDDAlNb2NrIFNBTUwwIBcNMjIwMjI4
MjE0NjM4WhgPMzAyMTA3MDEyMTQ2MzhaMDIxCzAJBgNVBAYTAlVLMQ8wDQYDVQQK
DAZCb3h5SFExEjAQBgNVBAMMCU1vY2sgU0FNTDCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBALGfYettMsct1T6tVUwTudNJH5Pnb9GGnkXi9Zw/e6x45DD0
RuRONbFlJ2T4RjAE/uG+AjXxXQ8o2SZfb9+GgmCHuTJFNgHoZ1nFVXCmb/Hg8Hpd
4vOAGXndixaReOiq3EH5XvpMjMkJ3+8+9VYMzMZOjkgQtAqO36eAFFfNKX7dTj3V
pwLkvz6/KFCq8OAwY+AUi4eZm5J57D31GzjHwfjH9WTeX0MyndmnNB1qV75qQR3b
2/W5sGHRv+9AarggJkF+ptUkXoLtVA51wcfYm6hILptpde5FQC8RWY1YrswBWAEZ
NfyrR4JeSweElNHg4NVOs4TwGjOPwWGqzTfgTlECAwEAATANBgkqhkiG9w0BAQsF
AAOCAQEAAYRlYflSXAWoZpFfwNiCQVE5d9zZ0DPzNdWhAybXcTyMf0z5mDf6FWBW
5Gyoi9u3EMEDnzLcJNkwJAAc39Apa4I2/tml+Jy29dk8bTyX6m93ngmCgdLh5Za4
khuU3AM3L63g7VexCuO7kwkjh/+LqdcIXsVGO6XDfu2QOs1Xpe9zIzLpwm/RNYeX
UjbSj5ce/jekpAw7qyVVL4xOyh8AtUW1ek3wIw1MJvEgEPt0d16oshWJpoS1OT8L
r/22SvYEo3EmSGdTVGgk3x3s+A0qWAqTcyjr7Q4s/GKYRFfomGwz0TZ4Iw1ZN99M
m0eo2USlSRTVl7QHRTuiuSThHpLKQQ==
</X509Certificate></X509Data></KeyInfo></Signature><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status><saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="_253814d38cf3432d122e" IssueInstant="2022-11-29T09:11:07.356Z"><saml:Issuer>https://saml.example.com/entityid</saml:Issuer><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">jackson@example.com</saml:NameID></saml:Subject><saml:Conditions NotBefore="2022-11-29T09:06:07.356Z" NotOnOrAfter="2022-11-29T09:16:07.356Z"><saml:AudienceRestriction><saml:Audience>https://saml.boxyhq.com</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="2022-11-29T09:11:07.356Z" SessionIndex="_YIlFoNFzLMDYxdwf-T_BuimfkGa5qhKg"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement><saml:AttributeStatement xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><saml:Attribute Name="id" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">1dda9fb491dc01bd24d2423ba2f22ae561f56ddf2376b29a11c80281d21201f9</saml:AttributeValue></saml:Attribute><saml:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">jackson@example.com</saml:AttributeValue></saml:Attribute><saml:Attribute Name="firstName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">jackson</saml:AttributeValue></saml:Attribute><saml:Attribute Name="lastName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:basic"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">jackson</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion></samlp:Response>

View File

@ -0,0 +1,128 @@
import tap from 'tap';
import path from 'path';
import sinon from 'sinon';
import { promisify } from 'util';
import { deflateRaw } from 'zlib';
import saml from '@boxyhq/saml20';
import { promises as fs } from 'fs';
const deflateRawAsync = promisify(deflateRaw);
import { databaseOptions } from '../utils';
import { tenant, product, serviceProvider } from './constants';
import type {
ISAMLFederationController,
IConnectionAPIController,
IOAuthController,
SAMLFederationApp,
SAMLSSORecord,
} from '../../src';
let oauthController: IOAuthController;
let samlFederatedController: ISAMLFederationController;
let connectionAPIController: IConnectionAPIController;
let app: SAMLFederationApp;
let connection: SAMLSSORecord;
tap.before(async () => {
const jackson = await (await import('../../src/index')).default(databaseOptions);
oauthController = jackson.oauthController;
samlFederatedController = jackson.samlFederatedController;
connectionAPIController = jackson.connectionAPIController;
// Create app
app = await samlFederatedController.app.create({
name: 'Test App',
tenant,
product,
entityId: serviceProvider.entityId,
acsUrl: serviceProvider.acsUrl,
});
// Create SAML connection
connection = await connectionAPIController.createSAMLConnection({
tenant,
product,
rawMetadata: await fs.readFile(path.join(__dirname, '/data/metadata.xml'), 'utf8'),
defaultRedirectUrl: 'http://localhost:3366/sso/callback',
redirectUrl: '["http://localhost:3366"]',
});
});
tap.test('Federated SAML flow', async (t) => {
const relayStateFromSP = 'sp-saml-request-relay-state';
const requestXML = await fs.readFile(path.join(__dirname, '/data/request.xml'), 'utf8');
const responseXML = await fs.readFile(path.join(__dirname, '/data/response.xml'), 'utf8');
const samlRequestFromSP = Buffer.from(await deflateRawAsync(requestXML)).toString('base64');
const samlResponseFromIdP = Buffer.from(responseXML).toString('base64');
let jacksonRelayState: string | null = null;
tap.test('Should be able to accept SAML Request from SP and generate SAML Request for IdP', async (t) => {
const response = await samlFederatedController.sso.getAuthorizeUrl({
request: samlRequestFromSP,
relayState: relayStateFromSP,
});
// Extract relay state created by Jackson
jacksonRelayState = new URL(response.redirectUrl).searchParams.get('RelayState');
t.ok(
response.redirectUrl?.startsWith(`${connection.idpMetadata.sso.redirectUrl}`),
'Should have a SSO URL that starts with IdP SSO URL'
);
t.ok(response.redirectUrl, 'Should have a redirect URL');
t.ok(response.redirectUrl?.includes('SAMLRequest'), 'Should have a SAMLRequest in the redirect URL');
t.ok(response.redirectUrl?.includes('RelayState'), 'Should have a RelayState in the redirect URL');
t.end();
});
tap.test('Should be able to accept SAML Response from IdP and generate SAML Response for SP', async (t) => {
const stubValidate = sinon.stub(saml, 'validate').resolves({
audience: 'https://saml.boxyhq.com',
claims: {
id: '00u3e3cmpdDydXdzV5d7',
email: 'kiran@boxyhq.com',
firstName: 'Kiran',
lastName: 'Krishnan',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier': 'kiran@boxyhq.com',
},
issuer: 'https://saml.example.com/entityid',
sessionIndex: '_a30730c45288bbc4986b',
});
const response = await oauthController.samlResponse({
SAMLResponse: samlResponseFromIdP,
RelayState: jacksonRelayState ?? '',
});
t.ok(response);
t.ok('responseForm' in response);
t.ok(response.responseForm?.includes('SAMLResponse'), 'Should have a SAMLResponse in the response form');
t.ok(response.responseForm?.includes('RelayState'), 'Should have a RelayState in the response form');
const relayState = response.responseForm
? response.responseForm.match(/<input type="hidden" name="RelayState" value="(.*)"\/>/)?.[1]
: null;
t.match(relayState, relayStateFromSP, 'Should have the same relay state as the one sent by SP');
stubValidate.restore();
t.end();
});
t.end();
});
tap.teardown(async () => {
await samlFederatedController.app.delete(app.id);
await connectionAPIController.deleteConnections({ tenant, product });
process.exit(0);
});

View File

@ -34,6 +34,7 @@ const databaseOptions = <JacksonOption>{
jwtSigningKeys: { private: 'PRIVATE_KEY', public: 'PUBLIC_KEY' },
jwsAlg: 'RS256',
},
boxyhqLicenseKey: 'dummy-license',
};
export { addSSOConnections, databaseOptions };

View File

@ -0,0 +1,11 @@
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export { default } from 'ee/federated-saml/pages/edit';
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
};
}

View File

@ -0,0 +1,11 @@
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export { default } from 'ee/federated-saml/pages/metadata';
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
};
}

View File

@ -0,0 +1,11 @@
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export { default } from 'ee/federated-saml/pages/index';
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
};
}

View File

@ -0,0 +1,11 @@
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export { default } from 'ee/federated-saml/pages/new';
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ['common'])),
},
};
}

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { DirectoryType } from '@lib/jackson';
import type { DirectoryType } from '@boxyhq/saml-jackson';
import jackson from '@lib/jackson';
import { checkSession } from '@lib/middleware';

View File

@ -0,0 +1 @@
export { default } from 'ee/federated-saml/api/admin/[id]/index';

View File

@ -0,0 +1 @@
export { default } from 'ee/federated-saml/api/admin/index';

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` } });
}
}
// Check License key
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { checkLicense } = await jackson();
try {
const hasValidLicense = await checkLicense();
res.status(200).json({ data: { status: hasValidLicense } });
} catch (error: any) {
const { message, statusCode = 500 } = error;
res.status(statusCode).json({
error: { message },
});
}
};

View File

@ -0,0 +1 @@
export { default } from 'ee/federated-saml/api/metadata';

View File

@ -0,0 +1 @@
export { default } from 'ee/federated-saml/api/sso';

View File

@ -1,27 +1,49 @@
import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { setErrorCookie } from '@lib/utils';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req;
try {
if (req.method !== 'POST') {
throw { message: 'Method not allowed', statusCode: 405 };
if (method !== 'POST') {
throw { message: `Method ${method} Not Allowed`, statusCode: 405 };
}
const { oauthController } = await jackson();
const { redirect_url, app_select_form } = await oauthController.samlResponse(req.body);
const { SAMLResponse, RelayState, idp_hint } = req.body as {
SAMLResponse: string;
RelayState: string;
idp_hint: string;
};
// Handle SAML Response generated by IdP
const { redirect_url, app_select_form, responseForm } = await oauthController.samlResponse({
SAMLResponse,
RelayState,
idp_hint,
});
if (redirect_url) {
res.redirect(302, redirect_url);
} else {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(app_select_form);
return res.redirect(302, redirect_url);
}
} catch (err: any) {
console.error('callback error:', err);
const { message, statusCode = 500 } = err;
// set error in cookie redirect to error page
if (app_select_form) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(app_select_form);
}
if (responseForm) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
return res.send(responseForm);
}
} catch (error: any) {
const { message, statusCode = 500 } = error;
setErrorCookie(res, { message, statusCode }, { path: '/error' });
res.redirect('/error');
return res.redirect('/error');
}
}

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { HTTPMethod, DirectorySyncRequest } from '@lib/jackson';
import type { HTTPMethod, DirectorySyncRequest } from '@boxyhq/saml-jackson';
import jackson from '@lib/jackson';
import { extractAuthToken } from '@lib/auth';
import { bodyParser } from '@lib/utils';

View File

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

View File

@ -1,5 +1,6 @@
import jackson, { type GetConnectionsQuery } from '@lib/jackson';
import jackson from '@lib/jackson';
import { NextApiRequest, NextApiResponse } from 'next';
import type { GetConnectionsQuery } from '@boxyhq/saml-jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {

View File

@ -1,7 +1,8 @@
// Maintain /config path for backward compatibility
import jackson, { type GetConfigQuery } from '@lib/jackson';
import jackson from '@lib/jackson';
import { NextApiRequest, NextApiResponse } from 'next';
import type { GetConfigQuery } from '@boxyhq/saml-jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {

View File

@ -1,7 +1,8 @@
// Maintain /config path for backward compatibility
import jackson, { type GetConfigQuery } from '@lib/jackson';
import jackson from '@lib/jackson';
import { NextApiRequest, NextApiResponse } from 'next';
import type { GetConfigQuery } from '@boxyhq/saml-jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {

View File

@ -1,124 +1,202 @@
import type { GetServerSideProps } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import getRawBody from 'raw-body';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export default function IdPSelection({ SAMLResponse, appList }) {
const { t } = useTranslation('common');
import jackson from '@lib/jackson';
export default function ChooseIdPConnection({
connections,
SAMLResponse,
requestType,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<div className='mx-auto my-28 w-[500px]'>
<div className='mx-5 flex flex-col space-y-10 rounded border border-gray-300 p-10'>
{requestType === 'sp-initiated' ? (
<IdpSelector connections={connections} />
) : (
<AppSelector connections={connections} SAMLResponse={SAMLResponse} />
)}
</div>
</div>
);
}
const IdpSelector = ({ connections }: { connections: (OIDCSSORecord | SAMLSSORecord)[] }) => {
const router = useRouter();
const { idp: idpList, ...rest } = router.query as { idp?: string[] };
const formRef = useRef<HTMLFormElement>(null);
const [app, setApp] = useState<string>('');
const handleChange = (event: React.FormEvent<HTMLInputElement>) => {
setApp(event.currentTarget.value);
// SP initiated SSO: Redirect to the same path with idp_hint set to the selected connection clientID
const connectionSelected = (clientID: string) => {
return router.push(`${router.asPath}&idp_hint=${clientID}`);
};
useEffect(() => {
if (app) {
formRef.current?.submit();
}
}, [app]);
return (
<>
<h3 className='text-center text-xl font-bold'>Select an Identity Provider to continue</h3>
<ul className='flex flex-col space-y-5'>
{connections.map((connection) => {
const idpMetadata = 'idpMetadata' in connection ? connection.idpMetadata : undefined;
const oidcProvider = 'oidcProvider' in connection ? connection.oidcProvider : undefined;
if (Array.isArray(idpList) && idpList.length !== 0) {
const paramsToRelay = new URLSearchParams(Object.entries(rest));
return (
<div className='relative top-1/2 left-1/2 w-1/2 max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-md border-[1px] py-4 px-6 text-center'>
<h1 className='mb-4 px-3 text-center text-lg font-bold text-black'>
{t('choose_an_identity_provider')}
</h1>
<ul className='max-h-96 overflow-auto'>
{idpList.map((idp) => {
const { clientID, name, provider, connectionIsSAML, connectionIsOIDC } = JSON.parse(idp);
const connectionType = connectionIsOIDC ? 'OIDC' : connectionIsSAML ? 'SAML' : '';
const connectionLabel = name ? `${name} (${provider})` : provider;
const name = connection.name || (idpMetadata ? idpMetadata.provider : `${oidcProvider?.provider}`);
return (
<li key={connection.clientID} className='rounded bg-gray-100'>
<button
type='button'
className='w-full'
onClick={() => {
connectionSelected(connection.clientID);
}}>
<div className='flex items-center gap-2 py-3 px-3'>
<div className='placeholder avatar'>
<div className='w-8 rounded-full bg-primary text-white'>
<span className='text-xs font-bold'>{name.charAt(0).toUpperCase()}</span>
</div>
</div>
{name}
</div>
</button>
</li>
);
})}
</ul>
<p className='text-center text-sm text-slate-600'>
Choose an Identity Provider to continue. If you don&apos;t see your Identity Provider, please contact
your administrator.
</p>
</>
);
};
const AppSelector = ({
connections,
SAMLResponse,
}: {
connections: (OIDCSSORecord | SAMLSSORecord)[];
SAMLResponse: string | null;
}) => {
const { t } = useTranslation('common');
const formRef = useRef<HTMLFormElement>(null);
if (!SAMLResponse) {
return <p className='text-center text-sm text-slate-600'>No SAMLResponse found.</p>;
}
// IdP initiated SSO: Submit the SAMLResponse and idp_hint to the SAML ACS endpoint
const appSelected = () => {
formRef.current?.submit();
};
return (
<>
<h3 className='text-center text-xl font-bold'>{t('select_an_app')}</h3>
<form method='POST' action='/api/oauth/saml' ref={formRef}>
<input type='hidden' name='SAMLResponse' value={SAMLResponse} />
<ul className='flex flex-col space-y-5'>
{connections.map((connection) => {
return (
<li className='relative my-3 border-b-[1px] bg-white last:border-b-0' key={clientID}>
<Link
href={`/api/oauth/authorize?${paramsToRelay.toString()}&idp_hint=${clientID}`}
className='relative flex w-full cursor-pointer items-center overflow-hidden py-3 px-8 text-center text-[#3C454C] outline-none transition-colors hover:bg-primary/10 focus:bg-primary/30'
aria-label={`Connection ${connectionLabel} of type ${connectionType}`}>
<span aria-hidden className='m-auto'>
{connectionLabel}
</span>
<span aria-hidden className='badge-primary badge badge-md'>
{connectionType}
</span>
</Link>
<li key={connection.clientID} className='rounded bg-gray-100'>
<div className='flex items-center gap-2 py-3 px-3'>
<input
type='radio'
name='idp_hint'
className='radio'
value={connection.clientID}
onChange={appSelected}
id={connection.clientID}
/>
<label htmlFor={connection.clientID}>{connection.product}</label>
</div>
</li>
);
})}
</ul>
</div>
);
}
if (Array.isArray(appList) && appList.length !== 0 && SAMLResponse) {
const paramsToRelay = Object.entries({ SAMLResponse });
return (
<form
ref={formRef}
action='/api/oauth/saml'
method='post'
className='relative top-1/2 left-1/2 w-1/2 max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-md border-[1px] py-4 px-6 text-center'>
{paramsToRelay
.filter(([, value]) => value !== undefined)
.map(([key, value]) => (
<input key={key} type='hidden' name={key} value={value} />
))}
<fieldset className='border-0'>
<legend className='mb-4 px-3 text-center text-lg font-bold text-black'>{t('select_an_app')}</legend>
<div className='max-h-96 overflow-auto'>
{appList.map((idp) => {
const { clientID, name, description, product } = idp;
return (
<div className='relative my-2 border-b-[1px] bg-white last:border-b-0' key={clientID}>
<input
id={`radio-${clientID}`}
name='idp_hint'
type='radio'
className={`peer sr-only`}
value={clientID}
checked={app === clientID}
onChange={handleChange}
/>
<label
htmlFor={`radio-${clientID}`}
className='relative flex w-full cursor-pointer flex-col items-start overflow-hidden py-3 px-8 text-[#3C454C] transition-colors hover:bg-primary/10 focus:bg-primary/30 peer-checked:bg-primary/25'>
<span className='font-bold'>{name || product}</span>
{description && <span className='font-light'>{description}</span>}
</label>
</div>
);
})}
</div>
</fieldset>
<input type='submit' value='submit' />
</form>
);
<p className='text-center text-sm text-slate-600'>
Choose an app to continue. If you don&apos;t see your app, please contact your administrator.
</p>
</>
);
};
export const getServerSideProps: GetServerSideProps<{
connections: (OIDCSSORecord | SAMLSSORecord)[];
SAMLResponse: string | null;
requestType: 'sp-initiated' | 'idp-initiated';
}> = async ({ query, locale, req }) => {
const { connectionAPIController } = await jackson();
const paramsToRelay = { ...query } as { [key: string]: string };
const { authFlow, entityId, tenant, product, idp_hint } = query as {
authFlow: 'saml' | 'oauth';
tenant?: string;
product?: string;
idp_hint?: string;
entityId?: string;
};
// The user has selected an IdP to continue with
if (idp_hint) {
const params = new URLSearchParams(paramsToRelay).toString();
const destinations = {
saml: `/api/federated-saml/sso?${params}`,
oauth: `/api/oauth/authorize?${params}`,
};
return {
redirect: {
destination: destinations[authFlow],
permanent: false,
},
};
}
return <div className='text-black'>{t('selection_list_empty')}</div>;
}
// Otherwise, show the list of IdPs
let connections: (OIDCSSORecord | SAMLSSORecord)[] = [];
export const getServerSideProps: GetServerSideProps = async ({ req, locale }) => {
if (tenant && product) {
connections = await connectionAPIController.getConnections({ tenant, product });
} else if (entityId) {
connections = await connectionAPIController.getConnections({ entityId: decodeURIComponent(entityId) });
}
// For idp-initiated flows, we need to parse the SAMLResponse from the request body and pass it to the component
if (req.method == 'POST') {
const body = await getRawBody(req);
const payload = body.toString('utf-8');
const params = new URLSearchParams(body.toString('utf-8'));
const params = new URLSearchParams(payload);
const SAMLResponse = params.get('SAMLResponse') || '';
const app = decodeURIComponent(params.get('app') || '');
const SAMLResponse = params.get('SAMLResponse');
return { props: { SAMLResponse, appList: app ? JSON.parse(app) : [] } };
// SAMLResponse should exist with idp-initiated flow
if (!SAMLResponse) {
return {
notFound: true,
};
}
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
requestType: 'idp-initiated',
SAMLResponse,
connections,
},
};
}
return {
props: {
...(locale ? await serverSideTranslations(locale, ['common']) : {}),
requestType: 'sp-initiated',
SAMLResponse: null,
connections,
},
};
};

View File

@ -4,6 +4,7 @@ module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./ee/**/*.{js,ts,jsx,tsx}',
'node_modules/daisyui/dist/**/*.js',
],
daisyui: {

View File

@ -22,7 +22,8 @@
"paths": {
"@components/*": ["components/*"],
"@lib/*": ["lib/*"],
"@styles/*": ["styles/*"]
"@styles/*": ["styles/*"],
"@ee/*": ["ee/*"]
}
},
"include": ["next-env.d.ts", "types/*.d.ts", "**/*.ts", "**/*.tsx"],

View File

@ -1,6 +1,7 @@
export type ApiSuccess<T> = { data: T };
export interface ApiError extends Error {
info?: string;
status: number;
}