Skip to content

Commit

Permalink
Support for OIDC Identity Providers (#385)
Browse files Browse the repository at this point in the history
* Support connection dynamic param in route

* Pass `connection`

* Fix tests

* Accept oidc params and validate the same

* Rename `connection` --> `strategy`

* Use saml for preLoadedConfig for now

* Rename `apiController` --> `apiConfigController`

* Flatten the params

* Validate passed config

* Backward compatibility for embed setup

* Impl for oidc config save

* index addition for oidc clientId

* Remove param, defaults to saml

* Validation will be done inside controller

* Zap secondary index on clientId, not required

* Rename `APIConfigController` --> `ConfigAPIController`

* Update swagger

* Fix name

* Fix name elsewhere

* Revert filter

* Split `saml` and `oidc` create/update logic

* Route `saml` and `oidc`

* Test update

* Update swagger

* Update swagger

* Use tenant/product from stored config
in lieu of params

* Validate passed OIDC clientId using hash

* Update swagger annotations

* Handlers for getting OIDC/SAML configs

* Validate tenant/product in update

* Typo fix

* Fix test

* Default to empty string, validation is done
to check if the params are not empty

* Extract provider name just like saml

* OIDC Connection support
*delta for authorize*
- Renamed samlConfig(s) → connection(s)
- Renamed resolvedSamlConfig -> resolvedConnection
- Detect connection is SAML or OIDC
- Perform Issuer discovery and oidc client init
- Tweak error responses
- Persist oidc client metadata in session

* Test type fix

* Test fix

* openid-client dependency

* Sync package locks

* Fix return type
- Remove `undefined` from return type
- Return `OAuthErrorResponse` for else case

* Handle OIDC Authorization response

* Persist OIDC code_verifier

* Remove scope check for OIDC connection

* Normalize scope before relaying

* Method name update

* Extract user profile from id token and userinfo

* Handle error response from OIDC Provider

* Update type

* Type update with OIDC specific error codes

* Bug fix : typo

* Cleanup

* OIDC callback route

* Bug fix: return profile and parameter fix

* Rename `config` -> `connection`

* Use `Link` and add oidc connection nav item

* Use `strategy` from query param

* Delta ↴
 - Reorganised api routes
 - Removed Admin controller filtering methods for saml/oidc

* Fix page link in e2e test

* Changes:
 - Handle oidc connection fields
 - Rename component file path

* Remove slug for save/update connection

* Fix keyname in update operation

* Import path update

* Radio select connection type for new connection

* Update lock file

* Sync lock file

* Sync package lock

* Fix connectionType detection for new connection

* Fix error message

* Add comment

* Tweak comment

* Use the correct state and directly from session

* Sync lock file

* Remove `provider` from OAuthReqBody

* Remove duplicate scopes

* Pass recent param additions to idpSelection page

* Add badge for Provider type

* Style tweak

* Style IdP type selection

* Add test for oidc provider

* Comment

* Check for empty state

* Add test for oidcAuthzResponse

* Add test for oidcAuthzResponse

* Add test for error response from OP

* Error message tweak

* Test the happy path

* Remove unused import

* Fix assertion

* - Fix types
- add createOIDCConfig` test for missing params

* Test happy path for `createOIDCConfig`

* Param validation tests for `createOIDCConfig`

* Test for `updateOIDCConfig`

* Tests for `updateOIDCConfig`

* Male `oidcPath` required like `samlPath`

* Bump `openid-client` version

* Refactor

* Update test coverage map

* Tweak label

* Split openid/oauth tests

* call `t.end`

* Fix file name in comment

* Add test teardown

* Improve coverage and rename test files

* For backwards compatibility

* Minor formatting

* Add api paths for /connection

* Zap config path for admin ui

* Update swagger spec

* Rename `configAPIController`
→ `connectionAPIController`

* Rename `IdPConfig` → `IdPConnection`

* Rename `validateIdPConfig` → `validateIdPConnection`

* Rename `createSAMLConfig` → `createSAMLConnection`

* Rename `createOIDCConfig` → `createOIDCConnection`

* Update swagger spec

* Rename `updateSAMLConfig` → `updateSAMLConnection`,
 `updateOIDCConfig` → `updateOIDCConnection`

* Make `clientID`/`clientSecret` readOnly

* Rename `configStore` → `connectionStore`

* Update swagger spec

* Add `getConnection` + `deleteConnection`

* Remove `/api/v1/oidc/config`
and keep `api/v1/saml/config`

* Rename `getAllConfig` → `getAllConnection`

* Rename `readConfig` → `loadConnection`

* Rename `deleteConfiguration` → `deleteConnection`

* Add `preLoadedConnection` env

* Update map and cli

* Refactor api tests and rename config to connection

* Rename `configList` → `connectionList`

* Rename `samlConfig` → `samlConnection`

* Rename config -> connection

* Rename `config` → `connection`

* Rename counters for otl

* Sync package lock

* Remove api key validation from api route

* Update Admin ui title

* Update swagger

* Update otl metric descriptions

* Update var naming to connection

* Add strategy validation

* Add tests for invalid strategy

* Sync package lock

* Upgrade and pin version

* Update saml config api with deprecated

* Updated swagger spec for deprecated config api

* Bump package version

* Fix label

* - removed strategy for `get` and `delete`
 - Type update

* Type updates

* getConnection -> getConnections,
deleteConnection -> deleteConnections

* Update swagger spec

* Use only for saml connection

* Remove slug from api routes

* API path updates

* Type updates

* Helper util for api routes to check strategy

* Type updates and api changes

* `OAuthReq` typings enhancement

* Narrowed down types for `OAuthTokenReq` and
`OIDCAuthzResponsePayload`

* `IdPConnection` -> `SSOConnection`

* Update cookie name to avoid clash

* Handle the uncaught case to prevent req hanging
  • Loading branch information
niwsa authored Sep 30, 2022
1 parent 2d3e66a commit d5cbb40
Show file tree
Hide file tree
Showing 63 changed files with 4,017 additions and 1,662 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ OPENID_JWS_ALG=
# cat private_key.pem | base64
OPENID_RSA_PRIVATE_KEY=
# openssl rsa -in private_key.pem -pubout -out public_key.pem
# cat public-key.pem | base64
# cat public_key.pem | base64
OPENID_RSA_PUBLIC_KEY=
28 changes: 14 additions & 14 deletions components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {

const menus = [
{
href: '/admin/saml/config',
text: 'SAML Connections',
href: '/admin/connection',
text: 'SSO Connections',
icon: ShieldCheckIcon,
active: asPath.includes('/admin/saml'),
},
Expand Down Expand Up @@ -66,7 +66,7 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
height={36}
className='h-8 w-auto'
/>
<span className='ml-4 text-xl font-bold text-gray-900'>SAML Jackson</span>
<span className='ml-4 text-xl font-bold text-gray-900'>Admin UI - BoxyHQ</span>
</a>
</Link>
</div>
Expand All @@ -93,23 +93,23 @@ export const Sidebar = (props: { isOpen: boolean; setIsOpen: any }) => {
<Link href='/'>
<a className='flex items-center'>
<Image src={Logo} alt='BoxyHQ' layout='fixed' width={36} height={36} className='h-8 w-auto' />
<span className='ml-4 text-xl font-bold text-gray-900'>SAML Jackson</span>
<span className='ml-4 text-lg font-bold text-gray-900'>Admin UI - BoxyHQ</span>
</a>
</Link>
</div>
<div className='mt-5 flex flex-1 flex-col'>
<nav className='flex-1 space-y-1 px-2 pb-4' id='menu'>
{menus.map((menu) => (
<a
key={menu.text}
href={menu.href}
className={classNames(
'group flex items-center rounded-md px-2 py-2 text-sm text-gray-900',
menu.active ? 'bg-gray-100 font-bold' : 'font-medium'
)}>
<menu.icon className='mr-4 h-6 w-6 flex-shrink-0' aria-hidden='true' />
<div>{menu.text}</div>
</a>
<Link key={menu.text} href={menu.href}>
<a
className={classNames(
'group flex items-center rounded-md px-2 py-2 text-sm text-gray-900',
menu.active ? 'bg-gray-100 font-bold' : 'font-medium'
)}>
<menu.icon className='mr-4 h-6 w-6 flex-shrink-0' aria-hidden='true' />
<div>{menu.text}</div>
</a>
</Link>
))}
</nav>
</div>
Expand Down
174 changes: 140 additions & 34 deletions components/saml/AddEdit.tsx → components/connection/AddEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ConfirmationModal from '@components/ConfirmationModal';
* to render parsed metadata and other attributes.
* All fields are editable unless they have `editable` set to false.
* All fields are required unless they have `required` or `requiredInEditView` set to false.
* `accessor` only used to set initial state and retrieve saved value. Useful when key is different from retrieved payload.
*/
const fieldCatalog = [
{
Expand Down Expand Up @@ -55,6 +56,27 @@ const fieldCatalog = [
placeholder: 'http://localhost:3366/login/saml',
attributes: {},
},
{
key: 'oidcDiscoveryUrl',
label: 'Well-known URL of OpenId Provider',
type: 'url',
placeholder: 'https://example.com/.well-known/openid-configuration',
attributes: { connection: 'oidc', accessor: (o) => o?.oidcProvider?.discoveryUrl },
},
{
key: 'oidcClientId',
label: 'Client ID [OIDC Provider]',
type: 'text',
placeholder: '',
attributes: { editable: false, connection: 'oidc', accessor: (o) => o?.oidcProvider?.clientId },
},
{
key: 'oidcClientSecret',
label: 'Client Secret [OIDC Provider]',
type: 'text',
placeholder: '',
attributes: { connection: 'oidc', accessor: (o) => o?.oidcProvider?.clientSecret },
},
{
key: 'rawMetadata',
label: 'Raw IdP XML',
Expand All @@ -64,6 +86,7 @@ const fieldCatalog = [
rows: 5,
requiredInEditView: false, //not required in edit view
labelInEditView: 'Raw IdP XML (fully replaces the current one)',
connection: 'saml',
},
},
{
Expand All @@ -74,6 +97,7 @@ const fieldCatalog = [
rows: 10,
editable: false,
showOnlyInEditView: true,
connection: 'saml',
formatForDisplay: (value) => {
const obj = JSON.parse(JSON.stringify(value));
delete obj.validTo;
Expand All @@ -82,36 +106,37 @@ const fieldCatalog = [
},
},
{
key: 'idpMetadata',
key: 'idpCertExpiry',
label: 'IdP Certificate Validity',
type: 'pre',
attributes: {
isHidden: (value): boolean => !value.validTo || new Date(value.validTo).toString() == 'Invalid Date',
rows: 10,
editable: false,
showOnlyInEditView: true,
showWarning: (value) => new Date(value.validTo) < new Date(),
formatForDisplay: (value) => new Date(value.validTo).toString(),
connection: 'saml',
accessor: (o) => o?.idpMetadata?.validTo,
showWarning: (value) => new Date(value) < new Date(),
formatForDisplay: (value) => new Date(value).toString(),
},
},
{
key: 'clientID',
label: 'Client ID',
type: 'text',
attributes: { showOnlyInEditView: true },
attributes: { showOnlyInEditView: true, editable: false },
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'password',
attributes: { showOnlyInEditView: true },
attributes: { showOnlyInEditView: true, editable: false },
},
{
key: 'forceAuthn',
label: 'Force Authentication',
type: 'checkbox',

attributes: { showOnlyInEditView: false, requiredInEditView: false, required: false },
attributes: { requiredInEditView: false, required: false, connection: 'saml' },
},
];

Expand All @@ -121,51 +146,78 @@ function getFieldList(isEditView) {
: fieldCatalog.filter(({ attributes: { showOnlyInEditView } }) => !showOnlyInEditView); // filtered list for add view
}

function getInitialState(samlConfig, isEditView) {
function getInitialState(connection, isEditView) {
const _state = {};
const _fieldCatalog = getFieldList(isEditView);

_fieldCatalog.forEach(({ key, attributes }) => {
_state[key] = samlConfig?.[key]
let value;

if (typeof attributes.accessor === 'function') {
value = attributes.accessor(connection);
} else {
value = connection?.[key];
}
_state[key] = value
? attributes.isArray
? samlConfig[key].join('\r\n') // render list of items on newline eg:- redirect URLs
: samlConfig[key]
? value.join('\r\n') // render list of items on newline eg:- redirect URLs
: value
: '';
});
return _state;
}

type AddEditProps = {
samlConfig?: Record<string, any>;
connection?: Record<string, any>;
};

const AddEdit = ({ samlConfig }: AddEditProps) => {
const AddEdit = ({ connection }: AddEditProps) => {
const router = useRouter();
const isEditView = !!samlConfig;
// STATE: New connection type
const [newConnectionType, setNewConnectionType] = useState<'saml' | 'oidc'>('saml');
const handleNewConnectionTypeChange = (event) => {
setNewConnectionType(event.target.value);
};

const { id: connectionClientId } = router.query;
const isEditView = !!connection && !!connectionClientId;
const connectionIsSAML = isEditView
? connection?.idpMetadata && typeof connection.idpMetadata === 'object'
: newConnectionType === 'saml';
const connectionIsOIDC = isEditView
? connection?.oidcProvider && typeof connection.oidcProvider === 'object'
: newConnectionType === 'oidc';
// FORM LOGIC: SUBMIT
const [{ status }, setSaveStatus] = useState<{ status: 'UNKNOWN' | 'SUCCESS' | 'ERROR' }>({
status: 'UNKNOWN',
});
const saveSAMLConfiguration = async (event) => {
const saveConnection = async (event) => {
event.preventDefault();
const { rawMetadata, redirectUrl, ...rest } = formObj;
const { rawMetadata, redirectUrl, oidcDiscoveryUrl, oidcClientId, oidcClientSecret, ...rest } = formObj;
const encodedRawMetadata = btoa(rawMetadata || '');
const redirectUrlList = redirectUrl.split(/\r\n|\r|\n/);

const res = await fetch('/api/admin/saml/config', {
const res = await fetch('/api/admin/connections', {
method: isEditView ? 'PATCH' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...rest, encodedRawMetadata, redirectUrl: JSON.stringify(redirectUrlList) }),
body: JSON.stringify({
...rest,
encodedRawMetadata: connectionIsSAML ? encodedRawMetadata : undefined,
oidcDiscoveryUrl: connectionIsOIDC ? oidcDiscoveryUrl : undefined,
oidcClientId: connectionIsOIDC ? oidcClientId : undefined,
oidcClientSecret: connectionIsOIDC ? oidcClientSecret : undefined,
redirectUrl: JSON.stringify(redirectUrlList),
}),
});
if (res.ok) {
if (!isEditView) {
router.replace('/admin/saml/config');
router.replace('/admin/connection');
} else {
setSaveStatus({ status: 'SUCCESS' });
// revalidate on save
mutate(`/api/admin/saml/config/${router.query.id}`);
mutate(`/api/admin/connections/${connectionClientId}`);
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
}
} else {
Expand All @@ -178,28 +230,28 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
// LOGIC: DELETE
const [delModalVisible, setDelModalVisible] = useState(false);
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
const deleteConfiguration = async () => {
await fetch('/api/admin/saml/config', {
const deleteConnection = async () => {
await fetch('/api/admin/connections', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ clientID: samlConfig?.clientID, clientSecret: samlConfig?.clientSecret }),
body: JSON.stringify({ clientID: connection?.clientID, clientSecret: connection?.clientSecret }),
});
toggleDelConfirm();
await mutate('/api/admin/saml/config');
router.replace('/admin/saml/config');
await mutate('/api/admin/connections');
router.replace('/admin/connection');
};

// STATE: FORM
const [formObj, setFormObj] = useState<Record<string, string>>(() =>
getInitialState(samlConfig, isEditView)
getInitialState(connection, isEditView)
);
// Resync form state on save
useEffect(() => {
const _state = getInitialState(samlConfig, isEditView);
const _state = getInitialState(connection, isEditView);
setFormObj(_state);
}, [samlConfig, isEditView]);
}, [connection, isEditView]);

function getHandleChange(opts: any = {}) {
return (event: FormEvent) => {
Expand All @@ -208,9 +260,14 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
};
}

function fieldCatalogFilterByConnection(connection) {
return ({ attributes }) =>
attributes.connection && connection !== null ? attributes.connection === connection : true;
}

return (
<>
<Link href='/admin/saml/config'>
<Link href='/admin/connection'>
<a className='btn btn-outline items-center space-x-2'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span>Back</span>
Expand All @@ -220,9 +277,58 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
{isEditView ? 'Edit Connection' : 'Create Connection'}
</h2>
<form onSubmit={saveSAMLConfiguration}>
{!isEditView && (
<div className='mb-4 flex'>
<div className='mr-2 py-3'>Select Type:</div>
<div className='flex flex-nowrap items-stretch justify-start gap-1 rounded-md border-2 border-dashed py-3'>
<div>
<input
type='radio'
name='connection'
value='saml'
className='peer sr-only'
checked={newConnectionType === 'saml'}
onChange={handleNewConnectionTypeChange}
id='saml-conn'></input>
{/* var(--radio-border-width) solid var(--color-gray) */}
<label
htmlFor='saml-conn'
className='cursor-pointer rounded-md border-2 border-solid py-3 px-8 font-semibold hover:shadow-md peer-checked:border-secondary-focus peer-checked:bg-secondary peer-checked:text-white'>
SAML
</label>
</div>
<div>
<input
type='radio'
name='connection'
value='oidc'
className='peer sr-only'
checked={newConnectionType === 'oidc'}
onChange={handleNewConnectionTypeChange}
id='oidc-conn'></input>
<label
htmlFor='oidc-conn'
className='cursor-pointer rounded-md border-2 border-solid px-8 py-3 font-semibold hover:shadow-md peer-checked:bg-secondary peer-checked:text-white'>
OIDC
</label>
</div>
</div>
</div>
)}
<form onSubmit={saveConnection}>
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
{fieldCatalog
.filter(
fieldCatalogFilterByConnection(
isEditView
? connectionIsSAML
? 'saml'
: connectionIsOIDC
? 'oidc'
: null
: newConnectionType
)
)
.filter(({ attributes: { showOnlyInEditView } }) => (isEditView ? true : !showOnlyInEditView))
.map(
({
Expand Down Expand Up @@ -347,7 +453,7 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
</p>
</div>
</div>
{samlConfig?.clientID && samlConfig.clientSecret && (
{connection?.clientID && connection.clientSecret && (
<section className='mt-10 flex items-center rounded bg-red-100 p-6 text-red-900'>
<div className='flex-1'>
<h6 className='mb-1 font-medium'>Delete this connection</h6>
Expand All @@ -364,10 +470,10 @@ const AddEdit = ({ samlConfig }: AddEditProps) => {
)}
</form>
<ConfirmationModal
title='Delete the SAML Connection'
description='This action cannot be undone. This will permanently delete the SAML config.'
title='Delete the Connection'
description='This action cannot be undone. This will permanently delete the Connection.'
visible={delModalVisible}
onConfirm={deleteConfiguration}
onConfirm={deleteConnection}
onCancel={toggleDelConfirm}></ConfirmationModal>
</div>
</>
Expand Down
4 changes: 2 additions & 2 deletions e2e/admin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { test } from '@playwright/test';

test('MAGIC_LINK in globalSetup should log me in', async ({ page }) => {
await page.goto('/admin/saml/config');
await page.goto('/admin/connection');

// Find the button and click on it
await page.locator('data-test-id=create-saml-connection').click();
await page.locator('data-test-id=create-connection').click();
});
Loading

0 comments on commit d5cbb40

Please sign in to comment.