feat: discord oauth whitelist

This commit is contained in:
diced
2025-06-06 20:33:41 -07:00
parent 6e9dea989e
commit e5eaaca5a0
8 changed files with 47 additions and 1 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "oauthDiscordWhitelistIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -82,6 +82,7 @@ model Zipline {
oauthDiscordClientId String?
oauthDiscordClientSecret String?
oauthDiscordRedirectUri String?
oauthDiscordWhitelistIds String[] @default([])
oauthGoogleClientId String?
oauthGoogleClientSecret String?

View File

@@ -30,6 +30,7 @@ export default function ServerSettingsOauth({
oauthDiscordClientId: '',
oauthDiscordClientSecret: '',
oauthDiscordRedirectUri: '',
oauthDiscordWhitelistIds: '',
oauthGoogleClientId: '',
oauthGoogleClientSecret: '',
@@ -50,7 +51,7 @@ export default function ServerSettingsOauth({
const onSubmit = async (values: typeof form.values) => {
for (const key in values) {
if (!['oauthBypassLocalLogin', 'oauthLoginOnly'].includes(key)) {
if (!['oauthBypassLocalLogin', 'oauthLoginOnly', 'oauthDiscordWhitelistIds'].includes(key)) {
if ((values[key as keyof typeof form.values] as string)?.trim() === '') {
// @ts-ignore
values[key as keyof typeof form.values] = null;
@@ -61,6 +62,16 @@ export default function ServerSettingsOauth({
)?.trim();
}
}
if (key === 'oauthDiscordWhitelistIds') {
if (Array.isArray(values['oauthDiscordWhitelistIds'])) continue;
// @ts-ignore
values['oauthDiscordWhitelistIds'] = (values['oauthDiscordWhitelistIds'] as string)
.split(',')
.map((id) => id.trim())
.filter((id) => id !== '');
}
}
return settingsOnSubmit(router, form)(values);
@@ -76,6 +87,9 @@ export default function ServerSettingsOauth({
oauthDiscordClientId: data?.oauthDiscordClientId ?? '',
oauthDiscordClientSecret: data?.oauthDiscordClientSecret ?? '',
oauthDiscordRedirectUri: data?.oauthDiscordRedirectUri ?? '',
oauthDiscordWhitelistIds: data?.oauthDiscordWhitelistIds
? data?.oauthDiscordWhitelistIds.join(', ')
: '',
oauthGoogleClientId: data?.oauthGoogleClientId ?? '',
oauthGoogleClientSecret: data?.oauthGoogleClientSecret ?? '',
@@ -129,6 +143,11 @@ export default function ServerSettingsOauth({
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
<TextInput
label='Discord Whitelist IDs'
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to allow all users.'
{...form.getInputProps('oauthDiscordWhitelistIds')}
/>
<TextInput
label='Discord Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'

View File

@@ -235,6 +235,7 @@ export const DATABASE_TO_PROP = {
oauthDiscordClientId: 'oauth.discord.clientId',
oauthDiscordClientSecret: 'oauth.discord.clientSecret',
oauthDiscordRedirectUri: 'oauth.discord.redirectUri',
oauthDiscordWhitelistIds: 'oauth.discord.whitelistIds',
oauthGoogleClientId: 'oauth.google.clientId',
oauthGoogleClientSecret: 'oauth.google.clientSecret',

View File

@@ -221,12 +221,14 @@ export const schema = z.object({
clientId: z.string(),
clientSecret: z.string(),
redirectUri: z.string().url().nullable().default(null),
whitelistIds: z.array(z.string()).default([]),
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
whitelistIds: z.undefined().or(z.array(z.string()).default([])),
}),
),
github: z

View File

@@ -80,6 +80,16 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
logger.debug('user', { '@me': userJson });
if (config.oauth.discord.whitelistIds?.length) {
if (!config.oauth.discord.whitelistIds.includes(userJson.id)) {
logger.warn('Discord user not whitelisted', { userId: userJson.id });
return {
error: 'You are not whitelisted to use Discord OAuth',
error_code: 403,
};
}
}
const avatar = userJson.avatar
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;

View File

@@ -222,6 +222,16 @@ export default fastifyPlugin(
oauthDiscordClientId: z.string().nullable(),
oauthDiscordClientSecret: z.string().nullable(),
oauthDiscordRedirectUri: z.string().url().endsWith('/api/auth/oauth/discord').nullable(),
oauthDiscordWhitelistIds: z
.union([
z.array(z.string().refine((s) => /^\d+$/.test(s), 'Discord ID must be a number')),
z
.string()
.refine((s) => s === '' || /^\d+(,\d+)*$/.test(s), 'Discord IDs must be comma-separated'),
])
.transform((value) =>
typeof value === 'string' ? value.split(',').map((id) => id.trim()) : value,
),
oauthGoogleClientId: z.string().nullable(),
oauthGoogleClientSecret: z.string().nullable(),