From 0334c10fce17825abacf09981f2257b833b2cc1b Mon Sep 17 00:00:00 2001 From: louisremi Date: Wed, 17 Jun 2026 15:29:21 +0200 Subject: [PATCH 1/2] Make SAML email domains configurable via AVAILABLE_DOMAINS Allow operators to configure the email domains accepted for mock SAML login through a comma-separated AVAILABLE_DOMAINS env var, instead of the hardcoded example.com/example.org. Defaults to example.com,example.org when unset, so behavior is unchanged out of the box. - lib/env.ts: parse and expose availableDomains on config - pages/api/saml/auth.ts: validate against availableDomains; also return on the 403 so a SAML response is no longer generated for denied emails - pages/saml/login.tsx: pass domains via getServerSideProps and drive the dropdown options, default selection, and info text from the list - pages/namespace/[namespace]/saml/login.tsx: re-export getServerSideProps - .env.example: document the new variable Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 4 +++ lib/env.ts | 5 +++ pages/api/saml/auth.ts | 5 +-- pages/namespace/[namespace]/saml/login.tsx | 2 ++ pages/saml/login.tsx | 37 ++++++++++++++++++---- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 732c8a2b..21b6a12d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ APP_URL=http://localhost:4000 ENTITY_ID=https://saml.example.com/entityid +# Comma-separated list of email domains allowed for SAML login. +# Defaults to example.com,example.org when unset. +AVAILABLE_DOMAINS=example.com,example.org + # Generate a private/public key combination, this is needed for signing the SAML AuthnRequest # openssl req -x509 -newkey rsa:2048 -keyout key.pem -out public.crt -sha256 -days 365000 -nodes # Base64 encoded value of public key `cat public.crt | base64` diff --git a/lib/env.ts b/lib/env.ts index d7c9281e..108c3380 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -6,12 +6,17 @@ const appUrl = `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` || 'http://localhost:4000'; const entityId = process.env.ENTITY_ID || 'https://saml.example.com/entityid'; +const availableDomains = (process.env.AVAILABLE_DOMAINS || 'example.com,example.org') + .split(',') + .map((d: string) => d.trim()) + .filter(Boolean); const privateKey = fetchPrivateKey(); const publicKey = fetchPublicKey(); const config = { appUrl, entityId, + availableDomains, privateKey, publicKey, }; diff --git a/pages/api/saml/auth.ts b/pages/api/saml/auth.ts index 55244a6b..0fdf548f 100644 --- a/pages/api/saml/auth.ts +++ b/pages/api/saml/auth.ts @@ -9,8 +9,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method === 'POST') { const { email, audience, acsUrl, id, relayState } = req.body; - if (!email.endsWith('@example.com') && !email.endsWith('@example.org')) { - res.status(403).send(`${email} denied access`); + const domain = email.split('@')[1]; + if (!config.availableDomains.includes(domain)) { + return res.status(403).send(`${email} denied access`); } const userId = createHash('sha256').update(email).digest('hex'); diff --git a/pages/namespace/[namespace]/saml/login.tsx b/pages/namespace/[namespace]/saml/login.tsx index ac18cda9..409bd8bf 100644 --- a/pages/namespace/[namespace]/saml/login.tsx +++ b/pages/namespace/[namespace]/saml/login.tsx @@ -1,3 +1,5 @@ import Login from '../../../saml/login'; +export { getServerSideProps } from '../../../saml/login'; + export default Login; diff --git a/pages/saml/login.tsx b/pages/saml/login.tsx index ca84002d..52010b23 100644 --- a/pages/saml/login.tsx +++ b/pages/saml/login.tsx @@ -1,16 +1,22 @@ +import config from 'lib/env'; 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 LoginProps = { + availableDomains: string[]; +}; + +export default function Login({ availableDomains }: LoginProps) { const router = useRouter(); const { id, audience, acsUrl, providerName, relayState, namespace } = router.query; const authUrl = namespace ? `/api/namespace/${namespace}/saml/auth` : '/api/saml/auth'; const [state, setState] = useState({ username: 'jackson', - domain: 'example.com', + domain: availableDomains[0], acsUrl: 'https://sso.eu.boxyhq.com/api/oauth/saml', audience: 'https://saml.boxyhq.com', }); @@ -135,8 +141,11 @@ export default function Login() { 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'> - - + {availableDomains.map((domain) => ( + + ))} @@ -164,9 +173,15 @@ export default function Login() { {/* Info box */}

- This is a simulated login screen. You may choose any username, but only the domains{' '} - example.com and{' '} - example.org are allowed. + This is a simulated login screen. You may choose any username, but only the{' '} + {availableDomains.length > 1 ? 'domains' : 'domain'}{' '} + {availableDomains.map((domain, index) => ( + + {index > 0 && (index === availableDomains.length - 1 ? ' and ' : ', ')} + {domain} + + ))}{' '} + {availableDomains.length > 1 ? 'are' : 'is'} allowed.

@@ -174,3 +189,11 @@ export default function Login() { ); } + +export const getServerSideProps: GetServerSideProps = async () => { + return { + props: { + availableDomains: config.availableDomains, + }, + }; +}; From 5c9cb1a1738e915f03242f94141d154ed404b69f Mon Sep 17 00:00:00 2001 From: louisremi Date: Wed, 17 Jun 2026 17:48:33 +0200 Subject: [PATCH 2/2] Harden domain allowlist parsing and email validation Address automated review feedback: - lib/env.ts: fall back to the default domains if AVAILABLE_DOMAINS parses to an empty list (e.g. ", ,"), so we never end up with an empty allowlist that blocks every login. - pages/api/saml/auth.ts: reject non-string emails and require exactly one "@" with a non-empty local part and domain before the allowlist check, so a malformed value like "a@example.com@evil.com" can't slip an allowed domain past the check (returns 400 instead of crashing or bypassing). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/env.ts | 7 ++++++- pages/api/saml/auth.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/env.ts b/lib/env.ts index 108c3380..1ef5b6fb 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -6,10 +6,15 @@ const appUrl = `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` || 'http://localhost:4000'; const entityId = process.env.ENTITY_ID || 'https://saml.example.com/entityid'; -const availableDomains = (process.env.AVAILABLE_DOMAINS || 'example.com,example.org') +const defaultDomains = ['example.com', 'example.org']; +const parsedDomains = (process.env.AVAILABLE_DOMAINS || defaultDomains.join(',')) .split(',') .map((d: string) => d.trim()) .filter(Boolean); +// Fall back to the defaults if AVAILABLE_DOMAINS is set but contains no usable +// entries (e.g. ", ,"), so we never end up with an empty allowlist that blocks +// every login. +const availableDomains = parsedDomains.length > 0 ? parsedDomains : defaultDomains; const privateKey = fetchPrivateKey(); const publicKey = fetchPublicKey(); diff --git a/pages/api/saml/auth.ts b/pages/api/saml/auth.ts index 0fdf548f..c68fa156 100644 --- a/pages/api/saml/auth.ts +++ b/pages/api/saml/auth.ts @@ -9,7 +9,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method === 'POST') { const { email, audience, acsUrl, id, relayState } = req.body; - const domain = email.split('@')[1]; + if (typeof email !== 'string') { + return res.status(400).send('Invalid email'); + } + + // Require exactly one "@" with a non-empty local part and domain, so a + // malformed value like "a@example.com@evil.com" can't slip an allowed + // domain past the check. + const parts = email.split('@'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return res.status(400).send('Invalid email'); + } + + const domain = parts[1]; if (!config.availableDomains.includes(domain)) { return res.status(403).send(`${email} denied access`); }