diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx new file mode 100644 index 00000000000..934930c4134 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx @@ -0,0 +1,306 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import type { CreateOAuthClientParams, OAuthClient } from '@supabase/supabase-js' +import { Plus, Trash2, X } from 'lucide-react' +import Link from 'next/link' +import { useEffect } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { useOAuthServerAppCreateMutation } from 'data/oauth-server-apps/oauth-server-app-create-mutation' +import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query' +import { + Button, + FormControl_Shadcn_, + FormDescription_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormLabel_Shadcn_, + FormMessage_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + Separator, + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + cn, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface CreateOAuthAppSheetProps { + visible: boolean + onSuccess: (app: OAuthClient) => void + onCancel: () => void +} + +const FormSchema = z.object({ + name: z + .string() + .min(1, 'Please provide a name for your OAuth app') + .max(100, 'Name must be less than 100 characters'), + type: z.enum(['manual', 'dynamic']).default('manual'), + // scope: z.string().min(1, 'Please select a scope'), + redirect_uris: z + .object({ + value: z.string().trim().url('Please provide a valid URL'), + }) + .array() + .min(1, 'At least one redirect URI is required'), + is_public: z.boolean().default(false), +}) + +const FORM_ID = 'create-or-update-oauth-app-form' + +const initialValues = { + name: '', + type: 'manual' as const, + // scope: 'email', + redirect_uris: [{ value: '' }], + is_public: false, +} + +export const CreateOAuthAppSheet = ({ visible, onSuccess, onCancel }: CreateOAuthAppSheetProps) => { + const { ref: projectRef } = useParams() + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: initialValues, + }) + + const { + fields: redirectUriFields, + append: appendRedirectUri, + remove: removeRedirectUri, + } = useFieldArray({ + name: 'redirect_uris', + control: form.control, + }) + + const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef }) + + const { mutateAsync: createOAuthApp, isLoading: isCreating } = useOAuthServerAppCreateMutation({ + onSuccess: (data) => { + toast.success(`Successfully created OAuth app "${data.client_name}"`) + onSuccess(data) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + useEffect(() => { + if (visible) { + form.reset(initialValues) + } + }, [visible]) + + const onSubmit = async (data: z.infer) => { + // Filter out empty redirect URIs + const validRedirectUris = data.redirect_uris + .map((uri) => uri.value.trim()) + .filter((uri) => uri !== '') + + const payload: CreateOAuthClientParams = { + client_name: data.name, + client_uri: '', + // scope: data.scope, + redirect_uris: validRedirectUris, + } + + createOAuthApp({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + ...payload, + }) + } + + const onClose = () => { + form.reset(initialValues) + onCancel() + } + + return ( + <> + onCancel()}> + + +
+ + + Close + + Create a new OAuth app +
+
+ + +
+ ( + + + + + + )} + /> + + {/* ( + + Select the permissions your app will request from users.{' '} + + Learn more + + + } + className={'px-5'} + > + + + + + + + {OAUTH_APP_SCOPE_OPTIONS.map((scope) => ( + + {scope.name} + + ))} + + + + + )} + /> */} + +
+ Redirect URIs + +
+ {redirectUriFields.map((fieldItem, index) => ( + ( + +
+ + { + inputField.onChange(e) + }} + /> + + {redirectUriFields.length > 1 && ( +
+ +
+ )} + /> + ))} +
+
+ +
+ + URLs where users will be redirected after authentication. + +
+ + + ( + + If enabled, the Authorization Code with PKCE (Proof Key for Code Exchange) + flow can be used, particularly beneficial for applications that cannot + securely store Client Secrets, such as native and mobile apps.{' '} + + Learn more + + + } + className={'px-5'} + > + + + + + )} + /> + +
+
+ + + + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/DeleteOAuthAppModal.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/DeleteOAuthAppModal.tsx new file mode 100644 index 00000000000..990dc47cba1 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/DeleteOAuthAppModal.tsx @@ -0,0 +1,77 @@ +import type { OAuthClient } from '@supabase/supabase-js' +import { useParams } from 'common' +import { useOAuthServerAppDeleteMutation } from 'data/oauth-server-apps/oauth-server-app-delete-mutation' +import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query' +import { useState } from 'react' +import { toast } from 'sonner' + +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +interface DeleteOAuthAppModalProps { + visible: boolean + selectedApp?: OAuthClient + onClose: () => void +} + +export const DeleteOAuthAppModal = ({ + visible, + selectedApp, + onClose, +}: DeleteOAuthAppModalProps) => { + const { ref: projectRef } = useParams() + const [isDeleting, setIsDeleting] = useState(false) + + const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef }) + + const { mutateAsync: deleteOAuthApp } = useOAuthServerAppDeleteMutation() + + const onConfirmDeleteApp = async () => { + if (!selectedApp) return console.error('No OAuth app selected') + + setIsDeleting(true) + + try { + await deleteOAuthApp({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + clientId: selectedApp.client_id, + }) + + toast.success(`Successfully deleted OAuth app "${selectedApp.client_name}"`) + onClose() + } catch (error) { + toast.error('Failed to delete OAuth app') + console.error('Error deleting OAuth app:', error) + } finally { + setIsDeleting(false) + } + } + + return ( + + Confirm to delete OAuth app {selectedApp?.client_name} + + } + confirmLabel="Confirm delete" + confirmLabelLoading="Deleting..." + onCancel={onClose} + onConfirm={() => onConfirmDeleteApp()} + alert={{ + title: 'This action cannot be undone', + description: 'You will need to re-create the OAuth app if you want to revert the deletion.', + }} + > +

Before deleting this OAuth app, consider:

+
    +
  • Any applications using this OAuth app will lose access
  • +
  • This OAuth app is no longer in use by any applications
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/NewOAuthAppBanner.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/NewOAuthAppBanner.tsx new file mode 100644 index 00000000000..9718ffcc647 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/NewOAuthAppBanner.tsx @@ -0,0 +1,59 @@ +import type { OAuthClient } from '@supabase/supabase-js' +import { X } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { Input } from 'ui-patterns/DataInputs/Input' + +interface NewOAuthAppBannerProps { + oauthApp: OAuthClient + onClose: () => void +} + +export const NewOAuthAppBanner = ({ oauthApp, onClose }: NewOAuthAppBannerProps) => { + return ( + +

+ Do copy this client id and client secret and store it in a secure place - you will not + be able to see it again. +

+
+ {}} + onCopy={() => toast.success('Client Id copied to clipboard')} + /> +
+
+ {}} + onCopy={() => toast.success('Client secret copied to clipboard')} + /> +
+ + } + > + + + + + )} +
+
+ } + value={filterString} + className="w-full lg:w-52" + onChange={(e) => setFilterString(e.target.value)} + /> + + +
+
+ } + onClick={() => setShowCreateSheet(true)} + className="flex-grow" + tooltip={{ + content: { + side: 'bottom', + text: !isOAuthServerEnabled + ? 'OAuth server must be enabled in settings' + : undefined, + }, + }} + > + New OAuth App + +
+
+ +
+ + + + + Name + Client ID + Type + Scope + Created + + + + + {oAuthApps.length === 0 && ( + + +

No OAuth apps found

+
+
+ )} + {oAuthApps.length > 0 && + oAuthApps.map((app) => ( + + + {app.client_name} + + + {app.client_id} + + + {app.client_type === 'public' ? 'Public' : 'Private'} + + + {app.scope ? ( + {app.scope} + ) : ( + N/A + )} + + + + + +
+ + +
+
+
+ ))} +
+
+
+
+ + + { + setShowCreateSheet(false) + setSelectedApp(undefined) + setNewOAuthApp(app) + }} + onCancel={() => { + setShowCreateSheet(false) + setSelectedApp(undefined) + }} + /> + + setShowDeleteModal(false)} + selectedApp={selectedApp} + /> + + setShowRegenerateDialog(false)} + onConfirm={() => { + regenerateSecret({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + clientId: selectedApp?.client_id!, + }) + setShowRegenerateDialog(false) + }} + > +

+ Are you sure you wish to regenerate the client secret for "{selectedApp?.client_name}"? + All existing sessions will be invalidated. This action cannot be undone. +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx new file mode 100644 index 00000000000..9342d5d164e --- /dev/null +++ b/apps/studio/components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx @@ -0,0 +1,416 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useOAuthServerAppsQuery } from 'data/oauth-server-apps/oauth-server-apps-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query' +import { + Button, + Card, + CardContent, + CardFooter, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + Switch, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const configUrlSchema = z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + description: z.string().optional(), +}) + +const schema = z + .object({ + OAUTH_SERVER_ENABLED: z.boolean().default(false), + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: z.boolean().default(false), + OAUTH_SERVER_AUTHORIZATION_PATH: z.string().default(''), + availableScopes: z.array(z.string()).default(['openid', 'email', 'profile']), + config_urls: z.array(configUrlSchema).optional(), + }) + .superRefine((data, ctx) => { + if (data.OAUTH_SERVER_ENABLED && data.OAUTH_SERVER_AUTHORIZATION_PATH.trim() === '') { + ctx.addIssue({ + path: ['OAUTH_SERVER_AUTHORIZATION_PATH'], + code: z.ZodIssueCode.custom, + message: 'Authorization Path is required when OAuth Server is enabled.', + }) + } + }) + +interface ConfigUrl { + id: string + name: string + value: string + description?: string +} + +interface OAuthServerSettings { + OAUTH_SERVER_ENABLED: boolean + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean + OAUTH_SERVER_AUTHORIZATION_PATH?: string + availableScopes: string[] + config_urls?: ConfigUrl[] +} + +export const OAuthServerSettingsForm = () => { + const { ref: projectRef } = useParams() + const { + data: authConfig, + isLoading: isAuthConfigLoading, + isSuccess, + } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading } = useAuthConfigUpdateMutation({ + onSuccess: () => { + toast.success('OAuth server settings updated successfully') + }, + onError: (error) => { + toast.error(`Failed to update OAuth server settings: ${error?.message}`) + }, + }) + + const [showDynamicAppsConfirmation, setShowDynamicAppsConfirmation] = useState(false) + const [showDisableOAuthServerConfirmation, setShowDisableOAuthServerConfirmation] = + useState(false) + + const { + can: canReadConfig, + isLoading: isLoadingPermissions, + isSuccess: isPermissionsLoaded, + } = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + + const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef }) + + const { data: oAuthAppsData } = useOAuthServerAppsQuery({ + projectRef, + supabaseClient: supabaseClientData?.supabaseClient, + }) + + const oauthApps = oAuthAppsData?.clients || [] + + const { can: canUpdateConfig } = useAsyncCheckPermissions( + PermissionAction.UPDATE, + 'custom_config_gotrue' + ) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + OAUTH_SERVER_ENABLED: true, + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: false, + OAUTH_SERVER_AUTHORIZATION_PATH: '/oauth/consent', + availableScopes: ['openid', 'email', 'profile'], + }, + }) + + // Reset the values when the authConfig is loaded + useEffect(() => { + if (isSuccess && authConfig) { + form.reset({ + OAUTH_SERVER_ENABLED: authConfig.OAUTH_SERVER_ENABLED ?? false, + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: + authConfig.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION ?? false, + OAUTH_SERVER_AUTHORIZATION_PATH: + authConfig.OAUTH_SERVER_AUTHORIZATION_PATH ?? '/oauth/consent', + availableScopes: ['openid', 'email', 'profile'], // Keep default scopes + }) + } + }, [isSuccess]) + + const onSubmit = async (values: OAuthServerSettings) => { + if (!projectRef) return console.error('Project ref is required') + + const config = { + OAUTH_SERVER_ENABLED: values.OAUTH_SERVER_ENABLED, + OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: values.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION, + OAUTH_SERVER_AUTHORIZATION_PATH: values.OAUTH_SERVER_AUTHORIZATION_PATH, + } + + updateAuthConfig({ projectRef, config }) + } + + const handleDynamicAppsToggle = (checked: boolean) => { + if (checked) { + setShowDynamicAppsConfirmation(true) + } else { + form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', false, { shouldDirty: true }) + } + } + + const confirmDynamicApps = () => { + form.setValue('OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION', true, { shouldDirty: true }) + setShowDynamicAppsConfirmation(false) + } + + const cancelDynamicApps = () => { + setShowDynamicAppsConfirmation(false) + } + + const handleOAuthServerToggle = (checked: boolean) => { + if (!checked && oauthApps.length > 0) { + setShowDisableOAuthServerConfirmation(true) + } else { + form.setValue('OAUTH_SERVER_ENABLED', checked, { shouldDirty: true }) + } + } + + const confirmDisableOAuthServer = () => { + form.setValue('OAUTH_SERVER_ENABLED', false, { shouldDirty: true }) + setShowDisableOAuthServerConfirmation(false) + } + + const cancelDisableOAuthServer = () => { + setShowDisableOAuthServerConfirmation(false) + } + + if (isPermissionsLoaded && !canReadConfig) { + return ( + + OAuth Server +
+ +
+
+ ) + } + + if (isAuthConfigLoading || isLoadingPermissions) { + return ( +
+ +
+ ) + } + + return ( + <> + + + +
+ + + ( + + Enable OAuth server functionality for your project to create and manage + OAuth applications.{' '} + + Learn more + + + } + > + + + + + )} + /> + + {/* Site URL and Authorization Path - Only show when OAuth Server is enabled */} + {form.watch('OAUTH_SERVER_ENABLED') && ( + <> + + + The base URL of your application, configured in{' '} + + Auth URL Configuration + {' '} + settings. + + } + > + + + + ( + + + + + + )} + /> + + + + ( + + Enable dynamic OAuth app registration. Apps can be registered + programmatically via apis.{' '} + + Learn more + + + } + > + + + + + )} + /> + + + )} + + + + + + +
+
+
+
+ + {/* Dynamic Apps Confirmation Modal */} + +

+ Enabling dynamic client registration will open up a public API endpoint that anyone can + use to register OAuth applications with your app. This can be a security concern, as + attackers could register OAuth applications with legitimate-sounding names and send them + to your users for approval. +

+

+ If your users don't look carefully and accept, the attacker could potentially take over + the user's account. Attackers can also flood your application with thousands of OAuth + applications that cannot be attributed to anyone (as it's a public endpoint), and make it + difficult for you to find and shut them down, or even find legitimate ones. +

+

+ If dynamic client registration is enabled, the consent screen is forced to be enabled for + all OAuth flows and can no longer be disabled. Disabling the consent screen opens up a + CSRF vulnerability in your app. +

+
+ + {/* Disable OAuth Server Confirmation Modal */} + 1 ? 's' : ''} that will be deactivated.`, + }} + > +

+ Disabling the OAuth Server will immediately deactivate all OAuth applications and prevent + new authentication flows from working. This action will affect all users currently using + your OAuth applications. +

+

+ What will happen: +

+
    +
  • All OAuth apps will be deactivated
  • +
  • Existing access tokens will become invalid
  • +
  • Users won't be able to sign in through OAuth flows
  • +
  • Third-party integrations will stop working
  • +
+

+ You can re-enable the OAuth Server at any time. +

+
+ + ) +} diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx index f0060141017..32817eda82f 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -15,6 +15,7 @@ const AuthProductMenu = () => { const { ref: projectRef = 'default' } = useParams() const authenticationShowOverview = useFlag('authOverviewPage') + const authenticationOauth21 = useFlag('EnableOAuth21') const authenticationShowSecurityNotifications = useIsSecurityNotificationsEnabled() const { @@ -48,6 +49,7 @@ const AuthProductMenu = () => { authenticationAdvanced, authenticationShowOverview, authenticationShowSecurityNotifications, + authenticationOauth21, })} /> ) diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts index 27f0a52e396..95a2ea263a8 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts @@ -12,6 +12,7 @@ export const generateAuthMenu = ( authenticationAdvanced: boolean authenticationShowOverview: boolean authenticationShowSecurityNotifications: boolean + authenticationOauth21: boolean } ): ProductMenuGroup[] => { const { @@ -23,6 +24,7 @@ export const generateAuthMenu = ( authenticationAdvanced, authenticationShowOverview, authenticationShowSecurityNotifications, + authenticationOauth21, } = flags ?? {} return [ @@ -33,6 +35,16 @@ export const generateAuthMenu = ( ? [{ name: 'Overview', key: 'overview', url: `/project/${ref}/auth/overview`, items: [] }] : []), { name: 'Users', key: 'users', url: `/project/${ref}/auth/users`, items: [] }, + ...(authenticationOauth21 + ? [ + { + name: 'OAuth Apps', + key: 'oauth-apps', + url: `/project/${ref}/auth/oauth-apps`, + items: [], + }, + ] + : []), ], }, ...(authenticationEmails && authenticationShowSecurityNotifications && IS_PLATFORM @@ -77,6 +89,16 @@ export const generateAuthMenu = ( }, ] : []), + ...(authenticationOauth21 + ? [ + { + name: 'OAuth Server', + key: 'oauth-server', + url: `/project/${ref}/auth/oauth-server`, + label: 'BETA', + }, + ] + : []), { name: 'Sessions', key: 'sessions', diff --git a/apps/studio/data/api-keys/keys.ts b/apps/studio/data/api-keys/keys.ts index e0bfd893264..3a8d7e0c738 100644 --- a/apps/studio/data/api-keys/keys.ts +++ b/apps/studio/data/api-keys/keys.ts @@ -3,4 +3,5 @@ export const apiKeysKeys = { ['projects', projectRef, 'api-keys', reveal].filter(Boolean), single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const, status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const, + temporary: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'temporary'] as const, } diff --git a/apps/studio/data/api-keys/temp-api-keys-query.ts b/apps/studio/data/api-keys/temp-api-keys-query.ts index 5a15846d269..5d27625ef3a 100644 --- a/apps/studio/data/api-keys/temp-api-keys-query.ts +++ b/apps/studio/data/api-keys/temp-api-keys-query.ts @@ -1,4 +1,8 @@ +import { type UseQueryOptions, useQuery } from '@tanstack/react-query' + import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { apiKeysKeys } from './keys' interface getTemporaryAPIKeyVariables { projectRef?: string @@ -6,8 +10,7 @@ interface getTemporaryAPIKeyVariables { expiry?: number } -// [Joshen] This one specifically shouldn't need a useQuery hook since the expiry is meant to be short lived -// Used in storage explorer and realtime inspector. +// Used in storage explorer, realtime inspector and OAuth Server apps. export async function getTemporaryAPIKey( { projectRef, expiry = 300 }: getTemporaryAPIKeyVariables, signal?: AbortSignal @@ -28,3 +31,18 @@ export async function getTemporaryAPIKey( if (error) handleError(error) return data } + +export type TemporaryAPIKeyData = Awaited> + +export const useTemporaryAPIKeyQuery = ( + { projectRef, expiry = 300 }: getTemporaryAPIKeyVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + return useQuery({ + queryKey: apiKeysKeys.temporary(projectRef), + queryFn: ({ signal }) => getTemporaryAPIKey({ projectRef, expiry }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval: expiry * 1000, // convert to ms + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/keys.ts b/apps/studio/data/oauth-server-apps/keys.ts new file mode 100644 index 00000000000..34df6f8cca1 --- /dev/null +++ b/apps/studio/data/oauth-server-apps/keys.ts @@ -0,0 +1,4 @@ +export const oauthServerAppKeys = { + // temporaryApiKey has to be added to reset the query when it changes + list: (projectRef: string | undefined) => ['projects', projectRef, 'oauth-server-apps'] as const, +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-app-create-mutation.ts b/apps/studio/data/oauth-server-apps/oauth-server-app-create-mutation.ts new file mode 100644 index 00000000000..019e43cb73a --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-app-create-mutation.ts @@ -0,0 +1,58 @@ +import { CreateOAuthClientParams, SupabaseClient } from '@supabase/supabase-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppCreateVariables = CreateOAuthClientParams & { + projectRef?: string + supabaseClient?: SupabaseClient +} + +export async function createOAuthServerApp({ + projectRef, + supabaseClient, + ...params +}: OAuthServerAppCreateVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.createClient(params) + + if (error) return handleError(error) + return data +} + +type OAuthAppCreateData = Awaited> + +export const useOAuthServerAppCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => createOAuthServerApp(vars), + onSuccess: async (data, variables, context) => { + const { projectRef } = variables + await queryClient.invalidateQueries({ + queryKey: oauthServerAppKeys.list(projectRef), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to create OAuth Server application: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-app-delete-mutation.ts b/apps/studio/data/oauth-server-apps/oauth-server-app-delete-mutation.ts new file mode 100644 index 00000000000..460de8fbe6d --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-app-delete-mutation.ts @@ -0,0 +1,60 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppDeleteVariables = { + clientId?: string + projectRef?: string + supabaseClient?: SupabaseClient +} + +export async function deleteOAuthServerApp({ + projectRef, + supabaseClient, + clientId, +}: OAuthServerAppDeleteVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + if (!clientId) throw new Error('Client ID is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.deleteClient(clientId) + console.log(data, error) + if (error) return handleError(error) + return null +} + +type OAuthAppDeleteData = Awaited> + +export const useOAuthServerAppDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => deleteOAuthServerApp(vars), + onSuccess: async (data, variables, context) => { + const { projectRef } = variables + await queryClient.invalidateQueries({ + queryKey: oauthServerAppKeys.list(projectRef), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to delete OAuth Server application: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation.ts b/apps/studio/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation.ts new file mode 100644 index 00000000000..e9e4c2c862c --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation.ts @@ -0,0 +1,68 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppRegenerateSecretVariables = { + projectRef?: string + supabaseClient?: SupabaseClient + clientId: string +} + +export async function regenerateSecret({ + projectRef, + supabaseClient, + clientId, +}: OAuthServerAppRegenerateSecretVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + if (!clientId) throw new Error('Oauth app client id is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.regenerateClientSecret(clientId) + + if (error) handleError(error) + return data +} + +type OAuthAppRegenerateSecretData = Awaited> + +export const useOAuthServerAppRegenerateSecretMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + OAuthAppRegenerateSecretData, + ResponseError, + OAuthServerAppRegenerateSecretVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OAuthAppRegenerateSecretData, + ResponseError, + OAuthServerAppRegenerateSecretVariables + >({ + mutationFn: (vars) => regenerateSecret(vars), + onSuccess: async (data, variables, context) => { + const { projectRef } = variables + await queryClient.invalidateQueries({ + queryKey: oauthServerAppKeys.list(projectRef), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to regenerate OAuth Server application secret: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-server-apps/oauth-server-apps-query.ts b/apps/studio/data/oauth-server-apps/oauth-server-apps-query.ts new file mode 100644 index 00000000000..7360db608cc --- /dev/null +++ b/apps/studio/data/oauth-server-apps/oauth-server-apps-query.ts @@ -0,0 +1,52 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { oauthServerAppKeys } from './keys' + +export type OAuthServerAppsVariables = { + projectRef?: string + supabaseClient?: SupabaseClient + page?: number +} + +const APPS_PER_PAGE = 100 + +export type OAuthApp = components['schemas']['OAuthAppResponse'] + +export async function getOAuthServerApps({ + projectRef, + supabaseClient, + page = 1, +}: OAuthServerAppsVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!supabaseClient) throw new Error('Supabase client is required') + + const { data, error } = await supabaseClient.auth.admin.oauth.listClients({ + page, + perPage: APPS_PER_PAGE, + }) + + if (error) handleError(error) + return data +} + +export type OAuthServerAppsData = Awaited> +export type OAuthServerAppsError = ResponseError + +export const useOAuthServerAppsQuery = ( + { projectRef, supabaseClient }: OAuthServerAppsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + return useQuery({ + queryKey: oauthServerAppKeys.list(projectRef), + queryFn: () => getOAuthServerApps({ projectRef, supabaseClient }), + enabled: enabled && typeof projectRef !== 'undefined' && !!supabaseClient, + ...options, + }) +} diff --git a/apps/studio/hooks/use-supabase-client-query.ts b/apps/studio/hooks/use-supabase-client-query.ts new file mode 100644 index 00000000000..acada318d47 --- /dev/null +++ b/apps/studio/hooks/use-supabase-client-query.ts @@ -0,0 +1,54 @@ +import { createClient } from '@supabase/supabase-js' +import { useQuery } from '@tanstack/react-query' + +import { useTemporaryAPIKeyQuery } from 'data/api-keys/temp-api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' + +const getSupabaseClient = ({ + projectRef, + endpoint, + temporaryApiKey, +}: { + projectRef?: string + endpoint?: string + temporaryApiKey?: string +}) => { + if (!projectRef) { + return undefined + } + if (!endpoint) { + return undefined + } + + if (temporaryApiKey === undefined) { + return undefined + } + + const supabaseClient = createClient(endpoint, temporaryApiKey) + + return { supabaseClient } +} + +/** + * The client uses a temporary API key to authenticate requests. The API key expires after one hour which may cause + * 401 errors for all requests made with the client. It's easily fixable by refreshing the page. + */ +export const useSupabaseClientQuery = ( + { projectRef }: { projectRef?: string }, + { enabled = true, ...options } = {} +) => { + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: temporaryApiKeyData } = useTemporaryAPIKeyQuery({ projectRef, expiry: 3600 }) + + const endpoint = settings + ? `${settings?.app_config?.protocol ?? 'https'}://${settings?.app_config?.endpoint}` + : undefined + const temporaryApiKey = temporaryApiKeyData?.api_key + + return useQuery({ + queryKey: [projectRef, 'supabase-client', endpoint, temporaryApiKey], + queryFn: () => getSupabaseClient({ projectRef, endpoint, temporaryApiKey }), + enabled: enabled && typeof projectRef !== 'undefined' && !!endpoint && !!temporaryApiKey, + ...options, + }) +} diff --git a/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx new file mode 100644 index 00000000000..9034ea70138 --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx @@ -0,0 +1,28 @@ +import { OAuthAppsList } from 'components/interfaces/Auth/OAuthApps/OAuthAppsList' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import type { NextPageWithLayout } from 'types' + +const OAuthApps: NextPageWithLayout = () => ( + + +
+ + +
+
+
+) + +OAuthApps.getLayout = (page) => ( + + {page} + +) + +export default OAuthApps diff --git a/apps/studio/pages/project/[ref]/auth/oauth-server.tsx b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx new file mode 100644 index 00000000000..58e88fa8636 --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx @@ -0,0 +1,29 @@ +import { OAuthServerSettingsForm } from 'components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import type { NextPageWithLayout } from 'types' + +const ProvidersPage: NextPageWithLayout = () => { + return ( + + + + ) +} + +ProvidersPage.getLayout = (page) => ( + + + + {page} + + + +) + +export default ProvidersPage