OpenAPI: Support OAuth 2.0 in-browser authorize dialog

This commit is contained in:
Fuma Nama
2025-02-25 19:17:10 +08:00
parent b7da7ed08c
commit ecf7288804
12 changed files with 571 additions and 154 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support OAuth 2.0 in-browser authorize dialog
+8 -1
View File
@@ -5,7 +5,14 @@ import { baseOptions } from '@/app/layout.config';
export default function RootDocsLayout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
<DocsLayout
tree={source.pageTree}
{...baseOptions}
nav={{
...baseOptions.nav,
mode: 'top',
}}
>
{children}
</DocsLayout>
);
+10 -3
View File
@@ -62,9 +62,16 @@
"components": {
"securitySchemes": {
"bearerAuth": {
"bearerFormat": "root key",
"type": "http",
"scheme": "bearer"
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://discord.com/oauth2/authorize",
"tokenUrl": "https://discord.com/api/oauth2/token",
"scopes": {
"identify": "Get profile information"
}
}
}
}
},
"schemas": {
+1
View File
@@ -51,6 +51,7 @@
},
"dependencies": {
"@fumari/json-schema-to-typescript": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@scalar/openapi-parser": "0.10.8",
@@ -0,0 +1,205 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/ui/components/dialog';
import type { OpenAPIV3_1 } from 'openapi-types';
import { useForm } from 'react-hook-form';
import { Input, labelVariants } from '@/ui/components/input';
import { useQuery } from '@/utils/use-query';
import { useEffect, useState } from 'react';
import { cn } from 'fumadocs-ui/components/api';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
export interface AuthDialogProps {
flow: keyof OpenAPIV3_1.OAuth2SecurityScheme['flows'];
scheme: OpenAPIV3_1.OAuth2SecurityScheme;
setToken: (token: string) => void;
children: React.ReactNode;
}
interface FormValues {
clientId: string;
clientSecret: string;
}
interface OAuthState {
redirect_uri: string;
client_id: string;
client_secret: string;
}
export function OauthDialog({
flow,
scheme,
setToken,
children,
}: AuthDialogProps) {
const [open, setOpen] = useState(false);
const form = useForm<FormValues>({
defaultValues: {
clientId: '',
clientSecret: '',
},
});
const tokenQuery = useQuery(async (code: string, state: OAuthState) => {
const value = scheme.flows.authorizationCode!;
const res = await fetch(value.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
// note: `state` could be invalid, but server will check it
redirect_uri: state.redirect_uri,
client_id: state.client_id,
client_secret: state.client_secret,
}).toString(),
});
if (!res.ok) throw new Error(await res.text());
const { access_token } = (await res.json()) as {
access_token: string;
};
setToken(access_token);
setOpen(false);
});
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (!code || !state || flow !== 'authorizationCode') return;
const parsedState = JSON.parse(state) as OAuthState;
setOpen(true);
form.setValue('clientId', parsedState.client_id);
form.setValue('clientSecret', parsedState.client_secret);
tokenQuery.start(code, parsedState);
window.history.replaceState(null, '', window.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps -- first page load only
}, []);
const authorize = useQuery(async (values: FormValues) => {
if (flow === 'authorizationCode') {
const value = scheme.flows[flow]!;
const params = new URLSearchParams();
params.set('response_type', 'code');
params.set('client_id', values.clientId);
params.set('redirect_uri', window.location.href);
params.set('scope', Object.keys(value.scopes).join('+'));
params.set(
'state',
JSON.stringify({
client_id: values.clientId,
client_secret: values.clientSecret,
redirect_uri: window.location.href,
} satisfies OAuthState),
);
window.location.replace(`${value.authorizationUrl}?${params.toString()}`);
}
if (flow === 'clientCredentials') {
const value = scheme.flows[flow]!;
await fetch(value.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: Object.keys(value.scopes).join('+'),
}).toString(),
});
}
});
const onSubmit = form.handleSubmit((values) => {
return authorize.start(values);
});
const isLoading = authorize.isLoading || tokenQuery.isLoading;
return (
<Dialog open={open} onOpenChange={setOpen}>
{children}
<DialogContent>
<DialogHeader>
<DialogTitle>Authorization</DialogTitle>
<DialogDescription>
Obtain the access token for API.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-6" onSubmit={onSubmit}>
{(flow === 'authorizationCode' || flow === 'clientCredentials') && (
<>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_id" className={cn(labelVariants())}>
Client ID
</label>
<p className="text-fd-muted-foreground text-sm">
The client ID of your OAuth application.
</p>
<Input
id="client_id"
placeholder="Enter value"
type="text"
disabled={isLoading}
{...form.register('clientId')}
/>
</fieldset>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_secret" className={cn(labelVariants())}>
Client Secret
</label>
<p className="text-fd-muted-foreground text-sm">
The client secret of your OAuth application.
</p>
<Input
id="client_secret"
placeholder="Enter value"
type="password"
disabled={isLoading}
{...form.register('clientSecret')}
/>
</fieldset>
</>
)}
{tokenQuery.error ? (
<p className="text-red-400 font-medium text-sm">
{String(tokenQuery.error)}
</p>
) : null}
<button
disabled={isLoading}
className={cn(
buttonVariants({
color: 'primary',
}),
)}
>
{tokenQuery.isLoading ? 'Fetching token...' : 'Submit'}
</button>
</form>
</DialogContent>
</Dialog>
);
}
export const OauthDialogTrigger = DialogTrigger;
+167 -132
View File
@@ -42,6 +42,10 @@ import {
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import type { Security } from '@/utils/get-security';
import {
OauthDialog,
OauthDialogTrigger,
} from '@/playground/auth/oauth-dialog';
interface FormValues {
authorization:
@@ -163,34 +167,32 @@ export function Client({
const query = { ...input.query };
const header = { ...input.header };
if (input.authorization && authorization) {
if (authorization.type === 'apiKey') {
if (authorization.in === 'header') {
header[authorization.name] = input.authorization;
} else if (authorization.in === 'query') {
query[authorization.name] = input.authorization;
} else {
if ('cookie' in header) {
header.Cookie = header.cookie;
delete header.cookie;
}
header.Cookie = [
header.Cookie as string,
`${authorization.name}=${input.authorization}`,
]
.filter((s) => s.length > 0)
.join('; ');
}
} else if (
authorization.type === 'http' &&
authorization.scheme === 'basic'
) {
if (typeof input.authorization === 'object')
header.Authorization = `Basic ${btoa(`${input.authorization.username}:${input.authorization.password}`)}`;
if (authorization?.type === 'apiKey') {
if (authorization.in === 'header') {
header[authorization.name] = input.authorization;
} else if (authorization.in === 'query') {
query[authorization.name] = input.authorization;
} else {
header.Authorization = input.authorization;
if ('cookie' in header) {
header.Cookie = header.cookie;
delete header.cookie;
}
header.Cookie = [
header.Cookie as string,
`${authorization.name}=${input.authorization}`,
]
.filter((s) => s.length > 0)
.join('; ');
}
} else if (
authorization?.type === 'http' &&
authorization.scheme === 'basic'
) {
if (typeof input.authorization === 'object')
header.Authorization = `Basic ${btoa(`${input.authorization.username}:${input.authorization.password}`)}`;
} else if (authorization) {
header.Authorization = input.authorization;
}
const serverUrl = serverRef.current
@@ -260,6 +262,144 @@ export function Client({
);
}
let authField: ReactNode = null;
if (authorization) {
authField = (
<>
{renderCustomField(
'authorization',
authorization?.type === 'http' && authorization.scheme === 'basic'
? {
name: 'Authorization',
type: 'object',
isRequired: true,
properties: {
username: {
type: 'string',
isRequired: true,
defaultValue: '',
},
password: {
type: 'string',
isRequired: true,
defaultValue: '',
},
},
}
: {
name: 'Authorization',
type: 'string',
isRequired: true,
description: 'The Authorization access token',
defaultValue: '',
},
fields.auth,
)}
{authorization?.type === 'oauth2' && (
<OauthDialogTrigger
type="button"
className={cn(
buttonVariants({
color: 'secondary',
}),
)}
>
Open
</OauthDialogTrigger>
)}
</>
);
}
let children = (
<form
{...props}
className={cn(
'not-prose flex flex-col gap-2 rounded-xl border p-3 shadow-md',
props.className,
)}
onSubmit={onSubmit}
>
<FormHeader
method={method}
route={route}
isLoading={testQuery.isLoading}
/>
{servers.length > 1 ? (
<CollapsiblePanel title="Server URL">
<ServerSelect />
</CollapsiblePanel>
) : null}
{header.length > 0 || authorization ? (
<CollapsiblePanel title="Headers">
{authField}
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{path.length > 0 ? (
<CollapsiblePanel title="Path">
{path.map((field) =>
renderCustomField(
`path.${field.name}`,
field,
fields.path,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{query.length > 0 ? (
<CollapsiblePanel title="Query">
{query.map((field) =>
renderCustomField(
`query.${field.name}`,
field,
fields.query,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{body ? (
<CollapsiblePanel title="Body">
{body.type === 'object' && !fields.body ? (
<ObjectInput field={body} fieldName="body" />
) : (
renderCustomField('body', body, fields.body)
)}
</CollapsiblePanel>
) : null}
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
</form>
);
if (authorization?.type === 'oauth2') {
// only the first one, so it looks simpler :)
const flow = Object.keys(authorization.flows)[0];
children = (
<OauthDialog
flow={flow as keyof typeof authorization.flows}
scheme={authorization}
setToken={(token) => form.setValue('authorization', `Bearer ${token}`)}
>
{children}
</OauthDialog>
);
}
return (
<FormProvider {...form}>
<SchemaContext.Provider
@@ -268,112 +408,7 @@ export function Client({
[schemas],
)}
>
<form
{...props}
className={cn(
'not-prose flex flex-col gap-2 rounded-xl border p-3 shadow-md',
props.className,
)}
onSubmit={onSubmit as React.FormEventHandler}
>
<FormHeader
method={method}
route={route}
isLoading={testQuery.isLoading}
/>
{servers.length > 1 ? (
<CollapsiblePanel title="Server URL">
<ServerSelect />
</CollapsiblePanel>
) : null}
{header.length > 0 || authorization ? (
<CollapsiblePanel title="Headers">
{authorization?.type === 'http' &&
authorization.scheme === 'basic'
? renderCustomField(
'authorization',
{
name: 'Authorization',
type: 'object',
isRequired: true,
properties: {
username: {
type: 'string',
isRequired: true,
defaultValue: '',
},
password: {
type: 'string',
isRequired: true,
defaultValue: '',
},
},
},
fields.auth,
)
: authorization
? renderCustomField(
'authorization',
{
name: 'Authorization',
type: 'string',
isRequired: true,
description: 'The Authorization access token',
defaultValue: '',
},
fields.auth,
)
: null}
{header.map((field) =>
renderCustomField(
`header.${field.name}`,
field,
fields.header,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{path.length > 0 ? (
<CollapsiblePanel title="Path">
{path.map((field) =>
renderCustomField(
`path.${field.name}`,
field,
fields.path,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{query.length > 0 ? (
<CollapsiblePanel title="Query">
{query.map((field) =>
renderCustomField(
`query.${field.name}`,
field,
fields.query,
field.name,
),
)}
</CollapsiblePanel>
) : null}
{body ? (
<CollapsiblePanel title="Body">
{body.type === 'object' && !fields.body ? (
<ObjectInput field={body} fieldName="body" />
) : (
renderCustomField('body', body, fields.body)
)}
</CollapsiblePanel>
) : null}
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
</form>
{children}
</SchemaContext.Provider>
</FormProvider>
);
+19 -2
View File
@@ -153,9 +153,26 @@ function getAuthorizationField(
const singular = security.find(
(requirements) => Object.keys(requirements).length === 1,
);
if (!singular) return;
if (!singular) {
console.warn(
`Cannot find suitable security schema for API Playground from ${JSON.stringify(security, null, 2)}. Only one requirement is allowed`,
);
return;
}
return getSecurities(singular, document)[0];
const scheme = getSecurities(singular, document)[0];
if (scheme.type === 'oauth2') {
const flow = Object.keys(scheme.flows).at(0);
if (!flow) throw new Error("security scheme's `flows` must not be empty");
if (flow === 'implicit' || flow === 'password')
throw new Error(
`OAuth 2.0 flow type: ${flow} is not supported, consider other types like \`authorizationCode\` instead.`,
);
}
return scheme;
}
function getIdFromSchema(
@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from 'fumadocs-ui/components/api';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-fd-popover p-6 shadow-lg duration-200 data-[state=open]:animate-fd-dialog-in data-[state=closed]:animate-fd-dialog-out sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-fd-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-fd-accent data-[state=open]:text-fd-muted-foreground">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-start',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-fd-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva } from 'class-variance-authority';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const labelVariants = cva(
'text-[13px] font-mono text-fd-card-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'text-[13px] font-medium text-fd-card-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Input = React.forwardRef<HTMLInputElement, InputProps>(
+16 -8
View File
@@ -1,34 +1,42 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
export function useQuery<I, T>(
fn: (input: I) => Promise<T>,
export function useQuery<I extends unknown[], T>(
fn: (...input: I) => Promise<T>,
): {
start: (input: I) => void;
start: (...input: I) => void;
data?: T;
error?: unknown;
isLoading: boolean;
} {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<T>();
const [error, setError] = useState<T>();
const fnRef = useRef(fn);
fnRef.current = fn;
return useMemo(
() => ({
isLoading: loading,
data,
start(input) {
error,
start(...input) {
setLoading(true);
void fn(input)
void fnRef
.current(...input)
.then((res) => {
setData(res);
setError(undefined);
})
.catch(() => {
.catch((err) => {
setData(undefined);
setError(err);
})
.finally(() => {
setLoading(false);
});
},
}),
[data, fn, loading],
[error, data, loading],
);
}
+14 -7
View File
@@ -7,7 +7,8 @@
"name": "name",
"description": "",
"tags": {},
"type": "string"
"type": "string",
"required": true
},
{
"name": "age",
@@ -15,7 +16,8 @@
"tags": {
"defaultValue": "4"
},
"type": "number"
"type": "number",
"required": false
}
]
},
@@ -27,7 +29,8 @@
"name": "name",
"description": "",
"tags": {},
"type": "string"
"type": "string",
"required": true
},
{
"name": "age",
@@ -35,13 +38,15 @@
"tags": {
"defaultValue": "4"
},
"type": "number"
"type": "number",
"required": false
},
{
"name": "generic",
"description": "",
"tags": {},
"type": "GenericType<string, string, string>"
"type": "GenericType<string, string, string>",
"required": true
}
]
},
@@ -56,7 +61,8 @@
"see": "https://fumadocs.vercel.app",
"defaultValue": "Henry"
},
"type": "string"
"type": "string",
"required": true
},
{
"name": "age",
@@ -65,7 +71,8 @@
"example": "```js\nconsole.log(\"Hello World\")\n```",
"remarks": "`timestamp`\nReturned by API"
},
"type": "timestamp"
"type": "timestamp",
"required": true
}
]
}
+3
View File
@@ -904,6 +904,9 @@ importers:
'@fumari/json-schema-to-typescript':
specifier: ^1.1.2
version: 1.1.2
'@radix-ui/react-dialog':
specifier: ^1.1.6
version: 1.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.1.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])