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>