mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: OAuth 2.1 - OAuth apps (#39165)
* oAuth clients index layout * oAuth apps crud * is public * add user count and client secret generation and management * scaffold oauth server settings * improve oauth server enablement / disablement * show cover when oAuth server is disabled * fix update panel update button * add site url and authorization path settings values * move oauth server to it's own nav item * remove unneeded oauth server settings * let the user disactivate oauth server even after creating oauth apps * better delete button * cleanup * fix typecheck * test endpoints * add EnableOAuth21 feature flag * update OAUTH_SERVER_ auth config api * load OAUTH_SERVER_ENABLED in oauth list * Update the api.d.ts. Remove the custom versions of supa libs. * Add query for getTemporaryAPIKey. * Add a hook for initializing a supabase client. * Add hooks for oAuth Server apps. * Regenerate pnpm-lock.yaml. * Revert updates to the platform.d.ts. Not needed for this PR. * Migrate all code to use the new hooks. * Try to integrate the mutations and fix some of the sheet and dialogs. * improve default and saving states * fix oauth app form validation * unify components into CreateOrUpdateOAuthAppModal * create or update oauth app * Update the OAuth Server page. * Remove extra files. * Minor various fixes. * More fixes to the creation of oauth apps. * Bump the libs to fix a DELETE oauth app error. * Clean up the scope feature. * Move the feature flag in the auth layout. * Bunch of smaller fixes. * Regenerate pnpm-lock. * Revert SidePanel and CardDescription changes. * Add confirm dialog for regenerating secret. --------- Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
1 parent
cac225b24a
commit
bc22be3855
17 files changed
+1557
-2
No files matched your search
@@ -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<z.infer<typeof FormSchema>>({
|
||||
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<typeof FormSchema>) => {
|
||||
// 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 (
|
||||
<>
|
||||
<Sheet open={visible} onOpenChange={() => onCancel()}>
|
||||
<SheetContent
|
||||
size="default"
|
||||
showClose={false}
|
||||
className="flex flex-col gap-0"
|
||||
tabIndex={undefined}
|
||||
>
|
||||
<SheetHeader>
|
||||
<div className="flex flex-row gap-3 items-center">
|
||||
<SheetClose
|
||||
className={cn(
|
||||
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:pointer-events-none data-[state=open]:bg-secondary',
|
||||
'transition'
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetClose>
|
||||
<SheetTitle className="truncate">Create a new OAuth app</SheetTitle>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetSection className="overflow-auto flex-grow px-0">
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" className={'px-5'}>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="My OAuth App" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="scope"
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Scope"
|
||||
layout="vertical"
|
||||
description={
|
||||
<>
|
||||
Select the permissions your app will request from users.{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps#scope"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
className={'px-5'}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger_Shadcn_ className="w-full">
|
||||
<SelectValue_Shadcn_ placeholder="Select scope..." />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{OAUTH_APP_SCOPE_OPTIONS.map((scope) => (
|
||||
<SelectItem_Shadcn_ key={scope.value} value={scope.value}>
|
||||
{scope.name}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<div className="px-5 gap-2 flex flex-col">
|
||||
<FormLabel_Shadcn_ className="text-foreground">Redirect URIs</FormLabel_Shadcn_>
|
||||
|
||||
<div className="space-y-2">
|
||||
{redirectUriFields.map((fieldItem, index) => (
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
key={fieldItem.id}
|
||||
name={`redirect_uris.${index}.value`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem_Shadcn_>
|
||||
<div className="flex flex-row gap-2">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...inputField}
|
||||
placeholder={'https://example.com/callback'}
|
||||
onChange={(e) => {
|
||||
inputField.onChange(e)
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
{redirectUriFields.length > 1 && (
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
className="h-[34px]"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => removeRedirectUri(index)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage_Shadcn_ />
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus strokeWidth={1.5} />}
|
||||
onClick={() => appendRedirectUri({ value: '' })}
|
||||
>
|
||||
Add redirect URI
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription_Shadcn_ className="text-foreground-lighter">
|
||||
URLs where users will be redirected after authentication.
|
||||
</FormDescription_Shadcn_>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Public"
|
||||
layout="flex"
|
||||
description={
|
||||
<>
|
||||
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.{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/auth/oauth/public-oauth-apps"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
className={'px-5'}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</SheetSection>
|
||||
<SheetFooter>
|
||||
<Button type="default" disabled={isCreating} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" form={FORM_ID} loading={isCreating}>
|
||||
Create app
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<ConfirmationModal
|
||||
variant={'destructive'}
|
||||
size="medium"
|
||||
loading={isDeleting}
|
||||
visible={visible}
|
||||
title={
|
||||
<>
|
||||
Confirm to delete OAuth app <code className="text-sm">{selectedApp?.client_name}</code>
|
||||
</>
|
||||
}
|
||||
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.',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">Before deleting this OAuth app, consider:</p>
|
||||
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
|
||||
<li className="list-disc ml-6">Any applications using this OAuth app will lose access</li>
|
||||
<li className="list-disc ml-6">This OAuth app is no longer in use by any applications</li>
|
||||
</ul>
|
||||
</ConfirmationModal>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Admonition
|
||||
type="default"
|
||||
className="relative mb-6"
|
||||
title={`Successfully generated credentials for your ${oauthApp.client_name} OAuth app!`}
|
||||
description={
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm">
|
||||
Do copy this client id and client secret and store it in a secure place - you will not
|
||||
be able to see it again.
|
||||
</p>
|
||||
<div className="max-w-xl">
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="max-w-xl input-mono"
|
||||
value={oauthApp?.client_id}
|
||||
onChange={() => {}}
|
||||
onCopy={() => toast.success('Client Id copied to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-xl">
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
size="small"
|
||||
className="max-w-xl input-mono"
|
||||
value={oauthApp?.client_secret}
|
||||
onChange={() => {}}
|
||||
onCopy={() => toast.success('Client secret copied to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="w-7 h-7 absolute top-3 right-3"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { OAuthClient } from '@supabase/supabase-js'
|
||||
import { MoreVertical, Plus, RotateCw, Search, Trash } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
|
||||
import { useOAuthServerAppRegenerateSecretMutation } from 'data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
|
||||
import { useOAuthServerAppsQuery } from 'data/oauth-server-apps/oauth-server-apps-query'
|
||||
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
import { CreateOAuthAppSheet } from './CreateOAuthAppSheet'
|
||||
import { DeleteOAuthAppModal } from './DeleteOAuthAppModal'
|
||||
import { NewOAuthAppBanner } from './NewOAuthAppBanner'
|
||||
|
||||
export const OAUTH_APP_SCOPE_OPTIONS = [
|
||||
{ name: 'email', value: 'email' },
|
||||
{ name: 'profile', value: 'profile' },
|
||||
{ name: 'openid', value: 'openid' },
|
||||
]
|
||||
|
||||
export const OAUTH_APP_TYPE_OPTIONS = [
|
||||
{ name: 'Manual', value: 'manual' },
|
||||
{ name: 'Dynamic', value: 'dynamic' },
|
||||
]
|
||||
|
||||
export const OAuthAppsList = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: authConfig, isLoading: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
|
||||
const isOAuthServerEnabled = !!authConfig?.OAUTH_SERVER_ENABLED
|
||||
const [newOAuthApp, setNewOAuthApp] = useState<OAuthClient | undefined>(undefined)
|
||||
|
||||
// State for OAuth apps
|
||||
const [showCreateSheet, setShowCreateSheet] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
|
||||
const [selectedApp, setSelectedApp] = useState<OAuthClient>()
|
||||
const [filteredAppTypes, setFilteredAppTypes] = useState<string[]>([])
|
||||
const [filteredAppScopes, setFilteredAppScopes] = useState<string[]>([])
|
||||
|
||||
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
|
||||
|
||||
const { data, isLoading, isError, error } = useOAuthServerAppsQuery(
|
||||
{
|
||||
projectRef,
|
||||
supabaseClient: supabaseClientData?.supabaseClient,
|
||||
},
|
||||
{ enabled: isOAuthServerEnabled }
|
||||
)
|
||||
|
||||
const { mutateAsync: regenerateSecret, isLoading: isRegenerating } =
|
||||
useOAuthServerAppRegenerateSecretMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
setNewOAuthApp(data)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const oAuthApps = data?.clients || []
|
||||
|
||||
const handleDeleteClick = (app: OAuthClient) => {
|
||||
setSelectedApp(app)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
if (isAuthConfigLoading || (isOAuthServerEnabled && isLoading)) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <AlertError error={error} subject="Failed to retrieve OAuth Server apps" />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{newOAuthApp && (
|
||||
<NewOAuthAppBanner oauthApp={newOAuthApp} onClose={() => setNewOAuthApp(undefined)} />
|
||||
)}
|
||||
{!isOAuthServerEnabled && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col md:flex-row gap-4 justify-between md:items-center">
|
||||
<div className="flex flex-col gap-2-4">
|
||||
<h3 className="">OAuth Server is disabled</h3>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Enable the OAuth Server to make your project act as an identity provider for
|
||||
third-party applications.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/project/${projectRef}/auth/oauth-server`}>
|
||||
Go to OAuth Server Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search oAuth apps"
|
||||
size="tiny"
|
||||
icon={<Search size="14" />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-52"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Type"
|
||||
options={OAUTH_APP_TYPE_OPTIONS}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
iconKey="icon"
|
||||
activeOptions={filteredAppTypes}
|
||||
labelClass="text-xs text-foreground-light"
|
||||
maxHeightClass="h-[190px]"
|
||||
className="w-52"
|
||||
onSaveFilters={setFilteredAppTypes}
|
||||
/>
|
||||
<FilterPopover
|
||||
name="Scope"
|
||||
options={OAUTH_APP_SCOPE_OPTIONS}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
iconKey="icon"
|
||||
activeOptions={filteredAppScopes}
|
||||
labelClass="text-xs text-foreground-light"
|
||||
maxHeightClass="h-[190px]"
|
||||
className="w-52"
|
||||
onSaveFilters={setFilteredAppScopes}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
disabled={!isOAuthServerEnabled}
|
||||
icon={<Plus />}
|
||||
onClick={() => setShowCreateSheet(true)}
|
||||
className="flex-grow"
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !isOAuthServerEnabled
|
||||
? 'OAuth server must be enabled in settings'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New OAuth App
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Client ID</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-8"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{oAuthApps.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<p className="text-foreground-lighter">No OAuth apps found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{oAuthApps.length > 0 &&
|
||||
oAuthApps.map((app) => (
|
||||
<TableRow key={app.client_id} className="w-full">
|
||||
<TableCell className="w-100 max-w-64 truncate" title={app.client_name}>
|
||||
{app.client_name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-40" title={app.client_id}>
|
||||
<Badge>{app.client_id}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-40">
|
||||
{app.client_type === 'public' ? 'Public' : 'Private'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-40">
|
||||
{app.scope ? (
|
||||
<Badge>{app.scope}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-light">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-foreground-light w-1/6">
|
||||
<TimestampInfo utcTimestamp={app.created_at} labelFormat="D MMM, YYYY" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setSelectedApp(app)
|
||||
setShowRegenerateDialog(true)
|
||||
}}
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
<p>Regenerate client secret</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => handleDeleteClick(app)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p>Delete</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateOAuthAppSheet
|
||||
visible={showCreateSheet}
|
||||
onSuccess={(app) => {
|
||||
setShowCreateSheet(false)
|
||||
setSelectedApp(undefined)
|
||||
setNewOAuthApp(app)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCreateSheet(false)
|
||||
setSelectedApp(undefined)
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteOAuthAppModal
|
||||
visible={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
selectedApp={selectedApp}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="warning"
|
||||
visible={showRegenerateDialog}
|
||||
loading={isRegenerating}
|
||||
title="Confirm regenerating client secret"
|
||||
confirmLabel="Confirm"
|
||||
onCancel={() => setShowRegenerateDialog(false)}
|
||||
onConfirm={() => {
|
||||
regenerateSecret({
|
||||
projectRef,
|
||||
supabaseClient: supabaseClientData?.supabaseClient,
|
||||
clientId: selectedApp?.client_id!,
|
||||
})
|
||||
setShowRegenerateDialog(false)
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
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.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<OAuthServerSettings>({
|
||||
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 (
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">OAuth Server</ScaffoldSectionTitle>
|
||||
<div className="mt-8">
|
||||
<NoPermission resourceText="view OAuth server settings" />
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthConfigLoading || isLoadingPermissions) {
|
||||
return (
|
||||
<div className="pt-12">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionContent>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="pb-10">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col py-6 gap-y-4">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="OAUTH_SERVER_ENABLED"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Enable the Supabase OAuth Server"
|
||||
description={
|
||||
<>
|
||||
Enable OAuth server functionality for your project to create and manage
|
||||
OAuth applications.{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={handleOAuthServerToggle}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
{/* Site URL and Authorization Path - Only show when OAuth Server is enabled */}
|
||||
{form.watch('OAUTH_SERVER_ENABLED') && (
|
||||
<>
|
||||
<CardContent className="flex flex-col py-6 gap-y-4">
|
||||
<FormItemLayout
|
||||
label="Site URL"
|
||||
description={
|
||||
<>
|
||||
The base URL of your application, configured in{' '}
|
||||
<Link
|
||||
href={`/project/${projectRef}/auth/url-configuration`}
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
Auth URL Configuration
|
||||
</Link>{' '}
|
||||
settings.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input_Shadcn_
|
||||
value={authConfig?.SITE_URL}
|
||||
disabled
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="OAUTH_SERVER_AUTHORIZATION_PATH"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Authorization Path"
|
||||
description="Path where you'll implement the OAuth authorization UI (consent screens)."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="/auth/authorize" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<Admonition
|
||||
type="tip"
|
||||
title="Make sure this path is implemented in your application."
|
||||
description={`Preview Authorization URL: ${authConfig?.SITE_URL}${form.watch('OAUTH_SERVER_AUTHORIZATION_PATH') || '/oauth/consent'}`}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent className="py-6">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Allow Dynamic OAuth Apps"
|
||||
description={
|
||||
<>
|
||||
Enable dynamic OAuth app registration. Apps can be registered
|
||||
programmatically via apis.{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps#dynamic-oauth-apps"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={handleDynamicAppsToggle}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
<Button type="default" onClick={() => form.reset()} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || !form.formState.isDirty}
|
||||
loading={isLoading}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSectionContent>
|
||||
</ScaffoldSection>
|
||||
|
||||
{/* Dynamic Apps Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
variant="warning"
|
||||
visible={showDynamicAppsConfirmation}
|
||||
size="large"
|
||||
title="Enable dynamic client registration"
|
||||
confirmLabel="Enable dynamic registration"
|
||||
onConfirm={confirmDynamicApps}
|
||||
onCancel={cancelDynamicApps}
|
||||
alert={{
|
||||
title:
|
||||
'By confirming, you acknowledge the risks and would like to move forward with enabling dynamic client registration.',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-lighter pb-4">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-sm text-foreground-lighter pb-4">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-sm text-foreground-lighter pb-4">
|
||||
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.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
{/* Disable OAuth Server Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
variant="warning"
|
||||
visible={showDisableOAuthServerConfirmation}
|
||||
size="large"
|
||||
title="Disable OAuth Server"
|
||||
confirmLabel="Disable OAuth Server"
|
||||
onConfirm={confirmDisableOAuthServer}
|
||||
onCancel={cancelDisableOAuthServer}
|
||||
alert={{
|
||||
title: `You have ${oauthApps.length} active OAuth app${oauthApps.length > 1 ? 's' : ''} that will be deactivated.`,
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-lighter pb-4">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-sm text-foreground-lighter pb-4">
|
||||
<strong>What will happen:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-foreground-lighter pb-4 list-disc list-inside space-y-1">
|
||||
<li>All OAuth apps will be deactivated</li>
|
||||
<li>Existing access tokens will become invalid</li>
|
||||
<li>Users won't be able to sign in through OAuth flows</li>
|
||||
<li>Third-party integrations will stop working</li>
|
||||
</ul>
|
||||
<p className="text-sm text-foreground-lighter pb-4">
|
||||
You can re-enable the OAuth Server at any time.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<ReturnType<typeof getTemporaryAPIKey>>
|
||||
|
||||
export const useTemporaryAPIKeyQuery = <TData = TemporaryAPIKeyData>(
|
||||
{ projectRef, expiry = 300 }: getTemporaryAPIKeyVariables,
|
||||
{ enabled = true, ...options }: UseQueryOptions<TemporaryAPIKeyData, ResponseError, TData> = {}
|
||||
) => {
|
||||
return useQuery<TemporaryAPIKeyData, ResponseError, TData>({
|
||||
queryKey: apiKeysKeys.temporary(projectRef),
|
||||
queryFn: ({ signal }) => getTemporaryAPIKey({ projectRef, expiry }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
refetchInterval: expiry * 1000, // convert to ms
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<any>
|
||||
}
|
||||
|
||||
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<ReturnType<typeof createOAuthServerApp>>
|
||||
|
||||
export const useOAuthServerAppCreateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<OAuthAppCreateData, ResponseError, OAuthServerAppCreateVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OAuthAppCreateData, ResponseError, OAuthServerAppCreateVariables>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<any>
|
||||
}
|
||||
|
||||
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<ReturnType<typeof deleteOAuthServerApp>>
|
||||
|
||||
export const useOAuthServerAppDeleteMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<OAuthAppDeleteData, ResponseError, OAuthServerAppDeleteVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OAuthAppDeleteData, ResponseError, OAuthServerAppDeleteVariables>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<any>
|
||||
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<ReturnType<typeof regenerateSecret>>
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<any>
|
||||
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<ReturnType<typeof getOAuthServerApps>>
|
||||
export type OAuthServerAppsError = ResponseError
|
||||
|
||||
export const useOAuthServerAppsQuery = <TData = OAuthServerAppsData>(
|
||||
{ projectRef, supabaseClient }: OAuthServerAppsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseQueryOptions<OAuthServerAppsData, OAuthServerAppsError, TData> = {}
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: oauthServerAppKeys.list(projectRef),
|
||||
queryFn: () => getOAuthServerApps({ projectRef, supabaseClient }),
|
||||
enabled: enabled && typeof projectRef !== 'undefined' && !!supabaseClient,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<FormHeader
|
||||
title="OAuth Apps"
|
||||
docsUrl="https://supabase.com/docs/guides/auth/oauth/oauth-apps"
|
||||
/>
|
||||
<OAuthAppsList />
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
|
||||
OAuthApps.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default OAuthApps
|
||||
@@ -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 (
|
||||
<ScaffoldContainer>
|
||||
<OAuthServerSettingsForm />
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
ProvidersPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout>
|
||||
<PageLayout
|
||||
title="OAuth Server"
|
||||
subtitle="Configure your project to act as an identity provider for third-party applications."
|
||||
>
|
||||
{page}
|
||||
</PageLayout>
|
||||
</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default ProvidersPage
|
||||
Reference in new issue
Block a user