Skip to content

Commit bc22be3

Browse files
feat: OAuth 2.1 - OAuth apps (supabase#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 <[email protected]>
1 parent cac225b commit bc22be3

17 files changed

+1557
-2
lines changed
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { zodResolver } from '@hookform/resolvers/zod'
2+
import type { CreateOAuthClientParams, OAuthClient } from '@supabase/supabase-js'
3+
import { Plus, Trash2, X } from 'lucide-react'
4+
import Link from 'next/link'
5+
import { useEffect } from 'react'
6+
import { useFieldArray, useForm } from 'react-hook-form'
7+
import { toast } from 'sonner'
8+
import * as z from 'zod'
9+
10+
import { useParams } from 'common'
11+
import { useOAuthServerAppCreateMutation } from 'data/oauth-server-apps/oauth-server-app-create-mutation'
12+
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
13+
import {
14+
Button,
15+
FormControl_Shadcn_,
16+
FormDescription_Shadcn_,
17+
FormField_Shadcn_,
18+
FormItem_Shadcn_,
19+
FormLabel_Shadcn_,
20+
FormMessage_Shadcn_,
21+
Form_Shadcn_,
22+
Input_Shadcn_,
23+
Separator,
24+
Sheet,
25+
SheetClose,
26+
SheetContent,
27+
SheetFooter,
28+
SheetHeader,
29+
SheetSection,
30+
SheetTitle,
31+
Switch,
32+
cn,
33+
} from 'ui'
34+
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
35+
36+
interface CreateOAuthAppSheetProps {
37+
visible: boolean
38+
onSuccess: (app: OAuthClient) => void
39+
onCancel: () => void
40+
}
41+
42+
const FormSchema = z.object({
43+
name: z
44+
.string()
45+
.min(1, 'Please provide a name for your OAuth app')
46+
.max(100, 'Name must be less than 100 characters'),
47+
type: z.enum(['manual', 'dynamic']).default('manual'),
48+
// scope: z.string().min(1, 'Please select a scope'),
49+
redirect_uris: z
50+
.object({
51+
value: z.string().trim().url('Please provide a valid URL'),
52+
})
53+
.array()
54+
.min(1, 'At least one redirect URI is required'),
55+
is_public: z.boolean().default(false),
56+
})
57+
58+
const FORM_ID = 'create-or-update-oauth-app-form'
59+
60+
const initialValues = {
61+
name: '',
62+
type: 'manual' as const,
63+
// scope: 'email',
64+
redirect_uris: [{ value: '' }],
65+
is_public: false,
66+
}
67+
68+
export const CreateOAuthAppSheet = ({ visible, onSuccess, onCancel }: CreateOAuthAppSheetProps) => {
69+
const { ref: projectRef } = useParams()
70+
71+
const form = useForm<z.infer<typeof FormSchema>>({
72+
resolver: zodResolver(FormSchema),
73+
defaultValues: initialValues,
74+
})
75+
76+
const {
77+
fields: redirectUriFields,
78+
append: appendRedirectUri,
79+
remove: removeRedirectUri,
80+
} = useFieldArray({
81+
name: 'redirect_uris',
82+
control: form.control,
83+
})
84+
85+
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
86+
87+
const { mutateAsync: createOAuthApp, isLoading: isCreating } = useOAuthServerAppCreateMutation({
88+
onSuccess: (data) => {
89+
toast.success(`Successfully created OAuth app "${data.client_name}"`)
90+
onSuccess(data)
91+
},
92+
onError: (error) => {
93+
toast.error(error.message)
94+
},
95+
})
96+
97+
useEffect(() => {
98+
if (visible) {
99+
form.reset(initialValues)
100+
}
101+
}, [visible])
102+
103+
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
104+
// Filter out empty redirect URIs
105+
const validRedirectUris = data.redirect_uris
106+
.map((uri) => uri.value.trim())
107+
.filter((uri) => uri !== '')
108+
109+
const payload: CreateOAuthClientParams = {
110+
client_name: data.name,
111+
client_uri: '',
112+
// scope: data.scope,
113+
redirect_uris: validRedirectUris,
114+
}
115+
116+
createOAuthApp({
117+
projectRef,
118+
supabaseClient: supabaseClientData?.supabaseClient,
119+
...payload,
120+
})
121+
}
122+
123+
const onClose = () => {
124+
form.reset(initialValues)
125+
onCancel()
126+
}
127+
128+
return (
129+
<>
130+
<Sheet open={visible} onOpenChange={() => onCancel()}>
131+
<SheetContent
132+
size="default"
133+
showClose={false}
134+
className="flex flex-col gap-0"
135+
tabIndex={undefined}
136+
>
137+
<SheetHeader>
138+
<div className="flex flex-row gap-3 items-center">
139+
<SheetClose
140+
className={cn(
141+
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
142+
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
143+
'disabled:pointer-events-none data-[state=open]:bg-secondary',
144+
'transition'
145+
)}
146+
>
147+
<X className="h-3 w-3" />
148+
<span className="sr-only">Close</span>
149+
</SheetClose>
150+
<SheetTitle className="truncate">Create a new OAuth app</SheetTitle>
151+
</div>
152+
</SheetHeader>
153+
<SheetSection className="overflow-auto flex-grow px-0">
154+
<Form_Shadcn_ {...form}>
155+
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
156+
<FormField_Shadcn_
157+
control={form.control}
158+
name="name"
159+
render={({ field }) => (
160+
<FormItemLayout label="Name" className={'px-5'}>
161+
<FormControl_Shadcn_>
162+
<Input_Shadcn_ {...field} placeholder="My OAuth App" />
163+
</FormControl_Shadcn_>
164+
</FormItemLayout>
165+
)}
166+
/>
167+
168+
{/* <FormField_Shadcn_
169+
control={form.control}
170+
name="scope"
171+
rules={{ required: true }}
172+
render={({ field }) => (
173+
<FormItemLayout
174+
label="Scope"
175+
layout="vertical"
176+
description={
177+
<>
178+
Select the permissions your app will request from users.{' '}
179+
<Link
180+
href="https://supabase.com/docs/guides/auth/oauth/oauth-apps#scope"
181+
target="_blank"
182+
rel="noreferrer"
183+
className="text-foreground-light underline hover:text-foreground transition"
184+
>
185+
Learn more
186+
</Link>
187+
</>
188+
}
189+
className={'px-5'}
190+
>
191+
<FormControl_Shadcn_>
192+
<Select_Shadcn_ value={field.value} onValueChange={field.onChange}>
193+
<SelectTrigger_Shadcn_ className="w-full">
194+
<SelectValue_Shadcn_ placeholder="Select scope..." />
195+
</SelectTrigger_Shadcn_>
196+
<SelectContent_Shadcn_>
197+
{OAUTH_APP_SCOPE_OPTIONS.map((scope) => (
198+
<SelectItem_Shadcn_ key={scope.value} value={scope.value}>
199+
{scope.name}
200+
</SelectItem_Shadcn_>
201+
))}
202+
</SelectContent_Shadcn_>
203+
</Select_Shadcn_>
204+
</FormControl_Shadcn_>
205+
</FormItemLayout>
206+
)}
207+
/> */}
208+
209+
<div className="px-5 gap-2 flex flex-col">
210+
<FormLabel_Shadcn_ className="text-foreground">Redirect URIs</FormLabel_Shadcn_>
211+
212+
<div className="space-y-2">
213+
{redirectUriFields.map((fieldItem, index) => (
214+
<FormField_Shadcn_
215+
control={form.control}
216+
key={fieldItem.id}
217+
name={`redirect_uris.${index}.value`}
218+
render={({ field: inputField }) => (
219+
<FormItem_Shadcn_>
220+
<div className="flex flex-row gap-2">
221+
<FormControl_Shadcn_>
222+
<Input_Shadcn_
223+
{...inputField}
224+
placeholder={'https://example.com/callback'}
225+
onChange={(e) => {
226+
inputField.onChange(e)
227+
}}
228+
/>
229+
</FormControl_Shadcn_>
230+
{redirectUriFields.length > 1 && (
231+
<Button
232+
type="default"
233+
size="tiny"
234+
className="h-[34px]"
235+
icon={<Trash2 size={14} />}
236+
onClick={() => removeRedirectUri(index)}
237+
/>
238+
)}
239+
</div>
240+
<FormMessage_Shadcn_ />
241+
</FormItem_Shadcn_>
242+
)}
243+
/>
244+
))}
245+
</div>
246+
<div>
247+
<Button
248+
type="default"
249+
icon={<Plus strokeWidth={1.5} />}
250+
onClick={() => appendRedirectUri({ value: '' })}
251+
>
252+
Add redirect URI
253+
</Button>
254+
</div>
255+
<FormDescription_Shadcn_ className="text-foreground-lighter">
256+
URLs where users will be redirected after authentication.
257+
</FormDescription_Shadcn_>
258+
</div>
259+
260+
<Separator />
261+
<FormField_Shadcn_
262+
control={form.control}
263+
name="is_public"
264+
render={({ field }) => (
265+
<FormItemLayout
266+
label="Public"
267+
layout="flex"
268+
description={
269+
<>
270+
If enabled, the Authorization Code with PKCE (Proof Key for Code Exchange)
271+
flow can be used, particularly beneficial for applications that cannot
272+
securely store Client Secrets, such as native and mobile apps.{' '}
273+
<Link
274+
href="https://supabase.com/docs/guides/auth/oauth/public-oauth-apps"
275+
target="_blank"
276+
rel="noreferrer"
277+
className="text-foreground-light underline hover:text-foreground transition"
278+
>
279+
Learn more
280+
</Link>
281+
</>
282+
}
283+
className={'px-5'}
284+
>
285+
<FormControl_Shadcn_>
286+
<Switch checked={field.value} onCheckedChange={field.onChange} />
287+
</FormControl_Shadcn_>
288+
</FormItemLayout>
289+
)}
290+
/>
291+
</form>
292+
</Form_Shadcn_>
293+
</SheetSection>
294+
<SheetFooter>
295+
<Button type="default" disabled={isCreating} onClick={onClose}>
296+
Cancel
297+
</Button>
298+
<Button htmlType="submit" form={FORM_ID} loading={isCreating}>
299+
Create app
300+
</Button>
301+
</SheetFooter>
302+
</SheetContent>
303+
</Sheet>
304+
</>
305+
)
306+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { OAuthClient } from '@supabase/supabase-js'
2+
import { useParams } from 'common'
3+
import { useOAuthServerAppDeleteMutation } from 'data/oauth-server-apps/oauth-server-app-delete-mutation'
4+
import { useSupabaseClientQuery } from 'hooks/use-supabase-client-query'
5+
import { useState } from 'react'
6+
import { toast } from 'sonner'
7+
8+
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
9+
10+
interface DeleteOAuthAppModalProps {
11+
visible: boolean
12+
selectedApp?: OAuthClient
13+
onClose: () => void
14+
}
15+
16+
export const DeleteOAuthAppModal = ({
17+
visible,
18+
selectedApp,
19+
onClose,
20+
}: DeleteOAuthAppModalProps) => {
21+
const { ref: projectRef } = useParams()
22+
const [isDeleting, setIsDeleting] = useState(false)
23+
24+
const { data: supabaseClientData } = useSupabaseClientQuery({ projectRef })
25+
26+
const { mutateAsync: deleteOAuthApp } = useOAuthServerAppDeleteMutation()
27+
28+
const onConfirmDeleteApp = async () => {
29+
if (!selectedApp) return console.error('No OAuth app selected')
30+
31+
setIsDeleting(true)
32+
33+
try {
34+
await deleteOAuthApp({
35+
projectRef,
36+
supabaseClient: supabaseClientData?.supabaseClient,
37+
clientId: selectedApp.client_id,
38+
})
39+
40+
toast.success(`Successfully deleted OAuth app "${selectedApp.client_name}"`)
41+
onClose()
42+
} catch (error) {
43+
toast.error('Failed to delete OAuth app')
44+
console.error('Error deleting OAuth app:', error)
45+
} finally {
46+
setIsDeleting(false)
47+
}
48+
}
49+
50+
return (
51+
<ConfirmationModal
52+
variant={'destructive'}
53+
size="medium"
54+
loading={isDeleting}
55+
visible={visible}
56+
title={
57+
<>
58+
Confirm to delete OAuth app <code className="text-sm">{selectedApp?.client_name}</code>
59+
</>
60+
}
61+
confirmLabel="Confirm delete"
62+
confirmLabelLoading="Deleting..."
63+
onCancel={onClose}
64+
onConfirm={() => onConfirmDeleteApp()}
65+
alert={{
66+
title: 'This action cannot be undone',
67+
description: 'You will need to re-create the OAuth app if you want to revert the deletion.',
68+
}}
69+
>
70+
<p className="text-sm">Before deleting this OAuth app, consider:</p>
71+
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
72+
<li className="list-disc ml-6">Any applications using this OAuth app will lose access</li>
73+
<li className="list-disc ml-6">This OAuth app is no longer in use by any applications</li>
74+
</ul>
75+
</ConfirmationModal>
76+
)
77+
}

0 commit comments

Comments
 (0)