mirror of https://github.com/boxyhq/jackson.git
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:
parent
2cf9675794
commit
7287a6bb37
|
@ -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=
|
|
@ -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;
|
|
@ -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')}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
The BoxyHQ Enterprise Edition (EE) license (the “EE License”)
|
|
@ -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);
|
|
@ -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);
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
40
lib/env.ts
40
lib/env.ts
|
@ -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 };
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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(':');
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
The BoxyHQ Enterprise Edition (EE) license (the “EE License”)
|
|
@ -0,0 +1,9 @@
|
|||
const checkLicense = async (license: string | undefined): Promise<boolean> => {
|
||||
if (!license) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return license === 'dummy-license';
|
||||
};
|
||||
|
||||
export default checkLicense;
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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>>;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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 };
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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);
|
||||
});
|
|
@ -34,6 +34,7 @@ const databaseOptions = <JacksonOption>{
|
|||
jwtSigningKeys: { private: 'PRIVATE_KEY', public: 'PUBLIC_KEY' },
|
||||
jwsAlg: 'RS256',
|
||||
},
|
||||
boxyhqLicenseKey: 'dummy-license',
|
||||
};
|
||||
|
||||
export { addSSOConnections, databaseOptions };
|
||||
|
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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'])),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'ee/federated-saml/api/admin/[id]/index';
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'ee/federated-saml/api/admin/index';
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'ee/federated-saml/api/metadata';
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'ee/federated-saml/api/sso';
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'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'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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"],
|
||||
|
|
Loading…
Reference in New Issue