Admin interface for Jackson (#71)

* NextAuth + users providers

* Add a temporary fix for verification token - don't use it in production

* Admin ui files

* Admin controller

* getAll db apis

* IdP provider page and api route

* Fix padding

* Style fixes

* middleware to check session

* Loading state handling

* fetcher better response handling

* Add new provider form and api route

* Tab panel in client add form

* Tab switching plus new fields

* Flowbite config

* darkMode with flowbite

* Save config

* Update route path to saml

* Reusable component for add/edit

* cleanup

* Set Secret in NextAuth options

* Prettier lint changes

* Support for delete operation

* Link update

* PopUp Modal reusable component

* Popup confirm before delete

* disable SWR revalidation on focus

* Display IdP metadata, clientID,secret

* Header fixed positioning and style fixes

* Filter raw XML in edit mode

* Add name field to config

* - Edit/New form delta
- Split by newline
- Route back after POST

* Remove flowbite

* Remove flowbite [cleanup]

* Add description field

* updateConfig implementation

* Route PATCH to updateConfig

* Naming change

* Naming Client -> Connection

* AddEdit component updates

* Omit provider, returns full config

* Destructure session first

* Change to domain ACL

* Delete unused component

* Support glob and list of emails for ACL

* Delete unused CSS

* Update package lock

* Remove flowbite from content source

* Redirect to admin route

* Check session in Layout and redirect to login

* Logout in dropdown

* vertical alignment

* Show status message on save (edit)

* Consolidate fields to one long vertical column

* GetAll function for SQL and Add CreationDate and Modification Date for Mongo and SQL

* Add name as header

* Styling and opacity transition for status

* Configure button style fix

* overflow for smaller viewports and rounded border

* Fallback to default behavior of useSession

* Store, use and dispose (after signIn)
verification token in db

* Remove unused class

* Rename Connections ➡ Configurations

* Handle getAll and getConfig using slug

* Better naming

* Update fetch paths

* Refactor getAllConfig ➡ getConfig (By Id)

* Better naming

* Rename saml ➡ samlconf

* Use light theme by not defaulting to system theme

* Path update /samlconf ➡ /saml/config

* Fix path

* Revert manual changes

* getall funcationality and migration  script

* message

* Updating migration file formating

* message

* Pull and fix package.json and lock file

* correcting the migration script formatting

* remove file

* add new migration files

* e2e with playwright

* Better naming

* Remove comment

* Make headless

* Run npm install from root

* Add e2e steps in workflow

* try with separate npm installs

* Move higher in the pipeline to test

* Fix quote

* Rely on npx

* fixed migration script formatting

* spelling correction

* headless for CI but false for local

* Use secret

* Type fixes for mongo

* [skip ci] Swagger annotation for getConfig

* Adding migration scriptis for all db's

* added migration script to prettierignore

* unformat migration script

* removing postgress migration files

* generate new migration files

* remove wrong migration files

* Add new migration files  for mysql and mariadb

* [skip ci] Swagger annotation for updateConfig

* Return empty for update op

* Update swagger spec

* Fix type

* Wait for mongo to start

* Fix db_engine

* Test with pg

* Test with POSTGRES_DB env to auto create db

* Swap install-deps with install

* Use prod build

* enable @ts-ignore

* Test some fixes

* Can be omitted in next-auth v4, uses secret

* Move env to playwright config

* authDbSeed script needs the db and other secrets

* Typo

* Bad typo day 😅

* Again typo

* Set NEXTAUTH_URL

* Use prod build in CI

* Prefix the env for seeding

* Try with inline

* tidying up migration scripts

* fixed migration scripts

* Set env in actions yml

* Remove comma

* Target chromium

* Prefix the env

* Try inline in playwright

* print env

* Move build to action step

* Remove console log

* Let env sit on the job level

* Add ACL

* Fix attribute check

* Add name field

* add name in metadata preload config

* Use postgres

* Remove unneeded secret

* Remove env/options from mongo service

* Fix swagger

* Update swagger spec

* [skip ci] Fix eslint warning

* Add updateConfig test

* Add description to preloaded config

* [skip ci] cleanup

* minor fix

* Update comment

* Expose PATCH in config api

* Added missing validation for clientSecret

* Update swagger spec

* updated example postgres url, updated deps

* Redirect to saml config route

* Remove unused pages/routes

* Update in package lock

* Add primary and secondary colors to tailwind

* Swap icon

* Remove text-color and apply default theme

* Use the primary color from theme

* Reusable custom class for btn-primary

* Add link-primary reusable class

* Use primary secondary colors for main logo

* Show error status & color align with primary color

* Show product if name is absent

* Simplify required attribute setting,
'description' is not required

* Make description optional

* Fix placeholder text

* Swagger updates

* Add validation for description

* Swagger - add missing status codes & descriptions

* Update swagger artifact

* Fix styling for status message

* revalidate config on successful save

* style text highlight globally

* Fix cancel button style

* Set the main height to 100%-headerHeight,
add overflow

* removed default ACL, if someone forgets to change it then we might have Tony Stark logging into everyones instances :)

* print the arch/platform

* Collect platform info

* Disable swc and remove platform query steps

* Try with custom babel config to disable swc

* Add next.js build cache

* Refactor step

* trying swc

* Make name parameter optional

* Update form state from backend after save

* port 5000 -> 5225

* Handle empty value case for ACL

* bumped up version

Co-authored-by: Kiran <kiran@boxyhq.com>
Co-authored-by: Vishal Lodha <vishal@boxyhq.com>
Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
Co-authored-by: Utkarsh Mehta <ukrocks.mehta@gmail.com>
This commit is contained in:
Aswin V 2022-02-23 00:33:21 +05:30 committed by GitHub
parent 8cdc011f9f
commit bd44c3479c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 5412 additions and 626 deletions

View File

@ -15,6 +15,17 @@ DB_CLEANUP_LIMIT=1000
# You can use openssl to generate a random 32 character key: openssl rand -base64 24
DB_ENCRYPTION_KEY=
# Admin UI settings
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
NEXTAUTH_URL=
NEXTAUTH_SECRET=
NEXTAUTH_ACL=
# OpenTelemetry
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=
OTEL_EXPORTER_OTLP_HEADERS=

View File

@ -20,7 +20,16 @@ on:
jobs:
ci:
runs-on: ubuntu-latest
env:
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: http://localhost:5225
NEXTAUTH_ACL: '*@boxyhq.com'
DB_ENGINE: sql
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
DB_TYPE: postgres
DEBUG: pw:webserver
SAML_AUDIENCE: https://saml.boxyhq.com
JACKSON_API_KEYS: secret
strategy:
matrix:
node-version: [16.x]
@ -73,8 +82,21 @@ jobs:
registry-url: https://registry.npmjs.org
scope: '@boxyhq'
cache: 'npm'
- name: Setup Next.js cache
uses: actions/cache@v2
with:
path: ${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: npm install
working-directory: ./npm
- run: npm run build
- name: Install playwright browser dependencies
run: npx playwright install chromium
- name: e2e tests
run: npx ts-node --log-error e2e/seedAuthDb.ts && npx playwright test
- run: npm run test
working-directory: ./npm
- run: |

1
.gitignore vendored
View File

@ -42,3 +42,4 @@ yarn-error.log*
npmversion.txt
publishTag.txt
.env

View File

@ -4,6 +4,7 @@ public
**/**/node_modules
**/**/.next
**/**/public
npm/migration/**
*.lock
*.log

34
components/ActiveLink.tsx Normal file
View File

@ -0,0 +1,34 @@
import { useRouter } from 'next/router';
import Link from 'next/link';
import { Children, cloneElement, ReactElement } from 'react';
const ActiveLink = ({
children,
activeClassName,
href,
...props
}: {
children: ReactElement<HTMLAnchorElement>;
activeClassName: string;
href: string;
} & Record<string, any>) => {
const { asPath } = useRouter();
const child = Children.only(children);
const childClassName = child.props.className || '';
// pages/index.js will be matched via props.href
// pages/about.js will be matched via props.href
// pages/[slug].js will be matched via props.as
const className =
asPath === href || asPath === props.as ? `${childClassName} ${activeClassName}`.trim() : childClassName;
return (
<Link href={href} {...props}>
{cloneElement(child as ReactElement, {
className: className || null,
})}
</Link>
);
};
export default ActiveLink;

150
components/Layout.tsx Normal file
View File

@ -0,0 +1,150 @@
import Head from 'next/head';
import Image from 'next/image';
import Link from 'next/link';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import Logo from '../public/logo.png';
import { LogoutIcon, MenuIcon, ShieldCheckIcon } from '@heroicons/react/outline';
import useOnClickOutside from 'hooks/useOnClickOutside';
import ActiveLink from './ActiveLink';
import useKeyPress from 'hooks/useKeyPress';
import useMediaQuery from 'hooks/useMediaQuery';
import { useSession, signOut } from 'next-auth/react';
const navigation = [
{
path: '/admin/saml/config',
text: <span className='ml-4'>SAML Configurations</span>,
icon: <ShieldCheckIcon className='w-5 h-5' aria-hidden />,
},
];
function Layout({ children }: { children: ReactNode }) {
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const ref = useRef(null);
const _closeSideNav = useCallback(() => {
if (isSideNavOpen) {
setIsSideNavOpen(false);
}
}, [isSideNavOpen]);
// close on clicking outside
useOnClickOutside(ref, _closeSideNav);
// close on "Escape" key press
const pressedEsc = useKeyPress('Escape');
useEffect(() => {
if (pressedEsc) {
_closeSideNav();
}
}, [_closeSideNav, pressedEsc]);
// reset state on window resize
/*IMPORTANT: matches the md breakpoint default setting at https://tailwindcss.com/docs/screens*/
const _mdBreakpointMatch = useMediaQuery('(min-width: 768px)');
useEffect(() => {
if (_mdBreakpointMatch) {
_closeSideNav();
}
}, [_closeSideNav, _mdBreakpointMatch]);
// check logged-in status, https://next-auth.js.org/getting-started/client#require-session
// The default behavior is to redirect the user to the sign-in page, from where - after a successful login - they will be sent back to the page they started on.
const { data: session, status } = useSession({ required: true });
// user settings dropdown state
const [isOpen, setIsOpen] = useState(false);
const userDropDownRef = useRef(null);
useOnClickOutside(userDropDownRef, () => setIsOpen(false));
if (status === 'loading') {
return <p>Loading...</p>;
}
return (
<>
<Head>
<title>Jackson SAML Dashboard</title>
<link rel='icon' href='/favicon.ico' />
</Head>
<header
role='banner'
className='p-5 md:px-12 fixed left-0 right-0 border-b bg-white dark:bg-gray-900 border-gray-900/10 dark:border-gray-300/10 z-10'>
<div className='flex justify-between items-center'>
<Link href='/'>
<a title='Go to dashboard' className='leading-10 font-bold flex items-center ml-10 md:ml-0'>
<Image src={Logo} alt='BoxyHQ' layout='fixed' width={36} height={36} />
<h1 className='ml-2 text-secondary hover:text-primary dark:text-white'>Jackson</h1>
</a>
</Link>
<div className='relative'>
<button
type='button'
className='rounded-full h-8 w-8 flex items-center justify-center bg-secondary text-cyan-50 uppercase'
aria-label='user settings'
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}>
{session?.user?.name?.[0]}
</button>
{isOpen && (
<ul
className='absolute z-50 top-full right-0 bg-white rounded-lg ring-1 ring-slate-900/10 shadow-lg overflow-hidden w-36 py-1 text-sm text-slate-700 font-semibold dark:bg-slate-800 dark:ring-0 dark:highlight-white/5 dark:text-slate-300'
ref={userDropDownRef}>
<li>
<button
type='button'
className='py-1 px-2 h-8 w-full flex justify-center items-center cursor-pointer'
onClick={() => signOut()}>
<LogoutIcon className='w-5 h-5' aria-hidden />
Log out
</button>
</li>
</ul>
)}
</div>
</div>
{isSideNavOpen && (
<div
className='fixed inset-0 bg-black/20 backdrop-blur-sm dark:bg-gray-900/80 md:hidden'
id='headlessui-dialog-overlay-14'
aria-hidden='true'></div>
)}
<nav role='navigation'>
<button
className={`w-10 h-10 inline-flex items-center justify-center absolute top-5 md:hidden`}
aria-expanded={isSideNavOpen}
aria-controls='menu'
onClick={() => setIsSideNavOpen((curState) => !curState)}>
<span className='sr-only'>Menu</span>
<MenuIcon aria-hidden='true' className='h-6 w-6 text-black dark:text-slate-50'></MenuIcon>
</button>
<ul
className={`fixed top-0 bottom-0 left-0 w-60 p-6 border-r border-gray-900/10 dark:border-gray-300/10 transition-transform ${
isSideNavOpen ? 'translate-x-0' : '-translate-x-full'
} md:translate-x-0 md:top-20 md:translate-y-[2px] bg-white dark:bg-gray-900`}
id='menu'
ref={ref}>
{navigation.map(({ path, text, icon }, index) => (
<li key={index}>
<ActiveLink href={path} activeClassName='text-primary/90 dark:text-sky-400 font-semibold'>
<a className='link-primary'>
{icon}
{text}
</a>
</ActiveLink>
</li>
))}
</ul>
</nav>
</header>
<main
role='main'
className='relative top-[81px] h-[calc(100%_-_81px)] md:left-60 md:w-[calc(100%_-_theme(space.60))] p-6 overflow-auto'>
{children}
</main>
{/* <footer role="contentinfo">
<p>&copy; 2022 BoxyHQ, Inc.</p>
</footer> */}
</>
);
}
export default Layout;

355
components/saml/AddEdit.tsx Normal file
View File

@ -0,0 +1,355 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import { FormEvent, useEffect, useState } from 'react';
import { mutate } from 'swr';
import { ArrowLeftIcon, CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/outline';
import { Modal } from '@supabase/ui';
/**
* Edit view will have extra fields (showOnlyInEditView: true)
* 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.
*/
const fieldCatalog = [
{
key: 'name',
label: 'Name',
type: 'text',
placeholder: 'MyApp',
attributes: { required: false, requiredInEditView: false },
},
{
key: 'description',
label: 'Description',
type: 'text',
placeholder: 'A short description not more than 100 characters',
attributes: { maxLength: 100, required: false, requiredInEditView: false }, // not required in create/edit view
},
{
key: 'tenant',
label: 'Tenant',
type: 'text',
placeholder: 'acme.com',
attributes: { editable: false },
},
{
key: 'product',
label: 'Product',
type: 'text',
placeholder: 'demo',
attributes: { editable: false },
},
{
key: 'redirectUrl',
label: 'Allowed redirect URLs (newline separated)',
type: 'textarea',
placeholder: 'http://localhost:3000',
attributes: { isArray: true, rows: 3 },
},
{
key: 'defaultRedirectUrl',
label: 'Default redirect URL',
type: 'url',
placeholder: 'http://localhost:3000/login/saml',
attributes: {},
},
{
key: 'rawMetadata',
label: 'Raw IdP XML',
type: 'textarea',
placeholder: 'Paste the raw XML here',
attributes: {
rows: 5,
requiredInEditView: false, //not required in edit view
labelInEditView: 'Raw IdP XML (fully replaces the current one)',
},
},
{
key: 'idpMetadata',
label: 'IDP Metadata',
type: 'pre',
attributes: {
rows: 10,
editable: false,
showOnlyInEditView: true,
formatForDisplay: (value) => JSON.stringify(value, null, 2),
},
},
{
key: 'clientID',
label: 'Client Id',
type: 'text',
attributes: { showOnlyInEditView: true },
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'password',
attributes: { showOnlyInEditView: true },
},
];
function getFieldList(isEditView) {
return isEditView
? fieldCatalog
: fieldCatalog.filter(({ attributes: { showOnlyInEditView } }) => !showOnlyInEditView); // filtered list for add view
}
function getInitialState(samlConfig, isEditView) {
const _state = {};
const _fieldCatalog = getFieldList(isEditView);
_fieldCatalog.forEach(({ key, attributes }) => {
_state[key] = samlConfig?.[key]
? attributes.isArray
? samlConfig[key].join('\r\n') // render list of items on newline eg:- redirect URLs
: samlConfig[key]
: '';
});
return _state;
}
type AddEditProps = {
samlConfig?: Record<string, any>;
};
const AddEdit = ({ samlConfig }: AddEditProps) => {
const router = useRouter();
const isEditView = !!samlConfig;
// FORM LOGIC: SUBMIT
const [{ status }, setSaveStatus] = useState<{ status: 'UNKNOWN' | 'SUCCESS' | 'ERROR' }>({
status: 'UNKNOWN',
});
const saveSAMLConfiguration = async (event) => {
event.preventDefault();
const { rawMetadata, redirectUrl, ...rest } = formObj;
const encodedRawMetadata = btoa(rawMetadata || '');
const redirectUrlList = redirectUrl.split(/\r\n|\r|\n/);
const res = await fetch('/api/admin/saml/config', {
method: isEditView ? 'PATCH' : 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Api-Key secret',
},
body: JSON.stringify({ ...rest, encodedRawMetadata, redirectUrl: JSON.stringify(redirectUrlList) }),
});
if (res.ok) {
if (!isEditView) {
router.replace('/admin/saml/config');
} else {
setSaveStatus({ status: 'SUCCESS' });
// revalidate on save
mutate(`/api/admin/saml/config/${router.query.id}`);
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
}
} else {
// save failed
setSaveStatus({ status: 'ERROR' });
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
}
};
// LOGIC: DELETE
const [delModalVisible, setDelModalVisible] = useState(false);
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
const [userNameEntry, setUserNameEntry] = useState('');
const deleteConfiguration = async () => {
await fetch('/api/admin/saml/config', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: 'Api-Key secret',
},
body: JSON.stringify({ clientID: samlConfig?.clientID, clientSecret: samlConfig?.clientSecret }),
});
toggleDelConfirm();
await mutate('/api/admin/saml/config');
router.replace('/admin/saml/config');
};
// STATE: FORM
const [formObj, setFormObj] = useState<Record<string, string>>(() =>
getInitialState(samlConfig, isEditView)
);
// Resync form state on save
useEffect(() => {
const _state = getInitialState(samlConfig, isEditView);
setFormObj(_state);
}, [samlConfig, isEditView]);
function handleChange(event: FormEvent) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
setFormObj((cur) => ({ ...cur, [target.id]: target.value }));
}
return (
<>
{/* Or use router.back() */}
<Link href='/admin/saml/config'>
<a className='link-primary'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span className='ml-2'>Back to Configurations</span>
</a>
</Link>
<div>
<h2 className='font-bold text-3xl text-primary mt-2 mb-4 dark:text-white'>
{samlConfig?.name || samlConfig?.product || 'New SAML Configuration'}
</h2>
<form onSubmit={saveSAMLConfiguration}>
<div className='bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6 rounded-xl md:w-3/4 min-w-[28rem] md:max-w-lg'>
{fieldCatalog
.filter(({ attributes: { showOnlyInEditView } }) => (isEditView ? true : !showOnlyInEditView))
.map(
({
key,
placeholder,
label,
type,
attributes: {
isArray,
rows,
formatForDisplay,
editable,
requiredInEditView = true, // by default all fields are required unless explicitly set to false
labelInEditView,
maxLength,
required = true, // by default all fields are required unless explicitly set to false
},
}) => {
const readOnly = isEditView && editable === false;
const _required = isEditView ? !!requiredInEditView : !!required;
const _label = isEditView && labelInEditView ? labelInEditView : label;
const value =
readOnly && typeof formatForDisplay === 'function'
? formatForDisplay(formObj[key])
: formObj[key];
return (
<div className='mb-6 ' key={key}>
<label
htmlFor={key}
className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
{_label}
</label>
{type === 'pre' ? (
<pre className='block p-2 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 overflow-auto'>
{value}
</pre>
) : type === 'textarea' ? (
<textarea
id={key}
placeholder={placeholder}
value={value}
required={_required}
readOnly={readOnly}
maxLength={maxLength}
onChange={handleChange}
className={`block p-2 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 ${
isArray ? 'whitespace-pre' : ''
}`}
rows={rows}
/>
) : (
<input
id={key}
type={type}
placeholder={placeholder}
value={value}
required={_required}
readOnly={readOnly}
maxLength={maxLength}
onChange={handleChange}
className='bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
/>
)}
</div>
);
}
)}
<div className='flex'>
<button type='submit' className='btn-primary'>
Save Changes
</button>
<p
role='status'
className={`ml-2 inline-flex items-center ${
status === 'SUCCESS' || status === 'ERROR' ? 'opacity-100' : 'opacity-0'
} transition-opacity motion-reduce:transition-none`}>
{status === 'SUCCESS' && (
<span className='text-primary inline-flex items-center'>
<CheckCircleIcon aria-hidden className='mr-1 h-5 w-5'></CheckCircleIcon>
Saved
</span>
)}
{/* TODO: also display error message once we standardise the response format */}
{status === 'ERROR' && (
<span className='text-red-900 inline-flex items-center'>
<ExclamationCircleIcon aria-hidden className='mr-1 h-5 w-5'></ExclamationCircleIcon>
ERROR
</span>
)}
</p>
</div>
</div>
{samlConfig?.clientID && samlConfig.clientSecret && (
<section className='flex items-center text-red-900 bg-red-100 p-6 rounded mt-10'>
<div className='flex-1'>
<h6 className='font-medium mb-1'>Delete this configuration</h6>
<p className='font-light'>All your apps using this configuration will stop working.</p>
</div>
<button
type='button'
className='bg-red-700 hover:bg-red-800 text-white text-sm font-bold py-2 px-4 rounded leading-6 inline-block'
onClick={toggleDelConfirm}
data-modal-toggle='popup-modal'>
Delete
</button>
</section>
)}
</form>
<Modal
closable
title='Are you absolutely sure ?'
description='This action cannot be undone. This will permanently delete the SAML config.'
visible={delModalVisible}
onCancel={toggleDelConfirm}
customFooter={
<div className='inline-flex ml-auto'>
<button
type='button'
onClick={toggleDelConfirm}
className='bg-gray-200 text-secondary/90 hover:bg-gray-300 border-2 text-sm font-bold py-2 px-4 rounded leading-6 inline-block'>
Cancel
</button>
<button
type='button'
disabled={userNameEntry !== samlConfig?.product}
onClick={deleteConfiguration}
className='ml-1.5 bg-red-700 hover:bg-red-800 disabled:bg-slate-400 text-white text-sm font-bold py-2 px-4 rounded leading-6 inline-block'>
Delete
</button>
</div>
}>
<p className='text-slate-600'>
Please type in the name of the product &apos;
{samlConfig?.product && <strong>{samlConfig.product}</strong>}&apos; to confirm.
</p>
<label htmlFor='nameOfProd' className='font-medium text-slate-900'>
Name *
</label>
<input
id='nameOfProd'
required
className='bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:white dark:border-gray-600 dark:placeholder-gray-400 d dark:focus:ring-blue-500 dark:focus:border-blue-500'
value={userNameEntry}
onChange={({ target }) => {
setUserNameEntry(target.value);
}}></input>
</Modal>
</div>
</>
);
};
export default AddEdit;

1
e2e/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
state.json

7
e2e/admin.spec.ts Normal file
View File

@ -0,0 +1,7 @@
import { test } from '@playwright/test';
test('MAGIC_LINK in globalSetup should log me in', async ({ page }) => {
await page.goto('/admin/saml/config');
// Find an element with the text 'SAML Configurations' and click on it
await page.waitForSelector('text=SAML Configurations');
});

18
e2e/globalSetup.ts Normal file
View File

@ -0,0 +1,18 @@
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
import { IDENTIFIER, TOKEN } from './nextAuth.constants';
async function globalSetup(config: FullConfig) {
const { baseURL, storageState } = config.projects[0].use;
// Generate a link with email, unhashed token and callback url
const params = new URLSearchParams({ callbackURL: baseURL || '', token: TOKEN, email: IDENTIFIER });
const providerId = 'email';
const MAGIC_LINK = `${baseURL}/api/auth/callback/${providerId}?${params}`;
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(MAGIC_LINK);
await page.context().storageState({ path: storageState as string });
await browser.close();
}
export default globalSetup;

View File

@ -0,0 +1,6 @@
// const ONE_DAY_IN_SECONDS = 86400;
// const EXPIRES = new Date(Date.now() + 1000000 * ONE_DAY_IN_SECONDS * 1000);
export const EXPIRES = new Date(88043913172731); /* Sat Jan 02 4760 00:02:52 GMT+0530 (India Standard Time) */
// const TOKEN = randomBytes(32).toString('hex');
export const TOKEN = '94b773327570de9bef4779a40529c467b5b5226f15b1d28b00e7fba629a0921b';
export const IDENTIFIER = 'headless@boxyhq.com';

20
e2e/seedAuthDb.ts Normal file
View File

@ -0,0 +1,20 @@
import { createHash } from 'crypto';
import { initNextAuthDB } from '@lib/nextAuthAdapter';
import { IDENTIFIER as identifier, EXPIRES as expires, TOKEN as token } from './nextAuth.constants';
export function hashToken(token: string) {
return createHash('sha256').update(`${token}${process.env.NEXTAUTH_SECRET}`).digest('hex');
}
(async function setup() {
const store = await initNextAuthDB();
const verificationToken = {
identifier,
expires,
token: hashToken(token),
};
await (await store).put(verificationToken.identifier, verificationToken);
process.exit(0);
})();

29
hooks/useKeyPress.ts Normal file
View File

@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
export default function useKeyPress(targetKey: string): boolean {
// State for keeping track of whether key is pressed
const [keyPressed, setKeyPressed] = useState<boolean>(false);
// If pressed key is our target key then set to true
function downHandler({ key }: KeyboardEvent) {
if (key === targetKey) {
setKeyPressed(true);
}
}
// If released key is our target key then set to false
const upHandler = ({ key }: KeyboardEvent) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
// Add event listeners
useEffect(() => {
window.addEventListener("keydown", downHandler);
window.addEventListener("keyup", upHandler);
// Remove event listeners on cleanup
return () => {
window.removeEventListener("keydown", downHandler);
window.removeEventListener("keyup", upHandler);
};
}, []); // Empty array ensures that effect is only run on mount and unmount
return keyPressed;
}

19
hooks/useMediaQuery.ts Normal file
View File

@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
}, [matches, query]);
return matches;
};
export default useMediaQuery;

View File

@ -0,0 +1,35 @@
import { RefObject, useEffect } from "react";
// https://usehooks-ts.com/react-hook/use-on-click-outside
type Handler = (event: MouseEvent | TouchEvent) => void;
export default function useOnClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
handler: Handler
) {
useEffect(
() => {
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
},
// Add ref and handler to effect dependencies
// It's worth noting that because passed in handler is a new ...
// ... function on every render that will cause this effect ...
// ... callback/cleanup to run every render. It's not a big deal ...
// ... but to optimize you can wrap handler in useCallback before ...
// ... passing it into this hook.
[ref, handler]
);
}

View File

@ -1,26 +1,33 @@
import jackson, { IAPIController, IOAuthController } from '@boxyhq/saml-jackson';
import jackson, { IAdminController, IAPIController, IOAuthController, IdPConfig } from '@boxyhq/saml-jackson';
import env from '@lib/env';
import '@lib/metrics';
let apiController: IAPIController;
let oauthController: IOAuthController;
let adminController: IAdminController;
const g = global as any;
export default async function init() {
if (!g.apiController || !g.oauthController) {
if (!g.apiController || !g.oauthController || !g.adminController) {
const ret = await jackson(env);
apiController = ret.apiController;
oauthController = ret.oauthController;
adminController = ret.adminController;
g.apiController = apiController;
g.oauthController = oauthController;
g.adminController = adminController;
} else {
apiController = g.apiController;
oauthController = g.oauthController;
adminController = g.adminController;
}
return {
apiController,
oauthController,
adminController,
};
}
export type { IdPConfig };

View File

@ -1,5 +1,6 @@
import Cors from 'cors';
import { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/react';
// Initializing the cors middleware
const corsFunction = Cors({
@ -23,3 +24,15 @@ function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) {
export async function cors(req: NextApiRequest, res: NextApiResponse) {
return await runMiddleware(req, res, corsFunction);
}
export const checkSession = (handler) => async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req });
if (session) {
// Signed in
return handler(req, res);
} else {
// Not Signed in
res.status(401);
}
res.end();
};

94
lib/nextAuthAdapter.ts Normal file
View File

@ -0,0 +1,94 @@
import { Storable } from '@boxyhq/saml-jackson';
import DB from 'npm/src/db/db';
import opts from './env';
import type { AdapterUser, VerificationToken } from 'next-auth/adapters';
import { validateEmailWithACL } from './utils';
const g = global as any;
export async function initNextAuthDB(): Promise<Storable> {
if (!g.adminAuthStore) {
const db = await DB.new(opts.db);
g.adminAuthStore = db.store('admin:auth');
}
return g.adminAuthStore as Storable;
}
/** @return { import("next-auth/adapters").Adapter } */
export default function Adapter() {
const store = (async () => await initNextAuthDB())();
return {
async createUser(user) {
return user;
},
async getUser(id) {
return;
},
async getUserByEmail(email) {
// ?? we already do the validation in signIn callback (see pages/api/auth/[...nextauth].ts)
if (validateEmailWithACL(email)) {
return {
id: email,
name: email.split('@')[0],
email,
role: 'admin',
emailVerified: new Date(),
} as AdapterUser;
}
return null;
},
async getUserByAccount({ providerAccountId, provider }) {
return;
},
async updateUser(user: AdapterUser) {
if (!user.id) {
return null;
}
const email = user.id;
// ?? we already do the validation in signIn callback (see pages/api/auth/[...nextauth].ts)
if (validateEmailWithACL(email)) {
return {
id: email,
name: email.split('@')[0],
email,
role: 'admin',
emailVerified: new Date(),
} as AdapterUser;
}
return null;
},
// will be required in a future release, but are not yet invoked
async deleteUser(userId) {
return;
},
async linkAccount(account) {
return;
},
// will be required in a future release, but are not yet invoked
async unlinkAccount({ providerAccountId, provider }) {
return;
},
async createSession({ sessionToken, userId, expires }) {
return;
},
async getSessionAndUser(sessionToken) {
return;
},
async updateSession({ sessionToken }) {
return;
},
async deleteSession(sessionToken) {
return;
},
async createVerificationToken(data: VerificationToken) {
await (await store).put(data.identifier, data);
},
async useVerificationToken({ identifier, token }) {
const tokenInStore = await (await store).get(identifier);
if (tokenInStore.token === token) {
await (await store).delete(identifier);
}
return tokenInStore ?? null;
},
};
}

View File

@ -1,5 +1,7 @@
import { NextApiRequest } from 'next';
import env from '@lib/env';
import micromatch from 'micromatch';
import { AdapterUser } from 'next-auth/adapters';
export const validateApiKey = (token) => {
return env.apiKeys.includes(token);
@ -14,3 +16,43 @@ export const extractAuthToken = (req: NextApiRequest) => {
return null;
};
export interface APIError extends Error {
info?: string;
status: number;
}
export const fetcher = async (url: string) => {
const res = await fetch(url, {
headers: new Headers({
Authorization: 'Api-Key secret',
}),
});
let resContent;
try {
resContent = await res.clone().json();
} catch (e) {
resContent = await res.clone().text();
}
if (!res.ok) {
const error = new Error('An error occurred while fetching the data.') as APIError;
// Attach extra info to the error object.
error.info = resContent;
error.status = res.status;
throw error;
}
return resContent;
};
export const validateEmailWithACL = (email) => {
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
const acl = NEXTAUTH_ACL?.split(',');
if (acl) {
if (micromatch.isMatch(email, acl)) {
return true;
}
}
return false;
};

View File

@ -0,0 +1,16 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class createdAt1644332636666 implements MigrationInterface {
name = 'createdAt1644332636666'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP()`);
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`modifiedAt\` timestamp NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`modifiedAt\``);
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`createdAt\``);
}
}

View File

@ -0,0 +1,16 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class createdAt1644332641078 implements MigrationInterface {
name = 'createdAt1644332641078'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP`);
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`modifiedAt\` timestamp NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`modifiedAt\``);
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`createdAt\``);
}
}

View File

@ -0,0 +1,16 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class createdAt1644332647279 implements MigrationInterface {
name = 'createdAt1644332647279'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "jackson_store" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "jackson_store" ADD "modifiedAt" TIMESTAMP`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "jackson_store" DROP COLUMN "modifiedAt"`);
await queryRunner.query(`ALTER TABLE "jackson_store" DROP COLUMN "createdAt"`);
}
}

View File

@ -18,9 +18,9 @@
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"db:migration:generate:postgres": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
"db:migration:generate:mysql": "cross-env DB_TYPE=mysql DB_URL=mysql://root:mysql@localhost:3307/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
"db:migration:generate:mariadb": "cross-env DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
"db:migration:generate:postgres": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n createdAt",
"db:migration:generate:mysql": "cross-env DB_TYPE=mysql DB_URL=mysql://root:mysql@localhost:3307/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n createdAt",
"db:migration:generate:mariadb": "cross-env DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n createdAt",
"db:migration:run:postgres": "ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run",
"db:migration:run:mysql": "cross-env DB_TYPE=mysql DB_URL=mysql://root:mysql@localhost:3307/mysql ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run",
"db:migration:run:mariadb": "cross-env DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run",

View File

@ -0,0 +1,17 @@
import { IAdminController, Storable, OAuth } from '../typings';
export class AdminController implements IAdminController {
configStore: Storable;
constructor({ configStore }) {
this.configStore = configStore;
}
public async getAllConfig(): Promise<Partial<OAuth>[]> {
const configList = (await this.configStore.getAll()) as Partial<OAuth>[];
if (!configList || !configList.length) {
return [];
}
return configList;
}
}

View File

@ -15,7 +15,8 @@ export class APIController implements IAPIController {
}
private _validateIdPConfig(body: IdPConfig): void {
const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product, description } =
body;
if (!rawMetadata && !encodedRawMetadata) {
throw new JacksonError('Please provide rawMetadata or encodedRawMetadata', 400);
@ -36,6 +37,10 @@ export class APIController implements IAPIController {
if (!product) {
throw new JacksonError('Please provide product', 400);
}
if (description && description.length > 100) {
throw new JacksonError('Description should not exceed 100 characters', 400);
}
}
/**
@ -51,10 +56,23 @@ export class APIController implements IAPIController {
* consumes:
* - application/x-www-form-urlencoded
* parameters:
* - name: name
* description: Name/identifier for the config
* type: string
* in: formData
* example: cal-saml-config
* - name: description
* description: A short description for the config not more than 100 characters
* type: string
* in: formData
* example: SAML login for cal.com app
* - name: encodedRawMetadata
* description: Base64 encoding of the XML metadata
* in: formData
* required: true
* type: string
* - name: rawMetadata
* description: Raw XML metadata
* in: formData
* type: string
* - name: defaultRedirectUrl
* description: The redirect URL to use in the IdP login flow
@ -92,11 +110,22 @@ export class APIController implements IAPIController {
* client_id: 8958e13053832b5af58fdf2ee83f35f5d013dc74
* client_secret: 13f01f4df5b01770c616e682d14d3ba23f20948cfa89b1d7
* type: accounts.google.com
* 400:
* description: Please provide rawMetadata or encodedRawMetadata | Please provide a defaultRedirectUrl | Please provide redirectUrl | Please provide tenant | Please provide product | Please provide a friendly name | Description should not exceed 100 characters
* 401:
* description: Unauthorized
*/
public async config(body: IdPConfig): Promise<OAuth> {
const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
const {
encodedRawMetadata,
rawMetadata,
defaultRedirectUrl,
redirectUrl,
tenant,
product,
name,
description,
} = body;
metrics.increment('createConfig');
@ -143,6 +172,8 @@ export class APIController implements IAPIController {
redirectUrl: JSON.parse(redirectUrl), // redirectUrl is a stringified array
tenant,
product,
name,
description,
clientID,
clientSecret,
certs,
@ -165,6 +196,152 @@ export class APIController implements IAPIController {
provider: idpMetadata.provider,
};
}
/**
* @swagger
*
* /api/v1/saml/config:
* patch:
* summary: Update SAML configuration
* operationId: update-saml-config
* tags: [SAML Config]
* consumes:
* - application/json
* - application/x-www-form-urlencoded
* parameters:
* - name: clientID
* description: Client ID for the config
* type: string
* in: formData
* required: true
* - name: clientSecret
* description: Client Secret for the config
* type: string
* in: formData
* required: true
* - name: name
* description: Name/identifier for the config
* type: string
* in: formData
* example: cal-saml-config
* - name: description
* description: A short description for the config not more than 100 characters
* type: string
* in: formData
* example: SAML login for cal.com app
* - name: encodedRawMetadata
* description: Base64 encoding of the XML metadata
* in: formData
* type: string
* - name: rawMetadata
* description: Raw XML metadata
* in: formData
* type: string
* - name: defaultRedirectUrl
* description: The redirect URL to use in the IdP login flow
* in: formData
* required: true
* type: string
* example: http://localhost:3000/login/saml
* - name: redirectUrl
* description: JSON encoded array containing a list of allowed redirect URLs
* in: formData
* required: true
* type: string
* example: '["http://localhost:3000/*"]'
* - name: tenant
* description: Tenant
* in: formData
* required: true
* type: string
* example: boxyhq.com
* - name: product
* description: Product
* in: formData
* required: true
* type: string
* example: demo
* responses:
* 204:
* description: Success
* 400:
* description: Please provide clientID | Please provide clientSecret | clientSecret mismatch | Tenant/Product config mismatch with IdP metadata | Description should not exceed 100 characters
* 401:
* description: Unauthorized
*/
public async updateConfig(body): Promise<void> {
const {
encodedRawMetadata, // could be empty
rawMetadata, // could be empty
defaultRedirectUrl,
redirectUrl,
name,
description,
...clientInfo
} = body;
if (!clientInfo?.clientID) {
throw new JacksonError('Please provide clientID', 400);
}
if (!clientInfo?.clientSecret) {
throw new JacksonError('Please provide clientSecret', 400);
}
if (description && description.length > 100) {
throw new JacksonError('Description should not exceed 100 characters', 400);
}
const _currentConfig = (await this.getConfig(clientInfo))?.config;
if (_currentConfig.clientSecret !== clientInfo?.clientSecret) {
throw new JacksonError('clientSecret mismatch', 400);
}
let metaData = rawMetadata;
if (encodedRawMetadata) {
metaData = Buffer.from(encodedRawMetadata, 'base64').toString();
}
let newMetadata;
if (metaData) {
newMetadata = await saml.parseMetadataAsync(metaData);
// extract provider
let providerName = extractHostName(newMetadata.entityID);
if (!providerName) {
providerName = extractHostName(newMetadata.sso.redirectUrl || newMetadata.sso.postUrl);
}
newMetadata.provider = providerName ? providerName : 'Unknown';
}
if (newMetadata) {
// check if clientID matches with new metadata payload
const clientID = dbutils.keyDigest(
dbutils.keyFromParts(clientInfo.tenant, clientInfo.product, newMetadata.entityID)
);
if (clientID !== clientInfo?.clientID) {
throw new JacksonError('Tenant/Product config mismatch with IdP metadata', 400);
}
}
await this.configStore.put(
clientInfo?.clientID,
{
..._currentConfig,
name: name ? name : _currentConfig.name,
description: description ? description : _currentConfig.description,
idpMetadata: newMetadata ? newMetadata : _currentConfig.idpMetadata,
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _currentConfig.defaultRedirectUrl,
redirectUrl: redirectUrl ? JSON.parse(redirectUrl) : _currentConfig.redirectUrl,
},
{
// secondary index on entityID
name: IndexNames.EntityID,
value: _currentConfig.idpMetadata.entityID,
},
{
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(_currentConfig.tenant, _currentConfig.product),
}
);
}
/**
* @swagger
@ -193,19 +370,39 @@ export class APIController implements IAPIController {
* description: Success
* schema:
* type: object
* properties:
* provider:
* type: string
* example:
* type: accounts.google.com
* {
* "config": {
* "idpMetadata": {
* "sso": {
* "postUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml",
* "redirectUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml"
* },
* "entityID": "http://www.okta.com/xxxxxxxxxxxxx",
* "thumbprint": "Eo+eUi3UM3XIMkFFtdVK3yJ5vO9f7YZdasdasdad",
* "loginType": "idp",
* "provider": "okta.com"
* },
* "defaultRedirectUrl": "https://hoppscotch.io/",
* "redirectUrl": ["https://hoppscotch.io/"],
* "tenant": "hoppscotch.io",
* "product": "API Engine",
* "name": "Hoppscotch-SP",
* "description": "SP for hoppscotch.io",
* "clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
* "clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
* "certs": {
* "publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
* "privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
* }
* }
* }
* '400':
* description: Please provide `clientID` or `tenant` and `product`.
* '401':
* description: Unauthorized
*/
public async getConfig(body: {
clientID: string;
tenant: string;
product: string;
}): Promise<Partial<OAuth>> {
public async getConfig(body: { clientID: string; tenant: string; product: string }): Promise<any> {
const { clientID, tenant, product } = body;
metrics.increment('getConfig');
@ -213,7 +410,7 @@ export class APIController implements IAPIController {
if (clientID) {
const samlConfig = await this.configStore.get(clientID);
return samlConfig ? { provider: samlConfig.idpMetadata.provider } : {};
return samlConfig ? { config: samlConfig } : {};
}
if (tenant && product) {
@ -226,7 +423,7 @@ export class APIController implements IAPIController {
return {};
}
return { provider: samlConfigs[0].idpMetadata.provider };
return { config: samlConfigs[0] };
}
throw new JacksonError('Please provide `clientID` or `tenant` and `product`.', 400);
@ -264,6 +461,8 @@ export class APIController implements IAPIController {
* responses:
* '200':
* description: Success
* '400':
* description: clientSecret mismatch | Please provide `clientID` and `clientSecret` or `tenant` and `product`.'
* '401':
* description: Unauthorized
*/
@ -287,7 +486,7 @@ export class APIController implements IAPIController {
if (samlConfig.clientSecret === clientSecret) {
await this.configStore.delete(clientID);
} else {
throw new JacksonError('clientSecret mismatch.', 400);
throw new JacksonError('clientSecret mismatch', 400);
}
return;

View File

@ -33,6 +33,14 @@ class DB implements DatabaseDriver {
return decrypt(res, this.encryptionKey);
}
async getAll(namespace): Promise<unknown[]> {
const res = (await this.db.getAll(namespace)) as Encrypted[];
const encryptionKey = this.encryptionKey;
return res.map((r) => {
return decrypt(r, encryptionKey);
});
}
async getByIndex(namespace: string, idx: Index): Promise<unknown[]> {
const res = await this.db.getByIndex(namespace, idx);
const encryptionKey = this.encryptionKey;

View File

@ -51,6 +51,19 @@ class Mem implements DatabaseDriver {
return null;
}
async getAll(namespace: string): Promise<unknown[]> {
const returnValue: string[] = [];
if (namespace) {
for (const key in this.store) {
if (key.startsWith(namespace)) {
returnValue.push(this.store[key]);
}
}
}
if (returnValue) return returnValue;
return [];
}
async getByIndex(namespace: string, idx: Index): Promise<any> {
const dbKeys = await this.indexes[dbutils.keyForIndex(namespace, idx)];
@ -67,6 +80,10 @@ class Mem implements DatabaseDriver {
this.store[k] = val;
if (!Date.parse(this.store['createdAt'])) this.store['createdAt'] = new Date().toISOString();
this.store['modifiedAt'] = new Date().toISOString();
// console.log(this.store)
if (ttl) {
this.ttlStore[k] = {
namespace,
@ -74,7 +91,6 @@ class Mem implements DatabaseDriver {
expiresAt: Date.now() + ttl * 1000,
};
}
// no ttl support for secondary indexes
for (const idx of indexes || []) {
const idxKey = dbutils.keyForIndex(namespace, idx);
@ -85,7 +101,6 @@ class Mem implements DatabaseDriver {
}
set.add(key);
const cleanupKey = dbutils.keyFromParts(dbutils.indexPrefix, k);
let cleanup = this.cleanup[cleanupKey];
if (!cleanup) {

View File

@ -1,27 +1,34 @@
import { MongoClient } from 'mongodb';
import { Collection, Db, MongoClient, UpdateOptions } from 'mongodb';
import { DatabaseDriver, DatabaseOption, Encrypted, Index } from '../typings';
import * as dbutils from './utils';
type Document = {
type _Document = {
value: Encrypted;
expiresAt: Date;
expiresAt?: Date;
modifiedAt: string;
indexes: string[];
};
class Mongo implements DatabaseDriver {
private options: DatabaseOption;
private client!: MongoClient;
private collection: any;
private db: any;
private collection!: Collection;
private db!: Db;
constructor(options: DatabaseOption) {
this.options = options;
}
async init(): Promise<Mongo> {
this.client = new MongoClient(this.options.url!);
await this.client.connect();
try {
if (!this.options.url) {
throw Error('Please specify a db url');
}
this.client = new MongoClient(this.options.url);
await this.client.connect();
} catch (err) {
console.error(`error connecting to ${this.options.type} db: ${err}`);
}
this.db = this.client.db();
this.collection = this.db.collection('jacksonStore');
@ -43,6 +50,14 @@ class Mongo implements DatabaseDriver {
return null;
}
async getAll(namespace: string): Promise<unknown[]> {
const _namespaceMatch = new RegExp(`^${namespace}:.*`);
const docs = await this.collection.find({ _id: _namespaceMatch }).toArray();
if (docs) return docs.map(({ value }) => value);
return [];
}
async getByIndex(namespace: string, idx: Index): Promise<any> {
const docs = await this.collection
.find({
@ -59,7 +74,7 @@ class Mongo implements DatabaseDriver {
}
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
const doc = <Document>{
const doc = <_Document>{
value: val,
};
@ -74,16 +89,19 @@ class Mongo implements DatabaseDriver {
if (!doc.indexes) {
doc.indexes = [];
}
doc.indexes.push(idxKey);
}
doc.modifiedAt = new Date().toISOString();
await this.collection.updateOne(
{ _id: dbutils.key(namespace, key) },
{
$set: doc,
$setOnInsert: {
createdAt: new Date().toISOString(),
},
},
{ upsert: true }
{ upsert: true } as UpdateOptions
);
}

View File

@ -36,6 +36,23 @@ class Redis implements DatabaseDriver {
return null;
}
async getAll(namespace: string): Promise<unknown[]> {
const keys = await this.client.sendCommand(['keys', namespace + ':*']);
const returnValue: string[] = [];
for (let i = 0; i < keys.length; i++) {
try {
if (this.client.get(keys[i])) {
const value = await this.client.get(keys[i]);
returnValue.push(JSON.parse(value));
}
} catch (error) {
console.error(error);
}
}
if (returnValue) return returnValue;
return [];
}
async getByIndex(namespace: string, idx: Index): Promise<any> {
const dbKeys = await this.client.sMembers(dbutils.keyForIndex(namespace, idx));

View File

@ -27,4 +27,17 @@ export class JacksonStore {
nullable: true,
})
tag?: string;
@Column({
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP',
nullable: false,
})
createdAt?: Date;
@Column({
type: 'timestamp',
nullable: true,
})
modifiedAt?: string;
}

View File

@ -3,7 +3,7 @@
require('reflect-metadata');
import { DatabaseDriver, DatabaseOption, Index, Encrypted } from '../../typings';
import { Connection, createConnection } from 'typeorm';
import { Connection, createConnection, Like } from 'typeorm';
import * as dbutils from '../utils';
import { JacksonStore } from './entity/JacksonStore';
@ -87,7 +87,7 @@ class Sql implements DatabaseDriver {
}
async get(namespace: string, key: string): Promise<any> {
let res = await this.storeRepository.findOne({
const res = await this.storeRepository.findOne({
key: dbutils.key(namespace, key),
});
@ -102,6 +102,21 @@ class Sql implements DatabaseDriver {
return null;
}
async getAll(namespace: string): Promise<unknown[]> {
const response = await this.storeRepository.find({
where: { key: Like(`%${namespace}%`) },
select: ['value', 'iv', 'tag'],
order: {
['createdAt']: 'DESC',
// ['createdAt']: 'ASC',
},
});
const returnValue = JSON.parse(JSON.stringify(response));
if (returnValue) return returnValue;
return [];
}
async getByIndex(namespace: string, idx: Index): Promise<any> {
const res = await this.indexRepository.find({
key: dbutils.keyForIndex(namespace, idx),
@ -122,13 +137,7 @@ class Sql implements DatabaseDriver {
return ret;
}
async put(
namespace: string,
key: string,
val: Encrypted,
ttl: number = 0,
...indexes: any[]
): Promise<void> {
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
await this.connection.transaction(async (transactionalEntityManager) => {
const dbKey = dbutils.key(namespace, key);
@ -137,7 +146,7 @@ class Sql implements DatabaseDriver {
store.value = val.value;
store.iv = val.iv;
store.tag = val.tag;
store.modifiedAt = new Date().toISOString();
await transactionalEntityManager.save(store);
if (ttl) {

View File

@ -16,6 +16,10 @@ class Store implements Storable {
return await this.db.get(this.namespace, dbutils.keyDigest(key));
}
async getAll(): Promise<unknown[]> {
return await this.db.getAll(this.namespace);
}
async getByIndex(idx: Index): Promise<any> {
idx.value = dbutils.keyDigest(idx.value);

View File

@ -1,5 +1,6 @@
import { APIController } from './controller/api';
import { OAuthController } from './controller/oauth';
import { AdminController } from './controller/admin';
import DB from './db/db';
import readConfig from './read-config';
import { JacksonOption } from './typings';
@ -38,6 +39,7 @@ export const controllers = async (
): Promise<{
apiController: APIController;
oauthController: OAuthController;
adminController: AdminController;
}> => {
opts = defaultOpts(opts);
@ -49,7 +51,7 @@ export const controllers = async (
const tokenStore = db.store('oauth:token', opts.db.ttl);
const apiController = new APIController({ configStore });
const adminController = new AdminController({ configStore });
const oauthController = new OAuthController({
configStore,
sessionStore,
@ -76,6 +78,7 @@ export const controllers = async (
return {
apiController,
oauthController,
adminController,
};
};

View File

@ -3,6 +3,8 @@ export type IdPConfig = {
redirectUrl: string;
tenant: string;
product: string;
name: string;
description: string;
rawMetadata?: string;
encodedRawMetadata?: string;
};
@ -15,7 +17,8 @@ export interface OAuth {
export interface IAPIController {
config(body: IdPConfig): Promise<OAuth>;
getConfig(body: { clientID?: string; tenant?: string; product?: string }): Promise<Partial<OAuth>>;
updateConfig(body: any): Promise<void>;
getConfig(body: { clientID?: string; tenant?: string; product?: string }): Promise<any>;
deleteConfig(body: {
clientID?: string;
clientSecret?: string;
@ -31,6 +34,10 @@ export interface IOAuthController {
userInfo(token: string): Promise<Profile>;
}
export interface IAdminController {
getAllConfig();
}
export interface OAuthReqBody {
response_type: 'code';
client_id: string;
@ -75,6 +82,7 @@ export interface Index {
}
export interface DatabaseDriver {
getAll(namespace: string): Promise<unknown[]>;
get(namespace: string, key: string): Promise<any>;
put(namespace: string, key: string, val: any, ttl: number, ...indexes: Index[]): Promise<any>;
delete(namespace: string, key: string): Promise<any>;
@ -82,6 +90,7 @@ export interface DatabaseDriver {
}
export interface Storable {
getAll(): Promise<unknown[]>;
get(key: string): Promise<any>;
put(key: string, val: any, ...indexes: Index[]): Promise<any>;
delete(key: string): Promise<any>;

View File

@ -143,11 +143,11 @@ tap.test('controller/api', async (t) => {
t.equal(response.client_id, CLIENT_ID);
t.equal(response.provider, PROVIDER);
const savedConf = await apiController.getConfig({
const { config: savedConfig } = await apiController.getConfig({
clientID: CLIENT_ID,
});
t.equal(savedConf.provider, PROVIDER);
t.equal(savedConfig.name, 'testConfig');
kdStub.restore();
@ -157,15 +157,67 @@ tap.test('controller/api', async (t) => {
t.end();
});
t.test('Update the config', async (t) => {
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
t.test('When clientID is missing', async (t) => {
const { client_secret: clientSecret } = await apiController.config(body as IdPConfig);
try {
await apiController.updateConfig({ description: 'A new description', clientSecret });
t.fail('Expecting JacksonError.');
} catch (err: any) {
t.equal(err.message, 'Please provide clientID');
t.equal(err.statusCode, 400);
t.end();
}
});
t.test('When clientSecret is missing', async (t) => {
const { client_id: clientID } = await apiController.config(body as IdPConfig);
try {
await apiController.updateConfig({ description: 'A new description', clientID });
t.fail('Expecting JacksonError.');
} catch (err: any) {
t.equal(err.message, 'Please provide clientSecret');
t.equal(err.statusCode, 400);
t.end();
}
});
t.test('Update the name/description', async (t) => {
const { client_id: clientID, client_secret: clientSecret } = await apiController.config(
body as IdPConfig
);
const {
config: { name, description },
} = await apiController.getConfig({ clientID });
t.equal(name, 'testConfig');
t.equal(description, 'Just a test configuration');
await apiController.updateConfig({
clientID,
clientSecret,
name: 'A new name',
description: 'A new description',
});
const { config: savedConfig } = await apiController.getConfig({ clientID });
t.equal(savedConfig.name, 'A new name');
t.equal(savedConfig.description, 'A new description');
t.end();
});
t.end();
});
t.test('Get the config', async (t) => {
t.test('when valid request', async (t) => {
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
await apiController.config(body as IdPConfig);
const { provider } = await apiController.getConfig(body);
const { config: savedConfig } = await apiController.getConfig(body);
t.equal(provider, PROVIDER);
t.equal(savedConfig.name, 'testConfig');
t.end();
});
@ -248,7 +300,7 @@ tap.test('controller/api', async (t) => {
t.fail('Expecting Error.');
} catch (err: any) {
t.match(err.message, 'clientSecret mismatch.');
t.match(err.message, 'clientSecret mismatch');
}
t.end();

View File

@ -3,4 +3,6 @@ module.exports = {
redirectUrl: '["http://localhost:3366"]',
tenant: 'boxyhq.com',
product: 'crm',
name: 'testConfig',
description: 'Just a test configuration',
};

View File

@ -4,8 +4,8 @@ import DB from '../src/db/db';
const encryptionKey: EncryptionKey = '3yGrTcnKPBqqHoH3zZMAU6nt4bmIYb2q';
let configStores: Storable[] = [];
let ttlStores: Storable[] = [];
const configStores: Storable[] = [];
const ttlStores: Storable[] = [];
const ttl = 3;
const record1 = {

View File

@ -33,6 +33,7 @@ const options = <JacksonOption>{
};
const samlConfig = {
name: 'testConfig',
tenant: 'boxyhq.com',
product: 'crm',
redirectUrl: '["http://localhost:3366/*"]',

3914
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "jackson",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"description": "SAML 2.0 service",
"keywords": [
@ -26,7 +26,10 @@
"mariadb": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=sql DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql npm run dev",
"start": "next start -p 5225",
"swagger-jsdoc": "swagger-jsdoc -d swagger/swaggerDefinition.js npm/src/**/*.ts -o swagger/swagger.json arg",
"prepare": "husky install"
"redis": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=redis DB_TYPE=redis DB_URL=redis://localhost:6379/redis npm run dev",
"prepare": "husky install",
"pretest:e2e": "env-cmd -f .env.test.local ts-node --log-error e2e/seedAuthDb.ts",
"test:e2e": "env-cmd -f .env.test.local playwright test"
},
"husky": {
"hooks": {
@ -39,30 +42,45 @@
},
"dependencies": {
"@boxyhq/saml-jackson": "file:./npm",
"@heroicons/react": "1.0.5",
"@opentelemetry/api-metrics": "0.27.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.27.0",
"@opentelemetry/sdk-metrics-base": "0.27.0",
"@supabase/ui": "0.36.3",
"cors": "2.8.5",
"micromatch": "4.0.4",
"next": "12.1.0",
"next-auth": "4.1.2",
"nodemailer": "6.7.2",
"react": "17.0.2",
"react-dom": "17.0.2",
"swr": "1.2.1",
"webpack-filter-warnings-plugin": "1.2.1"
},
"devDependencies": {
"@apidevtools/swagger-cli": "4.0.4",
"@babel/plugin-proposal-decorators": "7.17.2",
"@playwright/test": "1.18.1",
"@types/cors": "2.8.12",
"@types/micromatch": "4.0.2",
"@types/node": "17.0.18",
"@types/react": "17.0.39",
"@typescript-eslint/eslint-plugin": "5.11.0",
"@typescript-eslint/parser": "5.11.0",
"autoprefixer": "10.4.2",
"cross-env": "7.0.3",
"env-cmd": "10.1.0",
"eslint": "8.9.0",
"eslint-config-next": "12.0.10",
"eslint-config-prettier": "8.3.0",
"husky": "7.0.4",
"lint-staged": "12.3.4",
"postcss": "8.4.6",
"prettier": "2.5.1",
"swagger-jsdoc": "6.1.0",
"tailwindcss": "3.0.22",
"ts-node": "10.5.0",
"tsconfig-paths": "3.12.0",
"typescript": "4.5.5"
},
"engines": {

View File

@ -1,8 +1,16 @@
import '../styles/globals.css';
import Layout from '@components/Layout';
import { SessionProvider } from 'next-auth/react';
import type { AppProps } from 'next/app';
import '../styles/globals.css';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<SessionProvider session={session}>
<Layout>
<Component {...pageProps} />
</Layout>
</SessionProvider>
);
}
export default MyApp;

17
pages/_document.tsx Normal file
View File

@ -0,0 +1,17 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
class MyDocument extends Document {
render() {
return (
<Html lang='en' className='h-full'>
<Head />
<body className='antialiased theme-default h-full bg-white dark:bg-gray-900 selection:bg-primary/20 selection:text-secondary'>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;

View File

@ -0,0 +1,28 @@
import { NextPage } from 'next';
import useSWR from 'swr';
import { fetcher } from '@lib/utils';
import AddEdit from '@components/saml/AddEdit';
import { useRouter } from 'next/router';
const EditSAMLConfiguration: NextPage = () => {
const router = useRouter();
const { id } = router.query;
const { data: samlConfig, error } = useSWR(`/api/admin/saml/config/${id}`, fetcher, {
revalidateOnFocus: false,
});
if (error) {
return (
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>
{error.info ? JSON.stringify(error.info) : error.status}
</div>
);
}
if (!samlConfig) {
return <div>Loading...</div>;
}
return <AddEdit samlConfig={samlConfig?.config} />;
};
export default EditSAMLConfiguration;

View File

@ -0,0 +1,85 @@
import { NextPage } from 'next';
import useSWR from 'swr';
import { fetcher } from '@lib/utils';
import Link from 'next/link';
import { PencilAltIcon } from '@heroicons/react/outline';
const SAMLConfigurations: NextPage = () => {
const { data, error } = useSWR('/api/admin/saml/config', fetcher, { revalidateOnFocus: false });
if (error) {
return (
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>
{error.info ? JSON.stringify(error.info) : error.status}
</div>
);
}
if (!data) {
return <div>Loading...</div>;
}
if (!Array.isArray(data)) {
return (
<div>
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>Nothing to show</div>
</div>
);
}
return (
<div>
<div className='flex items-center justify-between'>
<h2 className='md:text-2xl text-primary dark:text-white font-bold'>SAML Configurations</h2>
<Link href={'/admin/saml/config/new'}>
<a className='btn-primary'>
<span className='inline-block mr-1 md:mr-2' aria-hidden>
+
</span>
Add Configuration
</a>
</Link>
</div>
<div className='overflow-auto shadow-md rounded-lg mt-6'>
<table className='min-w-full'>
<thead className='bg-gray-50 dark:bg-gray-700 shadow-md sm:rounded-lg'>
<tr>
<th
scope='col'
className='py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400'>
Tenant
</th>
<th
scope='col'
className='py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400'>
Product
</th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((provider) => (
<tr key={provider.clientID} className='bg-white border-b dark:bg-gray-800 dark:border-gray-700'>
<td className='py-4 px-6 text-sm font-medium text-gray-900 whitespace-nowrap dark:text-white'>
{provider.tenant}
</td>
<td className='py-4 px-6 text-sm text-gray-500 whitespace-nowrap dark:text-gray-400'>
{provider.product}
</td>
<td>
<Link href={`/admin/saml/config/edit/${provider.clientID}`}>
<a className='link-primary'>
<PencilAltIcon className='h-5 w-5 text-secondary' />
</a>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default SAMLConfigurations;

View File

@ -0,0 +1,8 @@
import { NextPage } from 'next';
import AddEdit from '@components/saml/AddEdit';
const NewSAMLConfiguration: NextPage = () => {
return <AddEdit />;
};
export default NewSAMLConfiguration;

View File

@ -0,0 +1,40 @@
import { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
import { extractAuthToken, validateApiKey } from '@lib/utils';
import { checkSession } from '@lib/middleware';
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const apiKey = extractAuthToken(req);
if (!validateApiKey(apiKey)) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const { adminController, apiController } = await jackson();
if (req.method === 'GET') {
const { slug } = req.query;
if (slug?.[0]) {
res.json(await apiController.getConfig({ clientID: slug[0] }));
} else {
res.json(await adminController.getAllConfig());
}
} else if (req.method === 'POST') {
res.json(await apiController.config(req.body));
} else if (req.method === 'PATCH') {
res.status(204).send(await apiController.updateConfig(req.body));
} else if (req.method === 'DELETE') {
res.json(await apiController.deleteConfig(req.body));
} else {
throw new Error('Method not allowed');
}
} catch (err: any) {
console.error('config api error:', err);
const { message, statusCode = 500 } = err;
res.status(statusCode).send(message);
}
};
export default checkSession(handler);

View File

@ -0,0 +1,44 @@
import Adapter from '@lib/nextAuthAdapter';
import NextAuth from 'next-auth';
import EmailProvider from 'next-auth/providers/email';
import { validateEmailWithACL } from '@lib/utils';
export default NextAuth({
debug: true,
theme: {
colorScheme: 'light',
},
providers: [
EmailProvider({
server: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
},
from: process.env.SMTP_FROM,
}),
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60,
},
jwt: {
maxAge: 60 * 60 * 24 * 30,
},
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async signIn({ user }): Promise<boolean> {
if (!user.email) {
return false;
}
const email = user.email;
return validateEmailWithACL(email);
},
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
adapter: Adapter(),
});

View File

@ -15,6 +15,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.json(await apiController.config(req.body));
} else if (req.method === 'GET') {
res.json(await apiController.getConfig(req.query as any));
} else if (req.method === 'PATCH') {
res.status(204).send(await apiController.updateConfig(req.body));
} else if (req.method === 'DELETE') {
res.status(204).end(await apiController.deleteConfig(req.body));
} else {

View File

@ -1,64 +1,15 @@
import type { NextPage } from 'next';
import Head from 'next/head';
import Image from 'next/image';
import styles from '../styles/Home.module.css';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
const Home: NextPage = () => {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<meta name='description' content='Generated by create next app' />
<link rel='icon' href='/favicon.ico' />
</Head>
const router = useRouter();
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href='https://nextjs.org'>Next.js!</a>
</h1>
useEffect(() => {
router.push('/admin/saml/config');
}, [router]);
<p className={styles.description}>
Get started by editing <code className={styles.code}>pages/index.tsx</code>
</p>
<div className={styles.grid}>
<a href='https://nextjs.org/docs' className={styles.card}>
<h2>Documentation &rarr;</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href='https://nextjs.org/learn' className={styles.card}>
<h2>Learn &rarr;</h2>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a href='https://github.com/vercel/next.js/tree/master/examples' className={styles.card}>
<h2>Examples &rarr;</h2>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href='https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app'
className={styles.card}>
<h2>Deploy &rarr;</h2>
<p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
</a>
</div>
</main>
<footer className={styles.footer}>
<a
href='https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app'
target='_blank'
rel='noopener noreferrer'>
Powered by{' '}
<span className={styles.logo}>
<Image src='/vercel.svg' alt='Vercel Logo' width={72} height={16} />
</span>
</a>
</footer>
</div>
);
return <p>Redirecting...</p>;
};
export default Home;

72
playwright.config.ts Normal file
View File

@ -0,0 +1,72 @@
import { PlaywrightTestConfig, devices } from '@playwright/test';
import path from 'path';
// Reference: https://playwright.dev/docs/test-configuration
const config: PlaywrightTestConfig = {
globalSetup: require.resolve('./e2e/globalSetup'),
// Timeout per test
timeout: 30 * 1000,
// Test directory
testDir: path.join(__dirname, 'e2e'),
// If a test fails, retry it additional 2 times
retries: 2,
// Artifacts folder where screenshots, videos, and traces are stored.
outputDir: 'test-results/',
// Run your local dev server before starting the tests:
// https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests
webServer: {
command: process.env.CI ? 'npm run start' : 'npm run postgres',
port: 5225,
timeout: 60 * 1000,
reuseExistingServer: !process.env.CI,
},
use: {
// Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc.
// More information: https://playwright.dev/docs/trace-viewer
trace: 'retry-with-trace',
storageState: './e2e/state.json',
headless: !!process.env.CI,
// All available context options: https://playwright.dev/docs/api/class-browser#browser-new-context
// contextOptions: {
// ignoreHTTPSErrors: true,
// },
},
projects: [
{
name: 'Desktop Chrome',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:5225',
storageState: './e2e/state.json',
},
},
// {
// name: 'Desktop Firefox',
// use: {
// ...devices['Desktop Firefox'],
// },
// },
// {
// name: 'Desktop Safari',
// use: {
// ...devices['Desktop Safari'],
// },
// },
// Test against mobile viewports.
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: devices['iPhone 12'],
// },
],
};
export default config;

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,116 +0,0 @@
.container {
padding: 0 2rem;
}
.main {
min-height: 100vh;
padding: 4rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
display: flex;
flex: 1;
padding: 2rem 0;
border-top: 1px solid #eaeaea;
justify-content: center;
align-items: center;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
flex-grow: 1;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
margin: 4rem 0;
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
}
.card {
margin: 1rem;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
max-width: 300px;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
margin-left: 0.5rem;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

View File

@ -1,9 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark light;
}
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans,
Droid Sans, Helvetica Neue, sans-serif;
}
a {
@ -14,3 +22,20 @@ a {
* {
box-sizing: border-box;
}
.theme-default {
--color-primary: 37 194 160;
--color-secondary: 48 56 70;
}
@layer components {
#__next {
@apply h-full;
}
.btn-primary {
@apply py-2 px-4 bg-primary/70 text-white font-semibold rounded-lg shadow-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-75;
}
.link-primary {
@apply flex items-center px-4 py-2 mt-2 md:text-sm md:leading-6 text-secondary/90 font-medium hover:font-bold dark:text-gray-400 dark:hover:text-gray-300;
}
}

View File

@ -48,11 +48,30 @@
"application/x-www-form-urlencoded"
],
"parameters": [
{
"name": "name",
"description": "Name/identifier for the config",
"type": "string",
"in": "formData",
"example": "cal-saml-config"
},
{
"name": "description",
"description": "A short description for the config not more than 100 characters",
"type": "string",
"in": "formData",
"example": "SAML login for cal.com app"
},
{
"name": "encodedRawMetadata",
"description": "Base64 encoding of the XML metadata",
"in": "formData",
"required": true,
"type": "string"
},
{
"name": "rawMetadata",
"description": "Raw XML metadata",
"in": "formData",
"type": "string"
},
{
@ -107,6 +126,105 @@
}
}
},
"400": {
"description": "Please provide rawMetadata or encodedRawMetadata | Please provide a defaultRedirectUrl | Please provide redirectUrl | Please provide tenant | Please provide product | Please provide a friendly name | Description should not exceed 100 characters"
},
"401": {
"description": "Unauthorized"
}
}
},
"patch": {
"summary": "Update SAML configuration",
"operationId": "update-saml-config",
"tags": [
"SAML Config"
],
"consumes": [
"application/json",
"application/x-www-form-urlencoded"
],
"parameters": [
{
"name": "clientID",
"description": "Client ID for the config",
"type": "string",
"in": "formData",
"required": true
},
{
"name": "clientSecret",
"description": "Client Secret for the config",
"type": "string",
"in": "formData",
"required": true
},
{
"name": "name",
"description": "Name/identifier for the config",
"type": "string",
"in": "formData",
"example": "cal-saml-config"
},
{
"name": "description",
"description": "A short description for the config not more than 100 characters",
"type": "string",
"in": "formData",
"example": "SAML login for cal.com app"
},
{
"name": "encodedRawMetadata",
"description": "Base64 encoding of the XML metadata",
"in": "formData",
"type": "string"
},
{
"name": "rawMetadata",
"description": "Raw XML metadata",
"in": "formData",
"type": "string"
},
{
"name": "defaultRedirectUrl",
"description": "The redirect URL to use in the IdP login flow",
"in": "formData",
"required": true,
"type": "string",
"example": "http://localhost:3000/login/saml"
},
{
"name": "redirectUrl",
"description": "JSON encoded array containing a list of allowed redirect URLs",
"in": "formData",
"required": true,
"type": "string",
"example": "[\"http://localhost:3000/*\"]"
},
{
"name": "tenant",
"description": "Tenant",
"in": "formData",
"required": true,
"type": "string",
"example": "boxyhq.com"
},
{
"name": "product",
"description": "Product",
"in": "formData",
"required": true,
"type": "string",
"example": "demo"
}
],
"responses": {
"204": {
"description": "Success"
},
"400": {
"description": "Please provide clientID | Please provide clientSecret | clientSecret mismatch | Tenant/Product config mismatch with IdP metadata | Description should not exceed 100 characters"
},
"401": {
"description": "Unauthorized"
}
@ -143,16 +261,39 @@
"description": "Success",
"schema": {
"type": "object",
"properties": {
"provider": {
"type": "string"
}
},
"example": {
"type": "accounts.google.com"
"config": {
"idpMetadata": {
"sso": {
"postUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml",
"redirectUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml"
},
"entityID": "http://www.okta.com/xxxxxxxxxxxxx",
"thumbprint": "Eo+eUi3UM3XIMkFFtdVK3yJ5vO9f7YZdasdasdad",
"loginType": "idp",
"provider": "okta.com"
},
"defaultRedirectUrl": "https://hoppscotch.io/",
"redirectUrl": [
"https://hoppscotch.io/"
],
"tenant": "hoppscotch.io",
"product": "API Engine",
"name": "Hoppscotch-SP",
"description": "SP for hoppscotch.io",
"clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
"clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
"certs": {
"publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
"privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
}
}
}
}
},
"400": {
"description": "Please provide `clientID` or `tenant` and `product`."
},
"401": {
"description": "Unauthorized"
}
@ -199,6 +340,9 @@
"200": {
"description": "Success"
},
"400": {
"description": "clientSecret mismatch | Please provide `clientID` and `clientSecret` or `tenant` and `product`.'"
},
"401": {
"description": "Unauthorized"
}

24
tailwind.config.js Normal file
View File

@ -0,0 +1,24 @@
const colors = require('tailwindcss/colors');
function withOpacityValue(variable) {
return ({ opacityValue }) => {
if (opacityValue === undefined) {
return `rgb(var(${variable}))`;
}
return `rgb(var(${variable}) / ${opacityValue})`;
};
}
module.exports = {
darkMode: 'class',
content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
colors: {
...colors,
primary: withOpacityValue('--color-primary'),
secondary: withOpacityValue('--color-secondary'),
},
extend: {},
},
plugins: [],
};

View File

@ -24,5 +24,11 @@
}
},
"include": ["next-env.d.ts", "types/*.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"ts-node": {
"compilerOptions": {
"module": "CommonJS"
},
"require": ["tsconfig-paths/register"]
}
}