mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
Currently, sessions timeouts and reuse interval inputs accepted any values. This PR caps: - absolute session timeout to 1 year - inactivity timeout to 1 year - refresh token reuse interval to 300 seconds Since these maximums are introduced _after_ some projects have values that exceed the new limits, we allow the users to save the form if their values exceed the max but are unchanged. However, if they decide to change the value, it must fit within the limits. <img width="1195" height="402" alt="Screenshot 2026-08-20 at 15 45 49" src="https://github.com/user-attachments/assets/192420e8-4878-4e4b-9d82-0d1cc4074728" /> <img width="1194" height="512" alt="Screenshot 2026-08-20 at 15 46 06" src="https://github.com/user-attachments/assets/bb333c54-daa3-46ac-b144-96263428f4d7" /> <img width="1168" height="323" alt="Screenshot 2026-08-20 at 15 46 35" src="https://github.com/user-attachments/assets/ae38fcf6-bc73-453f-a61b-2d6f2d0ecfee" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Added clear maximum-value guidance for session and refresh-token settings. * Existing projects with previously configured values above new limits can retain those values while making unrelated changes. * Removed session-related settings from the protection authentication form. * **Bug Fixes** * Improved validation for session timeouts, JWT expiration, and refresh-token reuse intervals. * Added clearer validation messages and support for reducing previously over-limit values. * **Tests** * Expanded coverage for boundary values, invalid inputs, saved settings, and submitted configuration updates. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useParams } from 'common'
|
|
import Link from 'next/link'
|
|
import { useEffect } from 'react'
|
|
import { useForm, useWatch } from 'react-hook-form'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardFooter,
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Switch,
|
|
} from 'ui'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import {
|
|
PageSection,
|
|
PageSectionContent,
|
|
PageSectionMeta,
|
|
PageSectionSummary,
|
|
PageSectionTitle,
|
|
} from 'ui-patterns/PageSection'
|
|
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
|
import * as z from 'zod'
|
|
|
|
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import { NoPermission } from '@/components/ui/NoPermission'
|
|
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
|
|
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
|
|
const CAPTCHA_PROVIDERS = [
|
|
{ key: 'hcaptcha', label: 'hCaptcha' },
|
|
{ key: 'turnstile', label: 'Turnstile by Cloudflare' },
|
|
]
|
|
|
|
type CaptchaProviders = 'hcaptcha' | 'turnstile'
|
|
|
|
const baseSchema = z.object({
|
|
DISABLE_SIGNUP: z.boolean(),
|
|
EXTERNAL_ANONYMOUS_USERS_ENABLED: z.boolean(),
|
|
SECURITY_MANUAL_LINKING_ENABLED: z.boolean(),
|
|
SITE_URL: z.string().min(1, 'Must have a Site URL'),
|
|
PASSWORD_MIN_LENGTH: z
|
|
.preprocess(
|
|
(val) => (val === '' || val == null ? undefined : val),
|
|
z.coerce
|
|
.number({
|
|
required_error: 'Must have a password min length',
|
|
invalid_type_error: 'Must have a password min length',
|
|
})
|
|
.min(6, 'Must be greater or equal to 6.')
|
|
)
|
|
.optional(),
|
|
PASSWORD_REQUIRED_CHARACTERS: z.string().optional(),
|
|
PASSWORD_HIBP_ENABLED: z.boolean().optional(),
|
|
})
|
|
|
|
const captchaEnabledSchema = z
|
|
.object({
|
|
SECURITY_CAPTCHA_ENABLED: z.literal(true),
|
|
SECURITY_CAPTCHA_SECRET: z.string().min(1, 'Must have a Captcha secret'),
|
|
SECURITY_CAPTCHA_PROVIDER: z.enum(['hcaptcha', 'turnstile'], {
|
|
required_error: 'Captcha provider must be either hcaptcha or turnstile',
|
|
}),
|
|
})
|
|
.merge(baseSchema)
|
|
|
|
const captchaDisabledSchema = z
|
|
.object({
|
|
SECURITY_CAPTCHA_ENABLED: z.literal(false),
|
|
SECURITY_CAPTCHA_SECRET: z.string().optional(),
|
|
SECURITY_CAPTCHA_PROVIDER: z.string().optional(),
|
|
})
|
|
.merge(baseSchema)
|
|
|
|
const formSchema = z.discriminatedUnion('SECURITY_CAPTCHA_ENABLED', [
|
|
captchaEnabledSchema,
|
|
captchaDisabledSchema,
|
|
])
|
|
type FormSchema = z.infer<typeof formSchema>
|
|
|
|
export const ProtectionAuthSettingsForm = () => {
|
|
const { ref: projectRef } = useParams()
|
|
const {
|
|
data: authConfig,
|
|
error: authConfigError,
|
|
isError,
|
|
isPending: isLoading,
|
|
} = useAuthConfigQuery({ projectRef })
|
|
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({
|
|
onError: (error) => {
|
|
toast.error(`Failed to update settings: ${error?.message}`)
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Successfully updated settings')
|
|
},
|
|
})
|
|
|
|
const { can: canReadConfig } = useAsyncCheckPermissions(
|
|
PermissionAction.READ,
|
|
'custom_config_gotrue'
|
|
)
|
|
const { can: canUpdateConfig } = useAsyncCheckPermissions(
|
|
PermissionAction.UPDATE,
|
|
'custom_config_gotrue'
|
|
)
|
|
|
|
const protectionForm = useForm<FormSchema>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
DISABLE_SIGNUP: true,
|
|
EXTERNAL_ANONYMOUS_USERS_ENABLED: false,
|
|
SECURITY_MANUAL_LINKING_ENABLED: false,
|
|
SITE_URL: '',
|
|
SECURITY_CAPTCHA_ENABLED: false,
|
|
SECURITY_CAPTCHA_SECRET: '',
|
|
SECURITY_CAPTCHA_PROVIDER: 'hcaptcha',
|
|
PASSWORD_MIN_LENGTH: 6,
|
|
PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS,
|
|
PASSWORD_HIBP_ENABLED: false,
|
|
},
|
|
})
|
|
|
|
const { isDirty } = protectionForm.formState
|
|
|
|
useEffect(() => {
|
|
if (authConfig && !isUpdatingConfig) {
|
|
const SECURITY_CAPTCHA_PROVIDER = (authConfig.SECURITY_CAPTCHA_PROVIDER ||
|
|
'hcaptcha') as CaptchaProviders
|
|
|
|
if (authConfig.SECURITY_CAPTCHA_ENABLED) {
|
|
protectionForm.reset({
|
|
DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
|
|
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
|
|
SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
|
|
SITE_URL: authConfig.SITE_URL || '',
|
|
SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
|
|
SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
|
|
SECURITY_CAPTCHA_PROVIDER,
|
|
PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
|
|
PASSWORD_REQUIRED_CHARACTERS:
|
|
authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
|
|
PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
|
|
})
|
|
} else {
|
|
protectionForm.reset({
|
|
DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
|
|
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
|
|
SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
|
|
SITE_URL: authConfig.SITE_URL || '',
|
|
SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
|
|
SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
|
|
SECURITY_CAPTCHA_PROVIDER,
|
|
PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
|
|
PASSWORD_REQUIRED_CHARACTERS:
|
|
authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
|
|
PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
|
|
})
|
|
}
|
|
}
|
|
}, [authConfig, isUpdatingConfig])
|
|
|
|
const onSubmitProtection = (values: any) => {
|
|
const payload = { ...values }
|
|
payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
|
|
// The backend uses empty string to represent no required characters in the password
|
|
if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
|
|
payload.PASSWORD_REQUIRED_CHARACTERS = ''
|
|
}
|
|
|
|
updateAuthConfig({ projectRef: projectRef!, config: payload })
|
|
}
|
|
|
|
const SECURITY_CAPTCHA_ENABLED = useWatch({
|
|
name: 'SECURITY_CAPTCHA_ENABLED',
|
|
control: protectionForm.control,
|
|
})
|
|
|
|
if (isError) {
|
|
return (
|
|
<PageSection>
|
|
<PageSectionContent>
|
|
<AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
|
|
</PageSectionContent>
|
|
</PageSection>
|
|
)
|
|
}
|
|
|
|
if (!canReadConfig) {
|
|
return (
|
|
<PageSection>
|
|
<PageSectionContent>
|
|
<NoPermission resourceText="view auth configuration settings" />
|
|
</PageSectionContent>
|
|
</PageSection>
|
|
)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<PageSection>
|
|
<PageSectionContent>
|
|
<GenericSkeletonLoader />
|
|
</PageSectionContent>
|
|
</PageSection>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<PageSection>
|
|
<PageSectionMeta>
|
|
<PageSectionSummary>
|
|
<PageSectionTitle>Bot and Abuse Protection</PageSectionTitle>
|
|
</PageSectionSummary>
|
|
</PageSectionMeta>
|
|
<PageSectionContent>
|
|
<Form {...protectionForm}>
|
|
<form onSubmit={protectionForm.handleSubmit(onSubmitProtection)} className="space-y-4">
|
|
<Card>
|
|
<CardContent>
|
|
<FormField
|
|
control={protectionForm.control}
|
|
name="SECURITY_CAPTCHA_ENABLED"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Enable Captcha protection"
|
|
description="Protect authentication endpoints from bots and abuse."
|
|
>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
disabled={!canUpdateConfig}
|
|
/>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
|
|
{SECURITY_CAPTCHA_ENABLED && (
|
|
<>
|
|
<CardContent>
|
|
<FormField
|
|
control={protectionForm.control}
|
|
name="SECURITY_CAPTCHA_PROVIDER"
|
|
render={({ field }) => {
|
|
const selectedProvider = CAPTCHA_PROVIDERS.find(
|
|
(x) => x.key === field.value
|
|
)
|
|
return (
|
|
<FormItemLayout layout="flex-row-reverse" label="Choose Captcha Provider">
|
|
<FormControl>
|
|
<Select
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
disabled={!canUpdateConfig}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select provider" />
|
|
</SelectTrigger>
|
|
<SelectContent align="end">
|
|
{CAPTCHA_PROVIDERS.map((x) => (
|
|
<SelectItem key={x.key} value={x.key}>
|
|
{x.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<InlineLink
|
|
href={
|
|
field.value === 'hcaptcha'
|
|
? `${DOCS_URL}/guides/auth/auth-captcha?queryGroups=captcha-method&captcha-method=hcaptcha-1`
|
|
: field.value === 'turnstile'
|
|
? `${DOCS_URL}/guides/auth/auth-captcha?queryGroups=captcha-method&captcha-method=turnstile-1`
|
|
: '/'
|
|
}
|
|
className="mt-2 text-xs text-foreground-light hover:text-foreground no-underline"
|
|
>
|
|
How to set up {selectedProvider?.label}?
|
|
</InlineLink>
|
|
</FormItemLayout>
|
|
)
|
|
}}
|
|
/>
|
|
</CardContent>
|
|
|
|
<CardContent>
|
|
<FormField
|
|
control={protectionForm.control}
|
|
name="SECURITY_CAPTCHA_SECRET"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Captcha secret"
|
|
description="Obtain this secret from the provider."
|
|
>
|
|
<FormControl>
|
|
<Input {...field} reveal copy disabled={!canUpdateConfig} />
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
|
|
<CardContent>
|
|
<FormField
|
|
control={protectionForm.control}
|
|
name="PASSWORD_HIBP_ENABLED"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Prevent use of leaked passwords"
|
|
description="Rejects the use of known or easy to guess passwords on sign up or password change. "
|
|
>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Badge variant={field.value ? 'success' : 'default'}>
|
|
{field.value ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
<Link href={`/project/${projectRef}/auth/providers?provider=Email`}>
|
|
<Button variant="default">Configure in email provider</Button>
|
|
</Link>
|
|
</div>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
|
|
<CardFooter className="justify-end space-x-2">
|
|
{isDirty && (
|
|
<Button variant="default" onClick={() => protectionForm.reset()}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={!canUpdateConfig || isUpdatingConfig || !isDirty}
|
|
loading={isUpdatingConfig}
|
|
>
|
|
Save changes
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</form>
|
|
</Form>
|
|
</PageSectionContent>
|
|
</PageSection>
|
|
)
|
|
}
|