OAuthServerSettingsForm.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { useParams } from 'common'
  4. import dynamic from 'next/dynamic'
  5. import Link from 'next/link'
  6. import { useEffect, useState } from 'react'
  7. import { useForm } from 'react-hook-form'
  8. import { toast } from 'sonner'
  9. import {
  10. Button,
  11. Card,
  12. CardContent,
  13. CardFooter,
  14. Form,
  15. FormControl,
  16. FormField,
  17. Input,
  18. Switch,
  19. } from 'ui'
  20. import { PageSection, PageSectionContent } from 'ui-patterns'
  21. import { Admonition } from 'ui-patterns/admonition'
  22. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  23. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  24. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  25. import * as z from 'zod'
  26. import { InlineLink } from '@/components/ui/InlineLink'
  27. import NoPermission from '@/components/ui/NoPermission'
  28. import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
  29. import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
  30. import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query'
  31. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  32. import { DOCS_URL } from '@/lib/constants'
  33. const OAuthEndpointsTable = dynamic(() =>
  34. import('./OAuthEndpointsTable').then((mod) => ({ default: mod.OAuthEndpointsTable }))
  35. )
  36. const configUrlSchema = z.object({
  37. id: z.string(),
  38. name: z.string(),
  39. value: z.string(),
  40. description: z.string().optional(),
  41. })
  42. const schema = z
  43. .object({
  44. OAUTH_SERVER_ENABLED: z.boolean().default(false),
  45. OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: z.boolean().default(false),
  46. OAUTH_SERVER_AUTHORIZATION_PATH: z.string().default(''),
  47. availableScopes: z.array(z.string()).default(['openid', 'email', 'profile']),
  48. config_urls: z.array(configUrlSchema).optional(),
  49. })
  50. .superRefine((data, ctx) => {
  51. if (data.OAUTH_SERVER_ENABLED && data.OAUTH_SERVER_AUTHORIZATION_PATH.trim() === '') {
  52. ctx.addIssue({
  53. path: ['OAUTH_SERVER_AUTHORIZATION_PATH'],
  54. code: z.ZodIssueCode.custom,
  55. message: 'Authorization Path is required when OAuth Server is enabled.',
  56. })
  57. }
  58. })
  59. interface ConfigUrl {
  60. id: string
  61. name: string
  62. value: string
  63. description?: string
  64. }
  65. interface OAuthServerSettings {
  66. OAUTH_SERVER_ENABLED: boolean
  67. OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean
  68. OAUTH_SERVER_AUTHORIZATION_PATH?: string
  69. availableScopes: string[]
  70. config_urls?: ConfigUrl[]
  71. }
  72. export const OAuthServerSettingsForm = () => {
  73. const { ref: projectRef } = useParams()
  74. const {
  75. data: authConfig,
  76. isPending: isAuthConfigLoading,
  77. isSuccess,
  78. } = useAuthConfigQuery({ projectRef })
  79. const { mutate: updateAuthConfig, isPending } = useAuthConfigUpdateMutation({
  80. onSuccess: (_, variables) => {
  81. toast.success('OAuth server settings updated successfully')
  82. form.reset({
  83. OAUTH_SERVER_ENABLED: variables.config.OAUTH_SERVER_ENABLED ?? false,
  84. OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION:
  85. variables.config.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION ?? false,
  86. OAUTH_SERVER_AUTHORIZATION_PATH:
  87. variables.config.OAUTH_SERVER_AUTHORIZATION_PATH ?? '/oauth/consent',
  88. availableScopes: ['openid', 'email', 'profile'],
  89. })
  90. },
  91. onError: (error) => {
  92. toast.error(`Failed to update OAuth server settings: ${error?.message}`)
  93. },
  94. })
  95. const [showDynamicAppsConfirmation, setShowDynamicAppsConfirmation] = useState(false)
  96. const [showDisableOAuthServerConfirmation, setShowDisableOAuthServerConfirmation] =
  97. useState(false)
  98. const {
  99. can: canReadConfig,
  100. isLoading: isLoadingPermissions,
  101. isSuccess: isPermissionsLoaded,
  102. } = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
  103. const { data: oAuthAppsData } = useOAuthServerAppsQuery({ projectRef })
  104. const oauthApps = oAuthAppsData?.clients || []
  105. const { can: canUpdateConfig } = useAsyncCheckPermissions(
  106. PermissionAction.UPDATE,
  107. 'custom_config_gotrue'
  108. )
  109. const form = useForm<OAuthServerSettings>({
  110. resolver: zodResolver(schema as any),
  111. defaultValues: {
  112. OAUTH_SERVER_ENABLED: true,
  113. OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: false,
  114. OAUTH_SERVER_AUTHORIZATION_PATH: '/oauth/consent',
  115. availableScopes: ['openid', 'email', 'profile'],
  116. },
  117. })
  118. // Reset the values when the authConfig is loaded
  119. useEffect(() => {
  120. if (isSuccess && authConfig) {
  121. form.reset({
  122. OAUTH_SERVER_ENABLED: authConfig.OAUTH_SERVER_ENABLED ?? false,
  123. OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION:
  124. authConfig.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION ?? false,
  125. OAUTH_SERVER_AUTHORIZATION_PATH:
  126. authConfig.OAUTH_SERVER_AUTHORIZATION_PATH ?? '/oauth/consent',
  127. availableScopes: ['openid', 'email', 'profile'], // Keep default scopes
  128. })
  129. }
  130. }, [isSuccess])
  131. const onSubmit = async (values: OAuthServerSettings) => {
  132. if (!projectRef) return console.error('Project ref is required')
  133. const config = {
  134. OAUTH_SERVER_ENABLED: values.OAUTH_SERVER_ENABLED,
  135. OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: values.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION,
  136. OAUTH_SERVER_AUTHORIZATION_PATH: values.OAUTH_SERVER_AUTHORIZATION_PATH,
  137. }
  138. updateAuthConfig({ projectRef, config })
  139. }
  140. const handleDynamicAppsToggle = (checked: boolean) => {
  141. if (checked) {
  142. setShowDynamicAppsConfirmation(true)
  143. } else {
  144. form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', false, { shouldDirty: true })
  145. }
  146. }
  147. const confirmDynamicApps = () => {
  148. form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', true, { shouldDirty: true })
  149. setShowDynamicAppsConfirmation(false)
  150. }
  151. const cancelDynamicApps = () => {
  152. setShowDynamicAppsConfirmation(false)
  153. }
  154. const handleOAuthServerToggle = (checked: boolean) => {
  155. if (!checked && oauthApps.length > 0) {
  156. setShowDisableOAuthServerConfirmation(true)
  157. } else {
  158. form.setValue('OAUTH_SERVER_ENABLED', checked, { shouldDirty: true })
  159. }
  160. }
  161. const confirmDisableOAuthServer = () => {
  162. form.setValue('OAUTH_SERVER_ENABLED', false, { shouldDirty: true })
  163. setShowDisableOAuthServerConfirmation(false)
  164. }
  165. const cancelDisableOAuthServer = () => {
  166. setShowDisableOAuthServerConfirmation(false)
  167. }
  168. if (isPermissionsLoaded && !canReadConfig) {
  169. return <NoPermission resourceText="view OAuth server settings" />
  170. }
  171. if (isAuthConfigLoading || isLoadingPermissions) {
  172. return (
  173. <PageSection>
  174. <PageSectionContent>
  175. <Card>
  176. <CardContent>
  177. <GenericSkeletonLoader />
  178. </CardContent>
  179. </Card>
  180. <OAuthEndpointsTable isLoading />
  181. </PageSectionContent>
  182. </PageSection>
  183. )
  184. }
  185. return (
  186. <>
  187. <PageSection>
  188. <PageSectionContent>
  189. <Form {...form}>
  190. <form onSubmit={form.handleSubmit(onSubmit)}>
  191. <Card>
  192. <CardContent>
  193. <FormField
  194. control={form.control}
  195. name="OAUTH_SERVER_ENABLED"
  196. render={({ field }) => (
  197. <FormItemLayout
  198. layout="flex-row-reverse"
  199. label="Enable the Briven OAuth Server"
  200. description="Enable OAuth server functionality for your project to create and manage OAuth applications."
  201. >
  202. <FormControl>
  203. <Switch
  204. checked={field.value}
  205. onCheckedChange={handleOAuthServerToggle}
  206. disabled={!canUpdateConfig}
  207. />
  208. </FormControl>
  209. </FormItemLayout>
  210. )}
  211. />
  212. </CardContent>
  213. {form.watch('OAUTH_SERVER_ENABLED') && (
  214. <>
  215. <CardContent>
  216. <FormItemLayout
  217. label="Site URL"
  218. layout="flex-row-reverse"
  219. description={
  220. <>
  221. The base URL of your application, configured in{' '}
  222. <Link
  223. href={`/project/${projectRef}/auth/url-configuration`}
  224. rel="noreferrer"
  225. className="text-foreground-light underline hover:text-foreground transition"
  226. >
  227. Auth URL Configuration
  228. </Link>{' '}
  229. settings.
  230. </>
  231. }
  232. >
  233. <Input
  234. value={authConfig?.SITE_URL}
  235. disabled
  236. placeholder="https://example.com"
  237. />
  238. </FormItemLayout>
  239. </CardContent>
  240. <CardContent className="space-y-4">
  241. <FormField
  242. control={form.control}
  243. name="OAUTH_SERVER_AUTHORIZATION_PATH"
  244. render={({ field }) => (
  245. <FormItemLayout
  246. label="Authorization Path"
  247. layout="flex-row-reverse"
  248. description="Path where you'll implement the OAuth authorization UI (consent screens)."
  249. >
  250. <FormControl>
  251. <Input {...field} placeholder="/auth/authorize" />
  252. </FormControl>
  253. </FormItemLayout>
  254. )}
  255. />
  256. {(() => {
  257. const siteUrl = authConfig?.SITE_URL?.trim()
  258. const authorizationPath =
  259. form.watch('OAUTH_SERVER_AUTHORIZATION_PATH')?.trim() || '/oauth/consent'
  260. const authorizationUrl = siteUrl ? `${siteUrl}${authorizationPath}` : ''
  261. return (
  262. <Admonition
  263. type="tip"
  264. title="Make sure this path is implemented in your application."
  265. description={
  266. <>
  267. Preview Authorization URL:{' '}
  268. {authorizationUrl ? (
  269. <a
  270. href={authorizationUrl}
  271. target="_blank"
  272. rel="noreferrer"
  273. className="text-foreground-light underline hover:text-foreground transition"
  274. >
  275. {authorizationUrl}
  276. </a>
  277. ) : (
  278. <span className="text-foreground-light">
  279. Set a Site URL to preview
  280. </span>
  281. )}
  282. </>
  283. }
  284. />
  285. )
  286. })()}
  287. </CardContent>
  288. <CardContent>
  289. <FormField
  290. control={form.control}
  291. name="OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"
  292. render={({ field }) => (
  293. <FormItemLayout
  294. layout="flex-row-reverse"
  295. label="Allow Dynamic OAuth Apps"
  296. description={
  297. <>
  298. Enable dynamic OAuth app registration. Apps can be registered
  299. programmatically via APIs.{' '}
  300. <InlineLink
  301. href={`${DOCS_URL}/guides/auth/oauth-server/mcp-authentication#oauth-client-setup`}
  302. >
  303. Learn more
  304. </InlineLink>
  305. </>
  306. }
  307. >
  308. <FormControl>
  309. <Switch
  310. checked={field.value}
  311. onCheckedChange={handleDynamicAppsToggle}
  312. disabled={!canUpdateConfig}
  313. />
  314. </FormControl>
  315. </FormItemLayout>
  316. )}
  317. />
  318. </CardContent>
  319. </>
  320. )}
  321. <CardFooter className="justify-end space-x-2">
  322. <Button type="default" onClick={() => form.reset()} disabled={isPending}>
  323. Cancel
  324. </Button>
  325. <Button
  326. type="primary"
  327. htmlType="submit"
  328. disabled={!canUpdateConfig || !form.formState.isDirty}
  329. loading={isPending}
  330. >
  331. Save changes
  332. </Button>
  333. </CardFooter>
  334. </Card>
  335. </form>
  336. </Form>
  337. </PageSectionContent>
  338. </PageSection>
  339. {isSuccess && authConfig?.OAUTH_SERVER_ENABLED && form.watch('OAUTH_SERVER_ENABLED') && (
  340. <OAuthEndpointsTable isLoading={isPending} />
  341. )}
  342. {/* Dynamic Apps Confirmation Modal */}
  343. <ConfirmationModal
  344. variant="warning"
  345. visible={showDynamicAppsConfirmation}
  346. size="large"
  347. title="Enable dynamic OAuth app registration"
  348. confirmLabel="Enable dynamic app registration"
  349. onConfirm={confirmDynamicApps}
  350. onCancel={cancelDynamicApps}
  351. alert={{
  352. title:
  353. 'By confirming, you acknowledge the risks and would like to move forward with enabling dynamic OAuth app registration.',
  354. }}
  355. >
  356. <p className="text-sm text-foreground-lighter pb-4">
  357. Dynamic OAuth apps (also known as dynamic client registration) exposes a public endpoint
  358. allowing anyone to register OAuth clients. Bad actors could create malicious apps with
  359. legitimate-sounding names to phish your users for authorization.
  360. </p>
  361. <p className="text-sm text-foreground-lighter pb-4">
  362. You may also see spam registrations that are difficult to trace or moderate, making it
  363. harder to identify trustworthy applications in your OAuth apps list.
  364. </p>
  365. <p className="text-sm text-foreground-lighter pb-4">
  366. Only enable this if you have a specific use case requiring programmatic client
  367. registration and understand the security implications.
  368. </p>
  369. </ConfirmationModal>
  370. {/* Disable OAuth Server Confirmation Modal */}
  371. <ConfirmationModal
  372. variant="warning"
  373. visible={showDisableOAuthServerConfirmation}
  374. size="large"
  375. title="Disable OAuth Server"
  376. confirmLabel="Disable OAuth Server"
  377. onConfirm={confirmDisableOAuthServer}
  378. onCancel={cancelDisableOAuthServer}
  379. alert={{
  380. title: `You have ${oauthApps.length} active OAuth app${oauthApps.length > 1 ? 's' : ''} that will be deactivated.`,
  381. }}
  382. >
  383. <p className="text-sm text-foreground-lighter pb-4">
  384. Disabling the OAuth Server will immediately deactivate all OAuth applications and prevent
  385. new authentication flows from working. This action will affect all users currently using
  386. your OAuth applications.
  387. </p>
  388. <p className="text-sm text-foreground-lighter pb-4">
  389. <strong>What will happen:</strong>
  390. </p>
  391. <ul className="text-sm text-foreground-lighter pb-4 list-disc list-inside space-y-1">
  392. <li>All OAuth apps will be deactivated</li>
  393. <li>Existing access tokens will become invalid</li>
  394. <li>Users won't be able to sign in through OAuth flows</li>
  395. <li>Third-party integrations will stop working</li>
  396. </ul>
  397. <p className="text-sm text-foreground-lighter pb-4">
  398. You can re-enable the OAuth Server at any time.
  399. </p>
  400. </ConfirmationModal>
  401. </>
  402. )
  403. }