diff --git a/.env.example b/.env.example index 732c8a2b..c171d4da 100644 --- a/.env.example +++ b/.env.example @@ -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_= 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} diff --git a/lib/env.ts b/lib/env.ts index d7c9281e..b1a3da62 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -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_= env vars. +// Values may contain {id}, {email}, {firstName}, {lastName} placeholders. +const extraAttributes: Record = {}; +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; diff --git a/next.config.js b/next.config.js index c17d62e5..9c28905c 100644 --- a/next.config.js +++ b/next.config.js @@ -2,6 +2,7 @@ module.exports = { reactStrictMode: true, output: 'standalone', + outputFileTracingRoot: __dirname, webpack: (config, { isServer }) => { if (!isServer) { config.resolve.fallback = { diff --git a/pages/api/saml/auth.ts b/pages/api/saml/auth.ts index 55244a6b..c9501639 100644 --- a/pages/api/saml/auth.ts +++ b/pages/api/saml/auth.ts @@ -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); +} + 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'); @@ -23,6 +32,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) lastName: userName, }; + const extraClaims: Record = {}; + 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); + } + } 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, @@ -30,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) requestId: id, claims: { email: user.email, - raw: user, + raw: { ...user, ...extraClaims }, }, privateKey: config.privateKey, publicKey: config.publicKey, diff --git a/pages/namespace/[namespace]/saml/login.tsx b/pages/namespace/[namespace]/saml/login.tsx index ac18cda9..bd5a3154 100644 --- a/pages/namespace/[namespace]/saml/login.tsx +++ b/pages/namespace/[namespace]/saml/login.tsx @@ -1,3 +1 @@ -import Login from '../../../saml/login'; - -export default Login; +export { default, getServerSideProps } from '../../../saml/login'; diff --git a/pages/saml/login.tsx b/pages/saml/login.tsx index ca84002d..f6ddcb37 100644 --- a/pages/saml/login.tsx +++ b/pages/saml/login.tsx @@ -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[]; + 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( + defaultAttributes.map((a, i) => ({ ...a, key: i })) + ); + const [newAttr, setNewAttr] = useState({ name: '', value: '' }); const acsUrlInp = useRef(null); const emailInp = useRef(null); @@ -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) => { 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, }), }); @@ -60,6 +92,10 @@ export default function Login() { } }; + const inputBase = + '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 ( <> @@ -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} />

This is where we will post the SAML Response @@ -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} /> @@ -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} /> @@ -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}> @@ -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} />

Any password works

+ {/* Attributes section */} +
+ + + {attributes.map((attr, i) => ( +
+ handleAttrChange(i, 'name', e.target.value)} + placeholder='name' + className={`w-2/5 ${inputBase}`} + /> + handleAttrChange(i, 'value', e.target.value)} + placeholder='value' + className={`flex-1 ${inputBase}`} + /> + +
+ ))} + + {/* Add row */} +
+ setNewAttr({ ...newAttr, name: e.target.value })} + placeholder='name' + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAttrAdd(); + } + }} + className={`w-2/5 ${inputBase}`} + /> + setNewAttr({ ...newAttr, value: e.target.value })} + placeholder='value' + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAttrAdd(); + } + }} + className={`flex-1 ${inputBase}`} + /> + +
+
+