OpenAPI: implement all OAuth flows

This commit is contained in:
Fuma Nama
2025-05-28 23:22:01 +08:00
parent 589d1011d1
commit 5067efc13d
6 changed files with 395 additions and 185 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Improved support for all OAuth flows
+1 -1
View File
@@ -4,5 +4,5 @@
@import 'fumadocs-openapi/css/preset.css';
:root {
--fd-layout-width: 1400px;
--fd-layout-width: 1600px;
}
@@ -13,11 +13,22 @@ import { useQuery } from '@/utils/use-query';
import { useEffect, useState } from 'react';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/ui/components/select';
type FlowType = keyof OpenAPIV3_1.OAuth2SecurityScheme['flows'];
export interface AuthDialogProps {
flow: keyof OpenAPIV3_1.OAuth2SecurityScheme['flows'];
scheme: OpenAPIV3_1.OAuth2SecurityScheme;
scopes: string[];
open: boolean;
setOpen: (v: boolean) => void;
setToken: (token: string) => void;
children: React.ReactNode;
}
@@ -25,116 +36,221 @@ export interface AuthDialogProps {
interface FormValues {
clientId: string;
clientSecret: string;
username: string;
password: string;
}
interface OAuthState {
interface AuthCodeState {
redirect_uri: string;
client_id: string;
client_secret: string;
}
interface ImplicitState {
redirect_uri: string;
client_id: string;
}
const FlowTypes = {
password: {
name: 'Resource Owner Password Flow',
description: 'Authenticate using username and password.',
},
clientCredentials: {
name: 'Client Credentials',
description: 'Intended for the server-to-server authentication.',
},
authorizationCode: {
name: 'Authorization code',
description: 'Authenticate with 3rd party services',
},
implicit: {
name: 'Implicit',
description: 'Retrieve the access token directly.',
},
} as const;
export function OauthDialog({
flow,
scheme,
scopes,
setToken,
children,
open,
setOpen,
}: AuthDialogProps) {
const [open, setOpen] = useState(false);
const [type, setType] = useState(() => {
return Object.keys(scheme.flows)[0] as FlowType;
});
const form = useForm<FormValues>({
defaultValues: {
clientId: '',
clientSecret: '',
username: '',
password: '',
},
});
const tokenQuery = useQuery(async (code: string, state: OAuthState) => {
const value = scheme.flows.authorizationCode!;
const authCodeCallback = useQuery(
async (code: string, state: AuthCodeState) => {
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,
}),
});
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,
}),
});
if (!res.ok) throw new Error(await res.text());
const { access_token } = (await res.json()) as {
access_token: string;
};
if (!res.ok) throw new Error(await res.text());
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
access_token: string;
token_type?: string;
};
setToken(access_token);
setOpen(false);
});
setToken(`${token_type} ${access_token}`);
setOpen(false);
},
);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (scheme.flows.authorizationCode) {
const params = new URLSearchParams(window.location.search);
const state = params.get('state');
const code = params.get('code');
const code = params.get('code');
const state = params.get('state');
if (state && code) {
const parsedState = JSON.parse(state) as AuthCodeState;
setOpen(true);
if (!code || !state || flow !== 'authorizationCode') return;
form.setValue('clientId', parsedState.client_id);
form.setValue('clientSecret', parsedState.client_secret);
authCodeCallback.start(code, parsedState);
window.history.replaceState(null, '', window.location.pathname);
return;
}
}
const parsedState = JSON.parse(state) as OAuthState;
if (scheme.flows.implicit && window.location.hash.length > 1) {
const params = new URLSearchParams(window.location.hash.slice(1));
const state = params.get('state');
const token = params.get('access_token');
const type = params.get('token_type') ?? 'Bearer';
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);
if (state && token) {
const parsedState = JSON.parse(state) as ImplicitState;
form.setValue('clientId', parsedState.client_id);
setToken(`${type} ${token}`);
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]!;
if (type === 'implicit') {
const value = scheme.flows[type]!;
const params = new URLSearchParams();
params.set('response_type', 'token');
params.set('client_id', values.clientId);
params.set('redirect_uri', window.location.href);
params.set('scope', scopes.join('+'));
params.set(
'state',
JSON.stringify({
client_id: values.clientId,
redirect_uri: window.location.href,
} satisfies ImplicitState),
);
window.location.replace(`${value.authorizationUrl}?${params.toString()}`);
return;
}
if (type === 'authorizationCode') {
const value = scheme.flows[type]!;
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('scope', scopes.join('+'));
params.set(
'state',
JSON.stringify({
client_id: values.clientId,
client_secret: values.clientSecret,
redirect_uri: window.location.href,
} satisfies OAuthState),
} satisfies AuthCodeState),
);
window.location.replace(`${value.authorizationUrl}?${params.toString()}`);
return;
}
if (flow === 'clientCredentials') {
const value = scheme.flows[flow]!;
let res;
if (type === 'password') {
const value = scheme.flows[type]!;
await fetch(value.tokenUrl, {
res = await fetch(value.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'password',
username: values.username,
password: values.password,
scope: scopes.join('+'),
}),
});
}
if (type === 'clientCredentials') {
const value = scheme.flows[type]!;
res = 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('+'),
client_id: values.clientId,
client_secret: values.clientSecret,
scope: scopes.join('+'),
}),
});
}
if (res) {
if (!res.ok) throw new Error(await res.text());
const { access_token, token_type = 'Bearer' } = (await res.json()) as {
access_token: string;
token_type?: string;
};
setToken(`${token_type} ${access_token}`);
setOpen(false);
}
});
const onSubmit = form.handleSubmit((values) => {
return authorize.start(values);
});
const isLoading = authorize.isLoading || tokenQuery.isLoading;
const isLoading = authorize.isLoading || authCodeCallback.isLoading;
const error = authCodeCallback.error ?? authorize.error;
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -146,47 +262,104 @@ export function OauthDialog({
Obtain the access token for API.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-6" onSubmit={onSubmit}>
{(flow === 'authorizationCode' || flow === 'clientCredentials') && (
<form
className="flex flex-col gap-6"
onSubmit={(e) => {
void onSubmit(e);
e.stopPropagation();
}}
>
<Select value={type} onValueChange={setType as (s: string) => void}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(scheme.flows).map((key) => {
const { name, description } = FlowTypes[key as FlowType];
return (
<SelectItem key={key} value={key}>
<p className="font-medium">{name}</p>
<p className="text-fd-muted-foreground">{description}</p>
</SelectItem>
);
})}
</SelectContent>
</Select>
{(type === 'authorizationCode' ||
type === 'clientCredentials' ||
type === 'implicit') && (
<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"
autoComplete="off"
disabled={isLoading}
{...form.register('clientId', { required: true })}
/>
</fieldset>
)}
{(type === 'authorizationCode' || type === 'clientCredentials') && (
<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"
autoComplete="off"
disabled={isLoading}
{...form.register('clientSecret', { required: true })}
/>
</fieldset>
)}
{type === 'password' && (
<>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_id" className={cn(labelVariants())}>
Client ID
<label htmlFor="username" className={cn(labelVariants())}>
Username
</label>
<p className="text-fd-muted-foreground text-sm">
The client ID of your OAuth application.
</p>
<Input
id="client_id"
id="username"
placeholder="Enter value"
type="text"
disabled={isLoading}
{...form.register('clientId', { required: true })}
autoComplete="off"
{...form.register('username', { required: true })}
/>
</fieldset>
<fieldset className="flex flex-col gap-1.5">
<label htmlFor="client_secret" className={cn(labelVariants())}>
<label htmlFor="password" 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"
id="password"
placeholder="Enter value"
type="password"
autoComplete="off"
disabled={isLoading}
{...form.register('clientSecret', { required: true })}
{...form.register('password', { required: true })}
/>
</fieldset>
</>
)}
{tokenQuery.error ? (
<p className="text-red-400 font-medium text-sm">
{String(tokenQuery.error)}
</p>
{error ? (
<p className="text-red-400 font-medium text-sm">{String(error)}</p>
) : null}
<button
type="submit"
disabled={isLoading}
className={cn(
buttonVariants({
@@ -194,7 +367,7 @@ export function OauthDialog({
}),
)}
>
{tokenQuery.isLoading ? 'Fetching token...' : 'Submit'}
{authCodeCallback.isLoading ? 'Fetching token...' : 'Submit'}
</button>
</form>
</DialogContent>
+147 -115
View File
@@ -124,6 +124,16 @@ function toRequestData(
}
const ServerSelect = lazy(() => import('@/ui/server-select'));
const OauthDialog = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
default: mod.OauthDialog,
})),
);
const OauthDialogTrigger = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
default: mod.OauthDialogTrigger,
})),
);
export default function Client({
route,
@@ -175,39 +185,57 @@ export default function Client({
});
});
function initAuthValues() {
function initAuthValues(values: FormValues, inputs: AuthField[]) {
for (const item of inputs) {
let value = item.defaultValue;
const stored = localStorage.getItem(AuthPrefix + item.original.id);
manipulateValues(values, item.fieldName, () => {
const stored = localStorage.getItem(AuthPrefix + item.original.id);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === typeof item.defaultValue) value = parsed;
}
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === typeof item.defaultValue) return parsed;
}
// @ts-expect-error -- safe
form.setValue(item.fieldName, value);
return item.defaultValue;
});
}
return values;
}
useOnChange(defaultValues, () => {
fieldInfoMap.clear();
form.reset(defaultValues);
initAuthValues();
form.reset(initAuthValues(defaultValues, inputs));
});
useOnChange(inputs, (_, previous) => {
for (const item of previous) {
form.reset(
(values) =>
manipulateValues(
values as unknown as Record<string, unknown>,
item.fieldName,
() => undefined,
) as unknown as FormValues,
);
useOnChange(inputs, (current, previous) => {
form.reset((values) => {
for (const item of previous) {
if (current.some(({ original }) => original.id === item.original.id)) {
continue;
}
manipulateValues(values, item.fieldName, () => undefined);
}
return initAuthValues(values, current);
});
});
const onUpdateDebounced = useEffectEvent((values: FormValues) => {
for (const item of inputs) {
const value = item.fieldName
.split('.')
.reduce((v, seg) => v[seg as keyof object], values as object);
if (value) {
localStorage.setItem(
AuthPrefix + item.original.id,
JSON.stringify(value),
);
}
}
initAuthValues();
updater.setData(toRequestData(method, body?.mediaType, mapInputs(values)));
});
useEffect(() => {
@@ -220,29 +248,12 @@ export default function Client({
callback({ values }) {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(
() => {
for (const item of inputs) {
const value = item.fieldName
.split('.')
.reduce((v, seg) => v[seg as keyof object], values as object);
if (value) {
localStorage.setItem(
AuthPrefix + item.original.id,
JSON.stringify(value),
);
}
}
updater.setData(
toRequestData(method, body?.mediaType, mapInputs(values)),
);
},
() => onUpdateDebounced(values),
timer ? 400 : 0,
);
},
});
initAuthValues();
form.reset((values) => initAuthValues(values, inputs));
return () => subscription();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mounted once only
@@ -284,27 +295,15 @@ export default function Client({
</div>
{securities.length > 0 && (
<div className="m-3 p-3 border bg-fd-muted rounded-lg">
<div className="flex gap-4 items-center justify-between mb-4">
<p className="text-sm font-medium">Authorization</p>
<select
value={securityId.toString()}
onChange={(e) => setSecurityId(Number(e.target.value))}
className="text-xs font-mono px-2 py-1.5 outline-none"
>
{securities.map((security, i) => (
<option key={i} value={i}>
{security.map((item) => item.id).join(' & ')}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-4">
{inputs.map((input) => (
<Fragment key={input.fieldName}>{input.children}</Fragment>
))}
</div>
</div>
<SecurityTabs
securities={securities}
securityId={securityId}
setSecurityId={setSecurityId}
>
{inputs.map((input) => (
<Fragment key={input.fieldName}>{input.children}</Fragment>
))}
</SecurityTabs>
)}
<FormBody body={body} fields={fields} parameters={parameters} />
{testQuery.data ? <ResultDisplay data={testQuery.data} /> : null}
@@ -314,6 +313,67 @@ export default function Client({
);
}
function SecurityTabs({
securities,
setSecurityId,
securityId,
children,
}: {
securities: SecurityEntry[][];
securityId: number;
setSecurityId: (value: number) => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const form = useFormContext();
const result = (
<CollapsiblePanel title="Authorization">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<select
value={securityId.toString()}
onChange={(e) => setSecurityId(Number(e.target.value))}
className="text-sm font-mono px-2 py-1.5 border rounded-lg bg-fd-muted outline-none"
>
{securities.map((security, i) => (
<option key={i} value={i}>
{security.map((item) => item.id).join(' & ')}
</option>
))}
</select>
<div className="flex flex-col gap-4 flex-1">{children}</div>
</div>
</CollapsiblePanel>
);
for (let i = 0; i < securities.length; i++) {
const security = securities[i];
for (const item of security) {
if (item.type === 'oauth2') {
return (
<OauthDialog
scheme={item}
scopes={item.scopes}
open={open}
setOpen={(v) => {
setOpen(v);
if (v) {
setSecurityId(i);
}
}}
setToken={(token) => form.setValue('header.Authorization', token)}
>
{result}
</OauthDialog>
);
}
}
}
return result;
}
const paramNames = ['Headers', 'Cookies', 'Query', 'Path'] as const;
const paramTypes = ['header', 'cookie', 'query', 'path'] as const;
@@ -423,7 +483,7 @@ function BodyInput({ field: _field }: { field: RequestSchema }) {
);
}
type AuthField = {
interface AuthField {
fieldName: string;
defaultValue: unknown;
@@ -431,20 +491,21 @@ type AuthField = {
children: ReactNode;
mapOutput?: (values: unknown) => unknown;
};
}
/**
* manipulate values without mutating the original object
*
* @returns a new manipulated object
*/
function manipulateValues(
values: Record<string, unknown>,
function manipulateValues<T extends object>(
values: T,
fieldName: string,
update: (v: unknown) => unknown,
): Record<string, unknown> {
const root = { ...values };
let current = root;
clone = false,
): T {
const root = clone ? { ...values } : values;
let current = root as Record<string, unknown>;
const segments = fieldName.split('.');
for (let i = 0; i < segments.length; i++) {
@@ -452,7 +513,7 @@ function manipulateValues(
if (i !== segments.length - 1) {
let v = current[segment] as Record<string, unknown>;
v = { ...v };
if (clone) v = { ...v };
current[segment] = v;
current = v;
@@ -530,7 +591,20 @@ function useAuthInputs(securities?: SecurityEntry[]) {
security.description ?? `The Authorization access token.`,
}}
/>
<OAuth authorization={security} />
{security.type === 'oauth2' && (
<OauthDialogTrigger
type="button"
className={cn(
buttonVariants({
size: 'sm',
color: 'primary',
className: 'w-fit',
}),
)}
>
Auth Settings
</OauthDialogTrigger>
)}
</>
),
});
@@ -565,11 +639,7 @@ function useAuthInputs(securities?: SecurityEntry[]) {
for (const item of inputs) {
if (!item.mapOutput) continue;
values = manipulateValues(
values as unknown as Record<string, unknown>,
item.fieldName,
item.mapOutput,
) as unknown as FormValues;
values = manipulateValues(values, item.fieldName, item.mapOutput, true);
}
return values;
@@ -594,44 +664,6 @@ function renderCustomField(
);
}
const OauthDialog = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
default: mod.OauthDialog,
})),
);
const OauthDialogTrigger = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
default: mod.OauthDialogTrigger,
})),
);
function OAuth({ authorization }: { authorization: SecurityEntry }) {
const form = useFormContext();
if (authorization.type !== 'oauth2') return;
// only the first one, so it looks simpler :)
const flow = Object.keys(authorization.flows)[0];
return (
<OauthDialog
flow={flow as keyof typeof authorization.flows}
scheme={authorization}
setToken={(token) => form.setValue('authorization', `Bearer ${token}`)}
>
<OauthDialogTrigger
type="button"
className={cn(
buttonVariants({
color: 'secondary',
}),
)}
>
Auth Settings
</OauthDialogTrigger>
</OauthDialog>
);
}
function Route({
route,
...props
@@ -665,7 +697,7 @@ function DefaultResultDisplay({ data }: { data: FetchResult }) {
const { shikiOptions } = useApiContext();
return (
<div className="flex flex-col gap-3 border-t p-3">
<div className="flex flex-col gap-3 p-3">
<div className="inline-flex items-center gap-1.5 text-sm font-medium text-fd-foreground">
<statusInfo.icon className={cn('size-4', statusInfo.color)} />
{statusInfo.description}
+1 -1
View File
@@ -148,7 +148,7 @@ function parseSecurities(
});
}
result.push(list);
if (list.length > 0) result.push(list);
}
return result;
+2 -2
View File
@@ -127,13 +127,13 @@ export function ObjectCollapsible(props: {
<CollapsibleTrigger
className={cn(
buttonVariants({ color: 'secondary', size: 'sm' }),
'group px-3 py-2 data-[state=open]:rounded-b-none data-[state=open]:border-b-0',
'group px-3 py-2 data-[state=open]:rounded-b-none',
)}
>
{props.name}
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-me-3">
<CollapsibleContent className="-me-3 -mt-px">
<div className="border-s border-y rounded-b-lg p-3">
{props.children}
</div>