From b69a145403d4a597ad43d339f7324c1e88833e7f Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Mon, 16 Mar 2026 17:13:08 +0100 Subject: [PATCH] feat: custom oauth providers (#43094) Co-authored-by: Ivan Vasilov Co-authored-by: Ali Waseem --- .../CreateOrUpdateCustomProviderSheet.tsx | 540 ++++++++++++++++++ .../CustomAuthProvidersList.tsx | 408 +++++++++++++ .../DeleteCustomProviderModal.tsx | 68 +++ .../customProviders.utils.ts | 44 ++ .../Auth/CustomAuthProviders/index.tsx | 32 ++ .../data/oauth-custom-providers/keys.ts | 5 + .../oauth-custom-provider-create-mutation.ts | 68 +++ .../oauth-custom-provider-delete-mutation.ts | 71 +++ .../oauth-custom-provider-update-mutation.ts | 74 +++ .../oauth-custom-providers-query.ts | 68 +++ .../pages/project/[ref]/auth/providers.tsx | 18 +- .../enabled-features/enabled-features.json | 1 + .../enabled-features.schema.json | 4 + 13 files changed, 1395 insertions(+), 6 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/CustomAuthProviders/CreateOrUpdateCustomProviderSheet.tsx create mode 100644 apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx create mode 100644 apps/studio/components/interfaces/Auth/CustomAuthProviders/DeleteCustomProviderModal.tsx create mode 100644 apps/studio/components/interfaces/Auth/CustomAuthProviders/customProviders.utils.ts create mode 100644 apps/studio/components/interfaces/Auth/CustomAuthProviders/index.tsx create mode 100644 apps/studio/data/oauth-custom-providers/keys.ts create mode 100644 apps/studio/data/oauth-custom-providers/oauth-custom-provider-create-mutation.ts create mode 100644 apps/studio/data/oauth-custom-providers/oauth-custom-provider-delete-mutation.ts create mode 100644 apps/studio/data/oauth-custom-providers/oauth-custom-provider-update-mutation.ts create mode 100644 apps/studio/data/oauth-custom-providers/oauth-custom-providers-query.ts diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/CreateOrUpdateCustomProviderSheet.tsx b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CreateOrUpdateCustomProviderSheet.tsx new file mode 100644 index 00000000000..d36f03b975c --- /dev/null +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CreateOrUpdateCustomProviderSheet.tsx @@ -0,0 +1,540 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import type { CustomOAuthProvider } from '@supabase/auth-js' +import { useParams } from 'common' +import { X } from 'lucide-react' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { + Button, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input, + Input_Shadcn_, + PrePostTab, + RadioGroupStacked, + RadioGroupStackedItem, + Separator, + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + useWatch_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + +import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' +import { FormSectionLabel } from '@/components/ui/Forms/FormSection' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' +import { useOAuthCustomProviderCreateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-create-mutation' +import { + useOAuthCustomProviderUpdateMutation, + type OAuthCustomProviderUpdateVariables, +} from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation' +import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' + +interface CreateOrUpdateCustomProviderSheetProps { + visible: boolean + providerToEdit?: CustomOAuthProvider + onClose: () => void +} + +const SharedFormSchema = z.object({ + identifier: z + .string() + .min(1, 'Please provide an identifier') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Identifier can only contain letters, numbers, hyphens, and underscores' + ), + name: z + .string() + .min(1, 'Please provide a name for your custom provider') + .max(100, 'Name must be less than 100 characters'), + provider_type: z.enum(['oidc', 'oauth2']).default('oidc'), + client_id: z.string().min(1, 'Please provide a client ID').trim(), + client_secret: z.string().min(1, 'Please provide a client secret').trim(), + email_optional: z.boolean().default(false), + issuer: z.string().url('Please provide a valid URL').trim(), + // comma-separated scopes in the form, will be transformed to array when sending + scopes: z.string().default(''), +}) + +const OidcSchema = SharedFormSchema.extend({ + provider_type: z.literal('oidc'), + discovery_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), +}) + +const OAuth2Schema = SharedFormSchema.extend({ + provider_type: z.literal('oauth2'), + authorization_url: z + .union([z.string().url('Please provide a valid URL'), z.literal('')]) + .default(''), + token_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), + userinfo_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), + jwks_uri: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), +}) + +const FormSchema = z.discriminatedUnion('provider_type', [OidcSchema, OAuth2Schema]) + +const FORM_ID = 'create-or-update-custom-provider-form' + +const initialValues = { + name: '', + identifier: '', + provider_type: 'oidc' as const, + issuer: '', + authorization_url: '', + token_url: '', + userinfo_url: '', + jwks_uri: '', + discovery_url: '', + scopes: '', + client_id: '', + client_secret: '', + email_optional: false, +} + +/** Mock autodiscovery endpoint: simulates success or error (random for demo) */ + +export const CreateOrUpdateCustomProviderSheet = ({ + visible, + providerToEdit, + onClose, +}: CreateOrUpdateCustomProviderSheetProps) => { + const isEditMode = !!providerToEdit + const { ref: projectRef } = useParams() + const { data: endpointData } = useProjectApiUrl({ projectRef }) + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: initialValues, + }) + + useEffect(() => { + if (visible) { + if (providerToEdit) { + if (providerToEdit.provider_type === 'oidc') { + form.reset({ + name: providerToEdit.name, + identifier: providerToEdit.identifier.replace('custom:', ''), + provider_type: providerToEdit.provider_type, + client_id: providerToEdit.client_id, + client_secret: 'placeholder', + email_optional: providerToEdit.email_optional, + issuer: providerToEdit.issuer, + discovery_url: providerToEdit.discovery_url, + scopes: (providerToEdit.scopes || []).join(', '), + }) + } else { + form.reset({ + name: providerToEdit.name, + identifier: providerToEdit.identifier.replace('custom:', ''), + provider_type: providerToEdit.provider_type, + client_id: providerToEdit.client_id, + client_secret: 'placeholder', + email_optional: providerToEdit.email_optional, + issuer: providerToEdit.issuer, + authorization_url: providerToEdit.authorization_url, + token_url: providerToEdit.token_url, + userinfo_url: providerToEdit.userinfo_url, + jwks_uri: providerToEdit.jwks_uri, + scopes: (providerToEdit.scopes || []).join(', '), + }) + } + } else { + form.reset(initialValues) + } + } + }, [visible, providerToEdit, form]) + + const { mutate: createCustomProvider, isPending: isCreating } = + useOAuthCustomProviderCreateMutation({ + onSuccess: () => { + toast.success('Custom provider created successfully') + onClose() + }, + }) + const { mutate: updateCustomProvider, isPending: isUpdating } = + useOAuthCustomProviderUpdateMutation({ + onSuccess: () => { + toast.success('Custom provider updated successfully') + onClose() + }, + }) + + const onSubmit = async (values: z.infer) => { + const identifierValue = (values.identifier || '').replace(/^custom:/i, '').trim() + const identifier = identifierValue ? `custom:${identifierValue}` : '' + + let payload: Partial = {} + if (values.provider_type === 'oidc') { + payload = { + skip_nonce_check: false, + discovery_url: + values.discovery_url || + `${values.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, + } + } else { + const issuer = values.issuer + payload = { + authorization_url: + values.authorization_url || `${issuer.replace(/\/$/, '')}/oauth/authorize`, + token_url: values.token_url || `${issuer.replace(/\/$/, '')}/oauth/token`, + userinfo_url: values.userinfo_url || `${issuer.replace(/\/$/, '')}/oauth/userinfo`, + jwks_uri: values.jwks_uri || `${issuer.replace(/\/$/, '')}/.well-known/jwks.json`, + } + } + + if (isEditMode) { + // only include the client secret if it was changed, otherwise keep existing secret + if (values.client_secret !== 'placeholder') { + payload.client_secret = values.client_secret + } + updateCustomProvider({ + identifier, + projectRef, + clientEndpoint: endpointData, + name: values.name, + client_id: values.client_id, + scopes: values.scopes.split(',').map((s) => s.trim()), + issuer: values.issuer, + pkce_enabled: true, + enabled: true, + email_optional: values.email_optional, + ...payload, + }) + } else { + createCustomProvider({ + identifier, + projectRef, + clientEndpoint: endpointData, + provider_type: values.provider_type, + name: values.name, + client_id: values.client_id, + client_secret: values.client_secret, + scopes: values.scopes.split(',').map((s) => s.trim()), + issuer: values.issuer, + pkce_enabled: true, + enabled: true, + email_optional: values.email_optional, + ...payload, + }) + } + } + + const isManualConfiguration = + useWatch_Shadcn_({ control: form.control, name: 'provider_type' }) === 'oauth2' + + const { + confirmOnClose, + handleOpenChange, + modalProps: closeConfirmationModalProps, + } = useConfirmOnClose({ + checkIsDirty: () => form.formState.isDirty, + onClose: () => { + form.reset(initialValues) + onClose() + }, + }) + + const issuerUrlValue = useWatch_Shadcn_({ control: form.control, name: 'issuer' }) + + return ( + + + +
+ + + Close + + + {isEditMode ? 'Update Custom Auth Provider' : 'Create Custom Auth Provider'} + +
+
+ +
+ + ( + + + + { + const raw = e.target.value + const userValue = raw.replace(/^custom:/i, '').trimStart() + field.onChange(userValue) + }} + /> + + + + )} + /> + + ( + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + + OAuth Endpoints + ( + + + + + + )} + /> + + {isManualConfiguration ? ( + + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + + ) : ( + + ( + + + + + + )} + /> + + )} + + + ( + + + + + + )} + /> + ( + + + + + + )} + /> + + + + ( + + + + + + )} + /> + ( + + + + + + )} + /> + + + + + + + + +
+ + + + +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx new file mode 100644 index 00000000000..0ad62b2ea64 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/CustomAuthProvidersList.tsx @@ -0,0 +1,408 @@ +import { useParams } from 'common' +import { Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react' +import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs' +import { useEffect, useMemo, useState } from 'react' +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + HoverCard, + HoverCardContent, + HoverCardTrigger, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableHeadSort, + TableRow, +} from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { CreateOrUpdateCustomProviderSheet } from './CreateOrUpdateCustomProviderSheet' +import { + CUSTOM_PROVIDER_ENABLED_OPTIONS, + CUSTOM_PROVIDER_TYPE_OPTIONS, + filterCustomProviders, + getNextPlanForCustomProviders, +} from './customProviders.utils' +import { DeleteCustomProviderModal } from './DeleteCustomProviderModal' +import AlertError from '@/components/ui/AlertError' +import { FilterPopover } from '@/components/ui/FilterPopover' +import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' +import { useAuthConfigQuery } from '@/data/auth/auth-config-query' +import { useOAuthCustomProvidersQuery } from '@/data/oauth-custom-providers/oauth-custom-providers-query' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' + +const CUSTOM_PROVIDERS_SORT_VALUES = [ + 'name:asc', + 'name:desc', + 'identifier:asc', + 'identifier:desc', + 'provider_type:asc', + 'provider_type:desc', + 'created_at:asc', + 'created_at:desc', +] as const + +type CustomProvidersSort = (typeof CUSTOM_PROVIDERS_SORT_VALUES)[number] +type CustomProvidersSortColumn = CustomProvidersSort extends `${infer Column}:${string}` + ? Column + : unknown +type CustomProvidersSortOrder = CustomProvidersSort extends `${string}:${infer Order}` + ? Order + : unknown + +const NewProviderButton = ({ + canCreateProvider, + setShowCreateSheet, +}: { + canCreateProvider: boolean + setShowCreateSheet: (show: boolean) => void +}) => { + return ( + + ) +} + +export const CustomAuthProvidersList = () => { + const { ref: projectRef } = useParams() + + const { data: organization } = useSelectedOrganizationQuery() + const { data: authConfig, isPending: isAuthConfigLoading } = useAuthConfigQuery({ projectRef }) + const nextPlan = getNextPlanForCustomProviders(organization?.plan?.id) + const isCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED + const providerLimit = authConfig?.CUSTOM_OAUTH_MAX_PROVIDERS || 0 + + const [selectedProviderToEdit, setSelectedProviderToEdit] = useState(null) + const [selectedProviderToDelete, setSelectedProviderToDelete] = useState(null) + const [filteredProviderTypes, setFilteredProviderTypes] = useState([]) + const [filteredEnabledStatuses, setFilteredEnabledStatuses] = useState([]) + + const { + data: customProviders, + isLoading: isPending, + isError, + error, + } = useOAuthCustomProvidersQuery({ projectRef }) + const providerCount = customProviders?.length ?? 0 + const atProviderLimit = providerLimit !== Infinity && providerCount >= providerLimit + + const [showCreateSheet, setShowCreateSheet] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + // Prevent opening the create sheet if custom providers are disabled or at plan limit + useEffect(() => { + if (showCreateSheet && (!isCustomProvidersEnabled || atProviderLimit)) { + setShowCreateSheet(false) + } + }, [showCreateSheet, atProviderLimit, isCustomProvidersEnabled, setShowCreateSheet]) + + const [filterString, setFilterString] = useState('') + + const [sort, setSort] = useQueryState( + 'sort', + parseAsStringLiteral(CUSTOM_PROVIDERS_SORT_VALUES).withDefault('name:asc') + ) + + const providerToEdit = useMemo( + () => customProviders?.find((p) => p.id === selectedProviderToEdit), + [customProviders, selectedProviderToEdit] + ) + + const providerToDelete = useMemo( + () => customProviders?.find((p) => p.id === selectedProviderToDelete), + [customProviders, selectedProviderToDelete] + ) + + const filteredAndSortedCustomProviders = useMemo(() => { + const filtered = filterCustomProviders({ + providers: customProviders ?? [], + searchString: filterString, + providerTypes: filteredProviderTypes, + enabledStatuses: filteredEnabledStatuses, + }) + + const [sortCol, sortOrder] = sort.split(':') as [ + CustomProvidersSortColumn, + CustomProvidersSortOrder, + ] + const orderMultiplier = sortOrder === 'asc' ? 1 : -1 + + return filtered.sort((a, b) => { + if (sortCol === 'name') { + return a.name.localeCompare(b.name) * orderMultiplier + } + if (sortCol === 'identifier') { + return a.identifier.localeCompare(b.identifier) * orderMultiplier + } + if (sortCol === 'provider_type') { + return a.provider_type.localeCompare(b.provider_type) * orderMultiplier + } + if (sortCol === 'created_at') { + return ( + (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier + ) + } + return 0 + }) + }, [customProviders, filterString, filteredProviderTypes, filteredEnabledStatuses, sort]) + + const hasActiveFilters = + filterString.length > 0 || + filteredProviderTypes.length > 0 || + filteredEnabledStatuses.length > 0 + + const handleResetFilters = () => { + setFilterString('') + setFilteredProviderTypes([]) + setFilteredEnabledStatuses([]) + } + + const handleSortChange = (column: CustomProvidersSortColumn) => { + const [currentCol, currentOrder] = sort.split(':') as [ + CustomProvidersSortColumn, + CustomProvidersSortOrder, + ] + if (currentCol === column) { + // Cycle through: asc -> desc -> no sort (default) + if (currentOrder === 'asc') { + setSort(`${column}:desc` as CustomProvidersSort) + } else { + // Reset to default sort (name:asc) + setSort('name:asc') + } + } else { + // New column, start with asc + setSort(`${column}:asc` as CustomProvidersSort) + } + } + + const isCreateOrUpdateSheetVisible = + isCustomProvidersEnabled && (showCreateSheet || !!providerToEdit) + const canCreateProvider = isCustomProvidersEnabled && !atProviderLimit + + if (isAuthConfigLoading || (isCustomProvidersEnabled && isPending)) { + return + } + + if (isError) { + return + } + + return ( + <> +
+
+
+ } + value={filterString} + className="w-full lg:w-52" + onChange={(e) => setFilterString(e.target.value)} + /> + + + {hasActiveFilters && ( +
+
+ {!isCustomProvidersEnabled || atProviderLimit ? ( + + + + + + {!isCustomProvidersEnabled ? ( + 'Custom providers are not enabled for this project. Please contact support to enable this feature.' + ) : ( + <> +

You've reached the limit of {providerLimit} providers for your plan.

+ + + )} +
+
+ ) : ( + + )} +
+
+ +
+ + + + + + + Name + + + + + Identifier + + + + + Type + + + Enabled + +
+ + + + + {filteredAndSortedCustomProviders.length === 0 && ( + + +

No custom providers found

+
+
+ )} + {filteredAndSortedCustomProviders.length > 0 && + filteredAndSortedCustomProviders.map((provider) => ( + + + + + + {provider.identifier} + + + {provider.provider_type} + + + + {provider.enabled ? 'Enabled' : 'Disabled'} + + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ + { + setShowCreateSheet(false) + setSelectedProviderToEdit(null) + }} + /> + + setSelectedProviderToDelete(null)} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/DeleteCustomProviderModal.tsx b/apps/studio/components/interfaces/Auth/CustomAuthProviders/DeleteCustomProviderModal.tsx new file mode 100644 index 00000000000..907f180f059 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/DeleteCustomProviderModal.tsx @@ -0,0 +1,68 @@ +import type { CustomOAuthProvider } from '@supabase/auth-js' +import { useParams } from 'common' +import { toast } from 'sonner' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' +import { useOAuthCustomProviderDeleteMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-delete-mutation' + +interface DeleteCustomProviderModalProps { + visible: boolean + selectedProvider?: CustomOAuthProvider + onClose: () => void +} + +export const DeleteCustomProviderModal = ({ + visible, + selectedProvider, + onClose, +}: DeleteCustomProviderModalProps) => { + const { ref: projectRef } = useParams() + const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef }) + const { mutate, isPending } = useOAuthCustomProviderDeleteMutation({ + onSuccess: () => { + toast.success('Custom provider deleted successfully') + onClose() + }, + }) + + const onConfirmDelete = () => { + mutate({ + identifier: selectedProvider?.identifier, + projectRef, + clientEndpoint, + }) + } + + return ( + + Confirm to delete custom provider{' '} + {selectedProvider?.name} + + } + confirmLabel="Confirm delete" + confirmLabelLoading="Deleting..." + onCancel={() => onClose()} + onConfirm={() => onConfirmDelete()} + alert={{ + title: 'This action cannot be undone', + description: + 'You will need to re-create the custom provider if you want to revert the deletion.', + }} + > +

Before deleting this custom provider, consider:

+
    +
  • + Any users authenticating with this provider will lose access +
  • +
  • This provider is no longer in use by any applications
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/customProviders.utils.ts b/apps/studio/components/interfaces/Auth/CustomAuthProviders/customProviders.utils.ts new file mode 100644 index 00000000000..44099ab1ab3 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/customProviders.utils.ts @@ -0,0 +1,44 @@ +import type { CustomOAuthProvider } from '@supabase/auth-js' + +/** Next plan to upgrade to for more custom providers: Free → Pro, Pro → Team */ +export function getNextPlanForCustomProviders(planId: string | undefined): 'Pro' | 'Team' | null { + if (planId === 'free') return 'Pro' + if (planId === 'pro') return 'Team' + return null +} + +export const CUSTOM_PROVIDER_TYPE_OPTIONS = [ + { name: 'OIDC', value: 'oidc', icon: null }, + { name: 'OAuth2', value: 'oauth2', icon: null }, +] + +export const CUSTOM_PROVIDER_ENABLED_OPTIONS = [ + { name: 'Enabled', value: 'true', icon: null }, + { name: 'Disabled', value: 'false', icon: null }, +] + +export function filterCustomProviders({ + providers, + searchString, + providerTypes, + enabledStatuses, +}: { + providers: CustomOAuthProvider[] + searchString: string + providerTypes: string[] + enabledStatuses: string[] +}) { + return providers.filter((provider) => { + const matchesSearch = + searchString === '' || + provider.name.toLowerCase().includes(searchString.toLowerCase()) || + provider.identifier.toLowerCase().includes(searchString.toLowerCase()) + + const matchesType = providerTypes.length === 0 || providerTypes.includes(provider.provider_type) + + const matchesEnabled = + enabledStatuses.length === 0 || enabledStatuses.includes(provider.enabled ? 'true' : 'false') + + return matchesSearch && matchesType && matchesEnabled + }) +} diff --git a/apps/studio/components/interfaces/Auth/CustomAuthProviders/index.tsx b/apps/studio/components/interfaces/Auth/CustomAuthProviders/index.tsx new file mode 100644 index 00000000000..12a5ab1c51a --- /dev/null +++ b/apps/studio/components/interfaces/Auth/CustomAuthProviders/index.tsx @@ -0,0 +1,32 @@ +import { Badge } from 'ui' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +import { CustomAuthProvidersList } from './CustomAuthProvidersList' + +export const CustomAuthProviders = () => { + return ( + + + +
+ Custom Providers + New +
+ + Configure OAuth/OIDC providers for this project using your own issuer or endpoints. + +
+
+ + + +
+ ) +} diff --git a/apps/studio/data/oauth-custom-providers/keys.ts b/apps/studio/data/oauth-custom-providers/keys.ts new file mode 100644 index 00000000000..61c5545c1e5 --- /dev/null +++ b/apps/studio/data/oauth-custom-providers/keys.ts @@ -0,0 +1,5 @@ +export const oAuthCustomProvidersKeys = { + // temporaryApiKey has to be added to reset the query when it changes + list: (projectRef: string | undefined, clientEndpoint: string | undefined) => + ['projects', projectRef, 'oauth-custom-providers', clientEndpoint] as const, +} diff --git a/apps/studio/data/oauth-custom-providers/oauth-custom-provider-create-mutation.ts b/apps/studio/data/oauth-custom-providers/oauth-custom-provider-create-mutation.ts new file mode 100644 index 00000000000..52d6a6ee2e1 --- /dev/null +++ b/apps/studio/data/oauth-custom-providers/oauth-custom-provider-create-mutation.ts @@ -0,0 +1,68 @@ +import type { CreateCustomProviderParams } from '@supabase/auth-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oAuthCustomProvidersKeys } from './keys' +import { handleError } from '@/data/fetchers' +import { createProjectSupabaseClient } from '@/lib/project-supabase-client' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthCustomProviderCreateVariables = CreateCustomProviderParams & { + projectRef: string | undefined + clientEndpoint: string | undefined +} + +export async function createOAuthCustomProvider({ + projectRef, + clientEndpoint, + ...params +}: OAuthCustomProviderCreateVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!clientEndpoint) throw new Error('Client endpoint is required') + + const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint) + const { data, error } = await supabaseClient.auth.admin.customProviders.createProvider(params) + + if (error) handleError(error) + return data! +} + +type OAuthCustomProviderCreateData = Awaited> + +export const useOAuthCustomProviderCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthCustomProviderCreateData, + ResponseError, + OAuthCustomProviderCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OAuthCustomProviderCreateData, + ResponseError, + OAuthCustomProviderCreateVariables + >({ + mutationFn: (vars) => createOAuthCustomProvider(vars), + onSuccess: async (data, variables, context) => { + const { projectRef, clientEndpoint } = variables + await queryClient.invalidateQueries({ + queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to create custom OAuth provider: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-custom-providers/oauth-custom-provider-delete-mutation.ts b/apps/studio/data/oauth-custom-providers/oauth-custom-provider-delete-mutation.ts new file mode 100644 index 00000000000..7777eacf1d5 --- /dev/null +++ b/apps/studio/data/oauth-custom-providers/oauth-custom-provider-delete-mutation.ts @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { useAuthConfigQuery } from '../auth/auth-config-query' +import { useProjectApiUrl } from '../config/project-endpoint-query' +import { oAuthCustomProvidersKeys } from './keys' +import { handleError } from '@/data/fetchers' +import { createProjectSupabaseClient } from '@/lib/project-supabase-client' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthCustomProviderDeleteVariables = { + identifier: string | undefined + projectRef: string | undefined + clientEndpoint: string | undefined +} + +export async function deleteOAuthCustomProvider({ + projectRef, + clientEndpoint, + identifier, +}: OAuthCustomProviderDeleteVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!clientEndpoint) throw new Error('Client endpoint is required') + if (!identifier) throw new Error('Provider identifier is required') + + const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint) + const { error } = await supabaseClient.auth.admin.customProviders.deleteProvider(identifier) + + if (error) handleError(error) + return null +} + +type OAuthCustomProviderDeleteData = Awaited> + +export const useOAuthCustomProviderDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthCustomProviderDeleteData, + ResponseError, + OAuthCustomProviderDeleteVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OAuthCustomProviderDeleteData, + ResponseError, + OAuthCustomProviderDeleteVariables + >({ + mutationFn: (vars) => deleteOAuthCustomProvider(vars), + onSuccess: async (data, variables, context) => { + const { projectRef, clientEndpoint } = variables + await queryClient.invalidateQueries({ + queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to delete custom OAuth provider: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-custom-providers/oauth-custom-provider-update-mutation.ts b/apps/studio/data/oauth-custom-providers/oauth-custom-provider-update-mutation.ts new file mode 100644 index 00000000000..9caf8c212dc --- /dev/null +++ b/apps/studio/data/oauth-custom-providers/oauth-custom-provider-update-mutation.ts @@ -0,0 +1,74 @@ +import type { UpdateCustomProviderParams } from '@supabase/auth-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oAuthCustomProvidersKeys } from './keys' +import { handleError } from '@/data/fetchers' +import { createProjectSupabaseClient } from '@/lib/project-supabase-client' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthCustomProviderUpdateVariables = UpdateCustomProviderParams & { + identifier: string | undefined + projectRef: string | undefined + clientEndpoint: string | undefined +} + +export async function updateOAuthCustomProvider({ + identifier, + projectRef, + clientEndpoint, + ...params +}: OAuthCustomProviderUpdateVariables) { + if (!projectRef) throw new Error('Project reference is required') + if (!clientEndpoint) throw new Error('Client endpoint is required') + if (!identifier) throw new Error('Provider identifier is required') + + const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint) + const { data, error } = await supabaseClient.auth.admin.customProviders.updateProvider( + identifier, + params + ) + + if (error) handleError(error) + return data! +} + +type OAuthCustomProviderUpdateData = Awaited> + +export const useOAuthCustomProviderUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthCustomProviderUpdateData, + ResponseError, + OAuthCustomProviderUpdateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + OAuthCustomProviderUpdateData, + ResponseError, + OAuthCustomProviderUpdateVariables + >({ + mutationFn: (vars) => updateOAuthCustomProvider(vars), + onSuccess: async (data, variables, context) => { + const { projectRef, clientEndpoint } = variables + await queryClient.invalidateQueries({ + queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint), + }) + await onSuccess?.(data, variables, context) + }, + onError: async (data, variables, context) => { + if (onError === undefined) { + toast.error(`Failed to update custom OAuth provider: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-custom-providers/oauth-custom-providers-query.ts b/apps/studio/data/oauth-custom-providers/oauth-custom-providers-query.ts new file mode 100644 index 00000000000..0bd043c0155 --- /dev/null +++ b/apps/studio/data/oauth-custom-providers/oauth-custom-providers-query.ts @@ -0,0 +1,68 @@ +import { AuthError, type CustomOAuthProvider } from '@supabase/auth-js' +import { useQuery } from '@tanstack/react-query' + +import { useAuthConfigQuery } from '../auth/auth-config-query' +import { oAuthCustomProvidersKeys } from './keys' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' +import { handleError } from '@/data/fetchers' +import { createProjectSupabaseClient } from '@/lib/project-supabase-client' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthCustomProvidersVariables = { + projectRef: string | undefined + page?: number +} + +export async function getOAuthCustomProviders({ + projectRef, + clientEndpoint, + page = 1, +}: OAuthCustomProvidersVariables & { clientEndpoint: string | undefined }) { + if (!projectRef) throw new Error('Project reference is required') + if (!clientEndpoint) throw new Error('Client endpoint is required') + + const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint) + + const { data, error } = await supabaseClient.auth.admin.customProviders.listProviders() + + if (error) { + let newError = error + console.log(newError) + if ( + newError.message.startsWith( + 'AuthUnknownError: JSON.parse: unexpected non-whitespace character' + ) + ) { + newError = new AuthError('Custom providers are not enabled for this project') + } + handleError(newError) + } + return data.providers as CustomOAuthProvider[] +} + +export type OAuthCustomProvidersData = Awaited> +export type OAuthCustomProvidersError = ResponseError + +export const useOAuthCustomProvidersQuery = ( + { projectRef }: OAuthCustomProvidersVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => { + const { data: clientEndpoint } = useProjectApiUrl({ projectRef }) + const { data: authConfig, isSuccess: isSuccessConfig } = useAuthConfigQuery({ projectRef }) + const isOAuthCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED + + return useQuery({ + queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint), + queryFn: () => getOAuthCustomProviders({ projectRef, clientEndpoint }), + enabled: + enabled && + typeof projectRef !== 'undefined' && + !!clientEndpoint && + isSuccessConfig && + isOAuthCustomProvidersEnabled, + ...options, + }) +} diff --git a/apps/studio/pages/project/[ref]/auth/providers.tsx b/apps/studio/pages/project/[ref]/auth/providers.tsx index 10b456a1ce4..d0dcea5bbd6 100644 --- a/apps/studio/pages/project/[ref]/auth/providers.tsx +++ b/apps/studio/pages/project/[ref]/auth/providers.tsx @@ -1,18 +1,24 @@ -import { AuthProvidersForm } from 'components/interfaces/Auth/AuthProvidersForm' -import { BasicAuthSettingsForm } from 'components/interfaces/Auth/BasicAuthSettingsForm' -import { AuthProvidersLayout } from 'components/layouts/AuthLayout/AuthProvidersLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import type { NextPageWithLayout } from 'types' +import { useFlag } from 'common' import { PageContainer } from 'ui-patterns/PageContainer' +import { AuthProvidersForm } from '@/components/interfaces/Auth/AuthProvidersForm' +import { BasicAuthSettingsForm } from '@/components/interfaces/Auth/BasicAuthSettingsForm' +import { CustomAuthProviders } from '@/components/interfaces/Auth/CustomAuthProviders' +import { AuthProvidersLayout } from '@/components/layouts/AuthLayout/AuthProvidersLayout' +import DefaultLayout from '@/components/layouts/DefaultLayout' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import type { NextPageWithLayout } from '@/types' + const ProvidersPage: NextPageWithLayout = () => { const showProviders = useIsFeatureEnabled('authentication:show_providers') + const showCustomProviders = useIsFeatureEnabled('authentication:show_custom_providers') + const isOauthProvidersEnabled = useFlag('CustomOauthProviders') return ( {showProviders && } + {showCustomProviders && isOauthProvidersEnabled && } ) } diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index c862597b29c..cbde9cf7e1f 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -20,6 +20,7 @@ "authentication:show_manual_linking": true, "authentication:show_provider_filter": true, "authentication:show_providers": true, + "authentication:show_custom_providers": true, "authentication:show_send_invitation": true, "authentication:show_sort_by_email": true, "authentication:show_sort_by_phone": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index bde7a6887c0..54b715085d2 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -61,6 +61,10 @@ "type": "boolean", "description": "Show the providers section in the authentication Sign In / Providers page" }, + "authentication:show_custom_providers": { + "type": "boolean", + "description": "Show the custom oAuth providers section in the authentication Sign In / Providers page" + }, "authentication:show_manual_linking": { "type": "boolean", "description": "Show the manual linking toggle in the authentication Sign In / Providers page under User Signups"