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

View File

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

View File

@ -2,7 +2,8 @@ import { ButtonLink } from '@components/ButtonLink';
import { Dispatch, FormEvent, SetStateAction, useMemo, useState } from 'react'; import { Dispatch, FormEvent, SetStateAction, useMemo, useState } from 'react';
import { EditViewOnlyFields, getCommonFields } from './fieldCatalog'; import { EditViewOnlyFields, getCommonFields } from './fieldCatalog';
import { CopyToClipboardButton } from '@components/ClipboardButton'; 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 { IconButton } from '@components/IconButton';
import { useTranslation } from 'next-i18next'; 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 handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson(); 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 handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson(); const { samlFederatedController } = await jackson();
@ -52,7 +52,7 @@ const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(200).json({ data: updatedApp }); 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 handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson(); 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 handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson(); const { samlFederatedController } = await jackson();
@ -31,7 +31,7 @@ const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(201).json({ data: app }); return res.status(201).json({ data: app });
}; };
// Get SAML Federation apps // Get Identity Federation apps
const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson(); const { samlFederatedController } = await jackson();

View File

@ -11,7 +11,11 @@ const AppsList = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
<FederatedSAMLApps <FederatedSAMLApps
urls={{ getApps: '/api/admin/federated-saml' }} urls={{ getApps: '/api/admin/federated-saml' }}
onEdit={(app) => router.push(`/admin/federated-saml/${app.id}/edit`)} 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 useSWR from 'swr';
import { useTranslation } from 'next-i18next'; 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 type { Group } from '../types';
import { fetcher, addQueryParamsToPath } from '../utils'; import { fetcher, addQueryParamsToPath } from '../utils';
import { DirectoryTab } from '../dsync'; import { DirectoryTab } from '../dsync';

View File

@ -1,6 +1,6 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { useTranslation } from 'next-i18next'; 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 type { User } from '../types';
import { addQueryParamsToPath, fetcher } from '../utils'; import { addQueryParamsToPath, fetcher } from '../utils';
import { DirectoryTab } from '../dsync'; import { DirectoryTab } from '../dsync';

View File

@ -1,7 +1,7 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'next-i18next'; 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 type { WebhookEventLog } from '../types';
import { fetcher, addQueryParamsToPath } from '../utils'; import { fetcher, addQueryParamsToPath } from '../utils';
import { DirectoryTab } from '../dsync'; import { DirectoryTab } from '../dsync';

View File

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

View File

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

View File

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

View File

@ -3,14 +3,15 @@ import TagsInput from 'react-tagsinput';
import { Card, Button } from 'react-daisyui'; import { Card, Button } from 'react-daisyui';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types'; 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 { defaultHeaders } from '../utils';
import { AttributesMapping } from './AttributesMapping'; import { AttributesMapping } from './AttributesMapping';
import { PageHeader } from '../shared'; import { PageHeader } from '../shared';
import { ItemList } from '../shared/ItemList';
type NewSAMLFederationApp = Pick< type NewSAMLFederationApp = Pick<
SAMLFederationApp, SAMLFederationApp,
'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings' 'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings' | 'type' | 'redirectUrl'
>; >;
export const NewFederatedSAMLApp = ({ export const NewFederatedSAMLApp = ({
@ -31,6 +32,7 @@ export const NewFederatedSAMLApp = ({
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const initialValues: NewSAMLFederationApp = { const initialValues: NewSAMLFederationApp = {
type: 'saml',
name: '', name: '',
tenant: '', tenant: '',
product: '', product: '',
@ -65,6 +67,9 @@ export const NewFederatedSAMLApp = ({
}, },
}); });
const connectionIsOIDC = formik.values.type === 'oidc';
const connectionIsSAML = !connectionIsOIDC;
const generateEntityId = () => { const generateEntityId = () => {
const id = crypto.randomUUID().replace(/-/g, ''); const id = crypto.randomUUID().replace(/-/g, '');
const entityId = `${samlAudience}/${id}`; const entityId = `${samlAudience}/${id}`;
@ -78,6 +83,38 @@ export const NewFederatedSAMLApp = ({
<PageHeader title={t('bui-fs-create-app')} /> <PageHeader title={t('bui-fs-create-app')} />
<form onSubmit={formik.handleSubmit} method='POST'> <form onSubmit={formik.handleSubmit} method='POST'>
<Card className='p-6 rounded space-y-3'> <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'> <label className='form-control w-full'>
<div className='label'> <div className='label'>
<span className='label-text'>{t('bui-shared-name')}</span> <span className='label-text'>{t('bui-shared-name')}</span>
@ -98,7 +135,7 @@ export const NewFederatedSAMLApp = ({
</div> </div>
<input <input
type='text' type='text'
placeholder='acme' placeholder='example.com'
className='input input-bordered w-full text-sm' className='input input-bordered w-full text-sm'
name='tenant' name='tenant'
value={formik.values.tenant} value={formik.values.tenant}
@ -122,49 +159,63 @@ export const NewFederatedSAMLApp = ({
/> />
</label> </label>
)} )}
<label className='form-control w-full'> {connectionIsSAML && (
<div className='label'> <label className='form-control w-full'>
<span className='label-text'>{t('bui-fs-acs-url')}</span> <div className='label'>
</div> <span className='label-text'>{t('bui-fs-acs-url')}</span>
<input </div>
type='url' <input
placeholder='https://your-sp.com/saml/acs' type='url'
className='input input-bordered w-full text-sm' placeholder='https://your-sp.com/saml/acs'
name='acsUrl' className='input input-bordered w-full text-sm'
value={formik.values.acsUrl} name='acsUrl'
onChange={formik.handleChange} value={formik.values.acsUrl}
required 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>
</label> </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='form-control w-full'>
<label className='label'> <label className='label'>
<span className='label-text'>{t('bui-fs-tenants')}</span> <span className='label-text'>{t('bui-fs-tenants')}</span>
@ -175,6 +226,7 @@ export const NewFederatedSAMLApp = ({
onlyUnique={true} onlyUnique={true}
inputProps={{ inputProps={{
placeholder: t('bui-fs-enter-tenant'), placeholder: t('bui-fs-enter-tenant'),
autocomplete: 'off',
}} }}
focusedClassName='input-focused' focusedClassName='input-focused'
addOnBlur={true} addOnBlur={true}
@ -183,20 +235,23 @@ export const NewFederatedSAMLApp = ({
<span className='label-text-alt'>{t('bui-fs-tenants-mapping-desc')}</span> <span className='label-text-alt'>{t('bui-fs-tenants-mapping-desc')}</span>
</label> </label>
</label> </label>
<label className='form-control w-full'> {connectionIsSAML && (
<div className='label'> <label className='form-control w-full'>
<span className='label-text'>{t('bui-fs-attribute-mappings')}</span> <div className='label'>
</div> <span className='label-text'>{t('bui-fs-attribute-mappings')}</span>
<div className='label'> </div>
<span className='label-text-alt'>{t('bui-fs-attribute-mappings-desc')}</span> <div className='label'>
</div> <span className='label-text-alt'>{t('bui-fs-attribute-mappings-desc')}</span>
</label> </div>
<AttributesMapping <AttributesMapping
mappings={formik.values.mappings || []} mappings={formik.values.mappings || []}
onAttributeMappingsChange={(mappings) => formik.setFieldValue('mappings', mappings)} onAttributeMappingsChange={(mappings) => formik.setFieldValue('mappings', mappings)}
/> />
</label>
)}
<div className='flex gap-2 justify-end pt-6'> <div className='flex gap-2 justify-end pt-6'>
<Button <Button
type='submit'
className='btn btn-primary btn-md' className='btn btn-primary btn-md'
loading={formik.isSubmitting} loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}> 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 = { export type SAMLFederationApp = {
id: string; id: string;
type?: string;
clientID?: string;
clientSecret?: string;
redirectUrl?: string[] | string;
name: string; name: string;
tenant: string; tenant: string;
product: string; product: string;

View File

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

View File

@ -55,10 +55,10 @@
"webhook_secret": "Webhook secret", "webhook_secret": "Webhook secret",
"webhook_url": "Webhook URL", "webhook_url": "Webhook URL",
"download": "Download", "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", "entity_id": "Entity ID / Audience URI / Audience Restriction",
"saml_federation_update_success": "SAML Federation app updated successfully.", "saml_federation_update_success": "Identity Federation app updated successfully.",
"saml_federation_delete_success": "SAML federation app deleted successfully", "saml_federation_delete_success": "Identity federation app deleted successfully",
"saml_federation_app_info": "SAML Federation App Information", "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", "saml_federation_app_info_details": "Choose from the following options to configure your SAML Federation on the service provider side",
"download_metadata": "Download Metadata", "download_metadata": "Download Metadata",
@ -68,7 +68,7 @@
"directory_created_successfully": "Directory created successfully", "directory_created_successfully": "Directory created successfully",
"directory_updated_successfully": "Directory updated successfully", "directory_updated_successfully": "Directory updated successfully",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"saml_federation": "SAML Federation", "saml_federation": "Identity Federation",
"sso_tracer": "SSO Tracer", "sso_tracer": "SSO Tracer",
"settings": "Settings", "settings": "Settings",
"admin_portal_sso": "SSO for Admin Portal", "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-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": "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-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-saml-idp-metadata": "SAML 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-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-idp-configuration": "IdP Configuration", "bui-wku-saml-idp-configuration": "SAML Federation 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-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-view": "View",
"bui-wku-download": "Download", "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-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-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.", "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-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": "Attribute Mappings",
"bui-fs-attribute-mappings-desc": "Map the attributes from your IdP to your SP (if needed)", "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-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-sp-attribute": "SP Attribute",
"bui-fs-idp-attribute": "IdP Attribute", "bui-fs-idp-attribute": "IdP Attribute",
"bui-fs-add-mapping": "Add Mapping", "bui-fs-add-mapping": "Add Mapping",
"bui-fs-add-another": "Add another", "bui-fs-add-another": "Add another",
"bui-fs-no-apps": "No SAML Federation Apps found.", "bui-fs-add": "Add",
"bui-fs-no-apps-desc": "Create a new SAML Federation App to configure SAML Federation.", "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-acs-url": "ACS URL",
"bui-fs-entity-id": "Entity ID / Audience URI / Audience Restriction", "bui-fs-entity-id": "Entity ID / Audience URI / Audience Restriction",
"bui-fs-branding-title": "Customize Look and Feel", "bui-fs-branding-title": "Customize Look and Feel",
@ -228,11 +236,12 @@
"bui-fs-primary-color": "Primary Color", "bui-fs-primary-color": "Primary Color",
"bui-fs-primary-color-desc": "Primary color will be applied to buttons, links, and other elements.", "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-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-title": "Delete this Identity Federation app",
"bui-fs-delete-app-desc": "This action cannot be undone. This will permanently delete the SAML Federation app.", "bui-fs-delete-app-desc": "This action cannot be undone. This will permanently delete the Identity Federation app.",
"bui-fs-apps": "SAML Federation Apps", "bui-fs-apps": "Identity Federation Apps",
"bui-fs-new-app": "New App", "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-saml-attributes": "SAML Attributes",
"bui-fs-oidc-attributes": "OIDC Attributes", "bui-fs-oidc-attributes": "OIDC Attributes",
"bui-dsync-name": "Name", "bui-dsync-name": "Name",
@ -313,6 +322,7 @@
"bui-sl-expiry-days": "Expiry in days", "bui-sl-expiry-days": "Expiry in days",
"bui-sl-default-redirect-url": "Default redirect URL", "bui-sl-default-redirect-url": "Default redirect URL",
"bui-sl-allowed-redirect-urls": "Allowed redirect URLs (newline separated)", "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-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-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", "bui-sl-dsync-name-placeholder": "Acme Directory",

44
npm/package-lock.json generated
View File

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

View File

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

View File

@ -11,9 +11,22 @@ export const redirect = (redirectUrl: string, redirectUrls: string[]): boolean =
for (const idx in redirectUrls) { for (const idx in redirectUrls) {
const rUrl: URL = new URL(redirectUrls[idx]); 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 // 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; return true;
} }
} }

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { App } from './app';
import type { JacksonOption, SSOTracerInstance } from '../../typings'; import type { JacksonOption, SSOTracerInstance } from '../../typings';
import { SSOHandler } from '../../controller/sso-handler'; 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 ({ const SAMLFederation = async ({
db, db,
opts, opts,

View File

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

View File

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

View File

@ -22,7 +22,7 @@ tap.test('Federated SAML App', async () => {
acsUrl: serviceProvider.acsUrl, 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.ok(app);
t.match(app.id, appId); t.match(app.id, appId);
t.match(app.tenant, tenant); t.match(app.tenant, tenant);
@ -31,14 +31,14 @@ tap.test('Federated SAML App', async () => {
t.match(app.acsUrl, serviceProvider.acsUrl); 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 }); const response = await samlFederatedController.app.get({ id: app.id });
t.ok(response); t.ok(response);
t.match(response.id, app.id); 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); const response = await samlFederatedController.app.getByEntityId(serviceProvider.entityId);
t.ok(response); t.ok(response);
@ -62,7 +62,7 @@ tap.test('Federated SAML App', async () => {
t.match(apps.data[0], app); 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 // Update by id
const response = await samlFederatedController.app.update({ const response = await samlFederatedController.app.update({
id: app.id, id: app.id,
@ -135,7 +135,7 @@ tap.test('Federated SAML App', async () => {
t.match(updatedApp.primaryColor, null); 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({}); const response = await samlFederatedController.app.getAll({});
t.ok(response); t.ok(response);

View File

@ -1,5 +1,6 @@
import type { NextPage } from 'next'; 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 { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import EmptyState from '@components/EmptyState'; import EmptyState from '@components/EmptyState';
import { useProjects } from '@lib/ui/retraced'; 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 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'; authFlow: 'sp-initiated' | 'idp-initiated';
tenant?: string; tenant?: string;
product?: string; product?: string;
idp_hint?: string; idp_hint?: string;
entityId?: string; entityId?: string;
samlFedAppId?: string; samlFedAppId?: string;
fedType?: string;
}; };
if (!['sp-initiated', 'idp-initiated'].includes(authFlow)) { 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 // The user has selected an IdP to continue with
if (idp_hint) { if (idp_hint) {
const params = new URLSearchParams(paramsToRelay); 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 { return {
redirect: { redirect: {

View File

@ -8,7 +8,7 @@ import { MDXRemote } from 'next-mdx-remote';
import { serialize } from 'next-mdx-remote/serialize'; import { serialize } from 'next-mdx-remote/serialize';
import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next'; import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'; 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 { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';

View File

@ -1142,7 +1142,7 @@
}, },
"/api/v1/federated-saml": { "/api/v1/federated-saml": {
"post": { "post": {
"summary": "Create a SAML Federation app", "summary": "Create an Identity Federation app",
"parameters": [ "parameters": [
{ {
"name": "name", "name": "name",
@ -1202,7 +1202,7 @@
}, },
{ {
"name": "tenants", "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", "in": "formData",
"required": false, "required": false,
"type": "array" "type": "array"
@ -1213,10 +1213,24 @@
"in": "formData", "in": "formData",
"required": false, "required": false,
"type": "array" "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": [ "tags": [
"SAML Federation" "Identity Federation"
], ],
"produces": [ "produces": [
"application/json" "application/json"
@ -1238,7 +1252,7 @@
} }
}, },
"get": { "get": {
"summary": "Get a SAML Federation app", "summary": "Get an Identity Federation app",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -1263,7 +1277,7 @@
} }
], ],
"tags": [ "tags": [
"SAML Federation" "Identity Federation"
], ],
"produces": [ "produces": [
"application/json" "application/json"
@ -1278,7 +1292,7 @@
} }
}, },
"patch": { "patch": {
"summary": "Update a SAML Federation app", "summary": "Update an Identity Federation app",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -1338,7 +1352,7 @@
}, },
{ {
"name": "tenants", "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", "in": "formData",
"required": false, "required": false,
"type": "array" "type": "array"
@ -1352,7 +1366,7 @@
} }
], ],
"tags": [ "tags": [
"SAML Federation" "Identity Federation"
], ],
"produces": [ "produces": [
"application/json" "application/json"
@ -1371,7 +1385,7 @@
} }
}, },
"delete": { "delete": {
"summary": "Delete a SAML Federation app", "summary": "Delete an Identity Federation app",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -1396,7 +1410,7 @@
} }
], ],
"tags": [ "tags": [
"SAML Federation" "Identity Federation"
], ],
"produces": [ "produces": [
"application/json" "application/json"
@ -1413,7 +1427,7 @@
}, },
"/api/v1/federated-saml/product": { "/api/v1/federated-saml/product": {
"get": { "get": {
"summary": "Get SAML Federation apps by product", "summary": "Get Identity Federation apps by product",
"parameters": [ "parameters": [
{ {
"name": "product", "name": "product",
@ -1424,7 +1438,7 @@
} }
], ],
"tags": [ "tags": [
"SAML Federation" "Identity Federation"
], ],
"produces": [ "produces": [
"application/json" "application/json"