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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
10 changes: 10 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
17 changes: 15 additions & 2 deletions pages/api/saml/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions pages/namespace/[namespace]/saml/login.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Login from '../../../saml/login';

export { getServerSideProps } from '../../../saml/login';

export default Login;
37 changes: 30 additions & 7 deletions pages/saml/login.tsx
Original file line number Diff line number Diff line change
@@ -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',
});
Expand Down Expand Up @@ -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'>
<option value='example.com'>@example.com</option>
<option value='example.org'>@example.org</option>
{availableDomains.map((domain) => (
<option key={domain} value={domain}>
@{domain}
</option>
))}
</select>
</div>

Expand Down Expand Up @@ -164,13 +173,27 @@ export default function Login() {
{/* Info box */}
<div className='rounded-md border border-blue-200 bg-blue-50 p-4'>
<p className='text-sm text-blue-900'>
This is a simulated login screen. You may choose any username, but only the domains{' '}
<code className='font-mono'>example.com</code> and{' '}
<code className='font-mono'>example.org</code> 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) => (
<span key={domain}>
{index > 0 && (index === availableDomains.length - 1 ? ' and ' : ', ')}
<code className='font-mono'>{domain}</code>
</span>
))}{' '}
{availableDomains.length > 1 ? 'are' : 'is'} allowed.
</p>
</div>
</div>
</div>
</>
);
}

export const getServerSideProps: GetServerSideProps<LoginProps> = async () => {
return {
props: {
availableDomains: config.availableDomains,
},
};
};
Loading