PasskeysSettingsForm.tsx 12 KB

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