OAuthServerSettingsForm.tsx435 lines · main
| 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 | |
| 27 | import { InlineLink } from '@/components/ui/InlineLink' |
| 28 | import NoPermission from '@/components/ui/NoPermission' |
| 29 | import { useAuthConfigQuery } from '@/data/auth/auth-config-query' |
| 30 | import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' |
| 31 | import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query' |
| 32 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 33 | import { DOCS_URL } from '@/lib/constants' |
| 34 | |
| 35 | const OAuthEndpointsTable = dynamic(() => |
| 36 | import('./OAuthEndpointsTable').then((mod) => ({ default: mod.OAuthEndpointsTable })) |
| 37 | ) |
| 38 | |
| 39 | const configUrlSchema = z.object({ |
| 40 | id: z.string(), |
| 41 | name: z.string(), |
| 42 | value: z.string(), |
| 43 | description: z.string().optional(), |
| 44 | }) |
| 45 | |
| 46 | const 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 | |
| 64 | interface ConfigUrl { |
| 65 | id: string |
| 66 | name: string |
| 67 | value: string |
| 68 | description?: string |
| 69 | } |
| 70 | |
| 71 | interface 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 | |
| 79 | export 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 | } |