Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,15 @@ PRIVATE_KEY=

# Google Tag Manager ID. Leave as blank because removing this causes next.js build to fail.
NEXT_PUBLIC_GTM_ID=""

# Default audience (SP entity ID) pre-filled on the login form (default: https://saml.boxyhq.com)
# SAML_AUDIENCE=https://saml.boxyhq.com

# Extra SAML attributes to include in the response.
# Set SAML_ATTRIBUTE_<name>=<value> for each attribute.
# Values support placeholders: {id}, {email}, {firstName}, {lastName}
# Examples:
# SAML_ATTRIBUTE_role=admin
# SAML_ATTRIBUTE_department=engineering
# SAML_ATTRIBUTE_uid={id}
# SAML_ATTRIBUTE_displayName={firstName} {lastName}
15 changes: 15 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,29 @@ const appUrl =
`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` ||
'http://localhost:4000';
const entityId = process.env.ENTITY_ID || 'https://saml.example.com/entityid';
const audience = process.env.SAML_AUDIENCE || 'https://saml.boxyhq.com';
const privateKey = fetchPrivateKey();
const publicKey = fetchPublicKey();

// Extra SAML attributes from SAML_ATTRIBUTE_<name>=<value> env vars.
// Values may contain {id}, {email}, {firstName}, {lastName} placeholders.
const extraAttributes: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('SAML_ATTRIBUTE_') && value !== undefined) {
const attrName = key.slice('SAML_ATTRIBUTE_'.length);
if (attrName) {
extraAttributes[attrName] = value;
}
}
}

const config = {
appUrl,
entityId,
audience,
privateKey,
publicKey,
extraAttributes,
};

export default config;
1 change: 1 addition & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
module.exports = {
reactStrictMode: true,
output: 'standalone',
outputFileTracingRoot: __dirname,
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
Expand Down
27 changes: 25 additions & 2 deletions pages/api/saml/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@ import type { User } from 'types';
import saml from '@boxyhq/saml20';
import { getEntityId } from 'lib/entity-id';

function resolveTemplate(template: string, user: User): string {
return template
.replace(/\{id\}/g, () => user.id)
.replace(/\{email\}/g, () => user.email)
.replace(/\{firstName\}/g, () => user.firstName)
.replace(/\{lastName\}/g, () => user.lastName);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { email, audience, acsUrl, id, relayState } = req.body;
const { email, audience, acsUrl, id, relayState, attributes } = req.body;

if (!email.endsWith('@example.com') && !email.endsWith('@example.org')) {
res.status(403).send(`${email} denied access`);
return;
}

const userId = createHash('sha256').update(email).digest('hex');
Expand All @@ -23,14 +32,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
lastName: userName,
};

const extraClaims: Record<string, string> = {};
if (Array.isArray(attributes)) {
for (const entry of attributes) {
if (!entry || typeof entry !== 'object') continue;
const { name, value } = entry as { name?: unknown; value?: unknown };
if (typeof name === 'string' && name)
extraClaims[name] = resolveTemplate(typeof value === 'string' ? value : '', user);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
for (const [name, template] of Object.entries(config.extraAttributes)) {
extraClaims[name] = resolveTemplate(template, user);
}
}

const xmlSigned = await saml.createSAMLResponse({
issuer: getEntityId(config.entityId, req.query.namespace as any),
audience,
acsUrl,
requestId: id,
claims: {
email: user.email,
raw: user,
raw: { ...user, ...extraClaims },
},
privateKey: config.privateKey,
publicKey: config.publicKey,
Expand Down
4 changes: 1 addition & 3 deletions pages/namespace/[namespace]/saml/login.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
import Login from '../../../saml/login';

export default Login;
export { default, getServerSideProps } from '../../../saml/login';
128 changes: 120 additions & 8 deletions pages/saml/login.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import type { GetServerSideProps } from 'next';
import type { FormEvent } from 'react';
import { useEffect, useRef, useState } from 'react';

export default function Login() {
type Attribute = { key: number; name: string; value: string };

type Props = {
defaultAttributes: Omit<Attribute, 'key'>[];
defaultAudience: string;
};


export default function Login({ defaultAttributes, defaultAudience }: Props) {
const router = useRouter();
const { id, audience, acsUrl, providerName, relayState, namespace } = router.query;

const authUrl = namespace ? `/api/namespace/${namespace}/saml/auth` : '/api/saml/auth';
const nextKey = useRef(defaultAttributes.length);
const [state, setState] = useState({
username: 'jackson',
domain: 'example.com',
acsUrl: 'https://sso.eu.boxyhq.com/api/oauth/saml',
audience: 'https://saml.boxyhq.com',
audience: defaultAudience,
});
const [attributes, setAttributes] = useState<Attribute[]>(
defaultAttributes.map((a, i) => ({ ...a, key: i }))
);
const [newAttr, setNewAttr] = useState({ name: '', value: '' });

const acsUrlInp = useRef<HTMLInputElement>(null);
const emailInp = useRef<HTMLInputElement>(null);
Expand All @@ -33,21 +47,39 @@ export default function Login() {
setState({ ...state, [name]: value });
};

const handleAttrChange = (index: number, field: 'name' | 'value', value: string) => {
setAttributes((prev) => prev.map((a, i) => (i === index ? { ...a, [field]: value } : a)));
};

const handleAttrRemove = (index: number) => {
setAttributes((prev) => prev.filter((_, i) => i !== index));
};

const handleAttrAdd = () => {
if (!newAttr.name) return;
setAttributes((prev) => [...prev, { ...newAttr, key: nextKey.current++ }]);
setNewAttr({ name: '', value: '' });
};

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();

const { username, domain } = state;
const email = `${username}@${domain}`;

const resolvedAttributes = attributes.map(({ name, value }) => ({ name, value }));

const response = await fetch(authUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `${username}@${domain}`,
email,
id,
audience: audience || state.audience,
acsUrl: acsUrl || state.acsUrl,
providerName,
relayState,
attributes: resolvedAttributes,
}),
});

Expand All @@ -60,6 +92,10 @@ export default function Login() {
}
};

const inputBase =
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not completely required for this PR, but since classes were being added for the attributes, a little DRYing was in order.

'rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-primary/30';
const inputClass = `w-full ${inputBase}`;

return (
<>
<Head>
Expand Down Expand Up @@ -89,7 +125,7 @@ export default function Login() {
value={state.acsUrl}
onChange={handleChange}
placeholder='https://sso.eu.boxyhq.com/api/oauth/saml'
className='w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-primary/30'
className={inputClass}
/>
<p className='mt-1 text-xs text-gray-500'>
This is where we will post the SAML Response
Expand All @@ -106,7 +142,7 @@ export default function Login() {
value={state.audience}
onChange={handleChange}
placeholder='https://saml.boxyhq.com'
className='w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-primary/30'
className={inputClass}
/>
</div>
</div>
Expand All @@ -123,7 +159,7 @@ export default function Login() {
value={state.username}
onChange={handleChange}
placeholder='jackson'
className='w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-primary/30'
className={inputClass}
/>
</div>

Expand All @@ -134,7 +170,7 @@ export default function Login() {
id='domain'
value={state.domain}
onChange={handleChange}
className='w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-primary/30'>
className={inputClass}>
<option value='example.com'>@example.com</option>
<option value='example.org'>@example.org</option>
</select>
Expand All @@ -147,11 +183,78 @@ export default function Login() {
type='password'
autoComplete='off'
defaultValue='samlstrongpassword'
className='w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-2 focus:ring-primary/30'
className={inputClass}
/>
<p className='mt-1 text-xs text-gray-500'>Any password works</p>
</div>

{/* Attributes section */}
<div className='col-span-2 space-y-2'>
<label className='block text-sm font-medium text-gray-700'>Attributes</label>

{attributes.map((attr, i) => (
<div key={attr.key} className='flex gap-2 items-center'>
<input
type='text'
value={attr.name}
onChange={(e) => handleAttrChange(i, 'name', e.target.value)}
placeholder='name'
className={`w-2/5 ${inputBase}`}
/>
<input
type='text'
value={attr.value}
onChange={(e) => handleAttrChange(i, 'value', e.target.value)}
placeholder='value'
className={`flex-1 ${inputBase}`}
/>
<button
type='button'
onClick={() => handleAttrRemove(i)}
className='shrink-0 rounded-md border border-gray-300 px-2 py-2 text-sm text-gray-500 hover:bg-gray-100'>
</button>
</div>
))}

{/* Add row */}
<div className='flex gap-2 items-center'>
<input
type='text'
value={newAttr.name}
onChange={(e) => setNewAttr({ ...newAttr, name: e.target.value })}
placeholder='name'
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAttrAdd();
}
}}
className={`w-2/5 ${inputBase}`}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<input
type='text'
value={newAttr.value}
onChange={(e) => setNewAttr({ ...newAttr, value: e.target.value })}
placeholder='value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAttrAdd();
}
}}
className={`flex-1 ${inputBase}`}
/>
<button
type='button'
onClick={handleAttrAdd}
disabled={!newAttr.name}
className='shrink-0 rounded-md border border-gray-300 px-2 py-2 text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40'>
+
</button>
</div>
</div>

<button
type='submit'
className='col-span-2 mt-2 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/40'>
Expand All @@ -174,3 +277,12 @@ export default function Login() {
</>
);
}

export const getServerSideProps: GetServerSideProps<Props> = async () => {
const { default: config } = await import('lib/env');
const defaultAttributes = Object.entries(config.extraAttributes).map(([name, value]) => ({
name,
value,
}));
return { props: { defaultAttributes, defaultAudience: config.audience } };
};