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