diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx
index a626f8a0de8..d2f5606bf60 100644
--- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx
+++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx
@@ -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,
diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx
index bf389146c3b..8c268801283 100644
--- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx
+++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ // 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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 })
+ )
+ })
+})
diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx
index 3f3aee161b3..8e8c21e29c2 100644
--- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx
+++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx
@@ -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>({
+ // 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({
resolver: zodResolver(AccessTokenSchema),
defaultValues: {
JWT_EXP: 3600,
},
})
- const refreshTokenForm = useForm>({
- 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) => {
+ const onSubmitAccessToken = (values: AccessTokenFormValues) => {
const payload = { ...values }
setIsUpdatingAccessToken(true)
@@ -259,11 +276,13 @@ export const SessionsAuthSettingsForm = () => {
render={({ field }) => (
{
render={({ field }) => (
{
render={({ field }) => (
{
render={({ field }) => (
{
render={({ field }) => (
{
render={({ field }) => (
,
+ 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, savedReuseInterval = 0) {
+ return createRefreshTokenSchema({ savedReuseInterval }).safeParse({
+ REFRESH_TOKEN_ROTATION_ENABLED: true,
+ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0,
+ ...values,
+ })
+}
+
+function errorFor(
+ result: ReturnType | ReturnType,
+ 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')
+ })
+})
diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.utils.ts b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.utils.ts
new file mode 100644
index 00000000000..b2ebb672b0a
--- /dev/null
+++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.utils.ts
@@ -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
+
+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>
+
+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>