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..1ef5b6fb 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -6,12 +6,22 @@ const appUrl = `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` || 'http://localhost:4000'; const entityId = process.env.ENTITY_ID || 'https://saml.example.com/entityid'; +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(); const config = { appUrl, entityId, + availableDomains, privateKey, publicKey, }; diff --git a/pages/api/saml/auth.ts b/pages/api/saml/auth.ts index 55244a6b..c68fa156 100644 --- a/pages/api/saml/auth.ts +++ b/pages/api/saml/auth.ts @@ -9,8 +9,21 @@ 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`); + 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`); } 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.