PasskeysSettingsForm.tsx401 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import { useForm } from 'react-hook-form'
5import { toast } from 'sonner'
6import {
7 Button,
8 Card,
9 CardContent,
10 CardFooter,
11 Form,
12 FormControl,
13 FormField,
14 Input,
15 Switch,
16 useWatch,
17} from 'ui'
18import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
19import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
20import * as z from 'zod'
21
22import { InlineLink } from '@/components/ui/InlineLink'
23import NoPermission from '@/components/ui/NoPermission'
24import type { components } from '@/data/api'
25import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
26import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
27import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
28import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
29import { DOCS_URL } from '@/lib/constants'
30
31type GoTrueConfig = components['schemas']['GoTrueConfigResponse']
32
33function isLocalhost(hostname: string): boolean {
34 return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
35}
36
37function validateRpId(rpId: string): string | null {
38 const trimmed = rpId.trim().toLowerCase()
39 if (!trimmed) return null
40 try {
41 const url = new URL('https://' + trimmed)
42 if (url.hostname !== trimmed) return null
43 return trimmed
44 } catch {
45 return null
46 }
47}
48
49function validateWebAuthnOrigins(
50 value: string,
51 rpId: string | null
52): { valid: true } | { valid: false; message: string } {
53 const origins = value
54 .split(',')
55 .map((o) => o.trim())
56 .filter(Boolean)
57
58 if (origins.length === 0) {
59 return { valid: false, message: 'At least one origin is required' }
60 }
61
62 if (origins.length > 5) {
63 return { valid: false, message: 'A maximum of 5 origins is allowed' }
64 }
65
66 for (const origin of origins) {
67 let url: URL
68 try {
69 url = new URL(origin)
70 } catch {
71 return { valid: false, message: `"${origin}" is not a valid URL` }
72 }
73
74 if (url.protocol === 'http:') {
75 if (!isLocalhost(url.hostname)) {
76 return {
77 valid: false,
78 message: `"${origin}" must use HTTPS unless it is a localhost origin`,
79 }
80 }
81 } else if (url.protocol !== 'https:') {
82 return {
83 valid: false,
84 message: `"${origin}" must use HTTPS unless it is a localhost origin`,
85 }
86 }
87
88 if (url.href !== url.origin + '/') {
89 return {
90 valid: false,
91 message: `"${origin}" must be a plain origin without path, query, or fragment (e.g. "${url.origin}")`,
92 }
93 }
94
95 if (rpId && !isOriginCompatibleWithRpId(url.hostname, rpId)) {
96 return {
97 valid: false,
98 message: `"${origin}" is not compatible with Relying Party ID "${rpId}". The origin's hostname must match or be a subdomain of the RP ID.`,
99 }
100 }
101 }
102
103 return { valid: true }
104}
105
106function isOriginCompatibleWithRpId(originHostname: string, rpId: string): boolean {
107 const host = originHostname.toLowerCase()
108 const id = rpId.toLowerCase()
109 if (isLocalhost(host) && isLocalhost(id)) return true
110 if (host === id) return true
111 if (host.endsWith('.' + id)) return true
112 return false
113}
114
115const schema = z
116 .object({
117 PASSKEY_ENABLED: z.boolean(),
118 WEBAUTHN_RP_ID: z.string().trim(),
119 WEBAUTHN_RP_DISPLAY_NAME: z.string().trim(),
120 WEBAUTHN_RP_ORIGINS: z.string().trim(),
121 })
122 .superRefine((data, ctx) => {
123 if (!data.PASSKEY_ENABLED) return
124
125 if (!data.WEBAUTHN_RP_DISPLAY_NAME) {
126 ctx.addIssue({
127 path: ['WEBAUTHN_RP_DISPLAY_NAME'],
128 code: z.ZodIssueCode.custom,
129 message: 'Relying Party Display Name is required when Passkey is enabled',
130 })
131 }
132
133 let validatedRpId: string | null = null
134 if (!data.WEBAUTHN_RP_ID) {
135 ctx.addIssue({
136 path: ['WEBAUTHN_RP_ID'],
137 code: z.ZodIssueCode.custom,
138 message: 'Relying Party ID is required when Passkey is enabled',
139 })
140 } else {
141 validatedRpId = validateRpId(data.WEBAUTHN_RP_ID)
142 if (validatedRpId === null) {
143 ctx.addIssue({
144 path: ['WEBAUTHN_RP_ID'],
145 code: z.ZodIssueCode.custom,
146 message:
147 'Relying Party ID must be a bare domain (e.g. "example.com"). Do not include a scheme, port, or path.',
148 })
149 }
150 }
151
152 const origins = data.WEBAUTHN_RP_ORIGINS
153 if (!origins) {
154 ctx.addIssue({
155 path: ['WEBAUTHN_RP_ORIGINS'],
156 code: z.ZodIssueCode.custom,
157 message: 'Relying Party Origins is required when Passkey is enabled',
158 })
159 return
160 }
161
162 const result = validateWebAuthnOrigins(origins, validatedRpId)
163 if (!result.valid) {
164 ctx.addIssue({
165 path: ['WEBAUTHN_RP_ORIGINS'],
166 code: z.ZodIssueCode.custom,
167 message: result.message,
168 })
169 }
170 })
171
172type PasskeysSettings = z.infer<typeof schema>
173
174function getPasskeyDefault(
175 key: keyof Pick<
176 PasskeysSettings,
177 'WEBAUTHN_RP_ID' | 'WEBAUTHN_RP_ORIGINS' | 'WEBAUTHN_RP_DISPLAY_NAME'
178 >,
179 config: GoTrueConfig,
180 project: { name: string } | undefined
181): string {
182 const siteUrl = config.SITE_URL
183 switch (key) {
184 case 'WEBAUTHN_RP_ID': {
185 if (!siteUrl) return ''
186 try {
187 return new URL(siteUrl).hostname
188 } catch {
189 return ''
190 }
191 }
192 case 'WEBAUTHN_RP_ORIGINS': {
193 if (!siteUrl) return ''
194 try {
195 return new URL(siteUrl).origin
196 } catch {
197 return ''
198 }
199 }
200 case 'WEBAUTHN_RP_DISPLAY_NAME': {
201 return project?.name ?? ''
202 }
203 default:
204 return ''
205 }
206}
207
208function buildPasskeysFormValues(
209 config: GoTrueConfig,
210 project: { name: string } | undefined
211): PasskeysSettings {
212 const values: PasskeysSettings = {
213 PASSKEY_ENABLED: config.PASSKEY_ENABLED ?? false,
214 WEBAUTHN_RP_ID: config.WEBAUTHN_RP_ID || getPasskeyDefault('WEBAUTHN_RP_ID', config, project),
215 WEBAUTHN_RP_DISPLAY_NAME:
216 config.WEBAUTHN_RP_DISPLAY_NAME ||
217 getPasskeyDefault('WEBAUTHN_RP_DISPLAY_NAME', config, project),
218 WEBAUTHN_RP_ORIGINS:
219 config.WEBAUTHN_RP_ORIGINS || getPasskeyDefault('WEBAUTHN_RP_ORIGINS', config, project),
220 }
221
222 return values
223}
224
225export const PasskeysSettingsForm = () => {
226 const { ref: projectRef } = useParams()
227 const { data: project } = useSelectedProjectQuery()
228
229 const {
230 data: authConfig,
231 isPending: isAuthConfigLoading,
232 isSuccess,
233 } = useAuthConfigQuery({ projectRef })
234
235 const { mutate: updateAuthConfig, isPending } = useAuthConfigUpdateMutation({
236 onSuccess: () => {
237 toast.success('Passkey settings updated successfully')
238 },
239 onError: (error) => {
240 toast.error(`Failed to update passkey settings: ${error?.message}`)
241 },
242 })
243
244 const {
245 can: canReadConfig,
246 isLoading: isLoadingPermissions,
247 isSuccess: isPermissionsLoaded,
248 } = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
249
250 const { can: canUpdateConfig } = useAsyncCheckPermissions(
251 PermissionAction.UPDATE,
252 'custom_config_gotrue'
253 )
254
255 const formValues =
256 isSuccess && authConfig ? buildPasskeysFormValues(authConfig, project) : undefined
257
258 const form = useForm<PasskeysSettings>({
259 resolver: zodResolver(schema as any),
260 defaultValues: formValues ?? {
261 PASSKEY_ENABLED: false,
262 WEBAUTHN_RP_ID: '',
263 WEBAUTHN_RP_DISPLAY_NAME: '',
264 WEBAUTHN_RP_ORIGINS: '',
265 },
266 values: formValues,
267 })
268
269 const onSubmit = (values: PasskeysSettings) => {
270 if (!projectRef) return
271
272 const payload: Record<string, string | boolean | null> = {
273 PASSKEY_ENABLED: values.PASSKEY_ENABLED,
274 WEBAUTHN_RP_ID: values.WEBAUTHN_RP_ID.trim() || null,
275 WEBAUTHN_RP_DISPLAY_NAME: values.WEBAUTHN_RP_DISPLAY_NAME.trim() || null,
276 WEBAUTHN_RP_ORIGINS: values.WEBAUTHN_RP_ORIGINS.trim() || null,
277 }
278
279 updateAuthConfig({ projectRef, config: payload })
280 }
281
282 const passKeysEnabled = useWatch({ control: form.control, name: 'PASSKEY_ENABLED' })
283
284 if (isPermissionsLoaded && !canReadConfig) {
285 return <NoPermission resourceText="view passkey settings" />
286 }
287
288 if (isAuthConfigLoading || isLoadingPermissions || !authConfig) {
289 return <GenericSkeletonLoader />
290 }
291
292 return (
293 <Form {...form}>
294 <form onSubmit={form.handleSubmit(onSubmit)}>
295 <Card>
296 <CardContent>
297 <FormField
298 control={form.control}
299 name="PASSKEY_ENABLED"
300 render={({ field }) => (
301 <FormItemLayout
302 layout="flex-row-reverse"
303 label="Enable Passkey authentication"
304 description={
305 <>
306 Allow users to sign in using passkeys (WebAuthn) with biometrics, security
307 keys, or platform authenticators.{' '}
308 <InlineLink href={`${DOCS_URL}/guides/auth/passkeys`}>Learn more</InlineLink>
309 </>
310 }
311 >
312 <FormControl>
313 <Switch
314 checked={field.value}
315 onCheckedChange={field.onChange}
316 disabled={!canUpdateConfig}
317 />
318 </FormControl>
319 </FormItemLayout>
320 )}
321 />
322 </CardContent>
323
324 {passKeysEnabled && (
325 <>
326 <CardContent>
327 <FormField
328 control={form.control}
329 name="WEBAUTHN_RP_DISPLAY_NAME"
330 render={({ field }) => (
331 <FormItemLayout
332 layout="flex-row-reverse"
333 label="Relying Party Display Name"
334 description="A human-readable name for your application shown during passkey registration."
335 >
336 <FormControl>
337 <Input {...field} placeholder="My project" />
338 </FormControl>
339 </FormItemLayout>
340 )}
341 />
342 </CardContent>
343 <CardContent>
344 <FormField
345 control={form.control}
346 name="WEBAUTHN_RP_ID"
347 render={({ field }) => (
348 <FormItemLayout
349 layout="flex-row-reverse"
350 label="Relying Party ID"
351 description='The domain name for your application (e.g. "example.com"). This determines which passkeys can be used.'
352 >
353 <FormControl>
354 <Input {...field} placeholder="example.com" />
355 </FormControl>
356 </FormItemLayout>
357 )}
358 />
359 </CardContent>
360 <CardContent>
361 <FormField
362 control={form.control}
363 name="WEBAUTHN_RP_ORIGINS"
364 render={({ field }) => (
365 <FormItemLayout
366 layout="flex-row-reverse"
367 label="Relying Party Origins"
368 description='Comma-separated list of allowed origins (e.g. "https://example.com"). HTTPS is required except for localhost.'
369 >
370 <FormControl>
371 <Input {...field} placeholder="https://example.com" />
372 </FormControl>
373 </FormItemLayout>
374 )}
375 />
376 </CardContent>
377 </>
378 )}
379
380 <CardFooter className="justify-end space-x-2">
381 <Button
382 type="default"
383 onClick={() => form.reset(buildPasskeysFormValues(authConfig, project))}
384 disabled={isPending}
385 >
386 Cancel
387 </Button>
388 <Button
389 type="primary"
390 htmlType="submit"
391 disabled={!canUpdateConfig || !form.formState.isDirty}
392 loading={isPending}
393 >
394 Save changes
395 </Button>
396 </CardFooter>
397 </Card>
398 </form>
399 </Form>
400 )
401}