New OIDC fed (#2336)

* add WellKnownURLs

* Fix translation keys

* Update dependencies and add IdP Configuration

* Update common.json with new translations

* wip

* Update @boxyhq/internal-ui version to 0.0.5

* add internal ui folder

* Fix imports and build

* Refactor internal-ui package structure

* wip shared UI

* Fix the build

* WIP

* Add new components and hooks for directory sync

* WIP

* lint fix

* updated swr

* WIP

* users

* Refactor shared components and fix API endpoints***

***Update directory user page and add new federated SAML app

* Fix lint

* wip

* Add new files and update existing files

* Refactor DirectoryGroups and DirectoryInfo components

* Update localization strings for directory UI

* Update Google Auth URL description in common.json

* Refactor directory tab and add delete functionality to webhook logs

* IdP selection screen changes

* Delete unused files and update dependencies

* Fix column declaration

* Add internal-ui/dist to .gitignore

* Update page limit and add new dependencies

* wip

* Refactor directory search in user API endpoint

* wip

* Refactor directory retrieval logic in user and group API handlers

* Add API endpoints for retrieving webhook events

* check app's redirectUrl, TODO: save app info into session to read later

* Add query parameters to API URLs in DirectoryGroups

* working saml login via IdP select. TODO: oidc login via IdP select and saml + oidc login with 1 connection

* oidc IdP working with selection

* working oidc fed -> saml flow

* Add Google authorization status badge and handle pagination in FederatedSAMLApps

* Add router prop to AppsList component and update page header titles

* UI changes

* updated peer-deps

* Add new files and export functions

* Remove unused router prop

* Add PencilIcon to FederatedSAMLApps

* updated federated app creation page

* updated federated app edit page

* Refactor FederatedSAMLApps and NewFederatedSAMLApp components

* lint fix

* lint fix

* updated package-lock

* add jose npm to dev dep

* added missing strings

* added missing strings

* locale strings fix

* locale strings cleanup

* tweaks to icon imports

* replaced textarea with list of inputs for Federated Apps redirect url

* update package-lock

* Add prepublish step

* Build and publish npm and internal ui

* Refactor install step

* Run npm install (for local) inside internal ui automatically using prepare

* Remove eslint setup for internal-ui

* updated package-lock

* Add `--legacy-peer-deps` to prevent installing peer dependencies

* Fix the types import path

* wip

* wip

* Fix the types

* Format

* Update package-lock

* Cleanup

* Try adding jose library version 5.2.2

* allow selective subdomain globbing

* removed duplicate jose lib

* updated package-lock

* updated swagger doc

* SAML Federation -> Identity Federation

* fixed locale strings

* turn off autocomplete for tags input

---------

Co-authored-by: Kiran K <mailtokirankk@gmail.com>
Co-authored-by: Aswin V <vaswin91@gmail.com>
This commit is contained in:
Deepak Prabhakara 2024-03-05 16:57:02 +00:00 committed by GitHub
parent f923451fbf
commit a473b360ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 600 additions and 246 deletions

View File

@ -8,7 +8,6 @@ const allStrings = {};
const localeFile = require('./locales/en/common.json');
const files = fs.readdirSync('./', { recursive: true, withFileTypes: true });
//console.log('files:', files);
let error = false;
@ -25,7 +24,6 @@ files.forEach((file) => {
(fileContent.match(regExp) || []).forEach((match) => {
const id = match.replace("t('", '').replace("'", '');
// console.log('match:', match);
allStrings[id] = true;
if (!localeFile[id]) {
error = true;
@ -35,7 +33,6 @@ files.forEach((file) => {
(fileContent.match(altRegExp) || []).forEach((match) => {
const id = match.replace("i18nKey='", '').replace("'", '');
// console.log('match:', match, id);
allStrings[id] = true;
if (!localeFile[id]) {
error = true;

View File

@ -10,7 +10,7 @@ import SSOLogo from '@components/logo/SSO';
import DSyncLogo from '@components/logo/DSync';
import AuditLogsLogo from '@components/logo/AuditLogs';
import Vault from '@components/logo/Vault';
import { Cog8ToothIcon } from '@heroicons/react/24/outline';
import Cog8ToothIcon from '@heroicons/react/24/outline/Cog8ToothIcon';
type SidebarProps = {
isOpen: boolean;

View File

@ -2,7 +2,8 @@ import { ButtonLink } from '@components/ButtonLink';
import { Dispatch, FormEvent, SetStateAction, useMemo, useState } from 'react';
import { EditViewOnlyFields, getCommonFields } from './fieldCatalog';
import { CopyToClipboardButton } from '@components/ClipboardButton';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import EyeSlashIcon from '@heroicons/react/24/outline/EyeSlashIcon';
import { IconButton } from '@components/IconButton';
import { useTranslation } from 'next-i18next';

View File

@ -26,7 +26,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
};
// Get SAML Federation app by id
// Get Identity Federation app by id
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
@ -43,7 +43,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
});
};
// Update SAML Federation app
// Update Identity Federation app
const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
@ -52,7 +52,7 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(200).json({ data: updatedApp });
};
// Delete the SAML Federation app
// Delete the Identity Federation app
const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();

View File

@ -22,7 +22,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
};
// Create new SAML Federation app
// Create new Identity Federation app
const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
@ -31,7 +31,7 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(201).json({ data: app });
};
// Get SAML Federation apps
// Get Identity Federation apps
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();

View File

@ -11,7 +11,11 @@ const AppsList = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
<FederatedSAMLApps
urls={{ getApps: '/api/admin/federated-saml' }}
onEdit={(app) => router.push(`/admin/federated-saml/${app.id}/edit`)}
actions={{ newApp: '/admin/federated-saml/new', idpConfiguration: '/.well-known/idp-configuration' }}
actions={{
newApp: '/admin/federated-saml/new',
samlConfiguration: '/.well-known/idp-configuration',
oidcConfiguration: '/.well-known/openid-configuration',
}}
/>
);
};

View File

@ -1,6 +1,6 @@
import useSWR from 'swr';
import { useTranslation } from 'next-i18next';
import { EyeIcon } from '@heroicons/react/24/outline';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import type { Group } from '../types';
import { fetcher, addQueryParamsToPath } from '../utils';
import { DirectoryTab } from '../dsync';

View File

@ -1,6 +1,6 @@
import useSWR from 'swr';
import { useTranslation } from 'next-i18next';
import { EyeIcon } from '@heroicons/react/24/outline';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import type { User } from '../types';
import { addQueryParamsToPath, fetcher } from '../utils';
import { DirectoryTab } from '../dsync';

View File

@ -1,7 +1,7 @@
import useSWR from 'swr';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { EyeIcon } from '@heroicons/react/24/outline';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import type { WebhookEventLog } from '../types';
import { fetcher, addQueryParamsToPath } from '../utils';
import { DirectoryTab } from '../dsync';

View File

@ -5,8 +5,9 @@ import { useTranslation } from 'next-i18next';
import { useFormik } from 'formik';
import { Card } from '../shared';
import { defaultHeaders } from '../utils';
import { ItemList } from '../shared/ItemList';
type EditApp = Pick<SAMLFederationApp, 'name' | 'acsUrl' | 'tenants'>;
type EditApp = Pick<SAMLFederationApp, 'name' | 'acsUrl' | 'tenants' | 'redirectUrl'>;
export const Edit = ({
app,
@ -23,12 +24,16 @@ export const Edit = ({
}) => {
const { t } = useTranslation('common');
const connectionIsOIDC = app.type === 'oidc';
const connectionIsSAML = !connectionIsOIDC;
const formik = useFormik<EditApp>({
enableReinitialize: true,
initialValues: {
name: app.name || '',
acsUrl: app.acsUrl || '',
tenants: app.tenants || [],
redirectUrl: app.redirectUrl || [],
},
onSubmit: async (values) => {
const rawResponse = await fetch(urls.patch, {
@ -92,34 +97,74 @@ export const Edit = ({
/>
</label>
)}
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-acs-url')}</span>
</div>
<input
type='url'
placeholder='https://your-sp.com/saml/acs'
className='input input-bordered w-full text-sm'
name='acsUrl'
value={formik.values.acsUrl}
onChange={formik.handleChange}
required
/>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-entity-id')}</span>
</div>
<input
type='text'
className='input input-bordered w-full text-sm'
value={app.entityId}
disabled
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-entity-id-edit-desc')}</span>
{connectionIsOIDC && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-client-id')}</span>
</div>
<input
type='text'
className='input-bordered input'
defaultValue={app.clientID}
disabled
/>
</label>
</label>
)}
{connectionIsOIDC && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-client-secret')}</span>
</div>
<input
type='text'
className='input-bordered input'
defaultValue={app.clientSecret}
disabled
/>
</label>
)}
{connectionIsSAML && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-entity-id')}</span>
</div>
<input
type='text'
className='input input-bordered w-full text-sm'
value={app.entityId}
disabled
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-entity-id-edit-desc')}</span>
</label>
</label>
)}
{connectionIsSAML && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-acs-url')}</span>
</div>
<input
type='url'
placeholder='https://your-sp.com/saml/acs'
className='input input-bordered w-full text-sm'
name='acsUrl'
value={formik.values.acsUrl}
onChange={formik.handleChange}
required
/>
</label>
)}
{connectionIsOIDC && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-sl-allowed-redirect-urls-new')}</span>
</div>
<ItemList
currentlist={formik.values.redirectUrl || ['']}
onItemListChange={(newList) => formik.setFieldValue('redirectUrl', newList)}></ItemList>
</label>
)}
<label className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('bui-fs-tenants')}</span>
@ -130,6 +175,7 @@ export const Edit = ({
onlyUnique={true}
inputProps={{
placeholder: t('bui-fs-enter-tenant'),
autocomplete: 'off',
}}
focusedClassName='input-focused'
addOnBlur={true}

View File

@ -42,6 +42,9 @@ export const EditFederatedSAMLApp = ({
const app = data?.data;
const connectionIsOIDC = app.type === 'oidc';
const connectionIsSAML = !connectionIsOIDC;
const deleteApp = async () => {
try {
await fetch(urls.deleteApp, { method: 'DELETE', headers: defaultHeaders });
@ -66,15 +69,17 @@ export const EditFederatedSAMLApp = ({
}}
excludeFields={excludeFields}
/>
<EditAttributesMapping
app={app}
urls={{ patch: urls.updateApp }}
onError={onError}
onUpdate={(data) => {
mutate({ data });
onUpdate?.(data);
}}
/>
{connectionIsSAML && (
<EditAttributesMapping
app={app}
urls={{ patch: urls.updateApp }}
onError={onError}
onUpdate={(data) => {
mutate({ data });
onUpdate?.(data);
}}
/>
)}
<EditBranding
app={app}
urls={{ patch: urls.updateApp }}

View File

@ -12,7 +12,7 @@ import {
} from '../shared';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types';
import { PencilIcon } from '@heroicons/react/24/outline';
import PencilIcon from '@heroicons/react/24/outline/PencilIcon';
import { TableBodyType } from '../shared/Table';
import { pageLimit } from '../shared/Pagination';
import { usePaginate } from '../hooks';
@ -30,7 +30,7 @@ export const FederatedSAMLApps = ({
urls: { getApps: string };
excludeFields?: ExcludeFields[];
onEdit?: (app: SAMLFederationApp) => void;
actions: { newApp: string; idpConfiguration: string };
actions: { newApp: string; samlConfiguration: string; oidcConfiguration: string };
actionCols?: { text: string; onClick: (app: SAMLFederationApp) => void; icon: JSX.Element }[];
}) => {
const { router } = useRouter();
@ -128,8 +128,11 @@ export const FederatedSAMLApps = ({
title={t('bui-fs-apps')}
actions={
<>
<LinkOutline href={actions.idpConfiguration} target='_blank' className='btn-md'>
{t('bui-fs-idp-config')}
<LinkOutline href={actions.oidcConfiguration} target='_blank' className='btn-md'>
{t('bui-fs-oidc-config')}
</LinkOutline>
<LinkOutline href={actions.samlConfiguration} target='_blank' className='btn-md'>
{t('bui-fs-saml-config')}
</LinkOutline>
<ButtonPrimary onClick={() => router?.push(actions.newApp)} className='btn-md'>
{t('bui-fs-new-app')}

View File

@ -3,14 +3,15 @@ import TagsInput from 'react-tagsinput';
import { Card, Button } from 'react-daisyui';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types';
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
import QuestionMarkCircleIcon from '@heroicons/react/24/outline/QuestionMarkCircleIcon';
import { defaultHeaders } from '../utils';
import { AttributesMapping } from './AttributesMapping';
import { PageHeader } from '../shared';
import { ItemList } from '../shared/ItemList';
type NewSAMLFederationApp = Pick<
SAMLFederationApp,
'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings'
'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings' | 'type' | 'redirectUrl'
>;
export const NewFederatedSAMLApp = ({
@ -31,6 +32,7 @@ export const NewFederatedSAMLApp = ({
const { t } = useTranslation('common');
const initialValues: NewSAMLFederationApp = {
type: 'saml',
name: '',
tenant: '',
product: '',
@ -65,6 +67,9 @@ export const NewFederatedSAMLApp = ({
},
});
const connectionIsOIDC = formik.values.type === 'oidc';
const connectionIsSAML = !connectionIsOIDC;
const generateEntityId = () => {
const id = crypto.randomUUID().replace(/-/g, '');
const entityId = `${samlAudience}/${id}`;
@ -78,6 +83,38 @@ export const NewFederatedSAMLApp = ({
<PageHeader title={t('bui-fs-create-app')} />
<form onSubmit={formik.handleSubmit} method='POST'>
<Card className='p-6 rounded space-y-3'>
<div className='mb-4 flex items-center'>
<div className='mr-2 py-3'>{t('bui-fs-select-app-type')}:</div>
<div className='flex w-52'>
<div className='form-control'>
<label className='label mr-4 cursor-pointer'>
<input
type='radio'
name='type'
value='saml'
className='radio-primary radio'
checked={formik.values.type === 'saml'}
onChange={formik.handleChange}
/>
<span className='label-text ml-1'>{t('bui-fs-saml')}</span>
</label>
</div>
<div className='form-control'>
<label className='label mr-4 cursor-pointer' data-testid='sso-type-oidc'>
<input
type='radio'
name='type'
value='oidc'
className='radio-primary radio'
checked={formik.values.type === 'oidc'}
onChange={formik.handleChange}
/>
<span className='label-text ml-1'>{t('bui-fs-oidc')}</span>
</label>
</div>
</div>
</div>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-name')}</span>
@ -98,7 +135,7 @@ export const NewFederatedSAMLApp = ({
</div>
<input
type='text'
placeholder='acme'
placeholder='example.com'
className='input input-bordered w-full text-sm'
name='tenant'
value={formik.values.tenant}
@ -122,49 +159,63 @@ export const NewFederatedSAMLApp = ({
/>
</label>
)}
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-acs-url')}</span>
</div>
<input
type='url'
placeholder='https://your-sp.com/saml/acs'
className='input input-bordered w-full text-sm'
name='acsUrl'
value={formik.values.acsUrl}
onChange={formik.handleChange}
required
/>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-entity-id')}</span>
<span className='label-text-alt'>
<div className='flex items-center gap-1'>
<span
className='cursor-pointer border-stone-600 border p-1 rounded'
onClick={generateEntityId}>
{t('bui-fs-generate-sp-entity-id')}
</span>
<div className='tooltip tooltip-left' data-tip={t('bui-fs-entity-id-instruction')}>
<QuestionMarkCircleIcon className='h-5 w-5' />
</div>
</div>
</span>
</div>
<input
type='text'
placeholder='https://your-sp.com/saml/entityId'
className='input input-bordered w-full text-sm'
name='entityId'
value={formik.values.entityId}
onChange={formik.handleChange}
required
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-entity-id-change-restriction')}</span>
{connectionIsSAML && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-acs-url')}</span>
</div>
<input
type='url'
placeholder='https://your-sp.com/saml/acs'
className='input input-bordered w-full text-sm'
name='acsUrl'
value={formik.values.acsUrl}
onChange={formik.handleChange}
required
/>
</label>
</label>
)}
{connectionIsOIDC && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-sl-allowed-redirect-urls-new')}</span>
</div>
<ItemList
currentlist={formik.values.redirectUrl || ['']}
onItemListChange={(newList) => formik.setFieldValue('redirectUrl', newList)}></ItemList>
</label>
)}
{connectionIsSAML && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-entity-id')}</span>
<span className='label-text-alt'>
<div className='flex items-center gap-1'>
<span
className='cursor-pointer border-stone-600 border p-1 rounded'
onClick={generateEntityId}>
{t('bui-fs-generate-sp-entity-id')}
</span>
<div className='tooltip tooltip-left' data-tip={t('bui-fs-entity-id-instruction')}>
<QuestionMarkCircleIcon className='h-5 w-5' />
</div>
</div>
</span>
</div>
<input
type='text'
placeholder='https://your-sp.com/saml/entityId'
className='input input-bordered w-full text-sm'
name='entityId'
value={formik.values.entityId}
onChange={formik.handleChange}
required
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-entity-id-change-restriction')}</span>
</label>
</label>
)}
<label className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('bui-fs-tenants')}</span>
@ -175,6 +226,7 @@ export const NewFederatedSAMLApp = ({
onlyUnique={true}
inputProps={{
placeholder: t('bui-fs-enter-tenant'),
autocomplete: 'off',
}}
focusedClassName='input-focused'
addOnBlur={true}
@ -183,20 +235,23 @@ export const NewFederatedSAMLApp = ({
<span className='label-text-alt'>{t('bui-fs-tenants-mapping-desc')}</span>
</label>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-attribute-mappings')}</span>
</div>
<div className='label'>
<span className='label-text-alt'>{t('bui-fs-attribute-mappings-desc')}</span>
</div>
</label>
<AttributesMapping
mappings={formik.values.mappings || []}
onAttributeMappingsChange={(mappings) => formik.setFieldValue('mappings', mappings)}
/>
{connectionIsSAML && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-attribute-mappings')}</span>
</div>
<div className='label'>
<span className='label-text-alt'>{t('bui-fs-attribute-mappings-desc')}</span>
</div>
<AttributesMapping
mappings={formik.values.mappings || []}
onAttributeMappingsChange={(mappings) => formik.setFieldValue('mappings', mappings)}
/>
</label>
)}
<div className='flex gap-2 justify-end pt-6'>
<Button
type='submit'
className='btn btn-primary btn-md'
loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}>

View File

@ -0,0 +1,73 @@
import { useTranslation } from 'next-i18next';
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
export const ItemList = ({
currentlist,
onItemListChange,
}: {
currentlist: string | string[];
onItemListChange: (list: string[]) => void;
}) => {
const { t } = useTranslation('common');
const list = Array.isArray(currentlist) ? currentlist : [currentlist];
const addAnother = () => {
onItemListChange([...list, '']);
};
return (
<div>
<div className='flex flex-col gap-4'>
{list.map((item, index) => (
<div key={index}>
<ItemRow
item={item}
onItemChange={(newItem) => {
const newList = [...list];
newList[index] = newItem;
onItemListChange(newList);
}}
onItemDelete={() => {
onItemListChange(list.filter((_, i) => i !== index));
}}
/>
</div>
))}
<div>
<button className='btn btn-primary btn-sm btn-outline' type='button' onClick={addAnother}>
{t('bui-fs-add')}
</button>
</div>
</div>
</div>
);
};
const ItemRow = ({
item,
onItemChange,
onItemDelete,
}: {
item: string;
onItemChange: (newItem: string) => void;
onItemDelete: () => void;
}) => {
return (
<div className='flex space-x-3 items-center'>
<input
type='text'
className='input input-bordered input-sm w-full'
name='item'
value={item}
onChange={(e) => {
onItemChange(e.target.value);
}}
required
/>
<button type='button' onClick={onItemDelete}>
<XMarkIcon className='h-5 w-5 text-red-500' />
</button>
</div>
);
};

View File

@ -91,6 +91,10 @@ export type AttributeMapping = {
export type SAMLFederationApp = {
id: string;
type?: string;
clientID?: string;
clientSecret?: string;
redirectUrl?: string[] | string;
name: string;
tenant: string;
product: string;

View File

@ -48,19 +48,26 @@ export const WellKnownURLs = ({ jacksonUrl }: { jacksonUrl?: string }) => {
type: 'auth',
},
{
title: t('bui-wku-idp-metadata'),
description: t('bui-wku-idp-metadata-desc'),
title: t('bui-wku-saml-idp-metadata'),
description: t('bui-wku-saml-idp-metadata-desc'),
href: `${baseUrl}/.well-known/idp-metadata`,
buttonText: viewText,
type: 'saml-fed',
},
{
title: t('bui-wku-idp-configuration'),
description: t('bui-wku-idp-config-desc'),
title: t('bui-wku-saml-idp-configuration'),
description: t('bui-wku-saml-idp-config-desc'),
href: `${baseUrl}/.well-known/idp-configuration`,
buttonText: viewText,
type: 'saml-fed',
},
{
title: t('bui-wku-oidc-federation'),
description: t('bui-wku-oidc-federation-desc'),
href: `${baseUrl}/.well-known/openid-configuration`,
buttonText: viewText,
type: 'saml-fed',
},
];
return (

View File

@ -55,10 +55,10 @@
"webhook_secret": "Webhook secret",
"webhook_url": "Webhook URL",
"download": "Download",
"saml_federation_new_success": "SAML Federation app created successfully.",
"saml_federation_new_success": "Identity Federation app created successfully.",
"entity_id": "Entity ID / Audience URI / Audience Restriction",
"saml_federation_update_success": "SAML Federation app updated successfully.",
"saml_federation_delete_success": "SAML federation app deleted successfully",
"saml_federation_update_success": "Identity Federation app updated successfully.",
"saml_federation_delete_success": "Identity federation app deleted successfully",
"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",
@ -68,7 +68,7 @@
"directory_created_successfully": "Directory created successfully",
"directory_updated_successfully": "Directory updated successfully",
"dashboard": "Dashboard",
"saml_federation": "SAML Federation",
"saml_federation": "Identity Federation",
"sso_tracer": "SSO Tracer",
"settings": "Settings",
"admin_portal_sso": "SSO for Admin Portal",
@ -194,12 +194,19 @@
"bui-wku-oidc-config-desc": "URIs that your customers will need to set up the OIDC app on the Identity Provider.",
"bui-wku-oidc-discovery": "OpenID Connect Discovery",
"bui-wku-oidc-discovery-desc": "Our OpenID well known URI which your customers will need if they are authenticating via OAuth 2.0 or Open ID Connect.",
"bui-wku-idp-metadata": "IdP Metadata",
"bui-wku-idp-metadata-desc": "The metadata file that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"bui-wku-idp-configuration": "IdP Configuration",
"bui-wku-idp-config-desc": "The configuration setup guide that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"bui-wku-saml-idp-metadata": "SAML IdP Metadata",
"bui-wku-saml-idp-metadata-desc": "The metadata file that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"bui-wku-saml-idp-configuration": "SAML Federation IdP Configuration",
"bui-wku-saml-idp-config-desc": "The configuration setup guide that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"bui-wku-oidc-federation": "OIDC Federation",
"bui-wku-oidc-federation-desc": "Our OIDC Federation well known URI which you will need if are configuring Identity Federation via OpenID Connect.",
"bui-wku-view": "View",
"bui-wku-download": "Download",
"bui-fs-client-id": "Client ID",
"bui-fs-client-secret": "Client Secret",
"bui-fs-saml": "SAML",
"bui-fs-oidc": "OIDC",
"bui-fs-select-app-type": "Select App Type",
"bui-fs-generate-sp-entity-id": "Generate Entity ID",
"bui-fs-entity-id-instruction": "Use the button to create a distinctive identifier if your service provider does not provide a unique Entity ID and set that in your provider's configuration. If your service provider does provide a unique ID, you can use that instead.",
"bui-fs-entity-id-change-restriction": "You can't change this value once the app is created.",
@ -208,15 +215,16 @@
"bui-fs-tenants-mapping-desc": "Provide a custom mapping for SAML federation apps that lets you associate multiple tenants to it. Enter the tenant name and press ENTER or TAB.",
"bui-fs-attribute-mappings": "Attribute Mappings",
"bui-fs-attribute-mappings-desc": "Map the attributes from your IdP to your SP (if needed)",
"bui-fs-create-app": "Create SAML Federation App",
"bui-fs-create-app": "Create Identity Federation App",
"bui-fs-create-app-btn": "Create App",
"bui-fs-edit-app": "Edit SAML Federation App",
"bui-fs-edit-app": "Edit Identity Federation App",
"bui-fs-sp-attribute": "SP Attribute",
"bui-fs-idp-attribute": "IdP Attribute",
"bui-fs-add-mapping": "Add Mapping",
"bui-fs-add-another": "Add another",
"bui-fs-no-apps": "No SAML Federation Apps found.",
"bui-fs-no-apps-desc": "Create a new SAML Federation App to configure SAML Federation.",
"bui-fs-add": "Add",
"bui-fs-no-apps": "No Identity Federation Apps found.",
"bui-fs-no-apps-desc": "Create a new App to configure Identity Federation.",
"bui-fs-acs-url": "ACS URL",
"bui-fs-entity-id": "Entity ID / Audience URI / Audience Restriction",
"bui-fs-branding-title": "Customize Look and Feel",
@ -228,11 +236,12 @@
"bui-fs-primary-color": "Primary Color",
"bui-fs-primary-color-desc": "Primary color will be applied to buttons, links, and other elements.",
"bui-fs-entity-id-edit-desc": "You can't change this value. Delete and create a new app if you need to change it.",
"bui-fs-delete-app-title": "Delete this SAML Federation app",
"bui-fs-delete-app-desc": "This action cannot be undone. This will permanently delete the SAML Federation app.",
"bui-fs-apps": "SAML Federation Apps",
"bui-fs-delete-app-title": "Delete this Identity Federation app",
"bui-fs-delete-app-desc": "This action cannot be undone. This will permanently delete the Identity Federation app.",
"bui-fs-apps": "Identity Federation Apps",
"bui-fs-new-app": "New App",
"bui-fs-idp-config": "IdP Configuration",
"bui-fs-saml-config": "SAML Configuration",
"bui-fs-oidc-config": "OIDC Configuration",
"bui-fs-saml-attributes": "SAML Attributes",
"bui-fs-oidc-attributes": "OIDC Attributes",
"bui-dsync-name": "Name",
@ -313,6 +322,7 @@
"bui-sl-expiry-days": "Expiry in days",
"bui-sl-default-redirect-url": "Default redirect URL",
"bui-sl-allowed-redirect-urls": "Allowed redirect URLs (newline separated)",
"bui-sl-allowed-redirect-urls-new": "Allowed redirect URLs",
"bui-sl-sso-desc": "Create a unique Setup Link to share with your customers so they can set Enterprise SSO connection with your app.",
"bui-sl-dsync-desc": "Create a unique Setup Link to share with your customers so they can set Directory Sync connection with your app.",
"bui-sl-dsync-name-placeholder": "Acme Directory",

44
npm/package-lock.json generated
View File

@ -3176,8 +3176,7 @@
},
"node_modules/@types/node": {
"version": "20.11.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
"integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
@ -4809,6 +4808,18 @@
"node": ">=14"
}
},
"node_modules/gcp-metadata": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz",
"integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==",
"dependencies": {
"gaxios": "^6.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@ -4930,18 +4941,6 @@
"node": ">=14"
}
},
"node_modules/google-auth-library/node_modules/gcp-metadata": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz",
"integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==",
"dependencies": {
"gaxios": "^6.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis-common": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.0.1.tgz",
@ -6327,8 +6326,7 @@
},
"node_modules/mongodb": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.4.0.tgz",
"integrity": "sha512-MdFHsyb1a/Ee0H3NmzWTSLqchacDV/APF0H6BNQvraWrOiIocys2EmTFZPgHxWhcfO94c1F34I9MACU7x0hHKA==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.0",
"bson": "^6.4.0",
@ -7734,11 +7732,11 @@
}
},
"node_modules/side-channel": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz",
"integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
"integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==",
"dependencies": {
"call-bind": "^1.0.6",
"call-bind": "^1.0.7",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.4",
"object-inspect": "^1.13.1"
@ -8882,9 +8880,9 @@
}
},
"node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.23.tgz",
"integrity": "sha512-9/4foRoUKp8s96tSkh8DlAAc5A0Ty8vLXld+l9gjKKY6ckwI8G15f0hskGmuLZu78ZlGa1vtsfOa+lnB4vG6Jg==",
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.24.tgz",
"integrity": "sha512-+VaWXDa6+l6MhflBvVXjIEAzb59nQ2JUK3bwRp2zRpPtU+8TFRy9Gg/5oIcNlkEL5PGlBFGfemUVvIgLnTzq7Q==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",

View File

@ -5,6 +5,7 @@ import { deflateRaw } from 'zlib';
import saml from '@boxyhq/saml20';
import { TokenSet, errors, generators } from 'openid-client';
import { SAMLProfile } from '@boxyhq/saml20/dist/typings';
import { clientIDFederatedPrefix, clientIDOIDCPrefix } from './utils';
import type {
IOAuthController,
@ -20,6 +21,7 @@ import type {
SSOTracerInstance,
OAuthErrorHandlerParams,
OIDCAuthzResponsePayload,
SAMLFederationApp,
} from '../typings';
import {
relayStatePrefix,
@ -43,6 +45,7 @@ import { getDefaultCertificate } from '../saml/x509';
import { SSOHandler } from './sso-handler';
import { ValidateOption, extractSAMLResponseAttributes } from '../saml/lib';
import { oidcIssuerInstance } from './oauth/oidc-issuer';
import { App } from '../ee/federated-saml/app';
const deflateRawAsync = promisify(deflateRaw);
@ -54,14 +57,16 @@ export class OAuthController implements IOAuthController {
private ssoTracer: SSOTracerInstance;
private opts: JacksonOption;
private ssoHandler: SSOHandler;
private samlFedApp: App;
constructor({ connectionStore, sessionStore, codeStore, tokenStore, ssoTracer, opts }) {
constructor({ connectionStore, sessionStore, codeStore, tokenStore, ssoTracer, opts, samlFedApp }) {
this.connectionStore = connectionStore;
this.sessionStore = sessionStore;
this.codeStore = codeStore;
this.tokenStore = tokenStore;
this.ssoTracer = ssoTracer;
this.opts = opts;
this.samlFedApp = samlFedApp;
this.ssoHandler = new SSOHandler({
connection: connectionStore,
@ -90,6 +95,7 @@ export class OAuthController implements IOAuthController {
let requestedScopes: string[] | undefined;
let requestedOIDCFlow: boolean | undefined;
let connection: SAMLSSORecord | OIDCSSORecord | undefined;
let fedApp: SAMLFederationApp | undefined;
try {
const tenant = 'tenant' in body ? body.tenant : undefined;
@ -167,10 +173,40 @@ export class OAuthController implements IOAuthController {
connection = response.connection;
}
} else {
connection = await this.connectionStore.get(client_id);
if (connection) {
requestedTenant = connection.tenant;
requestedProduct = connection.product;
// client_id is not encoded, so we look for the connection using the client_id
// First we check if it's a federated connection
if (client_id.startsWith(`${clientIDFederatedPrefix}${clientIDOIDCPrefix}`)) {
fedApp = await this.samlFedApp.get({
id: client_id.replace(clientIDFederatedPrefix, ''),
});
const response = await this.ssoHandler.resolveConnection({
tenant: fedApp.tenant,
product: fedApp.product,
idp_hint,
authFlow: 'oauth',
originalParams: { ...body },
tenants: fedApp.tenants,
samlFedAppId: fedApp.id,
fedType: fedApp.type,
});
if ('redirectUrl' in response) {
return {
redirect_url: response.redirectUrl,
};
}
if ('connection' in response) {
connection = response.connection;
}
} else {
// If it's not a federated connection, we look for the connection using the client_id
connection = await this.connectionStore.get(client_id);
if (connection) {
requestedTenant = connection.tenant;
requestedProduct = connection.product;
}
}
}
} else {
@ -182,7 +218,13 @@ export class OAuthController implements IOAuthController {
}
if (!allowed.redirect(redirect_uri, connection.redirectUrl as string[])) {
throw new JacksonError('Redirect URL is not allowed.', 403);
if (fedApp) {
if (!allowed.redirect(redirect_uri, fedApp.redirectUrl as string[])) {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
} else {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
}
} catch (err: unknown) {
const error_description = getErrorMessage(err);
@ -399,6 +441,10 @@ export class OAuthController implements IOAuthController {
}
if (idp_hint) {
requested.idp_hint = idp_hint;
} else {
if (fedApp) {
requested.idp_hint = connection.clientID;
}
}
if (requestedOIDCFlow) {
requested.oidc = true;
@ -417,6 +463,7 @@ export class OAuthController implements IOAuthController {
code_challenge,
code_challenge_method,
requested,
oidcFederated: fedApp ? { redirectUrl: fedApp.redirectUrl, id: fedApp.id } : undefined,
};
await this.sessionStore.put(
sessionId,
@ -495,6 +542,7 @@ export class OAuthController implements IOAuthController {
let issuer: string | undefined;
let isIdPFlow: boolean | undefined;
let isSAMLFederated: boolean | undefined;
let isOIDCFederated: boolean | undefined;
let validateOpts: ValidateOption;
let redirect_uri: string | undefined;
const { SAMLResponse, idp_hint, RelayState = '' } = body;
@ -536,6 +584,7 @@ export class OAuthController implements IOAuthController {
}
isSAMLFederated = session && 'samlFederated' in session;
isOIDCFederated = session && 'oidcFederated' in session;
const isSPFlow = !isIdPFlow && !isSAMLFederated;
// IdP initiated SSO flow
@ -564,10 +613,11 @@ export class OAuthController implements IOAuthController {
// SP initiated SSO flow
// Resolve if there are multiple matches for SP login
if (isSPFlow || isSAMLFederated) {
if (isSPFlow || isSAMLFederated || isOIDCFederated) {
connection = connections.filter((c) => {
return (
c.clientID === session.requested.client_id ||
c.clientID === session.requested.idp_hint ||
(c.tenant === session.requested.tenant && c.product === session.requested.product)
);
})[0];
@ -582,7 +632,13 @@ export class OAuthController implements IOAuthController {
session.redirect_uri &&
!allowed.redirect(session.redirect_uri, connection.redirectUrl as string[])
) {
throw new JacksonError('Redirect URL is not allowed.', 403);
if (isOIDCFederated) {
if (!allowed.redirect(session.redirect_uri, session.oidcFederated?.redirectUrl as string[])) {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
} else {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
}
const { privateKey } = await getDefaultCertificate();
@ -696,6 +752,7 @@ export class OAuthController implements IOAuthController {
let oidcConnection: OIDCSSORecord | undefined;
let session: any;
let isSAMLFederated: boolean | undefined;
let isOIDCFederated: boolean | undefined;
let redirect_uri: string | undefined;
let profile;
@ -714,6 +771,7 @@ export class OAuthController implements IOAuthController {
}
isSAMLFederated = session && 'samlFederated' in session;
isOIDCFederated = session && 'oidcFederated' in session;
oidcConnection = await this.connectionStore.get(session.id);
@ -728,7 +786,13 @@ export class OAuthController implements IOAuthController {
}
if (redirect_uri && !allowed.redirect(redirect_uri, oidcConnection.redirectUrl as string[])) {
throw new JacksonError('Redirect URL is not allowed.', 403);
if (isOIDCFederated) {
if (!allowed.redirect(redirect_uri, session.oidcFederated?.redirectUrl as string[])) {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
} else {
throw new JacksonError('Redirect URL is not allowed.', 403);
}
}
}
} catch (err) {
@ -744,6 +808,7 @@ export class OAuthController implements IOAuthController {
redirectUri: redirect_uri,
relayState: RelayState,
isSAMLFederated: !!isSAMLFederated,
isOIDCFederated: !!isOIDCFederated,
requestedOIDCFlow: !!session?.requested?.oidc,
},
});
@ -803,6 +868,7 @@ export class OAuthController implements IOAuthController {
redirectUri: redirect_uri,
relayState: RelayState,
isSAMLFederated: !!isSAMLFederated,
isOIDCFederated: !!isOIDCFederated,
acsUrl: session.requested.acsUrl,
entityId: session.requested.entityId,
requestedOIDCFlow: !!session.requested.oidc,

View File

@ -11,9 +11,22 @@ export const redirect = (redirectUrl: string, redirectUrls: string[]): boolean =
for (const idx in redirectUrls) {
const rUrl: URL = new URL(redirectUrls[idx]);
let hostname = url.hostname;
let hostNameAllowed = rUrl.hostname;
// allow subdomain globbing *.example.com only
try {
if (rUrl.hostname.startsWith('*.')) {
hostNameAllowed = rUrl.hostname.slice(2);
hostname = hostname.slice(hostname.indexOf('.') + 1);
}
} catch (e) {
// no-op
}
// TODO: Check pathname, for now pathname is ignored
if (rUrl.protocol === url.protocol && rUrl.hostname === url.hostname && rUrl.port === url.port) {
if (rUrl.protocol === url.protocol && hostNameAllowed === hostname && rUrl.port === url.port) {
return true;
}
}

View File

@ -46,6 +46,7 @@ export class SSOHandler {
entityId?: string;
idp_hint?: string;
samlFedAppId?: string;
fedType?: string;
tenants?: string[]; // Only used for SAML IdP initiated flow
}): Promise<
| {
@ -67,9 +68,22 @@ export class SSOHandler {
entityId,
tenants,
samlFedAppId = '',
fedType = '',
} = params;
let connections: (SAMLSSORecord | OIDCSSORecord)[] | null = null;
const noSSOConnectionErrMessage = 'No SSO connection found.';
// If an IdP is specified, find the connection for that IdP
if (idp_hint) {
const connection = await this.connection.get(idp_hint);
if (!connection) {
throw new JacksonError(noSSOConnectionErrMessage, 404);
}
return { connection };
}
// Find SAML connections for the app
if (tenants && tenants.length > 0 && product) {
@ -99,36 +113,27 @@ export class SSOHandler {
connections = result.data;
}
const noSSOConnectionErrMessage = 'No SSO connection found.';
if (!connections || connections.length === 0) {
throw new JacksonError(noSSOConnectionErrMessage, 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(noSSOConnectionErrMessage, 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,
if (['oauth', 'saml'].includes(authFlow)) {
const qps = {
authFlow: 'sp-initiated',
samlFedAppId,
fedType,
...originalParams,
});
};
if (tenant && product && fedType !== 'oidc') {
qps['tenant'] = tenant;
qps['product'] = product;
}
const params = new URLSearchParams(qps);
return { redirectUrl: `${url}?${params}` };
}

View File

@ -51,6 +51,8 @@ export const storeNamespacePrefix = {
};
export const relayStatePrefix = 'boxyhq_jackson_';
export const clientIDFederatedPrefix = 'fed_';
export const clientIDOIDCPrefix = 'oidc_';
export const validateAbsoluteUrl = (url, message) => {
try {
@ -302,6 +304,10 @@ export const appID = (tenant: string, product: string) => {
return dbutils.keyDigest(dbutils.keyFromParts(tenant, product));
};
export const fedAppID = (tenant: string, product: string, type?: string) => {
return (type === 'oidc' ? clientIDOIDCPrefix : '') + appID(tenant, product);
};
// List of well known providers
const wellKnownProviders = {
'okta.com': 'Okta',

View File

@ -1,3 +1,4 @@
import crypto from 'crypto';
import type {
Storable,
JacksonOption,
@ -6,7 +7,7 @@ import type {
GetByProductParams,
AppRequestParams,
} from '../../typings';
import { appID } from '../../controller/utils';
import { fedAppID, clientIDFederatedPrefix } from '../../controller/utils';
import { createMetadataXML } from '../../saml/lib';
import { JacksonError } from '../../controller/error';
import { getDefaultCertificate } from '../../saml/x509';
@ -15,7 +16,7 @@ import { throwIfInvalidLicense } from '../common/checkLicense';
type NewAppParams = Pick<
SAMLFederationApp,
'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings'
'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings' | 'type' | 'redirectUrl'
> & {
logoUrl?: string;
faviconUrl?: string;
@ -70,7 +71,7 @@ export class App {
* @swagger
* /api/v1/federated-saml:
* post:
* summary: Create a SAML Federation app
* summary: Create an Identity Federation app
* parameters:
* - name: name
* description: Name
@ -113,7 +114,7 @@ export class App {
* required: false
* type: string
* - name: tenants
* description: Mapping of tenants whose connections will be grouped under this SAML Federation app
* description: Mapping of tenants whose connections will be grouped under this Identity Federation app
* in: formData
* required: false
* type: array
@ -122,7 +123,17 @@ export class App {
* in: formData
* required: false
* type: array
* tags: [SAML Federation]
* - name: type
* description: If creating an OIDC app, this should be set to 'oidc' otherwise it defaults to 'saml'
* in: formData
* required: false
* type: array
* - name: redirectUrl
* description: If creating an OIDC app, provide the redirect URL
* in: formData
* required: false
* type: array
* tags: [Identity Federation]
* produces:
* - application/json
* consumes:
@ -138,6 +149,8 @@ export class App {
*/
public async create({
name,
type,
redirectUrl,
tenant,
product,
acsUrl,
@ -150,16 +163,25 @@ export class App {
}: NewAppParams) {
await throwIfInvalidLicense(this.opts.boxyhqLicenseKey);
if (!tenant || !product || !acsUrl || !entityId || !name) {
throw new JacksonError(
'Missing required parameters. Required parameters are: name, tenant, product, acsUrl, entityId',
400
);
if (type === 'oidc') {
if (!tenant || !product || !redirectUrl || !name) {
throw new JacksonError(
'Missing required parameters. Required parameters are: name, tenant, product, redirectUrl',
400
);
}
} else {
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 id = fedAppID(tenant, product, type);
// Check if an app already exists for the same tenant and product
const foundApp = await this.store.get(id);
@ -197,6 +219,8 @@ export class App {
const app: SAMLFederationApp = {
id,
type,
redirectUrl,
name,
tenant,
product,
@ -209,18 +233,26 @@ export class App {
mappings: mappings || [],
};
await this.store.put(
id,
app,
{
name: IndexNames.EntityID,
value: entityId,
},
if (type === 'oidc') {
app.clientID = `${clientIDFederatedPrefix}${id}`;
app.clientSecret = crypto.randomBytes(24).toString('hex');
}
const indexes = [
{
name: IndexNames.Product,
value: product,
}
);
},
];
if (type !== 'oidc') {
indexes.push({
name: IndexNames.EntityID,
value: entityId,
});
}
await this.store.put(id, app, ...indexes);
return app;
}
@ -229,7 +261,7 @@ export class App {
* @swagger
* /api/v1/federated-saml:
* get:
* summary: Get a SAML Federation app
* summary: Get an Identity Federation app
* parameters:
* - name: id
* description: App ID
@ -247,7 +279,7 @@ export class App {
* required: false
* type: string
* tags:
* - SAML Federation
* - Identity Federation
* produces:
* - application/json
* responses:
@ -263,17 +295,17 @@ export class App {
const app = await this.store.get(params.id);
if (!app) {
throw new JacksonError('SAML Federation app not found', 404);
throw new JacksonError('Identity Federation app not found', 404);
}
return app as SAMLFederationApp;
}
if ('tenant' in params && 'product' in params) {
const app = await this.store.get(appID(params.tenant, params.product));
const app = await this.store.get(fedAppID(params.tenant, params.product, params.type));
if (!app) {
throw new JacksonError('SAML Federation app not found', 404);
throw new JacksonError('Identity Federation app not found', 404);
}
return app as SAMLFederationApp;
@ -286,7 +318,7 @@ export class App {
* @swagger
* /api/v1/federated-saml/product:
* get:
* summary: Get SAML Federation apps by product
* summary: Get Identity Federation apps by product
* parameters:
* - name: product
* description: Product
@ -294,7 +326,7 @@ export class App {
* required: true
* type: string
* tags:
* - SAML Federation
* - Identity Federation
* produces:
* - application/json
* responses:
@ -341,7 +373,7 @@ export class App {
).data;
if (!apps || apps.length === 0) {
throw new JacksonError('SAML Federation app not found', 404);
throw new JacksonError('Identity Federation app not found', 404);
}
return apps[0];
@ -351,7 +383,7 @@ export class App {
* @swagger
* /api/v1/federated-saml:
* patch:
* summary: Update a SAML Federation app
* summary: Update an Identity Federation app
* parameters:
* - name: id
* description: App ID
@ -394,7 +426,7 @@ export class App {
* required: false
* type: string
* - name: tenants
* description: Mapping of tenants whose connections will be grouped under this SAML Federation app
* description: Mapping of tenants whose connections will be grouped under this Identity Federation app
* in: formData
* required: false
* type: array
@ -404,7 +436,7 @@ export class App {
* required: false
* type: array
* tags:
* - SAML Federation
* - Identity Federation
* produces:
* - application/json
* consumes:
@ -419,7 +451,7 @@ export class App {
public async update(params: Partial<SAMLFederationApp>) {
await throwIfInvalidLicense(this.opts.boxyhqLicenseKey);
const { id, tenant, product } = params;
const { id, tenant, product, type } = params;
if (!id && (!tenant || !product)) {
throw new JacksonError('Provide either the `id` or `tenant` and `product` to update the app', 400);
@ -430,11 +462,11 @@ export class App {
if (id) {
app = await this.get({ id });
} else if (tenant && product) {
app = await this.get({ tenant, product });
app = await this.get({ tenant, product, type });
}
if (!app) {
throw new JacksonError('SAML Federation app not found', 404);
throw new JacksonError('Identity Federation app not found', 404);
}
const toUpdate: Partial<SAMLFederationApp> = {};
@ -445,6 +477,10 @@ export class App {
toUpdate['name'] = params.name;
}
if ('redirectUrl' in params) {
toUpdate['redirectUrl'] = params.redirectUrl;
}
if ('acsUrl' in params) {
toUpdate['acsUrl'] = params.acsUrl;
}
@ -516,7 +552,7 @@ export class App {
* @swagger
* /api/v1/federated-saml:
* delete:
* summary: Delete a SAML Federation app
* summary: Delete an Identity Federation app
* parameters:
* - name: id
* description: App ID
@ -534,7 +570,7 @@ export class App {
* required: false
* type: string
* tags:
* - SAML Federation
* - Identity Federation
* produces:
* - application/json
* responses:
@ -551,7 +587,7 @@ export class App {
}
if ('tenant' in params && 'product' in params) {
const id = appID(params.tenant, params.product);
const id = fedAppID(params.tenant, params.product, params.type);
return await this.store.delete(id);
}

View File

@ -3,7 +3,7 @@ import { App } from './app';
import type { JacksonOption, SSOTracerInstance } from '../../typings';
import { SSOHandler } from '../../controller/sso-handler';
// This is the main entry point for the SAML Federation module
// This is the main entry point for the Identity Federation module
const SAMLFederation = async ({
db,
opts,

View File

@ -9,6 +9,10 @@ export type AttributeMapping = {
export type SAMLFederationApp = {
id: string;
type?: string;
clientID?: string;
clientSecret?: string;
redirectUrl?: string[] | string;
name: string;
tenant: string;
product: string;
@ -37,4 +41,5 @@ export type AppRequestParams =
| {
tenant: string;
product: string;
type?: string;
};

View File

@ -110,6 +110,10 @@ export const controllers = async (
// Create default certificate if it doesn't exist.
await x509.init(certificateStore, opts);
// Enterprise Features
const samlFederatedController = await initFederatedSAML({ db, opts, ssoTracer });
const brandingController = new BrandingController({ store: settingsStore, opts });
const oauthController = new OAuthController({
connectionStore,
sessionStore,
@ -117,6 +121,7 @@ export const controllers = async (
tokenStore,
ssoTracer,
opts,
samlFedApp: samlFederatedController.app,
});
const logoutController = new LogoutController({
@ -129,10 +134,6 @@ export const controllers = async (
const spConfig = new SPSSOConfig(opts);
const directorySyncController = await initDirectorySync({ db, opts, eventController });
// Enterprise Features
const samlFederatedController = await initFederatedSAML({ db, opts, ssoTracer });
const brandingController = new BrandingController({ store: settingsStore, opts });
// write pre-loaded connections if present
const preLoadedConnection = opts.preLoadedConnection || opts.preLoadedConfig;
if (preLoadedConnection && preLoadedConnection.length > 0) {

View File

@ -22,7 +22,7 @@ tap.test('Federated SAML App', async () => {
acsUrl: serviceProvider.acsUrl,
});
tap.test('Should be able to create a new SAML Federation app', async (t) => {
tap.test('Should be able to create a new Identity Federation app', async (t) => {
t.ok(app);
t.match(app.id, appId);
t.match(app.tenant, tenant);
@ -31,14 +31,14 @@ tap.test('Federated SAML App', async () => {
t.match(app.acsUrl, serviceProvider.acsUrl);
});
tap.test('Should be able to get the SAML Federation app by id', async (t) => {
tap.test('Should be able to get the Identity Federation app by id', async (t) => {
const response = await samlFederatedController.app.get({ id: app.id });
t.ok(response);
t.match(response.id, app.id);
});
tap.test('Should be able to get the SAML Federation app by entity id', async (t) => {
tap.test('Should be able to get the Identity Federation app by entity id', async (t) => {
const response = await samlFederatedController.app.getByEntityId(serviceProvider.entityId);
t.ok(response);
@ -62,7 +62,7 @@ tap.test('Federated SAML App', async () => {
t.match(apps.data[0], app);
});
tap.test('Should be able to update the SAML Federation app', async (t) => {
tap.test('Should be able to update the Identity Federation app', async (t) => {
// Update by id
const response = await samlFederatedController.app.update({
id: app.id,
@ -135,7 +135,7 @@ tap.test('Federated SAML App', async () => {
t.match(updatedApp.primaryColor, null);
});
tap.test('Should be able to get all SAML Federation apps', async (t) => {
tap.test('Should be able to get all Identity Federation apps', async (t) => {
const response = await samlFederatedController.app.getAll({});
t.ok(response);

View File

@ -1,5 +1,6 @@
import type { NextPage } from 'next';
import { DocumentMagnifyingGlassIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import DocumentMagnifyingGlassIcon from '@heroicons/react/24/outline/DocumentMagnifyingGlassIcon';
import WrenchScrewdriverIcon from '@heroicons/react/24/outline/WrenchScrewdriverIcon';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import EmptyState from '@components/EmptyState';
import { useProjects } from '@lib/ui/retraced';

View File

@ -182,13 +182,14 @@ export const getServerSideProps = async ({ query, locale, req }) => {
const paramsToRelay = { ...query } as { [key: string]: string };
const { authFlow, entityId, tenant, product, idp_hint, samlFedAppId } = query as {
const { authFlow, entityId, tenant, product, idp_hint, samlFedAppId, fedType } = query as {
authFlow: 'sp-initiated' | 'idp-initiated';
tenant?: string;
product?: string;
idp_hint?: string;
entityId?: string;
samlFedAppId?: string;
fedType?: string;
};
if (!['sp-initiated', 'idp-initiated'].includes(authFlow)) {
@ -200,7 +201,10 @@ export const getServerSideProps = async ({ query, locale, req }) => {
// The user has selected an IdP to continue with
if (idp_hint) {
const params = new URLSearchParams(paramsToRelay);
const destination = samlFedAppId ? `/api/federated-saml/sso?${params}` : `/api/oauth/authorize?${params}`;
const destination =
samlFedAppId && fedType !== 'oidc'
? `/api/federated-saml/sso?${params}`
: `/api/oauth/authorize?${params}`;
return {
redirect: {

View File

@ -8,7 +8,7 @@ import { MDXRemote } from 'next-mdx-remote';
import { serialize } from 'next-mdx-remote/serialize';
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
import type { MDXRemoteSerializeResult } from 'next-mdx-remote';
import { ArrowsRightLeftIcon } from '@heroicons/react/24/outline';
import ArrowsRightLeftIcon from '@heroicons/react/24/outline/ArrowsRightLeftIcon';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useTranslation } from 'next-i18next';

View File

@ -1142,7 +1142,7 @@
},
"/api/v1/federated-saml": {
"post": {
"summary": "Create a SAML Federation app",
"summary": "Create an Identity Federation app",
"parameters": [
{
"name": "name",
@ -1202,7 +1202,7 @@
},
{
"name": "tenants",
"description": "Mapping of tenants whose connections will be grouped under this SAML Federation app",
"description": "Mapping of tenants whose connections will be grouped under this Identity Federation app",
"in": "formData",
"required": false,
"type": "array"
@ -1213,10 +1213,24 @@
"in": "formData",
"required": false,
"type": "array"
},
{
"name": "type",
"description": "If creating an OIDC app, this should be set to 'oidc' otherwise it defaults to 'saml'",
"in": "formData",
"required": false,
"type": "array"
},
{
"name": "redirectUrl",
"description": "If creating an OIDC app, provide the redirect URL",
"in": "formData",
"required": false,
"type": "array"
}
],
"tags": [
"SAML Federation"
"Identity Federation"
],
"produces": [
"application/json"
@ -1238,7 +1252,7 @@
}
},
"get": {
"summary": "Get a SAML Federation app",
"summary": "Get an Identity Federation app",
"parameters": [
{
"name": "id",
@ -1263,7 +1277,7 @@
}
],
"tags": [
"SAML Federation"
"Identity Federation"
],
"produces": [
"application/json"
@ -1278,7 +1292,7 @@
}
},
"patch": {
"summary": "Update a SAML Federation app",
"summary": "Update an Identity Federation app",
"parameters": [
{
"name": "id",
@ -1338,7 +1352,7 @@
},
{
"name": "tenants",
"description": "Mapping of tenants whose connections will be grouped under this SAML Federation app",
"description": "Mapping of tenants whose connections will be grouped under this Identity Federation app",
"in": "formData",
"required": false,
"type": "array"
@ -1352,7 +1366,7 @@
}
],
"tags": [
"SAML Federation"
"Identity Federation"
],
"produces": [
"application/json"
@ -1371,7 +1385,7 @@
}
},
"delete": {
"summary": "Delete a SAML Federation app",
"summary": "Delete an Identity Federation app",
"parameters": [
{
"name": "id",
@ -1396,7 +1410,7 @@
}
],
"tags": [
"SAML Federation"
"Identity Federation"
],
"produces": [
"application/json"
@ -1413,7 +1427,7 @@
},
"/api/v1/federated-saml/product": {
"get": {
"summary": "Get SAML Federation apps by product",
"summary": "Get Identity Federation apps by product",
"parameters": [
{
"name": "product",
@ -1424,7 +1438,7 @@
}
],
"tags": [
"SAML Federation"
"Identity Federation"
],
"produces": [
"application/json"