mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(studio): show oauth server endpoints in oauth server settings (#41783)
Show OAuth server endpoints in oauth server settings page. Preview: [OAuth Server settings](https://studio-staging-git-chore-show-oauth-server-endpoints-supabase.vercel.app/dashboard/project/_/auth/oauth-server) <img width="1138" height="496" alt="Screenshot 2026-01-09 at 12 00 31" src="https://github.com/user-attachments/assets/eeca7726-0426-4abe-990d-271b702e4f7b" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an OAuth endpoints table showing Authorization, Token, JWKS, and Discovery/OpenID URLs with copy-to-clipboard and a masked preview mode. * Inline preview of the Authorization URL when an authorization path is set. * **Improvements** * Reorganized OAuth server settings for clearer enable/disable flow, conditional field visibility, and disable confirmation. * Dynamic loading of the endpoints table, improved loading skeletons, layout refinements, and form reset to reflect saved defaults. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
a325e86845
commit
82f798f75a
5 files changed
+350
-138
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { useParams } from 'common'
|
||||
import { Card, CardContent, cn } from 'ui'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionDescription,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { useOpenIDConfigurationQuery } from '@/data/oauth-server-apps/oauth-openid-configuration-query'
|
||||
|
||||
interface OAuthEndpointsTableProps {
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const OAuthEndpointsTable = ({
|
||||
isLoading: isLoadingProp = false,
|
||||
className,
|
||||
}: OAuthEndpointsTableProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const { data: openidConfig, isLoading: isEndpointsLoading } = useOpenIDConfigurationQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isLoadingProp }
|
||||
)
|
||||
|
||||
const isLoading = isLoadingProp || isEndpointsLoading
|
||||
|
||||
const endpoints = [
|
||||
{
|
||||
name: 'Authorization endpoint',
|
||||
value: openidConfig?.authorization_endpoint,
|
||||
},
|
||||
{
|
||||
name: 'Token endpoint',
|
||||
value: openidConfig?.token_endpoint,
|
||||
},
|
||||
{
|
||||
name: 'JWKS endpoint',
|
||||
value: openidConfig?.jwks_uri,
|
||||
},
|
||||
{
|
||||
name: 'OIDC discovery',
|
||||
value: openidConfig?.issuer
|
||||
? `${openidConfig.issuer}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageSection className={cn(className)}>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>OAuth Endpoints</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
Share these endpoints with third-party applications that need to integrate with your
|
||||
OAuth 2.1 server.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 pt-0 divide-y">
|
||||
{isLoading ? (
|
||||
<GenericSkeletonLoader className="mt-4" />
|
||||
) : (
|
||||
endpoints.map((endpoint) => (
|
||||
<FormItemLayout
|
||||
key={endpoint.name}
|
||||
layout="horizontal"
|
||||
isReactForm={false}
|
||||
label={endpoint.name}
|
||||
className="mt-4"
|
||||
>
|
||||
<Input readOnly copy value={endpoint.value ?? ''} />
|
||||
</FormItemLayout>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Input_Shadcn_,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
@@ -30,6 +32,10 @@ import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-a
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
const OAuthEndpointsTable = dynamic(() =>
|
||||
import('./OAuthEndpointsTable').then((mod) => ({ default: mod.OAuthEndpointsTable }))
|
||||
)
|
||||
|
||||
const configUrlSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -80,8 +86,16 @@ export const OAuthServerSettingsForm = () => {
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
|
||||
const { mutate: updateAuthConfig, isPending } = useAuthConfigUpdateMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success('OAuth server settings updated successfully')
|
||||
form.reset({
|
||||
OAUTH_SERVER_ENABLED: variables.config.OAUTH_SERVER_ENABLED ?? false,
|
||||
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION:
|
||||
variables.config.OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION ?? false,
|
||||
OAUTH_SERVER_AUTHORIZATION_PATH:
|
||||
variables.config.OAUTH_SERVER_AUTHORIZATION_PATH ?? '/oauth/consent',
|
||||
availableScopes: ['openid', 'email', 'profile'],
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update OAuth server settings: ${error?.message}`)
|
||||
@@ -182,134 +196,41 @@ export const OAuthServerSettingsForm = () => {
|
||||
}
|
||||
|
||||
if (isAuthConfigLoading || isLoadingPermissions) {
|
||||
return <GenericSkeletonLoader />
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<GenericSkeletonLoader />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OAuthEndpointsTable isLoading />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<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.{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/auth/oauth-server`}>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<FormItemLayout
|
||||
label="Site URL"
|
||||
layout="flex-row-reverse"
|
||||
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>
|
||||
</CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="OAUTH_SERVER_AUTHORIZATION_PATH"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Authorization Path"
|
||||
layout="flex-row-reverse"
|
||||
description="Path where you'll implement the OAuth authorization UI (consent screens)."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="/auth/authorize" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{(() => {
|
||||
const authorizationUrl = `${authConfig?.SITE_URL}${form.watch('OAUTH_SERVER_AUTHORIZATION_PATH') || '/oauth/consent'}`
|
||||
return (
|
||||
<Admonition
|
||||
type="tip"
|
||||
title="Make sure this path is implemented in your application."
|
||||
description={
|
||||
<>
|
||||
Preview Authorization URL:{' '}
|
||||
<a
|
||||
href={authorizationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
{authorizationUrl}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION"
|
||||
name="OAUTH_SERVER_ENABLED"
|
||||
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.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/auth/oauth-server/mcp-authentication#oauth-client-setup`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
label="Enable the Supabase OAuth Server"
|
||||
description="Enable OAuth server functionality for your project to create and manage OAuth applications."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={handleDynamicAppsToggle}
|
||||
onCheckedChange={handleOAuthServerToggle}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
@@ -317,25 +238,137 @@ export const OAuthServerSettingsForm = () => {
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
{form.watch('OAUTH_SERVER_ENABLED') && (
|
||||
<>
|
||||
<CardContent>
|
||||
<FormItemLayout
|
||||
label="Site URL"
|
||||
layout="flex-row-reverse"
|
||||
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>
|
||||
</CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="OAUTH_SERVER_AUTHORIZATION_PATH"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Authorization Path"
|
||||
layout="flex-row-reverse"
|
||||
description="Path where you'll implement the OAuth authorization UI (consent screens)."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="/auth/authorize" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{(() => {
|
||||
const siteUrl = authConfig?.SITE_URL?.trim()
|
||||
const authorizationPath =
|
||||
form.watch('OAUTH_SERVER_AUTHORIZATION_PATH')?.trim() || '/oauth/consent'
|
||||
const authorizationUrl = siteUrl ? `${siteUrl}${authorizationPath}` : ''
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
<Button type="default" onClick={() => form.reset()} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || !form.formState.isDirty}
|
||||
loading={isPending}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
return (
|
||||
<Admonition
|
||||
type="tip"
|
||||
title="Make sure this path is implemented in your application."
|
||||
description={
|
||||
<>
|
||||
Preview Authorization URL:{' '}
|
||||
{authorizationUrl ? (
|
||||
<a
|
||||
href={authorizationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground-light underline hover:text-foreground transition"
|
||||
>
|
||||
{authorizationUrl}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-foreground-light">
|
||||
Set a Site URL to preview
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<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.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/auth/oauth-server/mcp-authentication#oauth-client-setup`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canUpdateConfig || !form.formState.isDirty}
|
||||
loading={isPending}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
{isSuccess && authConfig?.OAUTH_SERVER_ENABLED && form.watch('OAUTH_SERVER_ENABLED') && (
|
||||
<OAuthEndpointsTable isLoading={isPending} />
|
||||
)}
|
||||
|
||||
{/* Dynamic Apps Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
|
||||
@@ -2,4 +2,6 @@ export const oauthServerAppKeys = {
|
||||
// temporaryApiKey has to be added to reset the query when it changes
|
||||
list: (projectRef: string | undefined, clientEndpoint: string | undefined) =>
|
||||
['projects', projectRef, 'oauth-server-apps', clientEndpoint] as const,
|
||||
openidConfiguration: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'oauth-server-openid-configuration'] as const,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useAuthConfigQuery } from '../auth/auth-config-query'
|
||||
import { oauthServerAppKeys } from './keys'
|
||||
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
||||
import { handleError } from '@/data/fetchers'
|
||||
import type { ResponseError, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export type OpenIDConfigurationVariables = {
|
||||
projectRef: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenID Connect Discovery response
|
||||
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
*/
|
||||
export type OpenIDConfiguration = {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
userinfo_endpoint?: string
|
||||
jwks_uri: string
|
||||
registration_endpoint?: string
|
||||
scopes_supported?: string[]
|
||||
response_types_supported?: string[]
|
||||
grant_types_supported?: string[]
|
||||
subject_types_supported?: string[]
|
||||
id_token_signing_alg_values_supported?: string[]
|
||||
token_endpoint_auth_methods_supported?: string[]
|
||||
code_challenge_methods_supported?: string[]
|
||||
}
|
||||
|
||||
export async function getOpenIDConfiguration({
|
||||
clientEndpoint,
|
||||
}: {
|
||||
clientEndpoint: string | undefined
|
||||
}): Promise<OpenIDConfiguration> {
|
||||
if (!clientEndpoint) throw new Error('Client endpoint is required')
|
||||
|
||||
const response = await fetch(`${clientEndpoint}/auth/v1/.well-known/openid-configuration`)
|
||||
|
||||
if (!response.ok) {
|
||||
handleError({ message: `Failed to fetch OpenID configuration: ${response.statusText}` })
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export type OpenIDConfigurationData = Awaited<ReturnType<typeof getOpenIDConfiguration>>
|
||||
export type OpenIDConfigurationError = ResponseError
|
||||
|
||||
export const useOpenIDConfigurationQuery = <TData = OpenIDConfigurationData>(
|
||||
{ projectRef }: OpenIDConfigurationVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<OpenIDConfigurationData, OpenIDConfigurationError, TData> = {}
|
||||
) => {
|
||||
const { data: clientEndpoint, isPending: isEndpointLoading } = useProjectApiUrl({
|
||||
projectRef,
|
||||
})
|
||||
|
||||
const {
|
||||
data: authConfig,
|
||||
isSuccess: isSuccessConfig,
|
||||
isLoading: isAuthConfigLoading,
|
||||
} = useAuthConfigQuery({ projectRef })
|
||||
const isOAuthServerEnabled = !!authConfig?.OAUTH_SERVER_ENABLED
|
||||
|
||||
const isQueryEnabled =
|
||||
enabled &&
|
||||
typeof projectRef !== 'undefined' &&
|
||||
!!clientEndpoint &&
|
||||
isSuccessConfig &&
|
||||
isOAuthServerEnabled
|
||||
|
||||
const query = useQuery<OpenIDConfigurationData, OpenIDConfigurationError, TData>({
|
||||
queryKey: oauthServerAppKeys.openidConfiguration(projectRef),
|
||||
queryFn: () => getOpenIDConfiguration({ clientEndpoint }),
|
||||
enabled: isQueryEnabled,
|
||||
...options,
|
||||
})
|
||||
|
||||
// Include loading states from dependencies
|
||||
const isLoading = query.isLoading || isEndpointLoading || isAuthConfigLoading
|
||||
|
||||
return {
|
||||
...query,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,7 @@ const ProvidersPage: NextPageWithLayout = () => {
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
<PageContainer size="default">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<OAuthServerSettingsForm />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<OAuthServerSettingsForm />
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user