mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(auth): add limits to session timouts and reuse inverval (#49312)
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 -->
This commit is contained in:
1 parent
2893c783d5
commit
344656edc5
5 files changed
+495
-60
No files matched your search
-22
@@ -54,19 +54,6 @@ const baseSchema = z.object({
|
||||
EXTERNAL_ANONYMOUS_USERS_ENABLED: z.boolean(),
|
||||
SECURITY_MANUAL_LINKING_ENABLED: z.boolean(),
|
||||
SITE_URL: z.string().min(1, 'Must have a Site URL'),
|
||||
SESSIONS_TIMEBOX: z
|
||||
.preprocess(
|
||||
(val) => (val === '' || val == null ? undefined : val),
|
||||
z.coerce
|
||||
.number({
|
||||
required_error: 'Must have a sessions timebox',
|
||||
invalid_type_error: 'Must have a sessions timebox',
|
||||
})
|
||||
.min(0, 'Must be greater than or equal to 0.')
|
||||
)
|
||||
.optional(),
|
||||
SESSIONS_INACTIVITY_TIMEOUT: z.number().min(0, 'Must be greater than or equal to 0').optional(),
|
||||
SESSIONS_SINGLE_PER_USER: z.boolean().optional(),
|
||||
PASSWORD_MIN_LENGTH: z
|
||||
.preprocess(
|
||||
(val) => (val === '' || val == null ? undefined : val),
|
||||
@@ -142,9 +129,6 @@ export const ProtectionAuthSettingsForm = () => {
|
||||
SECURITY_CAPTCHA_ENABLED: false,
|
||||
SECURITY_CAPTCHA_SECRET: '',
|
||||
SECURITY_CAPTCHA_PROVIDER: 'hcaptcha',
|
||||
SESSIONS_TIMEBOX: 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: 0,
|
||||
SESSIONS_SINGLE_PER_USER: false,
|
||||
PASSWORD_MIN_LENGTH: 6,
|
||||
PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS,
|
||||
PASSWORD_HIBP_ENABLED: false,
|
||||
@@ -167,9 +151,6 @@ export const ProtectionAuthSettingsForm = () => {
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
|
||||
SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
|
||||
SECURITY_CAPTCHA_PROVIDER,
|
||||
SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
|
||||
SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
|
||||
PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
|
||||
PASSWORD_REQUIRED_CHARACTERS:
|
||||
authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
|
||||
@@ -184,9 +165,6 @@ export const ProtectionAuthSettingsForm = () => {
|
||||
SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED,
|
||||
SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
|
||||
SECURITY_CAPTCHA_PROVIDER,
|
||||
SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
|
||||
SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
|
||||
PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
|
||||
PASSWORD_REQUIRED_CHARACTERS:
|
||||
authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
|
||||
|
||||
+176
@@ -4,6 +4,12 @@ import { HttpResponse } from 'msw'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { SessionsAuthSettingsForm } from './SessionsAuthSettingsForm'
|
||||
import {
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE,
|
||||
MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE,
|
||||
MAX_SESSIONS_TIMEBOX_HOURS,
|
||||
MAX_SESSIONS_TIMEBOX_MESSAGE,
|
||||
} from './SessionsAuthSettingsForm.utils'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
@@ -104,3 +110,173 @@ describe('SessionsAuthSettingsForm — Access Tokens', () => {
|
||||
expect(patchCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
async function saveForm(control: HTMLElement) {
|
||||
const form = control.closest('form') as HTMLFormElement
|
||||
const saveButton = within(form).getByRole('button', { name: 'Save changes' })
|
||||
await waitFor(() => expect(saveButton).toBeEnabled())
|
||||
fireEvent.click(saveButton)
|
||||
}
|
||||
|
||||
describe('SessionsAuthSettingsForm — User Sessions', () => {
|
||||
test('blocks submit when the timebox exceeds the maximum', async () => {
|
||||
mockAuthConfig({ SESSIONS_TIMEBOX: 0 })
|
||||
let patchCalled = false
|
||||
mockUpdateAuthConfig(() => {
|
||||
patchCalled = true
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Time-box user sessions')
|
||||
fireEvent.change(input, { target: { value: '9000' } })
|
||||
await saveForm(input)
|
||||
|
||||
expect(await screen.findByText(MAX_SESSIONS_TIMEBOX_MESSAGE)).toBeInTheDocument()
|
||||
expect(patchCalled).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts a timebox at the maximum', async () => {
|
||||
mockAuthConfig({ SESSIONS_TIMEBOX: 0 })
|
||||
let patchBody: unknown
|
||||
mockUpdateAuthConfig((body) => {
|
||||
patchBody = body
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Time-box user sessions')
|
||||
fireEvent.change(input, { target: { value: String(MAX_SESSIONS_TIMEBOX_HOURS) } })
|
||||
await saveForm(input)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchBody).toMatchObject({ SESSIONS_TIMEBOX: MAX_SESSIONS_TIMEBOX_HOURS })
|
||||
)
|
||||
})
|
||||
|
||||
test('saves an over-limit timebox that is already stored', async () => {
|
||||
mockAuthConfig({ SESSIONS_TIMEBOX: 20000 })
|
||||
let patchBody: unknown
|
||||
mockUpdateAuthConfig((body) => {
|
||||
patchBody = body
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
// Save is gated on isDirty, so change an unrelated field in the same card
|
||||
const singleSessionSwitch = await screen.findByLabelText('Enforce single session per user')
|
||||
fireEvent.click(singleSessionSwitch)
|
||||
await saveForm(singleSessionSwitch)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchBody).toMatchObject({ SESSIONS_TIMEBOX: 20000, SESSIONS_SINGLE_PER_USER: true })
|
||||
)
|
||||
})
|
||||
|
||||
test('blocks a reduction that is still above the maximum', async () => {
|
||||
mockAuthConfig({ SESSIONS_TIMEBOX: 20000 })
|
||||
let patchCalled = false
|
||||
mockUpdateAuthConfig(() => {
|
||||
patchCalled = true
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Time-box user sessions')
|
||||
fireEvent.change(input, { target: { value: '15000' } })
|
||||
await saveForm(input)
|
||||
|
||||
expect(await screen.findByText(MAX_SESSIONS_TIMEBOX_MESSAGE)).toBeInTheDocument()
|
||||
expect(patchCalled).toBe(false)
|
||||
})
|
||||
|
||||
test('saves a reduction into the allowed range', async () => {
|
||||
mockAuthConfig({ SESSIONS_TIMEBOX: 20000 })
|
||||
let patchBody: unknown
|
||||
mockUpdateAuthConfig((body) => {
|
||||
patchBody = body
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Time-box user sessions')
|
||||
fireEvent.change(input, { target: { value: '5000' } })
|
||||
await saveForm(input)
|
||||
|
||||
await waitFor(() => expect(patchBody).toMatchObject({ SESSIONS_TIMEBOX: 5000 }))
|
||||
})
|
||||
|
||||
test('blocks submit when the inactivity timeout exceeds the maximum', async () => {
|
||||
mockAuthConfig({ SESSIONS_INACTIVITY_TIMEOUT: 0 })
|
||||
let patchCalled = false
|
||||
mockUpdateAuthConfig(() => {
|
||||
patchCalled = true
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Inactivity timeout')
|
||||
fireEvent.change(input, { target: { value: '10000' } })
|
||||
await saveForm(input)
|
||||
|
||||
expect(await screen.findByText(MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE)).toBeInTheDocument()
|
||||
expect(patchCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionsAuthSettingsForm — Refresh Tokens', () => {
|
||||
test('blocks submit when the reuse interval exceeds the maximum', async () => {
|
||||
mockAuthConfig({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10 })
|
||||
let patchCalled = false
|
||||
mockUpdateAuthConfig(() => {
|
||||
patchCalled = true
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Refresh token reuse interval')
|
||||
fireEvent.change(input, { target: { value: '600' } })
|
||||
await saveForm(input)
|
||||
|
||||
expect(await screen.findByText(MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE)).toBeInTheDocument()
|
||||
expect(patchCalled).toBe(false)
|
||||
})
|
||||
|
||||
test('saves an over-limit reuse interval that is already stored', async () => {
|
||||
mockAuthConfig({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 })
|
||||
let patchBody: unknown
|
||||
mockUpdateAuthConfig((body) => {
|
||||
patchBody = body
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const rotationSwitch = await screen.findByLabelText(
|
||||
'Detect and revoke potentially compromised refresh tokens'
|
||||
)
|
||||
fireEvent.click(rotationSwitch)
|
||||
await saveForm(rotationSwitch)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchBody).toMatchObject({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 })
|
||||
)
|
||||
})
|
||||
|
||||
test('saves a reduction into the allowed range', async () => {
|
||||
mockAuthConfig({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 })
|
||||
let patchBody: unknown
|
||||
mockUpdateAuthConfig((body) => {
|
||||
patchBody = body
|
||||
})
|
||||
|
||||
customRender(<SessionsAuthSettingsForm />)
|
||||
|
||||
const input = await screen.findByLabelText('Refresh token reuse interval')
|
||||
fireEvent.change(input, { target: { value: '10' } })
|
||||
await saveForm(input)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchBody).toMatchObject({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10 })
|
||||
)
|
||||
})
|
||||
})
|
||||
+68
-38
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -27,8 +27,16 @@ import {
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
AccessTokenSchema,
|
||||
createRefreshTokenSchema,
|
||||
createUserSessionsSchema,
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS,
|
||||
MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS,
|
||||
MAX_SESSIONS_TIMEBOX_HOURS,
|
||||
type AccessTokenFormValues,
|
||||
} from './SessionsAuthSettingsForm.utils'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { NoPermission } from '@/components/ui/NoPermission'
|
||||
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
|
||||
@@ -48,30 +56,6 @@ function HoursOrNeverText({ value }: { value: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_JWT_EXP = 604800
|
||||
|
||||
const AccessTokenSchema = z.object({
|
||||
JWT_EXP: z.coerce
|
||||
.number()
|
||||
.int('Must be a whole number')
|
||||
.positive('Must be greater than 0')
|
||||
.max(MAX_JWT_EXP, `Must be less than ${MAX_JWT_EXP}`),
|
||||
})
|
||||
|
||||
const RefreshTokenSchema = z.object({
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(),
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce.number().min(0, 'Must be a value more than 0'),
|
||||
})
|
||||
|
||||
const UserSessionsSchema = z.object({
|
||||
SESSIONS_TIMEBOX: z.coerce.number().min(0, 'Must be a positive number'),
|
||||
SESSIONS_INACTIVITY_TIMEOUT: z.coerce
|
||||
.number()
|
||||
.multipleOf(0.1)
|
||||
.min(0, 'Must be a positive number'),
|
||||
SESSIONS_SINGLE_PER_USER: z.boolean(),
|
||||
})
|
||||
|
||||
export const SessionsAuthSettingsForm = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const {
|
||||
@@ -100,15 +84,40 @@ export const SessionsAuthSettingsForm = () => {
|
||||
useCheckEntitlements('auth.user_sessions')
|
||||
const promptProPlanUpgrade = IS_PLATFORM && !hasUserSessionsEntitlement
|
||||
|
||||
const accessTokenForm = useForm<z.infer<typeof AccessTokenSchema>>({
|
||||
// NOTE(fm): The maximums below were introduced after these settings were unbounded,
|
||||
// so they are validated against the currently saved value: a project already above a
|
||||
// maximum can still save the section, but can only move the value into range.
|
||||
// Normalized exactly as the reset() calls below, so an untouched field compares equal.
|
||||
const savedRefreshTokenReuseInterval = authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL ?? 0
|
||||
const savedSessionsTimebox = authConfig?.SESSIONS_TIMEBOX || 0
|
||||
const savedSessionsInactivityTimeout = authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0
|
||||
|
||||
const refreshTokenResolver = useMemo(
|
||||
() =>
|
||||
zodResolver(createRefreshTokenSchema({ savedReuseInterval: savedRefreshTokenReuseInterval })),
|
||||
[savedRefreshTokenReuseInterval]
|
||||
)
|
||||
|
||||
const userSessionsResolver = useMemo(
|
||||
() =>
|
||||
zodResolver(
|
||||
createUserSessionsSchema({
|
||||
savedTimebox: savedSessionsTimebox,
|
||||
savedInactivityTimeout: savedSessionsInactivityTimeout,
|
||||
})
|
||||
),
|
||||
[savedSessionsTimebox, savedSessionsInactivityTimeout]
|
||||
)
|
||||
|
||||
const accessTokenForm = useForm<AccessTokenFormValues>({
|
||||
resolver: zodResolver(AccessTokenSchema),
|
||||
defaultValues: {
|
||||
JWT_EXP: 3600,
|
||||
},
|
||||
})
|
||||
|
||||
const refreshTokenForm = useForm<z.infer<typeof RefreshTokenSchema>>({
|
||||
resolver: zodResolver(RefreshTokenSchema),
|
||||
const refreshTokenForm = useForm({
|
||||
resolver: refreshTokenResolver,
|
||||
defaultValues: {
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0,
|
||||
@@ -116,7 +125,7 @@ export const SessionsAuthSettingsForm = () => {
|
||||
})
|
||||
|
||||
const userSessionsForm = useForm({
|
||||
resolver: zodResolver(UserSessionsSchema),
|
||||
resolver: userSessionsResolver,
|
||||
defaultValues: {
|
||||
SESSIONS_TIMEBOX: 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: 0,
|
||||
@@ -136,21 +145,29 @@ export const SessionsAuthSettingsForm = () => {
|
||||
if (!isUpdatingRefreshTokens) {
|
||||
refreshTokenForm.reset({
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: authConfig.REFRESH_TOKEN_ROTATION_ENABLED || false,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: savedRefreshTokenReuseInterval,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isUpdatingUserSessions) {
|
||||
userSessionsForm.reset({
|
||||
SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
|
||||
SESSIONS_TIMEBOX: savedSessionsTimebox,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: savedSessionsInactivityTimeout,
|
||||
SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [authConfig, isUpdatingAccessToken, isUpdatingRefreshTokens, isUpdatingUserSessions])
|
||||
}, [
|
||||
authConfig,
|
||||
isUpdatingAccessToken,
|
||||
isUpdatingRefreshTokens,
|
||||
isUpdatingUserSessions,
|
||||
savedRefreshTokenReuseInterval,
|
||||
savedSessionsTimebox,
|
||||
savedSessionsInactivityTimeout,
|
||||
])
|
||||
|
||||
const onSubmitAccessToken = (values: z.infer<typeof AccessTokenSchema>) => {
|
||||
const onSubmitAccessToken = (values: AccessTokenFormValues) => {
|
||||
const payload = { ...values }
|
||||
setIsUpdatingAccessToken(true)
|
||||
|
||||
@@ -259,11 +276,13 @@ export const SessionsAuthSettingsForm = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
name="SESSIONS_SINGLE_PER_USER"
|
||||
label="Enforce single session per user"
|
||||
description="If enabled, all but a user's most recently active session will be terminated."
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
id="SESSIONS_SINGLE_PER_USER"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig || !hasUserSessionsEntitlement}
|
||||
@@ -281,12 +300,14 @@ export const SessionsAuthSettingsForm = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
name="SESSIONS_TIMEBOX"
|
||||
label="Time-box user sessions"
|
||||
description="The amount of time before a user is forced to sign in again. Use 0 for never."
|
||||
description={`The amount of time before a user is forced to sign in again. Use 0 for never. Maximum ${MAX_SESSIONS_TIMEBOX_HOURS} hours (1 year).`}
|
||||
>
|
||||
<FormControl className="w-full">
|
||||
<InputGroup>
|
||||
<FormInputGroupInput
|
||||
id="SESSIONS_TIMEBOX"
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
@@ -311,13 +332,16 @@ export const SessionsAuthSettingsForm = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
name="SESSIONS_INACTIVITY_TIMEOUT"
|
||||
label="Inactivity timeout"
|
||||
description="The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never."
|
||||
description={`The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never. Maximum ${MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS} hours (1 year).`}
|
||||
>
|
||||
<FormControl className="w-full">
|
||||
<InputGroup>
|
||||
<FormInputGroupInput
|
||||
id="SESSIONS_INACTIVITY_TIMEOUT"
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
className="flex-1"
|
||||
disabled={!canUpdateConfig || !hasUserSessionsEntitlement}
|
||||
@@ -389,12 +413,14 @@ export const SessionsAuthSettingsForm = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
name="JWT_EXP"
|
||||
label="Access token expiry time"
|
||||
description="How long access tokens are valid for before they must be refreshed. Recommendation: 3600 seconds."
|
||||
>
|
||||
<FormControl className="w-full">
|
||||
<InputGroup>
|
||||
<FormInputGroupInput
|
||||
id="JWT_EXP"
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
@@ -454,11 +480,13 @@ export const SessionsAuthSettingsForm = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
name="REFRESH_TOKEN_ROTATION_ENABLED"
|
||||
label="Detect and revoke potentially compromised refresh tokens"
|
||||
description="Prevent replay attacks from potentially compromised refresh tokens."
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
id="REFRESH_TOKEN_ROTATION_ENABLED"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
@@ -475,12 +503,14 @@ export const SessionsAuthSettingsForm = () => {
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
name="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
|
||||
label="Refresh token reuse interval"
|
||||
description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
|
||||
description={`Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds. Maximum ${MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS} seconds (5 minutes).`}
|
||||
>
|
||||
<FormControl className="w-full">
|
||||
<InputGroup>
|
||||
<FormInputGroupInput
|
||||
id="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
createRefreshTokenSchema,
|
||||
createUserSessionsSchema,
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE,
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS,
|
||||
MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE,
|
||||
MAX_SESSIONS_TIMEBOX_HOURS,
|
||||
MAX_SESSIONS_TIMEBOX_MESSAGE,
|
||||
} from './SessionsAuthSettingsForm.utils'
|
||||
|
||||
const OVER_LIMIT_TIMEBOX = 20000
|
||||
|
||||
function parseUserSessions(
|
||||
values: Record<string, unknown>,
|
||||
saved: { savedTimebox: number; savedInactivityTimeout: number } = {
|
||||
savedTimebox: 0,
|
||||
savedInactivityTimeout: 0,
|
||||
}
|
||||
) {
|
||||
return createUserSessionsSchema(saved).safeParse({
|
||||
SESSIONS_TIMEBOX: 0,
|
||||
SESSIONS_INACTIVITY_TIMEOUT: 0,
|
||||
SESSIONS_SINGLE_PER_USER: false,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
|
||||
function parseRefreshToken(values: Record<string, unknown>, savedReuseInterval = 0) {
|
||||
return createRefreshTokenSchema({ savedReuseInterval }).safeParse({
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: true,
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0,
|
||||
...values,
|
||||
})
|
||||
}
|
||||
|
||||
function errorFor(
|
||||
result: ReturnType<typeof parseUserSessions> | ReturnType<typeof parseRefreshToken>,
|
||||
field: string
|
||||
) {
|
||||
return result.success
|
||||
? undefined
|
||||
: result.error.issues.find((issue) => issue.path[0] === field)?.message
|
||||
}
|
||||
|
||||
describe('createUserSessionsSchema — SESSIONS_TIMEBOX', () => {
|
||||
test('accepts a value below the maximum', () => {
|
||||
expect(parseUserSessions({ SESSIONS_TIMEBOX: 24 }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('accepts a value exactly at the maximum', () => {
|
||||
expect(parseUserSessions({ SESSIONS_TIMEBOX: MAX_SESSIONS_TIMEBOX_HOURS }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a value above the maximum', () => {
|
||||
const result = parseUserSessions({ SESSIONS_TIMEBOX: MAX_SESSIONS_TIMEBOX_HOURS + 1 })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SESSIONS_TIMEBOX')).toBe(MAX_SESSIONS_TIMEBOX_MESSAGE)
|
||||
})
|
||||
|
||||
test('accepts an over-limit value that matches the saved value', () => {
|
||||
const result = parseUserSessions(
|
||||
{ SESSIONS_TIMEBOX: OVER_LIMIT_TIMEBOX },
|
||||
{ savedTimebox: OVER_LIMIT_TIMEBOX, savedInactivityTimeout: 0 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a reduction that is still above the maximum', () => {
|
||||
const result = parseUserSessions(
|
||||
{ SESSIONS_TIMEBOX: 15000 },
|
||||
{ savedTimebox: OVER_LIMIT_TIMEBOX, savedInactivityTimeout: 0 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SESSIONS_TIMEBOX')).toBe(MAX_SESSIONS_TIMEBOX_MESSAGE)
|
||||
})
|
||||
|
||||
test('accepts a reduction into the allowed range', () => {
|
||||
const result = parseUserSessions(
|
||||
{ SESSIONS_TIMEBOX: 5000 },
|
||||
{ savedTimebox: OVER_LIMIT_TIMEBOX, savedInactivityTimeout: 0 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a negative value', () => {
|
||||
const result = parseUserSessions({ SESSIONS_TIMEBOX: -1 })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SESSIONS_TIMEBOX')).toBe('Must be 0 or greater')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUserSessionsSchema — SESSIONS_INACTIVITY_TIMEOUT', () => {
|
||||
test('accepts a fractional value within the maximum', () => {
|
||||
expect(parseUserSessions({ SESSIONS_INACTIVITY_TIMEOUT: 1.5 }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a value above the maximum', () => {
|
||||
const result = parseUserSessions({ SESSIONS_INACTIVITY_TIMEOUT: 10000 })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SESSIONS_INACTIVITY_TIMEOUT')).toBe(
|
||||
MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts an over-limit value that matches the saved value', () => {
|
||||
const result = parseUserSessions(
|
||||
{ SESSIONS_INACTIVITY_TIMEOUT: 10000 },
|
||||
{ savedTimebox: 0, savedInactivityTimeout: 10000 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a reduction that is still above the maximum', () => {
|
||||
const result = parseUserSessions(
|
||||
{ SESSIONS_INACTIVITY_TIMEOUT: 9500 },
|
||||
{ savedTimebox: 0, savedInactivityTimeout: 10000 }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SESSIONS_INACTIVITY_TIMEOUT')).toBe(
|
||||
MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects a value that is not a multiple of 0.1', () => {
|
||||
const result = parseUserSessions({ SESSIONS_INACTIVITY_TIMEOUT: 1.55 })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SESSIONS_INACTIVITY_TIMEOUT')).toBe('Must be a multiple of 0.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRefreshTokenSchema — SECURITY_REFRESH_TOKEN_REUSE_INTERVAL', () => {
|
||||
test('accepts a value exactly at the maximum', () => {
|
||||
expect(
|
||||
parseRefreshToken({
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS,
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a value above the maximum', () => {
|
||||
const result = parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SECURITY_REFRESH_TOKEN_REUSE_INTERVAL')).toBe(
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts an over-limit value that matches the saved value', () => {
|
||||
expect(parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 }, 600).success).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects a reduction that is still above the maximum', () => {
|
||||
const result = parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 500 }, 600)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SECURITY_REFRESH_TOKEN_REUSE_INTERVAL')).toBe(
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts a reduction into the allowed range', () => {
|
||||
expect(parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10 }, 600).success).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a negative value', () => {
|
||||
const result = parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: -1 })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(errorFor(result, 'SECURITY_REFRESH_TOKEN_REUSE_INTERVAL')).toBe('Must be 0 or greater')
|
||||
})
|
||||
})
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const MAX_JWT_EXP = 604800
|
||||
|
||||
export const MAX_SESSIONS_TIMEBOX_HOURS = 8760 // 1 year
|
||||
export const MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS = 8760 // 1 year
|
||||
export const MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS = 300 // 5 mins
|
||||
|
||||
export const MAX_SESSIONS_TIMEBOX_MESSAGE = `Must be ${MAX_SESSIONS_TIMEBOX_HOURS} hours (1 year) or less`
|
||||
export const MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE = `Must be ${MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS} hours (1 year) or less`
|
||||
export const MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE = `Must be ${MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS} seconds (5 minutes) or less`
|
||||
|
||||
const isWithinMaxOrUnchanged = (max: number, savedValue: number) => (value: number) =>
|
||||
value <= max || value === savedValue
|
||||
|
||||
export const AccessTokenSchema = z.object({
|
||||
JWT_EXP: z.coerce
|
||||
.number()
|
||||
.int('Must be a whole number')
|
||||
.positive('Must be greater than 0')
|
||||
.max(MAX_JWT_EXP, `Must be less than ${MAX_JWT_EXP}`),
|
||||
})
|
||||
|
||||
export type AccessTokenFormValues = z.infer<typeof AccessTokenSchema>
|
||||
|
||||
export const createRefreshTokenSchema = ({ savedReuseInterval }: { savedReuseInterval: number }) =>
|
||||
z.object({
|
||||
REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(),
|
||||
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce
|
||||
.number()
|
||||
.min(0, 'Must be 0 or greater')
|
||||
.refine(
|
||||
isWithinMaxOrUnchanged(MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS, savedReuseInterval),
|
||||
MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE
|
||||
),
|
||||
})
|
||||
|
||||
export type RefreshTokenFormValues = z.infer<ReturnType<typeof createRefreshTokenSchema>>
|
||||
|
||||
export const createUserSessionsSchema = ({
|
||||
savedTimebox,
|
||||
savedInactivityTimeout,
|
||||
}: {
|
||||
savedTimebox: number
|
||||
savedInactivityTimeout: number
|
||||
}) =>
|
||||
z.object({
|
||||
SESSIONS_TIMEBOX: z.coerce
|
||||
.number()
|
||||
.min(0, 'Must be 0 or greater')
|
||||
.refine(
|
||||
isWithinMaxOrUnchanged(MAX_SESSIONS_TIMEBOX_HOURS, savedTimebox),
|
||||
MAX_SESSIONS_TIMEBOX_MESSAGE
|
||||
),
|
||||
SESSIONS_INACTIVITY_TIMEOUT: z.coerce
|
||||
.number()
|
||||
.multipleOf(0.1, 'Must be a multiple of 0.1')
|
||||
.min(0, 'Must be 0 or greater')
|
||||
.refine(
|
||||
isWithinMaxOrUnchanged(MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS, savedInactivityTimeout),
|
||||
MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE
|
||||
),
|
||||
SESSIONS_SINGLE_PER_USER: z.boolean(),
|
||||
})
|
||||
|
||||
export type UserSessionsFormValues = z.infer<ReturnType<typeof createUserSessionsSchema>>
|
||||
Reference in new issue
Block a user